/** * This file is generated by o-spreadsheet build tools. Do not edit it. * @see https://github.com/odoo/o-spreadsheet * @version 18.0.8 * @date 2024-12-19T07:55:19.099Z * @hash 7cf34a618 */ import { useEnv, useSubEnv, onWillUnmount, useComponent, status, Component, useRef, onMounted, useEffect, useState, onPatched, onWillPatch, onWillUpdateProps, useExternalListener, onWillStart, xml, useChildSubEnv, markRaw, toRaw } from '@odoo/owl'; function createActions(menuItems) { return menuItems.map(createAction).sort((a, b) => a.sequence - b.sequence); } let nextItemId = 1; function createAction(item) { const name = item.name; const children = item.children; const description = item.description; const icon = item.icon; const secondaryIcon = item.secondaryIcon; const itemId = item.id || nextItemId++; return { id: itemId.toString(), name: typeof name === "function" ? name : () => name, isVisible: item.isVisible ? item.isVisible : () => true, isEnabled: item.isEnabled ? item.isEnabled : () => true, isActive: item.isActive, execute: item.execute, children: children ? (env) => { return children .map((child) => (typeof child === "function" ? child(env) : child)) .flat() .map(createAction); } : () => [], isReadonlyAllowed: item.isReadonlyAllowed || false, separator: item.separator || false, icon: typeof icon === "function" ? icon : () => icon || "", iconColor: item.iconColor, secondaryIcon: typeof secondaryIcon === "function" ? secondaryIcon : () => secondaryIcon || "", description: typeof description === "function" ? description : () => description || "", textColor: item.textColor, sequence: item.sequence || 0, onStartHover: item.onStartHover, onStopHover: item.onStopHover, }; } /** * Registry * * The Registry class is basically just a mapping from a string key to an object. * It is really not much more than an object. It is however useful for the * following reasons: * * 1. it let us react and execute code when someone add something to the registry * (for example, the FunctionRegistry subclass this for this purpose) * 2. it throws an error when the get operation fails * 3. it provides a chained API to add items to the registry. */ class Registry { content = {}; /** * Add an item to the registry * * Note that this also returns the registry, so another add method call can * be chained */ add(key, value) { this.content[key] = value; return this; } /** * Get an item from the registry */ get(key) { /** * Note: key in {} is ~12 times slower than {}[key]. * So, we check the absence of key only when the direct access returns * a falsy value. It's done to ensure that the registry can contains falsy values */ const content = this.content[key]; if (!content) { if (!(key in this.content)) { throw new Error(`Cannot find ${key} in this registry!`); } } return content; } /** * Check if the key is already in the registry */ contains(key) { return key in this.content; } /** * Get a list of all elements in the registry */ getAll() { return Object.values(this.content); } /** * Get a list of all keys in the registry */ getKeys() { return Object.keys(this.content); } /** * Remove an item from the registry */ remove(key) { delete this.content[key]; } } const CANVAS_SHIFT = 0.5; // Colors const HIGHLIGHT_COLOR = "#37A850"; const BACKGROUND_GRAY_COLOR = "#f5f5f5"; const BACKGROUND_HEADER_COLOR = "#F8F9FA"; const BACKGROUND_HEADER_SELECTED_COLOR = "#E8EAED"; const BACKGROUND_HEADER_ACTIVE_COLOR = "#595959"; const TEXT_HEADER_COLOR = "#666666"; const FIGURE_BORDER_COLOR = "#c9ccd2"; const SELECTION_BORDER_COLOR = "#3266ca"; const HEADER_BORDER_COLOR = "#C0C0C0"; const CELL_BORDER_COLOR = "#E2E3E3"; const BACKGROUND_CHART_COLOR = "#FFFFFF"; const DISABLED_TEXT_COLOR = "#CACACA"; const DEFAULT_COLOR_SCALE_MIDPOINT_COLOR = 0xb6d7a8; const LINK_COLOR = "#017E84"; const FILTERS_COLOR = "#188038"; const SEPARATOR_COLOR = "#E0E2E4"; const ICONS_COLOR = "#4A4F59"; const HEADER_GROUPING_BACKGROUND_COLOR = "#F5F5F5"; const HEADER_GROUPING_BORDER_COLOR = "#999"; const GRID_BORDER_COLOR = "#E2E3E3"; const FROZEN_PANE_HEADER_BORDER_COLOR = "#BCBCBC"; const FROZEN_PANE_BORDER_COLOR = "#DADFE8"; const COMPOSER_ASSISTANT_COLOR = "#9B359B"; const CHART_WATERFALL_POSITIVE_COLOR = "#4EA7F2"; const CHART_WATERFALL_NEGATIVE_COLOR = "#EA6175"; const CHART_WATERFALL_SUBTOTAL_COLOR = "#AAAAAA"; const GRAY_900 = "#111827"; const GRAY_300 = "#D8DADD"; const GRAY_200 = "#E7E9ED"; const GRAY_100 = "#F9FAFB"; const TEXT_BODY = "#374151"; const TEXT_BODY_MUTED = TEXT_BODY + "C2"; const TEXT_HEADING = "#111827"; const PRIMARY_BUTTON_BG = "#714B67"; const PRIMARY_BUTTON_HOVER_BG = "#624159"; const PRIMARY_BUTTON_ACTIVE_BG = "#f1edf0"; const BUTTON_BG = GRAY_200; const BUTTON_HOVER_BG = GRAY_300; const BUTTON_HOVER_TEXT_COLOR = "#111827"; const BUTTON_ACTIVE_BG = "#e6f2f3"; const BUTTON_ACTIVE_TEXT_COLOR = "#111827"; const ACTION_COLOR = "#017E84"; const ACTION_COLOR_HOVER = "#01585c"; const ALERT_WARNING_BG = "#FBEBCC"; const ALERT_WARNING_BORDER = "#F8E2B3"; const ALERT_WARNING_TEXT_COLOR = "#946D23"; const ALERT_DANGER_BG = "#D44C591A"; const ALERT_DANGER_BORDER = "#C34A41"; const ALERT_DANGER_TEXT_COLOR = "#C34A41"; const ALERT_INFO_BG = "#CDEDF1"; const ALERT_INFO_BORDER = "#98DBE2"; const ALERT_INFO_TEXT_COLOR = "#09414A"; const BADGE_SELECTED_COLOR = "#E6F2F3"; const DEFAULT_CHART_PADDING = 20; const DEFAULT_CHART_FONT_SIZE = 22; const SCORECARD_GAUGE_CHART_PADDING = 10; const SCORECARD_GAUGE_CHART_FONT_SIZE = 14; // Color picker defaults as upper case HEX to match `toHex`helper const COLOR_PICKER_DEFAULTS = [ "#000000", "#434343", "#666666", "#999999", "#B7B7B7", "#CCCCCC", "#D9D9D9", "#EFEFEF", "#F3F3F3", "#FFFFFF", "#980000", "#FF0000", "#FF9900", "#FFFF00", "#00FF00", "#00FFFF", "#4A86E8", "#0000FF", "#9900FF", "#FF00FF", "#E6B8AF", "#F4CCCC", "#FCE5CD", "#FFF2CC", "#D9EAD3", "#D0E0E3", "#C9DAF8", "#CFE2F3", "#D9D2E9", "#EAD1DC", "#DD7E6B", "#EA9999", "#F9CB9C", "#FFE599", "#B6D7A8", "#A2C4C9", "#A4C2F4", "#9FC5E8", "#B4A7D6", "#D5A6BD", "#CC4125", "#E06666", "#F6B26B", "#FFD966", "#93C47D", "#76A5AF", "#6D9EEB", "#6FA8DC", "#8E7CC3", "#C27BA0", "#A61C00", "#CC0000", "#E69138", "#F1C232", "#6AA84F", "#45818E", "#3C78D8", "#3D85C6", "#674EA7", "#A64D79", "#85200C", "#990000", "#B45F06", "#BF9000", "#38761D", "#134F5C", "#1155CC", "#0B5394", "#351C75", "#741B47", "#5B0F00", "#660000", "#783F04", "#7F6000", "#274E13", "#0C343D", "#1C4587", "#073763", "#20124D", "#4C1130", ]; // Dimensions const MIN_ROW_HEIGHT = 10; const MIN_COL_WIDTH = 5; const HEADER_HEIGHT = 26; const HEADER_WIDTH = 48; const TOPBAR_HEIGHT = 63; const TOPBAR_TOOLBAR_HEIGHT = 34; const BOTTOMBAR_HEIGHT = 36; const DEFAULT_CELL_WIDTH = 96; const DEFAULT_CELL_HEIGHT = 23; const SCROLLBAR_WIDTH = 15; const AUTOFILL_EDGE_LENGTH = 8; const ICON_EDGE_LENGTH = 18; const MIN_CF_ICON_MARGIN = 4; const MIN_CELL_TEXT_MARGIN = 4; const CF_ICON_EDGE_LENGTH = 15; const PADDING_AUTORESIZE_VERTICAL = 3; const PADDING_AUTORESIZE_HORIZONTAL = MIN_CELL_TEXT_MARGIN; const GROUP_LAYER_WIDTH = 21; const GRID_ICON_MARGIN = 2; const GRID_ICON_EDGE_LENGTH = 17; const FOOTER_HEIGHT = 2 * DEFAULT_CELL_HEIGHT; // Menus const MENU_WIDTH = 250; const MENU_VERTICAL_PADDING = 6; const MENU_ITEM_HEIGHT = 26; const MENU_ITEM_PADDING_HORIZONTAL = 11; const MENU_ITEM_PADDING_VERTICAL = 4; const MENU_SEPARATOR_BORDER_WIDTH = 1; const MENU_SEPARATOR_PADDING = 5; // Style const DEFAULT_STYLE = { align: "left", verticalAlign: "bottom", wrapping: "overflow", bold: false, italic: false, strikethrough: false, underline: false, fontSize: 10, fillColor: "", textColor: "", }; const DEFAULT_VERTICAL_ALIGN = DEFAULT_STYLE.verticalAlign; const DEFAULT_WRAPPING_MODE = DEFAULT_STYLE.wrapping; // Fonts const DEFAULT_FONT_WEIGHT = "400"; const DEFAULT_FONT_SIZE = DEFAULT_STYLE.fontSize; const HEADER_FONT_SIZE = 11; const DEFAULT_FONT = "'Roboto', arial"; // Borders const DEFAULT_BORDER_DESC = { style: "thin", color: "#000000" }; // Max Number of history steps kept in memory const MAX_HISTORY_STEPS = 99; // Id of the first revision const DEFAULT_REVISION_ID = "START_REVISION"; // Figure const DEFAULT_FIGURE_HEIGHT = 335; const DEFAULT_FIGURE_WIDTH = 536; const FIGURE_BORDER_WIDTH = 1; const MIN_FIG_SIZE = 80; // Chart const MAX_CHAR_LABEL = 20; const FIGURE_ID_SPLITTER = "??"; const DEFAULT_GAUGE_LOWER_COLOR = "#EA6175"; const DEFAULT_GAUGE_MIDDLE_COLOR = "#FFD86D"; const DEFAULT_GAUGE_UPPER_COLOR = "#43C5B1"; const DEFAULT_SCORECARD_BASELINE_MODE = "difference"; const DEFAULT_SCORECARD_BASELINE_COLOR_UP = "#43C5B1"; const DEFAULT_SCORECARD_BASELINE_COLOR_DOWN = "#EA6175"; const LINE_FILL_TRANSPARENCY = 0.4; // session const DEBOUNCE_TIME = 200; const MESSAGE_VERSION = 1; // Sheets const FORBIDDEN_SHEETNAME_CHARS = ["'", "*", "?", "/", "\\", "[", "]"]; const FORBIDDEN_SHEETNAME_CHARS_IN_EXCEL_REGEX = /'|\*|\?|\/|\\|\[|\]/; // Cells const FORMULA_REF_IDENTIFIER = "|"; // Components var ComponentsImportance; (function (ComponentsImportance) { ComponentsImportance[ComponentsImportance["Grid"] = 0] = "Grid"; ComponentsImportance[ComponentsImportance["Highlight"] = 5] = "Highlight"; ComponentsImportance[ComponentsImportance["HeaderGroupingButton"] = 6] = "HeaderGroupingButton"; ComponentsImportance[ComponentsImportance["Figure"] = 10] = "Figure"; ComponentsImportance[ComponentsImportance["ScrollBar"] = 15] = "ScrollBar"; ComponentsImportance[ComponentsImportance["GridPopover"] = 19] = "GridPopover"; ComponentsImportance[ComponentsImportance["GridComposer"] = 20] = "GridComposer"; ComponentsImportance[ComponentsImportance["Dropdown"] = 21] = "Dropdown"; ComponentsImportance[ComponentsImportance["IconPicker"] = 25] = "IconPicker"; ComponentsImportance[ComponentsImportance["TopBarComposer"] = 30] = "TopBarComposer"; ComponentsImportance[ComponentsImportance["Popover"] = 35] = "Popover"; ComponentsImportance[ComponentsImportance["FigureAnchor"] = 1000] = "FigureAnchor"; ComponentsImportance[ComponentsImportance["FigureSnapLine"] = 1001] = "FigureSnapLine"; })(ComponentsImportance || (ComponentsImportance = {})); let DEFAULT_SHEETVIEW_SIZE = 0; function getDefaultSheetViewSize() { return DEFAULT_SHEETVIEW_SIZE; } function setDefaultSheetViewSize(size) { DEFAULT_SHEETVIEW_SIZE = size; } const MAXIMAL_FREEZABLE_RATIO = 0.85; const NEWLINE = "\n"; const FONT_SIZES = [6, 7, 8, 9, 10, 11, 12, 14, 18, 24, 36]; // Pivot const PIVOT_TABLE_CONFIG = { hasFilters: false, totalRow: false, firstColumn: true, lastColumn: false, numberOfHeaders: 1, bandedRows: true, bandedColumns: false, styleId: "TableStyleMedium5", automaticAutofill: false, }; const DEFAULT_CURRENCY = { symbol: "$", position: "before", decimalPlaces: 2, code: "", name: "Dollar", }; //------------------------------------------------------------------------------ // Miscellaneous //------------------------------------------------------------------------------ const sanitizeSheetNameRegex = new RegExp(FORBIDDEN_SHEETNAME_CHARS_IN_EXCEL_REGEX, "g"); /** * Remove quotes from a quoted string * ```js * removeStringQuotes('"Hello"') * > 'Hello' * ``` */ function removeStringQuotes(str) { if (str[0] === '"') { str = str.slice(1); } if (str[str.length - 1] === '"' && str[str.length - 2] !== "\\") { return str.slice(0, str.length - 1); } return str; } function isCloneable(obj) { return "clone" in obj && obj.clone instanceof Function; } /** * Escapes a string to use as a literal string in a RegExp. * @url https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping */ function escapeRegExp(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** * Deep copy arrays, plain objects and primitive values. * Throws an error for other types such as class instances. * Sparse arrays remain sparse. */ function deepCopy(obj) { const result = Array.isArray(obj) ? [] : {}; switch (typeof obj) { case "object": { if (obj === null) { return obj; } else if (isCloneable(obj)) { return obj.clone(); } else if (!(isPlainObject(obj) || obj instanceof Array)) { throw new Error("Unsupported type: only objects and arrays are supported"); } for (const key in obj) { result[key] = deepCopy(obj[key]); } return result; } case "number": case "string": case "boolean": case "function": case "undefined": return obj; default: throw new Error(`Unsupported type: ${typeof obj}`); } } /** * Check if the object is a plain old javascript object. */ function isPlainObject(obj) { return (typeof obj === "object" && obj !== null && // obj.constructor can be undefined when there's no prototype (`Object.create(null, {})`) (obj?.constructor === Object || obj?.constructor === undefined)); } /** * Sanitize the name of a sheet, by eventually removing quotes * @param sheetName name of the sheet, potentially quoted with single quotes */ function getUnquotedSheetName(sheetName) { return unquote(sheetName, "'"); } function unquote(string, quoteChar = '"') { if (string.startsWith(quoteChar)) { string = string.slice(1); } if (string.endsWith(quoteChar)) { string = string.slice(0, -1); } return string; } /** * Add quotes around the sheet name or any symbol name if it contains at least one non alphanumeric character * '\w' captures [0-9][a-z][A-Z] and _. * @param symbolName Name of the sheet or symbol */ function getCanonicalSymbolName(symbolName) { if (symbolName.match(/\w/g)?.length !== symbolName.length) { symbolName = `'${symbolName}'`; } return symbolName; } /** Replace the excel-excluded characters of a sheetName */ function sanitizeSheetName(sheetName, replacementChar = " ") { return sheetName.replace(sanitizeSheetNameRegex, replacementChar); } function clip(val, min, max) { return val < min ? min : val > max ? max : val; } /** * Create a range from start (included) to end (excluded). * range(10, 13) => [10, 11, 12] * range(2, 8, 2) => [2, 4, 6] */ function range(start, end, step = 1) { if (end <= start && step > 0) { return []; } if (step === 0) { throw new Error("range() step must not be zero"); } const length = Math.ceil(Math.abs((end - start) / step)); const array = Array(length); for (let i = 0; i < length; i++) { array[i] = start + i * step; } return array; } /** * Groups consecutive numbers. * The input array is assumed to be sorted * @param numbers */ function groupConsecutive(numbers) { return numbers.reduce((groups, currentRow, index, rows) => { if (Math.abs(currentRow - rows[index - 1]) === 1) { const lastGroup = groups[groups.length - 1]; lastGroup.push(currentRow); } else { groups.push([currentRow]); } return groups; }, []); } /** * Create one generator from two generators by linking * each item of the first generator to the next item of * the second generator. * * Let's say generator G1 yields A, B, C and generator G2 yields X, Y, Z. * The resulting generator of `linkNext(G1, G2)` will yield A', B', C' * where `A' = A & {next: Y}`, `B' = B & {next: Z}` and `C' = C & {next: undefined}` * @param generator * @param nextGenerator */ function* linkNext(generator, nextGenerator) { nextGenerator.next(); for (const item of generator) { const nextItem = nextGenerator.next(); yield { ...item, next: nextItem.done ? undefined : nextItem.value, }; } } function isBoolean(str) { const upperCased = str.toUpperCase(); return upperCased === "TRUE" || upperCased === "FALSE"; } const MARKDOWN_LINK_REGEX = /^\[(.+)\]\((.+)\)$/; //link must start with http or https //https://stackoverflow.com/a/3809435/4760614 const WEB_LINK_REGEX = /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)$/; function isMarkdownLink(str) { return MARKDOWN_LINK_REGEX.test(str); } /** * Check if the string is a web link. * e.g. http://odoo.com */ function isWebLink(str) { return WEB_LINK_REGEX.test(str); } /** * Build a markdown link from a label and an url */ function markdownLink(label, url) { return `[${label}](${url})`; } function parseMarkdownLink(str) { const matches = str.match(MARKDOWN_LINK_REGEX) || []; const label = matches[1]; const url = matches[2]; if (!label || !url) { throw new Error(`Could not parse markdown link ${str}.`); } return { label, url, }; } const O_SPREADSHEET_LINK_PREFIX = "o-spreadsheet://"; function isSheetUrl(url) { return url.startsWith(O_SPREADSHEET_LINK_PREFIX); } function buildSheetLink(sheetId) { return `${O_SPREADSHEET_LINK_PREFIX}${sheetId}`; } /** * Parse a sheet link and return the sheet id */ function parseSheetUrl(sheetLink) { if (sheetLink.startsWith(O_SPREADSHEET_LINK_PREFIX)) { return sheetLink.substr(O_SPREADSHEET_LINK_PREFIX.length); } throw new Error(`${sheetLink} is not a valid sheet link`); } /** * This helper function can be used as a type guard when filtering arrays. * const foo: number[] = [1, 2, undefined, 4].filter(isDefined) */ function isDefined(argument) { return argument !== undefined; } function isNotNull(argument) { return argument !== null; } /** * Check if all the values of an object, and all the values of the objects inside of it, are undefined. */ function isObjectEmptyRecursive(argument) { if (argument === undefined) return true; return Object.values(argument).every((value) => typeof value === "object" ? isObjectEmptyRecursive(value) : !value); } /** * Returns a function, that, as long as it continues to be invoked, will not * be triggered. The function will be called after it stops being called for * N milliseconds. If `immediate` is passed, trigger the function on the * leading edge, instead of the trailing. * * Also decorate the argument function with two methods: stopDebounce and isDebouncePending. * * Inspired by https://davidwalsh.name/javascript-debounce-function */ function debounce(func, wait, immediate) { let timeout = undefined; const debounced = function () { const context = this; const args = Array.from(arguments); function later() { timeout = undefined; if (!immediate) { func.apply(context, args); } } const callNow = immediate && !timeout; clearTimeout(timeout); timeout = setTimeout(later, wait); if (callNow) { func.apply(context, args); } }; debounced.isDebouncePending = () => timeout !== undefined; debounced.stopDebounce = () => { clearTimeout(timeout); }; return debounced; } /** * Creates a batched version of a callback so that all calls to it in the same * microtick will only call the original callback once. * * @param callback the callback to batch * @returns a batched version of the original callback * * Copied from odoo/owl repo. */ function batched(callback) { let scheduled = false; return async (...args) => { if (!scheduled) { scheduled = true; await Promise.resolve(); scheduled = false; callback(...args); } }; } /* * Concatenate an array of strings. */ function concat(chars) { // ~40% faster than chars.join("") let output = ""; for (let i = 0, len = chars.length; i < len; i++) { output += chars[i]; } return output; } /** * Lazy value computed by the provided function. */ function lazy(fn) { let isMemoized = false; let memo; const lazyValue = () => { if (!isMemoized) { memo = fn instanceof Function ? fn() : fn; isMemoized = true; } return memo; }; lazyValue.map = (callback) => lazy(() => callback(lazyValue())); return lazyValue; } /** * Find the next defined value after the given index in an array of strings. If there is no defined value * after the index, return the closest defined value before the index. Return an empty string if no * defined value was found. * */ function findNextDefinedValue(arr, index) { let value = arr.slice(index).find((val) => val); if (!value) { value = arr .slice(0, index) .reverse() .find((val) => val); } return value || ""; } /** Get index of first header added by an ADD_COLUMNS_ROWS command */ function getAddHeaderStartIndex(position, base) { return position === "after" ? base + 1 : base; } /** * Compares two objects. */ function deepEquals(o1, o2) { if (o1 === o2) return true; if ((o1 && !o2) || (o2 && !o1)) return false; if (typeof o1 !== typeof o2) return false; if (typeof o1 !== "object") return false; // Objects can have different keys if the values are undefined for (const key in o2) { if (!(key in o1) && o2[key] !== undefined) { return false; } } for (const key in o1) { if (typeof o1[key] !== typeof o2[key]) return false; if (typeof o1[key] === "object") { if (!deepEquals(o1[key], o2[key])) return false; } else { if (o1[key] !== o2[key]) return false; } } return true; } /** * Compares two arrays. * For performance reasons, this function is to be preferred * to 'deepEquals' in the case we know that the inputs are arrays. */ function deepEqualsArray(arr1, arr2) { if (arr1.length !== arr2.length) { return false; } for (let i = 0; i < arr1.length; i++) { if (!deepEquals(arr1[i], arr2[i])) { return false; } } return true; } /** Check if the given array contains all the values of the other array. */ function includesAll(arr, values) { return values.every((value) => arr.includes(value)); } /** * Return an object with all the keys in the object that have a falsy value removed. */ function removeFalsyAttributes(obj) { if (!obj) return obj; const cleanObject = { ...obj }; Object.keys(cleanObject).forEach((key) => !cleanObject[key] && delete cleanObject[key]); return cleanObject; } /** * Equivalent to "\s" in regexp, minus the new lines characters * * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Character_Classes */ const whiteSpaceSpecialCharacters = [ " ", "\t", "\f", "\v", String.fromCharCode(parseInt("00a0", 16)), String.fromCharCode(parseInt("1680", 16)), String.fromCharCode(parseInt("2000", 16)), String.fromCharCode(parseInt("200a", 16)), String.fromCharCode(parseInt("2028", 16)), String.fromCharCode(parseInt("2029", 16)), String.fromCharCode(parseInt("202f", 16)), String.fromCharCode(parseInt("205f", 16)), String.fromCharCode(parseInt("3000", 16)), String.fromCharCode(parseInt("feff", 16)), ]; const whiteSpaceRegexp = new RegExp(whiteSpaceSpecialCharacters.join("|"), "g"); const newLineRegexp = /(\r\n|\r)/g; /** * Replace all different newlines characters by \n */ function replaceNewLines(text) { if (!text) return ""; return text.replace(newLineRegexp, NEWLINE); } /** * Determine if the numbers are consecutive. */ function isConsecutive(iterable) { const array = Array.from(iterable).sort((a, b) => a - b); // sort numerically rather than lexicographically for (let i = 1; i < array.length; i++) { if (array[i] - array[i - 1] !== 1) { return false; } } return true; } /** * Creates a version of the function that's memoized on the value of its first * argument, if any. */ function memoize(func) { const cache = new Map(); const funcName = func.name ? func.name + " (memoized)" : "memoized"; return { [funcName](...args) { if (!cache.has(args[0])) { cache.set(args[0], func(...args)); } return cache.get(args[0]); }, }[funcName]; } function removeIndexesFromArray(array, indexes) { return array.filter((_, index) => !indexes.includes(index)); } function insertItemsAtIndex(array, items, index) { const newArray = [...array]; newArray.splice(index, 0, ...items); return newArray; } function replaceItemAtIndex(array, newItem, index) { const newArray = [...array]; newArray.splice(index, 1, newItem); return newArray; } function trimContent(content) { const contentLines = content.split("\n"); return contentLines.map((line) => line.replace(/\s+/g, " ").trim()).join("\n"); } function isNumberBetween(value, min, max) { if (min > max) { return isNumberBetween(value, max, min); } return value >= min && value <= max; } /** * Get a Regex for the find & replace that matches the given search string and options. */ function getSearchRegex(searchStr, searchOptions) { let searchValue = escapeRegExp(searchStr); const flags = !searchOptions.matchCase ? "i" : ""; if (searchOptions.exactMatch) { searchValue = `^${searchValue}$`; } return RegExp(searchValue, flags); } /** * Alternative to Math.max that works with large arrays. * Typically useful for arrays bigger than 100k elements. */ function largeMax(array) { let len = array.length; if (len < 100_000) return Math.max(...array); let max = -Infinity; while (len--) { max = array[len] > max ? array[len] : max; } return max; } /** * Alternative to Math.min that works with large arrays. * Typically useful for arrays bigger than 100k elements. */ function largeMin(array) { let len = array.length; if (len < 100_000) return Math.min(...array); let min = +Infinity; while (len--) { min = array[len] < min ? array[len] : min; } return min; } class TokenizingChars { text; currentIndex = 0; current; constructor(text) { this.text = text; this.current = text[0]; } shift() { const current = this.current; const next = this.text[++this.currentIndex]; this.current = next; return current; } advanceBy(length) { this.currentIndex += length; this.current = this.text[this.currentIndex]; } isOver() { return this.currentIndex >= this.text.length; } remaining() { return this.text.substring(this.currentIndex); } currentStartsWith(str) { if (this.current !== str[0]) { return false; } for (let j = 1; j < str.length; j++) { if (this.text[this.currentIndex + j] !== str[j]) { return false; } } return true; } } /** * Remove duplicates from an array. * * @param array The array to remove duplicates from. * @param cb A callback to get an element value. */ function removeDuplicates$1(array, cb = (a) => a) { const set = new Set(); return array.filter((item) => { const key = cb(item); if (set.has(key)) { return false; } set.add(key); return true; }); } /** * Similar to transposing and array, but with POJOs instead of arrays. Useful, for example, when manipulating * a POJO grid[col][row] and you want to transpose it to grid[row][col]. * * The resulting object is created such as result[key1][key2] = pojo[key2][key1] */ function transpose2dPOJO(pojo) { const result = {}; for (const key in pojo) { for (const subKey in pojo[key]) { if (!result[subKey]) { result[subKey] = {}; } result[subKey][key] = pojo[key][subKey]; } } return result; } const RBA_REGEX = /rgba?\(|\s+|\)/gi; const HEX_MATCH = /^#([A-F\d]{2}){3,4}$/; const colors$1 = [ "#eb6d00", "#0074d9", "#ad8e00", "#169ed4", "#b10dc9", "#00a82d", "#00a3a3", "#f012be", "#3d9970", "#111111", "#62A300", "#ff4136", "#949494", "#85144b", "#001f3f", ]; /* * transform a color number (R * 256^2 + G * 256 + B) into classic hex6 value * */ function colorNumberString(color) { return toHex(color.toString(16).padStart(6, "0")); } /** * Converts any CSS color value to a standardized hex6 value. * Accepts: hex3, hex6, hex8, rgb[1] and rgba[1]. * * [1] under the form rgb(r, g, b, a?) or rgba(r, g, b, a?) * with r,g,b ∈ [0, 255] and a ∈ [0, 1] * * toHex("#ABC") * >> "#AABBCC" * * toHex("#AAAFFF") * >> "#AAAFFF" * * toHex("rgb(30, 80, 16)") * >> "#1E5010" * * * toHex("rgb(30, 80, 16, 0.5)") * >> "#1E501080" * */ function toHex(color) { let hexColor = color; if (color.startsWith("rgb")) { hexColor = rgbaStringToHex(color); } else { hexColor = color.replace("#", "").toUpperCase(); if (hexColor.length === 3 || hexColor.length === 4) { hexColor = hexColor.split("").reduce((acc, h) => acc + h + h, ""); } hexColor = `#${hexColor}`; } if (!HEX_MATCH.test(hexColor)) { throw new Error(`invalid color input: ${color}`); } return hexColor; } function isColorValid(color) { try { toHex(color); return true; } catch (error) { return false; } } function isHSLAValid(color) { try { hslaToHex(color); return true; } catch (error) { return false; } } const isColorValueValid = (v) => v >= 0 && v <= 255; function rgba(r, g, b, a = 1) { const isInvalid = !isColorValueValid(r) || !isColorValueValid(g) || !isColorValueValid(b) || a < 0 || a > 1; if (isInvalid) { throw new Error(`Invalid RGBA values ${[r, g, b, a]}`); } return { a, b, g, r }; } /** * The relative brightness of a point in the colorspace, normalized to 0 for * darkest black and 1 for lightest white. * https://www.w3.org/TR/WCAG20/#relativeluminancedef */ function relativeLuminance(color) { let { r, g, b } = colorToRGBA(color); r /= 255; g /= 255; b /= 255; const toLinearValue = (c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); const R = toLinearValue(r); const G = toLinearValue(g); const B = toLinearValue(b); return 0.2126 * R + 0.7152 * G + 0.0722 * B; } /** * Convert a CSS rgb color string to a standardized hex6 color value. * * rgbaStringToHex("rgb(30, 80, 16)") * >> "#1E5010" * * rgbaStringToHex("rgba(30, 80, 16, 0.5)") * >> "#1E501080" * * DOES NOT SUPPORT NON INTEGER RGB VALUES */ function rgbaStringToHex(color) { const stringVals = color.replace(RBA_REGEX, "").split(","); let alphaHex = 255; if (stringVals.length !== 3 && stringVals.length !== 4) { throw new Error("invalid color"); } else if (stringVals.length === 4) { const alpha = parseFloat(stringVals.pop() || "1"); alphaHex = Math.round((alpha || 1) * 255); } const vals = stringVals.map((val) => parseInt(val, 10)); if (alphaHex !== 255) { vals.push(alphaHex); } return "#" + concat(vals.map((value) => value.toString(16).padStart(2, "0"))).toUpperCase(); } /** * RGBA to HEX representation (#RRGGBBAA). * * https://css-tricks.com/converting-color-spaces-in-javascript/ */ function rgbaToHex(rgba) { let r = rgba.r.toString(16); let g = rgba.g.toString(16); let b = rgba.b.toString(16); let a = Math.round(rgba.a * 255).toString(16); if (r.length === 1) r = "0" + r; if (g.length === 1) g = "0" + g; if (b.length === 1) b = "0" + b; if (a.length === 1) a = "0" + a; if (a === "ff") a = ""; return ("#" + r + g + b + a).toUpperCase(); } /** * Color string to RGBA representation */ function colorToRGBA(color) { color = toHex(color); let r; let g; let b; let a; if (color.length === 7) { r = parseInt(color[1] + color[2], 16); g = parseInt(color[3] + color[4], 16); b = parseInt(color[5] + color[6], 16); a = 255; } else if (color.length === 9) { r = parseInt(color[1] + color[2], 16); g = parseInt(color[3] + color[4], 16); b = parseInt(color[5] + color[6], 16); a = parseInt(color[7] + color[8], 16); } else { throw new Error("Invalid color"); } a = +(a / 255).toFixed(3); return { a, r, g, b }; } /** * HSLA to RGBA. * * https://css-tricks.com/converting-color-spaces-in-javascript/ */ function hslaToRGBA(hsla) { hsla = { ...hsla }; // Must be fractions of 1 hsla.s /= 100; hsla.l /= 100; let c = (1 - Math.abs(2 * hsla.l - 1)) * hsla.s; let x = c * (1 - Math.abs(((hsla.h / 60) % 2) - 1)); let m = hsla.l - c / 2; let r = 0; let g = 0; let b = 0; if (0 <= hsla.h && hsla.h < 60) { r = c; g = x; b = 0; } else if (60 <= hsla.h && hsla.h < 120) { r = x; g = c; b = 0; } else if (120 <= hsla.h && hsla.h < 180) { r = 0; g = c; b = x; } else if (180 <= hsla.h && hsla.h < 240) { r = 0; g = x; b = c; } else if (240 <= hsla.h && hsla.h < 300) { r = x; g = 0; b = c; } else if (300 <= hsla.h && hsla.h < 360) { r = c; g = 0; b = x; } r = Math.round((r + m) * 255); g = Math.round((g + m) * 255); b = Math.round((b + m) * 255); return { a: hsla.a, r, g, b }; } /** * HSLA to RGBA. * * https://css-tricks.com/converting-color-spaces-in-javascript/ */ function rgbaToHSLA(rgba) { // Make r, g, and b fractions of 1 const r = rgba.r / 255; const g = rgba.g / 255; const b = rgba.b / 255; // Find greatest and smallest channel values let cMin = Math.min(r, g, b); let cMax = Math.max(r, g, b); let delta = cMax - cMin; let h = 0; let s = 0; let l = 0; // Calculate hue // No difference if (delta === 0) h = 0; // Red is max else if (cMax === r) h = ((g - b) / delta) % 6; // Green is max else if (cMax === g) h = (b - r) / delta + 2; // Blue is max else h = (r - g) / delta + 4; h = Math.round(h * 60); // Make negative hues positive behind 360° if (h < 0) h += 360; l = (cMax + cMin) / 2; // Calculate saturation s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1)); // Multiply l and s by 100 s = +(s * 100).toFixed(1); l = +(l * 100).toFixed(1); return { a: rgba.a, h, s, l }; } function hslaToHex(hsla) { return rgbaToHex(hslaToRGBA(hsla)); } function hexToHSLA(hex) { return rgbaToHSLA(colorToRGBA(hex)); } /** * Will compare two color strings * A tolerance can be provided to account for small differences that could * be introduced by non-bijective transformations between color spaces. * * E.g. HSV <-> RGB is not a bijection * * Note that the tolerance is applied on the euclidean distance between * the two **normalized** color values. */ function isSameColor(color1, color2, tolerance = 0) { if (!(isColorValid(color1) && isColorValid(color2))) { return false; } const rgb1 = colorToRGBA(color1); const rgb2 = colorToRGBA(color2); // alpha cannot differ as it is not impacted by transformations if (rgb1.a !== rgb2.a) { return false; } const diff = Math.sqrt(((rgb1.r - rgb2.r) / 255) ** 2 + ((rgb1.g - rgb2.g) / 255) ** 2 + ((rgb1.b - rgb2.b) / 255) ** 2); return diff <= tolerance; } function setColorAlpha(color, alpha) { return alpha === 1 ? toHex(color).slice(0, 7) : rgbaToHex({ ...colorToRGBA(color), a: alpha }); } function lightenColor(color, percentage) { const hsla = hexToHSLA(color); if (percentage === 1) { return "#fff"; } hsla.l = percentage * (100 - hsla.l) + hsla.l; return hslaToHex(hsla); } function darkenColor(color, percentage) { const hsla = hexToHSLA(color); if (percentage === 1) { return "#000"; } hsla.l = hsla.l - percentage * hsla.l; return hslaToHex(hsla); } const COLORS_SM = [ "#4EA7F2", // Blue "#EA6175", // Red "#43C5B1", // Teal "#F4A261", // Orange "#8481DD", // Purple "#FFD86D", // Yellow ]; const COLORS_MD = [ "#4EA7F2", // Blue #1 "#3188E6", // Blue #2 "#43C5B1", // Teal #1 "#00A78D", // Teal #2 "#EA6175", // Red #1 "#CE4257", // Red #2 "#F4A261", // Orange #1 "#F48935", // Orange #2 "#8481DD", // Purple #1 "#5752D1", // Purple #2 "#FFD86D", // Yellow #1 "#FFBC2C", // Yellow #2 ]; const COLORS_LG = [ "#4EA7F2", // Blue #1 "#3188E6", // Blue #2 "#056BD9", // Blue #3 "#A76DBC", // Violet #1 "#7F4295", // Violet #2 "#6D2387", // Violet #3 "#EA6175", // Red #1 "#CE4257", // Red #2 "#982738", // Red #3 "#43C5B1", // Teal #1 "#00A78D", // Teal #2 "#0E8270", // Teal #3 "#F4A261", // Orange #1 "#F48935", // Orange #2 "#BE5D10", // Orange #3 "#8481DD", // Purple #1 "#5752D1", // Purple #2 "#3A3580", // Purple #3 "#A4A8B6", // Gray #1 "#7E8290", // Gray #2 "#545B70", // Gray #3 "#FFD86D", // Yellow #1 "#FFBC2C", // Yellow #2 "#C08A16", // Yellow #3 ]; const COLORS_XL = [ "#4EA7F2", // Blue #1 "#3188E6", // Blue #2 "#056BD9", // Blue #3 "#155193", // Blue #4 "#A76DBC", // Violet #1 "#7F4295", // Violet #1 "#6D2387", // Violet #1 "#4F1565", // Violet #1 "#EA6175", // Red #1 "#CE4257", // Red #2 "#982738", // Red #3 "#791B29", // Red #4 "#43C5B1", // Teal #1 "#00A78D", // Teal #2 "#0E8270", // Teal #3 "#105F53", // Teal #4 "#F4A261", // Orange #1 "#F48935", // Orange #2 "#BE5D10", // Orange #3 "#7D380D", // Orange #4 "#8481DD", // Purple #1 "#5752D1", // Purple #2 "#3A3580", // Purple #3 "#26235F", // Purple #4 "#A4A8B6", // Grey #1 "#7E8290", // Grey #2 "#545B70", // Grey #3 "#3F4250", // Grey #4 "#FFD86D", // Yellow #1 "#FFBC2C", // Yellow #2 "#C08A16", // Yellow #3 "#936A12", // Yellow #4 ]; function getNthColor(index, palette) { return palette[index % palette.length]; } function getColorsPalette(quantity) { if (quantity <= 6) { return COLORS_SM; } else if (quantity <= 12) { return COLORS_MD; } else if (quantity <= 24) { return COLORS_LG; } else { return COLORS_XL; } } class ColorGenerator { preferredColors; currentColorIndex = 0; palette; constructor(paletteSize, preferredColors = []) { this.preferredColors = preferredColors; this.palette = getColorsPalette(paletteSize).filter((c) => !preferredColors.includes(c)); } next() { return this.preferredColors?.[this.currentColorIndex] ? this.preferredColors[this.currentColorIndex++] : getNthColor(this.currentColorIndex++, this.palette); } } //------------------------------------------------------------------------------ // Coordinate //------------------------------------------------------------------------------ /** * Convert a (col) number to the corresponding letter. * * Examples: * 0 => 'A' * 25 => 'Z' * 26 => 'AA' * 27 => 'AB' */ function numberToLetters(n) { if (n < 0) { throw new Error(`number must be positive. Got ${n}`); } if (n < 26) { return String.fromCharCode(65 + n); } else { return numberToLetters(Math.floor(n / 26) - 1) + numberToLetters(n % 26); } } function lettersToNumber(letters) { let result = 0; const l = letters.length; for (let i = 0; i < l; i++) { const charCode = letters.charCodeAt(i); const colIndex = charCode >= 65 && charCode <= 90 ? charCode - 64 : charCode - 96; result = result * 26 + colIndex; } return result - 1; } function isCharALetter(char) { return (char >= "A" && char <= "Z") || (char >= "a" && char <= "z"); } function isCharADigit(char) { return char >= "0" && char <= "9"; } /** * Convert a "XC" coordinate to cartesian coordinates. * * Examples: * A1 => [0,0] * B3 => [1,2] * * Note: it also accepts lowercase coordinates, but not fixed references */ function toCartesian(xc) { xc = xc.trim(); let letterPart = ""; let numberPart = ""; let i = 0; // Process letter part if (xc[i] === "$") i++; while (i < xc.length && isCharALetter(xc[i])) { letterPart += xc[i++]; } if (letterPart.length === 0 || letterPart.length > 3) { // limit to max 3 letters for performance reasons throw new Error(`Invalid cell description: ${xc}`); } // Process number part if (xc[i] === "$") i++; while (i < xc.length && isCharADigit(xc[i])) { numberPart += xc[i++]; } if (i !== xc.length || numberPart.length === 0 || numberPart.length > 7) { // limit to max 7 numbers for performance reasons throw new Error(`Invalid cell description: ${xc}`); } const col = lettersToNumber(letterPart); const row = Number(numberPart) - 1; if (isNaN(row)) { throw new Error(`Invalid cell description: ${xc}`); } return { col, row }; } /** * Convert from cartesian coordinate to the "XC" coordinate system. * * Examples: * - 0,0 => A1 * - 1,2 => B3 * - 0,0, {colFixed: false, rowFixed: true} => A$1 * - 1,2, {colFixed: true, rowFixed: false} => $B3 */ function toXC(col, row, rangePart = { colFixed: false, rowFixed: false }) { return ((rangePart.colFixed ? "$" : "") + numberToLetters(col) + (rangePart.rowFixed ? "$" : "") + String(row + 1)); } /** * #################################################### * # INTRODUCTION * #################################################### * * This file contain the function recomputeZones. * This function try to recompute in a performant way * an ensemble of zones possibly overlapping to avoid * overlapping and to reduce the number of zones. * * It also allows to remove some zones from the ensemble. * * In the following example, 2 zones are overlapping. * Applying recomputeZones will return zones without * overlapping: * * ["B3:D4", "D2:E3"] ["B3:C4", "D2:D4", "E2:E3"] * * A B C D E A B C D E * 1 ___ 1 ___ * 2 ___|_ | 2 ___| | | * 3 | |_|_| ---> 3 | | |_| * 4 |_____| 4 |___|_| * 6 6 * 7 7 * * * In the following example, 2 zones are contiguous. * Applying recomputeZones will return only one zone: * * ["B2:B3", "C2:D3"] ["B2:D3"] * * A B C D E A B C D E * 1 _ ___ 1 _____ * 2 | | | ---> 2 | | * 3 |_|___| 3 |_____| * 4 4 * * * In the following example, we want to remove a zone * from the ensemble. Applying recomputeZones will * return the ensemble without the zone to remove: * * remove ["C3:D3"] ["B2:B4", "C2:D2", * "C4:D4", "E2:E4"] * * A B C D E F A B C D E F * 1 _______ 1 _______ * 2 | | ---> 2 | |___| | * 3 | xxx | 3 | |___| | * 4 |_______| 4 |_|___|_| * 5 5 * * * The exercise seems simple when we have only 2 zones. * But with n zones and in a performant way, we want to * avoid comparing each zone with all the others. * * * #################################################### * # Methodological approach * #################################################### * * The methodological approach to avoid comparing each * zone with all the others is to use a data structure * that allow to quickly find which zones are * overlapping with any other given zone. * * Here the idea is to profile the zones at the columns level. * * To do that, we propose to use a data structure * composed of 2 parts: * - profilesStartingPosition: a sorted number array * indicating on which columns a new profile begins. * - profiles: a map where the key is a column * position (from profilesStartingPosition) and the * value is a sorted number array representing a * profile. * * * See the following example: here profileStartingPosition * corresponds to [A,C,E,G,K] * A B C D E F G H I J K so with number [0,2,4,6,10] * 1 ' ' ' ' * 2 ' ' '_______' here profile correspond * 3 '___' |_______| for A to [] * 4 | | for C to [3, 5] * 5 |___| for E to [] * 6 for G to [2, 3] * 7 for K to [] * * * Now we can easily find which zones are overlapping * with a given zone. Suppose we want to add a new zone * D5:H6 to the ensemble: * * With a binary search of left and right * A B C D E F G H I J K on profilesStartingPosition, we can * 1 ' ' ' ' find the indexes of the profiles on which * 2 ' ' '_______' to apply a modification. * 3 '___' |_______| * 4 | _|_______ Here we will: * 5 |_|_| | - add a new profile in D --> become [3, 6] * 6 |_________| - modify the profile in E --> become [4, 6] * 7 - modify the profile in G --> become [2, 3, 4, 6] * - add a new profile in I --> become [8, 10] * * See below the result: * * Note the particularity of the profile * A B C D E F G H I J K for G: it will correspond to [2, 3, 4, 6] * 1 ' ' ' ' ' ' * 2 ' ' ' '___'___' To know how to modify the profile (add a * 3 '_'_' |___|___| zone or remove it) we do a binary * 4 | | |___ ___ search of the top and bottom value on the * 5 |_| | | | profile array. Depending on the result index * 6 |_|___|___| parity (odd or even), because zone boundaries * 7 go by pairs, we know if we are in a zone or * not and how operate. */ /** * Recompute the zone without the cells in toRemoveZones and avoid overlapping. * This compute is particularly useful because after this function: * - you will find coordinate of a cell only once among all the zones * - the number of zones will be reduced to the minimum */ function recomputeZones(zones, zonesToRemove = []) { if (zones.length <= 1 && zonesToRemove.length === 0) { return zones; } const profilesStartingPosition = [0]; const profiles = new Map([[0, []]]); modifyProfiles(profilesStartingPosition, profiles, zones, false); modifyProfiles(profilesStartingPosition, profiles, zonesToRemove, true); return constructZonesFromProfiles(profilesStartingPosition, profiles); } function modifyProfiles(// export for testing only profilesStartingPosition, profiles, zones, toRemove = false) { for (const zone of zones) { const leftValue = zone.left; const rightValue = zone.right === undefined ? undefined : zone.right + 1; const leftIndex = findIndexAndCreateProfile(profilesStartingPosition, profiles, leftValue, true, 0); const rightIndex = findIndexAndCreateProfile(profilesStartingPosition, profiles, rightValue, false, leftIndex); for (let i = leftIndex; i <= rightIndex; i++) { const profile = profiles.get(profilesStartingPosition[i]); modifyProfile(profile, zone, toRemove); } // maybe this part cost in performance, and maybe it's not necessary (depending on the use case). To be checked removeContiguousProfiles(profilesStartingPosition, profiles, leftIndex, rightIndex); } } function findIndexAndCreateProfile(profilesStartingPosition, profiles, value, searchLeft, startIndex) { if (value === undefined) { // this is only the case when the value correspond to a bottom value that could be undefined return profilesStartingPosition.length - 1; } const predecessorIndex = binaryPredecessorSearch(profilesStartingPosition, value, startIndex); if (value != profilesStartingPosition[predecessorIndex]) { // mean that the value is not ending/starting at the same position as the previous/next profile // --> it's a new profile // --> we need to add it profilesStartingPosition.splice(predecessorIndex + 1, 0, value); // suppose the we want to add the for the left value // following profile following zone: 'C', the predecessor index // for B: [1, 3] "C3:D4" correspond to 'B'. // The next line code will // A B C D A B C D copy the profile of 'B' // 1 '___' 1 '___' to 'C'. In the rest of the // 2 | | ---> 2 | _|_ process the 'modifyProfile' // 3 |___| 3 |_|_| | function will adapt the waiting // 4 4 |___| 'C' profile [1, 3] to the // correct 'C' profile [1, 4] profiles.set(value, [...profiles.get(profilesStartingPosition[predecessorIndex])]); return searchLeft ? predecessorIndex + 1 : predecessorIndex; } return searchLeft ? predecessorIndex : predecessorIndex - 1; } /** * Suppose the following Suppose we want to add We want to have the * profile: the following zone: following profile: * * A B C D E F A B C D E F A B C D E F * 1 '___' 1 ' ' 1 '___' * 2 |___| 2 '___' 2 | | * 3 ' ' 3 | | 3 | | * 4 '___' --> 4 | | --> 4 | | * 6 | | 6 |___| 6 | | * 7 |___| 7 7 |___| * 8 8 8 * * the profile for 'C' the top zone correspond Here [2, 3, 5, 8] with [3, 7] * corresponds to: to 3 and the bottom zone would be merged into [2, 8] * ____ ____ correspond to 6 * [2, 3, 5, 8] would be the profile: The difficulty of modify profile * ____ is to know what must be deleted * Note that the 'filled [3, 7] and what must be added to the * zone' are always between existing profile. * an even index and its * next index * */ function modifyProfile(profile, zone, toRemove = false) { const topValue = zone.top; const bottomValue = zone.bottom === undefined ? undefined : zone.bottom + 1; const newPoints = []; // Case we want to add a zone to the profile: // - If the top predecessor index `topPredIndex` is even, it means the top of the zone is already positioned on a filled zone // so we don't need to add it to the profile. we can keep in reference the index of the predecessor. // - If it is odd, it means the top of the zone must be the beginning of a filled zone. // so we can keep the index of the top position // Case we want to remove a zone from the profile: it's the opposite of the previous case const topPredIndex = binaryPredecessorSearch(profile, topValue, 0, false); if ((topPredIndex % 2 !== 0 && !toRemove) || (topPredIndex % 2 === 0 && toRemove)) { newPoints.push(topValue); } if (bottomValue === undefined) { // The following two code lines will not impact the final result, // but they will impact the intermediate profile. // We keep them for performance reason profile.splice(topPredIndex + 1); profile.push(...newPoints); return; } // Case we want to add a zone to the profile: // - If the bottom successor index `bottomSuccIndex` is even, it means the bottom of the zone must be the ending of a filled zone // so we can keep the index of the bottom position. // - If it is odd, it means the bottom of the zone is already positioned on a filled zone // so we don't need to add it to the profile. we can keep in reference the index of the successor // Case we want to remove a zone from the profile: it's the opposite of the previous case const bottomSuccIndex = binarySuccessorSearch(profile, bottomValue, 0, false); if ((bottomSuccIndex % 2 === 0 && !toRemove) || (bottomSuccIndex % 2 !== 0 && toRemove)) { newPoints.push(bottomValue); } // add the top and bottom value to the profile and // remove all information between the top and bottom index profile.splice(topPredIndex + 1, bottomSuccIndex - topPredIndex - 1, ...newPoints); } function removeContiguousProfiles(profilesStartingPosition, profiles, leftIndex, rightIndex) { const start = leftIndex - 1 === -1 ? 0 : leftIndex - 1; const end = rightIndex === profilesStartingPosition.length - 1 ? rightIndex : rightIndex + 1; for (let i = end; i > start; i--) { if (deepEqualsArray(profiles.get(profilesStartingPosition[i]), profiles.get(profilesStartingPosition[i - 1]))) { profiles.delete(profilesStartingPosition[i]); profilesStartingPosition.splice(i, 1); } } } function constructZonesFromProfiles(profilesStartingPosition, profiles) { const mergedZone = []; let pendingZones = []; for (let colIndex = 0; colIndex < profilesStartingPosition.length; colIndex++) { const left = profilesStartingPosition[colIndex]; const profile = profiles.get(left); if (!profile || profile.length === 0) { mergedZone.push(...pendingZones); pendingZones = []; continue; } let right = profilesStartingPosition[colIndex + 1]; if (right !== undefined) { right--; } const nextPendingZones = []; for (let i = 0; i < profile.length; i += 2) { const top = profile[i]; let bottom = profile[i + 1]; if (bottom !== undefined) { bottom--; } const profileZone = { top, left, bottom, right, hasHeader: (bottom === undefined && top !== 0) || (right === undefined && left !== 0), }; let findCorrespondingZone = false; for (let j = pendingZones.length - 1; j >= 0; j--) { const pendingZone = pendingZones[j]; if (pendingZone.top === profileZone.top && pendingZone.bottom === profileZone.bottom) { pendingZone.right = profileZone.right; pendingZones.splice(j, 1); nextPendingZones.push(pendingZone); findCorrespondingZone = true; break; } } if (!findCorrespondingZone) { nextPendingZones.push(profileZone); } } mergedZone.push(...pendingZones); pendingZones = nextPendingZones; } mergedZone.push(...pendingZones); return mergedZone; } function binaryPredecessorSearch(arr, val, start = 0, matchEqual = true) { let end = arr.length - 1; let result = -1; while (start <= end) { const mid = Math.floor((start + end) / 2); if (arr[mid] === val && matchEqual) { return mid; } else if (arr[mid] < val) { result = mid; start = mid + 1; } else { end = mid - 1; } } return result; } function binarySuccessorSearch(arr, val, start = 0, matchEqual = true) { let end = arr.length - 1; let result = arr.length; while (start <= end) { const mid = Math.floor((start + end) / 2); if (arr[mid] === val && matchEqual) { return mid; } else if (arr[mid] > val) { result = mid; end = mid - 1; } else { start = mid + 1; } } return result; } const defaultTranslate = (s) => s; const defaultLoaded = () => false; let _translate = defaultTranslate; let _loaded = defaultLoaded; function sprintf(s, ...values) { if (values.length === 1 && typeof values[0] === "object" && !(values[0] instanceof String)) { const valuesDict = values[0]; s = s.replace(/\%\(([^\)]+)\)s/g, (match, value) => valuesDict[value]); } else if (values.length > 0) { s = s.replace(/\%s/g, () => values.shift()); } return s; } /*** * Allow to inject a translation function from outside o-spreadsheet. This should be called before instantiating * a model. * @param tfn the function that will do the translation * @param loaded a function that returns true when the translation is loaded */ function setTranslationMethod(tfn, loaded = () => true) { _translate = tfn; _loaded = loaded; } /** * If no translation function has been set, this will mark the translation are loaded. * * By default, the translations should not be set as loaded, otherwise top-level translated constants will never be * translated. But if by the time the model is instantiated no custom translation function has been set, we can set * the default translation function as loaded so o-spreadsheet can be run in standalone with no translations. */ function setDefaultTranslationMethod() { if (_translate === defaultTranslate && _loaded === defaultLoaded) { _loaded = () => true; } } const _t = function (s, ...values) { if (!_loaded()) { return new LazyTranslatedString(s, values); } return sprintf(_translate(s), ...values); }; class LazyTranslatedString extends String { values; constructor(str, values) { super(str); this.values = values; } valueOf() { const str = super.valueOf(); return _loaded() ? sprintf(_translate(str), ...this.values) : sprintf(str, ...this.values); } toString() { return this.valueOf(); } } /** Reference of a cell (eg. A1, $B$5) */ const cellReference = new RegExp(/\$?([A-Z]{1,3})\$?([0-9]{1,7})/, "i"); // Same as above, but matches the exact string (nothing before or after) const singleCellReference = new RegExp(/^\$?([A-Z]{1,3})\$?([0-9]{1,7})$/, "i"); /** Reference of a column header (eg. A, AB, $A) */ const colHeader = new RegExp(/^\$?([A-Z]{1,3})+$/, "i"); /** Reference of a row header (eg. 1, $1) */ const rowHeader = new RegExp(/^\$?([0-9]{1,7})+$/, "i"); /** Reference of a column (eg. A, $CA, Sheet1!B) */ const colReference = new RegExp(/^\s*('.+'!|[^']+!)?\$?([A-Z]{1,3})$/, "i"); /** Reference of a row (eg. 1, 59, Sheet1!9) */ const rowReference = new RegExp(/^\s*('.+'!|[^']+!)?\$?([0-9]{1,7})$/, "i"); /** Reference of a normal range or a full row range (eg. A1:B1, 1:$5, $A2:5) */ const fullRowXc = /(\$?[A-Z]{1,3})?\$?[0-9]{1,7}\s*:\s*(\$?[A-Z]{1,3})?\$?[0-9]{1,7}\s*/i; /** Reference of a normal range or a column row range (eg. A1:B1, A:$B, $A1:C) */ const fullColXc = /\$?[A-Z]{1,3}(\$?[0-9]{1,7})?\s*:\s*\$?[A-Z]{1,3}(\$?[0-9]{1,7})?\s*/i; /** Reference of a cell or a range, it can be a bounded range, a full row or a full column */ const rangeReference = new RegExp(/^\s*('.+'!|[^']+!)?/.source + "(" + [cellReference.source, fullRowXc.source, fullColXc.source].join("|") + ")" + /$/.source, "i"); /** * Return true if the given xc is the reference of a column (e.g. A or AC or Sheet1!A) */ function isColReference(xc) { return colReference.test(xc); } /** * Return true if the given xc is the reference of a column (e.g. 1 or Sheet1!1) */ function isRowReference(xc) { return rowReference.test(xc); } function isColHeader(str) { return colHeader.test(str); } function isRowHeader(str) { return rowHeader.test(str); } /** * Return true if the given xc is the reference of a single cell, * without any specified sheet (e.g. A1) */ function isSingleCellReference(xc) { return singleCellReference.test(xc); } function splitReference(ref) { if (!ref.includes("!")) { return { xc: ref }; } const parts = ref.split("!"); const xc = parts.pop(); const sheetName = getUnquotedSheetName(parts.join("!")) || undefined; return { sheetName, xc }; } /** Return a reference SheetName!xc from the given arguments */ function getFullReference(sheetName, xc) { return sheetName !== undefined ? `${getCanonicalSymbolName(sheetName)}!${xc}` : xc; } /** * Convert from a cartesian reference to a Zone * The range boundaries will be kept in the same order as the * ones in the text. * Examples: * "A1" ==> Top 0, Bottom 0, Left: 0, Right: 0 * "B1:B3" ==> Top 0, Bottom 3, Left: 1, Right: 1 * "Sheet1!A1" ==> Top 0, Bottom 0, Left: 0, Right: 0 * "Sheet1!B1:B3" ==> Top 0, Bottom 3, Left: 1, Right: 1 * "C3:A1" ==> Top 2, Bottom 0, Left 2, Right 0 * "A:A" ==> Top 0, Bottom undefined, Left 0, Right 0 * "A:B3" or "B3:A" ==> Top 2, Bottom undefined, Left 0, Right 1 * * @param xc the string reference to convert * */ function toZoneWithoutBoundaryChanges(xc) { if (xc.includes("!")) { xc = xc.split("!").at(-1); } if (xc.includes("$")) { xc = xc.replaceAll("$", ""); } let firstRangePart = ""; let secondRangePart; if (xc.includes(":")) { [firstRangePart, secondRangePart] = xc.split(":"); firstRangePart = firstRangePart.trim(); secondRangePart = secondRangePart.trim(); } else { firstRangePart = xc.trim(); } let top, bottom, left, right; let fullCol = false; let fullRow = false; let hasHeader = false; if (isColReference(firstRangePart)) { left = right = lettersToNumber(firstRangePart); top = bottom = 0; fullCol = true; } else if (isRowReference(firstRangePart)) { top = bottom = parseInt(firstRangePart, 10) - 1; left = right = 0; fullRow = true; } else { const c = toCartesian(firstRangePart); left = right = c.col; top = bottom = c.row; hasHeader = true; } if (secondRangePart) { if (isColReference(secondRangePart)) { right = lettersToNumber(secondRangePart); fullCol = true; } else if (isRowReference(secondRangePart)) { bottom = parseInt(secondRangePart, 10) - 1; fullRow = true; } else { const c = toCartesian(secondRangePart); right = c.col; bottom = c.row; top = fullCol ? bottom : top; left = fullRow ? right : left; hasHeader = true; } } if (fullCol && fullRow) { throw new Error("Wrong zone xc. The zone cannot be at the same time a full column and a full row"); } const zone = { top, left, bottom: fullCol ? undefined : bottom, right: fullRow ? undefined : right, }; hasHeader = hasHeader && (fullRow || fullCol); if (hasHeader) { zone.hasHeader = hasHeader; } return zone; } /** * Convert from a cartesian reference to a (possibly unbounded) Zone * * Examples: * "A1" ==> Top 0, Bottom 0, Left: 0, Right: 0 * "B1:B3" ==> Top 0, Bottom 3, Left: 1, Right: 1 * "B:B" ==> Top 0, Bottom undefined, Left: 1, Right: 1 * "B2:B" ==> Top 1, Bottom undefined, Left: 1, Right: 1, hasHeader: 1 * "Sheet1!A1" ==> Top 0, Bottom 0, Left: 0, Right: 0 * "Sheet1!B1:B3" ==> Top 0, Bottom 3, Left: 1, Right: 1 * * @param xc the string reference to convert * */ function toUnboundedZone(xc) { const zone = toZoneWithoutBoundaryChanges(xc); if (zone.right !== undefined && zone.right < zone.left) { const tmp = zone.left; zone.left = zone.right; zone.right = tmp; } if (zone.bottom !== undefined && zone.bottom < zone.top) { const tmp = zone.top; zone.top = zone.bottom; zone.bottom = tmp; } return zone; } /** * Convert from a cartesian reference to a Zone. * Will return throw an error if given a unbounded zone (eg : A:A). * * Examples: * "A1" ==> Top 0, Bottom 0, Left: 0, Right: 0 * "B1:B3" ==> Top 0, Bottom 2, Left: 1, Right: 1 * "Sheet1!A1" ==> Top 0, Bottom 0, Left: 0, Right: 0 * "Sheet1!B1:B3" ==> Top 0, Bottom 2, Left: 1, Right: 1 * * @param xc the string reference to convert * */ function toZone(xc) { const zone = toUnboundedZone(xc); if (zone.bottom === undefined || zone.right === undefined) { throw new Error("This does not support unbounded ranges"); } return zone; } /** * Check that the zone has valid coordinates and in * the correct order. */ function isZoneValid(zone) { // Typescript *should* prevent this kind of errors but // it's better to be on the safe side at runtime as well. const { bottom, top, left, right } = zone; if ((bottom !== undefined && isNaN(bottom)) || isNaN(top) || isNaN(left) || (right !== undefined && isNaN(right))) { return false; } return isZoneOrdered(zone) && zone.top >= 0 && zone.left >= 0; } /** * Check that the zone properties are in the correct order. */ function isZoneOrdered(zone) { return ((zone.bottom === undefined || (zone.bottom >= zone.top && zone.bottom >= 0)) && (zone.right === undefined || (zone.right >= zone.left && zone.right >= 0))); } /** * Convert from zone to a cartesian reference * */ function zoneToXc(zone) { const { top, bottom, left, right } = zone; const hasHeader = "hasHeader" in zone ? zone.hasHeader : false; const isOneCell = top === bottom && left === right; if (bottom === undefined && right !== undefined) { return top === 0 && !hasHeader ? `${numberToLetters(left)}:${numberToLetters(right)}` : `${toXC(left, top)}:${numberToLetters(right)}`; } else if (right === undefined && bottom !== undefined) { return left === 0 && !hasHeader ? `${top + 1}:${bottom + 1}` : `${toXC(left, top)}:${bottom + 1}`; } else if (bottom !== undefined && right !== undefined) { return isOneCell ? toXC(left, top) : `${toXC(left, top)}:${toXC(right, bottom)}`; } throw new Error(_t("Bad zone format")); } /** * Expand a zone after inserting columns or rows. * * Don't resize the zone if a col/row was added right before/after the row but only move the zone. */ function expandZoneOnInsertion(zone, start, base, position, quantity) { const dimension = start === "left" ? "columns" : "rows"; const baseElement = position === "before" ? base - 1 : base; const end = start === "left" ? "right" : "bottom"; const zoneEnd = zone[end]; if (zone[start] <= baseElement && zoneEnd && zoneEnd > baseElement) { return createAdaptedZone(zone, dimension, "RESIZE", quantity); } if (baseElement < zone[start]) { return createAdaptedZone(zone, dimension, "MOVE", quantity); } return { ...zone }; } /** * Update the selection after column/row addition */ function updateSelectionOnInsertion(selection, start, base, position, quantity) { const dimension = start === "left" ? "columns" : "rows"; const baseElement = position === "before" ? base - 1 : base; const end = start === "left" ? "right" : "bottom"; if (selection[start] <= baseElement && selection[end] > baseElement) { return createAdaptedZone(selection, dimension, "RESIZE", quantity); } if (baseElement < selection[start]) { return createAdaptedZone(selection, dimension, "MOVE", quantity); } return { ...selection }; } /** * Update the selection after column/row deletion */ function updateSelectionOnDeletion(zone, start, elements) { const end = start === "left" ? "right" : "bottom"; let newStart = zone[start]; let newEnd = zone[end]; for (let removedElement of elements.sort((a, b) => b - a)) { if (zone[start] > removedElement) { newStart--; newEnd--; } if (zone[start] < removedElement && zone[end] >= removedElement) { newEnd--; } } return { ...zone, [start]: newStart, [end]: newEnd }; } /** * Reduce a zone after deletion of elements */ function reduceZoneOnDeletion(zone, start, elements) { const end = start === "left" ? "right" : "bottom"; let newStart = zone[start]; let newEnd = zone[end]; const zoneEnd = zone[end]; for (let removedElement of elements.sort((a, b) => b - a)) { if (zone[start] > removedElement) { newStart--; if (newEnd !== undefined) newEnd--; } if (zoneEnd !== undefined && newEnd !== undefined && zone[start] <= removedElement && zoneEnd >= removedElement) { newEnd--; } } if (newEnd !== undefined && newStart > newEnd) { return undefined; } return { ...zone, [start]: newStart, [end]: newEnd }; } /** * Compute the union of multiple zones. */ function union(...zones) { return { top: Math.min(...zones.map((zone) => zone.top)), left: Math.min(...zones.map((zone) => zone.left)), bottom: Math.max(...zones.map((zone) => zone.bottom)), right: Math.max(...zones.map((zone) => zone.right)), }; } /** * Compute the union of multiple unbounded zones. */ function unionUnboundedZones(...zones) { return { top: Math.min(...zones.map((zone) => zone.top)), left: Math.min(...zones.map((zone) => zone.left)), bottom: zones.some((zone) => zone.bottom === undefined) ? undefined : Math.max(...zones.map((zone) => zone.bottom)), right: zones.some((zone) => zone.right === undefined) ? undefined : Math.max(...zones.map((zone) => zone.right)), }; } /** * Compute the intersection of two zones. Returns nothing if the two zones don't overlap */ function intersection(z1, z2) { if (!overlap(z1, z2)) { return undefined; } return { top: Math.max(z1.top, z2.top), left: Math.max(z1.left, z2.left), bottom: Math.min(z1.bottom, z2.bottom), right: Math.min(z1.right, z2.right), }; } /** * Two zones are equal if they represent the same area, so we clearly cannot use * reference equality. */ function isEqual(z1, z2) { return (z1.left === z2.left && z1.right === z2.right && z1.top === z2.top && z1.bottom === z2.bottom); } /** * Return true if two zones overlap, false otherwise. */ function overlap(z1, z2) { if (z1.bottom < z2.top || z2.bottom < z1.top) { return false; } if (z1.right < z2.left || z2.right < z1.left) { return false; } return true; } function isInside(col, row, zone) { const { left, right, top, bottom } = zone; return col >= left && col <= right && row >= top && row <= bottom; } /** * Check if a zone is inside another */ function isZoneInside(smallZone, biggerZone) { return isEqual(union(biggerZone, smallZone), biggerZone); } function zoneToDimension(zone) { return { numberOfRows: zone.bottom - zone.top + 1, numberOfCols: zone.right - zone.left + 1, }; } function isOneDimensional(zone) { const { numberOfCols, numberOfRows } = zoneToDimension(zone); return numberOfCols === 1 || numberOfRows === 1; } function excludeTopLeft(zone) { const { top, left, bottom, right } = zone; if (getZoneArea(zone) === 1) { return []; } const leftColumnZone = { top: top + 1, bottom, left, right: left, }; if (right === left) { return [leftColumnZone]; } const rightPartZone = { top, bottom, left: left + 1, right, }; if (top === bottom) { return [rightPartZone]; } return [leftColumnZone, rightPartZone]; } function aggregatePositionsToZones(positions) { const result = {}; for (const position of positions) { result[position.sheetId] ??= []; result[position.sheetId].push(positionToZone(position)); } for (const sheetId in result) { result[sheetId] = recomputeZones(result[sheetId]); } return result; } /** * Array of all positions in the zone. */ function positions(zone) { const positions = []; const { left, right, top, bottom } = reorderZone(zone); for (const col of range(left, right + 1)) { for (const row of range(top, bottom + 1)) { positions.push({ col, row }); } } return positions; } function reorderZone(zone) { if (zone.left > zone.right) { zone = { left: zone.right, right: zone.left, top: zone.top, bottom: zone.bottom }; } if (zone.top > zone.bottom) { zone = { left: zone.left, right: zone.right, top: zone.bottom, bottom: zone.top }; } return zone; } /** * This function returns a zone with coordinates modified according to the change * applied to the zone. It may be possible to change the zone by resizing or moving * it according to different dimensions. * * @param zone the zone to modify * @param dimension the direction to change the zone among "columns", "rows" and * "both" * @param operation how to change the zone, modify its size "RESIZE" or modify * its location "MOVE" * @param by a number of how many units the change should be made. This parameter * takes the form of a two-number array when the dimension is "both" */ function createAdaptedZone(zone, dimension, operation, by) { const offsetX = dimension === "both" ? by[0] : dimension === "columns" ? by : 0; const offsetY = dimension === "both" ? by[1] : dimension === "rows" ? by : 0; // For full columns/rows, we have to make the distinction between the one that have a header and // whose start should be moved (eg. A2:A), and those who don't (eg. A:A) // The only time we don't want to move the start of the zone is if the zone is a full column (or a full row) // without header and that we are adding/removing a row (or a column) const hasHeader = "hasHeader" in zone ? zone.hasHeader : false; let shouldStartBeMoved; if (isFullCol(zone) && !hasHeader) { shouldStartBeMoved = dimension !== "rows"; } else if (isFullRow(zone) && !hasHeader) { shouldStartBeMoved = dimension !== "columns"; } else { shouldStartBeMoved = true; } const newZone = { ...zone }; if (shouldStartBeMoved && operation === "MOVE") { newZone["left"] += offsetX; newZone["top"] += offsetY; } if (newZone["right"] !== undefined) { newZone["right"] += offsetX; } if (newZone["bottom"] !== undefined) { newZone["bottom"] += offsetY; } return newZone; } /** * Returns a Zone array with unique occurrence of each zone. * For each multiple occurrence, the occurrence with the largest index is kept. * This allows to always have the last selection made in the last position. * */ function uniqueZones(zones) { return zones .reverse() .filter((zone, index, self) => index === self.findIndex((z) => z.top === zone.top && z.bottom === zone.bottom && z.left === zone.left && z.right === zone.right)) .reverse(); } /** * This function will find all overlapping zones in an array and transform them * into an union of each one. * */ function mergeOverlappingZones(zones) { return zones.reduce((dissociatedZones, zone) => { const nextIndex = dissociatedZones.length; for (let i = 0; i < nextIndex; i++) { if (overlap(dissociatedZones[i], zone)) { dissociatedZones[i] = union(dissociatedZones[i], zone); return dissociatedZones; } } dissociatedZones[nextIndex] = zone; return dissociatedZones; }, []); } /** * This function will compare the modifications of selection to determine * a cell that is part of the new zone and not the previous one. */ function findCellInNewZone(oldZone, currentZone) { let col, row; const { left: oldLeft, right: oldRight, top: oldTop, bottom: oldBottom } = oldZone; const { left, right, top, bottom } = currentZone; if (left != oldLeft) { col = left; } else if (right != oldRight) { col = right; } else { // left and right don't change col = left; } if (top != oldTop) { row = top; } else if (bottom != oldBottom) { row = bottom; } else { // top and bottom don't change row = top; } return { col, row }; } function positionToZone(position) { return { left: position.col, right: position.col, top: position.row, bottom: position.row }; } /** Transform a zone into a zone with only its top-left position */ function zoneToTopLeft(zone) { return { ...zone, right: zone.left, bottom: zone.top }; } function isFullRow(zone) { return zone.right === undefined; } function isFullCol(zone) { return zone.bottom === undefined; } /** Returns the area of a zone */ function getZoneArea(zone) { return (zone.bottom - zone.top + 1) * (zone.right - zone.left + 1); } /** * Check if the zones are continuous, ie. if they can be merged into a single zone without * including cells outside the zones * */ function areZonesContinuous(zones) { if (zones.length < 2) return true; return recomputeZones(zones).length === 1; } /** Return all the columns in the given list of zones */ function getZonesCols(zones) { const set = new Set(); for (let zone of recomputeZones(zones)) { for (let col of range(zone.left, zone.right + 1)) { set.add(col); } } return set; } /** Return all the rows in the given list of zones */ function getZonesRows(zones) { const set = new Set(); for (let zone of recomputeZones(zones)) { for (let row of range(zone.top, zone.bottom + 1)) { set.add(row); } } return set; } /** * Check if two zones are contiguous, ie. that they share a border */ function areZoneContiguous(zone1, zone2) { if (zone1.right + 1 === zone2.left || zone1.left === zone2.right + 1) { return ((zone1.top <= zone2.bottom && zone1.top >= zone2.top) || (zone2.top <= zone1.bottom && zone2.top >= zone1.top)); } if (zone1.bottom + 1 === zone2.top || zone1.top === zone2.bottom + 1) { return ((zone1.left <= zone2.right && zone1.left >= zone2.left) || (zone2.left <= zone1.right && zone2.left >= zone1.left)); } return false; } /** * Merge contiguous and overlapping zones that are in the array into bigger zones */ function mergeContiguousZones(zones) { const mergedZones = [...zones]; let hasMerged = true; while (hasMerged) { hasMerged = false; for (let i = 0; i < mergedZones.length; i++) { const zone = mergedZones[i]; const mergeableZoneIndex = mergedZones.findIndex((z, j) => i !== j && (areZoneContiguous(z, zone) || overlap(z, zone))); if (mergeableZoneIndex !== -1) { mergedZones[i] = union(mergedZones[mergeableZoneIndex], zone); mergedZones.splice(mergeableZoneIndex, 1); hasMerged = true; break; } } } return mergedZones; } /** * Get the id of the given item (its key in the given dictionary). * If the given item does not exist in the dictionary, it creates one with a new id. */ function getItemId(item, itemsDic) { for (const key in itemsDic) { if (deepEquals(itemsDic[key], item)) { return parseInt(key, 10); } } // Generate new Id if the item didn't exist in the dictionary const ids = Object.keys(itemsDic); const maxId = ids.length === 0 ? 0 : largeMax(ids.map((id) => parseInt(id, 10))); itemsDic[maxId + 1] = item; return maxId + 1; } function groupItemIdsByZones(positionsByItemId) { const result = {}; for (const itemId in positionsByItemId) { const zones = recomputeZones(positionsByItemId[itemId].map(positionToZone)); for (const zone of zones) { result[zoneToXc(zone)] = Number(itemId); } } return result; } // ----------------------------------------------------------------------------- // Date Type // ----------------------------------------------------------------------------- /** * A DateTime object that can be used to manipulate spreadsheet dates. * Conceptually, a spreadsheet date is simply a number with a date format, * and it is timezone-agnostic. * This DateTime object consistently uses UTC time to represent a naive date and time. */ class DateTime { jsDate; constructor(year, month, day, hours = 0, minutes = 0, seconds = 0) { this.jsDate = new Date(Date.UTC(year, month, day, hours, minutes, seconds, 0)); } static fromTimestamp(timestamp) { const date = new Date(timestamp); return new DateTime(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds()); } static now() { const now = new Date(); return new DateTime(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), now.getMinutes(), now.getSeconds()); } toString() { return this.jsDate.toString(); } toLocaleDateString() { return this.jsDate.toLocaleDateString(); } getTime() { return this.jsDate.getTime(); } getFullYear() { return this.jsDate.getUTCFullYear(); } getMonth() { return this.jsDate.getUTCMonth(); } getQuarter() { return Math.floor(this.getMonth() / 3) + 1; } getDate() { return this.jsDate.getUTCDate(); } getDay() { return this.jsDate.getUTCDay(); } getHours() { return this.jsDate.getUTCHours(); } getMinutes() { return this.jsDate.getUTCMinutes(); } getSeconds() { return this.jsDate.getUTCSeconds(); } getIsoWeek() { const date = new Date(this.jsDate.getTime()); const dayNumber = date.getUTCDay() || 7; date.setUTCDate(date.getUTCDate() + 4 - dayNumber); const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1)); return Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); } setFullYear(year) { return this.jsDate.setUTCFullYear(year); } setMonth(month) { return this.jsDate.setUTCMonth(month); } setDate(date) { return this.jsDate.setUTCDate(date); } setHours(hours) { return this.jsDate.setUTCHours(hours); } setMinutes(minutes) { return this.jsDate.setUTCMinutes(minutes); } setSeconds(seconds) { return this.jsDate.setUTCSeconds(seconds); } } // ----------------------------------------------------------------------------- // Parsing // ----------------------------------------------------------------------------- const INITIAL_1900_DAY = new DateTime(1899, 11, 30); const MS_PER_DAY = 24 * 60 * 60 * 1000; const CURRENT_MILLENIAL = 2000; // note: don't forget to update this in 2999 const CURRENT_YEAR = DateTime.now().getFullYear(); const CURRENT_MONTH = DateTime.now().getMonth(); const INITIAL_JS_DAY = DateTime.fromTimestamp(0); const DATE_JS_1900_OFFSET = INITIAL_JS_DAY.getTime() - INITIAL_1900_DAY.getTime(); const mdyDateRegexp = /^\d{1,2}(\/|-|\s)\d{1,2}((\/|-|\s)\d{1,4})?$/; const ymdDateRegexp = /^\d{3,4}(\/|-|\s)\d{1,2}(\/|-|\s)\d{1,2}$/; const dateSeparatorsRegex = /\/|-|\s/; const dateRegexp = /^(\d{1,4})[\/-\s](\d{1,4})([\/-\s](\d{1,4}))?$/; const timeRegexp = /((\d+(:\d+)?(:\d+)?\s*(AM|PM))|(\d+:\d+(:\d+)?))$/; /** Convert a value number representing a date, or return undefined if it isn't possible */ function valueToDateNumber(value, locale) { switch (typeof value) { case "number": return value; case "string": if (isDateTime(value, locale)) { return parseDateTime(value, locale)?.value; } return !value || isNaN(Number(value)) ? undefined : Number(value); default: return undefined; } } function isDateTime(str, locale) { return parseDateTime(str, locale) !== null; } const CACHE = new Map(); function parseDateTime(str, locale) { if (!CACHE.has(locale)) { CACHE.set(locale, new Map()); } if (!CACHE.get(locale).has(str)) { CACHE.get(locale).set(str, _parseDateTime(str, locale)); } return CACHE.get(locale).get(str); } function _parseDateTime(str, locale) { str = str.trim(); let time = null; const timeMatch = str.match(timeRegexp); if (timeMatch) { time = parseTime(timeMatch[0]); if (time === null) { return null; } str = str.replace(timeMatch[0], "").trim(); } let date = null; const dateParts = getDateParts(str, locale); if (dateParts) { const separator = dateParts.dateString.match(dateSeparatorsRegex)[0]; date = parseDate(dateParts, separator); if (date === null) { return null; } str = str.replace(dateParts.dateString, "").trim(); } if (str !== "" || !(date || time)) { return null; } if (date && date.jsDate && time && time.jsDate) { return { value: date.value + time.value, format: date.format + " " + (time.format === "hhhh:mm:ss" ? "hh:mm:ss" : time.format), jsDate: new DateTime(date.jsDate.getFullYear() + time.jsDate.getFullYear() - 1899, date.jsDate.getMonth() + time.jsDate.getMonth() - 11, date.jsDate.getDate() + time.jsDate.getDate() - 30, date.jsDate.getHours() + time.jsDate.getHours(), date.jsDate.getMinutes() + time.jsDate.getMinutes(), date.jsDate.getSeconds() + time.jsDate.getSeconds()), }; } return date || time; } /** * Returns the parts (day/month/year) of a date string corresponding to the given locale. * * - A string "xxxx-xx-xx" will be parsed as "y-m-d" no matter the locale. * - A string "xx-xx-xxxx" will be parsed as "m-d-y" for mdy locale, and "d-m-y" for ymd and dmy locales. * - A string "xx-xx-xx" will be "y-m-d" for ymd locale, "d-m-y" for dmy locale, "m-d-y" for mdy locale. * - A string "xxxx-xx" will be parsed as "y-m" no matter the locale. * - A string "xx-xx" will be parsed as "m-d" for mdy and ymd locales, and "d-m" for dmy locale. */ function getDateParts(dateString, locale) { const match = dateString.match(dateRegexp); if (!match) { return null; } const [, part1, part2, , part3] = match; if (part1.length > 2 && part3 && part3.length > 2) { return null; } if (part1.length > 2) { return { year: part1, month: part2, day: part3, dateString, type: "ymd" }; } const localeDateType = getLocaleDateFormatType(locale); if (!part3) { if (part2.length > 2) { // e.g. 11/2023 return { month: part1, year: part2, day: undefined, dateString, type: localeDateType }; } if (localeDateType === "dmy") { return { day: part1, month: part2, year: part3, dateString, type: "dmy" }; } return { month: part1, day: part2, year: part3, dateString, type: "mdy" }; } if (part3.length > 2) { if (localeDateType === "mdy") { return { month: part1, day: part2, year: part3, dateString, type: "mdy" }; } return { day: part1, month: part2, year: part3, dateString, type: "dmy" }; } if (localeDateType === "mdy") { return { month: part1, day: part2, year: part3, dateString, type: "mdy" }; } if (localeDateType === "ymd") { return { year: part1, month: part2, day: part3, dateString, type: "ymd" }; } if (localeDateType === "dmy") { return { day: part1, month: part2, year: part3, dateString, type: "dmy" }; } return null; } function getLocaleDateFormatType(locale) { switch (locale.dateFormat[0]) { case "d": return "dmy"; case "m": return "mdy"; case "y": return "ymd"; } throw new Error("Invalid date format in locale"); } function parseDate(parts, separator) { let { year: yearStr, month: monthStr, day: dayStr } = parts; const month = inferMonth(monthStr); const day = inferDay(dayStr); const year = inferYear(yearStr); if (year === null || month === null || day === null) { return null; } // month + 1: months are 0-indexed in JS const leadingZero = (monthStr?.length === 2 && month + 1 < 10) || (dayStr?.length === 2 && day < 10); const fullYear = yearStr?.length !== 2; const jsDate = new DateTime(year, month, day); if (jsDate.getMonth() !== month || jsDate.getDate() !== day) { // invalid date return null; } const delta = jsDate.getTime() - INITIAL_1900_DAY.getTime(); const format = getFormatFromDateParts(parts, separator, leadingZero, fullYear); return { value: Math.round(delta / MS_PER_DAY), format: format, jsDate, }; } function getFormatFromDateParts(parts, sep, leadingZero, fullYear) { const yearFmt = parts.year ? (fullYear ? "yyyy" : "yy") : undefined; const monthFmt = parts.month ? (leadingZero ? "mm" : "m") : undefined; const dayFmt = parts.day ? (leadingZero ? "dd" : "d") : undefined; switch (parts.type) { case "mdy": return [monthFmt, dayFmt, yearFmt].filter(isDefined).join(sep); case "ymd": return [yearFmt, monthFmt, dayFmt].filter(isDefined).join(sep); case "dmy": return [dayFmt, monthFmt, yearFmt].filter(isDefined).join(sep); } } function inferYear(yearStr) { if (!yearStr) { return CURRENT_YEAR; } const nbr = Number(yearStr); switch (yearStr.length) { case 1: return CURRENT_MILLENIAL + nbr; case 2: const offset = CURRENT_MILLENIAL + nbr > CURRENT_YEAR + 10 ? -100 : 0; const base = CURRENT_MILLENIAL + offset; return base + nbr; case 3: case 4: return nbr; } return null; } function inferMonth(monthStr) { if (!monthStr) { return CURRENT_MONTH; } const nbr = Number(monthStr); if (nbr >= 1 && nbr <= 12) { return nbr - 1; } return null; } function inferDay(dayStr) { if (!dayStr) { return 1; } const nbr = Number(dayStr); if (nbr >= 0 && nbr <= 31) { return nbr; } return null; } function parseTime(str) { str = str.trim(); if (timeRegexp.test(str)) { const isAM = /AM/i.test(str); const isPM = /PM/i.test(str); const strTime = isAM || isPM ? str.substring(0, str.length - 2).trim() : str; const parts = strTime.split(/:/); const isMinutes = parts.length >= 2; const isSeconds = parts.length === 3; let hours = Number(parts[0]); let minutes = isMinutes ? Number(parts[1]) : 0; let seconds = isSeconds ? Number(parts[2]) : 0; let format = isSeconds ? "hh:mm:ss" : "hh:mm"; if (isAM || isPM) { format += " a"; } else if (!isMinutes) { return null; } if (hours >= 12 && isAM) { hours -= 12; } else if (hours < 12 && isPM) { hours += 12; } minutes += Math.floor(seconds / 60); seconds %= 60; hours += Math.floor(minutes / 60); minutes %= 60; if (hours >= 24) { format = "hhhh:mm:ss"; } const jsDate = new DateTime(1899, 11, 30, hours, minutes, seconds); return { value: hours / 24 + minutes / 1440 + seconds / 86400, format: format, jsDate: jsDate, }; } return null; } // ----------------------------------------------------------------------------- // Conversion // ----------------------------------------------------------------------------- function numberToJsDate(value) { const truncValue = Math.trunc(value); let date = DateTime.fromTimestamp(truncValue * MS_PER_DAY - DATE_JS_1900_OFFSET); let time = value - truncValue; time = time < 0 ? 1 + time : time; const hours = Math.round(time * 24); const minutes = Math.round((time - hours / 24) * 24 * 60); const seconds = Math.round((time - hours / 24 - minutes / 24 / 60) * 24 * 60 * 60); date.setHours(hours); date.setMinutes(minutes); date.setSeconds(seconds); return date; } function jsDateToRoundNumber(date) { return Math.round(jsDateToNumber(date)); } function jsDateToNumber(date) { const delta = date.getTime() - INITIAL_1900_DAY.getTime(); return delta / MS_PER_DAY; } /** Return the number of days in the current month of the given date */ function getDaysInMonth(date) { return new DateTime(date.getFullYear(), date.getMonth() + 1, 0).getDate(); } function isLastDayOfMonth(date) { return getDaysInMonth(date) === date.getDate(); } /** * Add a certain number of months to a date. This will adapt the month number, and possibly adapt * the day of the month to keep it in the month. * * For example "31/12/2020" minus one month will be "30/11/2020", and not "31/11/2020" * * @param keepEndOfMonth if true, if the given date was the last day of a month, the returned date will * also always be the last day of a month. */ function addMonthsToDate(date, months, keepEndOfMonth) { const yStart = date.getFullYear(); const mStart = date.getMonth(); const dStart = date.getDate(); const jsDate = new DateTime(yStart, mStart + months, 1); if (keepEndOfMonth && dStart === getDaysInMonth(date)) { jsDate.setDate(getDaysInMonth(jsDate)); } else if (dStart > getDaysInMonth(jsDate)) { // 31/03 minus one month should be 28/02, not 31/02 jsDate.setDate(getDaysInMonth(jsDate)); } else { jsDate.setDate(dStart); } return jsDate; } function isLeapYear(year) { const _year = Math.trunc(year); return (_year % 4 === 0 && _year % 100 != 0) || _year % 400 === 0; } function getYearFrac(startDate, endDate, _dayCountConvention) { if (startDate === endDate) { return 0; } if (startDate > endDate) { const stack = endDate; endDate = startDate; startDate = stack; } const jsStartDate = numberToJsDate(startDate); const jsEndDate = numberToJsDate(endDate); let dayStart = jsStartDate.getDate(); let dayEnd = jsEndDate.getDate(); const monthStart = jsStartDate.getMonth(); // january is 0 const monthEnd = jsEndDate.getMonth(); // january is 0 const yearStart = jsStartDate.getFullYear(); const yearEnd = jsEndDate.getFullYear(); let yearsStart = 0; let yearsEnd = 0; switch (_dayCountConvention) { // 30/360 US convention -------------------------------------------------- case 0: if (dayStart === 31) dayStart = 30; if (dayStart === 30 && dayEnd === 31) dayEnd = 30; // If jsStartDate is the last day of February if (monthStart === 1 && dayStart === (isLeapYear(yearStart) ? 29 : 28)) { dayStart = 30; // If jsEndDate is the last day of February if (monthEnd === 1 && dayEnd === (isLeapYear(yearEnd) ? 29 : 28)) { dayEnd = 30; } } yearsStart = yearStart + (monthStart * 30 + dayStart) / 360; yearsEnd = yearEnd + (monthEnd * 30 + dayEnd) / 360; break; // actual/actual convention ---------------------------------------------- case 1: let daysInYear = 365; const isSameYear = yearStart === yearEnd; const isOneDeltaYear = yearStart + 1 === yearEnd; const isMonthEndBigger = monthStart < monthEnd; const isSameMonth = monthStart === monthEnd; const isDayEndBigger = dayStart < dayEnd; // |-----| <-- one Year // 'A' is start date // 'B' is end date if ((!isSameYear && !isOneDeltaYear) || (!isSameYear && isMonthEndBigger) || (!isSameYear && isSameMonth && isDayEndBigger)) { // |---A-|-----|-B---| <-- !isSameYear && !isOneDeltaYear // |---A-|----B|-----| <-- !isSameYear && isMonthEndBigger // |---A-|---B-|-----| <-- !isSameYear && isSameMonth && isDayEndBigger let countYears = 0; let countDaysInYears = 0; for (let y = yearStart; y <= yearEnd; y++) { countYears++; countDaysInYears += isLeapYear(y) ? 366 : 365; } daysInYear = countDaysInYears / countYears; } else if (!isSameYear) { // |-AF--|B----|-----| if (isLeapYear(yearStart) && monthStart < 2) { daysInYear = 366; } // |--A--|FB---|-----| if (isLeapYear(yearEnd) && (monthEnd > 1 || (monthEnd === 1 && dayEnd === 29))) { daysInYear = 366; } } else { // remaining cases: // // |-F-AB|-----|-----| // |AB-F-|-----|-----| // |A-F-B|-----|-----| // if February 29 occurs between date1 (exclusive) and date2 (inclusive) // daysInYear --> 366 if (isLeapYear(yearStart)) { daysInYear = 366; } } yearsStart = startDate / daysInYear; yearsEnd = endDate / daysInYear; break; // actual/360 convention ------------------------------------------------- case 2: yearsStart = startDate / 360; yearsEnd = endDate / 360; break; // actual/365 convention ------------------------------------------------- case 3: yearsStart = startDate / 365; yearsEnd = endDate / 365; break; // 30/360 European convention -------------------------------------------- case 4: if (dayStart === 31) dayStart = 30; if (dayEnd === 31) dayEnd = 30; yearsStart = yearStart + (monthStart * 30 + dayStart) / 360; yearsEnd = yearEnd + (monthEnd * 30 + dayEnd) / 360; break; } return yearsEnd - yearsStart; } /** * Get the number of whole months between two dates. * e.g. * 2002/01/01 -> 2002/02/01 = 1 month, * 2002/01/01 -> 2003/02/01 = 13 months * @param startDate * @param endDate * @returns */ function getTimeDifferenceInWholeMonths(startDate, endDate) { const months = (endDate.getFullYear() - startDate.getFullYear()) * 12 + endDate.getMonth() - startDate.getMonth(); return startDate.getDate() > endDate.getDate() ? months - 1 : months; } function getTimeDifferenceInWholeDays(startDate, endDate) { const startUtc = startDate.getTime(); const endUtc = endDate.getTime(); return Math.floor((endUtc - startUtc) / MS_PER_DAY); } function getTimeDifferenceInWholeYears(startDate, endDate) { const years = endDate.getFullYear() - startDate.getFullYear(); const monthStart = startDate.getMonth(); const monthEnd = endDate.getMonth(); const dateStart = startDate.getDate(); const dateEnd = endDate.getDate(); const isEndMonthDateBigger = monthEnd > monthStart || (monthEnd === monthStart && dateEnd >= dateStart); return isEndMonthDateBigger ? years : years - 1; } function areTwoDatesWithinOneYear(startDate, endDate) { return getYearFrac(startDate, endDate, 1) < 1; } function areDatesSameDay(startDate, endDate) { return Math.trunc(startDate) === Math.trunc(endDate); } function isDateBetween(date, startDate, endDate) { if (startDate > endDate) { return isDateBetween(date, endDate, startDate); } date = Math.trunc(date); startDate = Math.trunc(startDate); endDate = Math.trunc(endDate); return date >= startDate && date <= endDate; } /** Check if the first date is strictly before the second date */ function isDateStrictlyBefore(date, dateBefore) { return Math.trunc(date) < Math.trunc(dateBefore); } /** Check if the first date is before or equal to the second date */ function isDateBefore(date, dateBefore) { return Math.trunc(date) <= Math.trunc(dateBefore); } /** Check if the first date is strictly after the second date */ function isDateStrictlyAfter(date, dateAfter) { return Math.trunc(date) > Math.trunc(dateAfter); } /** Check if the first date is after or equal to the second date */ function isDateAfter(date, dateAfter) { return Math.trunc(date) >= Math.trunc(dateAfter); } /** * This function returns a regexp that is supposed to be as close as possible as the numberRegexp, * but its purpose is to be used by the tokenizer. * * - it tolerates extra characters at the end. This is useful because the tokenizer * only needs to find the number at the start of a string * - it does not support % symbol, in formulas % is an operator */ const getFormulaNumberRegex = memoize(function getFormulaNumberRegex(decimalSeparator) { decimalSeparator = escapeRegExp(decimalSeparator); return new RegExp(`(?:^-?\\d+(?:${decimalSeparator}?\\d*(?:e\\d+)?)?|^-?${decimalSeparator}\\d+)(?!\\w|!)`); }); const getNumberRegex = memoize(function getNumberRegex(locale) { const decimalSeparator = escapeRegExp(locale.decimalSeparator); const thousandsSeparator = escapeRegExp(locale.thousandsSeparator || ""); const pIntegerAndDecimals = `(?:\\d+(?:${thousandsSeparator}\\d{3,})*(?:${decimalSeparator}\\d*)?)`; // pattern that match integer number with or without decimal digits const pOnlyDecimals = `(?:${decimalSeparator}\\d+)`; // pattern that match only expression with decimal digits const pScientificFormat = "(?:e(?:\\+|-)?\\d+)?"; // pattern that match scientific format between zero and one time (should be placed before pPercentFormat) const pPercentFormat = "(?:\\s*%)?"; // pattern that match percent symbol between zero and one time const pNumber = "(?:\\s*" + pIntegerAndDecimals + "|" + pOnlyDecimals + ")" + pScientificFormat + pPercentFormat; const pMinus = "(?:\\s*-)?"; // pattern that match negative symbol between zero and one time const pCurrencyFormat = "(?:\\s*[\\$€])?"; const p1 = pMinus + pCurrencyFormat + pNumber; const p2 = pMinus + pNumber + pCurrencyFormat; const p3 = pCurrencyFormat + pMinus + pNumber; const pNumberExp = "^(?:(?:" + [p1, p2, p3].join(")|(?:") + "))$"; const numberRegexp = new RegExp(pNumberExp, "i"); return numberRegexp; }); /** * Return true if the argument is a "number string". * * Note that "" (empty string) does not count as a number string */ function isNumber(value, locale) { if (!value) return false; // TO DO: add regexp for DATE string format (ex match: "28 02 2020") return getNumberRegex(locale).test(value.trim()); } const getInvaluableSymbolsRegexp = memoize(function getInvaluableSymbolsRegexp(locale) { return new RegExp(`[\$€${escapeRegExp(locale.thousandsSeparator || "")}]`, "g"); }); /** * Convert a string into a number. It assumes that the string actually represents * a number (as determined by the isNumber function) * * Note that it accepts "" (empty string), even though it does not count as a * number from the point of view of the isNumber function. */ function parseNumber(str, locale) { if (locale.decimalSeparator !== ".") { str = str.replace(locale.decimalSeparator, "."); } // remove invaluable characters str = str.replace(getInvaluableSymbolsRegexp(locale), ""); let n = Number(str); if (isNaN(n) && str.includes("%")) { n = Number(str.split("%")[0]); if (!isNaN(n)) { return n / 100; } } return n; } function percentile(values, percent, isInclusive) { const sortedValues = [...values].sort((a, b) => a - b); let percentIndex = (sortedValues.length + (isInclusive ? -1 : 1)) * percent; if (!isInclusive) { percentIndex--; } if (Number.isInteger(percentIndex)) { return sortedValues[percentIndex]; } const indexSup = Math.ceil(percentIndex); const indexLow = Math.floor(percentIndex); return (sortedValues[indexSup] * (percentIndex - indexLow) + sortedValues[indexLow] * (indexSup - percentIndex)); } var CellValueType; (function (CellValueType) { CellValueType["boolean"] = "boolean"; CellValueType["number"] = "number"; CellValueType["text"] = "text"; CellValueType["empty"] = "empty"; CellValueType["error"] = "error"; })(CellValueType || (CellValueType = {})); var ClipboardMIMEType; (function (ClipboardMIMEType) { ClipboardMIMEType["PlainText"] = "text/plain"; ClipboardMIMEType["Html"] = "text/html"; })(ClipboardMIMEType || (ClipboardMIMEType = {})); function isSheetDependent(cmd) { return "sheetId" in cmd; } function isHeadersDependant(cmd) { return "dimension" in cmd && "sheetId" in cmd && "elements" in cmd; } function isTargetDependent(cmd) { return "target" in cmd && "sheetId" in cmd; } function isRangeDependant(cmd) { return "ranges" in cmd; } function isZoneDependent(cmd) { return "zone" in cmd; } function isPositionDependent(cmd) { return "col" in cmd && "row" in cmd && "sheetId" in cmd; } const invalidateEvaluationCommands = new Set([ "RENAME_SHEET", "DELETE_SHEET", "CREATE_SHEET", "DUPLICATE_SHEET", "ADD_COLUMNS_ROWS", "REMOVE_COLUMNS_ROWS", "UNDO", "REDO", "ADD_MERGE", "REMOVE_MERGE", "UPDATE_LOCALE", "ADD_PIVOT", "UPDATE_PIVOT", "INSERT_PIVOT", "RENAME_PIVOT", "REMOVE_PIVOT", "DUPLICATE_PIVOT", ]); const invalidateChartEvaluationCommands = new Set([ "EVALUATE_CELLS", "UPDATE_CELL", "UNHIDE_COLUMNS_ROWS", "HIDE_COLUMNS_ROWS", "GROUP_HEADERS", "UNGROUP_HEADERS", "FOLD_ALL_HEADER_GROUPS", "FOLD_HEADER_GROUP", "FOLD_HEADER_GROUPS_IN_ZONE", "UNFOLD_ALL_HEADER_GROUPS", "UNFOLD_HEADER_GROUP", "UNFOLD_HEADER_GROUPS_IN_ZONE", "UPDATE_TABLE", "UPDATE_FILTER", "UNDO", "REDO", ]); const invalidateDependenciesCommands = new Set(["MOVE_RANGES"]); const invalidateCFEvaluationCommands = new Set([ "DUPLICATE_SHEET", "EVALUATE_CELLS", "ADD_CONDITIONAL_FORMAT", "REMOVE_CONDITIONAL_FORMAT", "CHANGE_CONDITIONAL_FORMAT_PRIORITY", ]); const invalidateBordersCommands = new Set([ "AUTOFILL_CELL", "SET_BORDER", "SET_ZONE_BORDERS", ]); const readonlyAllowedCommands = new Set([ "START", "ACTIVATE_SHEET", "COPY", "RESIZE_SHEETVIEW", "SET_VIEWPORT_OFFSET", "EVALUATE_CELLS", "SET_FORMULA_VISIBILITY", "UPDATE_FILTER", ]); const coreTypes = new Set([ /** CELLS */ "UPDATE_CELL", "UPDATE_CELL_POSITION", "CLEAR_CELL", "CLEAR_CELLS", "DELETE_CONTENT", /** GRID SHAPE */ "ADD_COLUMNS_ROWS", "REMOVE_COLUMNS_ROWS", "RESIZE_COLUMNS_ROWS", "HIDE_COLUMNS_ROWS", "UNHIDE_COLUMNS_ROWS", "SET_GRID_LINES_VISIBILITY", "UNFREEZE_COLUMNS", "UNFREEZE_ROWS", "FREEZE_COLUMNS", "FREEZE_ROWS", "UNFREEZE_COLUMNS_ROWS", /** MERGE */ "ADD_MERGE", "REMOVE_MERGE", /** SHEETS MANIPULATION */ "CREATE_SHEET", "DELETE_SHEET", "DUPLICATE_SHEET", "MOVE_SHEET", "RENAME_SHEET", "COLOR_SHEET", "HIDE_SHEET", "SHOW_SHEET", /** RANGES MANIPULATION */ "MOVE_RANGES", /** CONDITIONAL FORMAT */ "ADD_CONDITIONAL_FORMAT", "REMOVE_CONDITIONAL_FORMAT", "CHANGE_CONDITIONAL_FORMAT_PRIORITY", /** FIGURES */ "CREATE_FIGURE", "DELETE_FIGURE", "UPDATE_FIGURE", /** FORMATTING */ "SET_FORMATTING", "CLEAR_FORMATTING", "SET_BORDER", "SET_ZONE_BORDERS", /** CHART */ "CREATE_CHART", "UPDATE_CHART", /** FILTERS */ "CREATE_TABLE", "REMOVE_TABLE", "UPDATE_TABLE", "CREATE_TABLE_STYLE", "REMOVE_TABLE_STYLE", /** IMAGE */ "CREATE_IMAGE", /** HEADER GROUP */ "GROUP_HEADERS", "UNGROUP_HEADERS", "UNFOLD_HEADER_GROUP", "FOLD_HEADER_GROUP", "FOLD_ALL_HEADER_GROUPS", "UNFOLD_ALL_HEADER_GROUPS", "UNFOLD_HEADER_GROUPS_IN_ZONE", "FOLD_HEADER_GROUPS_IN_ZONE", /** DATA VALIDATION */ "ADD_DATA_VALIDATION_RULE", "REMOVE_DATA_VALIDATION_RULE", /** MISC */ "UPDATE_LOCALE", /** PIVOT */ "ADD_PIVOT", "UPDATE_PIVOT", "INSERT_PIVOT", "RENAME_PIVOT", "REMOVE_PIVOT", "DUPLICATE_PIVOT", ]); function isCoreCommand(cmd) { return coreTypes.has(cmd.type); } function canExecuteInReadonly(cmd) { return readonlyAllowedCommands.has(cmd.type); } /** * Holds the result of a command dispatch. * The command may have been successfully dispatched or cancelled * for one or more reasons. */ class DispatchResult { reasons; constructor(results = []) { if (!Array.isArray(results)) { results = [results]; } results = [...new Set(results)]; this.reasons = results.filter((result) => result !== "Success" /* CommandResult.Success */); } /** * Static helper which returns a successful DispatchResult */ static get Success() { return SUCCESS; } get isSuccessful() { return this.reasons.length === 0; } /** * Check if the dispatch has been cancelled because of * the given reason. */ isCancelledBecause(reason) { return this.reasons.includes(reason); } } const SUCCESS = new DispatchResult(); var CommandResult; (function (CommandResult) { CommandResult["Success"] = "Success"; CommandResult["CancelledForUnknownReason"] = "CancelledForUnknownReason"; CommandResult["WillRemoveExistingMerge"] = "WillRemoveExistingMerge"; CommandResult["MergeIsDestructive"] = "MergeIsDestructive"; CommandResult["CellIsMerged"] = "CellIsMerged"; CommandResult["InvalidTarget"] = "InvalidTarget"; CommandResult["EmptyUndoStack"] = "EmptyUndoStack"; CommandResult["EmptyRedoStack"] = "EmptyRedoStack"; CommandResult["NotEnoughElements"] = "NotEnoughElements"; CommandResult["NotEnoughSheets"] = "NotEnoughSheets"; CommandResult["MissingSheetName"] = "MissingSheetName"; CommandResult["UnchangedSheetName"] = "UnchangedSheetName"; CommandResult["DuplicatedSheetName"] = "DuplicatedSheetName"; CommandResult["DuplicatedSheetId"] = "DuplicatedSheetId"; CommandResult["ForbiddenCharactersInSheetName"] = "ForbiddenCharactersInSheetName"; CommandResult["WrongSheetMove"] = "WrongSheetMove"; CommandResult["WrongSheetPosition"] = "WrongSheetPosition"; CommandResult["InvalidAnchorZone"] = "InvalidAnchorZone"; CommandResult["SelectionOutOfBound"] = "SelectionOutOfBound"; CommandResult["TargetOutOfSheet"] = "TargetOutOfSheet"; CommandResult["WrongCutSelection"] = "WrongCutSelection"; CommandResult["WrongPasteSelection"] = "WrongPasteSelection"; CommandResult["WrongPasteOption"] = "WrongPasteOption"; CommandResult["WrongFigurePasteOption"] = "WrongFigurePasteOption"; CommandResult["EmptyClipboard"] = "EmptyClipboard"; CommandResult["EmptyRange"] = "EmptyRange"; CommandResult["InvalidRange"] = "InvalidRange"; CommandResult["InvalidZones"] = "InvalidZones"; CommandResult["InvalidSheetId"] = "InvalidSheetId"; CommandResult["InvalidFigureId"] = "InvalidFigureId"; CommandResult["InputAlreadyFocused"] = "InputAlreadyFocused"; CommandResult["MaximumRangesReached"] = "MaximumRangesReached"; CommandResult["MinimumRangesReached"] = "MinimumRangesReached"; CommandResult["InvalidChartDefinition"] = "InvalidChartDefinition"; CommandResult["InvalidDataSet"] = "InvalidDataSet"; CommandResult["InvalidLabelRange"] = "InvalidLabelRange"; CommandResult["InvalidScorecardKeyValue"] = "InvalidScorecardKeyValue"; CommandResult["InvalidScorecardBaseline"] = "InvalidScorecardBaseline"; CommandResult["InvalidGaugeDataRange"] = "InvalidGaugeDataRange"; CommandResult["EmptyGaugeRangeMin"] = "EmptyGaugeRangeMin"; CommandResult["GaugeRangeMinNaN"] = "GaugeRangeMinNaN"; CommandResult["EmptyGaugeRangeMax"] = "EmptyGaugeRangeMax"; CommandResult["GaugeRangeMaxNaN"] = "GaugeRangeMaxNaN"; CommandResult["GaugeRangeMinBiggerThanRangeMax"] = "GaugeRangeMinBiggerThanRangeMax"; CommandResult["GaugeLowerInflectionPointNaN"] = "GaugeLowerInflectionPointNaN"; CommandResult["GaugeUpperInflectionPointNaN"] = "GaugeUpperInflectionPointNaN"; CommandResult["GaugeLowerBiggerThanUpper"] = "GaugeLowerBiggerThanUpper"; CommandResult["InvalidAutofillSelection"] = "InvalidAutofillSelection"; CommandResult["MinBiggerThanMax"] = "MinBiggerThanMax"; CommandResult["LowerBiggerThanUpper"] = "LowerBiggerThanUpper"; CommandResult["MidBiggerThanMax"] = "MidBiggerThanMax"; CommandResult["MinBiggerThanMid"] = "MinBiggerThanMid"; CommandResult["FirstArgMissing"] = "FirstArgMissing"; CommandResult["SecondArgMissing"] = "SecondArgMissing"; CommandResult["MinNaN"] = "MinNaN"; CommandResult["MidNaN"] = "MidNaN"; CommandResult["MaxNaN"] = "MaxNaN"; CommandResult["ValueUpperInflectionNaN"] = "ValueUpperInflectionNaN"; CommandResult["ValueLowerInflectionNaN"] = "ValueLowerInflectionNaN"; CommandResult["MinInvalidFormula"] = "MinInvalidFormula"; CommandResult["MidInvalidFormula"] = "MidInvalidFormula"; CommandResult["MaxInvalidFormula"] = "MaxInvalidFormula"; CommandResult["ValueUpperInvalidFormula"] = "ValueUpperInvalidFormula"; CommandResult["ValueLowerInvalidFormula"] = "ValueLowerInvalidFormula"; CommandResult["InvalidSortZone"] = "InvalidSortZone"; CommandResult["WaitingSessionConfirmation"] = "WaitingSessionConfirmation"; CommandResult["MergeOverlap"] = "MergeOverlap"; CommandResult["TooManyHiddenElements"] = "TooManyHiddenElements"; CommandResult["Readonly"] = "Readonly"; CommandResult["InvalidViewportSize"] = "InvalidViewportSize"; CommandResult["InvalidScrollingDirection"] = "InvalidScrollingDirection"; CommandResult["ViewportScrollLimitsReached"] = "ViewportScrollLimitsReached"; CommandResult["FigureDoesNotExist"] = "FigureDoesNotExist"; CommandResult["InvalidConditionalFormatId"] = "InvalidConditionalFormatId"; CommandResult["InvalidCellPopover"] = "InvalidCellPopover"; CommandResult["EmptyTarget"] = "EmptyTarget"; CommandResult["InvalidFreezeQuantity"] = "InvalidFreezeQuantity"; CommandResult["FrozenPaneOverlap"] = "FrozenPaneOverlap"; CommandResult["ValuesNotChanged"] = "ValuesNotChanged"; CommandResult["InvalidFilterZone"] = "InvalidFilterZone"; CommandResult["TableNotFound"] = "TableNotFound"; CommandResult["TableOverlap"] = "TableOverlap"; CommandResult["InvalidTableConfig"] = "InvalidTableConfig"; CommandResult["InvalidTableStyle"] = "InvalidTableStyle"; CommandResult["FilterNotFound"] = "FilterNotFound"; CommandResult["MergeInTable"] = "MergeInTable"; CommandResult["NonContinuousTargets"] = "NonContinuousTargets"; CommandResult["DuplicatedFigureId"] = "DuplicatedFigureId"; CommandResult["InvalidSelectionStep"] = "InvalidSelectionStep"; CommandResult["DuplicatedChartId"] = "DuplicatedChartId"; CommandResult["ChartDoesNotExist"] = "ChartDoesNotExist"; CommandResult["InvalidHeaderIndex"] = "InvalidHeaderIndex"; CommandResult["InvalidQuantity"] = "InvalidQuantity"; CommandResult["MoreThanOneColumnSelected"] = "MoreThanOneColumnSelected"; CommandResult["EmptySplitSeparator"] = "EmptySplitSeparator"; CommandResult["SplitWillOverwriteContent"] = "SplitWillOverwriteContent"; CommandResult["NoSplitSeparatorInSelection"] = "NoSplitSeparatorInSelection"; CommandResult["NoActiveSheet"] = "NoActiveSheet"; CommandResult["InvalidLocale"] = "InvalidLocale"; CommandResult["MoreThanOneRangeSelected"] = "MoreThanOneRangeSelected"; CommandResult["NoColumnsProvided"] = "NoColumnsProvided"; CommandResult["ColumnsNotIncludedInZone"] = "ColumnsNotIncludedInZone"; CommandResult["DuplicatesColumnsSelected"] = "DuplicatesColumnsSelected"; CommandResult["InvalidHeaderGroupStartEnd"] = "InvalidHeaderGroupStartEnd"; CommandResult["HeaderGroupAlreadyExists"] = "HeaderGroupAlreadyExists"; CommandResult["UnknownHeaderGroup"] = "UnknownHeaderGroup"; CommandResult["UnknownDataValidationRule"] = "UnknownDataValidationRule"; CommandResult["UnknownDataValidationCriterionType"] = "UnknownDataValidationCriterionType"; CommandResult["InvalidDataValidationCriterionValue"] = "InvalidDataValidationCriterionValue"; CommandResult["InvalidNumberOfCriterionValues"] = "InvalidNumberOfCriterionValues"; CommandResult["InvalidCopyPasteSelection"] = "InvalidCopyPasteSelection"; CommandResult["NoChanges"] = "NoChanges"; CommandResult["InvalidInputId"] = "InvalidInputId"; CommandResult["SheetIsHidden"] = "SheetIsHidden"; CommandResult["InvalidTableResize"] = "InvalidTableResize"; CommandResult["PivotIdNotFound"] = "PivotIdNotFound"; CommandResult["EmptyName"] = "EmptyName"; CommandResult["ValueCellIsInvalidFormula"] = "ValueCellIsInvalidFormula"; CommandResult["InvalidDefinition"] = "InvalidDefinition"; CommandResult["InvalidColor"] = "InvalidColor"; })(CommandResult || (CommandResult = {})); const DEFAULT_LOCALES = [ { name: "English (US)", code: "en_US", thousandsSeparator: ",", decimalSeparator: ".", weekStart: 7, // Sunday dateFormat: "m/d/yyyy", timeFormat: "hh:mm:ss a", formulaArgSeparator: ",", }, { name: "French", code: "fr_FR", thousandsSeparator: " ", decimalSeparator: ",", weekStart: 1, // Monday dateFormat: "dd/mm/yyyy", timeFormat: "hh:mm:ss", formulaArgSeparator: ";", }, ]; const DEFAULT_LOCALE = DEFAULT_LOCALES[0]; const borderStyles = ["thin", "medium", "thick", "dashed", "dotted"]; function isMatrix(x) { return Array.isArray(x) && Array.isArray(x[0]); } var DIRECTION; (function (DIRECTION) { DIRECTION["UP"] = "up"; DIRECTION["DOWN"] = "down"; DIRECTION["LEFT"] = "left"; DIRECTION["RIGHT"] = "right"; })(DIRECTION || (DIRECTION = {})); const LAYERS = { Background: 0, Highlights: 1, Clipboard: 2, Chart: 4, Autofill: 5, Selection: 6, Headers: 100, // ensure that we end up on top }; const OrderedLayers = memoize(() => Object.keys(LAYERS).sort((a, b) => LAYERS[a] - LAYERS[b])); /** * * @param layer New layer name * @param priority The lower priorities are rendered first */ function addRenderingLayer(layer, priority) { if (LAYERS[layer]) { throw new Error(`Layer ${layer} already exists`); } LAYERS[layer] = priority; } const CellErrorType = { NotAvailable: "#N/A", InvalidReference: "#REF", BadExpression: "#BAD_EXPR", CircularDependency: "#CYCLE", UnknownFunction: "#NAME?", DivisionByZero: "#DIV/0!", SpilledBlocked: "#SPILL!", GenericError: "#ERROR", NullError: "#NULL!", }; const errorTypes = new Set(Object.values(CellErrorType)); class EvaluationError extends Error { value; constructor(message = _t("Error"), value = CellErrorType.GenericError) { super(message); this.value = value; } } class BadExpressionError extends EvaluationError { constructor(message = _t("Invalid expression")) { super(message, CellErrorType.BadExpression); } } class CircularDependencyError extends EvaluationError { constructor(message = _t("Circular reference")) { super(message, CellErrorType.CircularDependency); } } class InvalidReferenceError extends EvaluationError { constructor(message = _t("Invalid reference")) { super(message, CellErrorType.InvalidReference); } } class NotAvailableError extends EvaluationError { constructor(message = _t("Data not available")) { super(message, CellErrorType.NotAvailable); } } class UnknownFunctionError extends EvaluationError { constructor(message = _t("Unknown function")) { super(message, CellErrorType.UnknownFunction); } } class SplillBlockedError extends EvaluationError { constructor(message = _t("Spill range is not empty")) { super(message, CellErrorType.SpilledBlocked); } } // HELPERS const SORT_TYPES_ORDER = ["number", "string", "boolean", "undefined"]; function assert(condition, message, value) { if (!condition()) { throw new EvaluationError(message, value); } } function inferFormat(data) { if (data === undefined) { return undefined; } if (isMatrix(data)) { return data[0][0]?.format; } return data.format; } function isEvaluationError(error) { return typeof error === "string" && errorTypes.has(error); } // ----------------------------------------------------------------------------- // FORMAT FUNCTIONS // ----------------------------------------------------------------------------- const expectNumberValueError = (value) => _t("The function [[FUNCTION_NAME]] expects a number value, but '%s' is a string, and cannot be coerced to a number.", value); const expectNumberRangeError = (lowerBound, upperBound, value) => _t("The function [[FUNCTION_NAME]] expects a number value between %s and %s inclusive, but receives %s.", lowerBound.toString(), upperBound.toString(), value.toString()); const expectStringSetError = (stringSet, value) => { const stringSetString = stringSet.map((str) => `'${str}'`).join(", "); return _t("The function [[FUNCTION_NAME]] has an argument with value '%s'. It should be one of: %s.", value, stringSetString); }; function toNumber(data, locale) { const value = toValue(data); switch (typeof value) { case "number": return value; case "boolean": return value ? 1 : 0; case "string": if (isNumber(value, locale) || value === "") { return parseNumber(value, locale); } const internalDate = parseDateTime(value, locale); if (internalDate) { return internalDate.value; } throw new EvaluationError(expectNumberValueError(value)); default: return 0; } } function tryToNumber(value, locale) { try { return toNumber(value, locale); } catch (e) { return undefined; } } function toNumberMatrix(data, argName) { return toMatrix(data).map((row) => { return row.map((cell) => { if (typeof cell.value !== "number") { throw new EvaluationError(_t("Function [[FUNCTION_NAME]] expects number values for %s, but got a %s.", argName, typeof cell.value)); } return cell.value; }); }); } function strictToNumber(data, locale) { const value = toValue(data); if (value === "") { throw new EvaluationError(expectNumberValueError(value)); } return toNumber(value, locale); } function toInteger(value, locale) { return Math.trunc(toNumber(value, locale)); } function strictToInteger(value, locale) { return Math.trunc(strictToNumber(value, locale)); } function assertNumberGreaterThanOrEqualToOne(value) { assert(() => value >= 1, _t("The function [[FUNCTION_NAME]] expects a number value to be greater than or equal to 1, but receives %s.", value.toString())); } function assertNotZero(value) { assert(() => value !== 0, _t("Evaluation of function [[FUNCTION_NAME]] caused a divide by zero error."), CellErrorType.DivisionByZero); } function toString(data) { const value = toValue(data); switch (typeof value) { case "string": return value; case "number": return value.toString(); case "boolean": return value ? "TRUE" : "FALSE"; default: return ""; } } /** Normalize string by setting it to lowercase and replacing accent letters with plain letters */ const normalizeString = memoize(function normalizeString(str) { return str .toLowerCase() .normalize("NFD") .replace(/[\u0300-\u036f]/g, ""); }); const expectBooleanValueError = (value) => _t("The function [[FUNCTION_NAME]] expects a boolean value, but '%s' is a text, and cannot be coerced to a boolean.", value); function toBoolean(data) { const value = toValue(data); switch (typeof value) { case "boolean": return value; case "string": if (value) { let uppercaseVal = value.toUpperCase(); if (uppercaseVal === "TRUE") { return true; } if (uppercaseVal === "FALSE") { return false; } throw new EvaluationError(expectBooleanValueError(value)); } else { return false; } case "number": return value ? true : false; default: return false; } } function strictToBoolean(data) { const value = toValue(data); if (value === "") { throw new EvaluationError(expectBooleanValueError(value)); } return toBoolean(value); } function toJsDate(data, locale) { const value = toValue(data); return numberToJsDate(toNumber(value, locale)); } function toValue(data) { if (typeof data === "object" && data !== null && "value" in data) { if (isEvaluationError(data.value)) { throw data; } return data.value; } if (isEvaluationError(data)) { throw new EvaluationError("", data); } return data; } // ----------------------------------------------------------------------------- // VISIT FUNCTIONS // ----------------------------------------------------------------------------- function visitArgs(args, cellCb, dataCb) { for (let arg of args) { if (isMatrix(arg)) { // arg is ref to a Cell/Range const lenRow = arg.length; const lenCol = arg[0].length; for (let y = 0; y < lenCol; y++) { for (let x = 0; x < lenRow; x++) { cellCb(arg[x][y]); } } } else { // arg is set directly in the formula function dataCb(arg); } } } function visitAny(args, cb) { visitArgs(args, (cell) => { if (isEvaluationError(cell.value)) { throw cell; } cb(cell); }, (arg) => { if (isEvaluationError(arg?.value)) { throw arg; } cb(arg); }); } function visitNumbers(args, cb, locale) { visitArgs(args, (cell) => { if (typeof cell?.value === "number") { cb(cell); } if (isEvaluationError(cell?.value)) { throw cell; } }, (arg) => { cb({ value: strictToNumber(arg, locale), format: arg?.format }); }); } // ----------------------------------------------------------------------------- // REDUCE FUNCTIONS // ----------------------------------------------------------------------------- function reduceArgs(args, cellCb, dataCb, initialValue, dir = "rowFirst") { let val = initialValue; for (let arg of args) { if (isMatrix(arg)) { // arg is ref to a Cell/Range const numberOfCols = arg.length; const numberOfRows = arg[0].length; if (dir === "rowFirst") { for (let row = 0; row < numberOfRows; row++) { for (let col = 0; col < numberOfCols; col++) { val = cellCb(val, arg[col][row]); } } } else { for (let col = 0; col < numberOfCols; col++) { for (let row = 0; row < numberOfRows; row++) { val = cellCb(val, arg[col][row]); } } } } else { // arg is set directly in the formula function val = dataCb(val, arg); } } return val; } function reduceAny(args, cb, initialValue, dir = "rowFirst") { return reduceArgs(args, cb, cb, initialValue, dir); } function reduceNumbers(args, cb, initialValue, locale) { return reduceArgs(args, (acc, arg) => { const argValue = arg?.value; if (typeof argValue === "number") { return cb(acc, argValue); } else if (isEvaluationError(argValue)) { throw arg; } return acc; }, (acc, arg) => { return cb(acc, strictToNumber(arg, locale)); }, initialValue); } function reduceNumbersTextAs0(args, cb, initialValue, locale) { return reduceArgs(args, (acc, arg) => { const argValue = arg?.value; if (argValue !== undefined && argValue !== null) { if (typeof argValue === "number") { return cb(acc, argValue); } else if (typeof argValue === "boolean") { return cb(acc, toNumber(argValue, locale)); } else if (isEvaluationError(argValue)) { throw arg; } else { return cb(acc, 0); } } return acc; }, (acc, arg) => { return cb(acc, toNumber(arg, locale)); }, initialValue); } // ----------------------------------------------------------------------------- // MATRIX FUNCTIONS // ----------------------------------------------------------------------------- /** * Generate a matrix of size nColumns x nRows and apply a callback on each position */ function generateMatrix(nColumns, nRows, callback) { const returned = Array(nColumns); for (let col = 0; col < nColumns; col++) { returned[col] = Array(nRows); for (let row = 0; row < nRows; row++) { returned[col][row] = callback(col, row); } } return returned; } function matrixMap(matrix, callback) { if (matrix.length === 0) { return []; } return generateMatrix(matrix.length, matrix[0].length, (col, row) => callback(matrix[col][row])); } function matrixForEach(matrix, fn) { const numberOfCols = matrix.length; const numberOfRows = matrix[0]?.length ?? 0; for (let col = 0; col < numberOfCols; col++) { for (let row = 0; row < numberOfRows; row++) { fn(matrix[col][row]); } } } function transposeMatrix(matrix) { if (!matrix.length) { return []; } return generateMatrix(matrix[0].length, matrix.length, (i, j) => matrix[j][i]); } // ----------------------------------------------------------------------------- // CONDITIONAL EXPLORE FUNCTIONS // ----------------------------------------------------------------------------- /** * This function allows to visit arguments and stop the visit if necessary. * It is mainly used to bypass argument evaluation for functions like OR or AND. */ function conditionalVisitArgs(args, cellCb, dataCb) { for (let arg of args) { if (isMatrix(arg)) { // arg is ref to a Cell/Range const lenRow = arg.length; const lenCol = arg[0].length; for (let y = 0; y < lenCol; y++) { for (let x = 0; x < lenRow; x++) { if (!cellCb(arg[x][y] ?? undefined)) return; } } } else { // arg is set directly in the formula function if (!dataCb(arg)) return; } } } function conditionalVisitBoolean(args, cb) { return conditionalVisitArgs(args, (arg) => { const argValue = arg?.value; if (typeof argValue === "boolean") { return cb(argValue); } if (typeof argValue === "number") { return cb(argValue ? true : false); } if (isEvaluationError(argValue)) { throw arg; } return true; }, (arg) => { if (arg !== undefined && arg.value !== null) { return cb(strictToBoolean(arg)); } return true; }); } function getPredicate(descr, locale) { let operator; let operand; let subString = descr.substring(0, 2); if (subString === "<=" || subString === ">=" || subString === "<>") { operator = subString; operand = descr.substring(2); } else { subString = descr.substring(0, 1); if (subString === "<" || subString === ">" || subString === "=") { operator = subString; operand = descr.substring(1); } else { operator = "="; operand = descr; } } if (isNumber(operand, locale) || isDateTime(operand, locale)) { operand = toNumber(operand, locale); } else if (operand === "TRUE" || operand === "FALSE") { operand = toBoolean(operand); } return { operator, operand }; } /** * Converts a search string containing wildcard characters to a regular expression. * * The function iterates over each character in the input string. If the character is a wildcard * character ("?" or "*") and is not preceded by a "~", it is replaced by the corresponding regular * expression. * If the character is a special regular expression character, it is escaped with "\\". */ const wildcardToRegExp = memoize(function wildcardToRegExp(operand) { if (operand === "*") { return /.+/; } let exp = ""; let predecessor = ""; for (let char of operand) { if (char === "?" && predecessor !== "~") { exp += "."; } else if (char === "*" && predecessor !== "~") { exp += ".*"; } else { if (char === "*" || char === "?") { //remove "~" exp = exp.slice(0, -1); } if (["^", ".", "[", "]", "$", "(", ")", "*", "+", "?", "|", "{", "}", "\\"].includes(char)) { exp += "\\"; } exp += char; } predecessor = char; } return new RegExp("^" + exp + "$", "i"); }); function evaluatePredicate(value = "", criterion, locale) { const { operator, operand } = criterion; if (operand === undefined || value === null || operand === null) { return false; } if (typeof operand === "number" && operator === "=") { if (typeof value === "string" && (isNumber(value, locale) || isDateTime(value, locale))) { return toNumber(value, locale) === operand; } return value === operand; } if (operator === "<>" || operator === "=") { let result; if (typeof value === typeof operand) { if (typeof value === "string" && typeof operand === "string") { result = wildcardToRegExp(operand).test(value); } else { result = value === operand; } } else { result = false; } return operator === "=" ? result : !result; } if (typeof value === typeof operand) { switch (operator) { case "<": return value < operand; case ">": return value > operand; case "<=": return value <= operand; case ">=": return value >= operand; } } return false; } /** * Functions used especially for predicate evaluation on ranges. * * Take ranges with same dimensions and take predicates, one for each range. * For (i, j) coordinates, if all elements with coordinates (i, j) of each * range correspond to the associated predicate, then the function uses a callback * function with the parameters "i" and "j". * * Syntax: * visitMatchingRanges([range1, predicate1, range2, predicate2, ...], cb(i,j), likeSelection) * * - range1 (range): The range to check against predicate1. * - predicate1 (string): The pattern or test to apply to range1. * - range2: (range, repeatable) ranges to check. * - predicate2 (string, repeatable): Additional pattern or test to apply to range2. * * - cb(i: number, j: number) => void: the callback function. * * - isQuery (boolean) indicates if the comparison with a string should be done as a SQL-like query. * (Ex1 isQuery = true, predicate = "abc", element = "abcde": predicate match the element), * (Ex2 isQuery = false, predicate = "abc", element = "abcde": predicate not match the element). * (Ex3 isQuery = true, predicate = "abc", element = "abc": predicate match the element), * (Ex4 isQuery = false, predicate = "abc", element = "abc": predicate match the element). */ function visitMatchingRanges(args, cb, locale, isQuery = false) { const countArg = args.length; if (countArg % 2 === 1) { throw new EvaluationError(_t("Function [[FUNCTION_NAME]] expects criteria_range and criterion to be in pairs.")); } const firstArg = toMatrix(args[0]); const dimRow = firstArg.length; const dimCol = firstArg[0].length; let predicates = []; for (let i = 0; i < countArg - 1; i += 2) { const criteriaRange = toMatrix(args[i]); if (criteriaRange.length !== dimRow || criteriaRange[0].length !== dimCol) { throw new EvaluationError(_t("Function [[FUNCTION_NAME]] expects criteria_range to have the same dimension")); } const description = toString(args[i + 1]); const predicate = getPredicate(description, locale); if (isQuery && typeof predicate.operand === "string") { predicate.operand += "*"; } predicates.push(predicate); } for (let i = 0; i < dimRow; i++) { for (let j = 0; j < dimCol; j++) { let validatedPredicates = true; for (let k = 0; k < countArg - 1; k += 2) { const criteriaValue = toMatrix(args[k])[i][j].value; const criterion = predicates[k / 2]; validatedPredicates = evaluatePredicate(criteriaValue ?? undefined, criterion, locale); if (!validatedPredicates) { break; } } if (validatedPredicates) { cb(i, j); } } } } // ----------------------------------------------------------------------------- // COMMON FUNCTIONS // ----------------------------------------------------------------------------- /** * Perform a dichotomic search on an array and return the index of the nearest match. * * The array should be sorted, if not an incorrect value might be returned. In the case where multiple * element of the array match the target, the method will return the first match if the array is sorted * in descending order, and the last match if the array is in ascending order. * * * @param data the array in which to search. * @param target the value to search. * @param mode "nextGreater/nextSmaller" : return next greater/smaller value if no exact match is found. * @param sortOrder whether the array is sorted in ascending or descending order. * @param rangeLength the number of elements to consider in the search array. * @param getValueInData function returning the element at index i in the search array. */ function dichotomicSearch(data, target, mode, sortOrder, rangeLength, getValueInData) { if (target === undefined || target.value === null) { return -1; } if (isEvaluationError(target.value)) { throw target; } const _target = normalizeValue(target.value); const targetType = typeof _target; let matchVal = undefined; let matchValIndex = undefined; let indexLeft = 0; let indexRight = rangeLength - 1; let indexMedian; let currentIndex; let currentVal; let currentType; while (indexRight - indexLeft >= 0) { indexMedian = Math.floor((indexLeft + indexRight) / 2); currentIndex = indexMedian; currentVal = normalizeValue(getValueInData(data, currentIndex)); currentType = typeof currentVal; // 1 - linear search to find value with the same type while (indexLeft < currentIndex && targetType !== currentType) { currentIndex--; currentVal = normalizeValue(getValueInData(data, currentIndex)); currentType = typeof currentVal; } if (currentType !== targetType || currentVal === undefined || currentVal === null) { indexLeft = indexMedian + 1; continue; } // 2 - check if value match if (mode === "strict" && currentVal === _target) { matchVal = currentVal; matchValIndex = currentIndex; } else if (mode === "nextSmaller" && currentVal <= _target) { if (matchVal === undefined || matchVal === null || matchVal < currentVal || (matchVal === currentVal && sortOrder === "asc" && matchValIndex < currentIndex) || (matchVal === currentVal && sortOrder === "desc" && matchValIndex > currentIndex)) { matchVal = currentVal; matchValIndex = currentIndex; } } else if (mode === "nextGreater" && currentVal >= _target) { if (matchVal === undefined || matchVal > currentVal || (matchVal === currentVal && sortOrder === "asc" && matchValIndex < currentIndex) || (matchVal === currentVal && sortOrder === "desc" && matchValIndex > currentIndex)) { matchVal = currentVal; matchValIndex = currentIndex; } } // 3 - give new indexes for the Binary search if ((sortOrder === "asc" && currentVal > _target) || (sortOrder === "desc" && currentVal <= _target)) { indexRight = currentIndex - 1; } else { indexLeft = indexMedian + 1; } } // note that valMinIndex could be 0 return matchValIndex !== undefined ? matchValIndex : -1; } /** * Perform a linear search and return the index of the match. * -1 is returned if no value is found. * * Example: * - [3, 6, 10], 3 => 0 * - [3, 6, 10], 6 => 1 * - [3, 6, 10], 9 => -1 * - [3, 6, 10], 2 => -1 * * @param data the array to search in. * @param target the value to search in the array. * @param mode if "strict" return exact match index. "nextGreater" returns the next greater * element from the target and "nextSmaller" the next smaller * @param numberOfValues the number of elements to consider in the search array. * @param getValueInData function returning the element at index i in the search array. * @param reverseSearch if true, search in the array starting from the end. */ function linearSearch(data, target, mode, numberOfValues, getValueInData, reverseSearch = false) { if (target === undefined || target.value === null) { return -1; } if (isEvaluationError(target.value)) { throw target; } const _target = normalizeValue(target.value); const getValue = reverseSearch ? (data, i) => getValueInData(data, numberOfValues - i - 1) : getValueInData; let indexMatchTarget = (i) => { return normalizeValue(getValue(data, i)) === _target; }; if (mode === "wildcard" && typeof _target === "string" && (_target.includes("*") || _target.includes("?"))) { const regExp = wildcardToRegExp(_target); indexMatchTarget = (i) => { const value = normalizeValue(getValue(data, i)); if (typeof value === "string") { return regExp.test(value); } return false; }; } let closestMatch = undefined; let closestMatchIndex = -1; if (mode === "nextSmaller") { indexMatchTarget = (i) => { const value = normalizeValue(getValue(data, i)); if ((!closestMatch && compareCellValues(_target, value) >= 0) || (compareCellValues(_target, value) >= 0 && compareCellValues(value, closestMatch) > 0)) { closestMatch = value; closestMatchIndex = i; } return value === _target; }; } if (mode === "nextGreater") { indexMatchTarget = (i) => { const value = normalizeValue(getValue(data, i)); if ((!closestMatch && compareCellValues(_target, value) <= 0) || (compareCellValues(_target, value) <= 0 && compareCellValues(value, closestMatch) < 0)) { closestMatch = value; closestMatchIndex = i; } return value === _target; }; } for (let i = 0; i < numberOfValues; i++) { if (indexMatchTarget(i)) { return reverseSearch ? numberOfValues - i - 1 : i; } } return reverseSearch && closestMatchIndex !== -1 ? numberOfValues - closestMatchIndex - 1 : closestMatchIndex; } /** * Normalize a value. * If the cell value is a string, this will set it to lowercase and replacing accent letters with plain letters */ function normalizeValue(value) { return typeof value === "string" ? normalizeString(value) : value; } function compareCellValues(left, right) { let typeOrder = SORT_TYPES_ORDER.indexOf(typeof left) - SORT_TYPES_ORDER.indexOf(typeof right); if (typeOrder === 0) { if (typeof left === "string" && typeof right === "string") { typeOrder = left.localeCompare(right); } else if (typeof left === "number" && typeof right === "number") { typeOrder = left - right; } else if (typeof left === "boolean" && typeof right === "boolean") { typeOrder = Number(left) - Number(right); } } return typeOrder; } function toMatrix(data) { if (data === undefined) { return [[]]; } return isMatrix(data) ? data : [[data]]; } /** * Flatten an array of items, where each item can be a single value or a 2D array, and apply the * callback to each element. * * The 2D array are flattened row first. */ function flattenRowFirst(items, callback) { /**/ return reduceAny(items, (array, val) => { array.push(callback(val)); return array; }, [], "rowFirst"); } function isDataNonEmpty(data) { if (data === undefined) { return false; } const { value } = data; if (value === null || value === "") { return false; } return true; } function toCriterionDateNumber(dateValue) { const today = DateTime.now(); switch (dateValue) { case "today": return jsDateToNumber(today); case "yesterday": return jsDateToNumber(DateTime.fromTimestamp(today.setDate(today.getDate() - 1))); case "tomorrow": return jsDateToNumber(DateTime.fromTimestamp(today.setDate(today.getDate() + 1))); case "lastWeek": return jsDateToNumber(DateTime.fromTimestamp(today.setDate(today.getDate() - 7))); case "lastMonth": return jsDateToNumber(DateTime.fromTimestamp(today.setMonth(today.getMonth() - 1))); case "lastYear": return jsDateToNumber(DateTime.fromTimestamp(today.setFullYear(today.getFullYear() - 1))); } } /** Get all the dates values of a criterion converted to numbers, converting date values such as "today" to actual dates */ function getDateNumberCriterionValues(criterion, locale) { if ("dateValue" in criterion && criterion.dateValue !== "exactDate") { return [toCriterionDateNumber(criterion.dateValue)]; } return criterion.values.map((value) => valueToDateNumber(value, locale)); } /** Convert the criterion values to numbers. Return undefined values if they cannot be converted to numbers. */ function getCriterionValuesAsNumber(criterion, locale) { return criterion.values.map((value) => tryToNumber(value, locale)); } const MAX_DELAY = 140; const MIN_DELAY = 20; const ACCELERATION = 0.035; /** * Decreasing exponential function used to determine the "speed" of edge-scrolling * as the timeout delay. * * Returns a timeout delay in milliseconds. */ function scrollDelay(value) { // decreasing exponential from MAX_DELAY to MIN_DELAY return MIN_DELAY + (MAX_DELAY - MIN_DELAY) * Math.exp(-ACCELERATION * (value - 1)); } function tokenizeFormat(str) { const chars = new TokenizingChars(str); const result = []; let currentFormatPart = []; result.push(currentFormatPart); while (!chars.isOver()) { if (chars.current === ";") { currentFormatPart = []; result.push(currentFormatPart); chars.shift(); continue; } let token = tokenizeDigit(chars) || tokenizeString$1(chars) || tokenizeEscapedChars(chars) || tokenizeThousandsSeparator(chars) || tokenizeDecimalPoint(chars) || tokenizePercent(chars) || tokenizeDatePart(chars) || tokenizeTextPlaceholder(chars) || tokenizeRepeatedChar(chars); if (!token) { throw new Error("Unknown token at " + chars.remaining()); } currentFormatPart.push(token); } return result; } function tokenizeString$1(chars) { let enfOfStringChar; if (chars.current === '"') { chars.shift(); enfOfStringChar = '"'; } else if (chars.currentStartsWith("[$")) { chars.advanceBy(2); enfOfStringChar = "]"; } if (!enfOfStringChar) { return null; } let letters = ""; while (chars.current && chars.current !== enfOfStringChar) { letters += chars.shift(); } if (chars.current === enfOfStringChar) { chars.shift(); } else { throw new Error("Unterminated string in format"); } return { type: "STRING", value: letters, }; } const alwaysEscapedCharsInFormat = new Set("$+-/():!^&~{}<>= "); function tokenizeEscapedChars(chars) { if (chars.current === "\\") { chars.shift(); const escapedChar = chars.shift(); if (!escapedChar) { throw new Error("Unexpected end of format string"); } return { type: "CHAR", value: escapedChar, }; } if (alwaysEscapedCharsInFormat.has(chars.current)) { return { type: "CHAR", value: chars.shift(), }; } return null; } function tokenizeThousandsSeparator(chars) { if (chars.current === ",") { chars.shift(); return { type: "THOUSANDS_SEPARATOR", value: "," }; } return null; } function tokenizeTextPlaceholder(chars) { if (chars.current === "@") { chars.shift(); return { type: "TEXT_PLACEHOLDER", value: "@" }; } return null; } function tokenizeDecimalPoint(chars) { if (chars.current === ".") { chars.shift(); return { type: "DECIMAL_POINT", value: "." }; } return null; } function tokenizePercent(chars) { if (chars.current === "%") { chars.shift(); return { type: "PERCENT", value: "%" }; } return null; } function tokenizeDigit(chars) { if (chars.current === "0" || chars.current === "#") { const value = chars.current; chars.shift(); return { type: "DIGIT", value }; } return null; } const dateSymbols = new Set("dmqyhsa"); function tokenizeDatePart(chars) { if (!dateSymbols.has(chars.current)) { return null; } const char = chars.current; let value = ""; while (chars.current === char) { value += chars.shift(); } return { type: "DATE_PART", value }; } function tokenizeRepeatedChar(chars) { if (chars.current !== "*") { return null; } chars.shift(); const repeatedChar = chars.shift(); if (!repeatedChar) { throw new Error("Unexpected end of format string"); } return { type: "REPEATED_CHAR", value: repeatedChar, }; } /** * Constant used to indicate the maximum of digits that is possible to display * in a cell with standard size. */ const MAX_DECIMAL_PLACES = 20; const internalFormatCache = {}; function parseFormat(formatString) { let internalFormat = internalFormatCache[formatString]; if (internalFormat === undefined) { internalFormat = convertFormatToInternalFormat(formatString); internalFormatCache[formatString] = internalFormat; } return internalFormat; } function convertFormatToInternalFormat(format) { const formatParts = tokenizeFormat(format); // A format can only have a single REPEATED_CHAR token. The rest are converted to simple CHAR tokens. for (const part of formatParts) { const repeatedCharTokens = part.filter((token) => token.type === "REPEATED_CHAR"); for (const repeatedCharToken of repeatedCharTokens.slice(1)) { repeatedCharToken.type = "CHAR"; } } const positiveFormat = parseDateFormatTokens(formatParts[0]) || parseNumberFormatTokens(formatParts[0]) || tokensToTextInternalFormat(formatParts[0]); if (!positiveFormat) { throw new Error("Invalid first format part of: " + format); } if (formatParts.length > 1 && positiveFormat.type === "text") { throw new Error("The first format in a multi-part format must be a number format: " + format); } const negativeFormat = parseDateFormatTokens(formatParts[1]) || parseNumberFormatTokens(formatParts[1]); if (formatParts[1]?.length && !negativeFormat) { throw new Error("Invalid second format part of: " + format); } const zeroFormat = parseDateFormatTokens(formatParts[2]) || parseNumberFormatTokens(formatParts[2]); if (formatParts[2]?.length && !zeroFormat) { throw new Error("Invalid third format part of: " + format); } const textFormat = tokensToTextInternalFormat(formatParts[3]); if (formatParts[3]?.length && !textFormat) { throw new Error("Invalid fourth format part of: " + format); } return { positive: positiveFormat, negative: negativeFormat, zero: zeroFormat, text: textFormat }; } function areValidDateFormatTokens(tokens) { return tokens.every((token) => token.type === "DATE_PART" || token.type === "DECIMAL_POINT" || token.type === "THOUSANDS_SEPARATOR" || token.type === "STRING" || token.type === "CHAR" || token.type === "REPEATED_CHAR"); } function areValidNumberFormatTokens(tokens) { return tokens.every((token) => token.type === "DIGIT" || token.type === "DECIMAL_POINT" || token.type === "THOUSANDS_SEPARATOR" || token.type === "PERCENT" || token.type === "STRING" || token.type === "CHAR" || token.type === "REPEATED_CHAR"); } function areValidTextFormatTokens(tokens) { return tokens.every((token) => token.type === "STRING" || token.type === "TEXT_PLACEHOLDER" || token.type === "CHAR" || token.type === "REPEATED_CHAR"); } function parseNumberFormatTokens(tokens) { if (!tokens || !areValidNumberFormatTokens(tokens)) { return undefined; } const integerPart = []; let decimalPart = undefined; let parsedPart = integerPart; let percentSymbols = 0; let magnitude = 0; let lastIndexOfDigit = tokens.findLastIndex((token) => token.type === "DIGIT"); let hasThousandSeparator = false; let numberOfDecimalsDigits = 0; for (let i = 0; i < tokens.length; i++) { const token = tokens[i]; switch (token.type) { case "DIGIT": if (parsedPart === integerPart) { parsedPart.push(token); } else if (numberOfDecimalsDigits < MAX_DECIMAL_PLACES) { parsedPart.push(token); numberOfDecimalsDigits++; } break; case "DECIMAL_POINT": if (parsedPart === integerPart) { decimalPart = []; parsedPart = decimalPart; } else { throw new Error("Multiple decimal points in a number format"); } break; case "REPEATED_CHAR": case "CHAR": case "STRING": parsedPart.push(token); break; case "PERCENT": percentSymbols++; parsedPart.push(token); break; // Per OpenXML Spec: // - If a comma is between two DIGIT tokens, and in the integer part, a thousand separator is applied in the formatted value. // - If a comma is at the end of the number placeholder, the number is divided by a thousand. // - Otherwise, it's a string. case "THOUSANDS_SEPARATOR": if (i - 1 === lastIndexOfDigit) { magnitude += 1; lastIndexOfDigit++; // Can have multiple commas in a row parsedPart.push(token); } else if (tokens[i + 1]?.type === "DIGIT" && tokens[i - 1]?.type === "DIGIT") { if (parsedPart === integerPart) { hasThousandSeparator = true; } parsedPart.push(token); } else { parsedPart.push({ type: "CHAR", value: "," }); } break; } } return { type: "number", integerPart, decimalPart, percentSymbols, thousandsSeparator: hasThousandSeparator, magnitude, }; } function parseDateFormatTokens(tokens) { const internalFormat = tokens && areValidDateFormatTokens(tokens) ? { type: "date", tokens } : undefined; if (!internalFormat) { return undefined; } if (internalFormat.tokens.length && internalFormat.tokens.every((token) => token.type === "DATE_PART" && token.value === "a")) { throw new Error("Invalid date format"); } const dateTokens = internalFormat.tokens.map((token) => { if (token.type === "THOUSANDS_SEPARATOR" || token.type === "DECIMAL_POINT") { return { type: "CHAR", value: token.value }; } return token; }); const convertedTokens = convertTokensToMinutesInDateFormat(dateTokens); return { type: "date", tokens: convertedTokens }; } function tokensToTextInternalFormat(tokens) { return tokens && areValidTextFormatTokens(tokens) ? { type: "text", tokens } : undefined; } /** * Replace in place tokens "mm" and "m" that denote minutes in date format with "MM" to avoid confusion with months. * * As per OpenXML specification, in date formats if a date token "m" or "mm" is followed by a date token "s" or * preceded by a data token "h", then it's not a month but an minute. */ function convertTokensToMinutesInDateFormat(tokens) { const dateParts = tokens.filter((token) => token.type === "DATE_PART"); for (let i = 0; i < dateParts.length; i++) { if (!dateParts[i].value.startsWith("m") || dateParts[i].value.length > 2) { continue; } if (dateParts[i - 1]?.value.startsWith("h") || dateParts[i + 1]?.value.startsWith("s")) { dateParts[i].value = dateParts[i].value.replaceAll("m", "M"); } } return tokens; } function convertInternalFormatToFormat(internalFormat) { return [ internalFormatPartToFormat(internalFormat.positive), internalFormatPartToFormat(internalFormat.negative), internalFormatPartToFormat(internalFormat.zero), internalFormatPartToFormat(internalFormat.text), ] .filter(isDefined) .join(";"); } function internalFormatPartToFormat(internalFormat) { if (!internalFormat) { return undefined; } let format = ""; const tokens = internalFormat.type !== "number" ? internalFormat.tokens : numberInternalFormatToTokenList(internalFormat); for (let token of tokens) { switch (token.type) { case "STRING": format += `[$${token.value}]`; break; case "CHAR": format += shouldEscapeFormatChar(token.value) ? `\\${token.value}` : token.value; break; case "REPEATED_CHAR": format += "*" + token.value; break; default: format += token.value; } } return format; } function numberInternalFormatToTokenList(internalFormat) { let tokens = [...internalFormat.integerPart]; if (internalFormat.decimalPart) { tokens.push({ type: "DECIMAL_POINT", value: "." }); tokens.push(...internalFormat.decimalPart); } return tokens; } function shouldEscapeFormatChar(char) { return !alwaysEscapedCharsInFormat.has(char); } /** * Number of digits for the default number format. This number of digit make a number fit well in a cell * with default size and default font size. */ const DEFAULT_FORMAT_NUMBER_OF_DIGITS = 11; const REPEATED_CHAR_PLACEHOLDER = "REPEATED_CHAR_PLACEHOLDER_"; // TODO in the future : remove these constants MONTHS/DAYS, and use a library such as luxon to handle it // + possibly handle automatic translation of day/month const MONTHS = { 0: _t("January"), 1: _t("February"), 2: _t("March"), 3: _t("April"), 4: _t("May"), 5: _t("June"), 6: _t("July"), 7: _t("August"), 8: _t("September"), 9: _t("October"), 10: _t("November"), 11: _t("December"), }; const DAYS$1 = { 0: _t("Sunday"), 1: _t("Monday"), 2: _t("Tuesday"), 3: _t("Wednesday"), 4: _t("Thursday"), 5: _t("Friday"), 6: _t("Saturday"), }; /** * Formats a cell value with its format. */ function formatValue(value, { format, locale, formatWidth }) { if (typeof value === "boolean") { value = value ? "TRUE" : "FALSE"; } switch (typeof value) { case "string": { if (value.includes('\\"')) { value = value.replaceAll(/\\"/g, '"'); } if (!format) { return value; } const internalFormat = parseFormat(format); let formatToApply = internalFormat.text || internalFormat.positive; if (!formatToApply || formatToApply.type !== "text") { return value; } return applyTextInternalFormat(value, formatToApply, formatWidth); } case "number": if (!format) { format = createDefaultFormat(value); } const internalFormat = parseFormat(format); if (internalFormat.positive.type === "text") { return applyTextInternalFormat(value.toString(), internalFormat.positive, formatWidth); } let formatToApply = internalFormat.positive; if (value < 0 && internalFormat.negative) { formatToApply = internalFormat.negative; value = -value; } else if (value === 0 && internalFormat.zero) { formatToApply = internalFormat.zero; } if (formatToApply.type === "date") { return repeatCharToFitWidth(applyDateTimeFormat(value, formatToApply), formatWidth); } const isNegative = value < 0; const formatted = repeatCharToFitWidth(applyInternalNumberFormat(Math.abs(value), formatToApply, locale), formatWidth); return isNegative ? "-" + formatted : formatted; case "object": // case value === null return ""; } } function applyTextInternalFormat(value, internalFormat, formatWidth) { let formattedValue = ""; for (const token of internalFormat.tokens) { switch (token.type) { case "TEXT_PLACEHOLDER": formattedValue += value; break; case "CHAR": case "STRING": formattedValue += token.value; break; case "REPEATED_CHAR": formattedValue += REPEATED_CHAR_PLACEHOLDER + token.value; break; } } return repeatCharToFitWidth(formattedValue, formatWidth); } function repeatCharToFitWidth(formattedValue, formatWidth) { const placeholderIndex = formattedValue.indexOf(REPEATED_CHAR_PLACEHOLDER); if (placeholderIndex === -1) { return formattedValue; } const prefix = formattedValue.slice(0, placeholderIndex); const suffix = formattedValue.slice(placeholderIndex + REPEATED_CHAR_PLACEHOLDER.length + 1); const repeatedChar = formattedValue[placeholderIndex + REPEATED_CHAR_PLACEHOLDER.length]; function getTimesToRepeat() { if (!formatWidth) { return { timesToRepeat: 0, padding: "" }; } const widthTaken = formatWidth.measureText(prefix + suffix); const charWidth = formatWidth.measureText(repeatedChar); const availableWidth = formatWidth.availableWidth - widthTaken; if (availableWidth <= 0) { return { timesToRepeat: 0, padding: "" }; } const timesToRepeat = Math.floor(availableWidth / charWidth); const remainingWidth = availableWidth - timesToRepeat * charWidth; const paddingChar = "\u2009"; // thin space const paddingWidth = formatWidth.measureText(paddingChar); const padding = paddingChar.repeat(Math.floor(remainingWidth / paddingWidth)); return { timesToRepeat, padding }; } const { timesToRepeat, padding } = getTimesToRepeat(); return prefix + repeatedChar.repeat(timesToRepeat) + padding + suffix; } function applyInternalNumberFormat(value, format, locale) { if (value === Infinity) { return "∞" + (format.percentSymbols ? "%" : ""); } const multiplier = format.percentSymbols * 2 - format.magnitude * 3; value = value * 10 ** multiplier; let maxDecimals = 0; if (format.decimalPart !== undefined) { maxDecimals = format.decimalPart.filter((token) => token.type === "DIGIT").length; } const { integerDigits, decimalDigits } = splitNumber(Math.abs(value), maxDecimals); let formattedValue = applyIntegerFormat(integerDigits, format, format.thousandsSeparator ? locale.thousandsSeparator : undefined); if (format.decimalPart !== undefined) { formattedValue += locale.decimalSeparator + applyDecimalFormat(decimalDigits || "", format); } return formattedValue; } function applyIntegerFormat(integerDigits, internalFormat, thousandsSeparator) { let tokens = internalFormat.integerPart; if (!tokens.some((token) => token.type === "DIGIT")) { tokens = [...tokens, { type: "DIGIT", value: "#" }]; } if (integerDigits === "0") { integerDigits = ""; } let formattedInteger = ""; const firstDigitIndex = tokens.findIndex((token) => token.type === "DIGIT"); let indexInIntegerString = integerDigits.length - 1; function appendDigitToFormattedValue(digit, digitType) { if (digitType === "0") { digit = digit || "0"; } if (!digit) return; const digitIndex = integerDigits.length - 1 - indexInIntegerString; if (thousandsSeparator && digitIndex > 0 && digitIndex % 3 === 0) { formattedInteger = digit + thousandsSeparator + formattedInteger; } else { formattedInteger = digit + formattedInteger; } } for (let i = tokens.length - 1; i >= 0; i--) { const token = tokens[i]; switch (token.type) { case "DIGIT": let digit = integerDigits[indexInIntegerString]; appendDigitToFormattedValue(digit, token.value); indexInIntegerString--; // Apply the rest of the integer digits at the first digit character if (firstDigitIndex === i) { while (indexInIntegerString >= 0) { appendDigitToFormattedValue(integerDigits[indexInIntegerString], "0"); indexInIntegerString--; } } break; case "THOUSANDS_SEPARATOR": break; case "REPEATED_CHAR": formattedInteger = REPEATED_CHAR_PLACEHOLDER + token.value + formattedInteger; break; default: formattedInteger = token.value + formattedInteger; break; } } return formattedInteger; } function applyDecimalFormat(decimalDigits, internalFormat) { if (!internalFormat.decimalPart) { return ""; } let formattedDecimals = ""; let indexInDecimalString = 0; for (const token of internalFormat.decimalPart) { switch (token.type) { case "DIGIT": const digit = token.value === "#" ? decimalDigits[indexInDecimalString] || "" : decimalDigits[indexInDecimalString] || "0"; formattedDecimals += digit; indexInDecimalString++; break; case "THOUSANDS_SEPARATOR": break; case "REPEATED_CHAR": formattedDecimals += REPEATED_CHAR_PLACEHOLDER + token.value; break; default: formattedDecimals += token.value; break; } } return formattedDecimals; } /** * this is a cache that can contains number representation formats * from 0 (minimum) to 20 (maximum) digits after the decimal point */ const numberRepresentation = []; /** split a number into two strings that contain respectively: * - all digit stored in the integer part of the number * - 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. * **/ function splitNumber(value, maxDecimals = MAX_DECIMAL_PLACES) { 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 = 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) { let i = numberString.length - 1; while (i >= 0 && numberString[i] === "0") { i--; } return numberString.slice(0, i + 1) || undefined; } const leadingZeroesRegexp = /^0+/; /** * Limit the size of the decimal part of a number to the given number of digits. */ function limitDecimalDigits(decimalDigits, maxDecimals) { let integerDigits = "0"; let resultDecimalDigits = 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(decimalDigits[i]) < 5) { return { integerDigits, decimalDigits: slicedDecimalDigits }; } // round up const leadingZeroes = slicedDecimalDigits.match(leadingZeroesRegexp)?.[0] || ""; const slicedRoundedUp = (Number(slicedDecimalDigits) + 1).toString(); const withoutLeadingZeroes = slicedDecimalDigits.slice(leadingZeroes.length); // e.g. carry over from 99 to 100 const carryOver = slicedRoundedUp.length > withoutLeadingZeroes.length; if (carryOver && !leadingZeroes) { integerDigits = "1"; resultDecimalDigits = undefined; } else if (carryOver) { resultDecimalDigits = leadingZeroes.slice(0, -1) + slicedRoundedUp; } else { resultDecimalDigits = leadingZeroes + 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, maxDecimals = MAX_DECIMAL_PLACES) { let formatter = numberRepresentation[maxDecimals]; if (!formatter) { formatter = new Intl.NumberFormat("en-US", { maximumFractionDigits: maxDecimals, useGrouping: false, }); numberRepresentation[maxDecimals] = formatter; } const [integerDigits, decimalDigits] = formatter.format(value).split("."); return { integerDigits, decimalDigits }; } /** Convert a number into a string, without scientific notation */ function numberToString(number, decimalSeparator) { const { integerDigits, decimalDigits } = splitNumber(number, 20); return decimalDigits ? integerDigits + decimalSeparator + decimalDigits : integerDigits; } /** * Check if the given format is a time, date or date time format. Only check the first part of a multi-part format. */ const isDateTimeFormat = memoize(function isDateTimeFormat(format) { if (!format) { return false; } try { const internalFormat = parseFormat(format); return internalFormat.positive.type === "date"; } catch (error) { return false; } }); function applyDateTimeFormat(value, internalFormat) { const jsDate = numberToJsDate(value); const isMeridian = internalFormat.tokens.some((token) => token.type === "DATE_PART" && token.value === "a"); let currentValue = ""; for (const token of internalFormat.tokens) { switch (token.type) { case "DATE_PART": currentValue += formatJSDatePart(jsDate, token.value, isMeridian); break; case "REPEATED_CHAR": currentValue += REPEATED_CHAR_PLACEHOLDER + token.value; break; default: currentValue += token.value; break; } } return currentValue; } function formatJSDatePart(jsDate, tokenValue, isMeridian) { switch (tokenValue) { case "d": return jsDate.getDate(); case "dd": return jsDate.getDate().toString().padStart(2, "0"); case "ddd": return DAYS$1[jsDate.getDay()].slice(0, 3); case "dddd": // force translation because somehow node 22 doesn't call LazyTranslatedString.toString() whe concatenating it to a string return DAYS$1[jsDate.getDay()].toString(); case "m": return jsDate.getMonth() + 1; case "mm": return String(jsDate.getMonth() + 1).padStart(2, "0"); case "mmm": return MONTHS[jsDate.getMonth()].slice(0, 3); case "mmmm": return MONTHS[jsDate.getMonth()].toString(); case "mmmmm": return MONTHS[jsDate.getMonth()].slice(0, 1); case "qq": return _t("Q%(quarter)s", { quarter: jsDate.getQuarter() }).toString(); case "qqqq": return _t("Quarter %(quarter)s", { quarter: jsDate.getQuarter() }).toString(); case "yy": const fullYear = String(jsDate.getFullYear()).replace("-", "").padStart(2, "0"); return fullYear.slice(fullYear.length - 2); case "yyyy": return jsDate.getFullYear(); case "hhhh": const elapsedHours = Math.floor((jsDate.getTime() - INITIAL_1900_DAY.getTime()) / (60 * 60 * 1000)); return elapsedHours.toString(); case "hh": const dateHours = jsDate.getHours(); let hours = dateHours; if (isMeridian) { hours = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours; } return hours.toString().padStart(2, "0"); case "MM": // "MM" replaces "mm" for minutes during format parsing return jsDate.getMinutes().toString().padStart(2, "0"); case "ss": return jsDate.getSeconds().toString().padStart(2, "0"); case "a": return jsDate.getHours() >= 12 ? "PM" : "AM"; default: throw new Error(`invalid date format token: ${tokenValue}`); } } /** * Get a regex matching decimal number based on the locale's thousand separator * * eg. if the locale's thousand separator is a comma, this will return a regex /[0-9]+,[0-9]/ */ const getDecimalNumberRegex = memoize(function getDecimalNumberRegex(locale) { return new RegExp(`[0-9]+${escapeRegExp(locale.decimalSeparator)}[0-9]`); }); // ----------------------------------------------------------------------------- // CREATE / MODIFY FORMAT // ----------------------------------------------------------------------------- /** * Create a default format for a number. * * If possible this will try round the number to have less than DEFAULT_FORMAT_NUMBER_OF_DIGITS characters * in the number. This is obviously only possible for number with a big decimal part. For number with a lot * of digits in the integer part, keep the number as it is. */ function createDefaultFormat(value) { let { integerDigits, decimalDigits } = splitNumber(value); if (!decimalDigits) return "0"; const digitsInIntegerPart = integerDigits.replace("-", "").length; // If there's no space for at least the decimal separator + a decimal digit, don't display decimals if (digitsInIntegerPart + 2 > DEFAULT_FORMAT_NUMBER_OF_DIGITS) { return "0"; } // -1 for the decimal separator character const spaceForDecimalsDigits = DEFAULT_FORMAT_NUMBER_OF_DIGITS - digitsInIntegerPart - 1; ({ decimalDigits } = splitNumber(value, Math.min(spaceForDecimalsDigits, decimalDigits.length))); return decimalDigits ? "0." + "0".repeat(decimalDigits.length) : "0"; } function detectDateFormat(content, locale) { if (!isDateTime(content, locale)) { return undefined; } const internalDate = parseDateTime(content, locale); return internalDate.format; } /** use this function only if the content corresponds to a number (means that isNumber(content) return true */ function detectNumberFormat(content) { const digitBase = content.includes(".") ? "0.00" : "0"; const matchedCurrencies = content.match(/[\$€]/); if (matchedCurrencies) { const matchedFirstDigit = content.match(/[\d]/); const currency = "[$" + matchedCurrencies.values().next().value + "]"; if (matchedFirstDigit.index < matchedCurrencies.index) { return "#,##" + digitBase + currency; } return currency + "#,##" + digitBase; } if (content.includes("%")) { return digitBase + "%"; } return undefined; } function createCurrencyFormat(currency) { const decimalPlaces = currency.decimalPlaces ?? 2; const position = currency.position ?? "before"; const code = currency.code ?? ""; const symbol = currency.symbol ?? ""; const decimalRepresentation = decimalPlaces ? "." + "0".repeat(decimalPlaces) : ""; const numberFormat = "#,##0" + decimalRepresentation; let textExpression = `${code} ${symbol}`.trim(); if (position === "after" && code) { textExpression = " " + textExpression; } return insertTextInFormat(textExpression, position, numberFormat); } function createAccountingFormat(currency) { const decimalPlaces = currency.decimalPlaces ?? 2; const position = currency.position ?? "before"; const code = currency.code ?? ""; const symbol = currency.symbol ?? ""; const decimalRepresentation = decimalPlaces ? "." + "0".repeat(decimalPlaces) : ""; const numberFormat = "#,##0" + decimalRepresentation; let textExpression = `${code} ${symbol}`.trim(); if (position === "after" && code) { textExpression = " " + textExpression; } const positivePart = insertTextInAccountingFormat(textExpression, position, ` ${numberFormat} `); const negativePart = insertTextInAccountingFormat(textExpression, position, `(${numberFormat})`); const zeroPart = insertTextInAccountingFormat(textExpression, position, " - "); return [positivePart, negativePart, zeroPart].join(";"); } function insertTextInAccountingFormat(text, position, format) { const textExpression = `[$${text}]`; return position === "before" ? textExpression + "* " + format : format + "* " + textExpression; } function insertTextInFormat(text, position, format) { const textExpression = `[$${text}]`; return position === "before" ? textExpression + format : format + textExpression; } function roundFormat(format) { const multiPartFormat = parseFormat(format); const roundedInternalFormat = { positive: _roundFormat(multiPartFormat.positive), negative: multiPartFormat.negative ? _roundFormat(multiPartFormat.negative) : undefined, zero: multiPartFormat.zero ? _roundFormat(multiPartFormat.zero) : undefined, text: multiPartFormat.text, }; return convertInternalFormatToFormat(roundedInternalFormat); } function _roundFormat(internalFormat) { if (internalFormat.type !== "number" || !internalFormat.decimalPart) { return internalFormat; } const nonDigitDecimalPart = internalFormat.decimalPart.filter((token) => token.type !== "DIGIT"); return { ...internalFormat, decimalPart: undefined, integerPart: [...internalFormat.integerPart, ...nonDigitDecimalPart], }; } function humanizeNumber({ value, format }, locale) { const numberFormat = formatLargeNumber({ value, format, }, undefined, locale); return formatValue(value, { format: numberFormat, locale }); } function formatLargeNumber(arg, unit, locale) { let value = 0; try { value = Math.abs(toNumber(arg?.value, locale)); } catch (e) { return ""; } const format = arg?.format; if (unit !== undefined) { const postFix = unit?.value; switch (postFix) { case "k": return createLargeNumberFormat(format, 1, "k"); case "m": return createLargeNumberFormat(format, 2, "m"); case "b": return createLargeNumberFormat(format, 3, "b"); default: throw new EvaluationError(_t("The formatting unit should be 'k', 'm' or 'b'.")); } } if (value < 1e5) { return createLargeNumberFormat(format, 0, ""); } else if (value < 1e8) { return createLargeNumberFormat(format, 1, "k"); } else if (value < 1e11) { return createLargeNumberFormat(format, 2, "m"); } return createLargeNumberFormat(format, 3, "b"); } function createLargeNumberFormat(format, magnitude, postFix, locale) { const multiPartFormat = parseFormat(format || "#,##0"); const roundedInternalFormat = { positive: _createLargeNumberFormat(multiPartFormat.positive, magnitude, postFix), negative: multiPartFormat.negative ? _createLargeNumberFormat(multiPartFormat.negative, magnitude, postFix) : undefined, zero: multiPartFormat.zero ? _createLargeNumberFormat(multiPartFormat.zero, magnitude, postFix) : undefined, text: multiPartFormat.text, }; return convertInternalFormatToFormat(roundedInternalFormat); } function _createLargeNumberFormat(format, magnitude, postFix) { if (format.type !== "number") { return format; } const postFixToken = { type: "STRING", value: postFix }; let newIntegerPart = [...format.integerPart]; const lastDigitIndex = newIntegerPart.findLastIndex((token) => token.type === "DIGIT"); if (lastDigitIndex === -1) { throw new Error("Cannot create a large number format from a format with no digit."); } while (newIntegerPart[lastDigitIndex + 1]?.type === "THOUSANDS_SEPARATOR") { newIntegerPart = removeIndexesFromArray(newIntegerPart, [lastDigitIndex + 1]); } const tokenAfterDigits = newIntegerPart[lastDigitIndex + 1]; if (tokenAfterDigits?.type === "STRING" && ["m", "k", "b"].includes(tokenAfterDigits.value)) { newIntegerPart = replaceItemAtIndex(newIntegerPart, postFixToken, lastDigitIndex + 1); } else { newIntegerPart = insertItemsAtIndex(newIntegerPart, [postFixToken], lastDigitIndex + 1); } if (magnitude > 0) { newIntegerPart = insertItemsAtIndex(newIntegerPart, Array(magnitude).fill({ type: "THOUSANDS_SEPARATOR", value: "," }), lastDigitIndex + 1); } const missingPercents = format.percentSymbols - newIntegerPart.filter((tk) => tk.type === "PERCENT").length; newIntegerPart.push(...new Array(missingPercents).fill({ type: "PERCENT", value: "%" })); return { ...format, integerPart: newIntegerPart, decimalPart: undefined, magnitude }; } function changeDecimalPlaces(format, step) { const multiPartFormat = parseFormat(format); const newInternalFormat = { positive: _changeDecimalPlace(multiPartFormat.positive, step), negative: multiPartFormat.negative ? _changeDecimalPlace(multiPartFormat.negative, step) : undefined, zero: multiPartFormat.zero ? _changeDecimalPlace(multiPartFormat.zero, step) : undefined, text: multiPartFormat.text, }; // Re-parse the format to make sure we don't break the number of digit limit return convertInternalFormatToFormat(parseFormat(convertInternalFormatToFormat(newInternalFormat))); } function _changeDecimalPlace(format, step) { if (format.type !== "number") { return format; } return (step > 0 ? addDecimalPlaces(format, step) : removeDecimalPlaces(format, Math.abs(step))); } function removeDecimalPlaces(format, step) { let decimalPart = format.decimalPart; if (!decimalPart) { return format; } const indexesToRemove = []; let digitCount = 0; for (let i = decimalPart.length - 1; i >= 0; i--) { if (digitCount >= Math.abs(step)) { break; } if (decimalPart[i].type === "DIGIT") { digitCount++; indexesToRemove.push(i); } } decimalPart = removeIndexesFromArray(decimalPart, indexesToRemove); if (decimalPart.some((token) => token.type === "DIGIT")) { return { ...format, decimalPart }; } return { ...format, decimalPart: undefined, integerPart: [...format.integerPart, ...decimalPart], }; } function addDecimalPlaces(format, step) { let integerPart = format.integerPart; let decimalPart = format.decimalPart; if (!decimalPart) { const lastDigitIndex = integerPart.findLastIndex((token) => token.type === "DIGIT"); decimalPart = integerPart.slice(lastDigitIndex + 1); integerPart = integerPart.slice(0, lastDigitIndex + 1); } const digitsToAdd = range(0, step).map(() => ({ type: "DIGIT", value: "0" })); const lastDigitIndex = decimalPart.findLastIndex((token) => token.type === "DIGIT"); if (lastDigitIndex === -1) { decimalPart = [...digitsToAdd, ...decimalPart]; } else { decimalPart = insertItemsAtIndex(decimalPart, digitsToAdd, lastDigitIndex + 1); } return { ...format, decimalPart, integerPart }; } function isExcelCompatible(format) { const internalFormat = parseFormat(format); for (const part of [internalFormat.positive, internalFormat.negative, internalFormat.zero]) { if (part && part.type === "date" && part.tokens.some((token) => token.type === "DATE_PART" && token.value.includes("q"))) { return false; } } return true; } function isTextFormat(format) { if (!format) return false; try { const internalFormat = parseFormat(format); return internalFormat.positive.type === "text"; } catch { return false; } } class RangeImpl { getSheetSize; _zone; parts; invalidXc; prefixSheet = false; sheetId; // the sheet on which the range is defined invalidSheetName; // the name of any sheet that is invalid constructor(args, getSheetSize) { this.getSheetSize = getSheetSize; this._zone = args.zone; this.prefixSheet = args.prefixSheet; this.invalidXc = args.invalidXc; this.sheetId = args.sheetId; this.invalidSheetName = args.invalidSheetName; let _fixedParts = [...args.parts]; if (args.parts.length === 1 && getZoneArea(this.zone) > 1) { _fixedParts.push({ ...args.parts[0] }); } else if (args.parts.length === 2 && getZoneArea(this.zone) === 1) { _fixedParts.pop(); } this.parts = _fixedParts; } static fromRange(range, getters) { if (range instanceof RangeImpl) { return range; } return new RangeImpl(range, getters.getSheetSize); } get unboundedZone() { return this._zone; } get zone() { const { left, top, bottom, right } = this._zone; if (right !== undefined && bottom !== undefined) { return this._zone; } else if (bottom === undefined && right !== undefined) { return { right, top, left, bottom: this.getSheetSize(this.sheetId).numberOfRows - 1 }; } else if (right === undefined && bottom !== undefined) { return { bottom, left, top, right: this.getSheetSize(this.sheetId).numberOfCols - 1 }; } throw new Error(_t("Bad zone format")); } static getRangeParts(xc, zone) { const parts = xc.split(":").map((p) => { const isFullRow = isRowReference(p); return { colFixed: isFullRow ? false : p.startsWith("$"), rowFixed: isFullRow ? p.startsWith("$") : p.includes("$", 1), }; }); const isFullCol = zone.bottom === undefined; const isFullRow = zone.right === undefined; if (isFullCol) { parts[0].rowFixed = parts[0].rowFixed || parts[1].rowFixed; parts[1].rowFixed = parts[0].rowFixed || parts[1].rowFixed; } if (isFullRow) { parts[0].colFixed = parts[0].colFixed || parts[1].colFixed; parts[1].colFixed = parts[0].colFixed || parts[1].colFixed; } return parts; } get isFullCol() { return this._zone.bottom === undefined; } get isFullRow() { return this._zone.right === undefined; } get rangeData() { return { _zone: this._zone, _sheetId: this.sheetId, }; } /** * Check that a zone is valid regarding the order of top-bottom and left-right. * Left should be smaller than right, top should be smaller than bottom. * If it's not the case, simply invert them, and invert the linked parts */ orderZone() { if (isZoneOrdered(this._zone)) { return this; } const zone = { ...this._zone }; let parts = this.parts; if (zone.right !== undefined && zone.right < zone.left) { let right = zone.right; zone.right = zone.left; zone.left = right; parts = [ { colFixed: parts[1]?.colFixed || false, rowFixed: parts[0]?.rowFixed || false, }, { colFixed: parts[0]?.colFixed || false, rowFixed: parts[1]?.rowFixed || false, }, ]; } if (zone.bottom !== undefined && zone.bottom < zone.top) { let bottom = zone.bottom; zone.bottom = zone.top; zone.top = bottom; parts = [ { colFixed: parts[0]?.colFixed || false, rowFixed: parts[1]?.rowFixed || false, }, { colFixed: parts[1]?.colFixed || false, rowFixed: parts[0]?.rowFixed || false, }, ]; } return this.clone({ zone, parts }); } /** * * @param rangeParams optional, values to put in the cloned range instead of the current values of the range */ clone(rangeParams) { return new RangeImpl({ zone: rangeParams?.zone ? rangeParams.zone : { ...this._zone }, sheetId: rangeParams?.sheetId ? rangeParams.sheetId : this.sheetId, invalidSheetName: rangeParams && "invalidSheetName" in rangeParams // 'attr in obj' instead of just 'obj.attr' because we accept undefined values ? rangeParams.invalidSheetName : this.invalidSheetName, invalidXc: rangeParams && "invalidXc" in rangeParams ? rangeParams.invalidXc : this.invalidXc, parts: rangeParams?.parts ? rangeParams.parts : this.parts.map((part) => { return { rowFixed: part.rowFixed, colFixed: part.colFixed }; }), prefixSheet: rangeParams?.prefixSheet !== undefined ? rangeParams.prefixSheet : this.prefixSheet, }, this.getSheetSize); } } /** * Copy a range. If the range is on the sheetIdFrom, the range will target * sheetIdTo. */ function copyRangeWithNewSheetId(sheetIdFrom, sheetIdTo, range) { const sheetId = range.sheetId === sheetIdFrom ? sheetIdTo : range.sheetId; return range.clone({ sheetId }); } /** * Create a range from a xc. If the xc is empty, this function returns undefined. */ function createValidRange(getters, sheetId, xc) { if (!xc) return; const range = getters.getRangeFromSheetXC(sheetId, xc); return !(range.invalidSheetName || range.invalidXc) ? range : undefined; } /** * Spread multiple colrows zone to one row/col zone and add a many new input range as needed. * For example, A1:B4 will become [A1:A4, B1:B4] */ function spreadRange(getters, dataSets) { const postProcessedRanges = []; for (const dataSet of dataSets) { const range = dataSet.dataRange; if (!getters.isRangeValid(range)) { postProcessedRanges.push(dataSet); // ignore invalid range continue; } const { sheetName } = splitReference(range); const sheetPrefix = sheetName ? `${sheetName}!` : ""; const zone = toUnboundedZone(range); if (zone.bottom !== zone.top && zone.left != zone.right) { if (zone.right) { for (let j = zone.left; j <= zone.right; ++j) { postProcessedRanges.push({ ...dataSet, dataRange: `${sheetPrefix}${zoneToXc({ left: j, right: j, top: zone.top, bottom: zone.bottom, })}`, }); } } else { for (let j = zone.top; j <= zone.bottom; ++j) { postProcessedRanges.push({ ...dataSet, dataRange: `${sheetPrefix}${zoneToXc({ left: zone.left, right: zone.right, top: j, bottom: j, })}`, }); } } } else { postProcessedRanges.push(dataSet); } } return postProcessedRanges; } /** * Get all the cell positions in the given ranges. If a cell is in multiple ranges, it will be returned multiple times. */ function getCellPositionsInRanges(ranges) { const cellPositions = []; for (const range of ranges) { for (const position of positions(range.zone)) { cellPositions.push({ ...position, sheetId: range.sheetId }); } } return cellPositions; } /** Methods from Odoo Web Utils */ /** * This function computes a score that represent the fact that the * string contains the pattern, or not * * - If the score is 0, the string does not contain the letters of the pattern in * the correct order. * - if the score is > 0, it actually contains the letters. * * Better matches will get a higher score: consecutive letters are better, * and a match closer to the beginning of the string is also scored higher. */ function fuzzyMatch(pattern, str) { pattern = pattern.toLocaleLowerCase(); str = str.toLocaleLowerCase(); let totalScore = 0; let currentScore = 0; let len = str.length; let patternIndex = 0; for (let i = 0; i < len; i++) { if (str[i] === pattern[patternIndex]) { patternIndex++; currentScore += 100 + currentScore - i / 200; } else { currentScore = 0; } totalScore = totalScore + currentScore; } return patternIndex === pattern.length ? totalScore : 0; } /** * Return a list of things that matches a pattern, ordered by their 'score' ( * higher score first). An higher score means that the match is better. For * example, consecutive letters are considered a better match. */ function fuzzyLookup(pattern, list, fn) { const results = []; list.forEach((data) => { const score = fuzzyMatch(pattern, fn(data)); if (score > 0) { results.push({ score, elem: data }); } }); // we want better matches first results.sort((a, b) => b.score - a.score); return results.map((r) => r.elem); } function createDefaultRows(rowNumber) { const rows = []; for (let i = 0; i < rowNumber; i++) { const row = { cells: {}, }; rows.push(row); } return rows; } function moveHeaderIndexesOnHeaderAddition(indexHeaderAdded, numberAdded, headers) { return headers.map((header) => { if (header >= indexHeaderAdded) { return header + numberAdded; } return header; }); } function moveHeaderIndexesOnHeaderDeletion(deletedHeaders, headers) { deletedHeaders = [...deletedHeaders].sort((a, b) => b - a); return headers .map((header) => { for (const deletedHeader of deletedHeaders) { if (header > deletedHeader) { header--; } else if (header === deletedHeader) { return undefined; } } return header; }) .filter(isDefined); } function computeTextLinesHeight(textLineHeight, numberOfLines = 1) { return numberOfLines * (textLineHeight + MIN_CELL_TEXT_MARGIN) - MIN_CELL_TEXT_MARGIN; } /** * Get the default height of the cell given its style. */ function getDefaultCellHeight(ctx, cell, colSize) { if (!cell || (!cell.isFormula && !cell.content)) { return DEFAULT_CELL_HEIGHT; } const maxWidth = cell.style?.wrapping === "wrap" ? colSize - 2 * MIN_CELL_TEXT_MARGIN : undefined; const numberOfLines = cell.isFormula ? 1 : splitTextToWidth(ctx, cell.content, cell.style, maxWidth).length; const fontSize = computeTextFontSizeInPixels(cell.style); return computeTextLinesHeight(fontSize, numberOfLines) + 2 * PADDING_AUTORESIZE_VERTICAL; } function getDefaultContextFont(fontSize, bold = false, italic = false) { const italicStr = italic ? "italic" : ""; const weight = bold ? "bold" : ""; return `${italicStr} ${weight} ${fontSize}px ${DEFAULT_FONT}`; } const textWidthCache = {}; function computeTextWidth(context, text, style, fontUnit = "pt") { const font = computeTextFont(style, fontUnit); context.save(); context.font = font; const width = computeCachedTextWidth(context, text); context.restore(); return width; } function computeCachedTextWidth(context, text) { const font = context.font; if (!textWidthCache[font]) { textWidthCache[font] = {}; } if (textWidthCache[font][text] === undefined) { const textWidth = context.measureText(text).width; textWidthCache[font][text] = textWidth; } return textWidthCache[font][text]; } const textDimensionsCache = {}; function computeTextDimension(context, text, style, fontUnit = "pt") { const font = computeTextFont(style, fontUnit); context.save(); context.font = font; const dimensions = computeCachedTextDimension(context, text); context.restore(); return dimensions; } function computeCachedTextDimension(context, text) { const font = context.font; if (!textDimensionsCache[font]) { textDimensionsCache[font] = {}; } if (textDimensionsCache[font][text] === undefined) { const measure = context.measureText(text); const width = measure.width; const height = measure.fontBoundingBoxAscent + measure.fontBoundingBoxDescent; textDimensionsCache[font][text] = { width, height }; } return textDimensionsCache[font][text]; } function fontSizeInPixels(fontSize) { return Math.round((fontSize * 96) / 72); } function computeTextFont(style, fontUnit = "pt") { const italic = style.italic ? "italic " : ""; const weight = style.bold ? "bold" : DEFAULT_FONT_WEIGHT; const size = fontUnit === "pt" ? computeTextFontSizeInPixels(style) : style.fontSize; return `${italic}${weight} ${size ?? DEFAULT_FONT_SIZE}px ${DEFAULT_FONT}`; } function computeTextFontSizeInPixels(style) { const sizeInPt = style?.fontSize || DEFAULT_FONT_SIZE; return fontSizeInPixels(sizeInPt); } function splitWordToSpecificWidth(ctx, word, width, style) { const wordWidth = computeTextWidth(ctx, word, style); if (wordWidth <= width) { return [word]; } const splitWord = []; let wordPart = ""; for (let l of word) { const wordPartWidth = computeTextWidth(ctx, wordPart + l, style); if (wordPartWidth > width) { splitWord.push(wordPart); wordPart = l; } else { wordPart += l; } } splitWord.push(wordPart); return splitWord; } /** * Return the given text, split in multiple lines if needed. The text will be split in multiple * line if it contains NEWLINE characters, or if it's longer than the given width. */ function splitTextToWidth(ctx, text, style, width) { if (!style) style = {}; const brokenText = []; // Checking if text contains NEWLINE before split makes it very slightly slower if text contains it, // but 5-10x faster if it doesn't const lines = text.includes(NEWLINE) ? text.split(NEWLINE) : [text]; for (const line of lines) { const words = line.includes(" ") ? line.split(" ") : [line]; if (!width) { brokenText.push(line); continue; } let textLine = ""; let availableWidth = width; for (let word of words) { const splitWord = splitWordToSpecificWidth(ctx, word, width, style); const lastPart = splitWord.pop(); const lastPartWidth = computeTextWidth(ctx, lastPart, style); // At this step: "splitWord" is an array composed of parts of word whose // length is at most equal to "width". // Last part contains the end of the word. // Note that: When word length is less than width, then lastPart is equal // to word and splitWord is empty if (splitWord.length) { if (textLine !== "") { brokenText.push(textLine); textLine = ""; availableWidth = width; } splitWord.forEach((wordPart) => { brokenText.push(wordPart); }); textLine = lastPart; availableWidth = width - lastPartWidth; } else { // here "lastPart" is equal to "word" and the "word" size is smaller than "width" const _word = textLine === "" ? lastPart : " " + lastPart; const wordWidth = computeTextWidth(ctx, _word, style); if (wordWidth <= availableWidth) { textLine += _word; availableWidth -= wordWidth; } else { brokenText.push(textLine); textLine = lastPart; availableWidth = width - lastPartWidth; } } } if (textLine !== "") { brokenText.push(textLine); } } return brokenText; } /** * Return the font size that makes the width of a text match the given line width. * Minimum font size is 1. * * @param getTextWidth function that takes a fontSize as argument, and return the width of the text with this font size. */ function getFontSizeMatchingWidth(lineWidth, maxFontSize, getTextWidth, precision = 0.25) { let minFontSize = 1; if (getTextWidth(minFontSize) > lineWidth) return minFontSize; if (getTextWidth(maxFontSize) < lineWidth) return maxFontSize; // Dichotomic search let fontSize = (minFontSize + maxFontSize) / 2; let currentTextWidth = getTextWidth(fontSize); // Use a maximum number of iterations to be safe, because measuring text isn't 100% precise let iterations = 0; while (Math.abs(currentTextWidth - lineWidth) > precision && iterations < 20) { if (currentTextWidth >= lineWidth) { maxFontSize = (minFontSize + maxFontSize) / 2; } else { minFontSize = (minFontSize + maxFontSize) / 2; } fontSize = (minFontSize + maxFontSize) / 2; currentTextWidth = getTextWidth(fontSize); iterations++; } return fontSize; } function computeIconWidth(style) { return computeTextFontSizeInPixels(style) + 2 * MIN_CF_ICON_MARGIN; } /** Transform a string to lower case. If the string is undefined, return an empty string */ function toLowerCase(str) { return str ? str.toLowerCase() : ""; } /** * Extract the fontSize from a context font string * @param font The (context) font string to parse * @returns The fontSize in pixels */ const pxRegex = /([0-9\.]*)px/; function getContextFontSize(font) { return Number(font.match(pxRegex)?.[1]); } // Inspired from https://stackoverflow.com/a/10511598 function clipTextWithEllipsis(ctx, text, maxWidth) { let width = computeCachedTextWidth(ctx, text); if (width <= maxWidth) { return text; } const ellipsis = "…"; const ellipsisWidth = computeCachedTextWidth(ctx, ellipsis); if (width <= ellipsisWidth) { return text; } let len = text.length; while (width >= maxWidth - ellipsisWidth && len-- > 0) { text = text.substring(0, len); width = computeCachedTextWidth(ctx, text); } return text + ellipsis; } function drawDecoratedText(context, text, position, underline = false, strikethrough = false, strokeWidth = getContextFontSize(context.font) / 10 //This value is defined to get a good looking stroke ) { context.fillText(text, position.x, position.y); if (!underline && !strikethrough) { return; } const measure = context.measureText(text); const textWidth = measure.width; const textHeight = measure.actualBoundingBoxAscent + measure.actualBoundingBoxDescent; const boxHeight = measure.fontBoundingBoxAscent + measure.fontBoundingBoxDescent; let { x, y } = position; let strikeY = y, underlineY = y; switch (context.textAlign) { case "center": x -= textWidth / 2; break; case "right": x -= textWidth; break; } switch (context.textBaseline) { case "top": underlineY += boxHeight - 2 * strokeWidth; strikeY += boxHeight / 2 - strokeWidth; break; case "middle": underlineY += boxHeight / 2 - strokeWidth; break; case "alphabetic": underlineY += 2 * strokeWidth; strikeY -= 3 * strokeWidth; break; case "bottom": underlineY = y; strikeY -= textHeight / 2 - strokeWidth / 2; break; } if (underline) { context.lineWidth = strokeWidth; context.strokeStyle = context.fillStyle; context.beginPath(); context.moveTo(x, underlineY); context.lineTo(x + textWidth, underlineY); context.stroke(); } if (strikethrough) { context.lineWidth = strokeWidth; context.strokeStyle = context.fillStyle; context.beginPath(); context.moveTo(x, strikeY); context.lineTo(x + textWidth, strikeY); context.stroke(); } } /* * https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript * */ class UuidGenerator { isFastIdStrategy = false; fastIdStart = 0; setIsFastStrategy(isFast) { this.isFastIdStrategy = isFast; } uuidv4() { if (this.isFastIdStrategy) { this.fastIdStart++; return String(this.fastIdStart); //@ts-ignore } else if (window.crypto && window.crypto.getRandomValues) { //@ts-ignore return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) => (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16)); } else { // mainly for jest and other browsers that do not have the crypto functionality return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { var r = (Math.random() * 16) | 0, v = c === "x" ? r : (r & 0x3) | 0x8; return v.toString(16); }); } } } function getClipboardDataPositions(sheetId, zones) { const lefts = new Set(zones.map((z) => z.left)); const rights = new Set(zones.map((z) => z.right)); const tops = new Set(zones.map((z) => z.top)); const bottoms = new Set(zones.map((z) => z.bottom)); const areZonesCompatible = (tops.size === 1 && bottoms.size === 1) || (lefts.size === 1 && rights.size === 1); // In order to don't paste several times the same cells in intersected zones // --> we merge zones that have common cells const clippedZones = areZonesCompatible ? mergeOverlappingZones(zones) : [zones[zones.length - 1]]; const cellsPosition = clippedZones.map((zone) => positions(zone)).flat(); const columnsIndexes = [...new Set(cellsPosition.map((p) => p.col))].sort((a, b) => a - b); const rowsIndexes = [...new Set(cellsPosition.map((p) => p.row))].sort((a, b) => a - b); return { sheetId, zones, clippedZones, columnsIndexes, rowsIndexes }; } /** * The clipped zone is copied as many times as it fits in the target. * This returns the list of zones where the clipped zone is copy-pasted. */ function splitZoneForPaste(selection, splitWidth, splitHeight) { const right = Math.max(selection.right - splitWidth + 1, selection.left); const bottom = Math.max(selection.bottom - splitHeight + 1, selection.top); const zones = []; for (let left = selection.left; left <= right; left += splitWidth) { for (let top = selection.top; top <= bottom; top += splitHeight) { zones.push({ left, top, bottom: top + splitHeight - 1, right: left + splitWidth - 1, }); } } return zones; } /** * Compute the complete zones where to paste the current clipboard */ function getPasteZones(target, content) { if (!content.length || !content[0].length) { return target; } const width = content[0].length, height = content.length; return target.map((t) => splitZoneForPaste(t, width, height)).flat(); } function parseOSClipboardContent(content) { if (!content[ClipboardMIMEType.Html]) { return { text: content[ClipboardMIMEType.PlainText], }; } const htmlDocument = new DOMParser().parseFromString(content[ClipboardMIMEType.Html], "text/html"); const oSheetClipboardData = htmlDocument .querySelector("div") ?.getAttribute("data-osheet-clipboard"); const spreadsheetContent = oSheetClipboardData && JSON.parse(oSheetClipboardData); return { text: content[ClipboardMIMEType.PlainText], data: spreadsheetContent, }; } class ClipboardHandler { getters; dispatch; constructor(getters, dispatch) { this.getters = getters; this.dispatch = dispatch; } copy(data) { return; } paste(target, clippedContent, options) { } isPasteAllowed(sheetId, target, content, option) { return "Success" /* CommandResult.Success */; } isCutAllowed(data) { return "Success" /* CommandResult.Success */; } getPasteTarget(sheetId, target, content, options) { return { zones: [], sheetId }; } convertTextToClipboardData(data) { return; } } class AbstractCellClipboardHandler extends ClipboardHandler { copy(data) { return; } pasteFromCopy(sheetId, target, content, options) { if (target.length === 1) { // in this specific case, due to the isPasteAllowed function: // state.cells can contains several cells. // So if the target zone is larger than the copied zone, // we duplicate each cells as many times as possible to fill the zone. for (const zone of getPasteZones(target, content)) { this.pasteZone(sheetId, zone.left, zone.top, content, options); } } else { // in this case, due to the isPasteAllowed function: state.cells contains // only one cell for (const zone of recomputeZones(target)) { for (let col = zone.left; col <= zone.right; col++) { for (let row = zone.top; row <= zone.bottom; row++) { this.pasteZone(sheetId, col, row, content, options); } } } } } pasteZone(sheetId, col, row, data, clipboardOptions) { } } class BorderClipboardHandler extends AbstractCellClipboardHandler { copy(data) { const sheetId = data.sheetId; if (data.zones.length === 0) { return; } const { rowsIndexes, columnsIndexes } = data; const borders = []; for (const row of rowsIndexes) { const bordersInRow = []; for (const col of columnsIndexes) { const position = { col, row, sheetId }; bordersInRow.push(this.getters.getCellBorder(position)); } borders.push(bordersInRow); } return { borders }; } paste(target, content, options) { const sheetId = target.sheetId; if (options.pasteOption === "asValue") { return; } const zones = target.zones; if (!options.isCutOperation) { this.pasteFromCopy(sheetId, zones, content.borders); } else { const { left, top } = zones[0]; this.pasteZone(sheetId, left, top, content.borders); } } pasteZone(sheetId, col, row, borders) { for (const [r, rowBorders] of borders.entries()) { for (const [c, originBorders] of rowBorders.entries()) { const position = { col: col + c, row: row + r, sheetId }; this.pasteBorder(originBorders, position); } } } /** * Paste the border at the given position to the target position */ pasteBorder(originBorders, target) { const targetBorders = this.getters.getCellBorder(target); const border = { ...targetBorders, ...originBorders, }; this.dispatch("SET_BORDER", { ...target, border }); } } /** * Tokenizer * * A tokenizer is a piece of code whose job is to transform a string into a list * of "tokens". For example, "(12+" is converted into: * [{type: "LEFT_PAREN", value: "("}, * {type: "NUMBER", value: "12"}, * {type: "OPERATOR", value: "+"}] * * As the example shows, a tokenizer does not care about the meaning behind those * tokens. It only cares about the structure. * * The tokenizer is usually the first step in a compilation pipeline. Also, it * is useful for the composer, which needs to be able to work with incomplete * formulas. */ const POSTFIX_UNARY_OPERATORS = ["%"]; const OPERATORS = "+,-,*,/,:,=,<>,>=,>,<=,<,^,&".split(",").concat(POSTFIX_UNARY_OPERATORS); function tokenize(str, locale = DEFAULT_LOCALE) { str = replaceNewLines(str); const chars = new TokenizingChars(str); const result = []; while (!chars.isOver()) { let token = tokenizeSpace(chars) || tokenizeArgsSeparator(chars, locale) || tokenizeParenthesis(chars) || tokenizeOperator(chars) || tokenizeString(chars) || tokenizeDebugger(chars) || tokenizeInvalidRange(chars) || tokenizeNumber(chars, locale) || tokenizeSymbol(chars); if (!token) { token = { type: "UNKNOWN", value: chars.shift() }; } result.push(token); } return result; } function tokenizeDebugger(chars) { if (chars.current === "?") { chars.shift(); return { type: "DEBUGGER", value: "?" }; } return null; } const parenthesis = { "(": { type: "LEFT_PAREN", value: "(" }, ")": { type: "RIGHT_PAREN", value: ")" }, }; function tokenizeParenthesis(chars) { if (chars.current === "(" || chars.current === ")") { const value = chars.shift(); return parenthesis[value]; } return null; } function tokenizeArgsSeparator(chars, locale) { if (chars.current === locale.formulaArgSeparator) { const value = chars.shift(); const type = "ARG_SEPARATOR"; return { type, value }; } return null; } function tokenizeOperator(chars) { for (let op of OPERATORS) { if (chars.currentStartsWith(op)) { chars.advanceBy(op.length); return { type: "OPERATOR", value: op }; } } return null; } const FIRST_POSSIBLE_NUMBER_CHARS = new Set("0123456789"); function tokenizeNumber(chars, locale) { if (!FIRST_POSSIBLE_NUMBER_CHARS.has(chars.current) && chars.current !== locale.decimalSeparator) { return null; } const match = chars.remaining().match(getFormulaNumberRegex(locale.decimalSeparator)); if (match) { chars.advanceBy(match[0].length); return { type: "NUMBER", value: match[0] }; } return null; } function tokenizeString(chars) { if (chars.current === '"') { const startChar = chars.shift(); let letters = startChar; while (chars.current && (chars.current !== startChar || letters[letters.length - 1] === "\\")) { letters += chars.shift(); } if (chars.current === '"') { letters += chars.shift(); } return { type: "STRING", value: letters, }; } return null; } const SYMBOL_CHARS = new Set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.!$"); /** * A "Symbol" is just basically any word-like element that can appear in a * formula, which is not a string. So: * A1 * SUM * CEILING.MATH * A$1 * Sheet2!A2 * 'Sheet 2'!A2 * * are examples of symbols */ function tokenizeSymbol(chars) { let result = ""; // there are two main cases to manage: either something which starts with // a ', like 'Sheet 2'A2, or a word-like element. if (chars.current === "'") { let lastChar = chars.shift(); result += lastChar; while (chars.current) { lastChar = chars.shift(); result += lastChar; if (lastChar === "'") { if (chars.current && chars.current === "'") { lastChar = chars.shift(); result += lastChar; } else { break; } } } if (lastChar !== "'") { return { type: "UNKNOWN", value: result, }; } } while (chars.current && SYMBOL_CHARS.has(chars.current)) { result += chars.shift(); } if (result.length) { const value = result; const isReference = rangeReference.test(value); if (isReference) { return { type: "REFERENCE", value }; } return { type: "SYMBOL", value }; } return null; } function tokenizeSpace(chars) { let length = 0; while (chars.current === NEWLINE) { length++; chars.shift(); } if (length) { return { type: "SPACE", value: NEWLINE.repeat(length) }; } let spaces = ""; while (chars.current && chars.current.match(whiteSpaceRegexp)) { spaces += chars.shift(); } if (spaces) { return { type: "SPACE", value: spaces }; } return null; } function tokenizeInvalidRange(chars) { if (chars.currentStartsWith(CellErrorType.InvalidReference)) { chars.advanceBy(CellErrorType.InvalidReference.length); return { type: "INVALID_REFERENCE", value: CellErrorType.InvalidReference }; } return null; } function isValidLocale(locale) { if (!locale || typeof locale !== "object" || !(!locale.thousandsSeparator || typeof locale.thousandsSeparator === "string")) { return false; } for (const property of [ "code", "name", "decimalSeparator", "dateFormat", "timeFormat", "formulaArgSeparator", ]) { if (!locale[property] || typeof locale[property] !== "string") { return false; } } if (locale.formulaArgSeparator === locale.decimalSeparator) { return false; } if (locale.thousandsSeparator === locale.decimalSeparator) { return false; } try { formatValue(1, { locale, format: "#,##0.00" }); formatValue(1, { locale, format: locale.dateFormat }); formatValue(1, { locale, format: locale.timeFormat }); } catch { return false; } return true; } /** * Change a content string from the given locale to its canonical form (en_US locale). Don't convert date string. * * @example * canonicalizeNumberContent("=SUM(1,5; 02/12/2012)", FR_LOCALE) // "=SUM(1.5, 02/12/2012)" * canonicalizeNumberContent("125,9", FR_LOCALE) // "125.9" * canonicalizeNumberContent("02/12/2012", FR_LOCALE) // "02/12/2012" */ function canonicalizeNumberContent(content, locale) { return content.startsWith("=") ? canonicalizeFormula$1(content, locale) : canonicalizeNumberLiteral(content, locale); } /** * Change a content string from the given locale to its canonical form (en_US locale). Also convert date string. * This is destructive and won't preserve the original format. * * @example * canonicalizeContent("=SUM(1,5; 5)", FR_LOCALE) // "=SUM(1.5, 5)" * canonicalizeContent("125,9", FR_LOCALE) // "125.9" * canonicalizeContent("02/12/2012", FR_LOCALE) // "12/02/2012" * canonicalizeContent("02-12-2012", FR_LOCALE) // "12/02/2012" */ function canonicalizeContent(content, locale) { return content.startsWith("=") ? canonicalizeFormula$1(content, locale) : canonicalizeLiteral(content, locale); } /** * Change a content string from its canonical form (en_US locale) to the given locale. Also convert date string. * * @example * localizeContent("=SUM(1.5, 5)", FR_LOCALE) // "=SUM(1,5; 5)" * localizeContent("125.9", FR_LOCALE) // "125,9" * localizeContent("12/02/2012", FR_LOCALE) // "02/12/2012" */ function localizeContent(content, locale) { return content.startsWith("=") ? localizeFormula(content, locale) : localizeLiteral(content, locale); } /** Change a formula to its canonical form (en_US locale) */ function canonicalizeFormula$1(formula, locale) { return _localizeFormula$1(formula, locale, DEFAULT_LOCALE); } /** Change a formula from the canonical form to the given locale */ function localizeFormula(formula, locale) { return _localizeFormula$1(formula, DEFAULT_LOCALE, locale); } function _localizeFormula$1(formula, fromLocale, toLocale) { if (fromLocale.formulaArgSeparator === toLocale.formulaArgSeparator && fromLocale.decimalSeparator === toLocale.decimalSeparator) { return formula; } const tokens = tokenize(formula, fromLocale); let localizedFormula = ""; for (const token of tokens) { if (token.type === "NUMBER") { localizedFormula += token.value.replace(fromLocale.decimalSeparator, toLocale.decimalSeparator); } else if (token.type === "ARG_SEPARATOR") { localizedFormula += toLocale.formulaArgSeparator; } else { localizedFormula += token.value; } } return localizedFormula; } /** * Change a literal string from the given locale to its canonical form (en_US locale). Don't convert date string. * * @example * canonicalizeNumberLiteral("125,9", FR_LOCALE) // "125.9" * canonicalizeNumberLiteral("02/12/2012", FR_LOCALE) // "02/12/2012" */ function canonicalizeNumberLiteral(content, locale) { if (locale.decimalSeparator === "." || !isNumber(content, locale)) { return content; } if (locale.thousandsSeparator) { content = content.replaceAll(locale.thousandsSeparator, ""); } return content.replace(locale.decimalSeparator, "."); } /** * Change a content string from the given locale to its canonical form (en_US locale). Also convert date string. * This is destructive and won't preserve the original format. * * @example * canonicalizeLiteral("125,9", FR_LOCALE) // "125.9" * canonicalizeLiteral("02/12/2012", FR_LOCALE) // "12/02/2012" * canonicalizeLiteral("02-12-2012", FR_LOCALE) // "12/02/2012" */ function canonicalizeLiteral(content, locale) { if (isDateTime(content, locale)) { const dateNumber = toNumber(content, locale); let format = DEFAULT_LOCALE.dateFormat; if (!Number.isInteger(dateNumber)) { format += " " + DEFAULT_LOCALE.timeFormat; } return formatValue(dateNumber, { locale: DEFAULT_LOCALE, format }); } return canonicalizeNumberLiteral(content, locale); } /** * Change a literal string from its canonical form (en_US locale) to the given locale. Don't convert date string. * This is destructive and won't preserve the original format. * * @example * localizeNumberLiteral("125.9", FR_LOCALE) // "125,9" * localizeNumberLiteral("12/02/2012", FR_LOCALE) // "12/02/2012" * localizeNumberLiteral("12-02-2012", FR_LOCALE) // "12/02/2012" */ function localizeNumberLiteral(literal, locale) { if (locale.decimalSeparator === "." || !isNumber(literal, DEFAULT_LOCALE)) { return literal; } const decimalNumberRegex = getDecimalNumberRegex(DEFAULT_LOCALE); const localized = literal.replace(decimalNumberRegex, (match) => { return match.replace(".", locale.decimalSeparator); }); return localized; } /** * Change a literal string from its canonical form (en_US locale) to the given locale. Also convert date string. * * @example * localizeLiteral("125.9", FR_LOCALE) // "125,9" * localizeLiteral("12/02/2012", FR_LOCALE) // "02/12/2012" */ function localizeLiteral(literal, locale) { if (isDateTime(literal, DEFAULT_LOCALE)) { const dateNumber = toNumber(literal, DEFAULT_LOCALE); let format = locale.dateFormat; if (!Number.isInteger(dateNumber)) { format += " " + locale.timeFormat; } return formatValue(dateNumber, { locale, format }); } return localizeNumberLiteral(literal, locale); } function canonicalizeCFRule(cf, locale) { return changeCFRuleLocale(cf, (content) => canonicalizeContent(content, locale)); } function localizeCFRule(cf, locale) { return changeCFRuleLocale(cf, (content) => localizeContent(content, locale)); } function localizeDataValidationRule(rule, locale) { const localizedDVRule = deepCopy(rule); localizedDVRule.criterion.values = localizedDVRule.criterion.values.map((content) => localizeContent(content, locale)); return localizedDVRule; } function changeCFRuleLocale(rule, changeContentLocale) { rule = deepCopy(rule); switch (rule.type) { case "CellIsRule": // Only change value for number operators switch (rule.operator) { case "Between": case "NotBetween": case "Equal": case "NotEqual": case "GreaterThan": case "GreaterThanOrEqual": case "LessThan": case "LessThanOrEqual": rule.values = rule.values.map((v) => changeContentLocale(v)); return rule; case "BeginsWith": case "ContainsText": case "EndsWith": case "NotContains": case "IsEmpty": case "IsNotEmpty": return rule; } case "DataBarRule": return rule; case "ColorScaleRule": rule.minimum = changeCFRuleThresholdLocale(rule.minimum, changeContentLocale); rule.maximum = changeCFRuleThresholdLocale(rule.maximum, changeContentLocale); if (rule.midpoint) { rule.midpoint = changeCFRuleThresholdLocale(rule.midpoint, changeContentLocale); } return rule; case "IconSetRule": rule.lowerInflectionPoint.value = changeContentLocale(rule.lowerInflectionPoint.value); rule.upperInflectionPoint.value = changeContentLocale(rule.upperInflectionPoint.value); return rule; } } function changeCFRuleThresholdLocale(threshold, changeContentLocale) { if (!threshold?.value) { return threshold; } const value = threshold.type === "formula" ? "=" + threshold.value : threshold.value; const modified = changeContentLocale(value); const newValue = threshold.type === "formula" ? modified.slice(1) : modified; return { ...threshold, value: newValue }; } function getDateTimeFormat(locale) { return locale.dateFormat + " " + locale.timeFormat; } /** Change a number string to its canonical form (en_US locale) */ function canonicalizeNumberValue(content, locale) { return content.startsWith("=") ? canonicalizeFormula(content, locale) : canonicalizeNumberLiteral(content, locale); } /** Change a formula to its canonical form (en_US locale) */ function canonicalizeFormula(formula, locale) { return _localizeFormula(formula, locale, DEFAULT_LOCALE); } function _localizeFormula(formula, fromLocale, toLocale) { if (fromLocale.formulaArgSeparator === toLocale.formulaArgSeparator && fromLocale.decimalSeparator === toLocale.decimalSeparator) { return formula; } const tokens = tokenize(formula, fromLocale); let localizedFormula = ""; for (const token of tokens) { if (token.type === "NUMBER") { localizedFormula += token.value.replace(fromLocale.decimalSeparator, toLocale.decimalSeparator); } else if (token.type === "ARG_SEPARATOR") { localizedFormula += toLocale.formulaArgSeparator; } else { localizedFormula += token.value; } } return localizedFormula; } function boolAnd(args) { let foundBoolean = false; let acc = true; conditionalVisitBoolean(args, (arg) => { foundBoolean = true; acc = acc && arg; return acc; }); return { foundBoolean, result: acc, }; } function boolOr(args) { let foundBoolean = false; let acc = false; conditionalVisitBoolean(args, (arg) => { foundBoolean = true; acc = acc || arg; return !acc; }); return { foundBoolean, result: acc, }; } function sum(values, locale) { return reduceNumbers(values, (acc, a) => acc + a, 0, locale); } function countUnique(args) { return reduceAny(args, (acc, a) => (isDataNonEmpty(a) ? acc.add(a?.value) : acc), new Set()).size; } function getUnitMatrix(n) { const matrix = Array(n); for (let i = 0; i < n; i++) { matrix[i] = Array(n).fill(0); matrix[i][i] = 1; } return matrix; } /** * Invert a matrix and compute its determinant using Gaussian Elimination. * * The Matrix should be a square matrix, and should be indexed [col][row] instead of the * standard mathematical indexing [row][col]. */ function invertMatrix(M) { // Use Gaussian Elimination to calculate the inverse: // (1) 'augment' the matrix (left) by the identity (on the right) // (2) Turn the matrix on the left into the identity using elementary row operations // (3) The matrix on the right becomes the inverse (was the identity matrix) // // There are 3 elementary row operations: // (a) Swap 2 rows. This multiply the determinant by -1. // (b) Multiply a row by a scalar. This multiply the determinant by that scalar. // (c) Add to a row a multiple of another row. This does not change the determinant. if (M.length !== M[0].length) { throw new EvaluationError(_t("Function [[FUNCTION_NAME]] invert matrix error, only square matrices are invertible")); } let determinant = 1; const dim = M.length; const I = getUnitMatrix(dim); const C = M.map((row) => row.slice()); // Perform elementary row operations for (let pivot = 0; pivot < dim; pivot++) { let diagonalElement = C[pivot][pivot]; // if we have a 0 on the diagonal we'll need to swap with a lower row if (diagonalElement === 0) { //look through every row below the i'th row for (let row = pivot + 1; row < dim; row++) { //if the ii'th row has a non-0 in the i'th col, swap it with that row if (C[pivot][row] != 0) { swapMatrixRows(C, pivot, row); swapMatrixRows(I, pivot, row); determinant *= -1; break; } } diagonalElement = C[pivot][pivot]; //if it's still 0, matrix isn't invertible if (diagonalElement === 0) { return { determinant: 0 }; } } // Scale this row down by e (so we have a 1 on the diagonal) for (let col = 0; col < dim; col++) { C[col][pivot] = C[col][pivot] / diagonalElement; I[col][pivot] = I[col][pivot] / diagonalElement; } determinant *= diagonalElement; // Subtract a multiple of the current row from ALL of // the other rows so that there will be 0's in this column in the // rows above and below this one for (let row = 0; row < dim; row++) { if (row === pivot) { continue; } // We want to change this element to 0 const e = C[pivot][row]; // Subtract (the row above(or below) scaled by e) from (the // current row) but start at the i'th column and assume all the // stuff left of diagonal is 0 (which it should be if we made this // algorithm correctly) for (let col = 0; col < dim; col++) { C[col][row] -= e * C[col][pivot]; I[col][row] -= e * I[col][pivot]; } } } // We've done all operations, C should be the identity matrix I should be the inverse return { inverted: I, determinant }; } function swapMatrixRows(matrix, row1, row2) { for (let i = 0; i < matrix.length; i++) { const tmp = matrix[i][row1]; matrix[i][row1] = matrix[i][row2]; matrix[i][row2] = tmp; } } /** * Matrix multiplication of 2 matrices. * ex: matrix1 : n x l, matrix2 : m x n => result : m x l * * Note: we use indexing [col][row] instead of the standard mathematical notation [row][col] */ function multiplyMatrices(matrix1, matrix2) { if (matrix1.length !== matrix2[0].length) { throw new EvaluationError(_t("Cannot multiply matrices : incompatible matrices size.")); } const rowsM1 = matrix1[0].length; const colsM2 = matrix2.length; const n = matrix1.length; const result = Array(colsM2); for (let col = 0; col < colsM2; col++) { result[col] = Array(rowsM1); for (let row = 0; row < rowsM1; row++) { let sum = 0; for (let k = 0; k < n; k++) { sum += matrix1[k][row] * matrix2[col][k]; } result[col][row] = sum; } } return result; } /** * Return the input if it's a scalar or the first element of the input if it's a matrix. */ function toScalar(matrix) { if (!isMatrix(matrix)) { return matrix; } if (matrix.length !== 1 || matrix[0].length !== 1) { throw new EvaluationError(_t("The value should be a scalar or a 1x1 matrix")); } return matrix[0][0]; } function assertSameNumberOfElements(...args) { const dims = args[0].length; args.forEach((arg, i) => assert(() => arg.length === dims, _t("[[FUNCTION_NAME]] has mismatched dimensions for argument %s (%s vs %s).", i.toString(), dims.toString(), arg.length.toString()))); } function average(values, locale) { let count = 0; const sum = reduceNumbers(values, (acc, a) => { count += 1; return acc + a; }, 0, locale); assertNotZero(count); return sum / count; } function countNumbers(values, locale) { let count = 0; for (let n of values) { if (isMatrix(n)) { for (let i of n) { for (let j of i) { if (typeof j.value === "number") { count += 1; } } } } else { const value = n?.value; if (!isEvaluationError(value) && (typeof value !== "string" || isNumber(value, locale) || parseDateTime(value, locale))) { count += 1; } } } return count; } function countAny(values) { return reduceAny(values, (acc, a) => (a !== undefined && a.value !== null ? acc + 1 : acc), 0); } function max(values, locale) { let max = { value: -Infinity }; visitNumbers(values, (a) => { if (a.value >= max.value) { max = a; } }, locale); return max.value === -Infinity ? { value: 0 } : max; } function min(values, locale) { let min = { value: Infinity }; visitNumbers(values, (a) => { if (a.value <= min.value) { min = a; } }, locale); return min.value === Infinity ? { value: 0 } : min; } function prepareDataForRegression(X, Y, newX) { const _X = X[0].length ? X : [range(1, Y.flat().length + 1)]; const nVar = _X.length; let _newX = newX[0].length ? newX : _X; _newX = _newX.length === nVar ? transposeMatrix(_newX) : _newX; return { _X, _newX }; } /* * This function performs a linear regression on the data set. It returns an array with two elements. * The first element is the slope, and the second element is the intercept. * The linear regression line is: y = slope*x + intercept * The function use the least squares method to find the best fit for the data set : * see https://www.mathsisfun.com/data/least-squares-regression.html * https://www.statology.org/standard-error-of-estimate/ * https://agronomy4future.org/?p=16670 * https://vitalflux.com/interpreting-f-statistics-in-linear-regression-formula-examples/ * https://web.ist.utl.pt/~ist11038/compute/errtheory/,regression/regrthroughorigin.pdf */ function fullLinearRegression(X, Y, computeIntercept = true, verbose = false) { const y = Y.flat(); const n = y.length; let { _X } = prepareDataForRegression(X, Y, [[]]); _X = _X.length === n ? transposeMatrix(_X) : _X.slice(); assertSameNumberOfElements(_X[0], y); const nVar = _X.length; const nDeg = n - nVar - (computeIntercept ? 1 : 0); const yMatrix = [y]; const xMatrix = transposeMatrix(_X.reverse()); let avgX = []; for (let i = 0; i < nVar; i++) { avgX.push(0); if (computeIntercept) { for (const xij of _X[i]) { avgX[i] += xij; } avgX[i] /= n; } } let avgY = 0; if (computeIntercept) { for (const yi of y) { avgY += yi; } avgY /= n; } const redX = xMatrix.map((row) => row.map((value, i) => value - avgX[i])); if (computeIntercept) { xMatrix.forEach((row) => row.push(1)); } const coeffs = getLMSCoefficients(xMatrix, yMatrix); if (!computeIntercept) { coeffs.push([0]); } if (!verbose) { return coeffs; } const dot1 = multiplyMatrices(redX, transposeMatrix(redX)); const { inverted: dotInv } = invertMatrix(dot1); if (dotInv === undefined) { throw new EvaluationError(_t("Matrix is not invertible")); } let SSE = 0, SSR = 0; for (let i = 0; i < n; i++) { const yi = y[i] - avgY; let temp = 0; for (let j = 0; j < nVar; j++) { const xi = redX[i][j]; temp += xi * coeffs[j][0]; } const ei = yi - temp; SSE += ei * ei; SSR += temp * temp; } const RMSE = Math.sqrt(SSE / nDeg); const r2 = SSR / (SSR + SSE); const f_stat = SSR / nVar / (SSE / nDeg); const deltaCoeffs = []; for (let i = 0; i < nVar; i++) { deltaCoeffs.push(RMSE * Math.sqrt(dotInv[i][i])); } if (computeIntercept) { const dot2 = multiplyMatrices(dotInv, [avgX]); const dot3 = multiplyMatrices(transposeMatrix([avgX]), dot2); deltaCoeffs.push(RMSE * Math.sqrt(dot3[0][0] + 1 / y.length)); } const returned = [ [coeffs[0][0], deltaCoeffs[0], r2, f_stat, SSR], [coeffs[1][0], deltaCoeffs[1], RMSE, nDeg, SSE], ]; for (let i = 2; i < nVar; i++) { returned.push([coeffs[i][0], deltaCoeffs[i], "", "", ""]); } if (computeIntercept) { returned.push([coeffs[nVar][0], deltaCoeffs[nVar], "", "", ""]); } else { returned.push([0, "", "", "", ""]); } return returned; } /* This function performs a polynomial regression on the data set. It returns the coefficients of the polynomial function that best fits the data set. The polynomial function is: y = c0 + c1*x + c2*x^2 + ... + cn*x^n, where n is the order (degree) of the polynomial. The returned coefficients are then in the form: [c0, c1, c2, ..., cn] The function is based on the method of least squares : see: https://mathworld.wolfram.com/LeastSquaresFittingPolynomial.html */ function polynomialRegression(flatY, flatX, order, intercept) { assertSameNumberOfElements(flatX, flatY); assert(() => order >= 1, _t("Function [[FUNCTION_NAME]] A regression of order less than 1 cannot be possible.")); const yMatrix = [flatY]; const xMatrix = flatX.map((x) => range(0, order).map((i) => Math.pow(x, order - i))); if (intercept) { xMatrix.forEach((row) => row.push(1)); } const coeffs = getLMSCoefficients(xMatrix, yMatrix); if (!intercept) { coeffs.push([0]); } return coeffs; } function getLMSCoefficients(xMatrix, yMatrix) { const xMatrixT = transposeMatrix(xMatrix); const dot1 = multiplyMatrices(xMatrix, xMatrixT); const { inverted: dotInv } = invertMatrix(dot1); if (dotInv === undefined) { throw new EvaluationError(_t("Matrix is not invertible")); } const dot2 = multiplyMatrices(xMatrix, yMatrix); return transposeMatrix(multiplyMatrices(dotInv, dot2)); } function evaluatePolynomial(coeffs, x, order) { return coeffs.reduce((acc, coeff, i) => acc + coeff * Math.pow(x, order - i), 0); } function expM(M) { return M.map((col) => col.map((cell) => Math.exp(cell))); } function logM(M) { return M.map((col) => col.map((cell) => Math.log(cell))); } function predictLinearValues(Y, X, newX, computeIntercept) { const { _X, _newX } = prepareDataForRegression(X, Y, newX); const coeffs = fullLinearRegression(_X, Y, computeIntercept, false); const nVar = coeffs.length - 1; const newY = _newX.map((col) => { let value = 0; for (let i = 0; i < nVar; i++) { value += coeffs[i][0] * col[nVar - i - 1]; } value += coeffs[nVar][0]; return [value]; }); return newY.length === newX.length ? newY : transposeMatrix(newY); } const PREVIOUS_VALUE = "(previous)"; const NEXT_VALUE = "(next)"; function getDomainOfParentRow(pivot, domain) { const { colDomain, rowDomain } = domainToColRowDomain(pivot, domain); return [...colDomain, ...rowDomain.slice(0, rowDomain.length - 1)]; } function getDomainOfParentCol(pivot, domain) { const { colDomain, rowDomain } = domainToColRowDomain(pivot, domain); return [...colDomain.slice(0, colDomain.length - 1), ...rowDomain]; } /** * Split a pivot domain into the part related to the rows of the pivot, and the part related to the columns. */ function domainToColRowDomain(pivot, domain) { const rowFields = pivot.definition.rows.map((c) => c.nameWithGranularity); const rowDomain = domain.filter((node) => rowFields.includes(node.field)); const columnFields = pivot.definition.columns.map((c) => c.nameWithGranularity); const colDomain = domain.filter((node) => columnFields.includes(node.field)); return { colDomain, rowDomain }; } function getDimensionDomain(pivot, dimension, domain) { return dimension === "column" ? domainToColRowDomain(pivot, domain).colDomain : domainToColRowDomain(pivot, domain).rowDomain; } function getFieldValueInDomain(fieldNameWithGranularity, domain) { const node = domain.find((n) => n.field === fieldNameWithGranularity); return node?.value; } function isDomainIsInPivot(pivot, domain) { const { rowDomain, colDomain } = domainToColRowDomain(pivot, domain); return (checkIfDomainInInTree(rowDomain, pivot.getTableStructure().getRowTree()) && checkIfDomainInInTree(colDomain, pivot.getTableStructure().getColTree())); } function checkIfDomainInInTree(domain, tree) { return walkDomainTree(domain, tree) !== undefined; } /** * Given a tree of the col/rows of a pivot, and a domain related to those col/rows, return the node of the tree * corresponding to the domain. * * @param domain The domain to find in the tree * @param tree The tree to search in7 * @param stopAtField If provided, the search will stop at the field with this name */ function walkDomainTree(domain, tree, stopAtField) { let currentTreeNode = tree; for (const node of domain) { const child = currentTreeNode.find((n) => n.value === node.value); if (!child) { return undefined; } if (child.field === stopAtField) { return currentTreeNode; } currentTreeNode = child.children; } return currentTreeNode; } /** * Get the domain parent of the given domain with the field `parentFieldName` as leaf of the domain. * * In practice, if the `parentFieldName` is a row in the pivot, the helper will return a domain with the same column * domain, and with a row domain all groupBys children to `parentFieldName` removed. */ function getFieldParentDomain(pivot, parentFieldName, domain) { let { rowDomain, colDomain } = domainToColRowDomain(pivot, domain); const dimension = getFieldDimensionType(pivot, parentFieldName); if (dimension === "row") { const index = rowDomain.findIndex((node) => node.field === parentFieldName); if (index === -1) { return domain; } rowDomain = rowDomain.slice(0, index + 1); } else { const index = colDomain.findIndex((node) => node.field === parentFieldName); if (index === -1) { return domain; } colDomain = colDomain.slice(0, index + 1); } return [...rowDomain, ...colDomain]; } /** * Replace in the domain the value of the field `fieldNameWithGranularity` with the given `value` */ function replaceFieldValueInDomain(domain, fieldNameWithGranularity, value) { domain = deepCopy(domain); const node = domain.find((n) => n.field === fieldNameWithGranularity); if (!node) { return domain; } node.value = value; return domain; } function isFieldInDomain(nameWithGranularity, domain) { return domain.some((node) => node.field === nameWithGranularity); } /** * Check if the field is in the rows or columns of the pivot */ function getFieldDimensionType(pivot, nameWithGranularity) { const rowFields = pivot.definition.rows.map((c) => c.nameWithGranularity); if (rowFields.includes(nameWithGranularity)) { return "row"; } const columnFields = pivot.definition.columns.map((c) => c.nameWithGranularity); if (columnFields.includes(nameWithGranularity)) { return "column"; } throw new Error(`Field ${nameWithGranularity} not found in pivot`); } /** * Replace in the given domain the value of the field `fieldNameWithGranularity` with the previous or next value. */ function getPreviousOrNextValueDomain(pivot, domain, fieldNameWithGranularity, direction) { const dimension = getFieldDimensionType(pivot, fieldNameWithGranularity); const tree = dimension === "row" ? pivot.getTableStructure().getRowTree() : pivot.getTableStructure().getColTree(); const dimDomain = getDimensionDomain(pivot, dimension, domain); const currentTreeNode = walkDomainTree(dimDomain, tree, fieldNameWithGranularity); const values = currentTreeNode?.map((n) => n.value) ?? []; const value = getFieldValueInDomain(fieldNameWithGranularity, domain); if (value === undefined) { return undefined; } const valueIndex = values.indexOf(value); if (value === undefined || valueIndex === -1) { return undefined; } const offset = direction === PREVIOUS_VALUE ? -1 : 1; const newIndex = clip(valueIndex + offset, 0, values.length - 1); return replaceFieldValueInDomain(domain, fieldNameWithGranularity, values[newIndex]); } function domainToString(domain) { return domain ? domain.map(domainNodeToString).join(", ") : ""; } function domainNodeToString(domainNode) { return domainNode ? `${domainNode.field}=${domainNode.value}` : ""; } /** * * For the ranking, the pivot cell values of a column (or row) at the same depth are grouped together before being sorted * and ranked. * * The grouping of a pivot cell is done with both the value of the domain nodes that are parent of the field * `fieldNameWithGranularity` and the value of the last node of the domain of the pivot cell, if it's not the field * `fieldNameWithGranularity`. * * For example, let's take a pivot grouped by (Date:year, Stage, User, Product), where we want to rank by "Stage" field. * The domain nodes parents of the "Stage" are [Date:year]. The pivot cell with domain: * - [Date:year=2021] is not ranked because it does not contain the "Stage" field * - [Date:year=2021, Stage=Lead] is grouped with the cells [Date:year=2021, Stage=*, User=None, Product=None], * and then ranked within the group * - [Date:year=2021, Stage=Lead, User=Bob] is grouped with the cells [Date:year=2021, Stage=*, User=Bob, Product=None], * and then ranked within the group * - [Date:year=2021, Stage=Lead, User=Bob, Product=Table] is grouped with the cells [Date:year=2021, Stage=*, User=*, Product=Table], * and then ranked within the group * * If we rank the pivot on "User" instead, the parent domain becomes [Date:year, Sage] .The cell with domain: * - [Date:year=2021] is not ranked because it does not contain the "Stage" field * - [Date:year=2021, Stage=Lead] is not ranked because it does not contain the "User" field * - [Date:year=2021, Stage=Lead, User=Bob] is grouped with the cells [Date:year=2021, Stage=Lead, User=Bob, Product=None], * and then ranked within the group * - [Date:year=2021, Stage=Lead, User=Bob, Product=Table] is grouped with the cells with [Date:year=2021, Stage=Lead, User=*, Product=Table], * and then ranked within the group * */ function getRankingDomainKey(domain, fieldNameWithGranularity) { const index = domain.findIndex((node) => node.field === fieldNameWithGranularity); if (index === -1) { return ""; } const parent = domain.slice(0, index); const lastNode = domain.at(-1); return domainToString(lastNode.field === fieldNameWithGranularity ? parent : [...parent, lastNode]); } /** * The running total domain is the domain without the field `fieldNameWithGranularity`, ie. we do the running total of * all the pivot cells of the column that have any value for the field `fieldNameWithGranularity` and the same value for * the other fields. */ function getRunningTotalDomainKey(domain, fieldNameWithGranularity) { const index = domain.findIndex((node) => node.field === fieldNameWithGranularity); if (index === -1) { return ""; } return domainToString([...domain.slice(0, index), ...domain.slice(index + 1)]); } const pivotTimeAdapterRegistry = new Registry(); function pivotTimeAdapter(granularity) { return pivotTimeAdapterRegistry.get(granularity); } /** * The Time Adapter: Managing Time Periods for Pivot Functions * * Overview: * A time adapter is responsible for managing time periods associated with pivot functions. * Each type of period (day, week, month, quarter, etc.) has its own dedicated adapter. * The adapter's primary role is to normalize period values between spreadsheet functions, * and the pivot. * By normalizing the period value, it can be stored consistently in the pivot. * * Normalization Process: * When working with functions in the spreadsheet, the time adapter normalizes * the provided period to facilitate accurate lookup of values in the pivot. * For instance, if the spreadsheet function represents a day period as a number generated * by the DATE function (DATE(2023, 12, 25)), the time adapter will normalize it accordingly. * */ /** * Normalized value: "12/25/2023" * * Note: Those two format are equivalent: * - "MM/dd/yyyy" (luxon format) * - "mm/dd/yyyy" (spreadsheet format) **/ const dayAdapter = { normalizeFunctionValue(value) { return toNumber(value, DEFAULT_LOCALE); }, toValueAndFormat(normalizedValue, locale) { return { value: toNumber(normalizedValue, DEFAULT_LOCALE), format: "dd mmm yyyy", }; }, toFunctionValue(normalizedValue) { const date = toNumber(normalizedValue, DEFAULT_LOCALE); return `"${formatValue(date, { locale: DEFAULT_LOCALE, format: "mm/dd/yyyy" })}"`; }, }; /** * normalizes day of month number */ const dayOfMonthAdapter = { normalizeFunctionValue(value) { const day = toNumber(value, DEFAULT_LOCALE); if (day < 1 || day > 31) { throw new EvaluationError(_t("%s is not a valid day of month (it should be a number between 1 and 31)", day)); } return day; }, toValueAndFormat(normalizedValue) { return { value: toNumber(normalizedValue, DEFAULT_LOCALE), format: "0", }; }, toFunctionValue(normalizedValue) { return `${normalizedValue}`; }, }; /** * normalizes day of week number * * The day of week is a bit special as it depends on the locale week start day. * =PIVOT.VALUE(1, "xx:day_of_week", 1) will be different depending on the locale * - fr_FR: 1: Monday, 7: Sunday (weekStart = 1) * - en_US: 1: Sunday, 7: Saturday (weekStart = 7) * * The function that normalizes the value coming from the function * (`normalizeFunctionValue`) will return the day of week (1 based index) * depending on the locale week start day. * To display the value in the pivot, we need to convert it to retrieve the * correct day of week name (1 should be "Monday" in fr_FR and "Sunday" in en_US). */ const dayOfWeekAdapter = { normalizeFunctionValue(value) { const day = toNumber(value, DEFAULT_LOCALE); if (day < 1 || day > 7) { throw new EvaluationError(_t("%s is not a valid day of week (it should be a number between 1 and 7)", day)); } return day; }, toValueAndFormat(normalizedValue, locale) { /** * As explain above, normalizedValue is the day of week (1 based index) * depending on the locale week start day. To retrieve the correct day name, * we need to convert it to a 0 based index with 0 being Sunday. (DAYS is * an object of day names with 0 being Sunday) */ const index = (normalizedValue - 1 + (locale || DEFAULT_LOCALE).weekStart) % 7; return { value: DAYS$1[index].toString(), format: "@", }; }, toFunctionValue(normalizedValue) { return `${normalizedValue}`; }, }; /** * normalizes iso week number */ const isoWeekNumberAdapter = { normalizeFunctionValue(value) { const isoWeek = toNumber(value, DEFAULT_LOCALE); if (isoWeek < 0 || isoWeek > 53) { throw new EvaluationError(_t("%s is not a valid week (it should be a number between 0 and 53)", isoWeek)); } return isoWeek; }, toValueAndFormat(normalizedValue) { return { value: toNumber(normalizedValue, DEFAULT_LOCALE), format: "0", }; }, toFunctionValue(normalizedValue) { return `${normalizedValue}`; }, }; /** * normalizes month number */ const monthNumberAdapter = { normalizeFunctionValue(value) { const month = toNumber(value, DEFAULT_LOCALE); if (month < 1 || month > 12) { throw new EvaluationError(_t("%s is not a valid month (it should be a number between 1 and 12)", month)); } return month; }, toValueAndFormat(normalizedValue) { return { value: MONTHS[toNumber(normalizedValue, DEFAULT_LOCALE) - 1].toString(), format: "@", }; }, toFunctionValue(normalizedValue) { return `${normalizedValue}`; }, }; /** * normalizes quarter number */ const quarterNumberAdapter = { normalizeFunctionValue(value) { const quarter = toNumber(value, DEFAULT_LOCALE); if (quarter < 1 || quarter > 4) { throw new EvaluationError(_t("%s is not a valid quarter (it should be a number between 1 and 4)", quarter)); } return quarter; }, toValueAndFormat(normalizedValue) { return { value: _t("Q%(quarter_number)s", { quarter_number: normalizedValue }), format: "@", }; }, toFunctionValue(normalizedValue) { return `${normalizedValue}`; }, }; const yearAdapter = { normalizeFunctionValue(value) { return toNumber(value, DEFAULT_LOCALE); }, toValueAndFormat(normalizedValue) { return { value: toNumber(normalizedValue, DEFAULT_LOCALE), format: "0", }; }, toFunctionValue(normalizedValue) { return `${normalizedValue}`; }, }; /** * normalizes hour number */ const hourNumberAdapter = { normalizeFunctionValue(value) { const hour = toNumber(value, DEFAULT_LOCALE); if (hour < 0 || hour > 23) { throw new EvaluationError(_t("%s is not a valid hour (it should be a number between 0 and 23)", hour)); } return hour; }, toValueAndFormat(normalizedValue) { return { value: _t("%(hour_number)sh", { hour_number: normalizedValue }), format: "@", }; }, toFunctionValue(normalizedValue) { return `${normalizedValue}`; }, }; /** * normalizes hour number */ const minuteNumberAdapter = { normalizeFunctionValue(value) { const minute = toNumber(value, DEFAULT_LOCALE); if (minute < 0 || minute > 59) { throw new EvaluationError(_t("%s is not a valid minute (it should be a number between 0 and 59)", minute)); } return minute; }, toValueAndFormat(normalizedValue) { return { value: _t("%(minute_number)s'", { minute_number: normalizedValue }), format: "@", }; }, toFunctionValue(normalizedValue) { return `${normalizedValue}`; }, }; /** * normalizes second number */ const secondNumberAdapter = { normalizeFunctionValue(value) { const second = toNumber(value, DEFAULT_LOCALE); if (second < 0 || second > 59) { throw new EvaluationError(_t("%s is not a valid second (it should be a number between 0 and 59)", second)); } return second; }, toValueAndFormat(normalizedValue) { return { value: _t("%(second_number)s''", { second_number: normalizedValue }), format: "@", }; }, toFunctionValue(normalizedValue) { return `${normalizedValue}`; }, }; /** * This function takes an adapter and wraps it with a null handler. * null value means that the value is not set. */ function nullHandlerDecorator(adapter) { return { normalizeFunctionValue(value) { if (value === null) { return null; } return adapter.normalizeFunctionValue(value); }, toValueAndFormat(normalizedValue, locale) { if (normalizedValue === null) { return { value: _t("(Undefined)") }; //TODO Return NA ? } return adapter.toValueAndFormat(normalizedValue, locale); }, toFunctionValue(normalizedValue) { if (normalizedValue === null) { return "false"; //TODO Return NA ? } return adapter.toFunctionValue(normalizedValue); }, }; } pivotTimeAdapterRegistry .add("day", nullHandlerDecorator(dayAdapter)) .add("year", nullHandlerDecorator(yearAdapter)) .add("day_of_month", nullHandlerDecorator(dayOfMonthAdapter)) .add("iso_week_number", nullHandlerDecorator(isoWeekNumberAdapter)) .add("month_number", nullHandlerDecorator(monthNumberAdapter)) .add("quarter_number", nullHandlerDecorator(quarterNumberAdapter)) .add("day_of_week", nullHandlerDecorator(dayOfWeekAdapter)) .add("hour_number", nullHandlerDecorator(hourNumberAdapter)) .add("minute_number", nullHandlerDecorator(minuteNumberAdapter)) .add("second_number", nullHandlerDecorator(secondNumberAdapter)); const AGGREGATOR_NAMES = { count: _t("Count"), count_distinct: _t("Count Distinct"), bool_and: _t("Boolean And"), bool_or: _t("Boolean Or"), max: _t("Maximum"), min: _t("Minimum"), avg: _t("Average"), sum: _t("Sum"), }; const NUMBER_CHAR_AGGREGATORS = ["max", "min", "avg", "sum", "count_distinct", "count"]; const AGGREGATORS_BY_FIELD_TYPE = { integer: NUMBER_CHAR_AGGREGATORS, char: NUMBER_CHAR_AGGREGATORS, boolean: ["count_distinct", "count", "bool_and", "bool_or"], }; const AGGREGATORS = {}; for (const type in AGGREGATORS_BY_FIELD_TYPE) { AGGREGATORS[type] = {}; for (const aggregator of AGGREGATORS_BY_FIELD_TYPE[type]) { AGGREGATORS[type][aggregator] = AGGREGATOR_NAMES[aggregator]; } } const AGGREGATORS_FN = { count: (args) => ({ value: countAny([args]), format: "0", }), count_distinct: (args) => ({ value: countUnique([args]), format: "0", }), bool_and: (args) => ({ value: boolAnd([args]).result, }), bool_or: (args) => ({ value: boolOr([args]).result, }), max: (args, locale) => max([args], locale), min: (args, locale) => min([args], locale), avg: (args, locale) => ({ value: average([args], locale), format: inferFormat(args), }), sum: (args, locale) => ({ value: sum([args], locale), format: inferFormat(args), }), }; /** * Given an object of form {"1": {...}, "2": {...}, ...} get the maximum ID used * in this object * If the object has no keys, return 0 * */ function getMaxObjectId(o) { const keys = Object.keys(o); if (!keys.length) { return 0; } const nums = keys.map((id) => parseInt(id, 10)); const max = Math.max(...nums); return max; } const ALL_PERIODS = { year: _t("Year"), quarter: _t("Quarter & Year"), month: _t("Month & Year"), week: _t("Week & Year"), day: _t("Day"), quarter_number: _t("Quarter"), month_number: _t("Month"), iso_week_number: _t("Week"), day_of_month: _t("Day of Month"), day_of_week: _t("Day of Week"), hour_number: _t("Hour"), minute_number: _t("Minute"), second_number: _t("Second"), }; const DATE_FIELDS = ["date", "datetime"]; /** * Parse a dimension string into a pivot dimension definition. * e.g "create_date:month" => { name: "create_date", granularity: "month" } */ function parseDimension(dimension) { const [fieldName, granularity] = dimension.split(":"); if (granularity) { return { fieldName, granularity }; } return { fieldName }; } function isDateOrDatetimeField(field) { return DATE_FIELDS.includes(field.type); } function generatePivotArgs(formulaId, domain, measure) { const args = [formulaId]; if (measure) { args.push(`"${measure}"`); } for (const { field, value, type } of domain) { if (field === "measure") { args.push(`"measure"`, `"${value}"`); continue; } const { granularity } = parseDimension(field); const formattedValue = toFunctionPivotValue(value, { type, granularity }); args.push(`"${field}"`, formattedValue); } return args; } /** * Check if the fields in the domain part of * a pivot function are valid according to the pivot definition. * e.g. =PIVOT.VALUE(1,"revenue","country_id",...,"create_date:month",...,"source_id",...) */ function areDomainArgsFieldsValid(dimensions, definition) { let argIndex = 0; let definitionIndex = 0; const cols = definition.columns.map((col) => col.nameWithGranularity); const rows = definition.rows.map((row) => row.nameWithGranularity); while (dimensions[argIndex] !== undefined && dimensions[argIndex] === rows[definitionIndex]) { argIndex++; definitionIndex++; } definitionIndex = 0; while (dimensions[argIndex] !== undefined && dimensions[argIndex] === cols[definitionIndex]) { argIndex++; definitionIndex++; } return dimensions.length === argIndex; } function createPivotFormula(formulaId, cell) { switch (cell.type) { case "HEADER": return `=PIVOT.HEADER(${generatePivotArgs(formulaId, cell.domain).join(",")})`; case "VALUE": return `=PIVOT.VALUE(${generatePivotArgs(formulaId, cell.domain, cell.measure).join(",")})`; case "MEASURE_HEADER": return `=PIVOT.HEADER(${generatePivotArgs(formulaId, [ ...cell.domain, { field: "measure", value: cell.measure, type: "char" }, ]).join(",")})`; } return ""; } /** * Parses the value defining a pivot group in a PIVOT formula * e.g. given the following formula PIVOT.VALUE("1", "stage_id", "42", "status", "won"), * the two group values are "42" and "won". */ function toNormalizedPivotValue(dimension, groupValue) { if (groupValue === null || groupValue === "null") { return null; } const groupValueString = typeof groupValue === "boolean" ? toString(groupValue).toLocaleLowerCase() : toString(groupValue); if (groupValueString === "null") { return null; } if (!pivotNormalizationValueRegistry.contains(dimension.type)) { throw new EvaluationError(_t("Field %(field)s is not supported because of its type (%(type)s)", { field: dimension.displayName, type: dimension.type, })); } // represents a field which is not set (=False server side) if (groupValueString.toLowerCase() === "false") { return false; } const normalizer = pivotNormalizationValueRegistry.get(dimension.type); return normalizer(groupValueString, dimension.granularity); } function normalizeDateTime(value, granularity) { if (!granularity) { throw new Error("Missing granularity"); } return pivotTimeAdapter(granularity).normalizeFunctionValue(value); } function toFunctionPivotValue(value, dimension) { if (value === null) { return `"null"`; } if (!pivotToFunctionValueRegistry.contains(dimension.type)) { return `"${value}"`; } return pivotToFunctionValueRegistry.get(dimension.type)(value, dimension.granularity); } function toFunctionValueDateTime(value, granularity) { if (!granularity) { throw new Error("Missing granularity"); } return pivotTimeAdapter(granularity).toFunctionValue(value); } const pivotNormalizationValueRegistry = new Registry(); pivotNormalizationValueRegistry .add("date", normalizeDateTime) .add("datetime", normalizeDateTime) .add("integer", (value) => toNumber(value, DEFAULT_LOCALE)) .add("boolean", (value) => toBoolean(value)) .add("char", (value) => toString(value)); const pivotToFunctionValueRegistry = new Registry(); pivotToFunctionValueRegistry .add("date", toFunctionValueDateTime) .add("datetime", toFunctionValueDateTime) .add("integer", (value) => `${toNumber(value, DEFAULT_LOCALE)}`) .add("boolean", (value) => (toBoolean(value) ? "TRUE" : "FALSE")) .add("char", (value) => `"${toString(value).replace(/"/g, '\\"')}"`); function getFieldDisplayName(field) { return field.displayName + (field.granularity ? ` (${ALL_PERIODS[field.granularity]})` : ""); } function addIndentAndAlignToPivotHeader(pivot, domain, functionResult) { const { rowDomain, colDomain } = domainToColRowDomain(pivot, domain); if (rowDomain.length === 0 && colDomain.length === 0) { return functionResult; } if (rowDomain.length === 0 && colDomain.length > 0) { return { ...functionResult, format: (functionResult.format || "@") + "* ", }; } const indent = rowDomain.length - 1; const format = functionResult.format || "@"; return { ...functionResult, format: `${" ".repeat(indent)}${format}* `, }; } class CellClipboardHandler extends AbstractCellClipboardHandler { isCutAllowed(data) { if (data.zones.length !== 1) { return "WrongCutSelection" /* CommandResult.WrongCutSelection */; } return "Success" /* CommandResult.Success */; } copy(data) { const sheetId = data.sheetId; const { clippedZones, rowsIndexes, columnsIndexes } = data; const clippedCells = []; const isCopyingOneCell = rowsIndexes.length == 1 && columnsIndexes.length == 1; for (let row of rowsIndexes) { let cellsInRow = []; for (let col of columnsIndexes) { const position = { col, row, sheetId }; let cell = this.getters.getCell(position); const evaluatedCell = this.getters.getEvaluatedCell(position); const pivotId = this.getters.getPivotIdFromPosition(position); const spreader = this.getters.getArrayFormulaSpreadingOn(position); if (pivotId && spreader) { const pivotZone = this.getters.getSpreadZone(spreader); if ((!deepEquals(spreader, position) || !isCopyingOneCell) && pivotZone && !data.zones.some((z) => isZoneInside(pivotZone, z))) { const pivotCell = this.getters.getPivotCellFromPosition(position); const formulaPivotId = this.getters.getPivotFormulaId(pivotId); const pivotFormula = createPivotFormula(formulaPivotId, pivotCell); cell = { id: cell?.id || "", style: cell?.style, format: cell?.format, content: pivotFormula, isFormula: false, parsedValue: evaluatedCell.value, }; } } else { if (spreader && !deepEquals(spreader, position)) { const isSpreaderCopied = rowsIndexes.includes(spreader.row) && columnsIndexes.includes(spreader.col); const content = isSpreaderCopied ? "" : formatValue(evaluatedCell.value, { locale: this.getters.getLocale() }); cell = { id: cell?.id || "", style: cell?.style, format: evaluatedCell.format, content, isFormula: false, parsedValue: evaluatedCell.value, }; } } cellsInRow.push({ content: cell?.content ?? "", style: cell?.style, format: cell?.format, tokens: cell?.isFormula ? cell.compiledFormula.tokens.map(({ value, type }) => ({ value, type })) : [], border: this.getters.getCellBorder(position) || undefined, evaluatedCell, position, }); } clippedCells.push(cellsInRow); } return { cells: clippedCells, zones: clippedZones, sheetId: data.sheetId, }; } isPasteAllowed(sheetId, target, content, clipboardOptions) { if (!content.cells) { return "Success" /* CommandResult.Success */; } if (clipboardOptions?.isCutOperation && clipboardOptions?.pasteOption !== undefined) { // cannot paste only format or only value if the previous operation is a CUT return "WrongPasteOption" /* CommandResult.WrongPasteOption */; } if (target.length > 1) { // cannot paste if we have a clipped zone larger than a cell and multiple // zones selected if (content.cells.length > 1 || content.cells[0].length > 1) { return "WrongPasteSelection" /* CommandResult.WrongPasteSelection */; } } const clipboardHeight = content.cells.length; const clipboardWidth = content.cells[0].length; for (const zone of getPasteZones(target, content.cells)) { if (this.getters.doesIntersectMerge(sheetId, zone)) { if (target.length > 1 || !this.getters.isSingleCellOrMerge(sheetId, target[0]) || clipboardHeight * clipboardWidth !== 1) { return "WillRemoveExistingMerge" /* CommandResult.WillRemoveExistingMerge */; } } } return "Success" /* CommandResult.Success */; } /** * Paste the clipboard content in the given target */ paste(target, content, options) { const zones = target.zones; const sheetId = target.sheetId; if (!options.isCutOperation) { this.pasteFromCopy(sheetId, zones, content.cells, options); } else { this.pasteFromCut(sheetId, zones, content, options); } } getPasteTarget(sheetId, target, content, options) { const width = content.cells[0].length; const height = content.cells.length; if (options?.isCutOperation) { return { sheetId, zones: [ { left: target[0].left, top: target[0].top, right: target[0].left + width - 1, bottom: target[0].top + height - 1, }, ], }; } if (width === 1 && height === 1) { return { zones: [], sheetId }; } return { sheetId, zones: getPasteZones(target, content.cells) }; } pasteFromCut(sheetId, target, content, options) { this.clearClippedZones(content); const selection = target[0]; this.pasteZone(sheetId, selection.left, selection.top, content.cells, options); this.dispatch("MOVE_RANGES", { target: content.zones, sheetId: content.sheetId, targetSheetId: sheetId, col: selection.left, row: selection.top, }); } /** * Clear the clipped zones: remove the cells and clear the formatting */ clearClippedZones(content) { this.dispatch("CLEAR_CELLS", { sheetId: content.sheetId, target: content.zones, }); this.dispatch("CLEAR_FORMATTING", { sheetId: content.sheetId, target: content.zones, }); } pasteZone(sheetId, col, row, cells, clipboardOptions) { // then, perform the actual paste operation for (const [r, rowCells] of cells.entries()) { for (const [c, origin] of rowCells.entries()) { if (!origin) { continue; } const position = { col: col + c, row: row + r, sheetId }; this.pasteCell(origin, position, clipboardOptions); } } } /** * Paste the cell at the given position to the target position */ pasteCell(origin, target, clipboardOption) { const { sheetId, col, row } = target; const targetCell = this.getters.getEvaluatedCell(target); const originFormat = origin?.format ?? origin.evaluatedCell.format; if (clipboardOption?.pasteOption === "asValue") { this.dispatch("UPDATE_CELL", { ...target, content: origin.evaluatedCell.value?.toString() || "", format: originFormat, }); return; } if (clipboardOption?.pasteOption === "onlyFormat") { this.dispatch("UPDATE_CELL", { ...target, style: origin?.style ?? null, format: originFormat ?? targetCell.format, }); return; } let content = origin?.content; if (origin?.tokens && origin.tokens.length > 0 && !clipboardOption?.isCutOperation) { content = this.getters.getTranslatedCellFormula(sheetId, col - origin.position.col, row - origin.position.row, origin.tokens); } else if (origin?.tokens && origin.tokens.length > 0) { content = this.getters.getFormulaMovedInSheet(origin.position.sheetId, sheetId, origin.tokens); } if (content !== "" || origin?.format || origin?.style) { this.dispatch("UPDATE_CELL", { ...target, content, style: origin?.style || null, format: origin?.format, }); } else if (targetCell) { this.dispatch("CLEAR_CELL", target); } } convertTextToClipboardData(text) { const locale = this.getters.getLocale(); const copiedData = { cells: [], }; const values = []; let rowLength = 0; for (const [i, row] of text.replace(/\r/g, "").split("\n").entries()) { values.push(row.split("\t")); if (values[i].length > rowLength) { rowLength = values[i].length; } } for (const row of values) { const cells = []; for (let i = 0; i < rowLength; i++) { const content = canonicalizeNumberValue(row[i] || "", locale); cells.push({ content: content, evaluatedCell: { formattedValue: content, }, }); } copiedData.cells.push(cells); } return copiedData; } } class AbstractFigureClipboardHandler extends ClipboardHandler { copy(data) { return; } } class ChartClipboardHandler extends AbstractFigureClipboardHandler { copy(data) { const sheetId = data.sheetId; const figure = this.getters.getFigure(sheetId, data.figureId); if (!figure) { throw new Error(`No figure for the given id: ${data.figureId}`); } if (figure.tag !== "chart") { return; } const copiedFigure = { ...figure }; const chart = this.getters.getChart(data.figureId); if (!chart) { throw new Error(`No chart for the given id: ${data.figureId}`); } const copiedChart = chart.copyInSheetId(sheetId); return { figureId: data.figureId, copiedFigure, copiedChart, }; } getPasteTarget(sheetId, target, content, options) { const newId = new UuidGenerator().uuidv4(); return { zones: [], figureId: newId, sheetId }; } paste(target, clippedContent, options) { if (!target.figureId) { return; } const { zones, figureId } = target; const sheetId = target.sheetId; const numCols = this.getters.getNumberCols(sheetId); const numRows = this.getters.getNumberRows(sheetId); const targetX = this.getters.getColDimensions(sheetId, zones[0].left).start; const targetY = this.getters.getRowDimensions(sheetId, zones[0].top).start; const maxX = this.getters.getColDimensions(sheetId, numCols - 1).end; const maxY = this.getters.getRowDimensions(sheetId, numRows - 1).end; const { width, height } = clippedContent.copiedFigure; const position = { x: maxX < width ? 0 : Math.min(targetX, maxX - width), y: maxY < height ? 0 : Math.min(targetY, maxY - height), }; const copy = clippedContent.copiedChart.copyInSheetId(sheetId); this.dispatch("CREATE_CHART", { id: figureId, sheetId, position, size: { height, width }, definition: copy.getDefinition(), }); if (options.isCutOperation) { this.dispatch("DELETE_FIGURE", { sheetId: clippedContent.copiedChart.sheetId, id: clippedContent.copiedFigure.id, }); } this.dispatch("SELECT_FIGURE", { id: figureId }); } isPasteAllowed(sheetId, target, content, option) { if (target.length === 0) { return "EmptyTarget" /* CommandResult.EmptyTarget */; } if (option?.pasteOption !== undefined) { return "WrongFigurePasteOption" /* CommandResult.WrongFigurePasteOption */; } return "Success" /* CommandResult.Success */; } } class ConditionalFormatClipboardHandler extends AbstractCellClipboardHandler { uuidGenerator = new UuidGenerator(); queuedChanges = {}; copy(data) { if (!data.zones.length) { return; } const { rowsIndexes, columnsIndexes } = data; const sheetId = data.sheetId; const cfRules = []; for (const row of rowsIndexes) { const cfRuleInRow = []; for (const col of columnsIndexes) { const cfRules = Array.from(this.getters.getRulesByCell(sheetId, col, row)); cfRuleInRow.push({ position: { col, row, sheetId }, rules: cfRules, }); } cfRules.push(cfRuleInRow); } return { cfRules }; } paste(target, clippedContent, options) { this.queuedChanges = {}; if (options.pasteOption === "asValue") { return; } const zones = target.zones; const sheetId = target.sheetId; if (!options.isCutOperation) { this.pasteFromCopy(sheetId, zones, clippedContent.cfRules, options); } else { this.pasteFromCut(sheetId, zones, clippedContent); } this.executeQueuedChanges(); } pasteFromCut(sheetId, target, content) { const selection = target[0]; this.pasteZone(sheetId, selection.left, selection.top, content.cfRules, { isCutOperation: true, }); } pasteZone(sheetId, col, row, cfRules, clipboardOptions) { for (const [r, rowCells] of cfRules.entries()) { for (const [c, origin] of rowCells.entries()) { const position = { col: col + c, row: row + r, sheetId }; this.pasteCf(origin, position, clipboardOptions?.isCutOperation); } } } pasteCf(origin, target, isCutOperation) { if (origin?.rules && origin.rules.length > 0) { const zone = positionToZone(target); for (const rule of origin.rules) { const toRemoveZones = []; if (isCutOperation) { //remove from current rule toRemoveZones.push(positionToZone(origin.position)); } if (origin.position.sheetId === target.sheetId) { this.adaptCFRules(origin.position.sheetId, rule, [zone], toRemoveZones); } else { this.adaptCFRules(origin.position.sheetId, rule, [], toRemoveZones); const cfToCopyTo = this.getCFToCopyTo(target.sheetId, rule); this.adaptCFRules(target.sheetId, cfToCopyTo, [zone], []); } } } } /** * Add or remove cells to a given conditional formatting rule. */ adaptCFRules(sheetId, cf, toAdd, toRemove) { if (!this.queuedChanges[sheetId]) { this.queuedChanges[sheetId] = []; } const queuedChange = this.queuedChanges[sheetId].find((queued) => queued.cf.id === cf.id); if (!queuedChange) { this.queuedChanges[sheetId].push({ toAdd, toRemove, cf }); } else { queuedChange.toAdd.push(...toAdd); queuedChange.toRemove.push(...toRemove); } } executeQueuedChanges() { for (const sheetId in this.queuedChanges) { for (const { toAdd, toRemove, cf } of this.queuedChanges[sheetId]) { const newRangesXc = this.getters.getAdaptedCfRanges(sheetId, cf, toAdd, toRemove); if (!newRangesXc) { continue; } if (newRangesXc.length === 0) { this.dispatch("REMOVE_CONDITIONAL_FORMAT", { id: cf.id, sheetId }); continue; } this.dispatch("ADD_CONDITIONAL_FORMAT", { cf: { id: cf.id, rule: cf.rule, stopIfTrue: cf.stopIfTrue, }, ranges: newRangesXc, sheetId, }); } } } getCFToCopyTo(targetSheetId, originCF) { let targetCF = this.getters .getConditionalFormats(targetSheetId) .find((cf) => cf.stopIfTrue === originCF.stopIfTrue && deepEquals(cf.rule, originCF.rule)); const queuedCfs = this.queuedChanges[targetSheetId]; if (!targetCF && queuedCfs) { targetCF = queuedCfs.find((queued) => queued.cf.stopIfTrue === originCF.stopIfTrue && deepEquals(queued.cf.rule, originCF.rule))?.cf; } return targetCF || { ...originCF, id: this.uuidGenerator.uuidv4(), ranges: [] }; } } class DataValidationClipboardHandler extends AbstractCellClipboardHandler { uuidGenerator = new UuidGenerator(); queuedChanges = {}; copy(data) { const { rowsIndexes, columnsIndexes } = data; const sheetId = data.sheetId; const dvRules = []; for (const row of rowsIndexes) { const dvRuleInRow = []; for (const col of columnsIndexes) { const position = { sheetId, col, row }; const rule = this.getters.getValidationRuleForCell(position); dvRuleInRow.push({ position, rule }); } dvRules.push(dvRuleInRow); } return { dvRules }; } paste(target, clippedContent, options) { this.queuedChanges = {}; if (options.pasteOption) { return; } const zones = target.zones; const sheetId = target.sheetId; if (!options.isCutOperation) { this.pasteFromCopy(sheetId, zones, clippedContent.dvRules); } else { this.pasteFromCut(sheetId, zones, clippedContent); } this.executeQueuedChanges(); } pasteFromCut(sheetId, target, content) { const selection = target[0]; this.pasteZone(sheetId, selection.left, selection.top, content.dvRules, { isCutOperation: true, }); } pasteZone(sheetId, col, row, dvRules, clipboardOptions) { for (const [r, rowCells] of dvRules.entries()) { for (const [c, origin] of rowCells.entries()) { const position = { col: col + c, row: row + r, sheetId }; this.pasteDataValidation(origin, position, clipboardOptions?.isCutOperation); } } } pasteDataValidation(origin, target, isCutOperation) { if (origin) { const zone = positionToZone(target); const rule = origin.rule; if (!rule) { const targetRule = this.getters.getValidationRuleForCell(target); if (targetRule) { // Remove the data validation rule on the target cell this.adaptDataValidationRule(target.sheetId, targetRule, [], [zone]); } return; } const toRemoveZone = []; if (isCutOperation) { toRemoveZone.push(positionToZone(origin.position)); } if (origin.position.sheetId === target.sheetId) { const copyToRule = this.getDataValidationRuleToCopyTo(target.sheetId, rule, false); this.adaptDataValidationRule(origin.position.sheetId, copyToRule, [zone], toRemoveZone); } else { const originRule = this.getters.getValidationRuleForCell(origin.position); if (originRule) { this.adaptDataValidationRule(origin.position.sheetId, originRule, [], toRemoveZone); } const copyToRule = this.getDataValidationRuleToCopyTo(target.sheetId, rule); this.adaptDataValidationRule(target.sheetId, copyToRule, [zone], []); } } } getDataValidationRuleToCopyTo(targetSheetId, originRule, newId = true) { let targetRule = this.getters .getDataValidationRules(targetSheetId) .find((rule) => deepEquals(originRule.criterion, rule.criterion) && originRule.isBlocking === rule.isBlocking); const queuedRules = this.queuedChanges[targetSheetId]; if (!targetRule && queuedRules) { targetRule = queuedRules.find((queued) => deepEquals(originRule.criterion, queued.rule.criterion) && originRule.isBlocking === queued.rule.isBlocking)?.rule; } return (targetRule || { ...originRule, id: newId ? this.uuidGenerator.uuidv4() : originRule.id, ranges: [], }); } /** * Add or remove XCs to a given data validation rule. */ adaptDataValidationRule(sheetId, rule, toAdd, toRemove) { if (!this.queuedChanges[sheetId]) { this.queuedChanges[sheetId] = []; } const queuedChange = this.queuedChanges[sheetId].find((queued) => queued.rule.id === rule.id); if (!queuedChange) { this.queuedChanges[sheetId].push({ toAdd, toRemove, rule }); } else { queuedChange.toAdd.push(...toAdd); queuedChange.toRemove.push(...toRemove); } } executeQueuedChanges() { for (const sheetId in this.queuedChanges) { for (const { toAdd, toRemove, rule: dv } of this.queuedChanges[sheetId]) { // Remove the zones first in case the same position is in toAdd and toRemove const dvZones = dv.ranges.map((range) => range.zone); const withRemovedZones = recomputeZones(dvZones, toRemove); const newDvZones = recomputeZones([...withRemovedZones, ...toAdd], []); if (newDvZones.length === 0) { this.dispatch("REMOVE_DATA_VALIDATION_RULE", { sheetId, id: dv.id }); continue; } this.dispatch("ADD_DATA_VALIDATION_RULE", { rule: dv, ranges: newDvZones.map((zone) => this.getters.getRangeDataFromZone(sheetId, zone)), sheetId, }); } } } } class ImageClipboardHandler extends AbstractFigureClipboardHandler { copy(data) { const sheetId = data.sheetId; const figure = this.getters.getFigure(sheetId, data.figureId); if (!figure) { throw new Error(`No figure for the given id: ${data.figureId}`); } const copiedFigure = { ...figure }; if (figure.tag !== "image") { return; } const image = this.getters.getImage(data.figureId); const copiedImage = deepCopy(image); return { figureId: data.figureId, copiedFigure, copiedImage, sheetId, }; } getPasteTarget(sheetId, target, content, options) { const newId = new UuidGenerator().uuidv4(); return { sheetId, zones: [], figureId: newId }; } paste(target, clippedContent, options) { if (!target.figureId) { return; } const { zones, figureId } = target; const sheetId = this.getters.getActiveSheetId(); const numCols = this.getters.getNumberCols(sheetId); const numRows = this.getters.getNumberRows(sheetId); const targetX = this.getters.getColDimensions(sheetId, zones[0].left).start; const targetY = this.getters.getRowDimensions(sheetId, zones[0].top).start; const maxX = this.getters.getColDimensions(sheetId, numCols - 1).end; const maxY = this.getters.getRowDimensions(sheetId, numRows - 1).end; const { width, height } = clippedContent.copiedFigure; const position = { x: maxX < width ? 0 : Math.min(targetX, maxX - width), y: maxY < height ? 0 : Math.min(targetY, maxY - height), }; const copy = deepCopy(clippedContent.copiedImage); this.dispatch("CREATE_IMAGE", { figureId, sheetId, position, size: { height, width }, definition: copy, }); if (options.isCutOperation) { this.dispatch("DELETE_FIGURE", { sheetId: clippedContent.sheetId, id: clippedContent.copiedFigure.id, }); } this.dispatch("SELECT_FIGURE", { id: figureId }); } isPasteAllowed(sheetId, target, content, option) { if (target.length === 0) { return "EmptyTarget" /* CommandResult.EmptyTarget */; } if (option?.pasteOption !== undefined) { return "WrongFigurePasteOption" /* CommandResult.WrongFigurePasteOption */; } return "Success" /* CommandResult.Success */; } } class MergeClipboardHandler extends AbstractCellClipboardHandler { copy(data) { const sheetId = this.getters.getActiveSheetId(); const { rowsIndexes, columnsIndexes } = data; const merges = []; for (const row of rowsIndexes) { const mergesInRow = []; for (const col of columnsIndexes) { const position = { col, row, sheetId }; mergesInRow.push(this.getters.getMerge(position)); } merges.push(mergesInRow); } return { merges }; } /** * Paste the clipboard content in the given target */ paste(target, content, options) { if (options.isCutOperation) { return; } this.pasteFromCopy(target.sheetId, target.zones, content.merges, options); } pasteZone(sheetId, col, row, merges) { for (const [r, rowMerges] of merges.entries()) { for (const [c, originMerge] of rowMerges.entries()) { const position = { col: col + c, row: row + r, sheetId }; this.pasteMerge(originMerge, position); } } } pasteMerge(originMerge, target) { if (!originMerge) { return; } if (this.getters.isInMerge(target)) { return; } const { sheetId, col, row } = target; this.dispatch("ADD_MERGE", { sheetId, force: true, target: [ { left: col, top: row, right: col + originMerge.right - originMerge.left, bottom: row + originMerge.bottom - originMerge.top, }, ], }); } } class SheetClipboardHandler extends AbstractCellClipboardHandler { isPasteAllowed(sheetId, target, content, options) { if (!("cells" in content)) { return "Success" /* CommandResult.Success */; } const { xSplit, ySplit } = this.getters.getPaneDivisions(sheetId); for (const zone of getPasteZones(target, content.cells)) { if ((zone.left < xSplit && zone.right >= xSplit) || (zone.top < ySplit && zone.bottom >= ySplit)) { return "FrozenPaneOverlap" /* CommandResult.FrozenPaneOverlap */; } } return "Success" /* CommandResult.Success */; } } class TableClipboardHandler extends AbstractCellClipboardHandler { copy(data) { const sheetId = data.sheetId; const { rowsIndexes, columnsIndexes, zones } = data; const copiedTablesIds = new Set(); const tableCells = []; for (let row of rowsIndexes) { let tableCellsInRow = []; tableCells.push(tableCellsInRow); for (let col of columnsIndexes) { const position = { col, row, sheetId }; const table = this.getters.getTable(position); if (!table) { tableCellsInRow.push({}); continue; } const coreTable = this.getters.getCoreTable(position); const tableZone = coreTable?.range.zone; let copiedTable = undefined; // Copy whole table if (!copiedTablesIds.has(table.id) && coreTable && tableZone && zones.some((z) => isZoneInside(tableZone, z))) { copiedTablesIds.add(table.id); const values = []; for (const col of range(tableZone.left, tableZone.right + 1)) { values.push(this.getters.getFilterHiddenValues({ sheetId, col, row: tableZone.top })); } copiedTable = { range: coreTable.range.rangeData, config: coreTable.config, type: coreTable.type, }; } tableCellsInRow.push({ table: copiedTable, style: this.getTableStyleToCopy(position), isWholeTableCopied: copiedTablesIds.has(table.id), }); } } return { tableCells, sheetId: data.sheetId, }; } /** * Get the style to copy for a cell. We need to copy both the table style and the cell style, because * UPDATE_CELL replace the whole style of the cell with the style of the command, it doesn't merge the two. */ getTableStyleToCopy(cellPosition) { const styleFromTable = this.getters.getCellTableStyle(cellPosition); const cellStyle = this.getters.getCellStyle(cellPosition); const bordersFromTable = this.getters.getCellTableBorder(cellPosition); const cellBorder = this.getters.getCellBorder(cellPosition); return { style: { ...styleFromTable, ...removeFalsyAttributes(cellStyle) }, border: { ...bordersFromTable, ...removeFalsyAttributes(cellBorder) }, }; } paste(target, content, options) { const zones = target.zones; const sheetId = target.sheetId; if (!options.isCutOperation) { this.pasteFromCopy(sheetId, zones, content.tableCells, options); } else { this.pasteFromCut(sheetId, zones, content, options); } } pasteFromCut(sheetId, target, content, options) { for (const row of content.tableCells) { for (const tableCell of row) { if (tableCell.table) { this.dispatch("REMOVE_TABLE", { sheetId: content.sheetId, target: [this.getters.getRangeFromRangeData(tableCell.table.range).zone], }); } } } const selection = target[0]; this.pasteZone(sheetId, selection.left, selection.top, content.tableCells, options); } pasteZone(sheetId, col, row, tableCells, clipboardOptions) { for (let r = 0; r < tableCells.length; r++) { const rowCells = tableCells[r]; for (let c = 0; c < rowCells.length; c++) { const tableCell = rowCells[c]; if (!tableCell) { continue; } const position = { col: col + c, row: row + r, sheetId }; this.pasteTableCell(sheetId, tableCell, position, clipboardOptions); } } if (tableCells.length === 1) { for (let c = 0; c < tableCells[0].length; c++) { this.dispatch("AUTOFILL_TABLE_COLUMN", { col: col + c, row, sheetId }); } } } pasteTableCell(sheetId, tableCell, position, options) { if (tableCell.table && !options?.pasteOption) { const { range: tableRange } = tableCell.table; const zoneDims = zoneToDimension(this.getters.getRangeFromRangeData(tableRange).zone); const newTableZone = { left: position.col, top: position.row, right: position.col + zoneDims.numberOfCols - 1, bottom: position.row + zoneDims.numberOfRows - 1, }; this.dispatch("CREATE_TABLE", { sheetId: position.sheetId, ranges: [this.getters.getRangeDataFromZone(sheetId, newTableZone)], config: tableCell.table.config, tableType: tableCell.table.type, }); } // We cannot check for dynamic tables, because at this point the paste can have changed the evaluation, and the // dynamic tables are not yet computed if (this.getters.getCoreTable(position) || options?.pasteOption === "asValue") { return; } if ((!options?.pasteOption && !tableCell.isWholeTableCopied) || options?.pasteOption === "onlyFormat") { if (tableCell.style?.style) { this.dispatch("UPDATE_CELL", { ...position, style: tableCell.style.style }); } if (tableCell.style?.border) { this.dispatch("SET_BORDER", { ...position, border: tableCell.style.border }); } } } } const clipboardHandlersRegistries = { figureHandlers: new Registry(), cellHandlers: new Registry(), }; clipboardHandlersRegistries.figureHandlers .add("chart", ChartClipboardHandler) .add("image", ImageClipboardHandler); clipboardHandlersRegistries.cellHandlers .add("dataValidation", DataValidationClipboardHandler) .add("cell", CellClipboardHandler) .add("sheet", SheetClipboardHandler) .add("merge", MergeClipboardHandler) .add("border", BorderClipboardHandler) .add("table", TableClipboardHandler) .add("conditionalFormat", ConditionalFormatClipboardHandler); function transformZone(zone, executed) { if (executed.type === "REMOVE_COLUMNS_ROWS") { return reduceZoneOnDeletion(zone, executed.dimension === "COL" ? "left" : "top", executed.elements); } if (executed.type === "ADD_COLUMNS_ROWS") { return expandZoneOnInsertion(zone, executed.dimension === "COL" ? "left" : "top", executed.base, executed.position, executed.quantity); } return { ...zone }; } function transformRangeData(range, executed) { const deletedSheet = executed.type === "DELETE_SHEET" && executed.sheetId; if ("sheetId" in executed && range._sheetId !== executed.sheetId) { return range; } else { const newZone = transformZone(range._zone, executed); if (newZone && deletedSheet !== range._sheetId) { return { ...range, _zone: newZone }; } } return undefined; } /** * This is a generic event bus based on the Owl event bus. * This bus however ensures type safety across events and subscription callbacks. */ class EventBus { subscriptions = {}; /** * Add a listener for the 'eventType' events. * * Note that the 'owner' of this event can be anything, but will more likely * be a component or a class. The idea is that the callback will be called with * the proper owner bound. * * Also, the owner should be kind of unique. This will be used to remove the * listener. */ on(type, owner, callback) { if (!callback) { throw new Error("Missing callback"); } if (!this.subscriptions[type]) { this.subscriptions[type] = []; } this.subscriptions[type].push({ owner, callback, }); } /** * Emit an event of type 'eventType'. Any extra arguments will be passed to * the listeners callback. */ trigger(type, payload) { const subs = this.subscriptions[type] || []; for (let i = 0, iLen = subs.length; i < iLen; i++) { const sub = subs[i]; sub.callback.call(sub.owner, payload); } } /** * Remove a listener */ off(eventType, owner) { const subs = this.subscriptions[eventType]; if (subs) { this.subscriptions[eventType] = subs.filter((s) => s.owner !== owner); } } /** * Remove all subscriptions. */ clear() { this.subscriptions = {}; } } /** * A type-safe dependency container */ class DependencyContainer extends EventBus { dependencies = new Map(); factory = new StoreFactory(this.get.bind(this)); /** * Injects a store instance in the dependency container. * Useful for injecting an external store that is not created by the container. * Also useful for mocking a store. */ inject(Store, instance) { if (this.dependencies.has(Store) && this.dependencies.get(Store) !== instance) { throw new Error(`Store ${Store.name} already has an instance`); } this.dependencies.set(Store, instance); } /** * Get an instance of a store. */ get(Store) { if (!this.dependencies.has(Store)) { this.dependencies.set(Store, this.instantiate(Store)); } return this.dependencies.get(Store); } instantiate(Store, ...args) { return this.factory.build(Store, ...args); } resetStores() { this.dependencies.clear(); } } class StoreFactory { get; pendingBuilds = new Set(); constructor(get) { this.get = get; } /** * Build a store instance and get all its dependencies * while detecting and preventing circular dependencies */ build(Store, ...args) { if (this.pendingBuilds.has(Store)) { throw new Error(`Circular dependency detected: ${[...this.pendingBuilds, Store] .map((s) => s.name) .join(" -> ")}`); } this.pendingBuilds.add(Store); const instance = new Store(this.get, ...args); this.pendingBuilds.delete(Store); return instance; } } /** * Create a store to expose an external resource (which is not a store itself) * to other stores. * The external resource needs to be injected in the store provider to provide * the store implementation. * * @example * const MyMetaStore = createAbstractStore("MyStore"); * const stores = useStoreProvider(); * stores.inject(MyMetaStore, externalResourceInstance); */ function createAbstractStore(storeName) { class MetaStore { constructor(get) { throw new Error(`This is a abstract store for ${storeName}, it cannot be instantiated. Did you forget to inject your store instance? const stores = useStoreProvider(); stores.inject(MyMetaStore, storeInstance); `); } } return MetaStore; } class DisposableStore { get; disposeCallbacks = []; constructor(get) { this.get = get; } onDispose(callback) { this.disposeCallbacks.push(callback); } dispose() { this.disposeCallbacks.forEach((cb) => cb()); } } /** * This hook should be used at the root of your app to provide the store container. */ function useStoreProvider() { const env = useEnv(); if (env.__spreadsheet_stores__ instanceof DependencyContainer) { return env.__spreadsheet_stores__; } const container = new DependencyContainer(); useSubEnv({ __spreadsheet_stores__: container, getStore: (Store) => { const store = container.get(Store); return proxifyStoreMutation(store, () => container.trigger("store-updated")); }, }); return container; } /** * Get the instance of a store. */ function useStore(Store) { const env = useEnv(); const container = getDependencyContainer(env); const store = container.get(Store); return useStoreRenderProxy(container, store); } function useLocalStore(Store, ...args) { const env = useEnv(); const container = getDependencyContainer(env); const store = container.instantiate(Store, ...args); onWillUnmount(() => store.dispose()); return useStoreRenderProxy(container, store); } /** * Trigger an event to re-render the app (deep render) when * a store is mutated by invoking one of its mutator methods. */ function useStoreRenderProxy(container, store) { const component = useComponent(); const proxy = proxifyStoreMutation(store, () => { if (status(component) === "mounted") { container.trigger("store-updated"); } }); return proxy; } /** * Creates a proxied version of a store object with mutation tracking. * Whenever a mutator method of the store is called, the provided callback function is invoked. */ function proxifyStoreMutation(store, callback) { const proxy = new Proxy(store, { get(target, property, receiver) { const thisStore = target; // The third argument is `thisStore` (target) instead of `receiver`. // The goal is to always have the same `this` value in getter functions // (when `target[property]` is an accessor property). // `thisStore` is always the same object reference. `receiver` however is the // object on which the property is called, which is the Proxy object which is different for each component. const value = Reflect.get(target, property, thisStore); if (store.mutators?.includes(property)) { const functionProxy = new Proxy(value, { // trap the function call apply(target, thisArg, argArray) { Reflect.apply(target, thisStore, argArray); callback(); }, }); return functionProxy; } return value; }, }); return proxy; } function getDependencyContainer(env) { const container = env.__spreadsheet_stores__; if (!(container instanceof DependencyContainer)) { throw new Error("No store provider found. Did you forget to call useStoreProvider()?"); } return container; } const ModelStore = createAbstractStore("Model"); class RendererStore { mutators = ["register", "unRegister"]; renderers = {}; register(renderer) { if (!renderer.renderingLayers.length) { return; } for (const layer of renderer.renderingLayers) { if (!this.renderers[layer]) { this.renderers[layer] = []; } this.renderers[layer].push(renderer); } } unRegister(renderer) { for (const layer of Object.keys(this.renderers)) { this.renderers[layer] = this.renderers[layer].filter((r) => r !== renderer); } } drawLayer(context, layer) { const renderers = this.renderers[layer]; if (!renderers) { return; } for (const renderer of renderers) { context.ctx.save(); renderer.drawLayer(context, layer); context.ctx.restore(); } } } class SpreadsheetStore extends DisposableStore { // cast the model store as Model to allow model.dispatch to return the DispatchResult model = this.get(ModelStore); getters = this.model.getters; renderer = this.get(RendererStore); constructor(get) { super(get); this.model.on("command-dispatched", this, this.handle); this.model.on("command-finalized", this, this.finalize); this.renderer.register(this); this.onDispose(() => { this.model.off("command-dispatched", this); this.model.off("command-finalized", this); this.renderer.unRegister(this); }); } get renderingLayers() { return []; } handle(cmd) { } finalize() { } drawLayer(ctx, layer) { } } const VOID_COMPOSER = { id: "void-composer", get editionMode() { return "inactive"; }, startEdition: () => { throw new Error("No composer is registered"); }, stopEdition: () => { throw new Error("No composer is registered"); }, setCurrentContent: () => { throw new Error("No composer is registered"); }, }; class ComposerFocusStore extends SpreadsheetStore { mutators = ["focusComposer", "focusActiveComposer"]; activeComposer = VOID_COMPOSER; _focusMode = "inactive"; get focusMode() { return this.activeComposer.editionMode === "inactive" ? "inactive" : this._focusMode; } focusComposer(listener, args) { this.activeComposer = listener; if (this.getters.isReadonly()) { return; } this._focusMode = args.focusMode || "contentFocus"; if (this._focusMode !== "inactive") { this.setComposerContent(args); } } focusActiveComposer(args) { if (this.getters.isReadonly()) { return; } if (!this.activeComposer) { throw new Error("No composer is registered"); } this._focusMode = args.focusMode || "contentFocus"; if (this._focusMode !== "inactive") { this.setComposerContent(args); } } /** * Start the edition or update the content if it's already started. */ setComposerContent({ content, selection, }) { if (this.activeComposer.editionMode === "inactive") { this.activeComposer.startEdition(content, selection); } else if (content) { this.activeComposer.setCurrentContent(content, selection); } } } const TREND_LINE_XAXIS_ID = "x1"; /** * This file contains helpers that are common to different charts (mainly * line, bar and pie charts) */ /** * Adapt ranges of a chart which support DataSet (dataSets and LabelRange). */ function updateChartRangesWithDataSets(getters, applyChange, chartDataSets, chartLabelRange) { let isStale = false; const dataSetsWithUndefined = []; for (let index in chartDataSets) { let ds = chartDataSets[index]; if (ds.labelCell) { const labelCell = adaptChartRange(ds.labelCell, applyChange); if (ds.labelCell !== labelCell) { isStale = true; ds = { ...ds, labelCell: labelCell, }; } } const dataRange = adaptChartRange(ds.dataRange, applyChange); if (dataRange === undefined || getters.getRangeString(dataRange, dataRange.sheetId) === CellErrorType.InvalidReference) { isStale = true; ds = undefined; } else if (dataRange !== ds.dataRange) { isStale = true; ds = { ...ds, dataRange, }; } dataSetsWithUndefined[index] = ds; } let labelRange = chartLabelRange; const range = adaptChartRange(labelRange, applyChange); if (range !== labelRange) { isStale = true; labelRange = range; } const dataSets = dataSetsWithUndefined.filter(isDefined); return { isStale, dataSets, labelRange, }; } /** * Copy the dataSets given. All the ranges which are on sheetIdFrom will target * sheetIdTo. */ function copyDataSetsWithNewSheetId(sheetIdFrom, sheetIdTo, dataSets) { return dataSets.map((ds) => { return { dataRange: copyRangeWithNewSheetId(sheetIdFrom, sheetIdTo, ds.dataRange), labelCell: ds.labelCell ? copyRangeWithNewSheetId(sheetIdFrom, sheetIdTo, ds.labelCell) : undefined, }; }); } /** * Copy a range. If the range is on the sheetIdFrom, the range will target * sheetIdTo. */ function copyLabelRangeWithNewSheetId(sheetIdFrom, sheetIdTo, range) { return range ? copyRangeWithNewSheetId(sheetIdFrom, sheetIdTo, range) : undefined; } /** * Adapt a single range of a chart */ function adaptChartRange(range, applyChange) { if (!range) { return undefined; } const change = applyChange(range); switch (change.changeType) { case "NONE": return range; case "REMOVE": return undefined; default: return change.range; } } /** * Create the dataSet objects from xcs */ function createDataSets(getters, customizedDataSets, sheetId, dataSetsHaveTitle) { const dataSets = []; for (const dataSet of customizedDataSets) { const dataRange = getters.getRangeFromSheetXC(sheetId, dataSet.dataRange); const { unboundedZone: zone, sheetId: dataSetSheetId, invalidSheetName, invalidXc } = dataRange; if (invalidSheetName || invalidXc) { continue; } // It's a rectangle. We treat all columns (arbitrary) as different data series. if (zone.left !== zone.right && zone.top !== zone.bottom) { if (zone.right === undefined) { // Should never happens because of the allowDispatch of charts, but just making sure continue; } for (let column = zone.left; column <= zone.right; column++) { const columnZone = { ...zone, left: column, right: column, }; dataSets.push({ ...createDataSet(getters, dataSetSheetId, columnZone, dataSetsHaveTitle ? { top: columnZone.top, bottom: columnZone.top, left: columnZone.left, right: columnZone.left, } : undefined), backgroundColor: dataSet.backgroundColor, rightYAxis: dataSet.yAxisId === "y1", customLabel: dataSet.label, }); } } else { /* 1 cell, 1 row or 1 column */ dataSets.push({ ...createDataSet(getters, dataSetSheetId, zone, dataSetsHaveTitle ? { top: zone.top, bottom: zone.top, left: zone.left, right: zone.left, } : undefined), backgroundColor: dataSet.backgroundColor, rightYAxis: dataSet.yAxisId === "y1", customLabel: dataSet.label, }); } } return dataSets; } function createDataSet(getters, sheetId, fullZone, titleZone) { if (fullZone.left !== fullZone.right && fullZone.top !== fullZone.bottom) { throw new Error(`Zone should be a single column or row: ${zoneToXc(fullZone)}`); } if (titleZone) { const dataXC = zoneToXc(fullZone); const labelCellXC = zoneToXc(titleZone); return { labelCell: getters.getRangeFromSheetXC(sheetId, labelCellXC), dataRange: getters.getRangeFromSheetXC(sheetId, dataXC), }; } else { return { labelCell: undefined, dataRange: getters.getRangeFromSheetXC(sheetId, zoneToXc(fullZone)), }; } } /** * Transform a dataSet to a ExcelDataSet */ function toExcelDataset(getters, ds) { const labelZone = ds.labelCell?.zone; let dataZone = ds.dataRange.zone; if (labelZone) { const { numberOfRows, numberOfCols } = zoneToDimension(dataZone); if (numberOfRows === 1) { dataZone = { ...dataZone, left: dataZone.left + 1 }; } else if (numberOfCols === 1) { dataZone = { ...dataZone, top: dataZone.top + 1 }; } } const dataRange = ds.dataRange.clone({ zone: dataZone }); let label = {}; if (ds.customLabel) { label = { text: ds.customLabel, }; } else if (ds.labelCell) { label = { reference: getters.getRangeString(ds.labelCell, "forceSheetReference", { useFixedReference: true, }), }; } return { label, range: getters.getRangeString(dataRange, "forceSheetReference", { useFixedReference: true }), backgroundColor: ds.backgroundColor, rightYAxis: ds.rightYAxis, }; } function toExcelLabelRange(getters, labelRange, shouldRemoveFirstLabel) { if (!labelRange) return undefined; let zone = { ...labelRange.zone, }; if (shouldRemoveFirstLabel && labelRange.zone.bottom > labelRange.zone.top) { zone.top = zone.top + 1; } const range = labelRange.clone({ zone }); return getters.getRangeString(range, "forceSheetReference", { useFixedReference: true }); } /** * Transform a chart definition which supports dataSets (dataSets and LabelRange) * with an executed command */ function transformChartDefinitionWithDataSetsWithZone(definition, executed) { let labelRange; if (definition.labelRange) { const labelZone = transformZone(toUnboundedZone(definition.labelRange), executed); labelRange = labelZone ? zoneToXc(labelZone) : undefined; } const dataSets = definition.dataSets .map((ds) => toUnboundedZone(ds.dataRange)) .map((zone) => transformZone(zone, executed)) .filter(isDefined) .map((xc) => ({ dataRange: zoneToXc(xc) })); return { ...definition, labelRange, dataSets, }; } /** * Choose a font color based on a background color. * The font is white with a dark background. */ function chartFontColor(backgroundColor) { if (!backgroundColor) { return "#000000"; } return relativeLuminance(backgroundColor) < 0.3 ? "#FFFFFF" : "#000000"; } function checkDataset(definition) { if (definition.dataSets) { const invalidRanges = definition.dataSets.find((range) => !rangeReference.test(range.dataRange)) !== undefined; if (invalidRanges) { return "InvalidDataSet" /* CommandResult.InvalidDataSet */; } const zones = definition.dataSets.map((ds) => toUnboundedZone(ds.dataRange)); if (zones.some((zone) => zone.top !== zone.bottom && isFullRow(zone))) { return "InvalidDataSet" /* CommandResult.InvalidDataSet */; } } return "Success" /* CommandResult.Success */; } function checkLabelRange(definition) { if (definition.labelRange) { const invalidLabels = !rangeReference.test(definition.labelRange || ""); if (invalidLabels) { return "InvalidLabelRange" /* CommandResult.InvalidLabelRange */; } } return "Success" /* CommandResult.Success */; } function shouldRemoveFirstLabel(labelRange, dataset, dataSetsHaveTitle) { if (!dataSetsHaveTitle) return false; if (!labelRange) return false; if (!dataset) return true; const datasetLength = getZoneArea(dataset.dataRange.zone); const labelLength = getZoneArea(labelRange.zone); if (labelLength < datasetLength) { return false; } return true; } function getChartPositionAtCenterOfViewport(getters, chartSize) { const { x, y } = getters.getMainViewportCoordinates(); const { scrollX, scrollY } = getters.getActiveSheetScrollInfo(); const { width, height } = getters.getVisibleRect(getters.getActiveMainViewport()); const position = { x: x + scrollX + Math.max(0, (width - chartSize.width) / 2), y: y + scrollY + Math.max(0, (height - chartSize.height) / 2), }; // Position at the center of the scrollable viewport return position; } function getChartAxisTitleRuntime(design) { if (design?.title?.text) { const { text, color, align, italic, bold } = design.title; return { display: true, text, color, font: { style: italic ? "italic" : "normal", weight: bold ? "bold" : "normal", }, align: align === "left" ? "start" : align === "right" ? "end" : "center", }; } return; } function getDefinedAxis(definition) { let useLeftAxis = false, useRightAxis = false; if ("horizontal" in definition && definition.horizontal) { return { useLeftAxis: true, useRightAxis: false }; } for (const design of definition.dataSets || []) { if (design.yAxisId === "y1") { useRightAxis = true; } else { useLeftAxis = true; } } useLeftAxis ||= !useRightAxis; return { useLeftAxis, useRightAxis }; } function computeChartPadding({ displayTitle, displayLegend, }) { let top = 25; if (displayTitle) { top = 0; } else if (displayLegend) { top = 10; } return { left: 20, right: 20, top, bottom: 10 }; } function getTrendDatasetForBarChart(config, dataset) { const filteredValues = []; const filteredLabels = []; const labels = []; for (let i = 0; i < dataset.data.length; i++) { if (typeof dataset.data[i] === "number") { filteredValues.push(dataset.data[i]); filteredLabels.push(i + 1); } labels.push(i + 1); } const newLabels = range(0.5, labels.length + 0.55, 0.2); const newValues = interpolateData(config, filteredValues, filteredLabels, newLabels); if (!newValues.length) { return; } return getFullTrendingLineDataSet(dataset, config, newValues); } function getFullTrendingLineDataSet(dataset, config, data) { const defaultBorderColor = colorToRGBA(dataset.backgroundColor); defaultBorderColor.a = 1; const borderColor = config.color || lightenColor(rgbaToHex(defaultBorderColor), 0.5); return { type: "line", xAxisID: TREND_LINE_XAXIS_ID, yAxisID: dataset.yAxisID, label: dataset.label ? _t("Trend line for %s", dataset.label) : "", data, order: -1, showLine: true, pointRadius: 0, backgroundColor: borderColor, borderColor, borderDash: [5, 5], borderWidth: undefined, fill: false, pointBackgroundColor: borderColor, }; } function interpolateData(config, values, labels, newLabels) { if (values.length < 2 || labels.length < 2 || newLabels.length === 0) { return []; } const labelMin = Math.min(...labels); const labelMax = Math.max(...labels); const labelRange = labelMax - labelMin; const normalizedLabels = labels.map((v) => (v - labelMin) / labelRange); const normalizedNewLabels = newLabels.map((v) => (v - labelMin) / labelRange); try { switch (config.type) { case "polynomial": { const order = config.order; if (!order) { return Array.from({ length: newLabels.length }, () => NaN); } if (order === 1) { return predictLinearValues([values], [normalizedLabels], [normalizedNewLabels], true)[0]; } const coeffs = polynomialRegression(values, normalizedLabels, order, true).flat(); return normalizedNewLabels.map((v) => evaluatePolynomial(coeffs, v, order)); } case "exponential": { const positiveLogValues = []; const filteredLabels = []; for (let i = 0; i < values.length; i++) { if (values[i] > 0) { positiveLogValues.push(Math.log(values[i])); filteredLabels.push(normalizedLabels[i]); } } if (!filteredLabels.length) { return Array.from({ length: newLabels.length }, () => NaN); } return expM(predictLinearValues([positiveLogValues], [filteredLabels], [normalizedNewLabels], true))[0]; } case "logarithmic": { return predictLinearValues([values], logM([normalizedLabels]), logM([normalizedNewLabels]), true)[0]; } default: return []; } } catch (e) { return Array.from({ length: newLabels.length }, () => NaN); } } function formatTickValue(localeFormat) { return (value) => { value = Number(value); if (isNaN(value)) return value; const { locale, format } = localeFormat; return formatValue(value, { locale, format: !format && Math.abs(value) >= 1000 ? "#,##" : format, }); }; } function getChartColorsGenerator(definition, dataSetsSize) { return new ColorGenerator(dataSetsSize, definition.dataSets.map((ds) => ds.backgroundColor)); } const CHART_AXIS_CHOICES = [ { value: "left", label: _t("Left") }, { value: "right", label: _t("Right") }, ]; /** This is a chartJS plugin that will draw the values of each data next to the point/bar/pie slice */ const chartShowValuesPlugin = { id: "chartShowValuesPlugin", afterDatasetsDraw(chart, args, options) { if (!options.showValues) { return; } const drawData = chart._metasets?.[0]?.data; if (!drawData) { return; } const ctx = chart.ctx; ctx.save(); ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.miterLimit = 1; // Avoid sharp artifacts on strokeText switch (chart.config.type) { case "pie": case "doughnut": drawPieChartValues(chart, options, ctx); break; case "bar": case "line": options.horizontal ? drawHorizontalBarChartValues(chart, options, ctx) : drawLineOrBarChartValues(chart, options, ctx); break; } ctx.restore(); }, }; function drawTextWithBackground(text, x, y, ctx) { ctx.lineWidth = 3; // Stroke the text with a big lineWidth width to have some kind of background ctx.strokeText(text, x, y); ctx.lineWidth = 1; ctx.fillText(text, x, y); } function drawLineOrBarChartValues(chart, options, ctx) { const yMax = chart.chartArea.bottom; const yMin = chart.chartArea.top; const textsPositions = {}; for (const dataset of chart._metasets) { if (dataset.xAxisID === TREND_LINE_XAXIS_ID) { return; // ignore trend lines } for (let i = 0; i < dataset._parsed.length; i++) { const value = dataset._parsed[i].y; const point = dataset.data[i]; const xPosition = point.x; let yPosition = 0; if (chart.config.type === "line") { yPosition = point.y - 10; } else { yPosition = value < 0 ? point.y - point.height / 2 : point.y + point.height / 2; } yPosition = Math.min(yPosition, yMax); yPosition = Math.max(yPosition, yMin); // Avoid overlapping texts with same X if (!textsPositions[xPosition]) { textsPositions[xPosition] = []; } for (const otherPosition of textsPositions[xPosition] || []) { if (Math.abs(otherPosition - yPosition) < 13) { yPosition = otherPosition - 13; } } textsPositions[xPosition].push(yPosition); ctx.fillStyle = point.options.backgroundColor; ctx.strokeStyle = options.background || "#ffffff"; drawTextWithBackground(options.callback(value - 0), xPosition, yPosition, ctx); } } } function drawHorizontalBarChartValues(chart, options, ctx) { const xMax = chart.chartArea.right; const xMin = chart.chartArea.left; const textsPositions = {}; for (const dataset of chart._metasets) { if (dataset.xAxisID === TREND_LINE_XAXIS_ID) { return; // ignore trend lines } for (let i = 0; i < dataset._parsed.length; i++) { const value = dataset._parsed[i].x; const displayValue = options.callback(value - 0); const point = dataset.data[i]; const yPosition = point.y; let xPosition = value < 0 ? point.x + point.width / 2 : point.x - point.width / 2; xPosition = Math.min(xPosition, xMax); xPosition = Math.max(xPosition, xMin); // Avoid overlapping texts with same Y if (!textsPositions[yPosition]) { textsPositions[yPosition] = []; } const textWidth = computeTextWidth(ctx, displayValue, { fontSize: 12 }, "px"); for (const otherPosition of textsPositions[yPosition]) { if (Math.abs(otherPosition - xPosition) < textWidth) { xPosition = otherPosition + textWidth + 3; } } textsPositions[yPosition].push(xPosition); ctx.fillStyle = point.options.backgroundColor; ctx.strokeStyle = options.background || "#ffffff"; drawTextWithBackground(displayValue, xPosition, yPosition, ctx); } } } function drawPieChartValues(chart, options, ctx) { for (const dataset of chart._metasets) { for (let i = 0; i < dataset._parsed.length; i++) { const value = Number(dataset._parsed[i]); if (isNaN(value) || value === 0) { continue; } const bar = dataset.data[i]; const { startAngle, endAngle, innerRadius, outerRadius } = bar; const midAngle = (startAngle + endAngle) / 2; const midRadius = (innerRadius + outerRadius) / 2; const x = bar.x + midRadius * Math.cos(midAngle); const y = bar.y + midRadius * Math.sin(midAngle) + 7; ctx.fillStyle = chartFontColor(options.background); ctx.strokeStyle = options.background || "#ffffff"; const displayValue = options.callback(value); drawTextWithBackground(displayValue, x, y, ctx); } } } /** This is a chartJS plugin that will draw connector lines between the bars of a Waterfall chart */ const waterfallLinesPlugin = { id: "waterfallLinesPlugin", beforeDraw(chart, args, options) { if (!options.showConnectorLines) { return; } // Note: private properties are not in the typing of chartJS (and some of the existing types are missing properties) // so we don't type anything in this file const drawData = chart._metasets?.[0]?.data; if (!drawData) { return; } const ctx = chart.ctx; ctx.save(); ctx.setLineDash([3, 2]); for (let i = 0; i < drawData.length; i++) { const bar = drawData[i]; if (bar.height === 0) { continue; } const nextBar = getNextNonEmptyBar(drawData, i); if (!nextBar) { break; } const rect = getBarElementRect(bar); const nextBarRect = getBarElementRect(nextBar); const rawBarValues = bar.$context.raw; const value = rawBarValues[1] - rawBarValues[0]; const lineY = Math.round(value < 0 ? rect.bottom - 1 : rect.top); const lineStart = Math.round(rect.right); const lineEnd = Math.round(nextBarRect.left); ctx.strokeStyle = "#999"; ctx.beginPath(); ctx.moveTo(lineStart + 1, lineY + 0.5); ctx.lineTo(lineEnd, lineY + 0.5); ctx.stroke(); } ctx.restore(); }, }; function getBarElementRect(bar) { const flipped = bar.base < bar.y; // Bar are flipped for negative values in the dataset return { left: bar.x - bar.width / 2, right: bar.x + bar.width / 2, bottom: flipped ? bar.base + bar.height : bar.y + bar.height, top: flipped ? bar.base : bar.y, }; } function getNextNonEmptyBar(bars, startIndex) { return bars.find((bar, i) => i > startIndex && bar.height !== 0); } window.Chart?.register(waterfallLinesPlugin); window.Chart?.register(chartShowValuesPlugin); class ChartJsComponent extends Component { static template = "o-spreadsheet-ChartJsComponent"; static props = { figure: Object, }; canvas = useRef("graphContainer"); chart; currentRuntime; get background() { return this.chartRuntime.background; } get canvasStyle() { return `background-color: ${this.background}`; } get chartRuntime() { const runtime = this.env.model.getters.getChartRuntime(this.props.figure.id); if (!("chartJsConfig" in runtime)) { throw new Error("Unsupported chart runtime"); } return runtime; } setup() { onMounted(() => { const runtime = this.chartRuntime; this.currentRuntime = runtime; // Note: chartJS modify the runtime in place, so it's important to give it a copy this.createChart(deepCopy(runtime.chartJsConfig)); }); onWillUnmount(() => this.chart?.destroy()); useEffect(() => { const runtime = this.chartRuntime; if (runtime !== this.currentRuntime) { if (runtime.chartJsConfig.type !== this.currentRuntime.chartJsConfig.type) { this.chart?.destroy(); this.createChart(deepCopy(runtime.chartJsConfig)); } else { this.updateChartJs(deepCopy(runtime)); } this.currentRuntime = runtime; } }); } createChart(chartData) { const canvas = this.canvas.el; const ctx = canvas.getContext("2d"); this.chart = new window.Chart(ctx, chartData); } updateChartJs(chartRuntime) { const chartData = chartRuntime.chartJsConfig; if (chartData.data && chartData.data.datasets) { this.chart.data = chartData.data; if (chartData.options?.plugins?.title) { this.chart.config.options.plugins.title = chartData.options.plugins.title; } } else { this.chart.data.datasets = []; } this.chart.config.options = chartData.options; this.chart.update(); } } /** * AbstractChart is the class from which every Chart should inherit. * The role of this class is to maintain the state of each chart. */ class AbstractChart { sheetId; title; getters; constructor(definition, sheetId, getters) { this.title = definition.title; this.sheetId = sheetId; this.getters = getters; } /** * Validate the chart definition given as arguments. This function will be * called from allowDispatch function */ static validateChartDefinition(validator, definition) { throw new Error("This method should be implemented by sub class"); } /** * Get a new chart definition transformed with the executed command. This * functions will be called during operational transform process */ static transformDefinition(definition, executed) { throw new Error("This method should be implemented by sub class"); } /** * Get an empty definition based on the given context */ static getDefinitionFromContextCreation(context) { throw new Error("This method should be implemented by sub class"); } } function getBaselineText(baseline, keyValue, baselineMode, humanize, locale) { if (!baseline) { return ""; } else if (baselineMode === "text" || keyValue?.type !== CellValueType.number || baseline.type !== CellValueType.number) { if (humanize) { return humanizeNumber(baseline, locale); } return baseline.formattedValue; } let { value, format } = baseline; if (baselineMode === "progress") { value = keyValue.value / value; format = "0.0%"; } else { value = Math.abs(keyValue.value - value); if (baselineMode === "percentage" && value !== 0) { value = value / baseline.value; } if (baselineMode === "percentage") { format = "0.0%"; } if (!format) { value = Math.round(value * 100) / 100; } } if (humanize) { return humanizeNumber({ value, format }, locale); } return formatValue(value, { format, locale }); } function getKeyValueText(keyValueCell, humanize, locale) { if (!keyValueCell) { return ""; } if (humanize) { return humanizeNumber(keyValueCell, locale); } return keyValueCell.formattedValue ?? String(keyValueCell.value ?? ""); } function getBaselineColor(baseline, baselineMode, keyValue, colorUp, colorDown) { if (baselineMode === "text" || baselineMode === "progress" || baseline?.type !== CellValueType.number || keyValue?.type !== CellValueType.number) { return undefined; } const diff = keyValue.value - baseline.value; if (diff > 0) { return colorUp; } else if (diff < 0) { return colorDown; } return undefined; } function getBaselineArrowDirection(baseline, keyValue, baselineMode) { if (baselineMode === "text" || baseline?.type !== CellValueType.number || keyValue?.type !== CellValueType.number) { return "neutral"; } const diff = keyValue.value - baseline.value; if (diff > 0) { return "up"; } else if (diff < 0) { return "down"; } return "neutral"; } function checkKeyValue(definition) { return definition.keyValue && !rangeReference.test(definition.keyValue) ? "InvalidScorecardKeyValue" /* CommandResult.InvalidScorecardKeyValue */ : "Success" /* CommandResult.Success */; } function checkBaseline(definition) { return definition.baseline && !rangeReference.test(definition.baseline) ? "InvalidScorecardBaseline" /* CommandResult.InvalidScorecardBaseline */ : "Success" /* CommandResult.Success */; } const arrowDownPath = new window.Path2D("M8.6 4.8a.5.5 0 0 1 0 .75l-3.9 3.9a.5 .5 0 0 1 -.75 0l-3.8 -3.9a.5 .5 0 0 1 0 -.75l.4-.4a.5.5 0 0 1 .75 0l2.3 2.4v-5.7c0-.25.25-.5.5-.5h.6c.25 0 .5.25.5.5v5.8l2.3 -2.4a.5.5 0 0 1 .75 0z"); const arrowUpPath = new window.Path2D("M8.7 5.5a.5.5 0 0 0 0-.75l-3.8-4a.5.5 0 0 0-.75 0l-3.8 4a.5.5 0 0 0 0 .75l.4.4a.5.5 0 0 0 .75 0l2.3-2.4v5.8c0 .25.25.5.5.5h.6c.25 0 .5-.25.5-.5v-5.8l2.2 2.4a.5.5 0 0 0 .75 0z"); let ScorecardChart$1 = class ScorecardChart extends AbstractChart { keyValue; baseline; baselineMode; baselineDescr; progressBar = false; background; baselineColorUp; baselineColorDown; fontColor; humanize; type = "scorecard"; constructor(definition, sheetId, getters) { super(definition, sheetId, getters); this.keyValue = createValidRange(getters, sheetId, definition.keyValue); this.baseline = createValidRange(getters, sheetId, definition.baseline); this.baselineMode = definition.baselineMode; this.baselineDescr = definition.baselineDescr; this.background = definition.background; this.baselineColorUp = definition.baselineColorUp ?? DEFAULT_SCORECARD_BASELINE_COLOR_UP; this.baselineColorDown = definition.baselineColorDown ?? DEFAULT_SCORECARD_BASELINE_COLOR_DOWN; this.humanize = definition.humanize ?? false; } static validateChartDefinition(validator, definition) { return validator.checkValidations(definition, checkKeyValue, checkBaseline); } static getDefinitionFromContextCreation(context) { return { background: context.background, type: "scorecard", keyValue: context.range ? context.range[0].dataRange : undefined, title: context.title || { text: "" }, baselineMode: DEFAULT_SCORECARD_BASELINE_MODE, baselineColorUp: DEFAULT_SCORECARD_BASELINE_COLOR_UP, baselineColorDown: DEFAULT_SCORECARD_BASELINE_COLOR_DOWN, baseline: context.auxiliaryRange || "", }; } static transformDefinition(definition, executed) { let baselineZone; let keyValueZone; if (definition.baseline) { baselineZone = transformZone(toUnboundedZone(definition.baseline), executed); } if (definition.keyValue) { keyValueZone = transformZone(toUnboundedZone(definition.keyValue), executed); } return { ...definition, baseline: baselineZone ? zoneToXc(baselineZone) : undefined, keyValue: keyValueZone ? zoneToXc(keyValueZone) : undefined, }; } copyForSheetId(sheetId) { const baseline = copyLabelRangeWithNewSheetId(this.sheetId, sheetId, this.baseline); const keyValue = copyLabelRangeWithNewSheetId(this.sheetId, sheetId, this.keyValue); const definition = this.getDefinitionWithSpecificRanges(baseline, keyValue, sheetId); return new ScorecardChart(definition, sheetId, this.getters); } copyInSheetId(sheetId) { const definition = this.getDefinitionWithSpecificRanges(this.baseline, this.keyValue, sheetId); return new ScorecardChart(definition, sheetId, this.getters); } getDefinition() { return this.getDefinitionWithSpecificRanges(this.baseline, this.keyValue); } getContextCreation() { return { ...this, range: this.keyValue ? [{ dataRange: this.getters.getRangeString(this.keyValue, this.sheetId) }] : undefined, auxiliaryRange: this.baseline ? this.getters.getRangeString(this.baseline, this.sheetId) : undefined, }; } getDefinitionWithSpecificRanges(baseline, keyValue, targetSheetId) { return { baselineColorDown: this.baselineColorDown, baselineColorUp: this.baselineColorUp, baselineMode: this.baselineMode, title: this.title, type: "scorecard", background: this.background, baseline: baseline ? this.getters.getRangeString(baseline, targetSheetId || this.sheetId) : undefined, baselineDescr: this.baselineDescr, keyValue: keyValue ? this.getters.getRangeString(keyValue, targetSheetId || this.sheetId) : undefined, humanize: this.humanize, }; } getDefinitionForExcel() { // This kind of graph is not exportable in Excel return undefined; } updateRanges(applyChange) { const baseline = adaptChartRange(this.baseline, applyChange); const keyValue = adaptChartRange(this.keyValue, applyChange); if (this.baseline === baseline && this.keyValue === keyValue) { return this; } const definition = this.getDefinitionWithSpecificRanges(baseline, keyValue); return new ScorecardChart(definition, this.sheetId, this.getters); } }; function drawScoreChart(structure, canvas) { const ctx = canvas.getContext("2d"); canvas.width = structure.canvas.width; const availableWidth = canvas.width - DEFAULT_CHART_PADDING; canvas.height = structure.canvas.height; ctx.fillStyle = structure.canvas.backgroundColor; ctx.fillRect(0, 0, structure.canvas.width, structure.canvas.height); if (structure.title) { ctx.font = structure.title.style.font; ctx.fillStyle = structure.title.style.color; const baseline = ctx.textBaseline; ctx.textBaseline = "middle"; ctx.fillText(clipTextWithEllipsis(ctx, structure.title.text, availableWidth - structure.title.position.x), structure.title.position.x, structure.title.position.y); ctx.textBaseline = baseline; } if (structure.baseline) { ctx.font = structure.baseline.style.font; ctx.fillStyle = structure.baseline.style.color; drawDecoratedText(ctx, structure.baseline.text, structure.baseline.position, structure.baseline.style.underline, structure.baseline.style.strikethrough); } if (structure.baselineArrow && structure.baselineArrow.style.size > 0) { ctx.save(); ctx.fillStyle = structure.baselineArrow.style.color; ctx.translate(structure.baselineArrow.position.x, structure.baselineArrow.position.y); // This ratio is computed according to the original svg size and the final size we want const ratio = structure.baselineArrow.style.size / 10; ctx.scale(ratio, ratio); switch (structure.baselineArrow.direction) { case "down": { ctx.fill(arrowDownPath); break; } case "up": { ctx.fill(arrowUpPath); break; } } ctx.restore(); } if (structure.baselineDescr) { const descr = structure.baselineDescr[0]; ctx.font = descr.style.font; ctx.fillStyle = descr.style.color; for (const description of structure.baselineDescr) { ctx.fillText(clipTextWithEllipsis(ctx, description.text, availableWidth - description.position.x), description.position.x, description.position.y); } } if (structure.key) { ctx.font = structure.key.style.font; ctx.fillStyle = structure.key.style.color; drawDecoratedText(ctx, clipTextWithEllipsis(ctx, structure.key.text, availableWidth - structure.key.position.x), structure.key.position, structure.key.style.underline, structure.key.style.strikethrough); } if (structure.progressBar) { ctx.fillStyle = structure.progressBar.style.backgroundColor; ctx.beginPath(); ctx.roundRect(structure.progressBar.position.x, structure.progressBar.position.y, structure.progressBar.dimension.width, structure.progressBar.dimension.height, structure.progressBar.dimension.height / 2); ctx.fill(); ctx.fillStyle = structure.progressBar.style.color; ctx.beginPath(); if (structure.progressBar.value > 0) { ctx.roundRect(structure.progressBar.position.x, structure.progressBar.position.y, structure.progressBar.dimension.width * Math.max(0, Math.min(1.0, structure.progressBar.value)), structure.progressBar.dimension.height, structure.progressBar.dimension.height / 2); } else { const width = structure.progressBar.dimension.width * Math.max(0, Math.min(1.0, -structure.progressBar.value)); ctx.roundRect(structure.progressBar.position.x + structure.progressBar.dimension.width - width, structure.progressBar.position.y, width, structure.progressBar.dimension.height, structure.progressBar.dimension.height / 2); } ctx.fill(); } } function createScorecardChartRuntime(chart, getters) { let formattedKeyValue = ""; let keyValueCell; const locale = getters.getLocale(); if (chart.keyValue) { const keyValuePosition = { sheetId: chart.keyValue.sheetId, col: chart.keyValue.zone.left, row: chart.keyValue.zone.top, }; keyValueCell = getters.getEvaluatedCell(keyValuePosition); formattedKeyValue = getKeyValueText(keyValueCell, chart.humanize ?? false, locale); } let baselineCell; const baseline = chart.baseline; if (baseline) { const baselinePosition = { sheetId: baseline.sheetId, col: baseline.zone.left, row: baseline.zone.top, }; baselineCell = getters.getEvaluatedCell(baselinePosition); } const { background, fontColor } = getters.getStyleOfSingleCellChart(chart.background, chart.keyValue); const baselineDisplay = getBaselineText(baselineCell, keyValueCell, chart.baselineMode, chart.humanize ?? false, locale); const baselineValue = chart.baselineMode === "progress" && isNumber(baselineDisplay, locale) ? toNumber(baselineDisplay, locale) : 0; return { title: { ...chart.title, // chart titles are extracted from .json files and they are translated at runtime here text: chart.title.text ? _t(chart.title.text) : "", }, keyValue: formattedKeyValue, baselineDisplay, baselineArrow: getBaselineArrowDirection(baselineCell, keyValueCell, chart.baselineMode), baselineColor: getBaselineColor(baselineCell, chart.baselineMode, keyValueCell, chart.baselineColorUp, chart.baselineColorDown), baselineDescr: chart.baselineMode !== "progress" && chart.baselineDescr ? _t(chart.baselineDescr) // descriptions are extracted from .json files and they are translated at runtime here : "", fontColor, background, baselineStyle: chart.baselineMode !== "percentage" && chart.baselineMode !== "progress" && baseline ? getters.getCellStyle({ sheetId: baseline.sheetId, col: baseline.zone.left, row: baseline.zone.top, }) : undefined, keyValueStyle: chart.keyValue ? getters.getCellStyle({ sheetId: chart.keyValue.sheetId, col: chart.keyValue.zone.left, row: chart.keyValue.zone.top, }) : undefined, progressBar: chart.baselineMode === "progress" ? { value: baselineValue, color: baselineValue > 0 ? chart.baselineColorUp : chart.baselineColorDown, } : undefined, }; } /* Padding at the border of the chart */ const CHART_PADDING = SCORECARD_GAUGE_CHART_PADDING; const BOTTOM_PADDING_RATIO = 0.05; /* Maximum font sizes of each element */ const CHART_TITLE_FONT_SIZE = SCORECARD_GAUGE_CHART_FONT_SIZE; const KEY_VALUE_FONT_SIZE = 32; const BASELINE_MAX_FONT_SIZE = 16; function formatBaselineDescr(baselineDescr, baseline) { const _baselineDescr = baselineDescr || ""; return baseline && _baselineDescr ? " " + _baselineDescr : _baselineDescr; } function getScorecardConfiguration({ width, height }, runtime) { const designer = new ScorecardChartConfigBuilder({ width, height }, runtime); return designer.computeDesign(); } class ScorecardChartConfigBuilder { runtime; context; width; height; constructor({ width, height }, runtime) { this.runtime = runtime; const canvas = document.createElement("canvas"); this.width = canvas.width = width; this.height = canvas.height = height; this.context = canvas.getContext("2d"); } computeDesign() { const structure = { canvas: { width: this.width, height: this.height, backgroundColor: this.backgroundColor, }, }; const style = this.getTextStyles(); let titleHeight = 0; if (this.title) { let x, titleWidth; ({ height: titleHeight, width: titleWidth } = this.getFullTextDimensions(this.title, style.title.font)); switch (this.runtime.title.align) { case "center": x = (this.width - titleWidth) / 2; break; case "right": x = this.width - titleWidth - CHART_PADDING; break; case "left": default: x = CHART_PADDING; } structure.title = { text: this.title, style: style.title, position: { x, y: CHART_PADDING + titleHeight / 2, }, }; } const baselineArrowSize = style.baselineArrow?.size ?? 0; let { height: baselineHeight, width: baselineWidth } = this.getTextDimensions(this.baseline, style.baselineValue.font); if (!this.baseline) { baselineHeight = this.getTextDimensions(this.baselineDescr, style.baselineDescr.font).height; } const baselineDescrWidth = this.getTextDimensions(this.baselineDescr, style.baselineDescr.font).width; structure.baseline = { text: this.baseline, style: style.baselineValue, position: { x: (this.width - baselineWidth - baselineDescrWidth + baselineArrowSize) / 2, y: this.keyValue ? this.height * (1 - BOTTOM_PADDING_RATIO * (this.runtime.progressBar ? 1 : 2)) : this.height - (this.height - titleHeight - baselineHeight) / 2 - CHART_PADDING, }, }; const minimalBaselinePosition = baselineArrowSize + DEFAULT_CHART_PADDING; if (structure.baseline.position.x < minimalBaselinePosition) { structure.baseline.position.x = minimalBaselinePosition; } if (style.baselineArrow && !this.runtime.progressBar) { structure.baselineArrow = { direction: this.baselineArrow, style: style.baselineArrow, position: { x: structure.baseline.position.x - baselineArrowSize, y: structure.baseline.position.y - (baselineHeight + baselineArrowSize) / 2, }, }; } if (this.baselineDescr) { const position = { x: structure.baseline.position.x + baselineWidth, y: structure.baseline.position.y, }; structure.baselineDescr = [ { text: this.baselineDescr, style: style.baselineDescr, position, }, ]; } let progressBarHeight = 0; if (this.runtime.progressBar) { progressBarHeight = this.height * 0.05; structure.progressBar = { position: { x: 2 * CHART_PADDING, y: this.height * (1 - 2 * BOTTOM_PADDING_RATIO) - baselineHeight - progressBarHeight, }, dimension: { height: progressBarHeight, width: this.width - 4 * CHART_PADDING, }, value: this.runtime.progressBar.value, style: { color: this.runtime.progressBar.color, backgroundColor: this.secondaryFontColor, }, }; } const { width: keyWidth, height: keyHeight } = this.getFullTextDimensions(this.keyValue, style.keyValue.font); if (this.keyValue) { structure.key = { text: this.keyValue, style: style.keyValue, position: { x: Math.max(CHART_PADDING, (this.width - keyWidth) / 2), y: this.height * (0.5 - BOTTOM_PADDING_RATIO * 2) + CHART_PADDING / 2 + (titleHeight + keyHeight / 2) / 2, }, }; } return structure; } get title() { return this.runtime.title.text ?? ""; } get keyValue() { return this.runtime.keyValue; } get baseline() { return this.runtime.baselineDisplay; } get baselineDescr() { return formatBaselineDescr(this.runtime.baselineDescr, this.baseline); } get baselineArrow() { return this.runtime.baselineArrow; } get backgroundColor() { return this.runtime.background; } get secondaryFontColor() { return relativeLuminance(this.backgroundColor) > 0.3 ? "#525252" : "#C8C8C8"; } getTextDimensions(text, font) { this.context.font = font; const measure = this.context.measureText(text); return { width: measure.width, height: measure.actualBoundingBoxAscent + measure.actualBoundingBoxDescent, }; } getFullTextDimensions(text, font) { this.context.font = font; const measure = this.context.measureText(text); return { width: measure.width, height: measure.fontBoundingBoxAscent + measure.fontBoundingBoxDescent, }; } getTextStyles() { let baselineValueFontSize = BASELINE_MAX_FONT_SIZE; const baselineDescrFontSize = Math.floor(0.9 * baselineValueFontSize); if (this.runtime.progressBar) { baselineValueFontSize /= 1.5; } return { title: { font: getDefaultContextFont(CHART_TITLE_FONT_SIZE, this.runtime.title.bold, this.runtime.title.italic), color: this.runtime.title.color ?? this.secondaryFontColor, }, keyValue: { color: this.runtime.keyValueStyle?.textColor || this.runtime.fontColor, font: getDefaultContextFont(KEY_VALUE_FONT_SIZE, this.runtime.keyValueStyle?.bold, this.runtime.keyValueStyle?.italic), strikethrough: this.runtime.keyValueStyle?.strikethrough, underline: this.runtime.keyValueStyle?.underline, }, baselineValue: { font: getDefaultContextFont(baselineValueFontSize, this.runtime.baselineStyle?.bold, this.runtime.baselineStyle?.italic), strikethrough: this.runtime.baselineStyle?.strikethrough, underline: this.runtime.baselineStyle?.underline, color: this.runtime.baselineStyle?.textColor || this.runtime.baselineColor || this.secondaryFontColor, }, baselineDescr: { font: getDefaultContextFont(baselineDescrFontSize), color: this.secondaryFontColor, }, baselineArrow: this.baselineArrow === "neutral" || this.runtime.progressBar ? undefined : { size: this.keyValue ? 0.8 * baselineValueFontSize : 0, color: this.runtime.baselineColor || this.secondaryFontColor, }, }; } } class ScorecardChart extends Component { static template = "o-spreadsheet-ScorecardChart"; static props = { figure: Object, }; canvas = useRef("chartContainer"); get runtime() { return this.env.model.getters.getChartRuntime(this.props.figure.id); } get title() { const title = this.env.model.getters.getChartDefinition(this.props.figure.id).title.text ?? ""; // chart titles are extracted from .json files and they are translated at runtime here return _t(title); } setup() { useEffect(this.createChart.bind(this), () => { const canvas = this.canvas.el; const rect = canvas.getBoundingClientRect(); return [rect.width, rect.height, this.runtime, this.canvas.el]; }); } createChart() { const canvas = this.canvas.el; const config = getScorecardConfiguration(canvas.getBoundingClientRect(), this.runtime); drawScoreChart(config, canvas); } } const autoCompleteProviders = new Registry(); autoCompleteProviders.add("dataValidation", { getProposals(tokenAtCursor, content) { if (content.startsWith("=")) { return []; } if (!this.composer.currentEditedCell) { return []; } const position = this.composer.currentEditedCell; const rule = this.getters.getValidationRuleForCell(position); if (!rule || (rule.criterion.type !== "isValueInList" && rule.criterion.type !== "isValueInRange")) { return []; } let values; if (rule.criterion.type === "isValueInList") { values = rule.criterion.values; } else { const range = this.getters.getRangeFromSheetXC(position.sheetId, rule.criterion.values[0]); values = Array.from(new Set(this.getters .getRangeValues(range) .filter(isNotNull) .map((value) => value.toString()) .filter((val) => val !== ""))); } return values.map((value) => ({ text: value })); }, selectProposal(tokenAtCursor, value) { this.composer.setCurrentContent(value); this.composer.stopEdition(); }, }); function getHtmlContentFromPattern(pattern, value, highlightColor, className) { const pendingHtmlContent = []; pattern = pattern.toLowerCase(); for (const patternChar of pattern) { const index = value.toLocaleLowerCase().indexOf(patternChar); if (index === -1) { continue; } pendingHtmlContent.push({ value: value.slice(0, index), color: "" }, { value: value[index], color: highlightColor, class: className }); value = value.slice(index + 1); } pendingHtmlContent.push({ value }); const htmlContent = pendingHtmlContent.filter((content) => content.value); return htmlContent; } //------------------------------------------------------------------------------ // Arg description DSL //------------------------------------------------------------------------------ const ARG_REGEXP = /(.*?)\((.*?)\)(.*)/; const ARG_TYPES = [ "ANY", "BOOLEAN", "DATE", "NUMBER", "STRING", "RANGE", "RANGE", "RANGE", "RANGE", "RANGE", "META", ]; function arg(definition, description = "") { return makeArg(definition, description); } function makeArg(str, description) { let parts = str.match(ARG_REGEXP); let name = parts[1].trim(); if (!name) { throw new Error(`Function argument definition is missing a name: '${str}'.`); } let types = []; let isOptional = false; let isRepeating = false; let defaultValue; for (let param of parts[2].split(",")) { const key = param.trim().toUpperCase(); let type = ARG_TYPES.find((t) => key === t); if (type) { types.push(type); } else if (key === "RANGE") { types.push("RANGE"); } else if (key === "OPTIONAL") { isOptional = true; } else if (key === "REPEATING") { isRepeating = true; } else if (key.startsWith("DEFAULT=")) { defaultValue = param.trim().slice(8); } } const result = { name, description, type: types, }; const acceptErrors = types.includes("ANY") || types.includes("RANGE"); if (acceptErrors) { result.acceptErrors = true; } if (isOptional) { result.optional = true; } if (isRepeating) { result.repeating = true; } if (defaultValue !== undefined) { result.default = true; result.defaultValue = defaultValue; } if (types.some((t) => t.startsWith("RANGE"))) { result.acceptMatrix = true; } if (types.every((t) => t.startsWith("RANGE"))) { result.acceptMatrixOnly = true; } return result; } /** * This function adds on description more general information derived from the * arguments. * * This information is useful during compilation. */ function addMetaInfoFromArg(addDescr) { let countArg = 0; let minArg = 0; let repeatingArg = 0; for (let arg of addDescr.args) { countArg++; if (!arg.optional && !arg.repeating && !arg.default) { minArg++; } if (arg.repeating) { repeatingArg++; } } const descr = addDescr; descr.minArgRequired = minArg; descr.maxArgPossible = repeatingArg ? Infinity : countArg; descr.nbrArgRepeating = repeatingArg; descr.getArgToFocus = argTargeting(countArg, repeatingArg); descr.hidden = addDescr.hidden || false; return descr; } /** * Returns a function allowing finding which argument corresponds a position * in a function. This is particularly useful for functions with repeatable * arguments. * * Indeed the function makes it possible to etablish corespondance between * arguments when the number of arguments supplied is greater than the number of * arguments defined by the function. * * Ex: * * in the formula "=SUM(11, 55, 66)" which is defined like this "SUM(value1, [value2, ...])" * - 11 corresponds to the value1 argument => position will be 1 * - 55 corresponds to the [value2, ...] argument => position will be 2 * - 66 corresponds to the [value2, ...] argument => position will be 2 * * in the formula "=AVERAGE.WEIGHTED(1, 2, 3, 4, 5, 6)" which is defined like this * "AVERAGE.WEIGHTED(values, weights, [additional_values, ...], [additional_weights, ...])" * - 1 corresponds to the values argument => position will be 1 * - 2 corresponds to the weights argument => position will be 2 * - 3 corresponds to the [additional_values, ...] argument => position will be 3 * - 4 corresponds to the [additional_weights, ...] argument => position will be 4 * - 5 corresponds to the [additional_values, ...] argument => position will be 3 * - 6 corresponds to the [additional_weights, ...] argument => position will be 4 */ function argTargeting(countArg, repeatingArg) { if (!repeatingArg) { return (argPosition) => argPosition; } if (repeatingArg === 1) { return (argPosition) => Math.min(argPosition, countArg); } const argBeforeRepeat = countArg - repeatingArg; return (argPosition) => { if (argPosition <= argBeforeRepeat) { return argPosition; } const argAfterRepeat = (argPosition - argBeforeRepeat) % repeatingArg || repeatingArg; return argBeforeRepeat + argAfterRepeat; }; } //------------------------------------------------------------------------------ // Argument validation //------------------------------------------------------------------------------ function validateArguments(args) { let previousArgRepeating = false; let previousArgOptional = false; let previousArgDefault = false; for (let current of args) { if (current.type.includes("META") && current.type.length > 1) { throw new Error(_t("Function ${name} has an argument that has been declared with more than one type whose type 'META'. The 'META' type can only be declared alone.")); } if (previousArgRepeating && !current.repeating) { throw new Error(_t("Function ${name} has no-repeatable arguments declared after repeatable ones. All repeatable arguments must be declared last.")); } const previousIsOptional = previousArgOptional || previousArgRepeating || previousArgDefault; const currentIsntOptional = !(current.optional || current.repeating || current.default); if (previousIsOptional && currentIsntOptional) { throw new Error(_t("Function ${name} has at mandatory arguments declared after optional ones. All optional arguments must be after all mandatory arguments.")); } previousArgRepeating = current.repeating; previousArgOptional = current.optional; previousArgDefault = current.default; } } function assertSingleColOrRow(errorStr, arg) { assert(() => arg.length === 1 || arg[0].length === 1, errorStr); } function assertSameDimensions(errorStr, ...args) { if (args.every(isMatrix)) { const cols = args[0].length; const rows = args[0][0].length; for (const arg of args) { assert(() => arg.length === cols && arg[0].length === rows, errorStr); } return; } if (args.some((arg) => Array.isArray(arg) && (arg.length !== 1 || arg[0].length !== 1))) { throw new EvaluationError(errorStr); } } function assertPositive(errorStr, arg) { assert(() => arg > 0, errorStr); } function assertSquareMatrix(errorStr, arg) { assert(() => arg.length === arg[0].length, errorStr); } // ----------------------------------------------------------------------------- // ARRAY_CONSTRAIN // ----------------------------------------------------------------------------- const ARRAY_CONSTRAIN = { description: _t("Returns a result array constrained to a specific width and height."), args: [ arg("input_range (any, range)", _t("The range to constrain.")), arg("rows (number)", _t("The number of rows in the constrained array.")), arg("columns (number)", _t("The number of columns in the constrained array.")), ], compute: function (array, rows, columns) { const _array = toMatrix(array); const _rowsArg = toInteger(rows?.value, this.locale); const _columnsArg = toInteger(columns?.value, this.locale); assertPositive(_t("The rows argument (%s) must be strictly positive.", _rowsArg.toString()), _rowsArg); assertPositive(_t("The columns argument (%s) must be strictly positive.", _rowsArg.toString()), _columnsArg); const _nbRows = Math.min(_rowsArg, _array[0].length); const _nbColumns = Math.min(_columnsArg, _array.length); return generateMatrix(_nbColumns, _nbRows, (col, row) => _array[col][row]); }, isExported: false, }; // ----------------------------------------------------------------------------- // CHOOSECOLS // ----------------------------------------------------------------------------- const CHOOSECOLS = { description: _t("Creates a new array from the selected columns in the existing range."), args: [ arg("array (any, range)", _t("The array that contains the columns to be returned.")), arg("col_num (number, range)", _t("The first column index of the columns to be returned.")), arg("col_num2 (number, range, repeating)", _t("The columns indexes of the columns to be returned.")), ], compute: function (array, ...columns) { const _array = toMatrix(array); const _columns = flattenRowFirst(columns, (item) => toInteger(item?.value, this.locale)); const argOutOfRange = _columns.filter((col) => col === 0 || _array.length < Math.abs(col)); assert(() => argOutOfRange.length === 0, _t("The columns arguments must be between -%s and %s (got %s), excluding 0.", _array.length.toString(), _array.length.toString(), argOutOfRange.join(","))); const result = Array(_columns.length); for (let col = 0; col < _columns.length; col++) { if (_columns[col] > 0) { result[col] = _array[_columns[col] - 1]; // -1 because columns arguments are 1-indexed } else { result[col] = _array[_array.length + _columns[col]]; } } return result; }, isExported: true, }; // ----------------------------------------------------------------------------- // CHOOSEROWS // ----------------------------------------------------------------------------- const CHOOSEROWS = { description: _t("Creates a new array from the selected rows in the existing range."), args: [ arg("array (any, range)", _t("The array that contains the rows to be returned.")), arg("row_num (number, range)", _t("The first row index of the rows to be returned.")), arg("row_num2 (number, range, repeating)", _t("The rows indexes of the rows to be returned.")), ], compute: function (array, ...rows) { const _array = toMatrix(array); const _rows = flattenRowFirst(rows, (item) => toInteger(item?.value, this.locale)); const _nbColumns = _array.length; const argOutOfRange = _rows.filter((row) => row === 0 || _array[0].length < Math.abs(row)); assert(() => argOutOfRange.length === 0, _t("The rows arguments must be between -%s and %s (got %s), excluding 0.", _array[0].length.toString(), _array[0].length.toString(), argOutOfRange.join(","))); return generateMatrix(_nbColumns, _rows.length, (col, row) => { if (_rows[row] > 0) { return _array[col][_rows[row] - 1]; // -1 because columns arguments are 1-indexed } return _array[col][_array[col].length + _rows[row]]; }); }, isExported: true, }; // ----------------------------------------------------------------------------- // EXPAND // ----------------------------------------------------------------------------- const EXPAND = { description: _t("Expands or pads an array to specified row and column dimensions."), args: [ arg("array (any, range)", _t("The array to expand.")), arg("rows (number)", _t("The number of rows in the expanded array. If missing, rows will not be expanded.")), arg("columns (number, optional)", _t("The number of columns in the expanded array. If missing, columns will not be expanded.")), arg("pad_with (any, default=0)", _t("The value with which to pad.")), // @compatibility: on Excel, pad with #N/A ], compute: function (arg, rows, columns, padWith = { value: 0 } // TODO : Replace with #N/A errors once it's supported ) { const _array = toMatrix(arg); const _nbRows = toInteger(rows?.value, this.locale); const _nbColumns = columns !== undefined ? toInteger(columns.value, this.locale) : _array.length; assert(() => _nbRows >= _array[0].length, _t("The rows arguments (%s) must be greater or equal than the number of rows of the array.", _nbRows.toString())); assert(() => _nbColumns >= _array.length, _t("The columns arguments (%s) must be greater or equal than the number of columns of the array.", _nbColumns.toString())); return generateMatrix(_nbColumns, _nbRows, (col, row) => col >= _array.length || row >= _array[col].length ? padWith : _array[col][row]); }, isExported: true, }; // ----------------------------------------------------------------------------- // FLATTEN // ----------------------------------------------------------------------------- const FLATTEN = { description: _t("Flattens all the values from one or more ranges into a single column."), args: [ arg("range (any, range)", _t("The first range to flatten.")), arg("range2 (any, range, repeating)", _t("Additional ranges to flatten.")), ], compute: function (...ranges) { return [flattenRowFirst(ranges, (val) => (val === undefined ? { value: "" } : val))]; }, isExported: false, }; // ----------------------------------------------------------------------------- // FREQUENCY // ----------------------------------------------------------------------------- const FREQUENCY = { description: _t("Calculates the frequency distribution of a range."), args: [ arg("data (range)", _t("The array of ranges containing the values to be counted.")), arg("classes (number, range)", _t("The range containing the set of classes.")), ], compute: function (data, classes) { const _data = flattenRowFirst([data], (data) => data.value).filter((val) => typeof val === "number"); const _classes = flattenRowFirst([classes], (data) => data.value).filter((val) => typeof val === "number"); /** * Returns the frequency distribution of the data in the classes, ie. the number of elements in the range * between each classes. * * For example: * - data = [1, 3, 2, 5, 4] * - classes = [3, 5, 1] * * The result will be: * - 2 ==> number of elements 1 > el >= 3 * - 2 ==> number of elements 3 > el >= 5 * - 1 ==> number of elements <= 1 * - 0 ==> number of elements > 5 * * @compatibility: GSheet sort the input classes. We do the implemntation of Excel, where we kee the classes unsorted. */ const sortedClasses = _classes .map((value, index) => ({ initialIndex: index, value, count: 0 })) .sort((a, b) => a.value - b.value); sortedClasses.push({ initialIndex: sortedClasses.length, value: Infinity, count: 0 }); const sortedData = _data.sort((a, b) => a - b); let index = 0; for (const val of sortedData) { while (val > sortedClasses[index].value && index < sortedClasses.length - 1) { index++; } sortedClasses[index].count++; } const result = sortedClasses .sort((a, b) => a.initialIndex - b.initialIndex) .map((val) => val.count); return [result]; }, isExported: true, }; // ----------------------------------------------------------------------------- // HSTACK // ----------------------------------------------------------------------------- const HSTACK = { description: _t("Appends ranges horizontally and in sequence to return a larger array."), args: [ arg("range1 (any, range)", _t("The first range to be appended.")), arg("range2 (any, range, repeating)", _t("Additional ranges to add to range1.")), ], compute: function (...ranges) { const nbRows = Math.max(...ranges.map((r) => r?.[0]?.length ?? 0)); const result = []; for (const range of ranges) { const _range = toMatrix(range); for (let col = 0; col < _range.length; col++) { //TODO: fill with #N/A for unavailable values instead of zeroes const array = Array(nbRows).fill({ value: null }); for (let row = 0; row < _range[col].length; row++) { array[row] = _range[col][row]; } result.push(array); } } return result; }, isExported: true, }; // ----------------------------------------------------------------------------- // MDETERM // ----------------------------------------------------------------------------- const MDETERM = { description: _t("Returns the matrix determinant of a square matrix."), args: [ arg("square_matrix (number, range)", _t("An range with an equal number of rows and columns representing a matrix whose determinant will be calculated.")), ], compute: function (matrix) { const _matrix = toNumberMatrix(matrix, "square_matrix"); assertSquareMatrix(_t("The argument square_matrix must have the same number of columns and rows."), _matrix); return invertMatrix(_matrix).determinant; }, isExported: true, }; // ----------------------------------------------------------------------------- // MINVERSE // ----------------------------------------------------------------------------- const MINVERSE = { description: _t("Returns the multiplicative inverse of a square matrix."), args: [ arg("square_matrix (number, range)", _t("An range with an equal number of rows and columns representing a matrix whose multiplicative inverse will be calculated.")), ], compute: function (matrix) { const _matrix = toNumberMatrix(matrix, "square_matrix"); assertSquareMatrix(_t("The argument square_matrix must have the same number of columns and rows."), _matrix); const { inverted } = invertMatrix(_matrix); if (!inverted) { throw new EvaluationError(_t("The matrix is not invertible.")); } return inverted; }, isExported: true, }; // ----------------------------------------------------------------------------- // MMULT // ----------------------------------------------------------------------------- const MMULT = { description: _t("Calculates the matrix product of two matrices."), args: [ arg("matrix1 (number, range)", _t("The first matrix in the matrix multiplication operation.")), arg("matrix2 (number, range)", _t("The second matrix in the matrix multiplication operation.")), ], compute: function (matrix1, matrix2) { const _matrix1 = toNumberMatrix(matrix1, "matrix1"); const _matrix2 = toNumberMatrix(matrix2, "matrix2"); assert(() => _matrix1.length === _matrix2[0].length, _t("In [[FUNCTION_NAME]], the number of columns of the first matrix (%s) must be equal to the \ number of rows of the second matrix (%s).", _matrix1.length.toString(), _matrix2[0].length.toString())); return multiplyMatrices(_matrix1, _matrix2); }, isExported: true, }; // ----------------------------------------------------------------------------- // SUMPRODUCT // ----------------------------------------------------------------------------- const SUMPRODUCT = { description: _t("Calculates the sum of the products of corresponding entries in equal-sized ranges."), args: [ arg("range1 (number, range)", _t("The first range whose entries will be multiplied with corresponding entries in the other ranges.")), arg("range2 (number, range, repeating)", _t("The other range whose entries will be multiplied with corresponding entries in the other ranges.")), ], compute: function (...args) { assertSameDimensions(_t("All the ranges must have the same dimensions."), ...args); const _args = args.map(toMatrix); let result = 0; for (let col = 0; col < _args[0].length; col++) { for (let row = 0; row < _args[0][col].length; row++) { if (!_args.every((range) => typeof range[col][row].value === "number")) { continue; } let product = 1; for (const range of _args) { product *= toNumber(range[col][row], this.locale); } result += product; } } return result; }, isExported: true, }; // ----------------------------------------------------------------------------- // SUMX2MY2 // ----------------------------------------------------------------------------- /** * Return the sum of the callback applied to each pair of values in the two arrays. * * Ignore the pairs X,Y where one of the value isn't a number. Throw an error if no pair of numbers is found. */ function getSumXAndY(arrayX, arrayY, cb) { assertSameDimensions("The arguments array_x and array_y must have the same dimensions.", arrayX, arrayY); const _arrayX = toMatrix(arrayX); const _arrayY = toMatrix(arrayY); let validPairFound = false; let result = 0; for (const col in _arrayX) { for (const row in _arrayX[col]) { const arrayXValue = _arrayX[col][row].value; const arrayYValue = _arrayY[col][row].value; if (typeof arrayXValue !== "number" || typeof arrayYValue !== "number") { continue; } validPairFound = true; result += cb(arrayXValue, arrayYValue); } } if (!validPairFound) { throw new EvaluationError(_t("The arguments array_x and array_y must contain at least one pair of numbers.")); } return result; } const SUMX2MY2 = { description: _t("Calculates the sum of the difference of the squares of the values in two array."), args: [ arg("array_x (number, range)", _t("The array or range of values whose squares will be reduced by the squares of corresponding entries in array_y and added together.")), arg("array_y (number, range)", _t("The array or range of values whose squares will be subtracted from the squares of corresponding entries in array_x and added together.")), ], compute: function (arrayX, arrayY) { return getSumXAndY(arrayX, arrayY, (x, y) => x ** 2 - y ** 2); }, isExported: true, }; // ----------------------------------------------------------------------------- // SUMX2PY2 // ----------------------------------------------------------------------------- const SUMX2PY2 = { description: _t("Calculates the sum of the sum of the squares of the values in two array."), args: [ arg("array_x (number, range)", _t("The array or range of values whose squares will be added to the squares of corresponding entries in array_y and added together.")), arg("array_y (number, range)", _t("The array or range of values whose squares will be added to the squares of corresponding entries in array_x and added together.")), ], compute: function (arrayX, arrayY) { return getSumXAndY(arrayX, arrayY, (x, y) => x ** 2 + y ** 2); }, isExported: true, }; // ----------------------------------------------------------------------------- // SUMXMY2 // ----------------------------------------------------------------------------- const SUMXMY2 = { description: _t("Calculates the sum of squares of the differences of values in two array."), args: [ arg("array_x (number, range)", _t("The array or range of values that will be reduced by corresponding entries in array_y, squared, and added together.")), arg("array_y (number, range)", _t("The array or range of values that will be subtracted from corresponding entries in array_x, the result squared, and all such results added together.")), ], compute: function (arrayX, arrayY) { return getSumXAndY(arrayX, arrayY, (x, y) => (x - y) ** 2); }, isExported: true, }; // ----------------------------------------------------------------------------- // TOCOL // ----------------------------------------------------------------------------- const TO_COL_ROW_DEFAULT_IGNORE = 0; const TO_COL_ROW_DEFAULT_SCAN = false; const TO_COL_ROW_ARGS = [ arg("array (any, range)", _t("The array which will be transformed.")), arg(`ignore (number, default=${TO_COL_ROW_DEFAULT_IGNORE})`, _t("The control to ignore blanks and errors. 0 (default) is to keep all values, 1 is to ignore blanks, 2 is to ignore errors, and 3 is to ignore blanks and errors.")), arg(`scan_by_column (number, default=${TO_COL_ROW_DEFAULT_SCAN})`, _t("Whether the array should be scanned by column. True scans the array by column and false (default) \ scans the array by row.")), ]; function shouldKeepValue(ignore) { const _ignore = Math.trunc(ignore); if (_ignore === 0) { return () => true; } if (_ignore === 1) { return (data) => data.value !== null; } if (_ignore === 2) { return (data) => !isEvaluationError(data.value); } if (_ignore === 3) { return (data) => data.value !== null && !isEvaluationError(data.value); } throw new EvaluationError(_t("Argument ignore must be between 0 and 3")); } const TOCOL = { description: _t("Transforms a range of cells into a single column."), args: TO_COL_ROW_ARGS, compute: function (array, ignore = { value: TO_COL_ROW_DEFAULT_IGNORE }, scanByColumn = { value: TO_COL_ROW_DEFAULT_SCAN }) { const _array = toMatrix(array); const _ignore = toNumber(ignore.value, this.locale); const _scanByColumn = toBoolean(scanByColumn.value); const result = (_scanByColumn ? _array : transposeMatrix(_array)) .flat() .filter(shouldKeepValue(_ignore)); if (result.length === 0) { throw new NotAvailableError(_t("No results for the given arguments of TOCOL.")); } return [result]; }, isExported: true, }; // ----------------------------------------------------------------------------- // TOROW // ----------------------------------------------------------------------------- const TOROW = { description: _t("Transforms a range of cells into a single row."), args: TO_COL_ROW_ARGS, compute: function (array, ignore = { value: TO_COL_ROW_DEFAULT_IGNORE }, scanByColumn = { value: TO_COL_ROW_DEFAULT_SCAN }) { const _array = toMatrix(array); const _ignore = toNumber(ignore.value, this.locale); const _scanByColumn = toBoolean(scanByColumn.value); const result = (_scanByColumn ? _array : transposeMatrix(_array)) .flat() .filter(shouldKeepValue(_ignore)) .map((item) => [item]); if (result.length === 0 || result[0].length === 0) { throw new NotAvailableError(_t("No results for the given arguments of TOROW.")); } return result; }, isExported: true, }; // ----------------------------------------------------------------------------- // TRANSPOSE // ----------------------------------------------------------------------------- const TRANSPOSE = { description: _t("Transposes the rows and columns of a range."), args: [arg("range (any, range)", _t("The range to be transposed."))], compute: function (arg) { const _array = toMatrix(arg); const nbColumns = _array[0].length; const nbRows = _array.length; return generateMatrix(nbColumns, nbRows, (col, row) => _array[row][col]); }, isExported: true, }; // ----------------------------------------------------------------------------- // VSTACK // ----------------------------------------------------------------------------- const VSTACK = { description: _t("Appends ranges vertically and in sequence to return a larger array."), args: [ arg("range1 (any, range)", _t("The first range to be appended.")), arg("range2 (any, range, repeating)", _t("Additional ranges to add to range1.")), ], compute: function (...ranges) { const nbColumns = Math.max(...ranges.map((range) => toMatrix(range).length)); const nbRows = ranges.reduce((acc, range) => acc + toMatrix(range)[0].length, 0); const result = Array(nbColumns) .fill([]) .map(() => Array(nbRows).fill({ value: 0 })); // TODO fill with #N/A let currentRow = 0; for (const range of ranges) { const _array = toMatrix(range); for (let col = 0; col < _array.length; col++) { for (let row = 0; row < _array[col].length; row++) { result[col][currentRow + row] = _array[col][row]; } } currentRow += _array[0].length; } return result; }, isExported: true, }; // ----------------------------------------------------------------------------- // WRAPCOLS // ----------------------------------------------------------------------------- const WRAPCOLS = { description: _t("Wraps the provided row or column of cells by columns after a specified number of elements to form a new array."), args: [ arg("range (any, range)", _t("The range to wrap.")), arg("wrap_count (number)", _t("The maximum number of cells for each column, rounded down to the nearest whole number.")), arg("pad_with (any, default=0)", // TODO : replace with #N/A _t("The value with which to fill the extra cells in the range.")), ], compute: function (range, wrapCount, padWith = { value: 0 }) { const _array = toMatrix(range); const nbRows = toInteger(wrapCount?.value, this.locale); assertSingleColOrRow(_t("Argument range must be a single row or column."), _array); const array = _array.flat(); const nbColumns = Math.ceil(array.length / nbRows); return generateMatrix(nbColumns, nbRows, (col, row) => { const index = col * nbRows + row; return index < array.length ? array[index] : padWith; }); }, isExported: true, }; // ----------------------------------------------------------------------------- // WRAPROWS // ----------------------------------------------------------------------------- const WRAPROWS = { description: _t("Wraps the provided row or column of cells by rows after a specified number of elements to form a new array."), args: [ arg("range (any, range)", _t("The range to wrap.")), arg("wrap_count (number)", _t("The maximum number of cells for each row, rounded down to the nearest whole number.")), arg("pad_with (any, default=0)", // TODO : replace with #N/A _t("The value with which to fill the extra cells in the range.")), ], compute: function (range, wrapCount, padWith = { value: 0 }) { const _array = toMatrix(range); const nbColumns = toInteger(wrapCount?.value, this.locale); assertSingleColOrRow(_t("Argument range must be a single row or column."), _array); const array = _array.flat(); const nbRows = Math.ceil(array.length / nbColumns); return generateMatrix(nbColumns, nbRows, (col, row) => { const index = row * nbColumns + col; return index < array.length ? array[index] : padWith; }); }, isExported: true, }; var array = /*#__PURE__*/Object.freeze({ __proto__: null, ARRAY_CONSTRAIN: ARRAY_CONSTRAIN, CHOOSECOLS: CHOOSECOLS, CHOOSEROWS: CHOOSEROWS, EXPAND: EXPAND, FLATTEN: FLATTEN, FREQUENCY: FREQUENCY, HSTACK: HSTACK, MDETERM: MDETERM, MINVERSE: MINVERSE, MMULT: MMULT, SUMPRODUCT: SUMPRODUCT, SUMX2MY2: SUMX2MY2, SUMX2PY2: SUMX2PY2, SUMXMY2: SUMXMY2, TOCOL: TOCOL, TOROW: TOROW, TRANSPOSE: TRANSPOSE, VSTACK: VSTACK, WRAPCOLS: WRAPCOLS, WRAPROWS: WRAPROWS }); // ----------------------------------------------------------------------------- // FORMAT.LARGE.NUMBER // ----------------------------------------------------------------------------- const FORMAT_LARGE_NUMBER = { description: _t("Apply a large number format"), args: [ arg("value (number)", _t("The number.")), arg("unit (string, optional)", _t("The formatting unit. Use 'k', 'm', or 'b' to force the unit")), ], compute: function (value, unite) { return { value: toNumber(value, this.locale), format: formatLargeNumber(value, unite, this.locale), }; }, }; var misc = /*#__PURE__*/Object.freeze({ __proto__: null, FORMAT_LARGE_NUMBER: FORMAT_LARGE_NUMBER }); const DEFAULT_FACTOR = 1; const DEFAULT_MODE = 0; const DEFAULT_PLACES = 0; const DEFAULT_SIGNIFICANCE = 1; const DECIMAL_REPRESENTATION = /^-?[a-z0-9]+$/i; // ----------------------------------------------------------------------------- // ABS // ----------------------------------------------------------------------------- const ABS = { description: _t("Absolute value of a number."), args: [arg("value (number)", _t("The number of which to return the absolute value."))], compute: function (value) { return Math.abs(toNumber(value, this.locale)); }, isExported: true, }; // ----------------------------------------------------------------------------- // ACOS // ----------------------------------------------------------------------------- const ACOS = { description: _t("Inverse cosine of a value, in radians."), args: [ arg("value (number)", _t("The value for which to calculate the inverse cosine. Must be between -1 and 1, inclusive.")), ], compute: function (value) { const _value = toNumber(value, this.locale); assert(() => Math.abs(_value) <= 1, _t("The value (%s) must be between -1 and 1 inclusive.", _value.toString())); return Math.acos(_value); }, isExported: true, }; // ----------------------------------------------------------------------------- // ACOSH // ----------------------------------------------------------------------------- const ACOSH = { description: _t("Inverse hyperbolic cosine of a number."), args: [ arg("value (number)", _t("The value for which to calculate the inverse hyperbolic cosine. Must be greater than or equal to 1.")), ], compute: function (value) { const _value = toNumber(value, this.locale); assert(() => _value >= 1, _t("The value (%s) must be greater than or equal to 1.", _value.toString())); return Math.acosh(_value); }, isExported: true, }; // ----------------------------------------------------------------------------- // ACOT // ----------------------------------------------------------------------------- const ACOT = { description: _t("Inverse cotangent of a value."), args: [arg("value (number)", _t("The value for which to calculate the inverse cotangent."))], compute: function (value) { const _value = toNumber(value, this.locale); const sign = Math.sign(_value) || 1; // ACOT has two possible configurations: // @compatibility Excel: return Math.PI / 2 - Math.atan(toNumber(_value, this.locale)); // @compatibility Google: return sign * Math.PI / 2 - Math.atan(toNumber(_value, this.locale)); return (sign * Math.PI) / 2 - Math.atan(_value); }, isExported: true, }; // ----------------------------------------------------------------------------- // ACOTH // ----------------------------------------------------------------------------- const ACOTH = { description: _t("Inverse hyperbolic cotangent of a value."), args: [ arg("value (number)", _t("The value for which to calculate the inverse hyperbolic cotangent. Must not be between -1 and 1, inclusive.")), ], compute: function (value) { const _value = toNumber(value, this.locale); assert(() => Math.abs(_value) > 1, _t("The value (%s) cannot be between -1 and 1 inclusive.", _value.toString())); return Math.log((_value + 1) / (_value - 1)) / 2; }, isExported: true, }; // ----------------------------------------------------------------------------- // ASIN // ----------------------------------------------------------------------------- const ASIN = { description: _t("Inverse sine of a value, in radians."), args: [ arg("value (number)", _t("The value for which to calculate the inverse sine. Must be between -1 and 1, inclusive.")), ], compute: function (value) { const _value = toNumber(value, this.locale); assert(() => Math.abs(_value) <= 1, _t("The value (%s) must be between -1 and 1 inclusive.", _value.toString())); return Math.asin(_value); }, isExported: true, }; // ----------------------------------------------------------------------------- // ASINH // ----------------------------------------------------------------------------- const ASINH = { description: _t("Inverse hyperbolic sine of a number."), args: [ arg("value (number)", _t("The value for which to calculate the inverse hyperbolic sine.")), ], compute: function (value) { return Math.asinh(toNumber(value, this.locale)); }, isExported: true, }; // ----------------------------------------------------------------------------- // ATAN // ----------------------------------------------------------------------------- const ATAN = { description: _t("Inverse tangent of a value, in radians."), args: [arg("value (number)", _t("The value for which to calculate the inverse tangent."))], compute: function (value) { return Math.atan(toNumber(value, this.locale)); }, isExported: true, }; // ----------------------------------------------------------------------------- // ATAN2 // ----------------------------------------------------------------------------- const ATAN2 = { description: _t("Angle from the X axis to a point (x,y), in radians."), args: [ arg("x (number)", _t("The x coordinate of the endpoint of the line segment for which to calculate the angle from the x-axis.")), arg("y (number)", _t("The y coordinate of the endpoint of the line segment for which to calculate the angle from the x-axis.")), ], compute: function (x, y) { const _x = toNumber(x, this.locale); const _y = toNumber(y, this.locale); assert(() => _x !== 0 || _y !== 0, _t("Function [[FUNCTION_NAME]] caused a divide by zero error."), CellErrorType.DivisionByZero); return Math.atan2(_y, _x); }, isExported: true, }; // ----------------------------------------------------------------------------- // ATANH // ----------------------------------------------------------------------------- const ATANH = { description: _t("Inverse hyperbolic tangent of a number."), args: [ arg("value (number)", _t("The value for which to calculate the inverse hyperbolic tangent. Must be between -1 and 1, exclusive.")), ], compute: function (value) { const _value = toNumber(value, this.locale); assert(() => Math.abs(_value) < 1, _t("The value (%s) must be between -1 and 1 exclusive.", _value.toString())); return Math.atanh(_value); }, isExported: true, }; // ----------------------------------------------------------------------------- // CEILING // ----------------------------------------------------------------------------- const CEILING = { description: _t("Rounds number up to nearest multiple of factor."), args: [ arg("value (number)", _t("The value to round up to the nearest integer multiple of factor.")), arg(`factor (number, default=${DEFAULT_FACTOR})`, _t("The number to whose multiples value will be rounded.")), ], compute: function (value, factor = { value: DEFAULT_FACTOR }) { const _value = toNumber(value, this.locale); const _factor = toNumber(factor, this.locale); assert(() => _factor >= 0 || _value <= 0, _t("The factor (%s) must be positive when the value (%s) is positive.", _factor.toString(), _value.toString())); return { value: _factor ? Math.ceil(_value / _factor) * _factor : 0, format: value?.format, }; }, isExported: true, }; // ----------------------------------------------------------------------------- // CEILING.MATH // ----------------------------------------------------------------------------- function ceilingMath(number, significance, mode = 0) { if (significance === 0) { return 0; } significance = Math.abs(significance); if (number >= 0) { return Math.ceil(number / significance) * significance; } if (mode === 0) { return -Math.floor(Math.abs(number) / significance) * significance; } return -Math.ceil(Math.abs(number) / significance) * significance; } const CEILING_MATH = { description: _t("Rounds number up to nearest multiple of factor."), args: [ arg("number (number)", _t("The value to round up to the nearest integer multiple of significance.")), arg(`significance (number, default=${DEFAULT_SIGNIFICANCE})`, _t("The number to whose multiples number will be rounded. The sign of significance will be ignored.")), arg(`mode (number, default=${DEFAULT_MODE})`, _t("If number is negative, specifies the rounding direction. If 0 or blank, it is rounded towards zero. Otherwise, it is rounded away from zero.")), ], compute: function (number, significance = { value: DEFAULT_SIGNIFICANCE }, mode = { value: DEFAULT_MODE }) { const _significance = toNumber(significance, this.locale); const _number = toNumber(number, this.locale); const _mode = toNumber(mode, this.locale); return { value: ceilingMath(_number, _significance, _mode), format: number?.format, }; }, isExported: true, }; // ----------------------------------------------------------------------------- // CEILING.PRECISE // ----------------------------------------------------------------------------- const CEILING_PRECISE = { description: _t("Rounds number up to nearest multiple of factor."), args: [ arg("number (number)", _t("The value to round up to the nearest integer multiple of significance.")), arg(`significance (number, default=${DEFAULT_SIGNIFICANCE})`, _t("The number to whose multiples number will be rounded.")), ], compute: function (number, significance = { value: DEFAULT_SIGNIFICANCE }) { const _significance = toNumber(significance, this.locale); const _number = toNumber(number, this.locale); return { value: ceilingMath(_number, _significance), format: number?.format, }; }, isExported: true, }; // ----------------------------------------------------------------------------- // COS // ----------------------------------------------------------------------------- const COS = { description: _t("Cosine of an angle provided in radians."), args: [arg("angle (number)", _t("The angle to find the cosine of, in radians."))], compute: function (angle) { return Math.cos(toNumber(angle, this.locale)); }, isExported: true, }; // ----------------------------------------------------------------------------- // COSH // ----------------------------------------------------------------------------- const COSH = { description: _t("Hyperbolic cosine of any real number."), args: [arg("value (number)", _t("Any real value to calculate the hyperbolic cosine of."))], compute: function (value) { return Math.cosh(toNumber(value, this.locale)); }, isExported: true, }; // ----------------------------------------------------------------------------- // COT // ----------------------------------------------------------------------------- const COT = { description: _t("Cotangent of an angle provided in radians."), args: [arg("angle (number)", _t("The angle to find the cotangent of, in radians."))], compute: function (angle) { const _angle = toNumber(angle, this.locale); assertNotZero(_angle); return 1 / Math.tan(_angle); }, isExported: true, }; // ----------------------------------------------------------------------------- // COTH // ----------------------------------------------------------------------------- const COTH = { description: _t("Hyperbolic cotangent of any real number."), args: [arg("value (number)", _t("Any real value to calculate the hyperbolic cotangent of."))], compute: function (value) { const _value = toNumber(value, this.locale); assertNotZero(_value); return 1 / Math.tanh(_value); }, isExported: true, }; // ----------------------------------------------------------------------------- // COUNTBLANK // ----------------------------------------------------------------------------- const COUNTBLANK = { description: _t("Number of empty values."), args: [ arg("value1 (any, range)", _t("The first value or range in which to count the number of blanks.")), arg("value2 (any, range, repeating)", _t("Additional values or ranges in which to count the number of blanks.")), ], compute: function (...args) { return reduceAny(args, (acc, a) => { if (a === undefined) { return acc + 1; } if (a.value === null) { return acc + 1; } if (a.value === "") { return acc + 1; } return acc; }, 0); }, isExported: true, }; // ----------------------------------------------------------------------------- // COUNTIF // ----------------------------------------------------------------------------- const COUNTIF = { description: _t("A conditional count across a range."), args: [ arg("range (range)", _t("The range that is tested against criterion.")), arg("criterion (string)", _t("The pattern or test to apply to range.")), ], compute: function (...args) { let count = 0; visitMatchingRanges(args, (i, j) => { count += 1; }, this.locale); return count; }, isExported: true, }; // ----------------------------------------------------------------------------- // COUNTIFS // ----------------------------------------------------------------------------- const COUNTIFS = { description: _t("Count values depending on multiple criteria."), args: [ arg("criteria_range1 (range)", _t("The range to check against criterion1.")), arg("criterion1 (string)", _t("The pattern or test to apply to criteria_range1.")), arg("criteria_range2 (any, range, repeating)", _t("Additional ranges over which to evaluate the additional criteria. The filtered set will be the intersection of the sets produced by each criterion-range pair.")), arg("criterion2 (string, repeating)", _t("Additional criteria to check.")), ], compute: function (...args) { let count = 0; visitMatchingRanges(args, (i, j) => { count += 1; }, this.locale); return count; }, isExported: true, }; // ----------------------------------------------------------------------------- // COUNTUNIQUE // ----------------------------------------------------------------------------- const COUNTUNIQUE = { description: _t("Counts number of unique values in a range."), args: [ arg("value1 (any, range)", _t("The first value or range to consider for uniqueness.")), arg("value2 (any, range, repeating)", _t("Additional values or ranges to consider for uniqueness.")), ], compute: function (...args) { return countUnique(args); }, }; // ----------------------------------------------------------------------------- // COUNTUNIQUEIFS // ----------------------------------------------------------------------------- const COUNTUNIQUEIFS = { description: _t("Counts number of unique values in a range, filtered by a set of criteria."), args: [ arg("range (range)", _t("The range of cells from which the number of unique values will be counted.")), arg("criteria_range1 (range)", _t("The range of cells over which to evaluate criterion1.")), arg("criterion1 (string)", _t("The pattern or test to apply to criteria_range1, such that each cell that evaluates to TRUE will be included in the filtered set.")), arg("criteria_range2 (any, range, repeating)", _t("Additional ranges over which to evaluate the additional criteria. The filtered set will be the intersection of the sets produced by each criterion-range pair.")), arg("criterion2 (string, repeating)", _t("The pattern or test to apply to criteria_range2.")), ], compute: function (range, ...args) { let uniqueValues = new Set(); visitMatchingRanges(args, (i, j) => { const data = range[i]?.[j]; if (isDataNonEmpty(data)) { uniqueValues.add(data.value); } }, this.locale); return uniqueValues.size; }, }; // ----------------------------------------------------------------------------- // CSC // ----------------------------------------------------------------------------- const CSC = { description: _t("Cosecant of an angle provided in radians."), args: [arg("angle (number)", _t("The angle to find the cosecant of, in radians."))], compute: function (angle) { const _angle = toNumber(angle, this.locale); assertNotZero(_angle); return 1 / Math.sin(_angle); }, isExported: true, }; // ----------------------------------------------------------------------------- // CSCH // ----------------------------------------------------------------------------- const CSCH = { description: _t("Hyperbolic cosecant of any real number."), args: [arg("value (number)", _t("Any real value to calculate the hyperbolic cosecant of."))], compute: function (value) { const _value = toNumber(value, this.locale); assertNotZero(_value); return 1 / Math.sinh(_value); }, isExported: true, }; // ----------------------------------------------------------------------------- // DECIMAL // ----------------------------------------------------------------------------- const DECIMAL = { description: _t("Converts from another base to decimal."), args: [ arg("value (string)", _t("The number to convert.")), arg("base (number)", _t("The base to convert the value from.")), ], compute: function (value, base) { let _base = toNumber(base, this.locale); _base = Math.floor(_base); assert(() => 2 <= _base && _base <= 36, _t("The base (%s) must be between 2 and 36 inclusive.", _base.toString())); const _value = toString(value); if (_value === "") { return 0; } /** * @compatibility: on Google sheets, expects the parameter 'value' to be positive. * Return error if 'value' is positive. * Remove '-?' in the next regex to catch this error. */ assert(() => !!DECIMAL_REPRESENTATION.test(_value), _t("The value (%s) must be a valid base %s representation.", _value, _base.toString())); const deci = parseInt(_value, _base); assert(() => !isNaN(deci), _t("The value (%s) must be a valid base %s representation.", _value, _base.toString())); return deci; }, isExported: true, }; // ----------------------------------------------------------------------------- // DEGREES // ----------------------------------------------------------------------------- const DEGREES = { description: _t("Converts an angle value in radians to degrees."), args: [arg("angle (number)", _t("The angle to convert from radians to degrees."))], compute: function (angle) { return (toNumber(angle, this.locale) * 180) / Math.PI; }, isExported: true, }; // ----------------------------------------------------------------------------- // EXP // ----------------------------------------------------------------------------- const EXP = { description: _t("Euler's number, e (~2.718) raised to a power."), args: [arg("value (number)", _t("The exponent to raise e."))], compute: function (value) { return Math.exp(toNumber(value, this.locale)); }, isExported: true, }; // ----------------------------------------------------------------------------- // FLOOR // ----------------------------------------------------------------------------- const FLOOR = { description: _t("Rounds number down to nearest multiple of factor."), args: [ arg("value (number)", _t("The value to round down to the nearest integer multiple of factor.")), arg(`factor (number, default=${DEFAULT_FACTOR})`, _t("The number to whose multiples value will be rounded.")), ], compute: function (value, factor = { value: DEFAULT_FACTOR }) { const _value = toNumber(value, this.locale); const _factor = toNumber(factor, this.locale); assert(() => _factor >= 0 || _value <= 0, _t("The factor (%s) must be positive when the value (%s) is positive.", _factor.toString(), _value.toString())); return { value: _factor ? Math.floor(_value / _factor) * _factor : 0, format: value?.format, }; }, isExported: true, }; // ----------------------------------------------------------------------------- // FLOOR.MATH // ----------------------------------------------------------------------------- function floorMath(number, significance, mode = 0) { if (significance === 0) { return 0; } significance = Math.abs(significance); if (number >= 0) { return Math.floor(number / significance) * significance; } if (mode === 0) { return -Math.ceil(Math.abs(number) / significance) * significance; } return -Math.floor(Math.abs(number) / significance) * significance; } const FLOOR_MATH = { description: _t("Rounds number down to nearest multiple of factor."), args: [ arg("number (number)", _t("The value to round down to the nearest integer multiple of significance.")), arg(`significance (number, default=${DEFAULT_SIGNIFICANCE})`, _t("The number to whose multiples number will be rounded. The sign of significance will be ignored.")), arg(`mode (number, default=${DEFAULT_MODE})`, _t("If number is negative, specifies the rounding direction. If 0 or blank, it is rounded away from zero. Otherwise, it is rounded towards zero.")), ], compute: function (number, significance = { value: DEFAULT_SIGNIFICANCE }, mode = { value: DEFAULT_MODE }) { const _significance = toNumber(significance, this.locale); const _number = toNumber(number, this.locale); const _mode = toNumber(mode, this.locale); return { value: floorMath(_number, _significance, _mode), format: number?.format, }; }, isExported: true, }; // ----------------------------------------------------------------------------- // FLOOR.PRECISE // ----------------------------------------------------------------------------- const FLOOR_PRECISE = { description: _t("Rounds number down to nearest multiple of factor."), args: [ arg("number (number)", _t("The value to round down to the nearest integer multiple of significance.")), arg(`significance (number, default=${DEFAULT_SIGNIFICANCE})`, _t("The number to whose multiples number will be rounded.")), ], compute: function (number, significance = { value: DEFAULT_SIGNIFICANCE }) { const _significance = toNumber(significance, this.locale); const _number = toNumber(number, this.locale); return { value: floorMath(_number, _significance), format: number?.format, }; }, isExported: true, }; // ----------------------------------------------------------------------------- // ISEVEN // ----------------------------------------------------------------------------- const ISEVEN = { description: _t("Whether the provided value is even."), args: [arg("value (number)", _t("The value to be verified as even."))], compute: function (value) { const _value = strictToNumber(value, this.locale); return Math.floor(Math.abs(_value)) & 1 ? false : true; }, isExported: true, }; // ----------------------------------------------------------------------------- // ISO.CEILING // ----------------------------------------------------------------------------- const ISO_CEILING = { description: _t("Rounds number up to nearest multiple of factor."), args: [ arg("number (number)", _t("The value to round up to the nearest integer multiple of significance.")), arg(`significance (number, default=${DEFAULT_SIGNIFICANCE})`, _t("The number to whose multiples number will be rounded.")), ], compute: function (number, significance = { value: DEFAULT_SIGNIFICANCE }) { const _number = toNumber(number, this.locale); const _significance = toNumber(significance, this.locale); return { value: ceilingMath(_number, _significance), format: number?.format, }; }, isExported: true, }; // ----------------------------------------------------------------------------- // ISODD // ----------------------------------------------------------------------------- const ISODD = { description: _t("Whether the provided value is even."), args: [arg("value (number)", _t("The value to be verified as even."))], compute: function (value) { const _value = strictToNumber(value, this.locale); return Math.floor(Math.abs(_value)) & 1 ? true : false; }, isExported: true, }; // ----------------------------------------------------------------------------- // LN // ----------------------------------------------------------------------------- const LN = { description: _t("The logarithm of a number, base e (euler's number)."), args: [arg("value (number)", _t("The value for which to calculate the logarithm, base e."))], compute: function (value) { const _value = toNumber(value, this.locale); assert(() => _value > 0, _t("The value (%s) must be strictly positive.", _value.toString())); return Math.log(_value); }, isExported: true, }; // ----------------------------------------------------------------------------- // MOD // ----------------------------------------------------------------------------- function mod(dividend, divisor) { assert(() => divisor !== 0, _t("The divisor must be different from 0."), CellErrorType.DivisionByZero); const modulus = dividend % divisor; // -42 % 10 = -2 but we want 8, so need the code below if ((modulus > 0 && divisor < 0) || (modulus < 0 && divisor > 0)) { return modulus + divisor; } return modulus; } const MOD = { description: _t("Modulo (remainder) operator."), args: [ arg("dividend (number)", _t("The number to be divided to find the remainder.")), arg("divisor (number)", _t("The number to divide by.")), ], compute: function (dividend, divisor) { const _divisor = toNumber(divisor, this.locale); const _dividend = toNumber(dividend, this.locale); return { value: mod(_dividend, _divisor), format: dividend?.format, }; }, isExported: true, }; // ----------------------------------------------------------------------------- // MUNIT // ----------------------------------------------------------------------------- const MUNIT = { description: _t("Returns a n x n unit matrix, where n is the input dimension."), args: [ arg("dimension (number)", _t("An integer specifying the dimension size of the unit matrix. It must be positive.")), ], compute: function (n) { const _n = toInteger(n, this.locale); assertPositive(_t("The argument dimension must be positive"), _n); return getUnitMatrix(_n); }, isExported: true, }; // ----------------------------------------------------------------------------- // ODD // ----------------------------------------------------------------------------- const ODD = { description: _t("Rounds a number up to the nearest odd integer."), args: [arg("value (number)", _t("The value to round to the next greatest odd number."))], compute: function (value) { const _value = toNumber(value, this.locale); let temp = Math.ceil(Math.abs(_value)); temp = temp & 1 ? temp : temp + 1; return { value: _value < 0 ? -temp : temp, format: value?.format, }; }, isExported: true, }; // ----------------------------------------------------------------------------- // PI // ----------------------------------------------------------------------------- const PI = { description: _t("The number pi."), args: [], compute: function () { return Math.PI; }, isExported: true, }; // ----------------------------------------------------------------------------- // POWER // ----------------------------------------------------------------------------- const POWER = { description: _t("A number raised to a power."), args: [ arg("base (number)", _t("The number to raise to the exponent power.")), arg("exponent (number)", _t("The exponent to raise base to.")), ], compute: function (base, exponent) { const _base = toNumber(base, this.locale); const _exponent = toNumber(exponent, this.locale); assert(() => _base >= 0 || Number.isInteger(_exponent), _t("The exponent (%s) must be an integer when the base is negative.", _exponent.toString())); return { value: Math.pow(_base, _exponent), format: base?.format }; }, isExported: true, }; // ----------------------------------------------------------------------------- // PRODUCT // ----------------------------------------------------------------------------- const PRODUCT = { description: _t("Result of multiplying a series of numbers together."), args: [ arg("factor1 (number, range)", _t("The first number or range to calculate for the product.")), arg("factor2 (number, range, repeating)", _t("More numbers or ranges to calculate for the product.")), ], compute: function (...factors) { let count = 0; let acc = 1; for (let n of factors) { if (isMatrix(n)) { for (let i of n) { for (let j of i) { const f = j.value; if (typeof f === "number") { acc *= f; count += 1; } if (isEvaluationError(f)) { throw j; } } } } else if (n !== undefined && n.value !== null) { acc *= strictToNumber(n, this.locale); count += 1; } } return { value: count === 0 ? 0 : acc, format: inferFormat(factors[0]), }; }, isExported: true, }; // ----------------------------------------------------------------------------- // RAND // ----------------------------------------------------------------------------- const RAND = { description: _t("A random number between 0 inclusive and 1 exclusive."), args: [], compute: function () { return Math.random(); }, isExported: true, }; // ----------------------------------------------------------------------------- // RANDARRAY // ----------------------------------------------------------------------------- const RANDARRAY = { description: _t("Returns a grid of random numbers between 0 inclusive and 1 exclusive."), args: [ arg("rows (number, default=1)", _t("The number of rows to be returned.")), arg("columns (number, default=1)", _t("The number of columns to be returned.")), arg("min (number, default=0)", _t("The minimum number you would like returned.")), arg("max (number, default=1)", _t("The maximum number you would like returned.")), arg("whole_number (number, default=FALSE)", _t("Return a whole number or a decimal value.")), ], compute: function (rows = { value: 1 }, columns = { value: 1 }, min = { value: 0 }, max = { value: 1 }, wholeNumber = { value: false }) { const _cols = toInteger(columns, this.locale); const _rows = toInteger(rows, this.locale); const _min = toNumber(min, this.locale); const _max = toNumber(max, this.locale); const _whole_number = toBoolean(wholeNumber); assertPositive(_t("The number of columns (%s) must be positive.", _cols.toString()), _cols); assertPositive(_t("The number of rows (%s) must be positive.", _rows.toString()), _rows); assert(() => _min <= _max, _t("The maximum (%s) must be greater than or equal to the minimum (%s).", _max.toString(), _min.toString())); if (_whole_number) { assert(() => Number.isInteger(_min) && Number.isInteger(_max), _t("The maximum (%s) and minimum (%s) must be integers when whole_number is TRUE.", _max.toString(), _min.toString())); } const result = Array(_cols); for (let col = 0; col < _cols; col++) { result[col] = Array(_rows); for (let row = 0; row < _rows; row++) { if (!_whole_number) { result[col][row] = _min + Math.random() * (_max - _min); } else { result[col][row] = Math.floor(Math.random() * (_max - _min + 1) + _min); } } } return result; }, isExported: true, }; // ----------------------------------------------------------------------------- // RANDBETWEEN // ----------------------------------------------------------------------------- const RANDBETWEEN = { description: _t("Random integer between two values, inclusive."), args: [ arg("low (number)", _t("The low end of the random range.")), arg("high (number)", _t("The high end of the random range.")), ], compute: function (low, high) { let _low = toNumber(low, this.locale); if (!Number.isInteger(_low)) { _low = Math.ceil(_low); } let _high = toNumber(high, this.locale); if (!Number.isInteger(_high)) { _high = Math.floor(_high); } assert(() => _low <= _high, _t("The high (%s) must be greater than or equal to the low (%s).", _high.toString(), _low.toString())); return { value: _low + Math.ceil((_high - _low + 1) * Math.random()) - 1, format: low?.format, }; }, isExported: true, }; // ----------------------------------------------------------------------------- // ROUND // ----------------------------------------------------------------------------- const ROUND = { description: _t("Rounds a number according to standard rules."), args: [ arg("value (number)", _t("The value to round to places number of places.")), arg(`places (number, default=${DEFAULT_PLACES})`, _t("The number of decimal places to which to round.")), ], compute: function (value, places = { value: DEFAULT_PLACES }) { const _value = toNumber(value, this.locale); let _places = toNumber(places, this.locale); const absValue = Math.abs(_value); let tempResult; if (_places === 0) { tempResult = Math.round(absValue); } else { if (!Number.isInteger(_places)) { _places = Math.trunc(_places); } tempResult = Math.round(absValue * Math.pow(10, _places)) / Math.pow(10, _places); } return { value: _value >= 0 ? tempResult : -tempResult, format: value?.format, }; }, isExported: true, }; // ----------------------------------------------------------------------------- // ROUNDDOWN // ----------------------------------------------------------------------------- const ROUNDDOWN = { description: _t("Rounds down a number."), args: [ arg("value (number)", _t("The value to round to places number of places, always rounding down.")), arg(`places (number, default=${DEFAULT_PLACES})`, _t("The number of decimal places to which to round.")), ], compute: function (value, places = { value: DEFAULT_PLACES }) { const _value = toNumber(value, this.locale); let _places = toNumber(places, this.locale); const absValue = Math.abs(_value); let tempResult; if (_places === 0) { tempResult = Math.floor(absValue); } else { if (!Number.isInteger(_places)) { _places = Math.trunc(_places); } tempResult = Math.floor(absValue * Math.pow(10, _places)) / Math.pow(10, _places); } return { value: _value >= 0 ? tempResult : -tempResult, format: value?.format, }; }, isExported: true, }; // ----------------------------------------------------------------------------- // ROUNDUP // ----------------------------------------------------------------------------- const ROUNDUP = { description: _t("Rounds up a number."), args: [ arg("value (number)", _t("The value to round to places number of places, always rounding up.")), arg(`places (number, default=${DEFAULT_PLACES})`, _t("The number of decimal places to which to round.")), ], compute: function (value, places = { value: DEFAULT_PLACES }) { const _value = toNumber(value, this.locale); let _places = toNumber(places, this.locale); const absValue = Math.abs(_value); let tempResult; if (_places === 0) { tempResult = Math.ceil(absValue); } else { if (!Number.isInteger(_places)) { _places = Math.trunc(_places); } tempResult = Math.ceil(absValue * Math.pow(10, _places)) / Math.pow(10, _places); } return { value: _value >= 0 ? tempResult : -tempResult, format: value?.format, }; }, isExported: true, }; // ----------------------------------------------------------------------------- // SEC // ----------------------------------------------------------------------------- const SEC = { description: _t("Secant of an angle provided in radians."), args: [arg("angle (number)", _t("The angle to find the secant of, in radians."))], compute: function (angle) { return 1 / Math.cos(toNumber(angle, this.locale)); }, isExported: true, }; // ----------------------------------------------------------------------------- // SECH // ----------------------------------------------------------------------------- const SECH = { description: _t("Hyperbolic secant of any real number."), args: [arg("value (number)", _t("Any real value to calculate the hyperbolic secant of."))], compute: function (value) { return 1 / Math.cosh(toNumber(value, this.locale)); }, isExported: true, }; // ----------------------------------------------------------------------------- // SEQUENCE // ----------------------------------------------------------------------------- const SEQUENCE = { description: _t("Returns a sequence of numbers."), args: [ arg("rows (number)", _t("The number of rows to return")), arg("columns (number, optional, default=1)", _t("The number of columns to return")), arg("start (number, optional, default=1)", _t("The first number in the sequence")), arg("step (number, optional, default=1)", _t("The amount to increment each value in the sequence")), ], compute: function (rows, columns = { value: 1 }, start = { value: 1 }, step = { value: 1 }) { const _start = toNumber(start, this.locale); const _step = toNumber(step, this.locale); const _rows = toInteger(rows, this.locale); const _columns = toInteger(columns, this.locale); assertPositive(_t("The number of columns (%s) must be positive.", _columns), _columns); assertPositive(_t("The number of rows (%s) must be positive.", _rows), _rows); return generateMatrix(_columns, _rows, (col, row) => { return { value: _start + row * _columns * _step + col * _step, }; }); }, isExported: true, }; // ----------------------------------------------------------------------------- // SIN // ----------------------------------------------------------------------------- const SIN = { description: _t("Sine of an angle provided in radians."), args: [arg("angle (number)", _t("The angle to find the sine of, in radians."))], compute: function (angle) { return Math.sin(toNumber(angle, this.locale)); }, isExported: true, }; // ----------------------------------------------------------------------------- // SINH // ----------------------------------------------------------------------------- const SINH = { description: _t("Hyperbolic sine of any real number."), args: [arg("value (number)", _t("Any real value to calculate the hyperbolic sine of."))], compute: function (value) { return Math.sinh(toNumber(value, this.locale)); }, isExported: true, }; // ----------------------------------------------------------------------------- // SQRT // ----------------------------------------------------------------------------- const SQRT = { description: _t("Positive square root of a positive number."), args: [arg("value (number)", _t("The number for which to calculate the positive square root."))], compute: function (value) { const _value = toNumber(value, this.locale); assert(() => _value >= 0, _t("The value (%s) must be positive or null.", _value.toString())); return { value: Math.sqrt(_value), format: value?.format }; }, isExported: true, }; // ----------------------------------------------------------------------------- // SUM // ----------------------------------------------------------------------------- const SUM = { description: _t("Sum of a series of numbers and/or cells."), args: [ arg("value1 (number, range)", _t("The first number or range to add together.")), arg("value2 (number, range, repeating)", _t("Additional numbers or ranges to add to value1.")), ], compute: function (...values) { const v1 = values[0]; return { value: sum(values, this.locale), format: inferFormat(v1), }; }, isExported: true, }; // ----------------------------------------------------------------------------- // SUMIF // ----------------------------------------------------------------------------- const SUMIF = { description: _t("A conditional sum across a range."), args: [ arg("criteria_range (range)", _t("The range which is tested against criterion.")), arg("criterion (string)", _t("The pattern or test to apply to range.")), arg("sum_range (range, default=criteria_range)", _t("The range to be summed, if different from range.")), ], compute: function (criteriaRange, criterion, sumRange) { if (sumRange === undefined) { sumRange = criteriaRange; } let sum = 0; visitMatchingRanges([criteriaRange, criterion], (i, j) => { const value = sumRange[i]?.[j]?.value; if (typeof value === "number") { sum += value; } }, this.locale); return sum; }, isExported: true, }; // ----------------------------------------------------------------------------- // SUMIFS // ----------------------------------------------------------------------------- const SUMIFS = { description: _t("Sums a range depending on multiple criteria."), args: [ arg("sum_range (range)", _t("The range to sum.")), arg("criteria_range1 (range)", _t("The range to check against criterion1.")), arg("criterion1 (string)", _t("The pattern or test to apply to criteria_range1.")), arg("criteria_range2 (any, range, repeating)", _t("Additional ranges to check.")), arg("criterion2 (string, repeating)", _t("Additional criteria to check.")), ], compute: function (sumRange, ...criters) { let sum = 0; visitMatchingRanges(criters, (i, j) => { const value = sumRange[i]?.[j]?.value; if (typeof value === "number") { sum += value; } }, this.locale); return sum; }, isExported: true, }; // ----------------------------------------------------------------------------- // TAN // ----------------------------------------------------------------------------- const TAN = { description: _t("Tangent of an angle provided in radians."), args: [arg("angle (number)", _t("The angle to find the tangent of, in radians."))], compute: function (angle) { return Math.tan(toNumber(angle, this.locale)); }, isExported: true, }; // ----------------------------------------------------------------------------- // TANH // ----------------------------------------------------------------------------- const TANH = { description: _t("Hyperbolic tangent of any real number."), args: [arg("value (number)", _t("Any real value to calculate the hyperbolic tangent of."))], compute: function (value) { return Math.tanh(toNumber(value, this.locale)); }, isExported: true, }; // ----------------------------------------------------------------------------- // TRUNC // ----------------------------------------------------------------------------- function trunc(value, places) { if (places === 0) { return Math.trunc(value); } if (!Number.isInteger(places)) { places = Math.trunc(places); } return Math.trunc(value * Math.pow(10, places)) / Math.pow(10, places); } const TRUNC = { description: _t("Truncates a number."), args: [ arg("value (number)", _t("The value to be truncated.")), arg(`places (number, default=${DEFAULT_PLACES})`, _t("The number of significant digits to the right of the decimal point to retain.")), ], compute: function (value, places = { value: DEFAULT_PLACES }) { const _value = toNumber(value, this.locale); const _places = toNumber(places, this.locale); return { value: trunc(_value, _places), format: value?.format }; }, isExported: true, }; // ----------------------------------------------------------------------------- // INT // ----------------------------------------------------------------------------- const INT = { description: _t("Rounds a number down to the nearest integer that is less than or equal to it."), args: [arg("value (number)", _t("The number to round down to the nearest integer."))], compute: function (value) { return Math.floor(toNumber(value, this.locale)); }, isExported: true, }; var math = /*#__PURE__*/Object.freeze({ __proto__: null, ABS: ABS, ACOS: ACOS, ACOSH: ACOSH, ACOT: ACOT, ACOTH: ACOTH, ASIN: ASIN, ASINH: ASINH, ATAN: ATAN, ATAN2: ATAN2, ATANH: ATANH, CEILING: CEILING, CEILING_MATH: CEILING_MATH, CEILING_PRECISE: CEILING_PRECISE, COS: COS, COSH: COSH, COT: COT, COTH: COTH, COUNTBLANK: COUNTBLANK, COUNTIF: COUNTIF, COUNTIFS: COUNTIFS, COUNTUNIQUE: COUNTUNIQUE, COUNTUNIQUEIFS: COUNTUNIQUEIFS, CSC: CSC, CSCH: CSCH, DECIMAL: DECIMAL, DEGREES: DEGREES, EXP: EXP, FLOOR: FLOOR, FLOOR_MATH: FLOOR_MATH, FLOOR_PRECISE: FLOOR_PRECISE, INT: INT, ISEVEN: ISEVEN, ISODD: ISODD, ISO_CEILING: ISO_CEILING, LN: LN, MOD: MOD, MUNIT: MUNIT, ODD: ODD, PI: PI, POWER: POWER, PRODUCT: PRODUCT, RAND: RAND, RANDARRAY: RANDARRAY, RANDBETWEEN: RANDBETWEEN, ROUND: ROUND, ROUNDDOWN: ROUNDDOWN, ROUNDUP: ROUNDUP, SEC: SEC, SECH: SECH, SEQUENCE: SEQUENCE, SIN: SIN, SINH: SINH, SQRT: SQRT, SUM: SUM, SUMIF: SUMIF, SUMIFS: SUMIFS, TAN: TAN, TANH: TANH, TRUNC: TRUNC }); function filterAndFlatData(dataY, dataX) { const _flatDataY = []; const _flatDataX = []; let lenY = 0; let lenX = 0; visitAny([dataY], (y) => { _flatDataY.push(y); lenY += 1; }); visitAny([dataX], (x) => { _flatDataX.push(x); lenX += 1; }); assert(() => lenY === lenX, _t("[[FUNCTION_NAME]] has mismatched argument count %s vs %s.", lenY, lenX)); const flatDataX = []; const flatDataY = []; for (let i = 0; i < lenY; i++) { const valueY = _flatDataY[i]?.value; const valueX = _flatDataX[i]?.value; if (typeof valueY === "number" && typeof valueX === "number") { flatDataY.push(valueY); flatDataX.push(valueX); } } return { flatDataX, flatDataY }; } // Note: dataY and dataX may not have the same dimension function covariance(dataY, dataX, isSample) { const { flatDataX, flatDataY } = filterAndFlatData(dataY, dataX); const count = flatDataY.length; assert(() => count !== 0 && (!isSample || count !== 1), _t("Evaluation of function [[FUNCTION_NAME]] caused a divide by zero error."), CellErrorType.DivisionByZero); let sumY = 0; let sumX = 0; for (let i = 0; i < count; i++) { sumY += flatDataY[i]; sumX += flatDataX[i]; } const averageY = sumY / count; const averageX = sumX / count; let acc = 0; for (let i = 0; i < count; i++) { acc += (flatDataY[i] - averageY) * (flatDataX[i] - averageX); } return acc / (count - (isSample ? 1 : 0)); } function variance(args, isSample, textAs0, locale) { let count = 0; let sum = 0; const reduceFunction = textAs0 ? reduceNumbersTextAs0 : reduceNumbers; sum = reduceFunction(args, (acc, a) => { count += 1; return acc + a; }, 0, locale); assert(() => count !== 0 && (!isSample || count !== 1), _t("Evaluation of function [[FUNCTION_NAME]] caused a divide by zero error."), CellErrorType.DivisionByZero); const average = sum / count; return (reduceFunction(args, (acc, a) => acc + Math.pow(a - average, 2), 0, locale) / (count - (isSample ? 1 : 0))); } function centile(data, percent, isInclusive, locale) { const _percent = toNumber(percent, locale); assert(() => (isInclusive ? 0 <= _percent && _percent <= 1 : 0 < _percent && _percent < 1), _t("Function [[FUNCTION_NAME]] parameter 2 value is out of range.")); let sortedArray = []; let index; let count = 0; visitAny(data, (d) => { const value = d?.value; if (typeof value === "number") { index = dichotomicSearch(sortedArray, d, "nextSmaller", "asc", sortedArray.length, (array, i) => array[i]); sortedArray.splice(index + 1, 0, value); count++; } }); assert(() => count !== 0, _t("[[FUNCTION_NAME]] has no valid input data.")); if (!isInclusive) { // 2nd argument must be between 1/(n+1) and n/(n+1) with n the number of data assert(() => 1 / (count + 1) <= _percent && _percent <= count / (count + 1), _t("Function [[FUNCTION_NAME]] parameter 2 value is out of range.")); } return percentile(sortedArray, _percent, isInclusive); } // ----------------------------------------------------------------------------- // AVEDEV // ----------------------------------------------------------------------------- const AVEDEV = { description: _t("Average magnitude of deviations from mean."), args: [ arg("value1 (number, range)", _t("The first value or range of the sample.")), arg("value2 (number, range, repeating)", _t("Additional values or ranges to include in the sample.")), ], compute: function (...values) { let count = 0; const sum = reduceNumbers(values, (acc, a) => { count += 1; return acc + a; }, 0, this.locale); assertNotZero(count); const average = sum / count; return reduceNumbers(values, (acc, a) => acc + Math.abs(average - a), 0, this.locale) / count; }, isExported: true, }; // ----------------------------------------------------------------------------- // AVERAGE // ----------------------------------------------------------------------------- const AVERAGE = { description: _t("Numerical average value in a dataset, ignoring text."), args: [ arg("value1 (number, range)", _t("The first value or range to consider when calculating the average value.")), arg("value2 (number, range, repeating)", _t("Additional values or ranges to consider when calculating the average value.")), ], compute: function (...values) { return { value: average(values, this.locale), format: inferFormat(values[0]), }; }, isExported: true, }; // ----------------------------------------------------------------------------- // AVERAGE.WEIGHTED // ----------------------------------------------------------------------------- const rangeError = _t("[[FUNCTION_NAME]] has mismatched range sizes."); const negativeWeightError = _t("[[FUNCTION_NAME]] expects the weight to be positive or equal to 0."); const AVERAGE_WEIGHTED = { description: _t("Weighted average."), args: [ arg("values (number, range)", _t("Values to average.")), arg("weights (number, range)", _t("Weights for each corresponding value.")), arg("additional_values (number, range, repeating)", _t("Additional values to average.")), arg("additional_weights (number, range, repeating)", _t("Additional weights.")), ], compute: function (...args) { let sum = 0; let count = 0; for (let n = 0; n < args.length - 1; n += 2) { const argN = args[n]; const argN1 = args[n + 1]; assertSameDimensions(rangeError, argN, argN1); if (isMatrix(argN)) { for (let i = 0; i < argN.length; i++) { for (let j = 0; j < argN[0].length; j++) { const value = argN[i][j].value; const weight = isMatrix(argN1) ? argN1?.[i][j].value : toNumber(argN1, this.locale); const valueIsNumber = typeof value === "number"; const weightIsNumber = typeof weight === "number"; if (valueIsNumber && weightIsNumber) { assert(() => weight >= 0, negativeWeightError); sum += value * weight; count += weight; continue; } assert(() => valueIsNumber === weightIsNumber, _t("[[FUNCTION_NAME]] expects number values.")); } } } else { const value = toNumber(argN, this.locale); const weight = isMatrix(argN1) ? argN1?.[0][0].value : toNumber(argN1, this.locale); if (typeof weight === "number") { assert(() => weight >= 0, negativeWeightError); sum += value * weight; count += weight; } } } assertNotZero(count); return { value: sum / count, format: inferFormat(args[0]) }; }, }; // ----------------------------------------------------------------------------- // AVERAGEA // ----------------------------------------------------------------------------- const AVERAGEA = { description: _t("Numerical average value in a dataset."), args: [ arg("value1 (number, range)", _t("The first value or range to consider when calculating the average value.")), arg("value2 (number, range, repeating)", _t("Additional values or ranges to consider when calculating the average value.")), ], compute: function (...args) { let count = 0; const sum = reduceNumbersTextAs0(args, (acc, a) => { count += 1; return acc + a; }, 0, this.locale); assertNotZero(count); return { value: sum / count, format: inferFormat(args[0]), }; }, isExported: true, }; // ----------------------------------------------------------------------------- // AVERAGEIF // ----------------------------------------------------------------------------- const AVERAGEIF = { description: _t("Average of values depending on criteria."), args: [ arg("criteria_range (number, range)", _t("The range to check against criterion.")), arg("criterion (string)", _t("The pattern or test to apply to criteria_range.")), arg("average_range (number, range, default=criteria_range)", _t("The range to average. If not included, criteria_range is used for the average instead.")), ], compute: function (criteriaRange, criterion, averageRange) { const _averageRange = averageRange === undefined ? toMatrix(criteriaRange) : toMatrix(averageRange); let count = 0; let sum = 0; visitMatchingRanges([criteriaRange, criterion], (i, j) => { const value = _averageRange[i]?.[j]?.value; if (typeof value === "number") { count += 1; sum += value; } }, this.locale); assertNotZero(count); return sum / count; }, isExported: true, }; // ----------------------------------------------------------------------------- // AVERAGEIFS // ----------------------------------------------------------------------------- const AVERAGEIFS = { description: _t("Average of values depending on multiple criteria."), args: [ arg("average_range (range)", _t("The range to average.")), arg("criteria_range1 (range)", _t("The range to check against criterion1.")), arg("criterion1 (string)", _t("The pattern or test to apply to criteria_range1.")), arg("criteria_range2 (any, range, repeating)", _t("Additional criteria_range and criterion to check.")), arg("criterion2 (string, repeating)", _t("The pattern or test to apply to criteria_range2.")), ], compute: function (averageRange, ...args) { const _averageRange = toMatrix(averageRange); let count = 0; let sum = 0; visitMatchingRanges(args, (i, j) => { const value = _averageRange[i]?.[j]?.value; if (typeof value === "number") { count += 1; sum += value; } }, this.locale); assertNotZero(count); return sum / count; }, isExported: true, }; // ----------------------------------------------------------------------------- // COUNT // ----------------------------------------------------------------------------- const COUNT = { description: _t("The number of numeric values in dataset."), args: [ arg("value1 (number, any, range)", _t("The first value or range to consider when counting.")), arg("value2 (number, any, range, repeating)", _t("Additional values or ranges to consider when counting.")), ], compute: function (...values) { return countNumbers(values, this.locale); }, isExported: true, }; // ----------------------------------------------------------------------------- // COUNTA // ----------------------------------------------------------------------------- const COUNTA = { description: _t("The number of values in a dataset."), args: [ arg("value1 (any, range)", _t("The first value or range to consider when counting.")), arg("value2 (any, range, repeating)", _t("Additional values or ranges to consider when counting.")), ], compute: function (...values) { return countAny(values); }, isExported: true, }; // ----------------------------------------------------------------------------- // COVAR // ----------------------------------------------------------------------------- // Note: Unlike the VAR function which corresponds to the variance over a sample (VAR.S), // the COVAR function corresponds to the covariance over an entire population (COVAR.P) const COVAR = { description: _t("The covariance of a dataset."), args: [ arg("data_y (any, range)", _t("The range representing the array or matrix of dependent data.")), arg("data_x (any, range)", _t("The range representing the array or matrix of independent data.")), ], compute: function (dataY, dataX) { return covariance(dataY, dataX, false); }, isExported: true, }; // ----------------------------------------------------------------------------- // COVARIANCE.P // ----------------------------------------------------------------------------- const COVARIANCE_P = { description: _t("The covariance of a dataset."), args: [ arg("data_y (any, range)", _t("The range representing the array or matrix of dependent data.")), arg("data_x (any, range)", _t("The range representing the array or matrix of independent data.")), ], compute: function (dataY, dataX) { return covariance(dataY, dataX, false); }, isExported: true, }; // ----------------------------------------------------------------------------- // COVARIANCE.S // ----------------------------------------------------------------------------- const COVARIANCE_S = { description: _t("The sample covariance of a dataset."), args: [ arg("data_y (any, range)", _t("The range representing the array or matrix of dependent data.")), arg("data_x (any, range)", _t("The range representing the array or matrix of independent data.")), ], compute: function (dataY, dataX) { return covariance(dataY, dataX, true); }, isExported: true, }; // ----------------------------------------------------------------------------- // FORECAST // ----------------------------------------------------------------------------- const FORECAST = { description: _t("Calculates the expected y-value for a specified x based on a linear regression of a dataset."), args: [ arg("x (number, range)", _t("The value(s) on the x-axis to forecast.")), arg("data_y (range)", _t("The range representing the array or matrix of dependent data.")), arg("data_x (range)", _t("The range representing the array or matrix of independent data.")), ], compute: function (x, dataY, dataX) { const { flatDataX, flatDataY } = filterAndFlatData(dataY, dataX); return predictLinearValues([flatDataY], [flatDataX], matrixMap(toMatrix(x), (value) => toNumber(value, this.locale)), true); }, isExported: true, }; // ----------------------------------------------------------------------------- // GROWTH // ----------------------------------------------------------------------------- const GROWTH = { description: _t("Fits points to exponential growth trend."), args: [ arg("known_data_y (range)", _t("The array or range containing dependent (y) values that are already known, used to curve fit an ideal exponential growth curve.")), arg("known_data_x (range, default={1;2;3;...})", _t("The values of the independent variable(s) corresponding with known_data_y.")), arg("new_data_x (any, range, default=known_data_x)", _t("The data points to return the y values for on the ideal curve fit.")), arg("b (boolean, default=TRUE)", _t("Given a general exponential form of y = b*m^x for a curve fit, calculates b if TRUE or forces b to be 1 and only calculates the m values if FALSE.")), ], compute: function (knownDataY, knownDataX = [[]], newDataX = [[]], b = { value: true }) { return expM(predictLinearValues(logM(toNumberMatrix(knownDataY, "the first argument (known_data_y)")), toNumberMatrix(knownDataX, "the second argument (known_data_x)"), toNumberMatrix(newDataX, "the third argument (new_data_y)"), toBoolean(b))); }, }; // ----------------------------------------------------------------------------- // INTERCEPT // ----------------------------------------------------------------------------- const INTERCEPT = { description: _t("Compute the intercept of the linear regression."), args: [ arg("data_y (range)", _t("The range representing the array or matrix of dependent data.")), arg("data_x (range)", _t("The range representing the array or matrix of independent data.")), ], compute: function (dataY, dataX) { const { flatDataX, flatDataY } = filterAndFlatData(dataY, dataX); const [[], [intercept]] = fullLinearRegression([flatDataX], [flatDataY]); return intercept; }, isExported: true, }; // ----------------------------------------------------------------------------- // LARGE // ----------------------------------------------------------------------------- const LARGE = { description: _t("Nth largest element from a data set."), args: [ arg("data (any, range)", _t("Array or range containing the dataset to consider.")), arg("n (number)", _t("The rank from largest to smallest of the element to return.")), ], compute: function (data, n) { const _n = Math.trunc(toNumber(n?.value, this.locale)); let largests = []; let index; let count = 0; visitAny([data], (d) => { if (typeof d?.value === "number") { index = dichotomicSearch(largests, d, "nextSmaller", "asc", largests.length, (array, i) => array[i].value); largests.splice(index + 1, 0, d); count++; if (count > _n) { largests.shift(); count--; } } }); const result = largests.shift(); assert(() => result !== undefined, _t("[[FUNCTION_NAME]] has no valid input data.")); assert(() => count >= _n, _t("Function [[FUNCTION_NAME]] parameter 2 value (%s) is out of range.", _n)); return result; }, isExported: true, }; // ----------------------------------------------------------------------------- // LINEST // ----------------------------------------------------------------------------- const LINEST = { description: _t("Given partial data about a linear trend, calculates various parameters about the ideal linear trend using the least-squares method."), args: [ arg("data_y (range)", _t("The range representing the array or matrix of dependent data.")), arg("data_x (range, default={1;2;3;...})", _t("The range representing the array or matrix of independent data.")), arg("calculate_b (boolean, default=TRUE)", _t("A flag specifying wheter to compute the slope or not")), arg("verbose (boolean, default=FALSE)", _t("A flag specifying whether to return additional regression statistics or only the linear coefficients and the y-intercept")), ], compute: function (dataY, dataX = [[]], calculateB = { value: true }, verbose = { value: false }) { return fullLinearRegression(toNumberMatrix(dataX, "the first argument (data_y)"), toNumberMatrix(dataY, "the second argument (data_x)"), toBoolean(calculateB), toBoolean(verbose)); }, isExported: true, }; // ----------------------------------------------------------------------------- // LOGEST // ----------------------------------------------------------------------------- const LOGEST = { description: _t("Given partial data about an exponential growth curve, calculates various parameters about the best fit ideal exponential growth curve."), args: [ arg("data_y (range)", _t("The range representing the array or matrix of dependent data.")), arg("data_x (range, optional, default={1;2;3;...})", _t("The range representing the array or matrix of independent data.")), arg("calculate_b (boolean, default=TRUE)", _t("A flag specifying wheter to compute the slope or not")), arg("verbose (boolean, default=FALSE)", _t("A flag specifying whether to return additional regression statistics or only the linear coefficients and the y-intercept")), ], compute: function (dataY, dataX = [[]], calculateB = { value: true }, verbose = { value: false }) { const coeffs = fullLinearRegression(toNumberMatrix(dataX, "the second argument (data_x)"), logM(toNumberMatrix(dataY, "the first argument (data_y)")), toBoolean(calculateB), toBoolean(verbose)); for (let i = 0; i < coeffs.length; i++) { coeffs[i][0] = Math.exp(coeffs[i][0]); } return coeffs; }, isExported: true, }; // ----------------------------------------------------------------------------- // MATTHEWS // ----------------------------------------------------------------------------- const MATTHEWS = { description: _t("Compute the Matthews correlation coefficient of a dataset."), args: [ arg("data_x (range)", _t("The range representing the array or matrix of observed data.")), arg("data_y (range)", _t("The range representing the array or matrix of predicted data.")), ], compute: function (dataX, dataY) { const flatX = dataX.flat(); const flatY = dataY.flat(); assertSameNumberOfElements(flatX, flatY); if (flatX.length === 0) { throw new EvaluationError(_t("[[FUNCTION_NAME]] expects non-empty ranges for both parameters.")); } const n = flatX.length; let trueN = 0, trueP = 0, falseP = 0, falseN = 0; for (let i = 0; i < n; ++i) { const isTrue1 = toBoolean(flatX[i]); const isTrue2 = toBoolean(flatY[i]); if (isTrue1 === isTrue2) { if (isTrue1) { trueP++; } else { trueN++; } } else { if (isTrue1) { falseN++; } else { falseP++; } } } return ((trueP * trueN - falseP * falseN) / Math.sqrt((trueP + falseP) * (trueP + falseN) * (trueN + falseP) * (trueN + falseN))); }, isExported: false, }; // ----------------------------------------------------------------------------- // MAX // ----------------------------------------------------------------------------- const MAX = { description: _t("Maximum value in a numeric dataset."), args: [ arg("value1 (number, range)", _t("The first value or range to consider when calculating the maximum value.")), arg("value2 (number, range, repeating)", _t("Additional values or ranges to consider when calculating the maximum value.")), ], compute: function (...values) { return max(values, this.locale); }, isExported: true, }; // ----------------------------------------------------------------------------- // MAXA // ----------------------------------------------------------------------------- const MAXA = { description: _t("Maximum numeric value in a dataset."), args: [ arg("value1 (any, range)", _t("The first value or range to consider when calculating the maximum value.")), arg("value2 (any, range, repeating)", _t("Additional values or ranges to consider when calculating the maximum value.")), ], compute: function (...args) { const maxa = reduceNumbersTextAs0(args, (acc, a) => { return Math.max(a, acc); }, -Infinity, this.locale); return { value: maxa === -Infinity ? 0 : maxa, format: inferFormat(args[0]) }; }, isExported: true, }; // ----------------------------------------------------------------------------- // MAXIFS // ----------------------------------------------------------------------------- const MAXIFS = { description: _t("Returns the maximum value in a range of cells, filtered by a set of criteria."), args: [ arg("range (range)", _t("The range of cells from which the maximum will be determined.")), arg("criteria_range1 (range)", _t("The range of cells over which to evaluate criterion1.")), arg("criterion1 (string)", _t("The pattern or test to apply to criteria_range1, such that each cell that evaluates to TRUE will be included in the filtered set.")), arg("criteria_range2 (any, range, repeating)", _t("Additional ranges over which to evaluate the additional criteria. The filtered set will be the intersection of the sets produced by each criterion-range pair.")), arg("criterion2 (string, repeating)", _t("The pattern or test to apply to criteria_range2.")), ], compute: function (range, ...args) { let result = -Infinity; visitMatchingRanges(args, (i, j) => { const value = range[i]?.[j]?.value; if (typeof value === "number") { result = result < value ? value : result; } }, this.locale); return result === -Infinity ? 0 : result; }, isExported: true, }; // ----------------------------------------------------------------------------- // MEDIAN // ----------------------------------------------------------------------------- const MEDIAN = { description: _t("Median value in a numeric dataset."), args: [ arg("value1 (any, range)", _t("The first value or range to consider when calculating the median value.")), arg("value2 (any, range, repeating)", _t("Additional values or ranges to consider when calculating the median value.")), ], compute: function (...values) { let data = []; visitNumbers(values, (value) => { data.push(value); }, this.locale); return { value: centile(data, { value: 0.5 }, true, this.locale), format: inferFormat(data[0]), }; }, isExported: true, }; // ----------------------------------------------------------------------------- // MIN // ----------------------------------------------------------------------------- const MIN = { description: _t("Minimum value in a numeric dataset."), args: [ arg("value1 (number, range)", _t("The first value or range to consider when calculating the minimum value.")), arg("value2 (number, range, repeating)", _t("Additional values or ranges to consider when calculating the minimum value.")), ], compute: function (...values) { return min(values, this.locale); }, isExported: true, }; // ----------------------------------------------------------------------------- // MINA // ----------------------------------------------------------------------------- const MINA = { description: _t("Minimum numeric value in a dataset."), args: [ arg("value1 (number, range)", _t("The first value or range to consider when calculating the minimum value.")), arg("value2 (number, range, repeating)", _t("Additional values or ranges to consider when calculating the minimum value.")), ], compute: function (...args) { const mina = reduceNumbersTextAs0(args, (acc, a) => { return Math.min(a, acc); }, Infinity, this.locale); return { value: mina === Infinity ? 0 : mina, format: inferFormat(args[0]) }; }, isExported: true, }; // ----------------------------------------------------------------------------- // MINIFS // ----------------------------------------------------------------------------- const MINIFS = { description: _t("Returns the minimum value in a range of cells, filtered by a set of criteria."), args: [ arg("range (range)", _t("The range of cells from which the minimum will be determined.")), arg("criteria_range1 (range)", _t("The range of cells over which to evaluate criterion1.")), arg("criterion1 (string)", _t("The pattern or test to apply to criteria_range1, such that each cell that evaluates to TRUE will be included in the filtered set.")), arg("criteria_range2 (any, range, repeating)", _t("Additional ranges over which to evaluate the additional criteria. The filtered set will be the intersection of the sets produced by each criterion-range pair.")), arg("criterion2 (string, repeating)", _t("The pattern or test to apply to criteria_range2.")), ], compute: function (range, ...args) { let result = Infinity; visitMatchingRanges(args, (i, j) => { const value = range[i]?.[j]?.value; if (typeof value === "number") { result = result > value ? value : result; } }, this.locale); return result === Infinity ? 0 : result; }, isExported: true, }; // ----------------------------------------------------------------------------- // PEARSON // ----------------------------------------------------------------------------- function pearson(dataY, dataX) { const { flatDataX, flatDataY } = filterAndFlatData(dataY, dataX); if (flatDataX.length === 0) { throw new EvaluationError(_t("[[FUNCTION_NAME]] expects non-empty ranges for both parameters.")); } if (flatDataX.length < 2) { throw new EvaluationError(_t("[[FUNCTION_NAME]] needs at least two values for both parameters.")); } const n = flatDataX.length; let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0, sumYY = 0; for (let i = 0; i < n; i++) { const xij = flatDataX[i]; const yij = flatDataY[i]; sumX += xij; sumY += yij; sumXY += xij * yij; sumXX += xij * xij; sumYY += yij * yij; } return ((n * sumXY - sumX * sumY) / Math.sqrt((n * sumXX - sumX * sumX) * (n * sumYY - sumY * sumY))); } const PEARSON = { description: _t("Compute the Pearson product-moment correlation coefficient of a dataset."), args: [ arg("data_y (range)", _t("The range representing the array or matrix of dependent data.")), arg("data_x (range)", _t("The range representing the array or matrix of independent data.")), ], compute: function (dataY, dataX) { return pearson(dataY, dataX); }, isExported: true, }; // CORREL // In GSheet, CORREL is just an alias to PEARSON const CORREL = PEARSON; // ----------------------------------------------------------------------------- // PERCENTILE // ----------------------------------------------------------------------------- const PERCENTILE = { description: _t("Value at a given percentile of a dataset."), args: [ arg("data (any, range)", _t("The array or range containing the dataset to consider.")), arg("percentile (number)", _t("The percentile whose value within data will be calculated and returned.")), ], compute: function (data, percentile) { return PERCENTILE_INC.compute.bind(this)(data, percentile); }, isExported: true, }; // ----------------------------------------------------------------------------- // PERCENTILE.EXC // ----------------------------------------------------------------------------- const PERCENTILE_EXC = { description: _t("Value at a given percentile of a dataset exclusive of 0 and 1."), args: [ arg("data (any, range)", _t("The array or range containing the dataset to consider.")), arg("percentile (number)", _t("The percentile, exclusive of 0 and 1, whose value within 'data' will be calculated and returned.")), ], compute: function (data, percentile) { return { value: centile([data], percentile, false, this.locale), format: inferFormat(data), }; }, isExported: true, }; // ----------------------------------------------------------------------------- // PERCENTILE.INC // ----------------------------------------------------------------------------- const PERCENTILE_INC = { description: _t("Value at a given percentile of a dataset."), args: [ arg("data (any, range)", _t("The array or range containing the dataset to consider.")), arg("percentile (number)", _t("The percentile whose value within data will be calculated and returned.")), ], compute: function (data, percentile) { return { value: centile([data], percentile, true, this.locale), format: inferFormat(data), }; }, isExported: true, }; // ----------------------------------------------------------------------------- // POLYFIT // ----------------------------------------------------------------------------- const POLYFIT_COEFFS = { description: _t("Compute the coefficients of polynomial regression of the dataset."), args: [ arg("data_y (range)", _t("The range representing the array or matrix of dependent data.")), arg("data_x (range)", _t("The range representing the array or matrix of independent data.")), arg("order (number)", _t("The order of the polynomial to fit the data, between 1 and 6.")), arg("intercept (boolean, default=TRUE)", _t("A flag specifying whether to compute the intercept or not.")), ], compute: function (dataY, dataX, order, intercept = { value: true }) { const { flatDataX, flatDataY } = filterAndFlatData(dataY, dataX); return polynomialRegression(flatDataY, flatDataX, toNumber(order, this.locale), toBoolean(intercept)); }, isExported: false, }; // ----------------------------------------------------------------------------- // POLYFIT.FORECAST // ----------------------------------------------------------------------------- const POLYFIT_FORECAST = { description: _t("Predict value by computing a polynomial regression of the dataset."), args: [ arg("x (number, range)", _t("The value(s) on the x-axis to forecast.")), arg("data_y (range)", _t("The range representing the array or matrix of dependent data.")), arg("data_x (range)", _t("The range representing the array or matrix of independent data.")), arg("order (number)", _t("The order of the polynomial to fit the data, between 1 and 6.")), arg("intercept (boolean, default=TRUE)", _t("A flag specifying whether to compute the intercept or not.")), ], compute: function (x, dataY, dataX, order, intercept = { value: true }) { const _order = toNumber(order, this.locale); const { flatDataX, flatDataY } = filterAndFlatData(dataY, dataX); const coeffs = polynomialRegression(flatDataY, flatDataX, _order, toBoolean(intercept)).flat(); return matrixMap(toMatrix(x), (xij) => evaluatePolynomial(coeffs, toNumber(xij, this.locale), _order)); }, isExported: false, }; // ----------------------------------------------------------------------------- // QUARTILE // ----------------------------------------------------------------------------- const QUARTILE = { description: _t("Value nearest to a specific quartile of a dataset."), args: [ arg("data (any, range)", _t("The array or range containing the dataset to consider.")), arg("quartile_number (number)", _t("Which quartile value to return.")), ], compute: function (data, quartileNumber) { return QUARTILE_INC.compute.bind(this)(data, quartileNumber); }, isExported: true, }; // ----------------------------------------------------------------------------- // QUARTILE.EXC // ----------------------------------------------------------------------------- const QUARTILE_EXC = { description: _t("Value nearest to a specific quartile of a dataset exclusive of 0 and 4."), args: [ arg("data (any, range)", _t("The array or range containing the dataset to consider.")), arg("quartile_number (number)", _t("Which quartile value, exclusive of 0 and 4, to return.")), ], compute: function (data, quartileNumber) { const _quartileNumber = Math.trunc(toNumber(quartileNumber, this.locale)); const percent = { value: 0.25 * _quartileNumber }; return { value: centile([data], percent, false, this.locale), format: inferFormat(data), }; }, isExported: true, }; // ----------------------------------------------------------------------------- // QUARTILE.INC // ----------------------------------------------------------------------------- const QUARTILE_INC = { description: _t("Value nearest to a specific quartile of a dataset."), args: [ arg("data (any, range)", _t("The array or range containing the dataset to consider.")), arg("quartile_number (number)", _t("Which quartile value to return.")), ], compute: function (data, quartileNumber) { const percent = { value: 0.25 * Math.trunc(toNumber(quartileNumber, this.locale)) }; return { value: centile([data], percent, true, this.locale), format: inferFormat(data), }; }, isExported: true, }; // RANK // ----------------------------------------------------------------------------- const RANK = { description: _t("Returns the rank of a specified value in a dataset."), args: [ arg("value (number)", _t("The value whose rank will be determined.")), arg("data (range)", _t("The range containing the dataset to consider.")), arg("is_ascending (boolean, default=FALSE)", _t("Whether to consider the values in data in descending or ascending order.")), ], compute: function (value, data, isAscending = { value: false }) { const _isAscending = toBoolean(isAscending); const _value = toNumber(value, this.locale); let rank = 1; let found = false; for (const row of data) { for (const cell of row) { if (typeof cell.value !== "number") { continue; } const _cell = toNumber(cell, this.locale); if (_cell === _value) { found = true; } else if (_cell > _value !== _isAscending) { rank++; } } } if (!found) { throw new NotAvailableError(_t("Value not found in the given data.")); } return rank; }, isExported: true, }; // ----------------------------------------------------------------------------- // RSQ // ----------------------------------------------------------------------------- const RSQ = { description: _t("Compute the square of r, the Pearson product-moment correlation coefficient of a dataset."), args: [ arg("data_y (range)", _t("The range representing the array or matrix of dependent data.")), arg("data_x (range)", _t("The range representing the array or matrix of independent data.")), ], compute: function (dataY, dataX) { return Math.pow(pearson(dataX, dataY), 2.0); }, isExported: true, }; // ----------------------------------------------------------------------------- // SLOPE // ----------------------------------------------------------------------------- const SLOPE = { description: _t("Compute the slope of the linear regression."), args: [ arg("data_y (range)", _t("The range representing the array or matrix of dependent data.")), arg("data_x (range)", _t("The range representing the array or matrix of independent data.")), ], compute: function (dataY, dataX) { const { flatDataX, flatDataY } = filterAndFlatData(dataY, dataX); const [[slope]] = fullLinearRegression([flatDataX], [flatDataY]); return slope; }, isExported: true, }; // ----------------------------------------------------------------------------- // SMALL // ----------------------------------------------------------------------------- const SMALL = { description: _t("Nth smallest element in a data set."), args: [ arg("data (any, range)", _t("The array or range containing the dataset to consider.")), arg("n (number)", _t("The rank from smallest to largest of the element to return.")), ], compute: function (data, n) { const _n = Math.trunc(toNumber(n?.value, this.locale)); let largests = []; let index; let count = 0; visitAny([data], (d) => { if (typeof d?.value === "number") { index = dichotomicSearch(largests, d, "nextSmaller", "asc", largests.length, (array, i) => array[i].value); largests.splice(index + 1, 0, d); count++; if (count > _n) { largests.pop(); count--; } } }); const result = largests.pop(); assert(() => result !== undefined, _t("[[FUNCTION_NAME]] has no valid input data.")); assert(() => count >= _n, _t("Function [[FUNCTION_NAME]] parameter 2 value (%s) is out of range.", _n)); return result; }, isExported: true, }; // ----------------------------------------------------------------------------- // SPEARMAN // ----------------------------------------------------------------------------- const SPEARMAN = { description: _t("Compute the Spearman rank correlation coefficient of a dataset."), args: [ arg("data_y (range)", _t("The range representing the array or matrix of dependent data.")), arg("data_x (range)", _t("The range representing the array or matrix of independent data.")), ], compute: function (dataX, dataY) { const { flatDataX, flatDataY } = filterAndFlatData(dataY, dataX); const n = flatDataX.length; const order = flatDataX.map((e, i) => [e, flatDataY[i]]); order.sort((a, b) => a[0] - b[0]); for (let i = 0; i < n; ++i) { order[i][0] = i; } order.sort((a, b) => a[1] - b[1]); let sum = 0.0; for (let i = 0; i < n; ++i) { sum += (order[i][0] - i) ** 2; } return 1 - (6 * sum) / (n ** 3 - n); }, isExported: false, }; // ----------------------------------------------------------------------------- // STDEV // ----------------------------------------------------------------------------- const STDEV = { description: _t("Standard deviation."), args: [ arg("value1 (number, range)", _t("The first value or range of the sample.")), arg("value2 (number, range, repeating)", _t("Additional values or ranges to include in the sample.")), ], compute: function (...args) { return Math.sqrt(VAR.compute.bind(this)(...args)); }, isExported: true, }; // ----------------------------------------------------------------------------- // STDEV.P // ----------------------------------------------------------------------------- const STDEV_P = { description: _t("Standard deviation of entire population."), args: [ arg("value1 (number, range)", _t("The first value or range of the population.")), arg("value2 (number, range, repeating)", _t("Additional values or ranges to include in the population.")), ], compute: function (...args) { return Math.sqrt(VAR_P.compute.bind(this)(...args)); }, isExported: true, }; // ----------------------------------------------------------------------------- // STDEV.S // ----------------------------------------------------------------------------- const STDEV_S = { description: _t("Standard deviation."), args: [ arg("value1 (number, range)", _t("The first value or range of the sample.")), arg("value2 (number, range, repeating)", _t("Additional values or ranges to include in the sample.")), ], compute: function (...args) { return Math.sqrt(VAR_S.compute.bind(this)(...args)); }, isExported: true, }; // ----------------------------------------------------------------------------- // STDEVA // ----------------------------------------------------------------------------- const STDEVA = { description: _t("Standard deviation of sample (text as 0)."), args: [ arg("value1 (number, range)", _t("The first value or range of the sample.")), arg("value2 (number, range, repeating)", _t("Additional values or ranges to include in the sample.")), ], compute: function (...args) { return Math.sqrt(VARA.compute.bind(this)(...args)); }, isExported: true, }; // ----------------------------------------------------------------------------- // STDEVP // ----------------------------------------------------------------------------- const STDEVP = { description: _t("Standard deviation of entire population."), args: [ arg("value1 (number, range)", _t("The first value or range of the population.")), arg("value2 (number, range, repeating)", _t("Additional values or ranges to include in the population.")), ], compute: function (...args) { return Math.sqrt(VARP.compute.bind(this)(...args)); }, isExported: true, }; // ----------------------------------------------------------------------------- // STDEVPA // ----------------------------------------------------------------------------- const STDEVPA = { description: _t("Standard deviation of entire population (text as 0)."), args: [ arg("value1 (number, range)", _t("The first value or range of the population.")), arg("value2 (number, range, repeating)", _t("Additional values or ranges to include in the population.")), ], compute: function (...args) { return Math.sqrt(VARPA.compute.bind(this)(...args)); }, isExported: true, }; // ----------------------------------------------------------------------------- // STEYX // ----------------------------------------------------------------------------- const STEYX = { description: _t("Calculates the standard error of the predicted y-value for each x in the regression of a dataset."), args: [ arg("data_y (range)", _t("The range representing the array or matrix of dependent data.")), arg("data_x (range)", _t("The range representing the array or matrix of independent data.")), ], compute: function (dataY, dataX) { const { flatDataX, flatDataY } = filterAndFlatData(dataY, dataX); const data = fullLinearRegression([flatDataX], [flatDataY], true, true); return data[1][2]; }, isExported: true, }; // ----------------------------------------------------------------------------- // TREND // ----------------------------------------------------------------------------- const TREND = { description: _t("Fits points to linear trend derived via least-squares."), args: [ arg("known_data_y (number, range)", _t("The array or range containing dependent (y) values that are already known, used to curve fit an ideal linear trend.")), arg("known_data_x (number, range, optional, default={1;2;3;...})", _t("The values of the independent variable(s) corresponding with known_data_y.")), arg("new_data_x (number, range, optional, default=known_data_x)", _t("The data points to return the y values for on the ideal curve fit.")), arg("b (boolean, optional, default=TRUE)", _t("Given a general linear form of y = m*x+b for a curve fit, calculates b if TRUE or forces b to be 0 and only calculates the m values if FALSE, i.e. forces the curve fit to pass through the origin.")), ], compute: function (knownDataY, knownDataX = [[]], newDataX = [[]], b = { value: true }) { return predictLinearValues(toNumberMatrix(knownDataY, "the first argument (known_data_y)"), toNumberMatrix(knownDataX, "the second argument (known_data_x)"), toNumberMatrix(newDataX, "the third argument (new_data_y)"), toBoolean(b)); }, }; // ----------------------------------------------------------------------------- // VAR // ----------------------------------------------------------------------------- const VAR = { description: _t("Variance."), args: [ arg("value1 (number, range)", _t("The first value or range of the sample.")), arg("value2 (number, range, repeating)", _t("Additional values or ranges to include in the sample.")), ], compute: function (...args) { return variance(args, true, false, this.locale); }, isExported: true, }; // ----------------------------------------------------------------------------- // VAR.P // ----------------------------------------------------------------------------- const VAR_P = { description: _t("Variance of entire population."), args: [ arg("value1 (number, range)", _t("The first value or range of the population.")), arg("value2 (number, range, repeating)", _t("Additional values or ranges to include in the population.")), ], compute: function (...args) { return variance(args, false, false, this.locale); }, isExported: true, }; // ----------------------------------------------------------------------------- // VAR.S // ----------------------------------------------------------------------------- const VAR_S = { description: _t("Variance."), args: [ arg("value1 (number, range)", _t("The first value or range of the sample.")), arg("value2 (number, range, repeating)", _t("Additional values or ranges to include in the sample.")), ], compute: function (...args) { return variance(args, true, false, this.locale); }, isExported: true, }; // ----------------------------------------------------------------------------- // VARA // ----------------------------------------------------------------------------- const VARA = { description: _t("Variance of sample (text as 0)."), args: [ arg("value1 (number, range)", _t("The first value or range of the sample.")), arg("value2 (number, range, repeating)", _t("Additional values or ranges to include in the sample.")), ], compute: function (...args) { return variance(args, true, true, this.locale); }, isExported: true, }; // ----------------------------------------------------------------------------- // VARP // ----------------------------------------------------------------------------- const VARP = { description: _t("Variance of entire population."), args: [ arg("value1 (number, range)", _t("The first value or range of the population.")), arg("value2 (number, range, repeating)", _t("Additional values or ranges to include in the population.")), ], compute: function (...args) { return variance(args, false, false, this.locale); }, isExported: true, }; // ----------------------------------------------------------------------------- // VARPA // ----------------------------------------------------------------------------- const VARPA = { description: _t("Variance of entire population (text as 0)."), args: [ arg("value1 (number, range)", _t("The first value or range of the population.")), arg("value2 (number, range, repeating)", _t("Additional values or ranges to include in the population.")), ], compute: function (...args) { return variance(args, false, true, this.locale); }, isExported: true, }; var statistical = /*#__PURE__*/Object.freeze({ __proto__: null, AVEDEV: AVEDEV, AVERAGE: AVERAGE, AVERAGEA: AVERAGEA, AVERAGEIF: AVERAGEIF, AVERAGEIFS: AVERAGEIFS, AVERAGE_WEIGHTED: AVERAGE_WEIGHTED, CORREL: CORREL, COUNT: COUNT, COUNTA: COUNTA, COVAR: COVAR, COVARIANCE_P: COVARIANCE_P, COVARIANCE_S: COVARIANCE_S, FORECAST: FORECAST, GROWTH: GROWTH, INTERCEPT: INTERCEPT, LARGE: LARGE, LINEST: LINEST, LOGEST: LOGEST, MATTHEWS: MATTHEWS, MAX: MAX, MAXA: MAXA, MAXIFS: MAXIFS, MEDIAN: MEDIAN, MIN: MIN, MINA: MINA, MINIFS: MINIFS, PEARSON: PEARSON, PERCENTILE: PERCENTILE, PERCENTILE_EXC: PERCENTILE_EXC, PERCENTILE_INC: PERCENTILE_INC, POLYFIT_COEFFS: POLYFIT_COEFFS, POLYFIT_FORECAST: POLYFIT_FORECAST, QUARTILE: QUARTILE, QUARTILE_EXC: QUARTILE_EXC, QUARTILE_INC: QUARTILE_INC, RANK: RANK, RSQ: RSQ, SLOPE: SLOPE, SMALL: SMALL, SPEARMAN: SPEARMAN, STDEV: STDEV, STDEVA: STDEVA, STDEVP: STDEVP, STDEVPA: STDEVPA, STDEV_P: STDEV_P, STDEV_S: STDEV_S, STEYX: STEYX, TREND: TREND, VAR: VAR, VARA: VARA, VARP: VARP, VARPA: VARPA, VAR_P: VAR_P, VAR_S: VAR_S }); function getMatchingCells(database, field, criteria, locale) { // Example // # DATABASE # CRITERIA # field = "C" // // | A | B | C | | A | C | // |===========| |=======| // | 1 | x | j | |<2 | j | // | 1 | Z | k | | | 7 | // | 5 | y | 7 | // 1 - Select coordinates of database columns ---------------------------------------------------- const indexColNameDB = new Map(); const dimRowDB = database.length; for (let indexCol = dimRowDB - 1; indexCol >= 0; indexCol--) { indexColNameDB.set(toString(database[indexCol][0]).toUpperCase(), indexCol); } // Example continuation: indexColNameDB = {"A" => 0, "B" => 1, "C" => 2} // 2 - Check if the field parameter exists in the column names of the database ------------------- // field may either be a text label corresponding to a column header in the // first row of database or a numeric index indicating which column to consider, // where the first column has the value 1. const fieldValue = field?.value; if (typeof fieldValue !== "number" && typeof fieldValue !== "string") { throw new EvaluationError(_t("The field must be a number or a string")); } let index; if (typeof fieldValue === "number") { index = Math.trunc(fieldValue) - 1; if (index < 0 || dimRowDB - 1 < index) { throw new EvaluationError(_t("The field (%(fieldValue)s) must be one of %(dimRowDB)s or must be a number between 1 and %s inclusive.", { fieldValue: fieldValue.toString(), dimRowDB: dimRowDB.toString(), })); } } else { const colName = toString(field).toUpperCase(); index = indexColNameDB.get(colName) ?? -1; if (index === -1) { throw new EvaluationError(_t("The field (%s) must be one of %s.", toString(field), [...indexColNameDB.keys()].toString())); } } // Example continuation: index = 2 // 3 - For each criteria row, find database row that correspond ---------------------------------- const dimColCriteria = criteria[0].length; if (dimColCriteria < 2) { throw new EvaluationError(_t("The criteria range contains %s row, it must be at least 2 rows.", dimColCriteria.toString())); } let matchingRows = new Set(); const dimColDB = database[0].length; for (let indexRow = 1; indexRow < dimColCriteria; indexRow++) { let args = []; let existColNameDB = true; for (let indexCol = 0; indexCol < criteria.length; indexCol++) { const currentName = toString(criteria[indexCol][0]).toUpperCase(); const indexColDB = indexColNameDB.get(currentName); const criter = criteria[indexCol][indexRow]; if (criter.value !== null) { if (indexColDB !== undefined) { args.push([database[indexColDB].slice(1, dimColDB)]); args.push(criter); } else { existColNameDB = false; break; } } } // Example continuation: args1 = [[1,1,5], "<2", ["j","k",7], "j"] // Example continuation: args2 = [["j","k",7], "7"] if (existColNameDB) { if (args.length > 0) { visitMatchingRanges(args, (i, j) => { matchingRows.add(j); }, locale, true); } else { // return indices of each database row when a criteria table row is void matchingRows = new Set(Array(dimColDB - 1).keys()); break; } } } // Example continuation: matchingRows = {0, 2} // 4 - return for each database row corresponding, the cells corresponding to the field parameter const fieldCol = database[index]; // Example continuation:: fieldCol = ["C", "j", "k", 7] const matchingCells = [...matchingRows].map((x) => fieldCol[x + 1]); // Example continuation:: matchingCells = ["j", 7] return matchingCells; } const databaseArgs = [ arg("database (range)", _t("The array or range containing the data to consider, structured in such a way that the first row contains the labels for each column's values.")), arg("field (number, string)", _t("Indicates which column in database contains the values to be extracted and operated on.")), arg("criteria (range)", _t("An array or range containing zero or more criteria to filter the database values by before operating.")), ]; // ----------------------------------------------------------------------------- // DAVERAGE // ----------------------------------------------------------------------------- const DAVERAGE = { description: _t("Average of a set of values from a table-like range."), args: databaseArgs, compute: function (database, field, criteria) { const cells = getMatchingCells(database, field, criteria, this.locale); return AVERAGE.compute.bind(this)([cells]); }, isExported: true, }; // ----------------------------------------------------------------------------- // DCOUNT // ----------------------------------------------------------------------------- const DCOUNT = { description: _t("Counts values from a table-like range."), args: databaseArgs, compute: function (database, field, criteria) { const cells = getMatchingCells(database, field, criteria, this.locale); return COUNT.compute.bind(this)([cells]); }, isExported: true, }; // ----------------------------------------------------------------------------- // DCOUNTA // ----------------------------------------------------------------------------- const DCOUNTA = { description: _t("Counts values and text from a table-like range."), args: databaseArgs, compute: function (database, field, criteria) { const cells = getMatchingCells(database, field, criteria, this.locale); return COUNTA.compute.bind(this)([cells]); }, isExported: true, }; // ----------------------------------------------------------------------------- // DGET // ----------------------------------------------------------------------------- const DGET = { description: _t("Single value from a table-like range."), args: databaseArgs, compute: function (database, field, criteria) { const cells = getMatchingCells(database, field, criteria, this.locale); assert(() => cells.length === 1, _t("More than one match found in DGET evaluation.")); return cells[0]; }, isExported: true, }; // ----------------------------------------------------------------------------- // DMAX // ----------------------------------------------------------------------------- const DMAX = { description: _t("Maximum of values from a table-like range."), args: databaseArgs, compute: function (database, field, criteria) { const cells = getMatchingCells(database, field, criteria, this.locale); return MAX.compute.bind(this)([cells]); }, isExported: true, }; // ----------------------------------------------------------------------------- // DMIN // ----------------------------------------------------------------------------- const DMIN = { description: _t("Minimum of values from a table-like range."), args: databaseArgs, compute: function (database, field, criteria) { const cells = getMatchingCells(database, field, criteria, this.locale); return MIN.compute.bind(this)([cells]); }, isExported: true, }; // ----------------------------------------------------------------------------- // DPRODUCT // ----------------------------------------------------------------------------- const DPRODUCT = { description: _t("Product of values from a table-like range."), args: databaseArgs, compute: function (database, field, criteria) { const cells = getMatchingCells(database, field, criteria, this.locale); return PRODUCT.compute.bind(this)([cells]); }, isExported: true, }; // ----------------------------------------------------------------------------- // DSTDEV // ----------------------------------------------------------------------------- const DSTDEV = { description: _t("Standard deviation of population sample from table."), args: databaseArgs, compute: function (database, field, criteria) { const cells = getMatchingCells(database, field, criteria, this.locale); return STDEV.compute.bind(this)([cells]); }, isExported: true, }; // ----------------------------------------------------------------------------- // DSTDEVP // ----------------------------------------------------------------------------- const DSTDEVP = { description: _t("Standard deviation of entire population from table."), args: databaseArgs, compute: function (database, field, criteria) { const cells = getMatchingCells(database, field, criteria, this.locale); return STDEVP.compute.bind(this)([cells]); }, isExported: true, }; // ----------------------------------------------------------------------------- // DSUM // ----------------------------------------------------------------------------- const DSUM = { description: _t("Sum of values from a table-like range."), args: databaseArgs, compute: function (database, field, criteria) { const cells = getMatchingCells(database, field, criteria, this.locale); return SUM.compute.bind(this)([cells]); }, isExported: true, }; // ----------------------------------------------------------------------------- // DVAR // ----------------------------------------------------------------------------- const DVAR = { description: _t("Variance of population sample from table-like range."), args: databaseArgs, compute: function (database, field, criteria) { const cells = getMatchingCells(database, field, criteria, this.locale); return VAR.compute.bind(this)([cells]); }, isExported: true, }; // ----------------------------------------------------------------------------- // DVARP // ----------------------------------------------------------------------------- const DVARP = { description: _t("Variance of a population from a table-like range."), args: databaseArgs, compute: function (database, field, criteria) { const cells = getMatchingCells(database, field, criteria, this.locale); return VARP.compute.bind(this)([cells]); }, isExported: true, }; var database = /*#__PURE__*/Object.freeze({ __proto__: null, DAVERAGE: DAVERAGE, DCOUNT: DCOUNT, DCOUNTA: DCOUNTA, DGET: DGET, DMAX: DMAX, DMIN: DMIN, DPRODUCT: DPRODUCT, DSTDEV: DSTDEV, DSTDEVP: DSTDEVP, DSUM: DSUM, DVAR: DVAR, DVARP: DVARP }); const DEFAULT_TYPE = 1; const DEFAULT_WEEKEND = 1; var TIME_UNIT; (function (TIME_UNIT) { TIME_UNIT["WHOLE_YEARS"] = "Y"; TIME_UNIT["WHOLE_MONTHS"] = "M"; TIME_UNIT["WHOLE_DAYS"] = "D"; TIME_UNIT["DAYS_WITHOUT_WHOLE_MONTHS"] = "MD"; TIME_UNIT["MONTH_WITHOUT_WHOLE_YEARS"] = "YM"; TIME_UNIT["DAYS_BETWEEN_NO_MORE_THAN_ONE_YEAR"] = "YD"; })(TIME_UNIT || (TIME_UNIT = {})); // ----------------------------------------------------------------------------- // DATE // ----------------------------------------------------------------------------- const DATE = { description: _t("Converts year/month/day into a date."), args: [ arg("year (number)", _t("The year component of the date.")), arg("month (number)", _t("The month component of the date.")), arg("day (number)", _t("The day component of the date.")), ], compute: function (year, month, day) { let _year = Math.trunc(toNumber(year, this.locale)); const _month = Math.trunc(toNumber(month, this.locale)); const _day = Math.trunc(toNumber(day, this.locale)); // For years less than 0 or greater than 10000, return #ERROR. assert(() => 0 <= _year && _year <= 9999, _t("The year (%s) must be between 0 and 9999 inclusive.", _year.toString())); // Between 0 and 1899, we add that value to 1900 to calculate the year if (_year < 1900) { _year += 1900; } const jsDate = new DateTime(_year, _month - 1, _day); const result = jsDateToRoundNumber(jsDate); assert(() => result >= 0, _t("The function [[FUNCTION_NAME]] result must be greater than or equal 01/01/1900.")); return { value: result, format: this.locale.dateFormat, }; }, isExported: true, }; // ----------------------------------------------------------------------------- // DATEDIF // ----------------------------------------------------------------------------- const DATEDIF = { description: _t("Calculates the number of days, months, or years between two dates."), args: [ arg("start_date (date)", _t("The start date to consider in the calculation. Must be a reference to a cell containing a DATE, a function returning a DATE type, or a number.")), arg("end_date (date)", _t("The end date to consider in the calculation. Must be a reference to a cell containing a DATE, a function returning a DATE type, or a number.")), arg("unit (string)", _t('A text abbreviation for unit of time. Accepted values are "Y" (the number of whole years between start_date and end_date), "M" (the number of whole months between start_date and end_date), "D" (the number of days between start_date and end_date), "MD" (the number of days between start_date and end_date after subtracting whole months), "YM" (the number of whole months between start_date and end_date after subtracting whole years), "YD" (the number of days between start_date and end_date, assuming start_date and end_date were no more than one year apart).')), ], compute: function (startDate, endDate, unit) { const _unit = toString(unit).toUpperCase(); assert(() => Object.values(TIME_UNIT).includes(_unit), expectStringSetError(Object.values(TIME_UNIT), toString(unit))); const _startDate = Math.trunc(toNumber(startDate, this.locale)); const _endDate = Math.trunc(toNumber(endDate, this.locale)); const jsStartDate = numberToJsDate(_startDate); const jsEndDate = numberToJsDate(_endDate); assert(() => _endDate >= _startDate, _t("start_date (%s) should be on or before end_date (%s).", jsStartDate.toLocaleDateString(), jsEndDate.toLocaleDateString())); switch (_unit) { case TIME_UNIT.WHOLE_YEARS: return getTimeDifferenceInWholeYears(jsStartDate, jsEndDate); case TIME_UNIT.WHOLE_MONTHS: return getTimeDifferenceInWholeMonths(jsStartDate, jsEndDate); case TIME_UNIT.WHOLE_DAYS: { return getTimeDifferenceInWholeDays(jsStartDate, jsEndDate); } case TIME_UNIT.MONTH_WITHOUT_WHOLE_YEARS: { return (getTimeDifferenceInWholeMonths(jsStartDate, jsEndDate) - getTimeDifferenceInWholeYears(jsStartDate, jsEndDate) * 12); } case TIME_UNIT.DAYS_WITHOUT_WHOLE_MONTHS: // Using "MD" may get incorrect result in Excel // See: https://support.microsoft.com/en-us/office/datedif-function-25dba1a4-2812-480b-84dd-8b32a451b35c let days = jsEndDate.getDate() - jsStartDate.getDate(); if (days < 0) { const monthBeforeEndMonth = new DateTime(jsEndDate.getFullYear(), jsEndDate.getMonth() - 1, 1); const daysInMonthBeforeEndMonth = getDaysInMonth(monthBeforeEndMonth); days = daysInMonthBeforeEndMonth - Math.abs(days); } return days; case TIME_UNIT.DAYS_BETWEEN_NO_MORE_THAN_ONE_YEAR: { if (areTwoDatesWithinOneYear(_startDate, _endDate)) { return getTimeDifferenceInWholeDays(jsStartDate, jsEndDate); } const endDateWithinOneYear = new DateTime(jsStartDate.getFullYear(), jsEndDate.getMonth(), jsEndDate.getDate()); let days = getTimeDifferenceInWholeDays(jsStartDate, endDateWithinOneYear); if (days < 0) { endDateWithinOneYear.setFullYear(jsStartDate.getFullYear() + 1); days = getTimeDifferenceInWholeDays(jsStartDate, endDateWithinOneYear); } return days; } } }, isExported: true, }; // ----------------------------------------------------------------------------- // DATEVALUE // ----------------------------------------------------------------------------- const DATEVALUE = { description: _t("Converts a date string to a date value."), args: [arg("date_string (string)", _t("The string representing the date."))], compute: function (dateString) { const _dateString = toString(dateString); const internalDate = parseDateTime(_dateString, this.locale); assert(() => internalDate !== null, _t("The date_string (%s) cannot be parsed to date/time.", _dateString.toString())); return Math.trunc(internalDate.value); }, isExported: true, }; // ----------------------------------------------------------------------------- // DAY // ----------------------------------------------------------------------------- const DAY = { description: _t("Day of the month that a specific date falls on."), args: [arg("date (string)", _t("The date from which to extract the day."))], compute: function (date) { return toJsDate(date, this.locale).getDate(); }, isExported: true, }; // ----------------------------------------------------------------------------- // DAYS // ----------------------------------------------------------------------------- const DAYS = { description: _t("Number of days between two dates."), args: [ arg("end_date (date)", _t("The end of the date range.")), arg("start_date (date)", _t("The start of the date range.")), ], compute: function (endDate, startDate) { const _endDate = toJsDate(endDate, this.locale); const _startDate = toJsDate(startDate, this.locale); const dateDif = _endDate.getTime() - _startDate.getTime(); return Math.round(dateDif / MS_PER_DAY); }, isExported: true, }; // ----------------------------------------------------------------------------- // DAYS360 // ----------------------------------------------------------------------------- const DEFAULT_DAY_COUNT_METHOD = 0; const DAYS360 = { description: _t("Number of days between two dates on a 360-day year (months of 30 days)."), args: [ arg("start_date (date)", _t("The start date to consider in the calculation.")), arg("end_date (date)", _t("The end date to consider in the calculation.")), arg(`method (number, default=${DEFAULT_DAY_COUNT_METHOD})`, _t("An indicator of what day count method to use. (0) US NASD method (1) European method")), ], compute: function (startDate, endDate, method = { value: DEFAULT_DAY_COUNT_METHOD }) { const _startDate = Math.trunc(toNumber(startDate, this.locale)); const _endDate = Math.trunc(toNumber(endDate, this.locale)); const dayCountConvention = toBoolean(method) ? 4 : 0; const yearFrac = getYearFrac(_startDate, _endDate, dayCountConvention); return Math.sign(_endDate - _startDate) * Math.round(yearFrac * 360); }, isExported: true, }; // ----------------------------------------------------------------------------- // EDATE // ----------------------------------------------------------------------------- const EDATE = { description: _t("Date a number of months before/after another date."), args: [ arg("start_date (date)", _t("The date from which to calculate the result.")), arg("months (number)", _t("The number of months before (negative) or after (positive) 'start_date' to calculate.")), ], compute: function (startDate, months) { const _startDate = toJsDate(startDate, this.locale); const _months = Math.trunc(toNumber(months, this.locale)); const jsDate = addMonthsToDate(_startDate, _months, false); return { value: jsDateToRoundNumber(jsDate), format: this.locale.dateFormat, }; }, isExported: true, }; // ----------------------------------------------------------------------------- // EOMONTH // ----------------------------------------------------------------------------- const EOMONTH = { description: _t("Last day of a month before or after a date."), args: [ arg("start_date (date)", _t("The date from which to calculate the result.")), arg("months (number)", _t("The number of months before (negative) or after (positive) 'start_date' to consider.")), ], compute: function (startDate, months) { const _startDate = toJsDate(startDate, this.locale); const _months = Math.trunc(toNumber(months, this.locale)); const yStart = _startDate.getFullYear(); const mStart = _startDate.getMonth(); const jsDate = new DateTime(yStart, mStart + _months + 1, 0); return { value: jsDateToRoundNumber(jsDate), format: this.locale.dateFormat, }; }, isExported: true, }; // ----------------------------------------------------------------------------- // HOUR // ----------------------------------------------------------------------------- const HOUR = { description: _t("Hour component of a specific time."), args: [arg("time (date)", _t("The time from which to calculate the hour component."))], compute: function (date) { return toJsDate(date, this.locale).getHours(); }, isExported: true, }; // ----------------------------------------------------------------------------- // ISOWEEKNUM // ----------------------------------------------------------------------------- const ISOWEEKNUM = { description: _t("ISO week number of the year."), args: [ arg("date (date)", _t("The date for which to determine the ISO week number. Must be a reference to a cell containing a date, a function returning a date type, or a number.")), ], compute: function (date) { const _date = toJsDate(date, this.locale); const y = _date.getFullYear(); // 1 - As the 1st week of a year can start the previous year or after the 1st // january we first look if the date is in the weeks of the current year, previous // year or year after. // A - We look for the current year, the first days of the first week // and the last days of the last week // The first week of the year is the week that contains the first // Thursday of the year. let firstThursday = 1; while (new DateTime(y, 0, firstThursday).getDay() !== 4) { firstThursday += 1; } const firstDayOfFirstWeek = new DateTime(y, 0, firstThursday - 3); // The last week of the year is the week that contains the last Thursday of // the year. let lastThursday = 31; while (new DateTime(y, 11, lastThursday).getDay() !== 4) { lastThursday -= 1; } const lastDayOfLastWeek = new DateTime(y, 11, lastThursday + 3); // B - If our date > lastDayOfLastWeek then it's in the weeks of the year after // If our date < firstDayOfFirstWeek then it's in the weeks of the year before let offsetYear; if (firstDayOfFirstWeek.getTime() <= _date.getTime()) { if (_date.getTime() <= lastDayOfLastWeek.getTime()) { offsetYear = 0; } else { offsetYear = 1; } } else { offsetYear = -1; } // 2 - now that the year is known, we are looking at the difference between // the first day of this year and the date. The difference in days divided by // 7 gives us the week number let firstDay; switch (offsetYear) { case 0: firstDay = firstDayOfFirstWeek; break; case 1: // firstDay is the 1st day of the 1st week of the year after // firstDay = lastDayOfLastWeek + 1 Day firstDay = new DateTime(y, 11, lastThursday + 3 + 1); break; case -1: // firstDay is the 1st day of the 1st week of the previous year. // The first week of the previous year is the week that contains the // first Thursday of the previous year. let firstThursdayPreviousYear = 1; while (new DateTime(y - 1, 0, firstThursdayPreviousYear).getDay() !== 4) { firstThursdayPreviousYear += 1; } firstDay = new DateTime(y - 1, 0, firstThursdayPreviousYear - 3); break; } const diff = (_date.getTime() - firstDay.getTime()) / MS_PER_DAY; return Math.floor(diff / 7) + 1; }, isExported: true, }; // ----------------------------------------------------------------------------- // MINUTE // ----------------------------------------------------------------------------- const MINUTE = { description: _t("Minute component of a specific time."), args: [arg("time (date)", _t("The time from which to calculate the minute component."))], compute: function (date) { return toJsDate(date, this.locale).getMinutes(); }, isExported: true, }; // ----------------------------------------------------------------------------- // MONTH // ----------------------------------------------------------------------------- const MONTH = { description: _t("Month of the year a specific date falls in"), args: [arg("date (date)", _t("The date from which to extract the month."))], compute: function (date) { return toJsDate(date, this.locale).getMonth() + 1; }, isExported: true, }; // ----------------------------------------------------------------------------- // NETWORKDAYS // ----------------------------------------------------------------------------- const NETWORKDAYS = { description: _t("Net working days between two provided days."), args: [ arg("start_date (date)", _t("The start date of the period from which to calculate the number of net working days.")), arg("end_date (date)", _t("The end date of the period from which to calculate the number of net working days.")), arg("holidays (date, range, optional)", _t("A range or array constant containing the date serial numbers to consider holidays.")), ], compute: function (startDate, endDate, holidays) { return NETWORKDAYS_INTL.compute.bind(this)(startDate, endDate, { value: 1 }, holidays); }, isExported: true, }; // ----------------------------------------------------------------------------- // NETWORKDAYS.INTL // ----------------------------------------------------------------------------- /** * Transform weekend Spreadsheet information into Date Day JavaScript information. * Take string (String method) or number (Number method), return array of numbers. * * String method: weekends can be specified using seven 0’s and 1’s, where the * first number in the set represents Monday and the last number is for Sunday. * A zero means that the day is a work day, a 1 means that the day is a weekend. * For example, “0000011” would mean Saturday and Sunday are weekends. * * Number method: instead of using the string method above, a single number can * be used. 1 = Saturday/Sunday are weekends, 2 = Sunday/Monday, and this pattern * repeats until 7 = Friday/Saturday. 11 = Sunday is the only weekend, 12 = Monday * is the only weekend, and this pattern repeats until 17 = Saturday is the only * weekend. * * Example: * - 11 return [0] (correspond to Sunday) * - 12 return [1] (correspond to Monday) * - 3 return [1,2] (correspond to Monday and Tuesday) * - "0101010" return [2,4,6] (correspond to Tuesday, Thursday and Saturday) */ function weekendToDayNumber(data) { const weekend = data?.value; // case "string" if (typeof weekend === "string") { assert(() => { if (weekend.length !== 7) { return false; } for (let day of weekend) { if (day !== "0" && day !== "1") { return false; } } return true; }, _t('When weekend is a string (%s) it must be composed of "0" or "1".', weekend)); let result = []; for (let i = 0; i < 7; i++) { if (weekend[i] === "1") { result.push((i + 1) % 7); } } return result; } //case "number" if (typeof weekend === "number") { assert(() => (1 <= weekend && weekend <= 7) || (11 <= weekend && weekend <= 17), _t("The weekend (%s) must be a string or a number in the range 1-7 or 11-17.", weekend.toString())); // case 1 <= weekend <= 7 if (weekend <= 7) { // 1 = Saturday/Sunday are weekends // 2 = Sunday/Monday // ... // 7 = Friday/Saturday. return [weekend - 2 === -1 ? 6 : weekend - 2, weekend - 1]; } // case 11 <= weekend <= 17 // 11 = Sunday is the only weekend // 12 = Monday is the only weekend // ... // 17 = Saturday is the only weekend. return [weekend - 11]; } throw new EvaluationError(_t("The weekend must be a number or a string.")); } const NETWORKDAYS_INTL = { description: _t("Net working days between two dates (specifying weekends)."), args: [ arg("start_date (date)", _t("The start date of the period from which to calculate the number of net working days.")), arg("end_date (date)", _t("The end date of the period from which to calculate the number of net working days.")), arg(`weekend (any, default=${DEFAULT_WEEKEND})`, _t("A number or string representing which days of the week are considered weekends.")), arg("holidays (date, range, optional)", _t("A range or array constant containing the dates to consider as holidays.")), ], compute: function (startDate, endDate, weekend = { value: DEFAULT_WEEKEND }, holidays) { const _startDate = toJsDate(startDate, this.locale); const _endDate = toJsDate(endDate, this.locale); const daysWeekend = weekendToDayNumber(weekend); let timesHoliday = new Set(); if (holidays !== undefined) { visitAny([holidays], (h) => { const holiday = toJsDate(h, this.locale); timesHoliday.add(holiday.getTime()); }); } const invertDate = _startDate.getTime() > _endDate.getTime(); const stopDate = DateTime.fromTimestamp((invertDate ? _startDate : _endDate).getTime()); let stepDate = DateTime.fromTimestamp((invertDate ? _endDate : _startDate).getTime()); const timeStopDate = stopDate.getTime(); let timeStepDate = stepDate.getTime(); let netWorkingDay = 0; while (timeStepDate <= timeStopDate) { if (!daysWeekend.includes(stepDate.getDay()) && !timesHoliday.has(timeStepDate)) { netWorkingDay += 1; } stepDate.setDate(stepDate.getDate() + 1); timeStepDate = stepDate.getTime(); } return invertDate ? -netWorkingDay : netWorkingDay; }, isExported: true, }; // ----------------------------------------------------------------------------- // NOW // ----------------------------------------------------------------------------- const NOW = { description: _t("Current date and time as a date value."), args: [], compute: function () { const today = DateTime.now(); const delta = today.getTime() - INITIAL_1900_DAY.getTime(); const time = today.getHours() / 24 + today.getMinutes() / 1440 + today.getSeconds() / 86400; return { value: Math.floor(delta / MS_PER_DAY) + time, format: getDateTimeFormat(this.locale), }; }, isExported: true, }; // ----------------------------------------------------------------------------- // SECOND // ----------------------------------------------------------------------------- const SECOND = { description: _t("Minute component of a specific time."), args: [arg("time (date)", _t("The time from which to calculate the second component."))], compute: function (date) { return toJsDate(date, this.locale).getSeconds(); }, isExported: true, }; // ----------------------------------------------------------------------------- // TIME // ----------------------------------------------------------------------------- const TIME = { description: _t("Converts hour/minute/second into a time."), args: [ arg("hour (number)", _t("The hour component of the time.")), arg("minute (number)", _t("The minute component of the time.")), arg("second (number)", _t("The second component of the time.")), ], compute: function (hour, minute, second) { let _hour = Math.trunc(toNumber(hour, this.locale)); let _minute = Math.trunc(toNumber(minute, this.locale)); let _second = Math.trunc(toNumber(second, this.locale)); _minute += Math.floor(_second / 60); _second = (_second % 60) + (_second < 0 ? 60 : 0); _hour += Math.floor(_minute / 60); _minute = (_minute % 60) + (_minute < 0 ? 60 : 0); _hour %= 24; assert(() => _hour >= 0, _t("The function [[FUNCTION_NAME]] result cannot be negative")); return { value: _hour / 24 + _minute / (24 * 60) + _second / (24 * 60 * 60), format: this.locale.timeFormat, }; }, isExported: true, }; // ----------------------------------------------------------------------------- // TIMEVALUE // ----------------------------------------------------------------------------- const TIMEVALUE = { description: _t("Converts a time string into its serial number representation."), args: [arg("time_string (string)", _t("The string that holds the time representation."))], compute: function (timeString) { const _timeString = toString(timeString); const internalDate = parseDateTime(_timeString, this.locale); assert(() => internalDate !== null, _t("The time_string (%s) cannot be parsed to date/time.", _timeString)); const result = internalDate.value - Math.trunc(internalDate.value); return result < 0 ? 1 + result : result; }, isExported: true, }; // ----------------------------------------------------------------------------- // TODAY // ----------------------------------------------------------------------------- const TODAY = { description: _t("Current date as a date value."), args: [], compute: function () { const today = DateTime.now(); const jsDate = new DateTime(today.getFullYear(), today.getMonth(), today.getDate()); return { value: jsDateToRoundNumber(jsDate), format: this.locale.dateFormat, }; }, isExported: true, }; // ----------------------------------------------------------------------------- // WEEKDAY // ----------------------------------------------------------------------------- const WEEKDAY = { description: _t("Day of the week of the date provided (as number)."), args: [ arg("date (date)", _t("The date for which to determine the day of the week. Must be a reference to a cell containing a date, a function returning a date type, or a number.")), arg(`type (number, default=${DEFAULT_TYPE})`, _t("A number indicating which numbering system to use to represent weekdays. By default, counts starting with Sunday = 1.")), ], compute: function (date, type = { value: DEFAULT_TYPE }) { const _date = toJsDate(date, this.locale); const _type = Math.round(toNumber(type, this.locale)); const m = _date.getDay(); assert(() => [1, 2, 3].includes(_type), _t("The type (%s) must be 1, 2 or 3.", _type.toString())); if (_type === 1) return m + 1; if (_type === 2) return m === 0 ? 7 : m; return m === 0 ? 6 : m - 1; }, isExported: true, }; // ----------------------------------------------------------------------------- // WEEKNUM // ----------------------------------------------------------------------------- const WEEKNUM = { description: _t("Week number of the year."), args: [ arg("date (date)", _t("The date for which to determine the week number. Must be a reference to a cell containing a date, a function returning a date type, or a number.")), arg(`type (number, default=${DEFAULT_TYPE})`, _t("A number representing the day that a week starts on. Sunday = 1.")), ], compute: function (date, type = { value: DEFAULT_TYPE }) { const _date = toJsDate(date, this.locale); const _type = Math.round(toNumber(type, this.locale)); assert(() => _type === 1 || _type === 2 || (11 <= _type && _type <= 17) || _type === 21, _t("The type (%s) is out of range.", _type.toString())); if (_type === 21) { return ISOWEEKNUM.compute.bind(this)(date); } let startDayOfWeek; if (_type === 1 || _type === 2) { startDayOfWeek = _type - 1; } else { // case 11 <= _type <= 17 startDayOfWeek = _type - 10 === 7 ? 0 : _type - 10; } const y = _date.getFullYear(); let dayStart = 1; let startDayOfFirstWeek = new DateTime(y, 0, dayStart); while (startDayOfFirstWeek.getDay() !== startDayOfWeek) { dayStart += 1; startDayOfFirstWeek = new DateTime(y, 0, dayStart); } const dif = (_date.getTime() - startDayOfFirstWeek.getTime()) / MS_PER_DAY; if (dif < 0) { return 1; } return Math.floor(dif / 7) + (dayStart === 1 ? 1 : 2); }, isExported: true, }; // ----------------------------------------------------------------------------- // WORKDAY // ----------------------------------------------------------------------------- const WORKDAY = { description: _t("Date after a number of workdays."), args: [ arg("start_date (date)", _t("The date from which to begin counting.")), arg("num_days (number)", _t("The number of working days to advance from start_date. If negative, counts backwards.")), arg("holidays (date, range, optional)", _t("A range or array constant containing the dates to consider holidays.")), ], compute: function (startDate, numDays, holidays = { value: null }) { return WORKDAY_INTL.compute.bind(this)(startDate, numDays, { value: 1 }, holidays); }, isExported: true, }; // ----------------------------------------------------------------------------- // WORKDAY.INTL // ----------------------------------------------------------------------------- const WORKDAY_INTL = { description: _t("Date after a number of workdays (specifying weekends)."), args: [ arg("start_date (date)", _t("The date from which to begin counting.")), arg("num_days (number)", _t("The number of working days to advance from start_date. If negative, counts backwards.")), arg(`weekend (any, default=${DEFAULT_WEEKEND})`, _t("A number or string representing which days of the week are considered weekends.")), arg("holidays (date, range, optional)", _t("A range or array constant containing the dates to consider holidays.")), ], compute: function (startDate, numDays, weekend = { value: DEFAULT_WEEKEND }, holidays) { let _startDate = toJsDate(startDate, this.locale); let _numDays = Math.trunc(toNumber(numDays, this.locale)); if (typeof weekend.value === "string") { assert(() => weekend.value !== "1111111", _t("The weekend must be different from '1111111'.")); } const daysWeekend = weekendToDayNumber(weekend); let timesHoliday = new Set(); if (holidays !== undefined) { visitAny([holidays], (h) => { const holiday = toJsDate(h, this.locale); timesHoliday.add(holiday.getTime()); }); } let stepDate = DateTime.fromTimestamp(_startDate.getTime()); let timeStepDate = stepDate.getTime(); const unitDay = Math.sign(_numDays); let stepDay = Math.abs(_numDays); while (stepDay > 0) { stepDate.setDate(stepDate.getDate() + unitDay); timeStepDate = stepDate.getTime(); if (!daysWeekend.includes(stepDate.getDay()) && !timesHoliday.has(timeStepDate)) { stepDay -= 1; } } const delta = timeStepDate - INITIAL_1900_DAY.getTime(); return { value: Math.round(delta / MS_PER_DAY), format: this.locale.dateFormat, }; }, isExported: true, }; // ----------------------------------------------------------------------------- // YEAR // ----------------------------------------------------------------------------- const YEAR = { description: _t("Year specified by a given date."), args: [arg("date (date)", _t("The date from which to extract the year."))], compute: function (date) { return toJsDate(date, this.locale).getFullYear(); }, isExported: true, }; // ----------------------------------------------------------------------------- // YEARFRAC // ----------------------------------------------------------------------------- const DEFAULT_DAY_COUNT_CONVENTION$1 = 0; const YEARFRAC = { description: _t("Exact number of years between two dates."), args: [ arg("start_date (date)", _t("The start date to consider in the calculation. Must be a reference to a cell containing a date, a function returning a date type, or a number.")), arg("end_date (date)", _t("The end date to consider in the calculation. Must be a reference to a cell containing a date, a function returning a date type, or a number.")), arg(`day_count_convention (number, default=${DEFAULT_DAY_COUNT_CONVENTION$1})`, _t("An indicator of what day count method to use.")), ], compute: function (startDate, endDate, dayCountConvention = { value: DEFAULT_DAY_COUNT_CONVENTION$1 }) { let _startDate = Math.trunc(toNumber(startDate, this.locale)); let _endDate = Math.trunc(toNumber(endDate, this.locale)); const _dayCountConvention = Math.trunc(toNumber(dayCountConvention, this.locale)); assert(() => _startDate >= 0, _t("The start_date (%s) must be positive or null.", _startDate.toString())); assert(() => _endDate >= 0, _t("The end_date (%s) must be positive or null.", _endDate.toString())); assert(() => 0 <= _dayCountConvention && _dayCountConvention <= 4, _t("The day_count_convention (%s) must be between 0 and 4 inclusive.", _dayCountConvention.toString())); return getYearFrac(_startDate, _endDate, _dayCountConvention); }, }; // ----------------------------------------------------------------------------- // MONTH.START // ----------------------------------------------------------------------------- const MONTH_START = { description: _t("First day of the month preceding a date."), args: [arg("date (date)", _t("The date from which to calculate the result."))], compute: function (date) { const _startDate = toJsDate(date, this.locale); const yStart = _startDate.getFullYear(); const mStart = _startDate.getMonth(); const jsDate = new DateTime(yStart, mStart, 1); return { value: jsDateToRoundNumber(jsDate), format: this.locale.dateFormat, }; }, }; // ----------------------------------------------------------------------------- // MONTH.END // ----------------------------------------------------------------------------- const MONTH_END = { description: _t("Last day of the month following a date."), args: [arg("date (date)", _t("The date from which to calculate the result."))], compute: function (date) { return EOMONTH.compute.bind(this)(date, { value: 0 }); }, }; // ----------------------------------------------------------------------------- // QUARTER // ----------------------------------------------------------------------------- const QUARTER = { description: _t("Quarter of the year a specific date falls in"), args: [arg("date (date)", _t("The date from which to extract the quarter."))], compute: function (date) { return Math.ceil((toJsDate(date, this.locale).getMonth() + 1) / 3); }, }; // ----------------------------------------------------------------------------- // QUARTER.START // ----------------------------------------------------------------------------- const QUARTER_START = { description: _t("First day of the quarter of the year a specific date falls in."), args: [arg("date (date)", _t("The date from which to calculate the start of quarter."))], compute: function (date) { const quarter = QUARTER.compute.bind(this)(date); const year = YEAR.compute.bind(this)(date); const jsDate = new DateTime(year, (quarter - 1) * 3, 1); return { value: jsDateToRoundNumber(jsDate), format: this.locale.dateFormat, }; }, }; // ----------------------------------------------------------------------------- // QUARTER.END // ----------------------------------------------------------------------------- const QUARTER_END = { description: _t("Last day of the quarter of the year a specific date falls in."), args: [arg("date (date)", _t("The date from which to calculate the end of quarter."))], compute: function (date) { const quarter = QUARTER.compute.bind(this)(date); const year = YEAR.compute.bind(this)(date); const jsDate = new DateTime(year, quarter * 3, 0); return { value: jsDateToRoundNumber(jsDate), format: this.locale.dateFormat, }; }, }; // ----------------------------------------------------------------------------- // YEAR.START // ----------------------------------------------------------------------------- const YEAR_START = { description: _t("First day of the year a specific date falls in."), args: [arg("date (date)", _t("The date from which to calculate the start of the year."))], compute: function (date) { const year = YEAR.compute.bind(this)(date); const jsDate = new DateTime(year, 0, 1); return { value: jsDateToRoundNumber(jsDate), format: this.locale.dateFormat, }; }, }; // ----------------------------------------------------------------------------- // YEAR.END // ----------------------------------------------------------------------------- const YEAR_END = { description: _t("Last day of the year a specific date falls in."), args: [arg("date (date)", _t("The date from which to calculate the end of the year."))], compute: function (date) { const year = YEAR.compute.bind(this)(date); const jsDate = new DateTime(year + 1, 0, 0); return { value: jsDateToRoundNumber(jsDate), format: this.locale.dateFormat, }; }, }; var date = /*#__PURE__*/Object.freeze({ __proto__: null, DATE: DATE, DATEDIF: DATEDIF, DATEVALUE: DATEVALUE, DAY: DAY, DAYS: DAYS, DAYS360: DAYS360, EDATE: EDATE, EOMONTH: EOMONTH, HOUR: HOUR, ISOWEEKNUM: ISOWEEKNUM, MINUTE: MINUTE, MONTH: MONTH, MONTH_END: MONTH_END, MONTH_START: MONTH_START, NETWORKDAYS: NETWORKDAYS, NETWORKDAYS_INTL: NETWORKDAYS_INTL, NOW: NOW, QUARTER: QUARTER, QUARTER_END: QUARTER_END, QUARTER_START: QUARTER_START, SECOND: SECOND, TIME: TIME, TIMEVALUE: TIMEVALUE, TODAY: TODAY, WEEKDAY: WEEKDAY, WEEKNUM: WEEKNUM, WORKDAY: WORKDAY, WORKDAY_INTL: WORKDAY_INTL, YEAR: YEAR, YEARFRAC: YEARFRAC, YEAR_END: YEAR_END, YEAR_START: YEAR_START }); const DEFAULT_DELTA_ARG = 0; // ----------------------------------------------------------------------------- // DELTA // ----------------------------------------------------------------------------- const DELTA = { description: _t("Compare two numeric values, returning 1 if they're equal."), args: [ arg("number1 (number)", _t("The first number to compare.")), arg(`number2 (number, default=${DEFAULT_DELTA_ARG})`, _t("The second number to compare.")), ], compute: function (number1, number2 = { value: DEFAULT_DELTA_ARG }) { const _number1 = toNumber(number1, this.locale); const _number2 = toNumber(number2, this.locale); return _number1 === _number2 ? 1 : 0; }, isExported: true, }; var engineering = /*#__PURE__*/Object.freeze({ __proto__: null, DELTA: DELTA }); const SORT_TYPES = [ CellValueType.number, CellValueType.error, CellValueType.text, CellValueType.boolean, ]; function cellsSortingCriterion(sortingOrder) { const inverse = sortingOrder === "ascending" ? 1 : -1; return (left, right) => { if (left.type === CellValueType.empty) { return right.type === CellValueType.empty ? 0 : 1; } else if (right.type === CellValueType.empty) { return -1; } let typeOrder = SORT_TYPES.indexOf(left.type) - SORT_TYPES.indexOf(right.type); if (typeOrder === 0) { if (left.type === CellValueType.text || left.type === CellValueType.error) { typeOrder = left.value.localeCompare(right.value); } else { typeOrder = left.value - right.value; } } return inverse * typeOrder; }; } function sortCells(cells, sortDirection, emptyCellAsZero) { const cellsWithIndex = cells.map((cell, index) => ({ index, type: cell.type, value: cell.value, })); const cellsToSort = emptyCellAsZero ? cellsWithIndex.map((cell) => cell.type === CellValueType.empty ? { ...cell, type: CellValueType.number, value: 0 } : cell) : cellsWithIndex; return cellsToSort.sort(cellsSortingCriterion(sortDirection)); } function interactiveSortSelection(env, sheetId, anchor, zone, sortDirection) { let result = DispatchResult.Success; //several columns => bypass the contiguity check let multiColumns = zone.right > zone.left; if (env.model.getters.doesIntersectMerge(sheetId, zone)) { multiColumns = false; let table; for (let row = zone.top; row <= zone.bottom; row++) { table = []; for (let col = zone.left; col <= zone.right; col++) { let merge = env.model.getters.getMerge({ sheetId, col, row }); if (merge && !table.includes(merge.id.toString())) { table.push(merge.id.toString()); } } if (table.length >= 2) { multiColumns = true; break; } } } const { col, row } = anchor; if (multiColumns) { result = env.model.dispatch("SORT_CELLS", { sheetId, col, row, zone, sortDirection }); } else { // check contiguity const contiguousZone = env.model.getters.getContiguousZone(sheetId, zone); if (isEqual(contiguousZone, zone)) { // merge as it is result = env.model.dispatch("SORT_CELLS", { sheetId, col, row, zone, sortDirection, }); } else { env.askConfirmation(_t("We found data next to your selection. Since this data was not selected, it will not be sorted. Do you want to extend your selection?"), () => { zone = contiguousZone; result = env.model.dispatch("SORT_CELLS", { sheetId, col, row, zone, sortDirection, }); }, () => { result = env.model.dispatch("SORT_CELLS", { sheetId, col, row, zone, sortDirection, }); }); } } if (result.isCancelledBecause("InvalidSortZone" /* CommandResult.InvalidSortZone */)) { const { col, row } = anchor; env.model.selection.selectZone({ cell: { col, row }, zone }); env.raiseError(_t("Cannot sort. To sort, select only cells or only merges that have the same size.")); } } function sortMatrix(matrix, locale, ...criteria) { for (const [i, value] of criteria.entries()) { assert(() => value !== undefined, _t("Value for parameter %d is missing, while the function [[FUNCTION_NAME]] expect a number or a range.", i + 1)); } const sortingOrders = []; const sortColumns = []; const nRows = matrix.length; for (let i = 0; i < criteria.length; i += 2) { sortingOrders.push(toBoolean(toScalar(criteria[i + 1])?.value) ? "ascending" : "descending"); const sortColumn = criteria[i]; if (isMatrix(sortColumn) && (sortColumn.length > 1 || sortColumn[0].length > 1)) { assert(() => sortColumn.length === 1 && sortColumn[0].length === nRows, _t("Wrong size for %s. Expected a range of size 1x%s. Got %sx%s.", `sort_column${i + 1}`, nRows, sortColumn.length, sortColumn[0].length)); sortColumns.push(sortColumn.flat().map((c) => c.value)); } else { const colIndex = toNumber(toScalar(sortColumn)?.value, locale); if (colIndex < 1 || colIndex > matrix[0].length) { return matrix; } sortColumns.push(matrix.map((row) => row[colIndex - 1].value)); } } if (sortColumns.length === 0) { for (let i = 0; i < matrix[0].length; i++) { sortColumns.push(matrix.map((row) => row[i].value)); sortingOrders.push("ascending"); } } const sortingCriteria = { descending: cellsSortingCriterion("descending"), ascending: cellsSortingCriterion("ascending"), }; const indexes = range(0, matrix.length); indexes.sort((a, b) => { for (const [i, sortColumn] of sortColumns.entries()) { const left = sortColumn[a]; const right = sortColumn[b]; const leftCell = { value: left, type: left === null ? CellValueType.empty : typeof left === "string" ? CellValueType.text : typeof left, }; const rightCell = { value: right, type: right === null ? CellValueType.empty : typeof right === "string" ? CellValueType.text : typeof right, }; const result = sortingCriteria[sortingOrders[i]](leftCell, rightCell); if (result !== 0) { return result; } } return 0; }); return indexes.map((i) => matrix[i]); } // ----------------------------------------------------------------------------- // FILTER // ----------------------------------------------------------------------------- const FILTER = { description: _t("Returns a filtered version of the source range, returning only rows or columns that meet the specified conditions."), // TODO modify args description when vectorization on formulas is available args: [ arg("range (any, range)", _t("The data to be filtered.")), arg("condition1 (boolean, range)", _t("A column or row containing true or false values corresponding to the first column or row of range.")), arg("condition2 (boolean, range, repeating)", _t("Additional column or row containing true or false values.")), ], compute: function (range, ...conditions) { let _array = toMatrix(range); const _conditionsMatrices = conditions.map((cond) => matrixMap(toMatrix(cond), (data) => data.value)); _conditionsMatrices.map((c) => assertSingleColOrRow(_t("The arguments condition must be a single column or row."), c)); assertSameDimensions(_t("The arguments conditions must have the same dimensions."), ...conditions); const _conditions = _conditionsMatrices.map((c) => c.flat()); const mode = _conditionsMatrices[0].length === 1 ? "row" : "col"; _array = mode === "row" ? transposeMatrix(_array) : _array; assert(() => _conditions.every((cond) => cond.length === _array.length), _t("FILTER has mismatched sizes on the range and conditions.")); const result = []; for (let i = 0; i < _array.length; i++) { const row = _array[i]; if (_conditions.every((c) => (typeof c[i] === "boolean" || typeof c[i] === "number") && c[i])) { result.push(row); } } if (!result.length) { throw new NotAvailableError(_t("No match found in FILTER evaluation")); } return mode === "row" ? transposeMatrix(result) : result; }, isExported: true, }; // ----------------------------------------------------------------------------- // SORT // ----------------------------------------------------------------------------- const SORT = { description: _t("Sorts the rows of a given array or range by the values in one or more columns."), args: [ arg("range (range)", _t("The data to be sorted.")), arg("sort_column (any, range, repeating)", _t("The index of the column in range or a range outside of range containing the values by which to sort.")), arg("is_ascending (boolean, repeating)", _t("TRUE or FALSE indicating whether to sort sort_column in ascending order. FALSE sorts in descending order.")), ], compute: function (range, ...sortingCriteria) { const _range = transposeMatrix(range); return transposeMatrix(sortMatrix(_range, this.locale, ...sortingCriteria)); }, isExported: true, }; // ----------------------------------------------------------------------------- // SORTN // ----------------------------------------------------------------------------- const SORTN = { description: _t("Returns the first n items in a data set after performing a sort."), args: [ arg("range (range)", _t("The data to be sorted.")), arg("n (number, default=1)", _t("The number of items to return.")), arg("display_ties_mode (number, default=0)", _t("A number representing the way to display ties.")), arg("sort_column (number, range, repeating)", _t("The index of the column in range or a range outside of range containing the values by which to sort.")), arg("is_ascending (boolean, repeating)", _t("TRUE or FALSE indicating whether to sort sort_column in ascending order. FALSE sorts in descending order.")), ], compute: function (range, n, displayTiesMode, ...sortingCriteria) { const _n = toNumber(n?.value ?? 1, this.locale); assert(() => _n >= 0, _t("Wrong value of 'n'. Expected a positive number. Got %s.", _n)); const _displayTiesMode = toNumber(displayTiesMode?.value ?? 0, this.locale); assert(() => _displayTiesMode >= 0 && _displayTiesMode <= 3, _t("Wrong value of 'display_ties_mode'. Expected a positive number between 0 and 3. Got %s.", _displayTiesMode)); const sortedData = sortMatrix(transposeMatrix(range), this.locale, ...sortingCriteria); const sameRows = (i, j) => JSON.stringify(sortedData[i].map((c) => c.value)) === JSON.stringify(sortedData[j].map((c) => c.value)); /* * displayTiesMode determine how ties (equal values) are dealt with: * 0 - ignore ties and show first n rows only * 1 - show first n rows plus any additional ties with nth row * 2 - show n rows but remove duplicates * 3 - show first n unique rows and all duplicates of these rows */ switch (_displayTiesMode) { case 0: return transposeMatrix(sortedData.slice(0, _n)); case 1: for (let i = _n; i < sortedData.length; i++) { if (!sameRows(i, _n - 1)) { return transposeMatrix(sortedData.slice(0, i)); } } return transposeMatrix(sortedData); case 2: { const uniques = [sortedData[0]]; for (let i = 1; i < sortedData.length; i++) { for (let j = 0; j < i; j++) { if (sameRows(i, j)) { break; } if (j === i - 1) { uniques.push(sortedData[i]); } } } return transposeMatrix(uniques.slice(0, _n)); } case 3: { const uniques = [sortedData[0]]; let counter = 1; for (let i = 1; i < sortedData.length; i++) { if (!sameRows(i, i - 1)) { counter++; } if (counter > _n) { break; } uniques.push(sortedData[i]); } return transposeMatrix(uniques); } } }, isExported: true, }; // ----------------------------------------------------------------------------- // UNIQUE // ----------------------------------------------------------------------------- const UNIQUE = { description: _t("Unique rows in the provided source range."), args: [ arg("range (any, range)", _t("The data to filter by unique entries.")), arg("by_column (boolean, default=FALSE)", _t("Whether to filter the data by columns or by rows.")), arg("exactly_once (boolean, default=FALSE)", _t("Whether to return only entries with no duplicates.")), ], compute: function (range = { value: "" }, byColumn, exactlyOnce) { if (!isMatrix(range)) { return [[range]]; } const _byColumn = toBoolean(byColumn?.value) || false; const _exactlyOnce = toBoolean(exactlyOnce?.value) || false; if (!_byColumn) { range = transposeMatrix(range); } const map = new Map(); for (const data of range) { const key = JSON.stringify(data.map((item) => item.value)); const occurrence = map.get(key); if (!occurrence) { map.set(key, { data, count: 1 }); } else { occurrence.count++; } } const result = []; for (const row of map.values()) { if (_exactlyOnce && row.count > 1) { continue; } result.push(row.data); } if (!result.length) throw new EvaluationError(_t("No unique values found")); return _byColumn ? result : transposeMatrix(result); }, isExported: true, }; var filter = /*#__PURE__*/Object.freeze({ __proto__: null, FILTER: FILTER, SORT: SORT, SORTN: SORTN, UNIQUE: UNIQUE }); /** Assert maturity date > settlement date */ function assertMaturityAndSettlementDatesAreValid(settlement, maturity) { assert(() => settlement < maturity, _t("The maturity (%s) must be strictly greater than the settlement (%s).", maturity.toString(), settlement.toString())); } /** Assert settlement date > issue date */ function assertSettlementAndIssueDatesAreValid(settlement, issue) { assert(() => issue < settlement, _t("The settlement date (%s) must be strictly greater than the issue date (%s).", settlement.toString(), issue.toString())); } /** Assert coupon frequency is in [1, 2, 4] */ function assertCouponFrequencyIsValid(frequency) { assert(() => [1, 2, 4].includes(frequency), _t("The frequency (%s) must be one of %s", frequency.toString(), [1, 2, 4].toString())); } /** Assert dayCountConvention is between 0 and 4 */ function assertDayCountConventionIsValid(dayCountConvention) { assert(() => 0 <= dayCountConvention && dayCountConvention <= 4, _t("The day_count_convention (%s) must be between 0 and 4 inclusive.", dayCountConvention.toString())); } function assertRedemptionStrictlyPositive(redemption) { assert(() => redemption > 0, _t("The redemption (%s) must be strictly positive.", redemption.toString())); } function assertPriceStrictlyPositive(price) { assert(() => price > 0, _t("The price (%s) must be strictly positive.", price.toString())); } function assertNumberOfPeriodsStrictlyPositive(nPeriods) { assert(() => nPeriods > 0, _t("The number_of_periods (%s) must be greater than 0.", nPeriods.toString())); } function assertRateStrictlyPositive(rate) { assert(() => rate > 0, _t("The rate (%s) must be strictly positive.", rate.toString())); } function assertLifeStrictlyPositive(life) { assert(() => life > 0, _t("The life (%s) must be strictly positive.", life.toString())); } function assertCostStrictlyPositive(cost) { assert(() => cost > 0, _t("The cost (%s) must be strictly positive.", cost.toString())); } function assertPurchaseDatePositiveOrZero(purchaseDate) { assert(() => purchaseDate >= 0, _t("The purchase_date (%s) must be positive or null.", purchaseDate.toString())); } function assertIssuePositiveOrZero(issue) { assert(() => issue >= 0, _t("The issue (%s) must be positive or null.", issue.toString())); } function assertCostPositiveOrZero(cost) { assert(() => cost >= 0, _t("The cost (%s) must be positive or null.", cost.toString())); } function assertPeriodStrictlyPositive(period) { assert(() => period > 0, _t("The period (%s) must be strictly positive.", period.toString())); } function assertPeriodPositiveOrZero(period) { assert(() => period >= 0, _t("The period (%s) must be positive or null.", period.toString())); } function assertSalvagePositiveOrZero(salvage) { assert(() => salvage >= 0, _t("The salvage (%s) must be positive or null.", salvage.toString())); } function assertSalvageSmallerOrEqualThanCost(salvage, cost) { assert(() => salvage <= cost, _t("The salvage (%s) must be smaller or equal than the cost (%s).", salvage.toString(), cost.toString())); } function assertPresentValueStrictlyPositive(pv) { assert(() => pv > 0, _t("The present value (%s) must be strictly positive.", pv.toString())); } function assertPeriodSmallerOrEqualToLife(period, life) { assert(() => period <= life, _t("The period (%s) must be less than or equal life (%s).", period.toString(), life.toString())); } function assertInvestmentStrictlyPositive(investment) { assert(() => investment > 0, _t("The investment (%s) must be strictly positive.", investment.toString())); } function assertDiscountStrictlyPositive(discount) { assert(() => discount > 0, _t("The discount (%s) must be strictly positive.", discount.toString())); } function assertDiscountStrictlySmallerThanOne(discount) { assert(() => discount < 1, _t("The discount (%s) must be smaller than 1.", discount.toString())); } function assertDeprecationFactorStrictlyPositive(factor) { assert(() => factor > 0, _t("The depreciation factor (%s) must be strictly positive.", factor.toString())); } function assertSettlementLessThanOneYearBeforeMaturity(settlement, maturity, locale) { const startDate = toJsDate(settlement, locale); const endDate = toJsDate(maturity, locale); const startDatePlusOneYear = toJsDate(settlement, locale); startDatePlusOneYear.setFullYear(startDate.getFullYear() + 1); assert(() => endDate.getTime() <= startDatePlusOneYear.getTime(), _t("The settlement date (%s) must at most one year after the maturity date (%s).", settlement.toString(), maturity.toString())); } /** * Check if the given periods are valid. This will assert : * * - 0 < numberOfPeriods * - 0 < firstPeriod <= lastPeriod * - 0 < lastPeriod <= numberOfPeriods * */ function assertFirstAndLastPeriodsAreValid(firstPeriod, lastPeriod, numberOfPeriods) { assertNumberOfPeriodsStrictlyPositive(numberOfPeriods); assert(() => firstPeriod > 0, _t("The first_period (%s) must be strictly positive.", firstPeriod.toString())); assert(() => lastPeriod > 0, _t("The last_period (%s) must be strictly positive.", lastPeriod.toString())); assert(() => firstPeriod <= lastPeriod, _t("The first_period (%s) must be smaller or equal to the last_period (%s).", firstPeriod.toString(), lastPeriod.toString())); assert(() => lastPeriod <= numberOfPeriods, _t("The last_period (%s) must be smaller or equal to the number_of_periods (%s).", firstPeriod.toString(), numberOfPeriods.toString())); } /** * Check if the given periods are valid. This will assert : * * - 0 < life * - 0 <= startPeriod <= endPeriod * - 0 <= endPeriod <= life * */ function assertStartAndEndPeriodAreValid(startPeriod, endPeriod, life) { assertLifeStrictlyPositive(life); assert(() => startPeriod >= 0, _t("The start_period (%s) must be greater or equal than 0.", startPeriod.toString())); assert(() => endPeriod >= 0, _t("The end_period (%s) must be greater or equal than 0.", endPeriod.toString())); assert(() => startPeriod <= endPeriod, _t("The start_period (%s) must be smaller or equal to the end_period (%s).", startPeriod.toString(), endPeriod.toString())); assert(() => endPeriod <= life, _t("The end_period (%s) must be smaller or equal to the life (%s).", startPeriod.toString(), life.toString())); } function assertRateGuessStrictlyGreaterThanMinusOne(guess) { assert(() => guess > -1, _t("The rate_guess (%s) must be strictly greater than -1.", guess.toString())); } function assertCashFlowsAndDatesHaveSameDimension(cashFlows, dates) { assert(() => cashFlows.length === dates.length && cashFlows[0].length === dates[0].length, _t("The cashflow_amounts and cashflow_dates ranges must have the same dimensions.")); } function assertCashFlowsHavePositiveAndNegativesValues(cashFlow) { assert(() => cashFlow.some((val) => val > 0) && cashFlow.some((val) => val < 0), _t("There must be both positive and negative values in cashflow_amounts.")); } function assertEveryDateGreaterThanFirstDateOfCashFlowDates(dates) { assert(() => dates.every((date) => date >= dates[0]), _t("All the dates should be greater or equal to the first date in cashflow_dates (%s).", dates[0].toString())); } const DEFAULT_DAY_COUNT_CONVENTION = 0; const DEFAULT_END_OR_BEGINNING = 0; const DEFAULT_FUTURE_VALUE = 0; const COUPON_FUNCTION_ARGS = [ arg("settlement (date)", _t("The settlement date of the security, the date after issuance when the security is delivered to the buyer.")), arg("maturity (date)", _t("The maturity or end date of the security, when it can be redeemed at face, or par value.")), arg("frequency (number)", _t("The number of interest or coupon payments per year (1, 2, or 4).")), arg(`day_count_convention (number, default=${DEFAULT_DAY_COUNT_CONVENTION} )`, _t("An indicator of what day count method to use.")), ]; /** * Use the Newton–Raphson method to find a root of the given function in an iterative manner. * * @param func the function to find a root of * @param derivFunc the derivative of the function * @param startValue the initial value for the first iteration of the algorithm * @param maxIterations the maximum number of iterations * @param epsMax the epsilon for the root * @param nanFallback a function giving a fallback value to use if func(x) returns NaN. Useful if the * function is not defined for some range, but we know approximately where the root is when the Newton * algorithm ends up in this range. */ function newtonMethod(func, derivFunc, startValue, maxIterations, epsMax = 1e-10, nanFallback) { let x = startValue; let newX; let xDelta; let y; let yEqual0 = false; let count = 0; let previousFallback = undefined; do { y = func(x); if (isNaN(y)) { assert(() => count < maxIterations && nanFallback !== undefined, _t("Function [[FUNCTION_NAME]] didn't find any result.")); count++; x = nanFallback(previousFallback); previousFallback = x; continue; } newX = x - y / derivFunc(x); xDelta = Math.abs(newX - x); x = newX; yEqual0 = xDelta < epsMax || Math.abs(y) < epsMax; assert(() => count < maxIterations, _t("Function [[FUNCTION_NAME]] didn't find any result.")); count++; } while (!yEqual0); return x; } // ----------------------------------------------------------------------------- // ACCRINTM // ----------------------------------------------------------------------------- const ACCRINTM = { description: _t("Accrued interest of security paying at maturity."), args: [ arg("issue (date)", _t("The date the security was initially issued.")), arg("maturity (date)", _t("The maturity date of the security.")), arg("rate (number)", _t("The annualized rate of interest.")), arg("redemption (number)", _t("The redemption amount per 100 face value, or par.")), arg(`day_count_convention (number, default=${DEFAULT_DAY_COUNT_CONVENTION} )`, _t("An indicator of what day count method to use.")), ], compute: function (issue, maturity, rate, redemption, dayCountConvention = { value: DEFAULT_DAY_COUNT_CONVENTION }) { const start = Math.trunc(toNumber(issue, this.locale)); const end = Math.trunc(toNumber(maturity, this.locale)); const _redemption = toNumber(redemption, this.locale); const _rate = toNumber(rate, this.locale); const _dayCountConvention = Math.trunc(toNumber(dayCountConvention, this.locale)); assertIssuePositiveOrZero(start); assertSettlementAndIssueDatesAreValid(end, start); assertDayCountConventionIsValid(_dayCountConvention); assertRedemptionStrictlyPositive(_redemption); assertRateStrictlyPositive(_rate); const yearFrac = getYearFrac(start, end, _dayCountConvention); return _redemption * _rate * yearFrac; }, isExported: true, }; // ----------------------------------------------------------------------------- // AMORLINC // ----------------------------------------------------------------------------- const AMORLINC = { description: _t("Depreciation for an accounting period."), args: [ arg("cost (number)", _t("The initial cost of the asset.")), arg("purchase_date (date)", _t("The date the asset was purchased.")), arg("first_period_end (date)", _t("The date the first period ended.")), arg("salvage (number)", _t("The value of the asset at the end of depreciation.")), arg("period (number)", _t("The single period within life for which to calculate depreciation.")), arg("rate (number)", _t("The deprecation rate.")), arg("day_count_convention (number, optional)", _t("An indicator of what day count method to use.")), ], compute: function (cost, purchaseDate, firstPeriodEnd, salvage, period, rate, dayCountConvention = { value: DEFAULT_DAY_COUNT_CONVENTION }) { dayCountConvention = dayCountConvention || 0; const _cost = toNumber(cost, this.locale); const _purchaseDate = Math.trunc(toNumber(purchaseDate, this.locale)); const _firstPeriodEnd = Math.trunc(toNumber(firstPeriodEnd, this.locale)); const _salvage = toNumber(salvage, this.locale); const _period = toNumber(period, this.locale); const _rate = toNumber(rate, this.locale); const _dayCountConvention = Math.trunc(toNumber(dayCountConvention, this.locale)); assertCostStrictlyPositive(_cost); assertPurchaseDatePositiveOrZero(_purchaseDate); assertSalvagePositiveOrZero(_salvage); assertSalvageSmallerOrEqualThanCost(_salvage, _cost); assertPeriodPositiveOrZero(_period); assertRateStrictlyPositive(_rate); assertDayCountConventionIsValid(_dayCountConvention); assert(() => _purchaseDate <= _firstPeriodEnd, _t("The purchase_date (%s) must be before the first_period_end (%s).", _purchaseDate.toString(), _firstPeriodEnd.toString())); /** * https://wiki.documentfoundation.org/Documentation/Calc_Functions/AMORLINC * * AMORLINC period 0 = cost * rate * YEARFRAC(purchase date, first period end) * AMORLINC period n = cost * rate * AMORLINC at the last period is such that the remaining deprecated cost is equal to the salvage value. * * The period is and rounded to 1 if < 1 truncated if > 1, * * Compatibility note : * If (purchase date) === (first period end), on GSheet the deprecation at the first period is 0, and on Excel * it is a full period deprecation. We choose to use the Excel behaviour. */ const roundedPeriod = _period < 1 && _period > 0 ? 1 : Math.trunc(_period); const deprec = _cost * _rate; const yearFrac = getYearFrac(_purchaseDate, _firstPeriodEnd, _dayCountConvention); const firstDeprec = _purchaseDate === _firstPeriodEnd ? deprec : deprec * yearFrac; const valueAtPeriod = _cost - firstDeprec - deprec * roundedPeriod; if (valueAtPeriod >= _salvage) { return roundedPeriod === 0 ? firstDeprec : deprec; } return _salvage - valueAtPeriod < deprec ? deprec - (_salvage - valueAtPeriod) : 0; }, isExported: true, }; // ----------------------------------------------------------------------------- // COUPDAYS // ----------------------------------------------------------------------------- const COUPDAYS = { description: _t("Days in coupon period containing settlement date."), args: COUPON_FUNCTION_ARGS, compute: function (settlement, maturity, frequency, dayCountConvention = { value: DEFAULT_DAY_COUNT_CONVENTION }) { dayCountConvention = dayCountConvention || 0; const start = Math.trunc(toNumber(settlement, this.locale)); const end = Math.trunc(toNumber(maturity, this.locale)); const _frequency = Math.trunc(toNumber(frequency, this.locale)); const _dayCountConvention = Math.trunc(toNumber(dayCountConvention, this.locale)); assertMaturityAndSettlementDatesAreValid(start, end); assertCouponFrequencyIsValid(_frequency); assertDayCountConventionIsValid(_dayCountConvention); // https://wiki.documentfoundation.org/Documentation/Calc_Functions/COUPDAYS if (_dayCountConvention === 1) { const before = COUPPCD.compute.bind(this)(settlement, maturity, frequency, dayCountConvention).value; const after = COUPNCD.compute.bind(this)(settlement, maturity, frequency, dayCountConvention).value; return after - before; } const daysInYear = _dayCountConvention === 3 ? 365 : 360; return daysInYear / _frequency; }, isExported: true, }; // ----------------------------------------------------------------------------- // COUPDAYBS // ----------------------------------------------------------------------------- const COUPDAYBS = { description: _t("Days from settlement until next coupon."), args: COUPON_FUNCTION_ARGS, compute: function (settlement, maturity, frequency, dayCountConvention = { value: DEFAULT_DAY_COUNT_CONVENTION }) { dayCountConvention = dayCountConvention || 0; const start = Math.trunc(toNumber(settlement, this.locale)); const end = Math.trunc(toNumber(maturity, this.locale)); const _frequency = Math.trunc(toNumber(frequency, this.locale)); const _dayCountConvention = Math.trunc(toNumber(dayCountConvention, this.locale)); assertMaturityAndSettlementDatesAreValid(start, end); assertCouponFrequencyIsValid(_frequency); assertDayCountConventionIsValid(_dayCountConvention); const couponBeforeStart = COUPPCD.compute.bind(this)(settlement, maturity, frequency, dayCountConvention).value; if ([1, 2, 3].includes(_dayCountConvention)) { return start - couponBeforeStart; } if (_dayCountConvention === 4) { const yearFrac = getYearFrac(couponBeforeStart, start, _dayCountConvention); return Math.round(yearFrac * 360); } const startDate = toJsDate(start, this.locale); const dateCouponBeforeStart = toJsDate(couponBeforeStart, this.locale); const y1 = dateCouponBeforeStart.getFullYear(); const y2 = startDate.getFullYear(); const m1 = dateCouponBeforeStart.getMonth() + 1; // +1 because months in js start at 0 and it's confusing const m2 = startDate.getMonth() + 1; let d1 = dateCouponBeforeStart.getDate(); let d2 = startDate.getDate(); /** * Rules based on https://en.wikipedia.org/wiki/Day_count_convention#30/360_US * * These are slightly modified (no mention of if investment is EOM and rules order is modified), * but from my testing this seems the rules used by Excel/GSheet. */ if (m1 === 2 && m2 === 2 && isLastDayOfMonth(dateCouponBeforeStart) && isLastDayOfMonth(startDate)) { d2 = 30; } if (d2 === 31 && (d1 === 30 || d1 === 31)) { d2 = 30; } if (m1 === 2 && isLastDayOfMonth(dateCouponBeforeStart)) { d1 = 30; } if (d1 === 31) { d1 = 30; } return (y2 - y1) * 360 + (m2 - m1) * 30 + (d2 - d1); }, isExported: true, }; // ----------------------------------------------------------------------------- // COUPDAYSNC // ----------------------------------------------------------------------------- const COUPDAYSNC = { description: _t("Days from settlement until next coupon."), args: COUPON_FUNCTION_ARGS, compute: function (settlement, maturity, frequency, dayCountConvention = { value: DEFAULT_DAY_COUNT_CONVENTION }) { dayCountConvention = dayCountConvention || 0; const start = Math.trunc(toNumber(settlement, this.locale)); const end = Math.trunc(toNumber(maturity, this.locale)); const _frequency = Math.trunc(toNumber(frequency, this.locale)); const _dayCountConvention = Math.trunc(toNumber(dayCountConvention, this.locale)); assertMaturityAndSettlementDatesAreValid(start, end); assertCouponFrequencyIsValid(_frequency); assertDayCountConventionIsValid(_dayCountConvention); const couponAfterStart = COUPNCD.compute.bind(this)(settlement, maturity, frequency, dayCountConvention).value; if ([1, 2, 3].includes(_dayCountConvention)) { return couponAfterStart - start; } if (_dayCountConvention === 4) { const yearFrac = getYearFrac(start, couponAfterStart, _dayCountConvention); return Math.round(yearFrac * 360); } const coupDayBs = COUPDAYBS.compute.bind(this)(settlement, maturity, frequency, dayCountConvention); const coupDays = COUPDAYS.compute.bind(this)(settlement, maturity, frequency, dayCountConvention); return coupDays - coupDayBs; }, isExported: true, }; // ----------------------------------------------------------------------------- // COUPNCD // ----------------------------------------------------------------------------- const COUPNCD = { description: _t("Next coupon date after the settlement date."), args: COUPON_FUNCTION_ARGS, compute: function (settlement, maturity, frequency, dayCountConvention = { value: DEFAULT_DAY_COUNT_CONVENTION }) { dayCountConvention = dayCountConvention || 0; const start = Math.trunc(toNumber(settlement, this.locale)); const end = Math.trunc(toNumber(maturity, this.locale)); const _frequency = Math.trunc(toNumber(frequency, this.locale)); const _dayCountConvention = Math.trunc(toNumber(dayCountConvention, this.locale)); assertMaturityAndSettlementDatesAreValid(start, end); assertCouponFrequencyIsValid(_frequency); assertDayCountConventionIsValid(_dayCountConvention); const monthsPerPeriod = 12 / _frequency; const coupNum = COUPNUM.compute.bind(this)(settlement, maturity, frequency, dayCountConvention); const date = addMonthsToDate(toJsDate(end, this.locale), -(coupNum - 1) * monthsPerPeriod, true); return { value: jsDateToRoundNumber(date), format: this.locale.dateFormat, }; }, isExported: true, }; // ----------------------------------------------------------------------------- // COUPNUM // ----------------------------------------------------------------------------- const COUPNUM = { description: _t("Number of coupons between settlement and maturity."), args: COUPON_FUNCTION_ARGS, compute: function (settlement, maturity, frequency, dayCountConvention = { value: DEFAULT_DAY_COUNT_CONVENTION }) { dayCountConvention = dayCountConvention || 0; const start = Math.trunc(toNumber(settlement, this.locale)); const end = Math.trunc(toNumber(maturity, this.locale)); const _frequency = Math.trunc(toNumber(frequency, this.locale)); const _dayCountConvention = Math.trunc(toNumber(dayCountConvention, this.locale)); assertMaturityAndSettlementDatesAreValid(start, end); assertCouponFrequencyIsValid(_frequency); assertDayCountConventionIsValid(_dayCountConvention); let num = 1; let currentDate = end; const monthsPerPeriod = 12 / _frequency; while (currentDate > start) { currentDate = jsDateToRoundNumber(addMonthsToDate(toJsDate(currentDate, this.locale), -monthsPerPeriod, false)); num++; } return num - 1; }, isExported: true, }; // ----------------------------------------------------------------------------- // COUPPCD // ----------------------------------------------------------------------------- const COUPPCD = { description: _t("Last coupon date prior to or on the settlement date."), args: COUPON_FUNCTION_ARGS, compute: function (settlement, maturity, frequency, dayCountConvention = { value: DEFAULT_DAY_COUNT_CONVENTION }) { dayCountConvention = dayCountConvention || 0; const start = Math.trunc(toNumber(settlement, this.locale)); const end = Math.trunc(toNumber(maturity, this.locale)); const _frequency = Math.trunc(toNumber(frequency, this.locale)); const _dayCountConvention = Math.trunc(toNumber(dayCountConvention, this.locale)); assertMaturityAndSettlementDatesAreValid(start, end); assertCouponFrequencyIsValid(_frequency); assertDayCountConventionIsValid(_dayCountConvention); const monthsPerPeriod = 12 / _frequency; const coupNum = COUPNUM.compute.bind(this)(settlement, maturity, frequency, dayCountConvention); const date = addMonthsToDate(toJsDate(end, this.locale), -coupNum * monthsPerPeriod, true); return { value: jsDateToRoundNumber(date), format: this.locale.dateFormat, }; }, isExported: true, }; // ----------------------------------------------------------------------------- // CUMIPMT // ----------------------------------------------------------------------------- const CUMIPMT = { description: _t("Cumulative interest paid over a set of periods."), args: [ arg("rate (number)", _t("The interest rate.")), arg("number_of_periods (number)", _t("The number of payments to be made.")), arg("present_value (number)", _t("The current value of the annuity.")), arg("first_period (number)", _t("The number of the payment period to begin the cumulative calculation.")), arg("last_period (number)", _t("The number of the payment period to end the cumulative calculation.")), arg(`end_or_beginning (number, default=${DEFAULT_END_OR_BEGINNING})`, _t("Whether payments are due at the end (0) or beginning (1) of each period.")), ], compute: function (rate, numberOfPeriods, presentValue, firstPeriod, lastPeriod, endOrBeginning = { value: DEFAULT_END_OR_BEGINNING }) { const first = toNumber(firstPeriod, this.locale); const last = toNumber(lastPeriod, this.locale); const r = toNumber(rate, this.locale); const pv = toNumber(presentValue, this.locale); const n = toNumber(numberOfPeriods, this.locale); const type = toBoolean(endOrBeginning) ? 1 : 0; assertFirstAndLastPeriodsAreValid(first, last, n); assertRateStrictlyPositive(r); assertPresentValueStrictlyPositive(pv); let cumSum = 0; for (let i = first; i <= last; i++) { cumSum += impt(r, i, n, pv, 0, type); } return cumSum; }, isExported: true, }; // ----------------------------------------------------------------------------- // CUMPRINC // ----------------------------------------------------------------------------- const CUMPRINC = { description: _t("Cumulative principal paid over a set of periods."), args: [ arg("rate (number)", _t("The interest rate.")), arg("number_of_periods (number)", _t("The number of payments to be made.")), arg("present_value (number)", _t("The current value of the annuity.")), arg("first_period (number)", _t("The number of the payment period to begin the cumulative calculation.")), arg("last_period (number)", _t("The number of the payment period to end the cumulative calculation.")), arg(`end_or_beginning (number, default=${DEFAULT_END_OR_BEGINNING})`, _t("Whether payments are due at the end (0) or beginning (1) of each period.")), ], compute: function (rate, numberOfPeriods, presentValue, firstPeriod, lastPeriod, endOrBeginning = { value: DEFAULT_END_OR_BEGINNING }) { const first = toNumber(firstPeriod, this.locale); const last = toNumber(lastPeriod, this.locale); const r = toNumber(rate, this.locale); const pv = toNumber(presentValue, this.locale); const n = toNumber(numberOfPeriods, this.locale); const type = toBoolean(endOrBeginning) ? 1 : 0; assertFirstAndLastPeriodsAreValid(first, last, n); assertRateStrictlyPositive(r); assertPresentValueStrictlyPositive(pv); let cumSum = 0; for (let i = first; i <= last; i++) { cumSum += ppmt(r, i, n, pv, 0, type); } return cumSum; }, isExported: true, }; // ----------------------------------------------------------------------------- // DB // ----------------------------------------------------------------------------- const DB = { description: _t("Depreciation via declining balance method."), args: [ arg("cost (number)", _t("The initial cost of the asset.")), arg("salvage (number)", _t("The value of the asset at the end of depreciation.")), arg("life (number)", _t("The number of periods over which the asset is depreciated.")), arg("period (number)", _t("The single period within life for which to calculate depreciation.")), arg("month (number, optional)", _t("The number of months in the first year of depreciation.")), ], // to do: replace by dollar format compute: function (cost, salvage, life, period, ...args) { const _cost = toNumber(cost, this.locale); const _salvage = toNumber(salvage, this.locale); const _life = toNumber(life, this.locale); const _period = Math.trunc(toNumber(period, this.locale)); const _month = args.length ? Math.trunc(toNumber(args[0], this.locale)) : 12; const lifeLimit = _life + (_month === 12 ? 0 : 1); assertCostPositiveOrZero(_cost); assertSalvagePositiveOrZero(_salvage); assertPeriodStrictlyPositive(_period); assertLifeStrictlyPositive(_life); assert(() => 1 <= _month && _month <= 12, _t("The month (%s) must be between 1 and 12 inclusive.", _month.toString())); assert(() => _period <= lifeLimit, _t("The period (%s) must be less than or equal to %s.", _period.toString(), lifeLimit.toString())); const monthPart = _month / 12; let rate = 1 - Math.pow(_salvage / _cost, 1 / _life); // round to 3 decimal places rate = Math.round(rate * 1000) / 1000; let before = _cost; let after = _cost * (1 - rate * monthPart); for (let i = 1; i < _period; i++) { before = after; after = before * (1 - rate); if (i === _life) { after = before * (1 - rate * (1 - monthPart)); } } return { value: before - after, format: "#,##0.00", }; }, isExported: true, }; // ----------------------------------------------------------------------------- // DDB // ----------------------------------------------------------------------------- const DEFAULT_DDB_DEPRECIATION_FACTOR = 2; function ddb(cost, salvage, life, period, factor) { assertCostPositiveOrZero(cost); assertSalvagePositiveOrZero(salvage); assertPeriodStrictlyPositive(period); assertLifeStrictlyPositive(life); assertPeriodSmallerOrEqualToLife(period, life); assertDeprecationFactorStrictlyPositive(factor); if (cost === 0 || salvage >= cost) return 0; const deprecFactor = factor / life; if (deprecFactor > 1) { return period === 1 ? cost - salvage : 0; } if (period <= 1) { return cost * deprecFactor; } const previousCost = cost * Math.pow(1 - deprecFactor, period - 1); const nextCost = cost * Math.pow(1 - deprecFactor, period); const deprec = nextCost < salvage ? previousCost - salvage : previousCost - nextCost; return Math.max(deprec, 0); } const DDB = { description: _t("Depreciation via double-declining balance method."), args: [ arg("cost (number)", _t("The initial cost of the asset.")), arg("salvage (number)", _t("The value of the asset at the end of depreciation.")), arg("life (number)", _t("The number of periods over which the asset is depreciated.")), arg("period (number)", _t("The single period within life for which to calculate depreciation.")), arg(`factor (number, default=${DEFAULT_DDB_DEPRECIATION_FACTOR})`, _t("The factor by which depreciation decreases.")), ], compute: function (cost, salvage, life, period, factor = { value: DEFAULT_DDB_DEPRECIATION_FACTOR }) { const _cost = toNumber(cost, this.locale); const _salvage = toNumber(salvage, this.locale); const _life = toNumber(life, this.locale); const _period = toNumber(period, this.locale); const _factor = toNumber(factor, this.locale); return { value: ddb(_cost, _salvage, _life, _period, _factor), format: "#,##0.00", }; }, isExported: true, }; // ----------------------------------------------------------------------------- // DISC // ----------------------------------------------------------------------------- const DISC = { description: _t("Discount rate of a security based on price."), args: [ arg("settlement (date)", _t("The settlement date of the security, the date after issuance when the security is delivered to the buyer.")), arg("maturity (date)", _t("The maturity or end date of the security, when it can be redeemed at face, or par value.")), arg("price (number)", _t("The price at which the security is bought per 100 face value.")), arg("redemption (number)", _t("The redemption amount per 100 face value, or par.")), arg(`day_count_convention (number, default=${DEFAULT_DAY_COUNT_CONVENTION} )`, _t("An indicator of what day count method to use.")), ], compute: function (settlement, maturity, price, redemption, dayCountConvention = { value: DEFAULT_DAY_COUNT_CONVENTION }) { dayCountConvention = dayCountConvention || 0; const _settlement = Math.trunc(toNumber(settlement, this.locale)); const _maturity = Math.trunc(toNumber(maturity, this.locale)); const _price = toNumber(price, this.locale); const _redemption = toNumber(redemption, this.locale); const _dayCountConvention = Math.trunc(toNumber(dayCountConvention, this.locale)); assertMaturityAndSettlementDatesAreValid(_settlement, _maturity); assertDayCountConventionIsValid(_dayCountConvention); assertPriceStrictlyPositive(_price); assertRedemptionStrictlyPositive(_redemption); /** * https://support.microsoft.com/en-us/office/disc-function-71fce9f3-3f05-4acf-a5a3-eac6ef4daa53 * * B = number of days in year, depending on year basis * DSM = number of days from settlement to maturity * * redemption - price B * DISC = ____________________ * ____ * redemption DSM */ const yearsFrac = getYearFrac(_settlement, _maturity, _dayCountConvention); return (_redemption - _price) / _redemption / yearsFrac; }, isExported: true, }; // ----------------------------------------------------------------------------- // DOLLARDE // ----------------------------------------------------------------------------- const DOLLARDE = { description: _t("Convert a decimal fraction to decimal value."), args: [ arg("fractional_price (number)", _t("The price quotation given using fractional decimal conventions.")), arg("unit (number)", _t("The units of the fraction, e.g. 8 for 1/8ths or 32 for 1/32nds.")), ], compute: function (fractionalPrice, unit) { const price = toNumber(fractionalPrice, this.locale); const _unit = Math.trunc(toNumber(unit, this.locale)); assert(() => _unit > 0, _t("The unit (%s) must be strictly positive.", _unit.toString())); const truncatedPrice = Math.trunc(price); const priceFractionalPart = price - truncatedPrice; const frac = 10 ** Math.ceil(Math.log10(_unit)) / _unit; return truncatedPrice + priceFractionalPart * frac; }, isExported: true, }; // ----------------------------------------------------------------------------- // DOLLARFR // ----------------------------------------------------------------------------- const DOLLARFR = { description: _t("Convert a decimal value to decimal fraction."), args: [ arg("decimal_price (number)", _t("The price quotation given as a decimal value.")), arg("unit (number)", _t("The units of the desired fraction, e.g. 8 for 1/8ths or 32 for 1/32nds.")), ], compute: function (decimalPrice, unit) { const price = toNumber(decimalPrice, this.locale); const _unit = Math.trunc(toNumber(unit, this.locale)); assert(() => _unit > 0, _t("The unit (%s) must be strictly positive.", _unit.toString())); const truncatedPrice = Math.trunc(price); const priceFractionalPart = price - truncatedPrice; const frac = _unit / 10 ** Math.ceil(Math.log10(_unit)); return truncatedPrice + priceFractionalPart * frac; }, isExported: true, }; // ----------------------------------------------------------------------------- // DURATION // ----------------------------------------------------------------------------- const DURATION = { description: _t("Number of periods for an investment to reach a value."), args: [ arg("settlement (date)", _t("The settlement date of the security, the date after issuance when the security is delivered to the buyer.")), arg("maturity (date)", _t("The maturity or end date of the security, when it can be redeemed at face, or par value.")), arg("rate (number)", _t("The annualized rate of interest.")), arg("yield (number)", _t("The expected annual yield of the security.")), arg("frequency (number)", _t("The number of interest or coupon payments per year (1, 2, or 4).")), arg(`day_count_convention (number, default=${DEFAULT_DAY_COUNT_CONVENTION} )`, _t("An indicator of what day count method to use.")), ], compute: function (settlement, maturity, rate, securityYield, frequency, dayCountConvention = { value: DEFAULT_DAY_COUNT_CONVENTION }) { const start = Math.trunc(toNumber(settlement, this.locale)); const end = Math.trunc(toNumber(maturity, this.locale)); const _rate = toNumber(rate, this.locale); const _yield = toNumber(securityYield, this.locale); const _frequency = Math.trunc(toNumber(frequency, this.locale)); const _dayCountConvention = Math.trunc(toNumber(dayCountConvention, this.locale)); assertMaturityAndSettlementDatesAreValid(start, end); assertCouponFrequencyIsValid(_frequency); assertDayCountConventionIsValid(_dayCountConvention); assert(() => _rate >= 0, _t("The rate (%s) must be positive or null.", _rate.toString())); assert(() => _yield >= 0, _t("The yield (%s) must be positive or null.", _yield.toString())); const years = getYearFrac(start, end, _dayCountConvention); const timeFirstYear = years - Math.trunc(years) || 1 / _frequency; const nbrCoupons = Math.ceil(years * _frequency); // The DURATION function return the Macaulay duration // See example: https://en.wikipedia.org/wiki/Bond_duration#Formulas const cashFlowFromCoupon = _rate / _frequency; const yieldPerPeriod = _yield / _frequency; let count = 0; let sum = 0; for (let i = 1; i <= nbrCoupons; i++) { const cashFlowPerPeriod = cashFlowFromCoupon + (i === nbrCoupons ? 1 : 0); const presentValuePerPeriod = cashFlowPerPeriod / (1 + yieldPerPeriod) ** i; sum += (timeFirstYear + (i - 1) / _frequency) * presentValuePerPeriod; count += presentValuePerPeriod; } return count === 0 ? 0 : sum / count; }, isExported: true, }; // ----------------------------------------------------------------------------- // EFFECT // ----------------------------------------------------------------------------- const EFFECT = { description: _t("Annual effective interest rate."), args: [ arg("nominal_rate (number)", _t("The nominal interest rate per year.")), arg("periods_per_year (number)", _t("The number of compounding periods per year.")), ], compute: function (nominal_rate, periods_per_year) { const nominal = toNumber(nominal_rate, this.locale); const periods = Math.trunc(toNumber(periods_per_year, this.locale)); assert(() => nominal > 0, _t("The nominal rate (%s) must be strictly greater than 0.", nominal.toString())); assert(() => periods > 0, _t("The number of periods by year (%s) must strictly greater than 0.", periods.toString())); // https://en.wikipedia.org/wiki/Nominal_interest_rate#Nominal_versus_effective_interest_rate return Math.pow(1 + nominal / periods, periods) - 1; }, isExported: true, }; // ----------------------------------------------------------------------------- // FV // ----------------------------------------------------------------------------- const DEFAULT_PRESENT_VALUE = 0; function fv(r, n, p, pv, t) { if (r === 0) { return -(pv + p * n); } return -pv * (1 + r) ** n - (p * (1 + r * t) * ((1 + r) ** n - 1)) / r; } const FV = { description: _t("Future value of an annuity investment."), args: [ arg("rate (number)", _t("The interest rate.")), arg("number_of_periods (number)", _t("The number of payments to be made.")), arg("payment_amount (number)", _t("The amount per period to be paid.")), arg(`present_value (number, default=${DEFAULT_PRESENT_VALUE})`, _t("The current value of the annuity.")), arg(`end_or_beginning (number, default=${DEFAULT_END_OR_BEGINNING})`, _t("Whether payments are due at the end (0) or beginning (1) of each period.")), ], // to do: replace by dollar format compute: function (rate, numberOfPeriods, paymentAmount, presentValue = { value: DEFAULT_PRESENT_VALUE }, endOrBeginning = { value: DEFAULT_END_OR_BEGINNING }) { presentValue = presentValue || 0; endOrBeginning = endOrBeginning || 0; const r = toNumber(rate, this.locale); const n = toNumber(numberOfPeriods, this.locale); const p = toNumber(paymentAmount, this.locale); const pv = toNumber(presentValue, this.locale); const type = toBoolean(endOrBeginning) ? 1 : 0; return { value: fv(r, n, p, pv, type), format: "#,##0.00", }; }, isExported: true, }; // ----------------------------------------------------------------------------- // FVSCHEDULE // ----------------------------------------------------------------------------- const FVSCHEDULE = { description: _t("Future value of principal from series of rates."), args: [ arg("principal (number)", _t("The amount of initial capital or value to compound against.")), arg("rate_schedule (number, range)", _t("A series of interest rates to compound against the principal.")), ], compute: function (principalAmount, rateSchedule) { const principal = toNumber(principalAmount, this.locale); return reduceAny([rateSchedule], (acc, rate) => acc * (1 + toNumber(rate, this.locale)), principal); }, isExported: true, }; // ----------------------------------------------------------------------------- // INTRATE // ----------------------------------------------------------------------------- const INTRATE = { description: _t("Calculates effective interest rate."), args: [ arg("settlement (date)", _t("The settlement date of the security, the date after issuance when the security is delivered to the buyer.")), arg("maturity (date)", _t("The maturity or end date of the security, when it can be redeemed at face, or par value.")), arg("investment (number)", _t("The amount invested in the security.")), arg("redemption (number)", _t("The amount to be received at maturity.")), arg(`day_count_convention (number, default=${DEFAULT_DAY_COUNT_CONVENTION} )`, _t("An indicator of what day count method to use.")), ], compute: function (settlement, maturity, investment, redemption, dayCountConvention = { value: DEFAULT_DAY_COUNT_CONVENTION }) { const _settlement = Math.trunc(toNumber(settlement, this.locale)); const _maturity = Math.trunc(toNumber(maturity, this.locale)); const _redemption = toNumber(redemption, this.locale); const _investment = toNumber(investment, this.locale); const _dayCountConvention = Math.trunc(toNumber(dayCountConvention, this.locale)); assertMaturityAndSettlementDatesAreValid(_settlement, _maturity); assertInvestmentStrictlyPositive(_investment); assertRedemptionStrictlyPositive(_redemption); assertDayCountConventionIsValid(_dayCountConvention); /** * https://wiki.documentfoundation.org/Documentation/Calc_Functions/INTRATE * * (Redemption - Investment) / Investment * INTRATE = _________________________________________ * YEARFRAC(settlement, maturity, basis) */ const yearFrac = getYearFrac(_settlement, _maturity, _dayCountConvention); return (_redemption - _investment) / _investment / yearFrac; }, isExported: true, }; // ----------------------------------------------------------------------------- // IPMT // ----------------------------------------------------------------------------- function impt(r, per, n, pv, fv, type) { return pmt(r, n, pv, fv, type) - ppmt(r, per, n, pv, fv, type); } const IPMT = { description: _t("Payment on the principal of an investment."), args: [ arg("rate (number)", _t("The annualized rate of interest.")), arg("period (number)", _t("The amortization period, in terms of number of periods.")), arg("number_of_periods (number)", _t("The number of payments to be made.")), arg("present_value (number)", _t("The current value of the annuity.")), arg(`future_value (number, default=${DEFAULT_FUTURE_VALUE})`, _t("The future value remaining after the final payment has been made.")), arg(`end_or_beginning (number, default=${DEFAULT_END_OR_BEGINNING})`, _t("Whether payments are due at the end (0) or beginning (1) of each period.")), ], compute: function (rate, currentPeriod, numberOfPeriods, presentValue, futureValue = { value: DEFAULT_FUTURE_VALUE }, endOrBeginning = { value: DEFAULT_END_OR_BEGINNING }) { const r = toNumber(rate, this.locale); const period = toNumber(currentPeriod, this.locale); const n = toNumber(numberOfPeriods, this.locale); const pv = toNumber(presentValue, this.locale); const fv = toNumber(futureValue, this.locale); const type = toBoolean(endOrBeginning) ? 1 : 0; return { value: impt(r, period, n, pv, fv, type), format: "#,##0.00", }; }, isExported: true, }; // ----------------------------------------------------------------------------- // IRR // ----------------------------------------------------------------------------- const DEFAULT_RATE_GUESS = 0.1; const IRR = { description: _t("Internal rate of return given periodic cashflows."), args: [ arg("cashflow_amounts (number, range)", _t("An array or range containing the income or payments associated with the investment.")), arg(`rate_guess (number, default=${DEFAULT_RATE_GUESS})`, _t("An estimate for what the internal rate of return will be.")), ], compute: function (cashFlowAmounts, rateGuess = { value: DEFAULT_RATE_GUESS }) { const _rateGuess = toNumber(rateGuess, this.locale); assertRateGuessStrictlyGreaterThanMinusOne(_rateGuess); // check that values contains at least one positive value and one negative value // and extract number present in the cashFlowAmount argument let positive = false; let negative = false; let amounts = []; visitNumbers([cashFlowAmounts], ({ value: amount }) => { if (amount > 0) positive = true; if (amount < 0) negative = true; amounts.push(amount); }, this.locale); assert(() => positive && negative, _t("The cashflow_amounts must include negative and positive values.")); const firstAmount = amounts.shift(); // The result of IRR is the rate at which the NPV() function will return zero with the given values. // This algorithm uses the Newton's method on the NPV function to determine the result // Newton's method: https://en.wikipedia.org/wiki/Newton%27s_method // As the NPV function isn't continuous, we apply the Newton's method on the numerator of the NPV formula. function npvNumerator(rate, startValue, values) { const nbrValue = values.length; let i = 0; return values.reduce((acc, v) => { i++; return acc + v * rate ** (nbrValue - i); }, startValue * rate ** nbrValue); } function npvNumeratorDeriv(rate, startValue, values) { const nbrValue = values.length; let i = 0; return values.reduce((acc, v) => { i++; return acc + v * (nbrValue - i) * rate ** (nbrValue - i - 1); }, startValue * nbrValue * rate ** (nbrValue - 1)); } function func(x) { return npvNumerator(x, firstAmount, amounts); } function derivFunc(x) { return npvNumeratorDeriv(x, firstAmount, amounts); } return { value: newtonMethod(func, derivFunc, _rateGuess + 1, 20, 1e-5) - 1, format: "0%", }; }, isExported: true, }; // ----------------------------------------------------------------------------- // ISPMT // ----------------------------------------------------------------------------- const ISPMT = { description: _t("Returns the interest paid at a particular period of an investment."), args: [ arg("rate (number)", _t("The interest rate.")), arg("period (number)", _t("The period for which you want to view the interest payment.")), arg("number_of_periods (number)", _t("The number of payments to be made.")), arg("present_value (number)", _t("The current value of the annuity.")), ], compute: function (rate, currentPeriod, numberOfPeriods, presentValue) { const interestRate = toNumber(rate, this.locale); const period = toNumber(currentPeriod, this.locale); const nOfPeriods = toNumber(numberOfPeriods, this.locale); const investment = toNumber(presentValue, this.locale); assert(() => nOfPeriods !== 0, _t("The number of periods must be different than 0.", nOfPeriods.toString())); const currentInvestment = investment - investment * (period / nOfPeriods); return -1 * currentInvestment * interestRate; }, isExported: true, }; // ----------------------------------------------------------------------------- // MDURATION // ----------------------------------------------------------------------------- const MDURATION = { description: _t("Modified Macaulay duration."), args: [ arg("settlement (date)", _t("The settlement date of the security, the date after issuance when the security is delivered to the buyer.")), arg("maturity (date)", _t("The maturity or end date of the security, when it can be redeemed at face, or par value.")), arg("rate (number)", _t("The annualized rate of interest.")), arg("yield (number)", _t("The expected annual yield of the security.")), arg("frequency (number)", _t("The number of interest or coupon payments per year (1, 2, or 4).")), arg(`day_count_convention (number, default=${DEFAULT_DAY_COUNT_CONVENTION} )`, _t("An indicator of what day count method to use.")), ], compute: function (settlement, maturity, rate, securityYield, frequency, dayCountConvention = { value: DEFAULT_DAY_COUNT_CONVENTION }) { const duration = DURATION.compute.bind(this)(settlement, maturity, rate, securityYield, frequency, dayCountConvention); const y = toNumber(securityYield, this.locale); const k = Math.trunc(toNumber(frequency, this.locale)); return duration / (1 + y / k); }, isExported: true, }; // ----------------------------------------------------------------------------- // MIRR // ----------------------------------------------------------------------------- const MIRR = { description: _t("Modified internal rate of return."), args: [ arg("cashflow_amounts (range)", _t("A range containing the income or payments associated with the investment. The array should contain bot payments and incomes.")), arg("financing_rate (number)", _t("The interest rate paid on funds invested.")), arg("reinvestment_return_rate (number)", _t("The return (as a percentage) earned on reinvestment of income received from the investment.")), ], compute: function (cashflowAmount, financingRate, reinvestmentRate) { const fRate = toNumber(financingRate, this.locale); const rRate = toNumber(reinvestmentRate, this.locale); const cashFlow = transposeMatrix(cashflowAmount) .flat() .filter((t) => t.value !== null) .map((val) => toNumber(val, this.locale)); const n = cashFlow.length; /** * https://en.wikipedia.org/wiki/Modified_internal_rate_of_return * * / FV(positive cash flows, reinvestment rate) \ ^ (1 / (n - 1)) * MIRR = | ___________________________________________ | - 1 * \ - PV(negative cash flows, finance rate) / * * with n the number of cash flows. * * You can compute FV and PV as : * * FV = SUM [ (cashFlow[i]>0 ? cashFlow[i] : 0) * (1 + rRate)**(n - i-1) ] * i= 0 => n * * PV = SUM [ (cashFlow[i]<0 ? cashFlow[i] : 0) / (1 + fRate)**i ] * i= 0 => n */ let fv = 0; let pv = 0; for (const i of range(0, n)) { const amount = cashFlow[i]; if (amount >= 0) { fv += amount * (rRate + 1) ** (n - i - 1); } else { pv += amount / (fRate + 1) ** i; } } assert(() => pv !== 0 && fv !== 0, _t("There must be both positive and negative values in cashflow_amounts.")); const exponent = 1 / (n - 1); return (-fv / pv) ** exponent - 1; }, isExported: true, }; // ----------------------------------------------------------------------------- // NOMINAL // ----------------------------------------------------------------------------- const NOMINAL = { description: _t("Annual nominal interest rate."), args: [ arg("effective_rate (number)", _t("The effective interest rate per year.")), arg("periods_per_year (number)", _t("The number of compounding periods per year.")), ], compute: function (effective_rate, periods_per_year) { const effective = toNumber(effective_rate, this.locale); const periods = Math.trunc(toNumber(periods_per_year, this.locale)); assert(() => effective > 0, _t("The effective rate (%s) must must strictly greater than 0.", effective.toString())); assert(() => periods > 0, _t("The number of periods by year (%s) must strictly greater than 0.", periods.toString())); // https://en.wikipedia.org/wiki/Nominal_interest_rate#Nominal_versus_effective_interest_rate return (Math.pow(effective + 1, 1 / periods) - 1) * periods; }, isExported: true, }; // ----------------------------------------------------------------------------- // NPER // ----------------------------------------------------------------------------- const NPER = { description: _t("Number of payment periods for an investment."), args: [ arg("rate (number)", _t("The interest rate.")), arg("payment_amount (number)", _t("The amount of each payment made.")), arg("present_value (number)", _t("The current value of the annuity.")), arg(`future_value (number, default=${DEFAULT_FUTURE_VALUE})`, _t("The future value remaining after the final payment has been made.")), arg(`end_or_beginning (number, default=${DEFAULT_END_OR_BEGINNING})`, _t("Whether payments are due at the end (0) or beginning (1) of each period.")), ], compute: function (rate, paymentAmount, presentValue, futureValue = { value: DEFAULT_FUTURE_VALUE }, endOrBeginning = { value: DEFAULT_END_OR_BEGINNING }) { futureValue = futureValue || 0; endOrBeginning = endOrBeginning || 0; const r = toNumber(rate, this.locale); const p = toNumber(paymentAmount, this.locale); const pv = toNumber(presentValue, this.locale); const fv = toNumber(futureValue, this.locale); const t = toBoolean(endOrBeginning) ? 1 : 0; /** * https://wiki.documentfoundation.org/Documentation/Calc_Functions/NPER * * 0 = pv * (1 + r)^N + fv + [ p * (1 + r * t) * ((1 + r)^N - 1) ] / r * * We solve the equation for N: * * with C = [ p * (1 + r * t)] / r and * R = 1 + r * * => 0 = pv * R^N + C * R^N - C + fv * <=> (C - fv) = R^N * (pv + C) * <=> log[(C - fv) / (pv + C)] = N * log(R) */ if (r === 0) { return -(fv + pv) / p; } const c = (p * (1 + r * t)) / r; return Math.log((c - fv) / (pv + c)) / Math.log(1 + r); }, isExported: true, }; // ----------------------------------------------------------------------------- // NPV // ----------------------------------------------------------------------------- function npvResult(r, startValue, values, locale) { let i = 0; return reduceNumbers(values, (acc, v) => { i++; return acc + v / (1 + r) ** i; }, startValue, locale); } const NPV = { description: _t("The net present value of an investment based on a series of periodic cash flows and a discount rate."), args: [ arg("discount (number)", _t("The discount rate of the investment over one period.")), arg("cashflow1 (number, range)", _t("The first future cash flow.")), arg("cashflow2 (number, range, repeating)", _t("Additional future cash flows.")), ], // to do: replace by dollar format compute: function (discount, ...values) { const _discount = toNumber(discount, this.locale); assert(() => _discount !== -1, _t("The discount (%s) must be different from -1.", _discount.toString())); return { value: npvResult(_discount, 0, values, this.locale), format: "#,##0.00", }; }, isExported: true, }; // ----------------------------------------------------------------------------- // PDURATION // ----------------------------------------------------------------------------- const PDURATION = { description: _t("Computes the number of periods needed for an investment to reach a value."), args: [ arg("rate (number)", _t("The rate at which the investment grows each period.")), arg("present_value (number)", _t("The investment's current value.")), arg("future_value (number)", _t("The investment's desired future value.")), ], compute: function (rate, presentValue, futureValue) { const _rate = toNumber(rate, this.locale); const _presentValue = toNumber(presentValue, this.locale); const _futureValue = toNumber(futureValue, this.locale); assertRateStrictlyPositive(_rate); assert(() => _presentValue > 0, _t("The present_value (%s) must be strictly positive.", _presentValue.toString())); assert(() => _futureValue > 0, _t("The future_value (%s) must be strictly positive.", _futureValue.toString())); return (Math.log(_futureValue) - Math.log(_presentValue)) / Math.log(1 + _rate); }, isExported: true, }; // ----------------------------------------------------------------------------- // PMT // ----------------------------------------------------------------------------- function pmt(r, n, pv, fv, t) { assertNumberOfPeriodsStrictlyPositive(n); /** * https://wiki.documentfoundation.org/Documentation/Calc_Functions/PMT * * 0 = pv * (1 + r)^N + fv + [ p * (1 + r * t) * ((1 + r)^N - 1) ] / r * * We simply the equation for p */ if (r === 0) { return -(fv + pv) / n; } let payment = -(pv * (1 + r) ** n + fv); payment = (payment * r) / ((1 + r * t) * ((1 + r) ** n - 1)); return payment; } const PMT = { description: _t("Periodic payment for an annuity investment."), args: [ arg("rate (number)", _t("The annualized rate of interest.")), arg("number_of_periods (number)", _t("The number of payments to be made.")), arg("present_value (number)", _t("The current value of the annuity.")), arg(`future_value (number, default=${DEFAULT_FUTURE_VALUE})`, _t("The future value remaining after the final payment has been made.")), arg(`end_or_beginning (number, default=${DEFAULT_END_OR_BEGINNING})`, _t("Whether payments are due at the end (0) or beginning (1) of each period.")), ], compute: function (rate, numberOfPeriods, presentValue, futureValue = { value: DEFAULT_FUTURE_VALUE }, endOrBeginning = { value: DEFAULT_END_OR_BEGINNING }) { const n = toNumber(numberOfPeriods, this.locale); const r = toNumber(rate, this.locale); const t = toBoolean(endOrBeginning) ? 1 : 0; const fv = toNumber(futureValue, this.locale); const pv = toNumber(presentValue, this.locale); return { value: pmt(r, n, pv, fv, t), format: "#,##0.00" }; }, isExported: true, }; // ----------------------------------------------------------------------------- // PPMT // ----------------------------------------------------------------------------- function ppmt(r, per, n, pValue, fValue, t) { assertNumberOfPeriodsStrictlyPositive(n); assert(() => per > 0 && per <= n, _t("The period must be between 1 and number_of_periods (%s)", n)); const payment = pmt(r, n, pValue, fValue, t); if (t === 1 && per === 1) return payment; const eqPeriod = t === 0 ? per - 1 : per - 2; const eqPv = pValue + payment * t; const capitalAtPeriod = -fv(r, eqPeriod, payment, eqPv, 0); const currentInterest = capitalAtPeriod * r; return payment + currentInterest; } const PPMT = { description: _t("Payment on the principal of an investment."), args: [ arg("rate (number)", _t("The annualized rate of interest.")), arg("period (number)", _t("The amortization period, in terms of number of periods.")), arg("number_of_periods (number)", _t("The number of payments to be made.")), arg("present_value (number)", _t("The current value of the annuity.")), arg(`future_value (number, default=${DEFAULT_FUTURE_VALUE})`, _t("The future value remaining after the final payment has been made.")), arg(`end_or_beginning (number, default=${DEFAULT_END_OR_BEGINNING})`, _t("Whether payments are due at the end (0) or beginning (1) of each period.")), ], compute: function (rate, currentPeriod, numberOfPeriods, presentValue, futureValue = { value: DEFAULT_FUTURE_VALUE }, endOrBeginning = { value: DEFAULT_END_OR_BEGINNING }) { const n = toNumber(numberOfPeriods, this.locale); const r = toNumber(rate, this.locale); const period = toNumber(currentPeriod, this.locale); const type = toBoolean(endOrBeginning) ? 1 : 0; const fv = toNumber(futureValue, this.locale); const pv = toNumber(presentValue, this.locale); return { value: ppmt(r, period, n, pv, fv, type), format: "#,##0.00", }; }, isExported: true, }; // ----------------------------------------------------------------------------- // PV // ----------------------------------------------------------------------------- const PV = { description: _t("Present value of an annuity investment."), args: [ arg("rate (number)", _t("The interest rate.")), arg("number_of_periods (number)", _t("The number of payments to be made.")), arg("payment_amount (number)", _t("The amount per period to be paid.")), arg(`future_value (number, default=${DEFAULT_FUTURE_VALUE})`, _t("The future value remaining after the final payment has been made.")), arg(`end_or_beginning (number, default=${DEFAULT_END_OR_BEGINNING})`, _t("Whether payments are due at the end (0) or beginning (1) of each period.")), ], // to do: replace by dollar format compute: function (rate, numberOfPeriods, paymentAmount, futureValue = { value: DEFAULT_FUTURE_VALUE }, endOrBeginning = { value: DEFAULT_END_OR_BEGINNING }) { futureValue = futureValue || 0; endOrBeginning = endOrBeginning || 0; const r = toNumber(rate, this.locale); const n = toNumber(numberOfPeriods, this.locale); const p = toNumber(paymentAmount, this.locale); const fv = toNumber(futureValue, this.locale); const type = toBoolean(endOrBeginning) ? 1 : 0; // https://wiki.documentfoundation.org/Documentation/Calc_Functions/PV return { value: r ? -((p * (1 + r * type) * ((1 + r) ** n - 1)) / r + fv) / (1 + r) ** n : -(fv + p * n), format: "#,##0.00", }; }, isExported: true, }; // ----------------------------------------------------------------------------- // PRICE // ----------------------------------------------------------------------------- const PRICE = { description: _t("Price of a security paying periodic interest."), args: [ arg("settlement (date)", _t("The settlement date of the security, the date after issuance when the security is delivered to the buyer.")), arg("maturity (date)", _t("The maturity or end date of the security, when it can be redeemed at face, or par value.")), arg("rate (number)", _t("The annualized rate of interest.")), arg("yield (number)", _t("The expected annual yield of the security.")), arg("redemption (number)", _t("The redemption amount per 100 face value, or par.")), arg("frequency (number)", _t("The number of interest or coupon payments per year (1, 2, or 4).")), arg(`day_count_convention (number, default=${DEFAULT_DAY_COUNT_CONVENTION} )`, _t("An indicator of what day count method to use.")), ], compute: function (settlement, maturity, rate, securityYield, redemption, frequency, dayCountConvention = { value: DEFAULT_DAY_COUNT_CONVENTION }) { dayCountConvention = dayCountConvention || 0; const _settlement = Math.trunc(toNumber(settlement, this.locale)); const _maturity = Math.trunc(toNumber(maturity, this.locale)); const _rate = toNumber(rate, this.locale); const _yield = toNumber(securityYield, this.locale); const _redemption = toNumber(redemption, this.locale); const _frequency = Math.trunc(toNumber(frequency, this.locale)); const _dayCountConvention = Math.trunc(toNumber(dayCountConvention, this.locale)); assertMaturityAndSettlementDatesAreValid(_settlement, _maturity); assertCouponFrequencyIsValid(_frequency); assertDayCountConventionIsValid(_dayCountConvention); assert(() => _rate >= 0, _t("The rate (%s) must be positive or null.", _rate.toString())); assert(() => _yield >= 0, _t("The yield (%s) must be positive or null.", _yield.toString())); assertRedemptionStrictlyPositive(_redemption); const years = getYearFrac(_settlement, _maturity, _dayCountConvention); const nbrRealCoupons = years * _frequency; const nbrFullCoupons = Math.ceil(nbrRealCoupons); const timeFirstCoupon = nbrRealCoupons - Math.floor(nbrRealCoupons) || 1; const yieldFactorPerPeriod = 1 + _yield / _frequency; const cashFlowFromCoupon = (100 * _rate) / _frequency; if (nbrFullCoupons === 1) { return ((cashFlowFromCoupon + _redemption) / ((timeFirstCoupon * _yield) / _frequency + 1) - cashFlowFromCoupon * (1 - timeFirstCoupon)); } let cashFlowsPresentValue = 0; for (let i = 1; i <= nbrFullCoupons; i++) { cashFlowsPresentValue += cashFlowFromCoupon / yieldFactorPerPeriod ** (i - 1 + timeFirstCoupon); } const redemptionPresentValue = _redemption / yieldFactorPerPeriod ** (nbrFullCoupons - 1 + timeFirstCoupon); return (redemptionPresentValue + cashFlowsPresentValue - cashFlowFromCoupon * (1 - timeFirstCoupon)); }, isExported: true, }; // ----------------------------------------------------------------------------- // PRICEDISC // ----------------------------------------------------------------------------- const PRICEDISC = { description: _t("Price of a discount security."), args: [ arg("settlement (date)", _t("The settlement date of the security, the date after issuance when the security is delivered to the buyer.")), arg("maturity (date)", _t("The maturity or end date of the security, when it can be redeemed at face, or par value.")), arg("discount (number)", _t("The discount rate of the security at time of purchase.")), arg("redemption (number)", _t("The redemption amount per 100 face value, or par.")), arg(`day_count_convention (number, default=${DEFAULT_DAY_COUNT_CONVENTION} )`, _t("An indicator of what day count method to use.")), ], compute: function (settlement, maturity, discount, redemption, dayCountConvention = { value: DEFAULT_DAY_COUNT_CONVENTION }) { dayCountConvention = dayCountConvention || 0; const _settlement = Math.trunc(toNumber(settlement, this.locale)); const _maturity = Math.trunc(toNumber(maturity, this.locale)); const _discount = toNumber(discount, this.locale); const _redemption = toNumber(redemption, this.locale); const _dayCountConvention = Math.trunc(toNumber(dayCountConvention, this.locale)); assertMaturityAndSettlementDatesAreValid(_settlement, _maturity); assertDayCountConventionIsValid(_dayCountConvention); assertDiscountStrictlyPositive(_discount); assertRedemptionStrictlyPositive(_redemption); /** * https://support.microsoft.com/en-us/office/pricedisc-function-d06ad7c1-380e-4be7-9fd9-75e3079acfd3 * * B = number of days in year, depending on year basis * DSM = number of days from settlement to maturity * * PRICEDISC = redemption - discount * redemption * (DSM/B) */ const yearsFrac = getYearFrac(_settlement, _maturity, _dayCountConvention); return _redemption - _discount * _redemption * yearsFrac; }, isExported: true, }; // ----------------------------------------------------------------------------- // PRICEMAT // ----------------------------------------------------------------------------- const PRICEMAT = { description: _t("Calculates the price of a security paying interest at maturity, based on expected yield."), args: [ arg("settlement (date)", _t("The settlement date of the security, the date after issuance when the security is delivered to the buyer.")), arg("maturity (date)", _t("The maturity or end date of the security, when it can be redeemed at face, or par value.")), arg("issue (date)", _t("The date the security was initially issued.")), arg("rate (number)", _t("The annualized rate of interest.")), arg("yield (number)", _t("The expected annual yield of the security.")), arg(`day_count_convention (number, default=${DEFAULT_DAY_COUNT_CONVENTION} )`, _t("An indicator of what day count method to use.")), ], compute: function (settlement, maturity, issue, rate, securityYield, dayCountConvention = { value: DEFAULT_DAY_COUNT_CONVENTION }) { dayCountConvention = dayCountConvention || 0; const _settlement = Math.trunc(toNumber(settlement, this.locale)); const _maturity = Math.trunc(toNumber(maturity, this.locale)); const _issue = Math.trunc(toNumber(issue, this.locale)); const _rate = toNumber(rate, this.locale); const _yield = toNumber(securityYield, this.locale); const _dayCount = Math.trunc(toNumber(dayCountConvention, this.locale)); assertSettlementAndIssueDatesAreValid(_settlement, _issue); assertMaturityAndSettlementDatesAreValid(_settlement, _maturity); assertDayCountConventionIsValid(_dayCount); assert(() => _rate >= 0, _t("The rate (%s) must be positive or null.", _rate.toString())); assert(() => _yield >= 0, _t("The yield (%s) must be positive or null.", _yield.toString())); /** * https://support.microsoft.com/en-us/office/pricemat-function-52c3b4da-bc7e-476a-989f-a95f675cae77 * * B = number of days in year, depending on year basis * DSM = number of days from settlement to maturity * DIM = number of days from issue to maturity * DIS = number of days from issue to settlement * * 100 + (DIM/B * rate * 100) * PRICEMAT = __________________________ - (DIS/B * rate * 100) * 1 + (DSM/B * yield) * * The ratios number_of_days / days_in_year are computed using the YEARFRAC function, that handle * differences due to day count conventions. * * Compatibility note : * * Contrary to GSheet and OpenOffice, Excel doesn't seems to always use its own YEARFRAC function * to compute PRICEMAT, and give different values for some combinations of dates and day count * conventions ( notably for leap years and dayCountConvention = 1 (Actual/Actual)). * * Our function PRICEMAT give us the same results as LibreOffice Calc. * Google Sheet use the formula with YEARFRAC, but its YEARFRAC function results are different * from the results of Excel/LibreOffice, thus we get different values with PRICEMAT. * */ const settlementToMaturity = getYearFrac(_settlement, _maturity, _dayCount); const issueToSettlement = getYearFrac(_settlement, _issue, _dayCount); const issueToMaturity = getYearFrac(_issue, _maturity, _dayCount); const numerator = 100 + issueToMaturity * _rate * 100; const denominator = 1 + settlementToMaturity * _yield; const term2 = issueToSettlement * _rate * 100; return numerator / denominator - term2; }, isExported: true, }; // ----------------------------------------------------------------------------- // RATE // ----------------------------------------------------------------------------- const RATE_GUESS_DEFAULT = 0.1; const RATE = { description: _t("Interest rate of an annuity investment."), args: [ arg("number_of_periods (number)", _t("The number of payments to be made.")), arg("payment_per_period (number)", _t("The amount per period to be paid.")), arg("present_value (number)", _t("The current value of the annuity.")), arg(`future_value (number, default=${DEFAULT_FUTURE_VALUE})`, _t("The future value remaining after the final payment has been made.")), arg(`end_or_beginning (number, default=${DEFAULT_END_OR_BEGINNING})`, _t("Whether payments are due at the end (0) or beginning (1) of each period.")), arg(`rate_guess (number, default=${RATE_GUESS_DEFAULT})`, _t("An estimate for what the interest rate will be.")), ], compute: function (numberOfPeriods, paymentPerPeriod, presentValue, futureValue = { value: DEFAULT_FUTURE_VALUE }, endOrBeginning = { value: DEFAULT_END_OR_BEGINNING }, rateGuess = { value: RATE_GUESS_DEFAULT }) { const n = toNumber(numberOfPeriods, this.locale); const payment = toNumber(paymentPerPeriod, this.locale); const type = toBoolean(endOrBeginning) ? 1 : 0; const guess = toNumber(rateGuess, this.locale) || RATE_GUESS_DEFAULT; let fv = toNumber(futureValue, this.locale); let pv = toNumber(presentValue, this.locale); assertNumberOfPeriodsStrictlyPositive(n); assert(() => [payment, pv, fv].some((val) => val > 0) && [payment, pv, fv].some((val) => val < 0), _t("There must be both positive and negative values in [payment_amount, present_value, future_value].", n.toString())); assertRateGuessStrictlyGreaterThanMinusOne(guess); fv -= payment * type; pv += payment * type; // https://github.com/apache/openoffice/blob/trunk/main/sc/source/core/tool/interpr2.cxx const func = (rate) => { const powN = Math.pow(1 + rate, n); const intResult = (powN - 1) / rate; return fv + pv * powN + payment * intResult; }; const derivFunc = (rate) => { const powNMinus1 = Math.pow(1 + rate, n - 1); const powN = Math.pow(1 + rate, n); const intResult = (powN - 1) / rate; const intResultDeriv = (n * powNMinus1) / rate - intResult / rate; const fTermDerivation = pv * n * powNMinus1 + payment * intResultDeriv; return fTermDerivation; }; return { value: newtonMethod(func, derivFunc, guess, 40, 1e-5), format: "0%", }; }, isExported: true, }; // ----------------------------------------------------------------------------- // RECEIVED // ----------------------------------------------------------------------------- const RECEIVED = { description: _t("Amount received at maturity for a security."), args: [ arg("settlement (date)", _t("The settlement date of the security, the date after issuance when the security is delivered to the buyer.")), arg("maturity (date)", _t("The maturity or end date of the security, when it can be redeemed at face, or par value.")), arg("investment (number)", _t("The amount invested (irrespective of face value of each security).")), arg("discount (number)", _t("The discount rate of the security invested in.")), arg(`day_count_convention (number, default=${DEFAULT_DAY_COUNT_CONVENTION} )`, _t("An indicator of what day count method to use.")), ], compute: function (settlement, maturity, investment, discount, dayCountConvention = { value: DEFAULT_DAY_COUNT_CONVENTION }) { dayCountConvention = dayCountConvention || 0; const _settlement = Math.trunc(toNumber(settlement, this.locale)); const _maturity = Math.trunc(toNumber(maturity, this.locale)); const _investment = toNumber(investment, this.locale); const _discount = toNumber(discount, this.locale); const _dayCountConvention = Math.trunc(toNumber(dayCountConvention, this.locale)); assertMaturityAndSettlementDatesAreValid(_settlement, _maturity); assertDayCountConventionIsValid(_dayCountConvention); assertInvestmentStrictlyPositive(_investment); assertDiscountStrictlyPositive(_discount); /** * https://support.microsoft.com/en-us/office/received-function-7a3f8b93-6611-4f81-8576-828312c9b5e5 * * investment * RECEIVED = _________________________ * 1 - discount * DSM / B * * with DSM = number of days from settlement to maturity and B = number of days in a year * * The ratio DSM/B can be computed with the YEARFRAC function to take the dayCountConvention into account. */ const yearsFrac = getYearFrac(_settlement, _maturity, _dayCountConvention); return _investment / (1 - _discount * yearsFrac); }, isExported: true, }; // ----------------------------------------------------------------------------- // RRI // ----------------------------------------------------------------------------- const RRI = { description: _t("Computes the rate needed for an investment to reach a specific value within a specific number of periods."), args: [ arg("number_of_periods (number)", _t("The number of periods.")), arg("present_value (number)", _t("The present value of the investment.")), arg("future_value (number)", _t("The future value of the investment.")), ], compute: function (numberOfPeriods, presentValue, futureValue) { const n = toNumber(numberOfPeriods, this.locale); const pv = toNumber(presentValue, this.locale); const fv = toNumber(futureValue, this.locale); assertNumberOfPeriodsStrictlyPositive(n); /** * https://support.microsoft.com/en-us/office/rri-function-6f5822d8-7ef1-4233-944c-79e8172930f4 * * RRI = (future value / present value) ^ (1 / number of periods) - 1 */ return (fv / pv) ** (1 / n) - 1; }, isExported: true, }; // ----------------------------------------------------------------------------- // SLN // ----------------------------------------------------------------------------- const SLN = { description: _t("Depreciation of an asset using the straight-line method."), args: [ arg("cost (number)", _t("The initial cost of the asset.")), arg("salvage (number)", _t("The value of the asset at the end of depreciation.")), arg("life (number)", _t("The number of periods over which the asset is depreciated.")), ], compute: function (cost, salvage, life) { const _cost = toNumber(cost, this.locale); const _salvage = toNumber(salvage, this.locale); const _life = toNumber(life, this.locale); // No assertion is done on the values of the arguments to be compatible with Excel/Gsheet that don't check the values. // It's up to the user to make sure the arguments make sense, which is good design because the user is smart. return { value: (_cost - _salvage) / _life, format: "#,##0.00", }; }, isExported: true, }; // ----------------------------------------------------------------------------- // SYD // ----------------------------------------------------------------------------- const SYD = { description: _t("Depreciation via sum of years digit method."), args: [ arg("cost (number)", _t("The initial cost of the asset.")), arg("salvage (number)", _t("The value of the asset at the end of depreciation.")), arg("life (number)", _t("The number of periods over which the asset is depreciated.")), arg("period (number)", _t("The single period within life for which to calculate depreciation.")), ], compute: function (cost, salvage, life, period) { const _cost = toNumber(cost, this.locale); const _salvage = toNumber(salvage, this.locale); const _life = toNumber(life, this.locale); const _period = toNumber(period, this.locale); assertPeriodStrictlyPositive(_period); assertLifeStrictlyPositive(_life); assertPeriodSmallerOrEqualToLife(_period, _life); /** * This deprecation method use the sum of digits of the periods of the life as the deprecation factor. * For example for a life = 5, we have a deprecation factor or 1 + 2 + 3 + 4 + 5 = 15 = life * (life + 1) / 2 = F. * * The deprecation for a period p is then computed based on F and the remaining lifetime at the period P. * * deprecation = (cost - salvage) * (number of remaining periods / F) */ const deprecFactor = (_life * (_life + 1)) / 2; const remainingPeriods = _life - _period + 1; return { value: (_cost - _salvage) * (remainingPeriods / deprecFactor), format: "#,##0.00", }; }, isExported: true, }; // ----------------------------------------------------------------------------- // TBILLPRICE // ----------------------------------------------------------------------------- function tBillPrice(start, end, disc) { /** * https://support.microsoft.com/en-us/office/tbillprice-function-eacca992-c29d-425a-9eb8-0513fe6035a2 * * TBILLPRICE = 100 * (1 - discount * DSM / 360) * * with DSM = number of days from settlement to maturity * * The ratio DSM/360 can be computed with the YEARFRAC function with dayCountConvention = 2 (actual/360). */ const yearFrac = getYearFrac(start, end, 2); return 100 * (1 - disc * yearFrac); } const TBILLPRICE = { description: _t("Price of a US Treasury bill."), args: [ arg("settlement (date)", _t("The settlement date of the security, the date after issuance when the security is delivered to the buyer.")), arg("maturity (date)", _t("The maturity or end date of the security, when it can be redeemed at face, or par value.")), arg("discount (number)", _t("The discount rate of the bill at time of purchase.")), ], compute: function (settlement, maturity, discount) { const start = Math.trunc(toNumber(settlement, this.locale)); const end = Math.trunc(toNumber(maturity, this.locale)); const disc = toNumber(discount, this.locale); assertMaturityAndSettlementDatesAreValid(start, end); assertSettlementLessThanOneYearBeforeMaturity(start, end, this.locale); assertDiscountStrictlyPositive(disc); assertDiscountStrictlySmallerThanOne(disc); return tBillPrice(start, end, disc); }, isExported: true, }; // ----------------------------------------------------------------------------- // TBILLEQ // ----------------------------------------------------------------------------- const TBILLEQ = { description: _t("Equivalent rate of return for a US Treasury bill."), args: [ arg("settlement (date)", _t("The settlement date of the security, the date after issuance when the security is delivered to the buyer.")), arg("maturity (date)", _t("The maturity or end date of the security, when it can be redeemed at face, or par value.")), arg("discount (number)", _t("The discount rate of the bill at time of purchase.")), ], compute: function (settlement, maturity, discount) { const start = Math.trunc(toNumber(settlement, this.locale)); const end = Math.trunc(toNumber(maturity, this.locale)); const disc = toNumber(discount, this.locale); assertMaturityAndSettlementDatesAreValid(start, end); assertSettlementLessThanOneYearBeforeMaturity(start, end, this.locale); assertDiscountStrictlyPositive(disc); assertDiscountStrictlySmallerThanOne(disc); /** * https://support.microsoft.com/en-us/office/tbilleq-function-2ab72d90-9b4d-4efe-9fc2-0f81f2c19c8c * * 365 * discount * TBILLEQ = ________________________ * 360 - discount * DSM * * with DSM = number of days from settlement to maturity * * What is not indicated in the Excel documentation is that this formula only works for duration between settlement * and maturity that are less than 6 months (182 days). This is because US Treasury bills use semi-annual interest, * and thus we have to take into account the compound interest for the calculation. * * For this case, the formula becomes (Treasury Securities and Derivatives, by Frank J. Fabozzi, page 49) * * -2X + 2* SQRT[ X² - (2X - 1) * (1 - 100/p) ] * TBILLEQ = ________________________________________________ * 2X - 1 * * with X = DSM / (number of days in a year), * and p is the price, computed with TBILLPRICE * * Note that from my tests in Excel, we take (number of days in a year) = 366 ONLY if DSM is 366, not if * the settlement year is a leap year. * */ const nDays = DAYS.compute.bind(this)({ value: end }, { value: start }); if (nDays <= 182) { return (365 * disc) / (360 - disc * nDays); } const p = tBillPrice(start, end, disc) / 100; const daysInYear = nDays === 366 ? 366 : 365; const x = nDays / daysInYear; const num = -2 * x + 2 * Math.sqrt(x ** 2 - (2 * x - 1) * (1 - 1 / p)); const denom = 2 * x - 1; return num / denom; }, isExported: true, }; // ----------------------------------------------------------------------------- // TBILLYIELD // ----------------------------------------------------------------------------- const TBILLYIELD = { description: _t("The yield of a US Treasury bill based on price."), args: [ arg("settlement (date)", _t("The settlement date of the security, the date after issuance when the security is delivered to the buyer.")), arg("maturity (date)", _t("The maturity or end date of the security, when it can be redeemed at face, or par value.")), arg("price (number)", _t("The price at which the security is bought per 100 face value.")), ], compute: function (settlement, maturity, price) { const start = Math.trunc(toNumber(settlement, this.locale)); const end = Math.trunc(toNumber(maturity, this.locale)); const p = toNumber(price, this.locale); assertMaturityAndSettlementDatesAreValid(start, end); assertSettlementLessThanOneYearBeforeMaturity(start, end, this.locale); assertPriceStrictlyPositive(p); /** * https://support.microsoft.com/en-us/office/tbillyield-function-6d381232-f4b0-4cd5-8e97-45b9c03468ba * * 100 - price 360 * TBILLYIELD = ____________ * _____ * price DSM * * with DSM = number of days from settlement to maturity * * The ratio DSM/360 can be computed with the YEARFRAC function with dayCountConvention = 2 (actual/360). * */ const yearFrac = getYearFrac(start, end, 2); return ((100 - p) / p) * (1 / yearFrac); }, isExported: true, }; // ----------------------------------------------------------------------------- // VDB // ----------------------------------------------------------------------------- const DEFAULT_VDB_NO_SWITCH = false; const VDB = { description: _t("Variable declining balance. WARNING : does not handle decimal periods."), args: [ arg("cost (number)", _t("The initial cost of the asset.")), arg("salvage (number)", _t("The value of the asset at the end of depreciation.")), arg("life (number)", _t("The number of periods over which the asset is depreciated.")), arg("start (number)", _t("Starting period to calculate depreciation.")), arg("end (number)", _t("Ending period to calculate depreciation.")), arg(`factor (number, default=${DEFAULT_DDB_DEPRECIATION_FACTOR})`, _t("The number of months in the first year of depreciation.")), arg(`no_switch (number, default=${DEFAULT_VDB_NO_SWITCH})`, _t("Whether to switch to straight-line depreciation when the depreciation is greater than the declining balance calculation.")), ], compute: function (cost, salvage, life, startPeriod, endPeriod, factor = { value: DEFAULT_DDB_DEPRECIATION_FACTOR }, noSwitch = { value: DEFAULT_VDB_NO_SWITCH }) { factor = factor || 0; const _cost = toNumber(cost, this.locale); const _salvage = toNumber(salvage, this.locale); const _life = toNumber(life, this.locale); /* TODO : handle decimal periods * on end_period it looks like it is a simple linear function, but I cannot understand exactly how * decimals periods are handled with start_period. */ const _startPeriod = Math.trunc(toNumber(startPeriod, this.locale)); const _endPeriod = Math.trunc(toNumber(endPeriod, this.locale)); const _factor = toNumber(factor, this.locale); const _noSwitch = toBoolean(noSwitch); assertCostPositiveOrZero(_cost); assertSalvagePositiveOrZero(_salvage); assertStartAndEndPeriodAreValid(_startPeriod, _endPeriod, _life); assertDeprecationFactorStrictlyPositive(_factor); if (_cost === 0) return 0; if (_salvage >= _cost) { return _startPeriod < 1 ? _cost - _salvage : 0; } const doubleDeprecFactor = _factor / _life; if (doubleDeprecFactor >= 1) { return _startPeriod < 1 ? _cost - _salvage : 0; } let previousCost = _cost; let currentDeprec = 0; let resultDeprec = 0; let isLinearDeprec = false; for (let i = 0; i < _endPeriod; i++) { // compute the current deprecation, or keep the last one if we reached a stage of linear deprecation if (!isLinearDeprec || _noSwitch) { const doubleDeprec = previousCost * doubleDeprecFactor; const remainingPeriods = _life - i; const linearDeprec = (previousCost - _salvage) / remainingPeriods; if (!_noSwitch && linearDeprec > doubleDeprec) { isLinearDeprec = true; currentDeprec = linearDeprec; } else { currentDeprec = doubleDeprec; } } const nextCost = Math.max(previousCost - currentDeprec, _salvage); if (i >= _startPeriod) { resultDeprec += previousCost - nextCost; } previousCost = nextCost; } return resultDeprec; }, isExported: true, }; // ----------------------------------------------------------------------------- // XIRR // ----------------------------------------------------------------------------- const XIRR = { description: _t("Internal rate of return given non-periodic cash flows."), args: [ arg("cashflow_amounts (range)", _t("An range containing the income or payments associated with the investment.")), arg("cashflow_dates (range)", _t("An range with dates corresponding to the cash flows in cashflow_amounts.")), arg(`rate_guess (number, default=${RATE_GUESS_DEFAULT})`, _t("An estimate for what the internal rate of return will be.")), ], compute: function (cashflowAmounts, cashflowDates, rateGuess = { value: RATE_GUESS_DEFAULT }) { const guess = toNumber(rateGuess, this.locale); const _cashFlows = cashflowAmounts.flat().map((val) => toNumber(val, this.locale)); const _dates = cashflowDates.flat().map((val) => toNumber(val, this.locale)); assertCashFlowsAndDatesHaveSameDimension(cashflowAmounts, cashflowDates); assertCashFlowsHavePositiveAndNegativesValues(_cashFlows); assertEveryDateGreaterThanFirstDateOfCashFlowDates(_dates); assertRateGuessStrictlyGreaterThanMinusOne(guess); const map = new Map(); for (const i of range(0, _dates.length)) { const date = _dates[i]; if (map.has(date)) map.set(date, map.get(date) + _cashFlows[i]); else map.set(date, _cashFlows[i]); } const dates = Array.from(map.keys()); const values = dates.map((date) => map.get(date)); /** * https://support.microsoft.com/en-us/office/xirr-function-de1242ec-6477-445b-b11b-a303ad9adc9d * * The rate is computed iteratively by trying to solve the equation * * * 0 = SUM [ P_i * (1 + rate) ^((d_0 - d_i) / 365) ] + P_0 * i = 1 => n * * with P_i = price number i * d_i = date number i * * This function is not defined for rate < -1. For the case where we get rates < -1 in the Newton method, add * a fallback for a number very close to -1 to continue the Newton method. * */ const func = (rate) => { let value = values[0]; for (const i of range(1, values.length)) { const dateDiff = (dates[0] - dates[i]) / 365; value += values[i] * (1 + rate) ** dateDiff; } return value; }; const derivFunc = (rate) => { let deriv = 0; for (const i of range(1, values.length)) { const dateDiff = (dates[0] - dates[i]) / 365; deriv += dateDiff * values[i] * (1 + rate) ** (dateDiff - 1); } return deriv; }; const nanFallback = (previousFallback) => { // -0.9 => -0.99 => -0.999 => ... if (!previousFallback) return -0.9; return previousFallback / 10 - 0.9; }; return newtonMethod(func, derivFunc, guess, 40, 1e-5, nanFallback); }, isExported: true, }; // ----------------------------------------------------------------------------- // XNPV // ----------------------------------------------------------------------------- const XNPV = { description: _t("Net present value given to non-periodic cash flows.."), args: [ arg("discount (number)", _t("The discount rate of the investment over one period.")), arg("cashflow_amounts (number, range)", _t("An range containing the income or payments associated with the investment.")), arg("cashflow_dates (number, range)", _t("An range with dates corresponding to the cash flows in cashflow_amounts.")), ], compute: function (discount, cashflowAmounts, cashflowDates) { const rate = toNumber(discount, this.locale); const _cashFlows = isMatrix(cashflowAmounts) ? cashflowAmounts.flat().map((data) => strictToNumber(data, this.locale)) : [strictToNumber(cashflowAmounts, this.locale)]; const _dates = isMatrix(cashflowDates) ? cashflowDates.flat().map((data) => strictToNumber(data, this.locale)) : [strictToNumber(cashflowDates, this.locale)]; if (isMatrix(cashflowDates) && isMatrix(cashflowAmounts)) { assertCashFlowsAndDatesHaveSameDimension(cashflowAmounts, cashflowDates); } else { assert(() => _cashFlows.length === _dates.length, _t("There must be the same number of values in cashflow_amounts and cashflow_dates.")); } assertEveryDateGreaterThanFirstDateOfCashFlowDates(_dates); assertRateStrictlyPositive(rate); if (_cashFlows.length === 1) return _cashFlows[0]; // aggregate values of the same date const map = new Map(); for (const i of range(0, _dates.length)) { const date = _dates[i]; if (map.has(date)) map.set(date, map.get(date) + _cashFlows[i]); else map.set(date, _cashFlows[i]); } const dates = Array.from(map.keys()); const values = dates.map((date) => map.get(date)); /** * https://support.microsoft.com/en-us/office/xirr-function-de1242ec-6477-445b-b11b-a303ad9adc9d * * The present value is computed using * * * NPV = SUM [ P_i *(1 + rate) ^((d_0 - d_i) / 365) ] + P_0 * i = 1 => n * * with P_i = price number i * d_i = date number i * * */ let pv = values[0]; for (const i of range(1, values.length)) { const dateDiff = (dates[0] - dates[i]) / 365; pv += values[i] * (1 + rate) ** dateDiff; } return pv; }, isExported: true, }; // ----------------------------------------------------------------------------- // YIELD // ----------------------------------------------------------------------------- const YIELD = { description: _t("Annual yield of a security paying periodic interest."), args: [ arg("settlement (date)", _t("The settlement date of the security, the date after issuance when the security is delivered to the buyer.")), arg("maturity (date)", _t("The maturity or end date of the security, when it can be redeemed at face, or par value.")), arg("rate (number)", _t("The annualized rate of interest.")), arg("price (number)", _t("The price at which the security is bought per 100 face value.")), arg("redemption (number)", _t("The redemption amount per 100 face value, or par.")), arg("frequency (number)", _t("The number of interest or coupon payments per year (1, 2, or 4).")), arg(`day_count_convention (number, default=${DEFAULT_DAY_COUNT_CONVENTION} )`, _t("An indicator of what day count method to use.")), ], compute: function (settlement, maturity, rate, price, redemption, frequency, dayCountConvention = { value: DEFAULT_DAY_COUNT_CONVENTION }) { dayCountConvention = dayCountConvention || 0; const _settlement = Math.trunc(toNumber(settlement, this.locale)); const _maturity = Math.trunc(toNumber(maturity, this.locale)); const _rate = toNumber(rate, this.locale); const _price = toNumber(price, this.locale); const _redemption = toNumber(redemption, this.locale); const _frequency = Math.trunc(toNumber(frequency, this.locale)); const _dayCountConvention = Math.trunc(toNumber(dayCountConvention, this.locale)); assertMaturityAndSettlementDatesAreValid(_settlement, _maturity); assertCouponFrequencyIsValid(_frequency); assertDayCountConventionIsValid(_dayCountConvention); assert(() => _rate >= 0, _t("The rate (%s) must be positive or null.", _rate.toString())); assertPriceStrictlyPositive(_price); assertRedemptionStrictlyPositive(_redemption); const years = getYearFrac(_settlement, _maturity, _dayCountConvention); const nbrRealCoupons = years * _frequency; const nbrFullCoupons = Math.ceil(nbrRealCoupons); const timeFirstCoupon = nbrRealCoupons - Math.floor(nbrRealCoupons) || 1; const cashFlowFromCoupon = (100 * _rate) / _frequency; if (nbrFullCoupons === 1) { const subPart = _price + cashFlowFromCoupon * (1 - timeFirstCoupon); return (((_redemption + cashFlowFromCoupon - subPart) * _frequency * (1 / timeFirstCoupon)) / subPart); } // The result of YIELD function is the yield at which the PRICE function will return the given price. // This algorithm uses the Newton's method on the PRICE function to determine the result. // Newton's method: https://en.wikipedia.org/wiki/Newton%27s_method // As the PRICE function isn't continuous, we apply the Newton's method on the numerator of the PRICE formula. // For simplicity, it is not yield but yieldFactorPerPeriod (= 1 + yield / frequency) which will be calibrated in Newton's method. // yield can be deduced from yieldFactorPerPeriod in sequence. function priceNumerator(price, timeFirstCoupon, nbrFullCoupons, yieldFactorPerPeriod, cashFlowFromCoupon, redemption) { let result = redemption - (price + cashFlowFromCoupon * (1 - timeFirstCoupon)) * yieldFactorPerPeriod ** (nbrFullCoupons - 1 + timeFirstCoupon); for (let i = 1; i <= nbrFullCoupons; i++) { result += cashFlowFromCoupon * yieldFactorPerPeriod ** (i - 1); } return result; } function priceNumeratorDeriv(price, timeFirstCoupon, nbrFullCoupons, yieldFactorPerPeriod, cashFlowFromCoupon) { let result = -(price + cashFlowFromCoupon * (1 - timeFirstCoupon)) * (nbrFullCoupons - 1 + timeFirstCoupon) * yieldFactorPerPeriod ** (nbrFullCoupons - 2 + timeFirstCoupon); for (let i = 1; i <= nbrFullCoupons; i++) { result += cashFlowFromCoupon * (i - 1) * yieldFactorPerPeriod ** (i - 2); } return result; } function func(x) { return priceNumerator(_price, timeFirstCoupon, nbrFullCoupons, x, cashFlowFromCoupon, _redemption); } function derivFunc(x) { return priceNumeratorDeriv(_price, timeFirstCoupon, nbrFullCoupons, x, cashFlowFromCoupon); } const initYield = _rate + 1; const initYieldFactorPerPeriod = 1 + initYield / _frequency; const methodResult = newtonMethod(func, derivFunc, initYieldFactorPerPeriod, 100, 1e-5); return (methodResult - 1) * _frequency; }, isExported: true, }; // ----------------------------------------------------------------------------- // YIELDDISC // ----------------------------------------------------------------------------- const YIELDDISC = { description: _t("Annual yield of a discount security."), args: [ arg("settlement (date)", _t("The settlement date of the security, the date after issuance when the security is delivered to the buyer.")), arg("maturity (date)", _t("The maturity or end date of the security, when it can be redeemed at face, or par value.")), arg("price (number)", _t("The price at which the security is bought per 100 face value.")), arg("redemption (number)", _t("The redemption amount per 100 face value, or par.")), arg(`day_count_convention (number, default=${DEFAULT_DAY_COUNT_CONVENTION} )`, _t("An indicator of what day count method to use.")), ], compute: function (settlement, maturity, price, redemption, dayCountConvention = { value: DEFAULT_DAY_COUNT_CONVENTION }) { dayCountConvention = dayCountConvention || 0; const _settlement = Math.trunc(toNumber(settlement, this.locale)); const _maturity = Math.trunc(toNumber(maturity, this.locale)); const _price = toNumber(price, this.locale); const _redemption = toNumber(redemption, this.locale); const _dayCountConvention = Math.trunc(toNumber(dayCountConvention, this.locale)); assertMaturityAndSettlementDatesAreValid(_settlement, _maturity); assertDayCountConventionIsValid(_dayCountConvention); assertPriceStrictlyPositive(_price); assertRedemptionStrictlyPositive(_redemption); /** * https://wiki.documentfoundation.org/Documentation/Calc_Functions/YIELDDISC * * (redemption / price) - 1 * YIELDDISC = _____________________________________ * YEARFRAC(settlement, maturity, basis) */ const yearFrac = getYearFrac(_settlement, _maturity, _dayCountConvention); return (_redemption / _price - 1) / yearFrac; }, isExported: true, }; // ----------------------------------------------------------------------------- // YIELDMAT // ----------------------------------------------------------------------------- const YIELDMAT = { description: _t("Annual yield of a security paying interest at maturity."), args: [ arg("settlement (date)", _t("The settlement date of the security, the date after issuance when the security is delivered to the buyer.")), arg("maturity (date)", _t("The maturity or end date of the security, when it can be redeemed at face, or par value.")), arg("issue (date)", _t("The date the security was initially issued.")), arg("rate (number)", _t("The annualized rate of interest.")), arg("price (number)", _t("The price at which the security is bought.")), arg(`day_count_convention (number, default=${DEFAULT_DAY_COUNT_CONVENTION} )`, _t("An indicator of what day count method to use.")), ], compute: function (settlement, maturity, issue, rate, price, dayCountConvention = { value: DEFAULT_DAY_COUNT_CONVENTION }) { dayCountConvention = dayCountConvention || 0; const _settlement = Math.trunc(toNumber(settlement, this.locale)); const _maturity = Math.trunc(toNumber(maturity, this.locale)); const _issue = Math.trunc(toNumber(issue, this.locale)); const _rate = toNumber(rate, this.locale); const _price = toNumber(price, this.locale); const _dayCountConvention = Math.trunc(toNumber(dayCountConvention, this.locale)); assertMaturityAndSettlementDatesAreValid(_settlement, _maturity); assertDayCountConventionIsValid(_dayCountConvention); assert(() => _settlement >= _issue, _t("The settlement (%s) must be greater than or equal to the issue (%s).", _settlement.toString(), _issue.toString())); assert(() => _rate >= 0, _t("The rate (%s) must be positive or null.", _rate.toString())); assertPriceStrictlyPositive(_price); const issueToMaturity = getYearFrac(_issue, _maturity, _dayCountConvention); const issueToSettlement = getYearFrac(_issue, _settlement, _dayCountConvention); const settlementToMaturity = getYearFrac(_settlement, _maturity, _dayCountConvention); const numerator = (100 * (1 + _rate * issueToMaturity)) / (_price + 100 * _rate * issueToSettlement) - 1; return numerator / settlementToMaturity; }, isExported: true, }; var financial = /*#__PURE__*/Object.freeze({ __proto__: null, ACCRINTM: ACCRINTM, AMORLINC: AMORLINC, COUPDAYBS: COUPDAYBS, COUPDAYS: COUPDAYS, COUPDAYSNC: COUPDAYSNC, COUPNCD: COUPNCD, COUPNUM: COUPNUM, COUPPCD: COUPPCD, CUMIPMT: CUMIPMT, CUMPRINC: CUMPRINC, DB: DB, DDB: DDB, DISC: DISC, DOLLARDE: DOLLARDE, DOLLARFR: DOLLARFR, DURATION: DURATION, EFFECT: EFFECT, FV: FV, FVSCHEDULE: FVSCHEDULE, INTRATE: INTRATE, IPMT: IPMT, IRR: IRR, ISPMT: ISPMT, MDURATION: MDURATION, MIRR: MIRR, NOMINAL: NOMINAL, NPER: NPER, NPV: NPV, PDURATION: PDURATION, PMT: PMT, PPMT: PPMT, PRICE: PRICE, PRICEDISC: PRICEDISC, PRICEMAT: PRICEMAT, PV: PV, RATE: RATE, RECEIVED: RECEIVED, RRI: RRI, SLN: SLN, SYD: SYD, TBILLEQ: TBILLEQ, TBILLPRICE: TBILLPRICE, TBILLYIELD: TBILLYIELD, VDB: VDB, XIRR: XIRR, XNPV: XNPV, YIELD: YIELD, YIELDDISC: YIELDDISC, YIELDMAT: YIELDMAT }); var State; (function (State) { /** * Initial state. * Expecting any reference for the left part of a range * e.g. "A1", "1", "A", "Sheet1!A1", "Sheet1!A" */ State[State["LeftRef"] = 0] = "LeftRef"; /** * Expecting any reference for the right part of a range * e.g. "A1", "1", "A", "Sheet1!A1", "Sheet1!A" */ State[State["RightRef"] = 1] = "RightRef"; /** * Expecting the separator without any constraint on the right part */ State[State["Separator"] = 2] = "Separator"; /** * Expecting the separator for a full column range */ State[State["FullColumnSeparator"] = 3] = "FullColumnSeparator"; /** * Expecting the separator for a full row range */ State[State["FullRowSeparator"] = 4] = "FullRowSeparator"; /** * Expecting the right part of a full column range * e.g. "1", "A1" */ State[State["RightColumnRef"] = 5] = "RightColumnRef"; /** * Expecting the right part of a full row range * e.g. "A", "A1" */ State[State["RightRowRef"] = 6] = "RightRowRef"; /** * Final state. A range has been matched */ State[State["Found"] = 7] = "Found"; })(State || (State = {})); const goTo = (state, guard = () => true) => [ { goTo: state, guard, }, ]; const goToMulti = (state, guard = () => true) => ({ goTo: state, guard, }); const machine = { [State.LeftRef]: { REFERENCE: goTo(State.Separator), NUMBER: goTo(State.FullRowSeparator), SYMBOL: [ goToMulti(State.FullColumnSeparator, (token) => isColReference(token.value)), goToMulti(State.FullRowSeparator, (token) => isRowReference(token.value)), ], }, [State.FullColumnSeparator]: { SPACE: goTo(State.FullColumnSeparator), OPERATOR: goTo(State.RightColumnRef, (token) => token.value === ":"), }, [State.FullRowSeparator]: { SPACE: goTo(State.FullRowSeparator), OPERATOR: goTo(State.RightRowRef, (token) => token.value === ":"), }, [State.Separator]: { SPACE: goTo(State.Separator), OPERATOR: goTo(State.RightRef, (token) => token.value === ":"), }, [State.RightRef]: { SPACE: goTo(State.RightRef), NUMBER: goTo(State.Found), REFERENCE: goTo(State.Found, (token) => isSingleCellReference(token.value)), SYMBOL: goTo(State.Found, (token) => isColHeader(token.value) || isRowHeader(token.value)), }, [State.RightColumnRef]: { SPACE: goTo(State.RightColumnRef), SYMBOL: goTo(State.Found, (token) => isColHeader(token.value)), REFERENCE: goTo(State.Found, (token) => isSingleCellReference(token.value)), }, [State.RightRowRef]: { SPACE: goTo(State.RightRowRef), NUMBER: goTo(State.Found), REFERENCE: goTo(State.Found, (token) => isSingleCellReference(token.value)), SYMBOL: goTo(State.Found, (token) => isRowHeader(token.value)), }, [State.Found]: {}, }; /** * Check if the list of tokens starts with a sequence of tokens representing * a range. * If a range is found, the sequence is removed from the list and is returned * as a single token. */ function matchReference(tokens) { let head = 0; let transitions = machine[State.LeftRef]; let matchedTokens = ""; while (transitions !== undefined) { const token = tokens[head++]; if (!token) { return null; } const transition = transitions[token.type]?.find((transition) => transition.guard(token)); const nextState = transition ? transition.goTo : undefined; switch (nextState) { case undefined: return null; case State.Found: matchedTokens += token.value; tokens.splice(0, head); return { type: "REFERENCE", value: matchedTokens, }; default: transitions = machine[nextState]; matchedTokens += token.value; break; } } return null; } /** * Take the result of the tokenizer and transform it to be usable in the * manipulations of range * * @param formula */ function rangeTokenize(formula, locale = DEFAULT_LOCALE) { const tokens = tokenize(formula, locale); const result = []; while (tokens.length) { result.push(matchReference(tokens) || tokens.shift()); } return result; } const functionRegex = /[a-zA-Z0-9\_]+(\.[a-zA-Z0-9\_]+)*/; const UNARY_OPERATORS_PREFIX = ["-", "+"]; const UNARY_OPERATORS_POSTFIX = ["%"]; const ASSOCIATIVE_OPERATORS = ["*", "+", "&"]; const OP_PRIORITY = { "^": 30, "%": 30, "*": 20, "/": 20, "+": 15, "-": 15, "&": 13, ">": 10, "<>": 10, ">=": 10, "<": 10, "<=": 10, "=": 10, }; /** * Parse the next operand in an arithmetic expression. * e.g. * for 1+2*3, the next operand is 1 * for (1+2)*3, the next operand is (1+2) * for SUM(1,2)+3, the next operand is SUM(1,2) */ function parseOperand(tokens) { const current = tokens.shift(); if (!current) { throw new BadExpressionError(); } switch (current.type) { case "DEBUGGER": const next = parseExpression(tokens, 1000); next.debug = true; return next; case "NUMBER": return { type: "NUMBER", value: parseNumber(current.value, DEFAULT_LOCALE) }; case "STRING": return { type: "STRING", value: removeStringQuotes(current.value) }; case "INVALID_REFERENCE": return { type: "REFERENCE", value: CellErrorType.InvalidReference, }; case "REFERENCE": if (tokens[0]?.value === ":" && tokens[1]?.type === "REFERENCE") { tokens.shift(); const rightReference = tokens.shift(); return { type: "REFERENCE", value: `${current.value}:${rightReference?.value}`, }; } return { type: "REFERENCE", value: current.value, }; case "SYMBOL": const value = current.value; const nextToken = tokens[0]; if (nextToken?.type === "LEFT_PAREN" && functionRegex.test(current.value) && value === unquote(value, "'")) { const args = parseFunctionArgs(tokens); return { type: "FUNCALL", value: value, args }; } const upperCaseValue = value.toUpperCase(); if (upperCaseValue === "TRUE" || upperCaseValue === "FALSE") { return { type: "BOOLEAN", value: upperCaseValue === "TRUE" }; } return { type: "SYMBOL", value: unquote(current.value, "'") }; case "LEFT_PAREN": const result = parseExpression(tokens); consumeOrThrow(tokens, "RIGHT_PAREN", _t("Missing closing parenthesis")); return result; case "OPERATOR": const operator = current.value; if (UNARY_OPERATORS_PREFIX.includes(operator)) { return { type: "UNARY_OPERATION", value: operator, operand: parseExpression(tokens, OP_PRIORITY[operator]), }; } throw new BadExpressionError(_t("Unexpected token: %s", current.value)); default: throw new BadExpressionError(_t("Unexpected token: %s", current.value)); } } function parseFunctionArgs(tokens) { consumeOrThrow(tokens, "LEFT_PAREN", _t("Missing opening parenthesis")); const nextToken = tokens[0]; if (nextToken?.type === "RIGHT_PAREN") { consumeOrThrow(tokens, "RIGHT_PAREN"); return []; } const args = []; args.push(parseOneFunctionArg(tokens)); while (tokens[0]?.type !== "RIGHT_PAREN") { consumeOrThrow(tokens, "ARG_SEPARATOR", _t("Wrong function call")); args.push(parseOneFunctionArg(tokens)); } consumeOrThrow(tokens, "RIGHT_PAREN"); return args; } function parseOneFunctionArg(tokens) { const nextToken = tokens[0]; if (nextToken?.type === "ARG_SEPARATOR" || nextToken?.type === "RIGHT_PAREN") { // arg is empty: "sum(1,,2)" "sum(,1)" "sum(1,)" return { type: "EMPTY", value: "" }; } return parseExpression(tokens); } function consumeOrThrow(tokens, type, message) { const token = tokens.shift(); if (!token || token.type !== type) { throw new BadExpressionError(message); } } function parseExpression(tokens, parent_priority = 0) { if (tokens.length === 0) { throw new BadExpressionError(); } let left = parseOperand(tokens); // as long as we have operators with higher priority than the parent one, // continue parsing the expression because it is a child sub-expression while (tokens[0]?.type === "OPERATOR" && OP_PRIORITY[tokens[0].value] > parent_priority) { const operator = tokens.shift().value; if (UNARY_OPERATORS_POSTFIX.includes(operator)) { left = { type: "UNARY_OPERATION", value: operator, operand: left, postfix: true, }; } else { const right = parseExpression(tokens, OP_PRIORITY[operator]); left = { type: "BIN_OPERATION", value: operator, left, right, }; } } return left; } /** * Parse an expression (as a string) into an AST. */ function parse(str) { return parseTokens(rangeTokenize(str)); } function parseTokens(tokens) { tokens = tokens.filter((x) => x.type !== "SPACE"); if (tokens[0]?.value === "=") { tokens.splice(0, 1); } const result = parseExpression(tokens); if (tokens.length) { throw new BadExpressionError(); } return result; } /** * Allows to visit all nodes of an AST and apply a mapping function * to nodes of a specific type. * Useful if you want to convert some part of a formula. * * @example * convertAstNodes(ast, "FUNCALL", convertFormulaToExcel) * * function convertFormulaToExcel(ast: ASTFuncall) { * // ... * return modifiedAst * } */ function convertAstNodes(ast, type, fn) { return mapAst(ast, (ast) => { if (ast.type === type) { return fn(ast); } return ast; }); } function iterateAstNodes(ast) { return Array.from(astIterator(ast)); } function* astIterator(ast) { yield ast; switch (ast.type) { case "FUNCALL": for (const arg of ast.args) { yield* astIterator(arg); } break; case "UNARY_OPERATION": yield* astIterator(ast.operand); break; case "BIN_OPERATION": yield* astIterator(ast.left); yield* astIterator(ast.right); break; } } function mapAst(ast, fn) { ast = fn(ast); switch (ast.type) { case "FUNCALL": return { ...ast, args: ast.args.map((child) => mapAst(child, fn)), }; case "UNARY_OPERATION": return { ...ast, operand: mapAst(ast.operand, fn), }; case "BIN_OPERATION": return { ...ast, right: mapAst(ast.right, fn), left: mapAst(ast.left, fn), }; default: return ast; } } /** * Converts an ast formula to the corresponding string */ function astToFormula(ast) { switch (ast.type) { case "FUNCALL": const args = ast.args.map((arg) => astToFormula(arg)); return `${ast.value}(${args.join(",")})`; case "NUMBER": return ast.value.toString(); case "REFERENCE": return ast.value; case "STRING": return `"${ast.value}"`; case "BOOLEAN": return ast.value ? "TRUE" : "FALSE"; case "UNARY_OPERATION": return ast.postfix ? leftOperandToFormula(ast) + ast.value : ast.value + rightOperandToFormula(ast); case "BIN_OPERATION": return leftOperandToFormula(ast) + ast.value + rightOperandToFormula(ast); default: return ast.value; } } /** * Convert the left operand of a binary operation to the corresponding string * and enclose the result inside parenthesis if necessary. */ function leftOperandToFormula(operationAST) { const mainOperator = operationAST.value; const leftOperation = "left" in operationAST ? operationAST.left : operationAST.operand; const leftOperator = leftOperation.value; const needParenthesis = leftOperation.type === "BIN_OPERATION" && OP_PRIORITY[leftOperator] < OP_PRIORITY[mainOperator]; return needParenthesis ? `(${astToFormula(leftOperation)})` : astToFormula(leftOperation); } /** * Convert the right operand of a binary or unary operation to the corresponding string * and enclose the result inside parenthesis if necessary. */ function rightOperandToFormula(operationAST) { const mainOperator = operationAST.value; const rightOperation = "right" in operationAST ? operationAST.right : operationAST.operand; const rightPriority = OP_PRIORITY[rightOperation.value]; const mainPriority = OP_PRIORITY[mainOperator]; let needParenthesis = false; if (rightOperation.type !== "BIN_OPERATION") { needParenthesis = false; } else if (rightPriority < mainPriority) { needParenthesis = true; } else if (rightPriority === mainPriority && !ASSOCIATIVE_OPERATORS.includes(mainOperator)) { needParenthesis = true; } return needParenthesis ? `(${astToFormula(rightOperation)})` : astToFormula(rightOperation); } /** * Add the following information on tokens: * - length * - start * - end */ function enrichTokens(tokens) { let current = 0; return tokens.map((x) => { const len = x.value.toString().length; const token = Object.assign({}, x, { start: current, end: current + len, length: len, }); current = token.end; return token; }); } /** * add on each token the length, start and end * also matches the opening to its closing parenthesis (using the same number) */ function mapParenthesis(tokens) { let maxParen = 1; const stack = []; return tokens.map((token) => { if (token.type === "LEFT_PAREN") { stack.push(maxParen); token.parenIndex = maxParen; maxParen++; } else if (token.type === "RIGHT_PAREN") { token.parenIndex = stack.pop(); } return token; }); } /** * add on each token its parent function and the index corresponding to * its position as an argument of the function. * In this example "=MIN(42,SUM(MAX(1,2),3))": * - the parent function of the token correspond to number 42 is the MIN function * - the argument position of the token correspond to number 42 is 0 * - the parent function of the token correspond to number 3 is the SUM function * - the argument position of the token correspond to number 3 is 1 */ function mapParentFunction(tokens) { let stack = []; let functionStarted = ""; function pushTokenToFunctionContext(token) { if (stack.length === 0) { return; } const functionContext = stack.at(-1); if (functionContext && functionContext.argsTokens) { const { argsTokens, argPosition } = functionContext; if (!argsTokens[argPosition]) { argsTokens[argPosition] = []; } argsTokens[argPosition].push({ value: token.value, type: token.type }); } } const res = tokens.map((token, i) => { if (!["SPACE", "LEFT_PAREN"].includes(token.type)) { functionStarted = ""; } switch (token.type) { case "SYMBOL": pushTokenToFunctionContext(token); functionStarted = token.value; break; case "LEFT_PAREN": stack.push({ parent: functionStarted, argPosition: 0, argsTokens: [], args: [] }); pushTokenToFunctionContext(token); functionStarted = ""; break; case "RIGHT_PAREN": const child = stack.pop(); child?.argsTokens?.flat().forEach(pushTokenToFunctionContext); pushTokenToFunctionContext(token); break; case "ARG_SEPARATOR": pushTokenToFunctionContext(token); if (stack.length) { // increment position on current function stack[stack.length - 1].argPosition++; } break; default: pushTokenToFunctionContext(token); break; } if (stack.length) { const functionContext = stack[stack.length - 1]; if (functionContext.parent) { token.functionContext = Object.assign({}, functionContext); } } return token; }); return res; } /** * Parse the list of tokens that compose the arguments of a function to * their AST representation. */ function addArgsAST(tokens) { for (const token of tokens) { if (token.functionContext) { const { argsTokens, args } = token.functionContext; // remove argsTokens from the context to remove noise // The business logic should not need it, it is only used temporarily // to build the arguments ASTs. delete token.functionContext.argsTokens; if (args.length || !argsTokens) { // function context already process at a previous token continue; } if (argsTokens[0]?.[0]?.type === "LEFT_PAREN") { // remove the parenthesis leading the first argument argsTokens[0] = argsTokens[0].slice(1); } for (const argTokens of argsTokens) { let tokens = argTokens; if (tokens.at(-1)?.type === "ARG_SEPARATOR") { tokens = tokens.slice(0, -1); } try { args.push(parseTokens(tokens)); } catch (error) { args.push(undefined); } } } } return tokens; } /** * Take the result of the tokenizer and transform it to be usable in the composer. * * @param formula */ function composerTokenize(formula, locale) { const tokens = rangeTokenize(formula, locale); return addArgsAST(mapParentFunction(mapParenthesis(enrichTokens(tokens)))); } /** * Change the reference types inside the given token, if the token represent a range or a cell * * Eg. : * A1 => $A$1 => A$1 => $A1 => A1 * A1:$B$1 => $A$1:B$1 => A$1:$B1 => $A1:B1 => A1:$B$1 */ function loopThroughReferenceType(token) { if (token.type !== "REFERENCE") return token; const { xc, sheetName } = splitReference(token.value); const [left, right] = xc.split(":"); const updatedLeft = getTokenNextReferenceType(left); const updatedRight = right ? `:${getTokenNextReferenceType(right)}` : ""; return { ...token, value: getFullReference(sheetName, updatedLeft + updatedRight) }; } /** * Get a new token with a changed type of reference from the given cell token symbol. * Undefined behavior if given a token other than a cell or if the Xc contains a sheet reference * * A1 => $A$1 => A$1 => $A1 => A1 */ function getTokenNextReferenceType(xc) { switch (getReferenceType(xc)) { case "none": xc = setXcToFixedReferenceType(xc, "colrow"); break; case "colrow": xc = setXcToFixedReferenceType(xc, "row"); break; case "row": xc = setXcToFixedReferenceType(xc, "col"); break; case "col": xc = setXcToFixedReferenceType(xc, "none"); break; } return xc; } /** * Returns the given XC with the given reference type. The XC string should not contain a sheet name. */ function setXcToFixedReferenceType(xc, referenceType) { if (xc.includes("!")) { throw new Error("The given XC should not contain a sheet name"); } xc = xc.replace(/\$/g, ""); let indexOfNumber; switch (referenceType) { case "col": return "$" + xc; case "row": indexOfNumber = xc.search(/[0-9]/); return xc.slice(0, indexOfNumber) + "$" + xc.slice(indexOfNumber); case "colrow": indexOfNumber = xc.search(/[0-9]/); if (indexOfNumber === -1 || indexOfNumber === 0) { // no row number (eg. A) or no column (eg. 1) return "$" + xc; } xc = xc.slice(0, indexOfNumber) + "$" + xc.slice(indexOfNumber); return "$" + xc; case "none": return xc; } } /** * Return the type of reference used in the given XC of a cell. * Undefined behavior if the XC have a sheet reference */ function getReferenceType(xcCell) { if (isColAndRowFixed(xcCell)) { return "colrow"; } else if (isColFixed(xcCell)) { return "col"; } else if (isRowFixed(xcCell)) { return "row"; } return "none"; } function isColFixed(xc) { return xc.startsWith("$"); } function isRowFixed(xc) { return xc.includes("$", 1); } function isColAndRowFixed(xc) { return xc.startsWith("$") && xc.length > 1 && xc.slice(1).includes("$"); } /** * Return the cycled reference if any (A1 -> $A$1 -> A$1 -> $A1 -> A1) */ function cycleFixedReference(selection, content, locale) { const currentTokens = content.startsWith("=") ? composerTokenize(content, locale) : []; const tokens = currentTokens.filter((t) => (t.start <= selection.start && t.end >= selection.start) || (t.start >= selection.start && t.start < selection.end)); const refTokens = tokens.filter((token) => token.type === "REFERENCE"); if (refTokens.length === 0) { return; } const updatedReferences = tokens .map(loopThroughReferenceType) .map((token) => token.value) .join(""); const start = tokens[0].start; const end = tokens[tokens.length - 1].end; const newContent = content.slice(0, start) + updatedReferences + content.slice(end); const lengthDiff = newContent.length - content.length; const startOfTokens = refTokens[0].start; const endOfTokens = refTokens[refTokens.length - 1].end + lengthDiff; const newSelection = { start: startOfTokens, end: endOfTokens }; if (refTokens.length === 1 && selection.start === selection.end) { newSelection.start = newSelection.end; } return { content: newContent, selection: newSelection }; } // ----------------------------------------------------------------------------- // CELL // ----------------------------------------------------------------------------- // NOTE: missing from Excel: "color", "filename", "parentheses", "prefix", "protect" and "width" const CELL_INFO_TYPES = ["address", "col", "contents", "format", "row", "type"]; const CELL = { description: _t("Gets information about a cell."), args: [ arg("info_type (string)", _t("The type of information requested. Can be one of %s", CELL_INFO_TYPES.join(", "))), arg("reference (meta)", _t("The reference to the cell.")), ], compute: function (info, reference) { const _info = toString(info).toLowerCase(); assert(() => CELL_INFO_TYPES.includes(_info), _t("The info_type should be one of %s.", CELL_INFO_TYPES.join(", "))); const sheetId = this.__originSheetId; const _reference = toString(reference); const topLeftReference = _reference.includes(":") ? _reference.split(":")[0] : _reference; let { sheetName, xc } = splitReference(topLeftReference); // only put the sheet name if the referenced range is in another sheet than the cell the formula is on sheetName = sheetName === this.getters.getSheetName(sheetId) ? undefined : sheetName; const fixedRef = getFullReference(sheetName, setXcToFixedReferenceType(xc, "colrow")); const range = this.getters.getRangeFromSheetXC(sheetId, fixedRef); switch (_info) { case "address": return this.getters.getRangeString(range, sheetId); case "col": return range.zone.left + 1; case "contents": { const position = { sheetId: range.sheetId, col: range.zone.left, row: range.zone.top }; return this.getters.getEvaluatedCell(position).value; } case "format": { const position = { sheetId: range.sheetId, col: range.zone.left, row: range.zone.top }; return this.getters.getEvaluatedCell(position).format || ""; } case "row": return range.zone.top + 1; case "type": { const position = { sheetId: range.sheetId, col: range.zone.left, row: range.zone.top }; const type = this.getters.getEvaluatedCell(position).type; if (type === CellValueType.empty) { return "b"; // blank } else if (type === CellValueType.text) { return "l"; // label } else { return "v"; // value } } } return ""; }, isExported: true, }; // ----------------------------------------------------------------------------- // ISERR // ----------------------------------------------------------------------------- const ISERR = { description: _t("Whether a value is an error other than #N/A."), args: [arg("value (any)", _t("The value to be verified as an error type."))], compute: function (data) { const value = data?.value; return isEvaluationError(value) && value !== CellErrorType.NotAvailable; }, isExported: true, }; // ----------------------------------------------------------------------------- // ISERROR // ----------------------------------------------------------------------------- const ISERROR = { description: _t("Whether a value is an error."), args: [arg("value (any)", _t("The value to be verified as an error type."))], compute: function (data) { const value = data?.value; return isEvaluationError(value); }, isExported: true, }; // ----------------------------------------------------------------------------- // ISLOGICAL // ----------------------------------------------------------------------------- const ISLOGICAL = { description: _t("Whether a value is `true` or `false`."), args: [arg("value (any)", _t("The value to be verified as a logical TRUE or FALSE."))], compute: function (value) { return typeof value?.value === "boolean"; }, isExported: true, }; // ----------------------------------------------------------------------------- // ISNA // ----------------------------------------------------------------------------- const ISNA = { description: _t("Whether a value is the error #N/A."), args: [arg("value (any)", _t("The value to be verified as an error type."))], compute: function (data) { return data?.value === CellErrorType.NotAvailable; }, isExported: true, }; // ----------------------------------------------------------------------------- // ISNONTEXT // ----------------------------------------------------------------------------- const ISNONTEXT = { description: _t("Whether a value is non-textual."), args: [arg("value (any)", _t("The value to be checked."))], compute: function (value) { return !ISTEXT.compute.bind(this)(value); }, isExported: true, }; // ----------------------------------------------------------------------------- // ISNUMBER // ----------------------------------------------------------------------------- const ISNUMBER = { description: _t("Whether a value is a number."), args: [arg("value (any)", _t("The value to be verified as a number."))], compute: function (value) { return typeof value?.value === "number"; }, isExported: true, }; // ----------------------------------------------------------------------------- // ISTEXT // ----------------------------------------------------------------------------- const ISTEXT = { description: _t("Whether a value is text."), args: [arg("value (any)", _t("The value to be verified as text."))], compute: function (value) { return typeof value?.value === "string" && isEvaluationError(value?.value) === false; }, isExported: true, }; // ----------------------------------------------------------------------------- // ISBLANK // ----------------------------------------------------------------------------- const ISBLANK = { description: _t("Whether the referenced cell is empty"), args: [arg("value (any)", _t("Reference to the cell that will be checked for emptiness."))], compute: function (value) { return value?.value === null; }, isExported: true, }; // ----------------------------------------------------------------------------- // NA // ----------------------------------------------------------------------------- const NA = { description: _t("Returns the error value #N/A."), args: [], compute: function () { return { value: CellErrorType.NotAvailable }; }, isExported: true, }; var info = /*#__PURE__*/Object.freeze({ __proto__: null, CELL: CELL, ISBLANK: ISBLANK, ISERR: ISERR, ISERROR: ISERROR, ISLOGICAL: ISLOGICAL, ISNA: ISNA, ISNONTEXT: ISNONTEXT, ISNUMBER: ISNUMBER, ISTEXT: ISTEXT, NA: NA }); // ----------------------------------------------------------------------------- // AND // ----------------------------------------------------------------------------- const AND = { description: _t("Logical `and` operator."), args: [ arg("logical_expression1 (boolean, range)", _t("An expression or reference to a cell containing an expression that represents some logical value, i.e. TRUE or FALSE, or an expression that can be coerced to a logical value.")), arg("logical_expression2 (boolean, range, repeating)", _t("More expressions that represent logical values.")), ], compute: function (...logicalExpressions) { const { result, foundBoolean } = boolAnd(logicalExpressions); assert(() => foundBoolean, _t("[[FUNCTION_NAME]] has no valid input data.")); return result; }, isExported: true, }; // ----------------------------------------------------------------------------- // FALSE // ----------------------------------------------------------------------------- const FALSE = { description: _t("Logical value `false`."), args: [], compute: function () { return false; }, isExported: true, }; // ----------------------------------------------------------------------------- // IF // ----------------------------------------------------------------------------- const IF = { description: _t("Returns value depending on logical expression."), args: [ arg("logical_expression (boolean)", _t("An expression or reference to a cell containing an expression that represents some logical value, i.e. TRUE or FALSE.")), arg("value_if_true (any)", _t("The value the function returns if logical_expression is TRUE.")), arg("value_if_false (any, default=FALSE)", _t("The value the function returns if logical_expression is FALSE.")), ], compute: function (logicalExpression, valueIfTrue, valueIfFalse) { const result = toBoolean(logicalExpression?.value) ? valueIfTrue : valueIfFalse; if (result === undefined) { return { value: "" }; } if (result.value === null) { result.value = ""; } return result; }, isExported: true, }; // ----------------------------------------------------------------------------- // IFERROR // ----------------------------------------------------------------------------- const IFERROR = { description: _t("Value if it is not an error, otherwise 2nd argument."), args: [ arg("value (any)", _t("The value to return if value itself is not an error.")), arg(`value_if_error (any, default="empty")`, _t("The value the function returns if value is an error.")), ], compute: function (value, valueIfError = { value: "" }) { const result = isEvaluationError(value?.value) ? valueIfError : value; if (result === undefined) { return { value: "" }; } if (result.value === null) { result.value = ""; } return result; }, isExported: true, }; // ----------------------------------------------------------------------------- // IFNA // ----------------------------------------------------------------------------- const IFNA = { description: _t("Value if it is not an #N/A error, otherwise 2nd argument."), args: [ arg("value (any)", _t("The value to return if value itself is not #N/A an error.")), arg(`value_if_error (any, default="empty")`, _t("The value the function returns if value is an #N/A error.")), ], compute: function (value, valueIfError = { value: "" }) { const result = value?.value === CellErrorType.NotAvailable ? valueIfError : value; if (result === undefined) { return { value: "" }; } if (result.value === null) { result.value = ""; } return result; }, isExported: true, }; // ----------------------------------------------------------------------------- // IFS // ----------------------------------------------------------------------------- const IFS = { description: _t("Returns a value depending on multiple logical expressions."), args: [ arg("condition1 (boolean)", _t("The first condition to be evaluated. This can be a boolean, a number, an array, or a reference to any of those.")), arg("value1 (any)", _t("The returned value if condition1 is TRUE.")), arg("condition2 (boolean, any, repeating)", _t("Additional conditions to be evaluated if the previous ones are FALSE.")), arg("value2 (any, repeating)", _t("Additional values to be returned if their corresponding conditions are TRUE.")), ], compute: function (...values) { assert(() => values.length % 2 === 0, _t("Wrong number of arguments. Expected an even number of arguments.")); for (let n = 0; n < values.length - 1; n += 2) { if (toBoolean(values[n]?.value)) { const result = values[n + 1]; if (result === undefined) { return { value: "" }; } if (result.value === null) { result.value = ""; } return result; } } throw new EvaluationError(_t("No match.")); }, isExported: true, }; // ----------------------------------------------------------------------------- // NOT // ----------------------------------------------------------------------------- const NOT = { description: _t("Returns opposite of provided logical value."), args: [ arg("logical_expression (boolean)", _t("An expression or reference to a cell holding an expression that represents some logical value.")), ], compute: function (logicalExpression) { return !toBoolean(logicalExpression); }, isExported: true, }; // ----------------------------------------------------------------------------- // OR // ----------------------------------------------------------------------------- const OR = { description: _t("Logical `or` operator."), args: [ arg("logical_expression1 (boolean, range)", _t("An expression or reference to a cell containing an expression that represents some logical value, i.e. TRUE or FALSE, or an expression that can be coerced to a logical value.")), arg("logical_expression2 (boolean, range, repeating)", _t("More expressions that evaluate to logical values.")), ], compute: function (...logicalExpressions) { const { result, foundBoolean } = boolOr(logicalExpressions); assert(() => foundBoolean, _t("[[FUNCTION_NAME]] has no valid input data.")); return result; }, isExported: true, }; // ----------------------------------------------------------------------------- // TRUE // ----------------------------------------------------------------------------- const TRUE = { description: _t("Logical value `true`."), args: [], compute: function () { return true; }, isExported: true, }; // ----------------------------------------------------------------------------- // XOR // ----------------------------------------------------------------------------- const XOR = { description: _t("Logical `xor` operator."), args: [ arg("logical_expression1 (boolean, range)", _t("An expression or reference to a cell containing an expression that represents some logical value, i.e. TRUE or FALSE, or an expression that can be coerced to a logical value.")), arg("logical_expression2 (boolean, range, repeating)", _t("More expressions that evaluate to logical values.")), ], compute: function (...logicalExpressions) { let foundBoolean = false; let acc = false; conditionalVisitBoolean(logicalExpressions, (arg) => { foundBoolean = true; acc = acc ? !arg : arg; return true; // no stop condition }); assert(() => foundBoolean, _t("[[FUNCTION_NAME]] has no valid input data.")); return acc; }, isExported: true, }; var logical = /*#__PURE__*/Object.freeze({ __proto__: null, AND: AND, FALSE: FALSE, IF: IF, IFERROR: IFERROR, IFNA: IFNA, IFS: IFS, NOT: NOT, OR: OR, TRUE: TRUE, XOR: XOR }); /** * Get the pivot ID from the formula pivot ID. */ function getPivotId(pivotFormulaId, getters) { const pivotId = getters.getPivotId(pivotFormulaId); if (!pivotId) { throw new EvaluationError(_t('There is no pivot with id "%s"', pivotFormulaId)); } return pivotId; } function assertMeasureExist(pivotId, measure, getters) { const { measures } = getters.getPivotCoreDefinition(pivotId); if (!measures.find((m) => m.id === measure)) { const validMeasures = `(${measures.map((m) => m.id).join(", ")})`; throw new EvaluationError(_t("The argument %s is not a valid measure. Here are the measures: %s", measure, validMeasures)); } } function assertDomainLength(domain) { if (domain.length % 2 !== 0) { throw new EvaluationError(_t("Function PIVOT takes an even number of arguments.")); } } function addPivotDependencies(evalContext, coreDefinition, forMeasures) { //TODO This function can be very costly when used with PIVOT.VALUE and PIVOT.HEADER const dependencies = []; if (coreDefinition.type === "SPREADSHEET" && coreDefinition.dataSet) { const { sheetId, zone } = coreDefinition.dataSet; const xc = zoneToXc(zone); const range = evalContext.getters.getRangeFromSheetXC(sheetId, xc); if (range === undefined || range.invalidXc || range.invalidSheetName) { throw new InvalidReferenceError(); } dependencies.push(range); } for (const measure of forMeasures) { if (measure.computedBy) { const formula = evalContext.getters.getMeasureCompiledFormula(measure); dependencies.push(...formula.dependencies.filter((range) => !range.invalidXc)); } } const originPosition = evalContext.__originCellPosition; if (originPosition && dependencies.length) { // The following line is used to reset the dependencies of the cell, to avoid // keeping dependencies from previous evaluation of the PIVOT formula (i.e. // in case the reference has been changed). evalContext.updateDependencies?.(originPosition); evalContext.addDependencies?.(originPosition, dependencies); } } const DEFAULT_IS_SORTED = true; const DEFAULT_MATCH_MODE = 0; const DEFAULT_SEARCH_MODE = 1; const DEFAULT_ABSOLUTE_RELATIVE_MODE = 1; function valueNotAvailable(searchKey) { return { value: CellErrorType.NotAvailable, message: _t("Did not find value '%s' in [[FUNCTION_NAME]] evaluation.", toString(searchKey)), }; } // ----------------------------------------------------------------------------- // ADDRESS // ----------------------------------------------------------------------------- const ADDRESS = { description: _t("Returns a cell reference as a string. "), args: [ arg("row (number)", _t("The row number of the cell reference. ")), arg("column (number)", _t("The column number (not name) of the cell reference. A is column number 1. ")), arg(`absolute_relative_mode (number, default=${DEFAULT_ABSOLUTE_RELATIVE_MODE})`, _t("An indicator of whether the reference is row/column absolute. 1 is row and column absolute (e.g. $A$1), 2 is row absolute and column relative (e.g. A$1), 3 is row relative and column absolute (e.g. $A1), and 4 is row and column relative (e.g. A1).")), arg("use_a1_notation (boolean, default=TRUE)", _t("A boolean indicating whether to use A1 style notation (TRUE) or R1C1 style notation (FALSE).")), arg("sheet (string, optional)", _t("A string indicating the name of the sheet into which the address points.")), ], compute: function (row, column, absoluteRelativeMode = { value: DEFAULT_ABSOLUTE_RELATIVE_MODE }, useA1Notation = { value: true }, sheet) { const rowNumber = strictToInteger(row, this.locale); const colNumber = strictToInteger(column, this.locale); assertNumberGreaterThanOrEqualToOne(rowNumber); assertNumberGreaterThanOrEqualToOne(colNumber); const _absoluteRelativeMode = strictToInteger(absoluteRelativeMode, this.locale); assert(() => [1, 2, 3, 4].includes(_absoluteRelativeMode), expectNumberRangeError(1, 4, _absoluteRelativeMode)); const _useA1Notation = toBoolean(useA1Notation); let cellReference; if (_useA1Notation) { const rangePart = { rowFixed: [1, 2].includes(_absoluteRelativeMode) ? true : false, colFixed: [1, 3].includes(_absoluteRelativeMode) ? true : false, }; cellReference = toXC(colNumber - 1, rowNumber - 1, rangePart); } else { const rowPart = [1, 2].includes(_absoluteRelativeMode) ? `R${rowNumber}` : `R[${rowNumber}]`; const colPart = [1, 3].includes(_absoluteRelativeMode) ? `C${colNumber}` : `C[${colNumber}]`; cellReference = rowPart + colPart; } if (sheet !== undefined) { return getFullReference(toString(sheet), cellReference); } return cellReference; }, isExported: true, }; // ----------------------------------------------------------------------------- // COLUMN // ----------------------------------------------------------------------------- const COLUMN = { description: _t("Column number of a specified cell."), args: [ arg("cell_reference (meta, default='this cell')", _t("The cell whose column number will be returned. Column A corresponds to 1. By default, the function use the cell in which the formula is entered.")), ], compute: function (cellReference) { if (isEvaluationError(cellReference?.value)) { throw cellReference; } const column = cellReference === undefined ? this.__originCellPosition?.col : toZone(cellReference.value).left; assert(() => column !== undefined, "In this context, the function [[FUNCTION_NAME]] needs to have a cell or range in parameter."); return column + 1; }, isExported: true, }; // ----------------------------------------------------------------------------- // COLUMNS // ----------------------------------------------------------------------------- const COLUMNS = { description: _t("Number of columns in a specified array or range."), args: [arg("range (meta)", _t("The range whose column count will be returned."))], compute: function (range) { if (isEvaluationError(range?.value)) { throw range; } const zone = toZone(range.value); return zone.right - zone.left + 1; }, isExported: true, }; // ----------------------------------------------------------------------------- // HLOOKUP // ----------------------------------------------------------------------------- const HLOOKUP = { description: _t("Horizontal lookup"), args: [ arg("search_key (string, number, boolean)", _t("The value to search for. For example, 42, 'Cats', or I24.")), arg("range (range)", _t("The range to consider for the search. The first row in the range is searched for the key specified in search_key.")), arg("index (number)", _t("The row index of the value to be returned, where the first row in range is numbered 1.")), arg(`is_sorted (boolean, default=${DEFAULT_IS_SORTED})`, _t("Indicates whether the row to be searched (the first row of the specified range) is sorted, in which case the closest match for search_key will be returned.")), ], compute: function (searchKey, range, index, isSorted = { value: DEFAULT_IS_SORTED }) { const _index = Math.trunc(toNumber(index?.value, this.locale)); assert(() => 1 <= _index && _index <= range[0].length, _t("[[FUNCTION_NAME]] evaluates to an out of bounds range.")); const getValueFromRange = (range, index) => range[index][0].value; const _isSorted = toBoolean(isSorted.value); const colIndex = _isSorted ? dichotomicSearch(range, searchKey, "nextSmaller", "asc", range.length, getValueFromRange) : linearSearch(range, searchKey, "wildcard", range.length, getValueFromRange); const col = range[colIndex]; if (col === undefined) { return valueNotAvailable(searchKey); } return col[_index - 1]; }, isExported: true, }; // ----------------------------------------------------------------------------- // INDEX // ----------------------------------------------------------------------------- const INDEX = { description: _t("Returns the content of a cell, specified by row and column offset."), args: [ arg("reference (any, range)", _t("The range of cells from which the values are returned.")), arg("row (number, default=0)", _t("The index of the row to be returned from within the reference range of cells.")), arg("column (number, default=0)", _t("The index of the column to be returned from within the reference range of cells.")), ], compute: function (reference, row = { value: 0 }, column = { value: 0 }) { const _reference = toMatrix(reference); const _row = toNumber(row.value, this.locale); const _column = toNumber(column.value, this.locale); assert(() => _column >= 0 && _column - 1 < _reference.length && _row >= 0 && _row - 1 < _reference[0].length, _t("Index out of range.")); if (_row === 0 && _column === 0) { return _reference; } if (_row === 0) { return [_reference[_column - 1]]; } if (_column === 0) { return _reference.map((col) => [col[_row - 1]]); } return _reference[_column - 1][_row - 1]; }, isExported: true, }; // ----------------------------------------------------------------------------- // INDIRECT // ----------------------------------------------------------------------------- const INDIRECT = { description: _t("Returns the content of a cell, specified by a string."), args: [ arg("reference (string)", _t("The range of cells from which the values are returned.")), arg("use_a1_notation (boolean, default=TRUE)", _t("A boolean indicating whether to use A1 style notation (TRUE) or R1C1 style notation (FALSE).")), ], compute: function (reference, useA1Notation = { value: true }) { let _reference = reference?.value?.toString(); if (!_reference) { throw new InvalidReferenceError(_t("Reference should be defined.")); } const _useA1Notation = toBoolean(useA1Notation); if (!_useA1Notation) { throw new EvaluationError(_t("R1C1 notation is not supported.")); } const sheetId = this.__originSheetId; const originPosition = this.__originCellPosition; if (originPosition) { // The following line is used to reset the dependencies of the cell, to avoid // keeping dependencies from previous evaluation of the INDIRECT formula (i.e. // in case the reference has been changed). this.updateDependencies?.(originPosition); } const range = this.getters.getRangeFromSheetXC(sheetId, _reference); if (range === undefined || range.invalidXc || range.invalidSheetName) { throw new InvalidReferenceError(); } if (originPosition) { this.addDependencies?.(originPosition, [range]); } const values = []; for (let col = range.zone.left; col <= range.zone.right; col++) { const colValues = []; for (let row = range.zone.top; row <= range.zone.bottom; row++) { const position = { sheetId: range.sheetId, col, row }; colValues.push(this.getters.getEvaluatedCell(position)); } values.push(colValues); } return values.length === 1 && values[0].length === 1 ? values[0][0] : values; }, isExported: true, }; // ----------------------------------------------------------------------------- // LOOKUP // ----------------------------------------------------------------------------- const LOOKUP = { description: _t("Look up a value."), args: [ arg("search_key (string, number, boolean)", _t("The value to search for. For example, 42, 'Cats', or I24.")), arg("search_array (range)", _t("One method of using this function is to provide a single sorted row or column search_array to look through for the search_key with a second argument result_range. The other way is to combine these two arguments into one search_array where the first row or column is searched and a value is returned from the last row or column in the array. If search_key is not found, a non-exact match may be returned.")), arg("result_range (range, optional)", _t("The range from which to return a result. The value returned corresponds to the location where search_key is found in search_range. This range must be only a single row or column and should not be used if using the search_result_array method.")), ], compute: function (searchKey, searchArray, resultRange) { let nbCol = searchArray.length; let nbRow = searchArray[0].length; const verticalSearch = nbRow >= nbCol; const getElement = verticalSearch ? (range, index) => range[0][index].value : (range, index) => range[index][0].value; const rangeLength = verticalSearch ? nbRow : nbCol; const index = dichotomicSearch(searchArray, searchKey, "nextSmaller", "asc", rangeLength, getElement); if (index === -1 || (verticalSearch && searchArray[0][index] === undefined) || (!verticalSearch && searchArray[index][nbRow - 1] === undefined)) { return valueNotAvailable(searchKey); } if (resultRange === undefined) { return verticalSearch ? searchArray[nbCol - 1][index] : searchArray[index][nbRow - 1]; } nbCol = resultRange.length; nbRow = resultRange[0].length; assert(() => nbCol === 1 || nbRow === 1, _t("The result_range must be a single row or a single column.")); if (nbCol > 1) { assert(() => index <= nbCol - 1, _t("[[FUNCTION_NAME]] evaluates to an out of range row value %s.", (index + 1).toString())); return resultRange[index][0]; } assert(() => index <= nbRow - 1, _t("[[FUNCTION_NAME]] evaluates to an out of range column value %s.", (index + 1).toString())); return resultRange[0][index]; }, isExported: true, }; // ----------------------------------------------------------------------------- // MATCH // ----------------------------------------------------------------------------- const DEFAULT_SEARCH_TYPE = 1; const MATCH = { description: _t("Position of item in range that matches value."), args: [ arg("search_key (string, number, boolean)", _t("The value to search for. For example, 42, 'Cats', or I24.")), arg("range (any, range)", _t("The one-dimensional array to be searched.")), arg(`search_type (number, default=${DEFAULT_SEARCH_TYPE})`, _t("The search method. 1 (default) finds the largest value less than or equal to search_key when range is sorted in ascending order. 0 finds the exact value when range is unsorted. -1 finds the smallest value greater than or equal to search_key when range is sorted in descending order.")), ], compute: function (searchKey, range, searchType = { value: DEFAULT_SEARCH_TYPE }) { let _searchType = toNumber(searchType, this.locale); const nbCol = range.length; const nbRow = range[0].length; assert(() => nbCol === 1 || nbRow === 1, _t("The range must be a single row or a single column.")); let index = -1; const getElement = nbCol === 1 ? (range, index) => range[0][index].value : (range, index) => range[index][0].value; const rangeLen = nbCol === 1 ? range[0].length : range.length; _searchType = Math.sign(_searchType); switch (_searchType) { case 1: index = dichotomicSearch(range, searchKey, "nextSmaller", "asc", rangeLen, getElement); break; case 0: index = linearSearch(range, searchKey, "wildcard", rangeLen, getElement); break; case -1: index = dichotomicSearch(range, searchKey, "nextGreater", "desc", rangeLen, getElement); break; } if ((nbCol === 1 && range[0][index] === undefined) || (nbCol !== 1 && range[index] === undefined)) { return valueNotAvailable(searchKey); } return index + 1; }, isExported: true, }; // ----------------------------------------------------------------------------- // ROW // ----------------------------------------------------------------------------- const ROW = { description: _t("Row number of a specified cell."), args: [ arg("cell_reference (meta, default='this cell')", _t("The cell whose row number will be returned. By default, this function uses the cell in which the formula is entered.")), ], compute: function (cellReference) { if (isEvaluationError(cellReference?.value)) { throw cellReference; } const row = cellReference === undefined ? this.__originCellPosition?.row : toZone(cellReference.value).top; assert(() => row !== undefined, "In this context, the function [[FUNCTION_NAME]] needs to have a cell or range in parameter."); return row + 1; }, isExported: true, }; // ----------------------------------------------------------------------------- // ROWS // ----------------------------------------------------------------------------- const ROWS = { description: _t("Number of rows in a specified array or range."), args: [arg("range (meta)", _t("The range whose row count will be returned."))], compute: function (range) { if (isEvaluationError(range?.value)) { throw range; } const zone = toZone(range.value); return zone.bottom - zone.top + 1; }, isExported: true, }; // ----------------------------------------------------------------------------- // VLOOKUP // ----------------------------------------------------------------------------- const VLOOKUP = { description: _t("Vertical lookup."), args: [ arg("search_key (string, number, boolean)", _t("The value to search for. For example, 42, 'Cats', or I24.")), arg("range (any, range)", _t("The range to consider for the search. The first column in the range is searched for the key specified in search_key.")), arg("index (number)", _t("The column index of the value to be returned, where the first column in range is numbered 1.")), arg(`is_sorted (boolean, default=${DEFAULT_IS_SORTED})`, _t("Indicates whether the column to be searched (the first column of the specified range) is sorted, in which case the closest match for search_key will be returned.")), ], compute: function (searchKey, range, index, isSorted = { value: DEFAULT_IS_SORTED }) { const _index = Math.trunc(toNumber(index?.value, this.locale)); assert(() => 1 <= _index && _index <= range.length, _t("[[FUNCTION_NAME]] evaluates to an out of bounds range.")); const getValueFromRange = (range, index) => range[0][index].value; const _isSorted = toBoolean(isSorted.value); const rowIndex = _isSorted ? dichotomicSearch(range, searchKey, "nextSmaller", "asc", range[0].length, getValueFromRange) : linearSearch(range, searchKey, "wildcard", range[0].length, getValueFromRange); const value = range[_index - 1][rowIndex]; if (value === undefined) { return valueNotAvailable(searchKey); } return value; }, isExported: true, }; // ----------------------------------------------------------------------------- // XLOOKUP // ----------------------------------------------------------------------------- const MATCH_MODE = { "0": "strict", "1": "nextGreater", "-1": "nextSmaller", "2": "wildcard", }; const XLOOKUP = { description: _t("Search a range for a match and return the corresponding item from a second range."), args: [ arg("search_key (string,number,boolean)", _t("The value to search for.")), arg("lookup_range (any, range)", _t("The range to consider for the search. Should be a single column or a single row.")), arg("return_range (any, range)", _t("The range containing the return value. Should have the same dimensions as lookup_range.")), arg("if_not_found (any, optional)", _t("If a valid match is not found, return this value.")), arg(`match_mode (any, default=${DEFAULT_MATCH_MODE})`, _t("(0) Exact match. \ (-1) Return next smaller item if no match. \ (1) Return next greater item if no match. \ (2) Wildcard match.")), arg(`search_mode (any, default=${DEFAULT_SEARCH_MODE})`, _t("(1) Search starting at first item. \ (-1) Search starting at last item. \ (2) Perform a binary search that relies on lookup_array being sorted in ascending order. If not sorted, invalid results will be returned. \ (-2) Perform a binary search that relies on lookup_array being sorted in descending order. If not sorted, invalid results will be returned.\ ")), ], compute: function (searchKey, lookupRange, returnRange, defaultValue, matchMode = { value: DEFAULT_MATCH_MODE }, searchMode = { value: DEFAULT_SEARCH_MODE }) { const _matchMode = Math.trunc(toNumber(matchMode.value, this.locale)); const _searchMode = Math.trunc(toNumber(searchMode.value, this.locale)); assert(() => lookupRange.length === 1 || lookupRange[0].length === 1, _t("lookup_range should be either a single row or single column.")); assert(() => [-1, 1, -2, 2].includes(_searchMode), _t("search_mode should be a value in [-1, 1, -2, 2].")); assert(() => [-1, 0, 1, 2].includes(_matchMode), _t("match_mode should be a value in [-1, 0, 1, 2].")); const lookupDirection = lookupRange.length === 1 ? "col" : "row"; assert(() => !(_matchMode === 2 && [-2, 2].includes(_searchMode)), _t("the search and match mode combination is not supported for XLOOKUP evaluation.")); assert(() => lookupDirection === "col" ? returnRange[0].length === lookupRange[0].length : returnRange.length === lookupRange.length, _t("return_range should have the same dimensions as lookup_range.")); const getElement = lookupDirection === "col" ? (range, index) => range[0][index].value : (range, index) => range[index][0].value; const rangeLen = lookupDirection === "col" ? lookupRange[0].length : lookupRange.length; const mode = MATCH_MODE[_matchMode]; const reverseSearch = _searchMode === -1; const index = _searchMode === 2 || _searchMode === -2 ? dichotomicSearch(lookupRange, searchKey, mode, _searchMode === 2 ? "asc" : "desc", rangeLen, getElement) : linearSearch(lookupRange, searchKey, mode, rangeLen, getElement, reverseSearch); if (index !== -1) { return lookupDirection === "col" ? returnRange.map((col) => [col[index]]) : [returnRange[index]]; } if (defaultValue === undefined) { return valueNotAvailable(searchKey); } return [[defaultValue]]; }, isExported: true, }; //-------------------------------------------------------------------------- // Pivot functions //-------------------------------------------------------------------------- // PIVOT.VALUE const PIVOT_VALUE = { description: _t("Get the value from a pivot."), args: [ arg("pivot_id (number,string)", _t("ID of the pivot.")), arg("measure_name (string)", _t("Name of the measure.")), arg("domain_field_name (string,optional,repeating)", _t("Field name.")), arg("domain_value (number,string,boolean,optional,repeating)", _t("Value.")), ], compute: function (formulaId, measureName, ...domainArgs) { const _pivotFormulaId = toString(formulaId); const _measure = toString(measureName); const pivotId = getPivotId(_pivotFormulaId, this.getters); assertMeasureExist(pivotId, _measure, this.getters); assertDomainLength(domainArgs); const pivot = this.getters.getPivot(pivotId); const coreDefinition = this.getters.getPivotCoreDefinition(pivotId); addPivotDependencies(this, coreDefinition, coreDefinition.measures.filter((m) => m.id === _measure)); pivot.init({ reload: pivot.needsReevaluation }); const error = pivot.assertIsValid({ throwOnError: false }); if (error) { return error; } if (!pivot.areDomainArgsFieldsValid(domainArgs)) { const suggestion = _t("Consider using a dynamic pivot formula: %s. Or re-insert the static pivot from the Data menu.", `=PIVOT(${_pivotFormulaId})`); return { value: CellErrorType.GenericError, message: _t("Dimensions don't match the pivot definition") + ". " + suggestion, }; } const domain = pivot.parseArgsToPivotDomain(domainArgs); return pivot.getPivotCellValueAndFormat(_measure, domain); }, }; // PIVOT.HEADER const PIVOT_HEADER = { description: _t("Get the header of a pivot."), args: [ arg("pivot_id (number,string)", _t("ID of the pivot.")), arg("domain_field_name (string,optional,repeating)", _t("Field name.")), arg("domain_value (number,string,value,optional,repeating)", _t("Value.")), ], compute: function (pivotId, ...domainArgs) { const _pivotFormulaId = toString(pivotId); const _pivotId = getPivotId(_pivotFormulaId, this.getters); assertDomainLength(domainArgs); const pivot = this.getters.getPivot(_pivotId); const coreDefinition = this.getters.getPivotCoreDefinition(_pivotId); addPivotDependencies(this, coreDefinition, []); pivot.init({ reload: pivot.needsReevaluation }); const error = pivot.assertIsValid({ throwOnError: false }); if (error) { return error; } if (!pivot.areDomainArgsFieldsValid(domainArgs)) { const suggestion = _t("Consider using a dynamic pivot formula: %s. Or re-insert the static pivot from the Data menu.", `=PIVOT(${_pivotFormulaId})`); return { value: CellErrorType.GenericError, message: _t("Dimensions don't match the pivot definition") + ". " + suggestion, }; } const domain = pivot.parseArgsToPivotDomain(domainArgs); const lastNode = domain.at(-1); if (lastNode?.field === "measure") { return pivot.getPivotMeasureValue(toString(lastNode.value), domain); } const { value, format } = pivot.getPivotHeaderValueAndFormat(domain); return { value, format: !lastNode || lastNode.field === "measure" || lastNode.value === "false" ? undefined : format, }; }, }; const PIVOT = { description: _t("Get a pivot table."), args: [ arg("pivot_id (string)", _t("ID of the pivot.")), arg("row_count (number, optional)", _t("number of rows")), arg("include_total (boolean, default=TRUE)", _t("Whether to include total/sub-totals or not.")), arg("include_column_titles (boolean, default=TRUE)", _t("Whether to include the column titles or not.")), arg("column_count (number, optional)", _t("number of columns")), ], compute: function (pivotFormulaId, rowCount = { value: 10000 }, includeTotal = { value: true }, includeColumnHeaders = { value: true }, columnCount = { value: Number.MAX_VALUE }) { const _pivotFormulaId = toString(pivotFormulaId); const _rowCount = toNumber(rowCount, this.locale); if (_rowCount < 0) { throw new EvaluationError(_t("The number of rows must be positive.")); } const _columnCount = toNumber(columnCount, this.locale); if (_columnCount < 0) { throw new EvaluationError(_t("The number of columns must be positive.")); } const _includeColumnHeaders = toBoolean(includeColumnHeaders); const _includedTotal = toBoolean(includeTotal); const pivotId = getPivotId(_pivotFormulaId, this.getters); const pivot = this.getters.getPivot(pivotId); const coreDefinition = this.getters.getPivotCoreDefinition(pivotId); addPivotDependencies(this, coreDefinition, coreDefinition.measures); pivot.init({ reload: pivot.needsReevaluation }); const error = pivot.assertIsValid({ throwOnError: false }); if (error) { return error; } const table = pivot.getTableStructure(); const cells = table.getPivotCells(_includedTotal, _includeColumnHeaders); const headerRows = _includeColumnHeaders ? table.columns.length : 0; const pivotTitle = this.getters.getPivotDisplayName(pivotId); const tableHeight = Math.min(headerRows + _rowCount, cells[0].length); if (tableHeight === 0) { return [[{ value: pivotTitle }]]; } const tableWidth = Math.min(1 + _columnCount, cells.length); const result = []; for (const col of range(0, tableWidth)) { result[col] = []; for (const row of range(0, tableHeight)) { const pivotCell = cells[col][row]; switch (pivotCell.type) { case "EMPTY": result[col].push({ value: "" }); break; case "HEADER": const valueAndFormat = pivot.getPivotHeaderValueAndFormat(pivotCell.domain); result[col].push(addIndentAndAlignToPivotHeader(pivot, pivotCell.domain, valueAndFormat)); break; case "MEASURE_HEADER": result[col].push(pivot.getPivotMeasureValue(pivotCell.measure, pivotCell.domain)); break; case "VALUE": result[col].push(pivot.getPivotCellValueAndFormat(pivotCell.measure, pivotCell.domain)); break; } } } if (_includeColumnHeaders) { result[0][0] = { value: pivotTitle }; } return result; }, }; //-------------------------------------------------------------------------- // OFFSET //-------------------------------------------------------------------------- const OFFSET = { description: _t("Returns a range reference shifted by a specified number of rows and columns from a starting cell reference."), args: [ arg("cell_reference (meta)", _t("The starting point from which to count the offset rows and columns.")), arg("offset_rows (number)", _t("The number of rows to offset by.")), arg("offset_columns (number)", _t("The number of columns to offset by.")), arg("height (number, default='height of cell_reference')", _t("The number of rows of the range to return starting at the offset target.")), arg("width (number, default='width of cell_reference')", _t("The number of columns of the range to return starting at the offset target.")), ], compute: function (cellReference, offsetRows, offsetColumns, height, width) { if (isEvaluationError(cellReference?.value)) { return cellReference; } const _cellReference = cellReference?.value; if (!_cellReference) { throw new Error("In this context, the function OFFSET needs to have a cell or range in parameter."); } const zone = toZone(_cellReference); let offsetHeight = zone.bottom - zone.top + 1; let offsetWidth = zone.right - zone.left + 1; if (height) { const _height = toNumber(height, this.locale); assertPositive(_t("Height value is %(_height)s. It should be greater than or equal to 1.", { _height }), _height); offsetHeight = _height; } if (width) { const _width = toNumber(width, this.locale); assertPositive(_t("Width value is %(_width)s. It should be greater than or equal to 1.", { _width }), _width); offsetWidth = _width; } const { sheetName } = splitReference(_cellReference); const sheetId = (sheetName && this.getters.getSheetIdByName(sheetName)) || this.getters.getActiveSheetId(); const _offsetRows = toNumber(offsetRows, this.locale); const _offsetColumns = toNumber(offsetColumns, this.locale); const originPosition = this.__originCellPosition; if (originPosition) { this.updateDependencies?.(originPosition); } const startingCol = zone.left + _offsetColumns; const startingRow = zone.top + _offsetRows; if (startingCol < 0 || startingRow < 0) { return new InvalidReferenceError(_t("OFFSET evaluates to an out of bounds range.")); } const dependencyZone = { left: startingCol, top: startingRow, right: startingCol + offsetWidth - 1, bottom: startingRow + offsetHeight - 1, }; const range = this.getters.getRangeFromZone(this.__originSheetId, dependencyZone); if (range.invalidXc || range.invalidSheetName) { return new InvalidReferenceError(); } if (originPosition) { this.addDependencies?.(originPosition, [range]); } return generateMatrix(offsetWidth, offsetHeight, (col, row) => this.getters.getEvaluatedCell({ sheetId, col: startingCol + col, row: startingRow + row, })); }, }; var lookup = /*#__PURE__*/Object.freeze({ __proto__: null, ADDRESS: ADDRESS, COLUMN: COLUMN, COLUMNS: COLUMNS, HLOOKUP: HLOOKUP, INDEX: INDEX, INDIRECT: INDIRECT, LOOKUP: LOOKUP, MATCH: MATCH, OFFSET: OFFSET, PIVOT: PIVOT, PIVOT_HEADER: PIVOT_HEADER, PIVOT_VALUE: PIVOT_VALUE, ROW: ROW, ROWS: ROWS, VLOOKUP: VLOOKUP, XLOOKUP: XLOOKUP }); // ----------------------------------------------------------------------------- // ADD // ----------------------------------------------------------------------------- const ADD = { description: _t("Sum of two numbers."), args: [ arg("value1 (number)", _t("The first addend.")), arg("value2 (number)", _t("The second addend.")), ], compute: function (value1, value2) { return { value: toNumber(value1, this.locale) + toNumber(value2, this.locale), format: value1?.format || value2?.format, }; }, }; // ----------------------------------------------------------------------------- // CONCAT // ----------------------------------------------------------------------------- const CONCAT = { description: _t("Concatenation of two values."), args: [ arg("value1 (string)", _t("The value to which value2 will be appended.")), arg("value2 (string)", _t("The value to append to value1.")), ], compute: function (value1, value2) { return toString(value1) + toString(value2); }, isExported: true, }; // ----------------------------------------------------------------------------- // DIVIDE // ----------------------------------------------------------------------------- const DIVIDE = { description: _t("One number divided by another."), args: [ arg("dividend (number)", _t("The number to be divided.")), arg("divisor (number)", _t("The number to divide by.")), ], compute: function (dividend, divisor) { const _divisor = toNumber(divisor, this.locale); assert(() => _divisor !== 0, _t("The divisor must be different from zero."), CellErrorType.DivisionByZero); return { value: toNumber(dividend, this.locale) / _divisor, format: dividend?.format || divisor?.format, }; }, }; // ----------------------------------------------------------------------------- // EQ // ----------------------------------------------------------------------------- function isEmpty(data) { return data === undefined || data.value === null; } const getNeutral = { number: 0, string: "", boolean: false }; const EQ = { description: _t("Equal."), args: [ arg("value1 (string, number, boolean)", _t("The first value.")), arg("value2 (string, number, boolean)", _t("The value to test against value1 for equality.")), ], compute: function (value1, value2) { let _value1 = isEmpty(value1) ? getNeutral[typeof value2?.value] : value1?.value; let _value2 = isEmpty(value2) ? getNeutral[typeof value1?.value] : value2?.value; if (typeof _value1 === "string") { _value1 = _value1.toUpperCase(); } if (typeof _value2 === "string") { _value2 = _value2.toUpperCase(); } return _value1 === _value2; }, }; // ----------------------------------------------------------------------------- // GT // ----------------------------------------------------------------------------- function applyRelationalOperator(value1, value2, cb) { let _value1 = isEmpty(value1) ? getNeutral[typeof value2?.value] : value1?.value; let _value2 = isEmpty(value2) ? getNeutral[typeof value1?.value] : value2?.value; if (isEvaluationError(_value1)) { throw value1; } if (isEvaluationError(_value2)) { throw value2; } if (typeof _value1 !== "number") { _value1 = toString(_value1).toUpperCase(); } if (typeof _value2 !== "number") { _value2 = toString(_value2).toUpperCase(); } const tV1 = typeof _value1; const tV2 = typeof _value2; if (tV1 === "string" && tV2 === "number") { return true; } if (tV2 === "string" && tV1 === "number") { return false; } return cb(_value1, _value2); } const GT = { description: _t("Strictly greater than."), args: [ arg("value1 (number, string, boolean)", _t("The value to test as being greater than value2.")), arg("value2 (number, string, boolean)", _t("The second value.")), ], compute: function (value1, value2) { return applyRelationalOperator(value1, value2, (v1, v2) => { return v1 > v2; }); }, }; // ----------------------------------------------------------------------------- // GTE // ----------------------------------------------------------------------------- const GTE = { description: _t("Greater than or equal to."), args: [ arg("value1 (number, string, boolean)", _t("The value to test as being greater than or equal to value2.")), arg("value2 (number, string, boolean)", _t("The second value.")), ], compute: function (value1, value2) { return applyRelationalOperator(value1, value2, (v1, v2) => { return v1 >= v2; }); }, }; // ----------------------------------------------------------------------------- // LT // ----------------------------------------------------------------------------- const LT = { description: _t("Less than."), args: [ arg("value1 (number, string, boolean)", _t("The value to test as being less than value2.")), arg("value2 (number, string, boolean)", _t("The second value.")), ], compute: function (value1, value2) { return !GTE.compute.bind(this)(value1, value2); }, }; // ----------------------------------------------------------------------------- // LTE // ----------------------------------------------------------------------------- const LTE = { description: _t("Less than or equal to."), args: [ arg("value1 (number, string, boolean)", _t("The value to test as being less than or equal to value2.")), arg("value2 (number, string, boolean)", _t("The second value.")), ], compute: function (value1, value2) { return !GT.compute.bind(this)(value1, value2); }, }; // ----------------------------------------------------------------------------- // MINUS // ----------------------------------------------------------------------------- const MINUS = { description: _t("Difference of two numbers."), args: [ arg("value1 (number)", _t("The minuend, or number to be subtracted from.")), arg("value2 (number)", _t("The subtrahend, or number to subtract from value1.")), ], compute: function (value1, value2) { return { value: toNumber(value1, this.locale) - toNumber(value2, this.locale), format: value1?.format || value2?.format, }; }, }; // ----------------------------------------------------------------------------- // MULTIPLY // ----------------------------------------------------------------------------- const MULTIPLY = { description: _t("Product of two numbers"), args: [ arg("factor1 (number)", _t("The first multiplicand.")), arg("factor2 (number)", _t("The second multiplicand.")), ], compute: function (factor1, factor2) { return { value: toNumber(factor1, this.locale) * toNumber(factor2, this.locale), format: factor1?.format || factor2?.format, }; }, }; // ----------------------------------------------------------------------------- // NE // ----------------------------------------------------------------------------- const NE = { description: _t("Not equal."), args: [ arg("value1 (string, number, boolean)", _t("The first value.")), arg("value2 (string, number, boolean)", _t("The value to test against value1 for inequality.")), ], compute: function (value1, value2) { return !EQ.compute.bind(this)(value1, value2); }, }; // ----------------------------------------------------------------------------- // POW // ----------------------------------------------------------------------------- const POW = { description: _t("A number raised to a power."), args: [ arg("base (number)", _t("The number to raise to the exponent power.")), arg("exponent (number)", _t("The exponent to raise base to.")), ], compute: function (base, exponent) { return POWER.compute.bind(this)(base, exponent); }, }; // ----------------------------------------------------------------------------- // UMINUS // ----------------------------------------------------------------------------- const UMINUS = { description: _t("A number with the sign reversed."), args: [ arg("value (number)", _t("The number to have its sign reversed. Equivalently, the number to multiply by -1.")), ], compute: function (value) { return { value: -toNumber(value, this.locale), format: value?.format, }; }, }; // ----------------------------------------------------------------------------- // UNARY_PERCENT // ----------------------------------------------------------------------------- const UNARY_PERCENT = { description: _t("Value interpreted as a percentage."), args: [arg("percentage (number)", _t("The value to interpret as a percentage."))], compute: function (percentage) { return toNumber(percentage, this.locale) / 100; }, }; // ----------------------------------------------------------------------------- // UPLUS // ----------------------------------------------------------------------------- const UPLUS = { description: _t("A specified number, unchanged."), args: [arg("value (any)", _t("The number to return."))], compute: function (value = { value: null }) { return value; }, }; var operators = /*#__PURE__*/Object.freeze({ __proto__: null, ADD: ADD, CONCAT: CONCAT, DIVIDE: DIVIDE, EQ: EQ, GT: GT, GTE: GTE, LT: LT, LTE: LTE, MINUS: MINUS, MULTIPLY: MULTIPLY, NE: NE, POW: POW, UMINUS: UMINUS, UNARY_PERCENT: UNARY_PERCENT, UPLUS: UPLUS }); const transformFromFactor = (factor) => ({ transform: (x) => x * factor, inverseTransform: (x) => x / factor, }); const standard = { transform: (x) => x, inverseTransform: (x) => x }; const ANG2M = 1e-10; const IN2M = 0.0254; const PICAPT2M = IN2M / 72; const FT2M = 0.3048; const YD2M = 0.9144; const MI2M = 1609.34; const NMI2M = 1852; const LY2M = 9.46073047258e15; const UNITS = { // WEIGHT UNITs : Standard = gramme g: { ...standard, category: "weight" }, u: { ...transformFromFactor(1.66053e-24), category: "weight" }, grain: { ...transformFromFactor(0.0647989), category: "weight" }, ozm: { ...transformFromFactor(28.3495), category: "weight" }, lbm: { ...transformFromFactor(453.592), category: "weight" }, stone: { ...transformFromFactor(6350.29), category: "weight" }, sg: { ...transformFromFactor(14593.90294), category: "weight" }, cwt: { ...transformFromFactor(45359.237), category: "weight" }, uk_cwt: { ...transformFromFactor(50802.3), category: "weight" }, ton: { ...transformFromFactor(907184.74), category: "weight" }, uk_ton: { ...transformFromFactor(1016046.9), category: "weight" }, // DISTANCE UNITS : Standard = meter m: { ...standard, category: "distance" }, km: { ...transformFromFactor(1000), category: "distance" }, ang: { ...transformFromFactor(ANG2M), category: "distance" }, Picapt: { ...transformFromFactor(PICAPT2M), category: "distance" }, pica: { ...transformFromFactor(IN2M / 6), category: "distance" }, in: { ...transformFromFactor(IN2M), category: "distance" }, ft: { ...transformFromFactor(FT2M), category: "distance" }, yd: { ...transformFromFactor(YD2M), category: "distance" }, ell: { ...transformFromFactor(1.143), category: "distance" }, mi: { ...transformFromFactor(MI2M), category: "distance" }, survey_mi: { ...transformFromFactor(1609.34), category: "distance" }, Nmi: { ...transformFromFactor(NMI2M), category: "distance" }, ly: { ...transformFromFactor(LY2M), category: "distance" }, parsec: { ...transformFromFactor(3.0856775814914e16), category: "distance" }, // TIME UNITS : Standard = second sec: { ...standard, category: "time" }, min: { ...transformFromFactor(60), category: "time" }, hr: { ...transformFromFactor(3600), category: "time" }, day: { ...transformFromFactor(86400), category: "time" }, yr: { ...transformFromFactor(31556952), category: "time" }, // PRESSURE UNITS : Standard = Pascal Pa: { ...standard, category: "pressure" }, bar: { ...transformFromFactor(100000), category: "pressure" }, mmHg: { ...transformFromFactor(133.322), category: "pressure" }, Torr: { ...transformFromFactor(133.322), category: "pressure" }, psi: { ...transformFromFactor(6894.76), category: "pressure" }, atm: { ...transformFromFactor(101325), category: "pressure" }, // FORCE UNITS : Standard = Newton N: { ...standard, category: "force" }, dyn: { ...transformFromFactor(1e-5), category: "force" }, pond: { ...transformFromFactor(0.00980665), category: "force" }, lbf: { ...transformFromFactor(4.44822), category: "force" }, // ENERGY UNITS : Standard = Joule J: { ...standard, category: "energy" }, eV: { ...transformFromFactor(1.60218e-19), category: "energy" }, e: { ...transformFromFactor(1e-7), category: "energy" }, flb: { ...transformFromFactor(1.3558179483), category: "energy" }, c: { ...transformFromFactor(4.184), category: "energy" }, cal: { ...transformFromFactor(4.1868), category: "energy" }, BTU: { ...transformFromFactor(1055.06), category: "energy" }, Wh: { ...transformFromFactor(3600), category: "energy" }, HPh: { ...transformFromFactor(2684520), category: "energy" }, // POWER UNITS : Standard = Watt W: { ...standard, category: "power" }, PS: { ...transformFromFactor(735.499), category: "power" }, HP: { ...transformFromFactor(745.7), category: "power" }, // MAGNETISM UNITS : Standard = Tesla T: { ...standard, category: "magnetism" }, ga: { ...transformFromFactor(1e-4), category: "magnetism" }, // TEMPERATURE UNITS : Standard = Kelvin K: { ...standard, category: "temperature" }, C: { transform: (T) => T + 273.15, inverseTransform: (T) => T - 273.15, category: "temperature", }, F: { transform: (T) => ((T - 32) * 5) / 9 + 273.15, inverseTransform: (T) => ((T - 273.15) * 9) / 5 + 32, category: "temperature", }, Rank: { ...transformFromFactor(5 / 9), category: "temperature" }, Reau: { transform: (T) => T * 1.25 + 273.15, inverseTransform: (T) => (T - 273.15) / 1.25, category: "temperature", }, // VOLUME UNITS : Standard = cubic meter "m^3": { ...standard, category: "volume", order: 3 }, "ang^3": { ...transformFromFactor(Math.pow(ANG2M, 3)), category: "volume", order: 3 }, "Picapt^3": { ...transformFromFactor(Math.pow(PICAPT2M, 3)), category: "volume", order: 3 }, tsp: { ...transformFromFactor(4.92892e-6), category: "volume" }, tspm: { ...transformFromFactor(5e-6), category: "volume" }, tbs: { ...transformFromFactor(1.4786764825785619e-5), category: "volume" }, "in^3": { ...transformFromFactor(Math.pow(IN2M, 3)), category: "volume", order: 3 }, oz: { ...transformFromFactor(2.95735295625e-5), category: "volume" }, cup: { ...transformFromFactor(0.000237), category: "volume" }, pt: { ...transformFromFactor(0.0004731765), category: "volume" }, uk_pt: { ...transformFromFactor(0.000568261), category: "volume" }, qt: { ...transformFromFactor(0.0009463529), category: "volume" }, l: { ...transformFromFactor(1e-3), category: "volume" }, uk_qt: { ...transformFromFactor(0.0011365225), category: "volume" }, gal: { ...transformFromFactor(0.0037854118), category: "volume" }, uk_gal: { ...transformFromFactor(0.00454609), category: "volume" }, "ft^3": { ...transformFromFactor(Math.pow(FT2M, 3)), category: "volume", order: 3 }, bushel: { ...transformFromFactor(0.0352390704), category: "volume" }, barrel: { ...transformFromFactor(0.158987295), category: "volume" }, "yd^3": { ...transformFromFactor(Math.pow(YD2M, 3)), category: "volume", order: 3 }, MTON: { ...transformFromFactor(1.13267386368), category: "volume" }, GRT: { ...transformFromFactor(2.83168), category: "volume" }, "mi^3": { ...transformFromFactor(Math.pow(MI2M, 3)), category: "volume", order: 3 }, "Nmi^3": { ...transformFromFactor(Math.pow(NMI2M, 3)), category: "volume", order: 3 }, "ly^3": { ...transformFromFactor(Math.pow(LY2M, 3)), category: "volume", order: 3 }, // AREA UNITS : Standard = square meter "m^2": { ...standard, category: "area", order: 2 }, "ang^2": { ...transformFromFactor(Math.pow(ANG2M, 2)), category: "area", order: 2 }, "Picapt^2": { ...transformFromFactor(Math.pow(PICAPT2M, 2)), category: "area", order: 2 }, "in^2": { ...transformFromFactor(Math.pow(IN2M, 2)), category: "area", order: 2 }, "ft^2": { ...transformFromFactor(Math.pow(FT2M, 2)), category: "area", order: 2 }, "yd^2": { ...transformFromFactor(Math.pow(YD2M, 2)), category: "area", order: 2 }, ar: { ...transformFromFactor(100), category: "area" }, Morgen: { ...transformFromFactor(2500), category: "area" }, uk_acre: { ...transformFromFactor(4046.8564224), category: "area" }, us_acre: { ...transformFromFactor(4046.8726098743), category: "area" }, ha: { ...transformFromFactor(1e4), category: "area" }, "mi^2": { ...transformFromFactor(Math.pow(MI2M, 2)), category: "area", order: 2 }, "Nmi^2": { ...transformFromFactor(Math.pow(NMI2M, 2)), category: "area", order: 2 }, "ly^2": { ...transformFromFactor(Math.pow(LY2M, 2)), category: "area", order: 2 }, // INFORMATION UNITS : Standard = bit bit: { ...standard, category: "information" }, byte: { ...transformFromFactor(8), category: "information" }, // SPEED UNITS : Standard = m/s "m/s": { ...standard, category: "speed" }, "m/hr": { ...transformFromFactor(1 / 3600), category: "speed" }, "km/hr": { ...transformFromFactor(1 / 3.6), category: "speed" }, mph: { ...transformFromFactor(0.44704), category: "speed" }, kn: { ...transformFromFactor(0.5144444444), category: "speed" }, admkn: { ...transformFromFactor(0.5147733333), category: "speed" }, }; const UNITS_ALIASES = { shweight: "cwt", lcwt: "uk_cwt", hweight: "uk_cwt", LTON: "uk_ton", brton: "uk_ton", pc: "parsec", Pica: "Picapt", d: "day", mn: "min", s: "sec", p: "Pa", at: "atm", dy: "dyn", ev: "eV", hh: "HPh", wh: "Wh", btu: "BTU", h: "HP", cel: "C", fah: "F", kel: "K", us_pt: "pt", L: "l", lt: "l", ang3: "ang^3", ft3: "ft^3", in3: "in^3", ly3: "ly^3", m3: "m^3", mi3: "mi^3", yd3: "yd^3", Nmi3: "Nmi^3", Picapt3: "Picapt^3", "Pica^3": "Picapt^3", Pica3: "Picapt^3", regton: "GRT", ang2: "ang^2", ft2: "ft^2", in2: "in^2", ly2: "ly^2", m2: "m^2", mi2: "mi^2", Nmi2: "Nmi^2", Picapt2: "Picapt^2", "Pica^2": "Picapt^2", Pica2: "Picapt^2", yd2: "yd^2", "m/h": "m/hr", "m/sec": "m/s", }; const UNIT_PREFIXES = { "": 1, Y: 1e24, Z: 1e21, E: 1e18, P: 1e15, T: 1e12, G: 1e9, M: 1e6, k: 1e3, h: 1e2, da: 1e1, e: 1e1, d: 1e-1, c: 1e-2, m: 1e-3, u: 1e-6, n: 1e-9, p: 1e-12, f: 1e-15, a: 1e-18, z: 1e-21, y: 1e-21, Yi: Math.pow(2, 80), Zi: Math.pow(2, 70), Ei: Math.pow(2, 60), Pi: Math.pow(2, 50), Ti: Math.pow(2, 40), Gi: Math.pow(2, 30), Mi: Math.pow(2, 20), ki: Math.pow(2, 10), }; const TRANSLATED_CATEGORIES = { weight: _t("Weight"), distance: _t("Distance"), time: _t("Time"), pressure: _t("Pressure"), force: _t("Force"), energy: _t("Energy"), power: _t("Power"), magnetism: _t("Magnetism"), temperature: _t("Temperature"), volume: _t("Volume"), area: _t("Area"), information: _t("Information"), speed: _t("Speed"), }; function getTranslatedCategory(key) { return TRANSLATED_CATEGORIES[key] ?? ""; } function getTransformation(key) { for (const [prefix, value] of Object.entries(UNIT_PREFIXES)) { if (prefix && !key.startsWith(prefix)) continue; const _key = key.slice(prefix.length); let conversion = UNITS[_key]; if (!conversion && UNITS_ALIASES[_key]) { conversion = UNITS[UNITS_ALIASES[_key]]; } if (conversion) { return { ...conversion, factor: conversion.order ? Math.pow(value, conversion.order) : value, }; } } return; } // ----------------------------------------------------------------------------- // CONVERT // ----------------------------------------------------------------------------- const CONVERT = { description: _t("Converts a numeric value to a different unit of measure."), args: [ arg("value (number)", _t("the numeric value in start_unit to convert to end_unit")), arg("start_unit (string)", _t("The starting unit, the unit currently assigned to value")), arg("end_unit (string)", _t("The unit of measure into which to convert value")), ], compute: function (value, startUnit, endUnit) { const _value = toNumber(value, this.locale); const _startUnit = toString(startUnit); const _endUnit = toString(endUnit); const startConversion = getTransformation(_startUnit); const endConversion = getTransformation(_endUnit); if (!startConversion) { return { value: CellErrorType.GenericError, message: _t("Invalid units of measure ('%s')", _startUnit), }; } if (!endConversion) { return { value: CellErrorType.GenericError, message: _t("Invalid units of measure ('%s')", _endUnit), }; } if (startConversion.category !== endConversion.category) { return { value: CellErrorType.GenericError, message: _t("Incompatible units of measure ('%s' vs '%s')", getTranslatedCategory(startConversion.category), getTranslatedCategory(endConversion.category)), }; } return { value: endConversion.inverseTransform(startConversion.factor * startConversion.transform(_value)) / endConversion.factor, format: value?.format, }; }, isExported: true, }; var parser = /*#__PURE__*/Object.freeze({ __proto__: null, CONVERT: CONVERT }); const DEFAULT_STARTING_AT = 1; /** Regex matching all the words in a string */ const wordRegex = /[A-Za-zÀ-ÖØ-öø-ÿ]+/g; // ----------------------------------------------------------------------------- // CHAR // ----------------------------------------------------------------------------- const CHAR = { description: _t("Gets character associated with number."), args: [ arg("table_number (number)", _t("The number of the character to look up from the current Unicode table in decimal format.")), ], compute: function (tableNumber) { const _tableNumber = Math.trunc(toNumber(tableNumber, this.locale)); assert(() => _tableNumber >= 1, _t("The table_number (%s) is out of range.", _tableNumber.toString())); return String.fromCharCode(_tableNumber); }, isExported: true, }; // ----------------------------------------------------------------------------- // CLEAN // ----------------------------------------------------------------------------- const CLEAN = { description: _t("Remove non-printable characters from a piece of text."), args: [arg("text (string)", _t("The text whose non-printable characters are to be removed."))], compute: function (text) { const _text = toString(text); let cleanedStr = ""; for (const char of _text) { if (char && char.charCodeAt(0) > 31) { cleanedStr += char; } } return cleanedStr; }, isExported: true, }; // ----------------------------------------------------------------------------- // CONCATENATE // ----------------------------------------------------------------------------- const CONCATENATE = { description: _t("Appends strings to one another."), args: [ arg("string1 (string, range)", _t("The initial string.")), arg("string2 (string, range, repeating)", _t("More strings to append in sequence.")), ], compute: function (...datas) { return reduceAny(datas, (acc, a) => acc + toString(a), ""); }, isExported: true, }; // ----------------------------------------------------------------------------- // EXACT // ----------------------------------------------------------------------------- const EXACT = { description: _t("Tests whether two strings are identical."), args: [ arg("string1 (string)", _t("The first string to compare.")), arg("string2 (string)", _t("The second string to compare.")), ], compute: function (string1, string2) { return toString(string1) === toString(string2); }, isExported: true, }; // ----------------------------------------------------------------------------- // FIND // ----------------------------------------------------------------------------- const FIND = { description: _t("First position of string found in text, case-sensitive."), args: [ arg("search_for (string)", _t("The string to look for within text_to_search.")), arg("text_to_search (string)", _t("The text to search for the first occurrence of search_for.")), arg(`starting_at (number, default=${DEFAULT_STARTING_AT})`, _t("The character within text_to_search at which to start the search.")), ], compute: function (searchFor, textToSearch, startingAt = { value: DEFAULT_STARTING_AT }) { const _searchFor = toString(searchFor); const _textToSearch = toString(textToSearch); const _startingAt = toNumber(startingAt, this.locale); assert(() => _textToSearch !== "", _t("The text_to_search must be non-empty.")); assert(() => _startingAt >= 1, _t("The starting_at (%s) must be greater than or equal to 1.", _startingAt.toString())); const result = _textToSearch.indexOf(_searchFor, _startingAt - 1); assert(() => result >= 0, _t("In [[FUNCTION_NAME]] evaluation, cannot find '%s' within '%s'.", _searchFor.toString(), _textToSearch)); return result + 1; }, isExported: true, }; // ----------------------------------------------------------------------------- // JOIN // ----------------------------------------------------------------------------- const JOIN = { description: _t("Concatenates elements of arrays with delimiter."), args: [ arg("delimiter (string)", _t("The character or string to place between each concatenated value.")), arg("value_or_array1 (string, range)", _t("The value or values to be appended using delimiter.")), arg("value_or_array2 (string, range, repeating)", _t("More values to be appended using delimiter.")), ], compute: function (delimiter, ...valuesOrArrays) { const _delimiter = toString(delimiter); return reduceAny(valuesOrArrays, (acc, a) => (acc ? acc + _delimiter : "") + toString(a), ""); }, }; // ----------------------------------------------------------------------------- // LEFT // ----------------------------------------------------------------------------- const LEFT = { description: _t("Substring from beginning of specified string."), args: [ arg("text (string)", _t("The string from which the left portion will be returned.")), arg("number_of_characters (number, optional)", _t("The number of characters to return from the left side of string.")), ], compute: function (text, ...args) { const _numberOfCharacters = args.length ? toNumber(args[0], this.locale) : 1; assert(() => _numberOfCharacters >= 0, _t("The number_of_characters (%s) must be positive or null.", _numberOfCharacters.toString())); return toString(text).substring(0, _numberOfCharacters); }, isExported: true, }; // ----------------------------------------------------------------------------- // LEN // ----------------------------------------------------------------------------- const LEN = { description: _t("Length of a string."), args: [arg("text (string)", _t("The string whose length will be returned."))], compute: function (text) { return toString(text).length; }, isExported: true, }; // ----------------------------------------------------------------------------- // LOWER // ----------------------------------------------------------------------------- const LOWER = { description: _t("Converts a specified string to lowercase."), args: [arg("text (string)", _t("The string to convert to lowercase."))], compute: function (text) { return toString(text).toLowerCase(); }, isExported: true, }; // ----------------------------------------------------------------------------- // MID // ----------------------------------------------------------------------------- const MID = { description: _t("A segment of a string."), args: [ arg("text (string)", _t("The string to extract a segment from.")), arg("starting_at (number)", _t("The index from the left of string from which to begin extracting. The first character in string has the index 1.")), arg("extract_length (number)", _t("The length of the segment to extract.")), ], compute: function (text, starting_at, extract_length) { const _text = toString(text); const _starting_at = toNumber(starting_at, this.locale); const _extract_length = toNumber(extract_length, this.locale); assert(() => _starting_at >= 1, _t("The starting_at argument (%s) must be positive greater than one.", _starting_at.toString())); assert(() => _extract_length >= 0, _t("The extract_length argument (%s) must be positive or null.", _extract_length.toString())); return _text.slice(_starting_at - 1, _starting_at + _extract_length - 1); }, isExported: true, }; // ----------------------------------------------------------------------------- // PROPER // ----------------------------------------------------------------------------- const PROPER = { description: _t("Capitalizes each word in a specified string."), args: [ arg("text_to_capitalize (string)", _t("The text which will be returned with the first letter of each word in uppercase and all other letters in lowercase.")), ], compute: function (text) { const _text = toString(text); return _text.replace(wordRegex, (word) => { return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); }); }, isExported: true, }; // ----------------------------------------------------------------------------- // REPLACE // ----------------------------------------------------------------------------- const REPLACE = { description: _t("Replaces part of a text string with different text."), args: [ arg("text (string)", _t("The text, a part of which will be replaced.")), arg("position (number)", _t("The position where the replacement will begin (starting from 1).")), arg("length (number)", _t("The number of characters in the text to be replaced.")), arg("new_text (string)", _t("The text which will be inserted into the original text.")), ], compute: function (text, position, length, newText) { const _position = toNumber(position, this.locale); assert(() => _position >= 1, _t("The position (%s) must be greater than or equal to 1.", _position.toString())); const _text = toString(text); const _length = toNumber(length, this.locale); const _newText = toString(newText); return _text.substring(0, _position - 1) + _newText + _text.substring(_position - 1 + _length); }, isExported: true, }; // ----------------------------------------------------------------------------- // RIGHT // ----------------------------------------------------------------------------- const RIGHT = { description: _t("A substring from the end of a specified string."), args: [ arg("text (string)", _t("The string from which the right portion will be returned.")), arg("number_of_characters (number, optional)", _t("The number of characters to return from the right side of string.")), ], compute: function (text, ...args) { const _numberOfCharacters = args.length ? toNumber(args[0], this.locale) : 1; assert(() => _numberOfCharacters >= 0, _t("The number_of_characters (%s) must be positive or null.", _numberOfCharacters.toString())); const _text = toString(text); const stringLength = _text.length; return _text.substring(stringLength - _numberOfCharacters, stringLength); }, isExported: true, }; // ----------------------------------------------------------------------------- // SEARCH // ----------------------------------------------------------------------------- const SEARCH = { description: _t("First position of string found in text, ignoring case."), args: [ arg("search_for (string)", _t("The string to look for within text_to_search.")), arg("text_to_search (string)", _t("The text to search for the first occurrence of search_for.")), arg(`starting_at (number, default=${DEFAULT_STARTING_AT})`, _t("The character within text_to_search at which to start the search.")), ], compute: function (searchFor, textToSearch, startingAt = { value: DEFAULT_STARTING_AT }) { const _searchFor = toString(searchFor).toLowerCase(); const _textToSearch = toString(textToSearch).toLowerCase(); const _startingAt = toNumber(startingAt, this.locale); assert(() => _textToSearch !== "", _t("The text_to_search must be non-empty.")); assert(() => _startingAt >= 1, _t("The starting_at (%s) must be greater than or equal to 1.", _startingAt.toString())); const result = _textToSearch.indexOf(_searchFor, _startingAt - 1); assert(() => result >= 0, _t("In [[FUNCTION_NAME]] evaluation, cannot find '%s' within '%s'.", _searchFor, _textToSearch)); return result + 1; }, isExported: true, }; // ----------------------------------------------------------------------------- // SPLIT // ----------------------------------------------------------------------------- const SPLIT_DEFAULT_SPLIT_BY_EACH = true; const SPLIT_DEFAULT_REMOVE_EMPTY_TEXT = true; const SPLIT = { description: _t("Split text by specific character delimiter(s)."), args: [ arg("text (string)", _t("The text to divide.")), arg("delimiter (string)", _t("The character or characters to use to split text.")), arg(`split_by_each (boolean, default=${SPLIT_DEFAULT_SPLIT_BY_EACH}})`, _t("Whether or not to divide text around each character contained in delimiter.")), arg(`remove_empty_text (boolean, default=${SPLIT_DEFAULT_REMOVE_EMPTY_TEXT})`, _t("Whether or not to remove empty text messages from the split results. The default behavior is to treat \ consecutive delimiters as one (if TRUE). If FALSE, empty cells values are added between consecutive delimiters.")), ], compute: function (text, delimiter, splitByEach = { value: SPLIT_DEFAULT_SPLIT_BY_EACH }, removeEmptyText = { value: SPLIT_DEFAULT_REMOVE_EMPTY_TEXT }) { const _text = toString(text); const _delimiter = escapeRegExp(toString(delimiter)); const _splitByEach = toBoolean(splitByEach); const _removeEmptyText = toBoolean(removeEmptyText); assert(() => _delimiter.length > 0, _t("The _delimiter (%s) must be not be empty.", _delimiter)); const regex = _splitByEach ? new RegExp(`[${_delimiter}]`, "g") : new RegExp(_delimiter, "g"); let result = _text.split(regex); if (_removeEmptyText) { result = result.filter((text) => text !== ""); } return transposeMatrix([result]); }, isExported: true, }; // ----------------------------------------------------------------------------- // SUBSTITUTE // ----------------------------------------------------------------------------- const SUBSTITUTE = { description: _t("Replaces existing text with new text in a string."), args: [ arg("text_to_search (string)", _t("The text within which to search and replace.")), arg("search_for (string)", _t("The string to search for within text_to_search.")), arg("replace_with (string)", _t("The string that will replace search_for.")), arg("occurrence_number (number, optional)", _t("The instance of search_for within text_to_search to replace with replace_with. By default, all occurrences of search_for are replaced; however, if occurrence_number is specified, only the indicated instance of search_for is replaced.")), ], compute: function (textToSearch, searchFor, replaceWith, occurrenceNumber) { const _occurrenceNumber = toNumber(occurrenceNumber, this.locale); assert(() => _occurrenceNumber >= 0, _t("The occurrenceNumber (%s) must be positive or null.", _occurrenceNumber.toString())); const _textToSearch = toString(textToSearch); const _searchFor = toString(searchFor); if (_searchFor === "") { return _textToSearch; } const _replaceWith = toString(replaceWith); const reg = new RegExp(escapeRegExp(_searchFor), "g"); if (_occurrenceNumber === 0) { return _textToSearch.replace(reg, _replaceWith); } let n = 0; return _textToSearch.replace(reg, (text) => (++n === _occurrenceNumber ? _replaceWith : text)); }, isExported: true, }; // ----------------------------------------------------------------------------- // TEXTJOIN // ----------------------------------------------------------------------------- const TEXTJOIN = { description: _t("Combines text from multiple strings and/or arrays."), args: [ arg("delimiter (string)", _t(" A string, possible empty, or a reference to a valid string. If empty, the text will be simply concatenated.")), arg("ignore_empty (boolean)", _t("A boolean; if TRUE, empty cells selected in the text arguments won't be included in the result.")), arg("text1 (string, range)", _t("Any text item. This could be a string, or an array of strings in a range.")), arg("text2 (string, range, repeating)", _t("Additional text item(s).")), ], compute: function (delimiter, ignoreEmpty, ...textsOrArrays) { const _delimiter = toString(delimiter); const _ignoreEmpty = toBoolean(ignoreEmpty); let n = 0; return reduceAny(textsOrArrays, (acc, a) => !(_ignoreEmpty && toString(a) === "") ? (n++ ? acc + _delimiter : "") + toString(a) : acc, ""); }, isExported: true, }; // ----------------------------------------------------------------------------- // TRIM // ----------------------------------------------------------------------------- const TRIM = { description: _t("Removes space characters."), args: [ arg("text (string)", _t("The text or reference to a cell containing text to be trimmed.")), ], compute: function (text) { return trimContent(toString(text)); }, isExported: true, }; // ----------------------------------------------------------------------------- // UPPER // ----------------------------------------------------------------------------- const UPPER = { description: _t("Converts a specified string to uppercase."), args: [arg("text (string)", _t("The string to convert to uppercase."))], compute: function (text) { return toString(text).toUpperCase(); }, isExported: true, }; // ----------------------------------------------------------------------------- // TEXT // ----------------------------------------------------------------------------- const TEXT = { description: _t("Converts a number to text according to a specified format."), args: [ arg("number (number)", _t("The number, date or time to format.")), arg("format (string)", _t("The pattern by which to format the number, enclosed in quotation marks.")), ], compute: function (number, format) { const _number = toNumber(number, this.locale); return formatValue(_number, { format: toString(format), locale: this.locale }); }, isExported: true, }; var text = /*#__PURE__*/Object.freeze({ __proto__: null, CHAR: CHAR, CLEAN: CLEAN, CONCATENATE: CONCATENATE, EXACT: EXACT, FIND: FIND, JOIN: JOIN, LEFT: LEFT, LEN: LEN, LOWER: LOWER, MID: MID, PROPER: PROPER, REPLACE: REPLACE, RIGHT: RIGHT, SEARCH: SEARCH, SPLIT: SPLIT, SUBSTITUTE: SUBSTITUTE, TEXT: TEXT, TEXTJOIN: TEXTJOIN, TRIM: TRIM, UPPER: UPPER }); // ----------------------------------------------------------------------------- // HYPERLINK // ----------------------------------------------------------------------------- const HYPERLINK = { description: _t("Creates a hyperlink in a cell."), args: [ arg("url (string)", _t("The full URL of the link enclosed in quotation marks.")), arg("link_label (string, optional)", _t("The text to display in the cell, enclosed in quotation marks.")), ], compute: function (url, linkLabel) { const processedUrl = toString(url).trim(); const processedLabel = toString(linkLabel) || processedUrl; if (processedUrl === "") return processedLabel; return markdownLink(processedLabel, processedUrl); }, isExported: true, }; var web = /*#__PURE__*/Object.freeze({ __proto__: null, HYPERLINK: HYPERLINK }); const categories = [ { name: _t("Array"), functions: array }, { name: _t("Database"), functions: database }, { name: _t("Date"), functions: date }, { name: _t("Filter"), functions: filter }, { name: _t("Financial"), functions: financial }, { name: _t("Info"), functions: info }, { name: _t("Lookup"), functions: lookup }, { name: _t("Logical"), functions: logical }, { name: _t("Math"), functions: math }, { name: _t("Misc"), functions: misc }, { name: _t("Operator"), functions: operators }, { name: _t("Statistical"), functions: statistical }, { name: _t("Text"), functions: text }, { name: _t("Engineering"), functions: engineering }, { name: _t("Web"), functions: web }, { name: _t("Parser"), functions: parser }, ]; const functionNameRegex = /^[A-Z0-9\_\.]+$/; class FunctionRegistry extends Registry { mapping = {}; add(name, addDescr) { name = name.toUpperCase(); if (!functionNameRegex.test(name)) { throw new Error(_t("Invalid function name %s. Function names can exclusively contain alphanumerical values separated by dots (.) or underscore (_)", name)); } const descr = addMetaInfoFromArg(addDescr); validateArguments(descr.args); this.mapping[name] = createComputeFunction(descr, name); super.add(name, descr); return this; } } const functionRegistry = new FunctionRegistry(); for (let category of categories) { const fns = category.functions; for (let name in fns) { const addDescr = fns[name]; addDescr.category = addDescr.category || category.name; name = name.replace(/_/g, "."); functionRegistry.add(name, { isExported: false, ...addDescr }); } } const notAvailableError = new NotAvailableError(_t("Array arguments to [[FUNCTION_NAME]] are of different size.")); function createComputeFunction(descr, functionName) { function vectorizedCompute(...args) { let countVectorizableCol = 1; let countVectorizableRow = 1; let vectorizableColLimit = Infinity; let vectorizableRowLimit = Infinity; let vectorArgsType = undefined; //#region Compute vectorisation limits for (let i = 0; i < args.length; i++) { const argDefinition = descr.args[descr.getArgToFocus(i + 1) - 1]; const arg = args[i]; if (isMatrix(arg) && !argDefinition.acceptMatrix) { // if argDefinition does not accept a matrix but arg is still a matrix // --> triggers the arguments vectorization const nColumns = arg.length; const nRows = arg[0].length; if (nColumns !== 1 || nRows !== 1) { vectorArgsType ??= new Array(args.length); if (nColumns !== 1 && nRows !== 1) { vectorArgsType[i] = "matrix"; countVectorizableCol = Math.max(countVectorizableCol, nColumns); countVectorizableRow = Math.max(countVectorizableRow, nRows); vectorizableColLimit = Math.min(vectorizableColLimit, nColumns); vectorizableRowLimit = Math.min(vectorizableRowLimit, nRows); } else if (nColumns !== 1) { vectorArgsType[i] = "horizontal"; countVectorizableCol = Math.max(countVectorizableCol, nColumns); vectorizableColLimit = Math.min(vectorizableColLimit, nColumns); } else if (nRows !== 1) { vectorArgsType[i] = "vertical"; countVectorizableRow = Math.max(countVectorizableRow, nRows); vectorizableRowLimit = Math.min(vectorizableRowLimit, nRows); } } else { args[i] = arg[0][0]; } } if (!isMatrix(arg) && argDefinition.acceptMatrixOnly) { throw new BadExpressionError(_t("Function %s expects the parameter '%s' to be reference to a cell or range.", functionName, (i + 1).toString())); } } //#endregion if (countVectorizableCol === 1 && countVectorizableRow === 1) { // either this function is not vectorized or it ends up with a 1x1 dimension return errorHandlingCompute.apply(this, args); } const getArgOffset = (i, j) => args.map((arg, index) => { switch (vectorArgsType?.[index]) { case "matrix": return arg[i][j]; case "horizontal": return arg[i][0]; case "vertical": return arg[0][j]; case undefined: return arg; } }); return generateMatrix(countVectorizableCol, countVectorizableRow, (col, row) => { if (col > vectorizableColLimit - 1 || row > vectorizableRowLimit - 1) { return notAvailableError; } const singleCellComputeResult = errorHandlingCompute.apply(this, getArgOffset(col, row)); // In the case where the user tries to vectorize arguments of an array formula, we will get an // array for every combination of the vectorized arguments, which will lead to a 3D matrix and // we won't be able to return the values. // In this case, we keep the first element of each spreading part, just as Excel does, and // create an array with these parts. // For exemple, we have MUNIT(x) that return an unitary matrix of x*x. If we use it with a // range, like MUNIT(A1:A2), we will get two unitary matrices (one for the value in A1 and one // for the value in A2). In this case, we will simply take the first value of each matrix and // return the array [First value of MUNIT(A1), First value of MUNIT(A2)]. return isMatrix(singleCellComputeResult) ? singleCellComputeResult[0][0] : singleCellComputeResult; }); } function errorHandlingCompute(...args) { for (let i = 0; i < args.length; i++) { const arg = args[i]; const argDefinition = descr.args[descr.getArgToFocus(i + 1) - 1]; // Early exit if the argument is an error and the function does not accept errors // We only check scalar arguments, not matrix arguments for performance reasons. // Casting helpers are responsible for handling errors in matrix arguments. if (!argDefinition.acceptErrors && !isMatrix(arg) && isEvaluationError(arg?.value)) { return arg; } } try { return computeFunctionToObject.apply(this, args); } catch (e) { return handleError(e, functionName); } } function computeFunctionToObject(...args) { const result = descr.compute.apply(this, args); if (!isMatrix(result)) { if (typeof result === "object" && result !== null && "value" in result) { replaceFunctionNamePlaceholder(result, functionName); return result; } return { value: result }; } if (typeof result[0][0] === "object" && result[0][0] !== null && "value" in result[0][0]) { matrixForEach(result, (result) => replaceFunctionNamePlaceholder(result, functionName)); return result; } return matrixMap(result, (row) => ({ value: row })); } return vectorizedCompute; } function handleError(e, functionName) { // the error could be an user error (instance of EvaluationError) // or a javascript error (instance of Error) // we don't want block the user with an implementation error // so we fallback to a generic error if (hasStringValue(e) && isEvaluationError(e.value)) { if (hasStringMessage(e)) { replaceFunctionNamePlaceholder(e, functionName); } return e; } console.error(e); return new EvaluationError(implementationErrorMessage + (hasStringMessage(e) ? " " + e.message : "")); } function hasStringValue(obj) { return (obj?.value !== undefined && typeof obj.value === "string"); } function replaceFunctionNamePlaceholder(functionResult, functionName) { // for performance reasons: change in place and only if needed if (functionResult.message?.includes("[[FUNCTION_NAME]]")) { functionResult.message = functionResult.message.replace("[[FUNCTION_NAME]]", functionName); } } const implementationErrorMessage = _t("An unexpected error occurred. Submit a support ticket at odoo.com/help."); function hasStringMessage(obj) { return (obj?.message !== undefined && typeof obj.message === "string"); } autoCompleteProviders.add("functions", { sequence: 100, autoSelectFirstProposal: true, maxDisplayedProposals: 10, getProposals(tokenAtCursor) { if (tokenAtCursor.type !== "SYMBOL") { return []; } const searchTerm = tokenAtCursor.value; if (!this.composer.currentContent.startsWith("=")) { return []; } const values = Object.entries(functionRegistry.content) .filter(([_, { hidden }]) => !hidden) .map(([text, { description }]) => { return { text, description, htmlContent: getHtmlContentFromPattern(searchTerm, text, COMPOSER_ASSISTANT_COLOR, "o-semi-bold"), }; }) .sort((a, b) => { return a.text.length - b.text.length || a.text.localeCompare(b.text); }); return values; }, selectProposal(tokenAtCursor, value) { let start = tokenAtCursor.end; let end = tokenAtCursor.end; // shouldn't it be REFERENCE ? if (["SYMBOL", "FUNCTION"].includes(tokenAtCursor.type)) { start = tokenAtCursor.start; } const tokens = this.composer.currentTokens; value += "("; const currentTokenIndex = tokens.map((token) => token.start).indexOf(tokenAtCursor.start); if (currentTokenIndex + 1 < tokens.length) { const nextToken = tokens[currentTokenIndex + 1]; if (nextToken?.type === "LEFT_PAREN") { end++; } } this.composer.changeComposerCursorSelection(start, end); this.composer.replaceComposerCursorSelection(value); }, }); class DOMFocusableElementStore { mutators = ["setFocusableElement", "focus"]; focusableElement = undefined; setFocusableElement(element) { this.focusableElement = element; } focus() { this.focusableElement?.focus(); } } /** * This file is largely inspired by owl 1. * `css` tag has been removed from owl 2 without workaround to manage css. * So, the solution was to import the behavior of owl 1 directly in our * codebase, with one difference: the css is added to the sheet as soon as the * css tag is executed. In owl 1, the css was added as soon as a Component was * created for the first time. */ const STYLESHEETS = {}; let nextId = 0; /** * CSS tag helper for defining inline stylesheets. With this, one can simply define * an inline stylesheet with just the following code: * ```js * css`.component-a { color: red; }`; * ``` */ function css(strings, ...args) { const name = `__sheet__${nextId++}`; const value = String.raw(strings, ...args); registerSheet(name, value); activateSheet(name); return name; } function processSheet(str) { const tokens = str.split(/(\{|\}|;)/).map((s) => s.trim()); const selectorStack = []; const parts = []; let rules = []; function generateSelector(stackIndex, parentSelector) { const parts = []; for (const selector of selectorStack[stackIndex]) { let part = (parentSelector && parentSelector + " " + selector) || selector; if (part.includes("&")) { part = selector.replace(/&/g, parentSelector || ""); } if (stackIndex < selectorStack.length - 1) { part = generateSelector(stackIndex + 1, part); } parts.push(part); } return parts.join(", "); } function generateRules() { if (rules.length) { parts.push(generateSelector(0) + " {"); parts.push(...rules); parts.push("}"); rules = []; } } while (tokens.length) { let token = tokens.shift(); if (token === "}") { generateRules(); selectorStack.pop(); } else { if (tokens[0] === "{") { generateRules(); selectorStack.push(token.split(/\s*,\s*/)); tokens.shift(); } if (tokens[0] === ";") { rules.push(" " + token + ";"); } } } return parts.join("\n"); } function registerSheet(id, css) { const sheet = document.createElement("style"); sheet.textContent = processSheet(css); STYLESHEETS[id] = sheet; } function activateSheet(id) { const sheet = STYLESHEETS[id]; sheet.setAttribute("component", id); document.head.appendChild(sheet); } function getTextDecoration({ strikethrough, underline, }) { if (!strikethrough && !underline) { return "none"; } return `${strikethrough ? "line-through" : ""} ${underline ? "underline" : ""}`; } /** * Convert the cell style to CSS properties. */ function cellStyleToCss(style) { const attributes = cellTextStyleToCss(style); if (!style) return attributes; if (style.fillColor) { attributes["background"] = style.fillColor; } return attributes; } /** * Convert the cell text style to CSS properties. */ function cellTextStyleToCss(style) { const attributes = {}; if (!style) return attributes; if (style.bold) { attributes["font-weight"] = "bold"; } if (style.italic) { attributes["font-style"] = "italic"; } if (style.strikethrough || style.underline) { let decoration = style.strikethrough ? "line-through" : ""; decoration = style.underline ? decoration + " underline" : decoration; attributes["text-decoration"] = decoration; } if (style.textColor) { attributes["color"] = style.textColor; } return attributes; } /** * Transform CSS properties into a CSS string. */ function cssPropertiesToCss(attributes) { let styleStr = ""; for (const attName in attributes) { if (!attributes[attName]) { continue; } styleStr += `${attName}:${attributes[attName]}; `; } return styleStr; } function getElementMargins(el) { const style = window.getComputedStyle(el); const margins = { top: parseInt(style.marginTop, 10) || 0, bottom: parseInt(style.marginBottom, 10) || 0, left: parseInt(style.marginLeft, 10) || 0, right: parseInt(style.marginRight, 10) || 0, }; return margins; } const macRegex = /Mac/i; const MODIFIER_KEYS = ["Shift", "Control", "Alt", "Meta"]; /** * Return true if the event was triggered from * a child element. */ function isChildEvent(parent, ev) { if (!parent) return false; return !!ev.target && parent.contains(ev.target); } function gridOverlayPosition() { const spreadsheetElement = document.querySelector(".o-grid-overlay"); if (spreadsheetElement) { const { top, left } = spreadsheetElement?.getBoundingClientRect(); return { top, left }; } throw new Error("Can't find spreadsheet position"); } function getBoundingRectAsPOJO(el) { const rect = el.getBoundingClientRect(); return { x: rect.x, y: rect.y, width: rect.width, height: rect.height, }; } /** * Iterate over all the children of `el` in the dom tree starting at `el`, depth first. */ function* iterateChildren(el) { yield el; if (el.hasChildNodes()) { for (let child of el.childNodes) { yield* iterateChildren(child); } } } function getOpenedMenus() { return Array.from(document.querySelectorAll(".o-spreadsheet .o-menu")); } const letterRegex = /^[a-zA-Z]$/; /** * Transform a keyboard event into a shortcut string that represent this event. The letters keys will be uppercased. * * @argument ev - The keyboard event to transform * @argument mode - Use either ev.key of ev.code to get the string shortcut * * @example * event : { ctrlKey: true, key: "a" } => "Ctrl+A" * event : { shift: true, alt: true, key: "Home" } => "Alt+Shift+Home" */ function keyboardEventToShortcutString(ev, mode = "key") { let keyDownString = ""; if (!MODIFIER_KEYS.includes(ev.key)) { if (isCtrlKey(ev)) keyDownString += "Ctrl+"; if (ev.altKey) keyDownString += "Alt+"; if (ev.shiftKey) keyDownString += "Shift+"; } const key = mode === "key" ? ev.key : ev.code; keyDownString += letterRegex.test(key) ? key.toUpperCase() : key; return keyDownString; } function isMacOS() { return Boolean(macRegex.test(navigator.userAgent)); } /** * @param {KeyboardEvent | MouseEvent} ev * @returns Returns true if the event was triggered with the "ctrl" modifier pressed. * On Mac, this is the "meta" or "command" key. */ function isCtrlKey(ev) { return isMacOS() ? ev.metaKey : ev.ctrlKey; } /** * Return the o-spreadsheet element position relative * to the browser viewport. */ function useSpreadsheetRect() { const position = useState({ x: 0, y: 0, width: 0, height: 0 }); let spreadsheetElement = null; function updatePosition() { if (!spreadsheetElement) { spreadsheetElement = document.querySelector(".o-spreadsheet"); } if (spreadsheetElement) { const { top, left, width, height } = spreadsheetElement.getBoundingClientRect(); position.x = left; position.y = top; position.width = width; position.height = height; } } onMounted(updatePosition); onPatched(updatePosition); return position; } /** * Return the component (or ref's component) BoundingRect, relative * to the upper left corner of the screen ( element). * * Note: when used with a component, it will * return the portal position, not the teleported position. */ function useAbsoluteBoundingRect(ref) { const rect = useState({ x: 0, y: 0, width: 0, height: 0 }); function updateElRect() { const el = ref.el; if (el === null) { return; } const { top, left, width, height } = el.getBoundingClientRect(); rect.x = left; rect.y = top; rect.width = width; rect.height = height; } onMounted(updateElRect); onPatched(updateElRect); return rect; } /** * Get the rectangle inside which a popover should stay when being displayed. * It's the value defined in `env.getPopoverContainerRect`, or the Rect of the "o-spreadsheet" * element by default. * * Coordinates are expressed expressed as absolute DOM position. */ function usePopoverContainer() { const container = useState({ x: 0, y: 0, width: 0, height: 0 }); const component = useComponent(); const spreadsheetRect = useSpreadsheetRect(); function updateRect() { const env = component.env; const newRect = "getPopoverContainerRect" in env ? env.getPopoverContainerRect() : spreadsheetRect; container.x = newRect.x; container.y = newRect.y; container.width = newRect.width; container.height = newRect.height; } updateRect(); onMounted(updateRect); onPatched(updateRect); return container; } const arrowMap = { ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right", ArrowUp: "up", }; function updateSelectionWithArrowKeys(ev, selection) { const direction = arrowMap[ev.key]; if (ev.shiftKey) { selection.resizeAnchorZone(direction, isCtrlKey(ev) ? "end" : 1); } else { selection.moveAnchorCell(direction, isCtrlKey(ev) ? "end" : 1); } } css /* scss */ ` .o-autocomplete-dropdown { pointer-events: auto; cursor: pointer; background-color: #fff; max-width: 400px; z-index: 1; .o-autocomplete-value-focus { background-color: #f2f2f2; } & > div { padding: 1px 5px 5px 5px; .o-autocomplete-description { padding-left: 5px; font-size: 11px; } } } `; class TextValueProvider extends Component { static template = "o-spreadsheet-TextValueProvider"; static props = { proposals: Array, selectedIndex: { type: Number, optional: true }, onValueSelected: Function, onValueHovered: Function, }; autoCompleteListRef = useRef("autoCompleteList"); setup() { useEffect(() => { const selectedIndex = this.props.selectedIndex; if (selectedIndex === undefined) { return; } const selectedElement = this.autoCompleteListRef.el?.children[selectedIndex]; selectedElement?.scrollIntoView?.({ block: "nearest" }); }, () => [this.props.selectedIndex, this.autoCompleteListRef.el]); } } class AutoCompleteStore extends SpreadsheetStore { mutators = ["useProvider", "moveSelection", "hide", "selectIndex"]; selectedIndex = undefined; provider; get selectedProposal() { if (this.selectedIndex === undefined || this.provider === undefined) { return undefined; } return this.provider.proposals[this.selectedIndex]; } useProvider(provider) { this.provider = provider; this.selectedIndex = provider.autoSelectFirstProposal ? 0 : undefined; } hide() { this.provider = undefined; this.selectedIndex = undefined; } selectIndex(index) { this.selectedIndex = index; } moveSelection(direction) { if (!this.provider) { return; } if (this.selectedIndex === undefined) { this.selectedIndex = 0; return; } if (direction === "previous") { this.selectedIndex--; if (this.selectedIndex < 0) { this.selectedIndex = this.provider.proposals.length - 1; } } else { this.selectedIndex = (this.selectedIndex + 1) % this.provider.proposals.length; } } } class ContentEditableHelper { // todo make el private and expose dedicated methods el; constructor(el) { this.el = el; } updateEl(el) { this.el = el; } /** * select the text at position start to end, no matter the children */ selectRange(start, end) { let selection = window.getSelection(); const { start: currentStart, end: currentEnd } = this.getCurrentSelection(); if (currentStart === start && currentEnd === end) { return; } const currentRange = selection.getRangeAt(0); let range; if (this.el.contains(currentRange.startContainer)) { range = currentRange; } else { range = document.createRange(); selection.removeAllRanges(); selection.addRange(range); } if (start === end && start === 0) { range.setStart(this.el, 0); range.setEnd(this.el, 0); } else { const textLength = this.getText().length; if (start < 0 || end > textLength) { console.warn(`wrong selection asked start ${start}, end ${end}, text content length ${textLength}`); if (start < 0) start = 0; if (end > textLength) end = textLength; if (start > textLength) start = textLength; } let startNode = this.findChildAtCharacterIndex(start); let endNode = this.findChildAtCharacterIndex(end); range.setStart(startNode.node, startNode.offset); selection.extend(endNode.node, endNode.offset); } } /** * finds the dom element that contains the character at `offset` */ findChildAtCharacterIndex(offset) { let it = iterateChildren(this.el); let current, previous; let usedCharacters = offset; let isFirstParagraph = true; do { current = it.next(); if (!current.done && !current.value.hasChildNodes()) { if (current.value.textContent && current.value.textContent.length < usedCharacters) { usedCharacters -= current.value.textContent.length; } else if (current.value.textContent && current.value.textContent.length >= usedCharacters) { it.return(current.value); } previous = current.value; } // One new paragraph = one new line character, except for the first paragraph if (!current.done && current.value.nodeName === "P") { if (isFirstParagraph) { isFirstParagraph = false; } else { usedCharacters--; } } } while (!current.done && usedCharacters); if (current.value) { return { node: current.value, offset: usedCharacters }; } return { node: previous, offset: usedCharacters }; } /** * Sets (or Replaces all) the text inside the root element in the form of distinctive paragraphs and * span for each element provided in `contents`. * * The function will apply the diff between the current content and the new content to avoid the systematic * destruction of DOM elements which interferes with IME[1] * * Each line of text will be encapsulated in a paragraph element. * Each span will have its own fontcolor and specific class if provided in the HtmlContent object. * * [1] https://developer.mozilla.org/en-US/docs/Glossary/Input_method_editor */ setText(contents) { if (contents.length === 0) { this.removeAll(); return; } const childElements = Array.from(this.el.childNodes); const contentLength = contents.length; for (let i = 0; i < contentLength; i++) { const line = contents[i]; const childElement = childElements[i]; let newChild = false; let p; if (childElement && childElement.nodeName === "P") { p = childElement; } else { newChild = true; p = document.createElement("p"); } const lineLength = line.length; const existingChildren = Array.from(p.childNodes); for (let j = 0; j < lineLength; j++) { const content = line[j]; const child = existingChildren[j]; // child nodes can be multiple types of nodes: Span, Text, Div, etc... // We can only modify a node in place if it has the same type as the content // that we would insert, which are spans. // Otherwise, it means that the node has been input by the user, through the keyboard or a copy/paste // @ts-ignore (somehow required because jest does not like child.tagName despite the prior check) const childIsSpan = child && "tagName" in child && child.tagName === "SPAN"; if (childIsSpan && compareContentToSpanElement(content, child)) { continue; } // this is an empty line in the content if (!content.value && !content.class) { if (child) p.removeChild(child); continue; } const span = document.createElement("span"); span.innerText = content.value; span.style.color = content.color || ""; if (content.class) { span.classList.add(content.class); } if (child) { p.replaceChild(span, child); } else { p.appendChild(span); } } if (existingChildren.length > lineLength) { for (let i = lineLength; i < existingChildren.length; i++) { p.removeChild(existingChildren[i]); } } // Empty line if (!p.hasChildNodes()) { const span = document.createElement("span"); span.appendChild(document.createElement("br")); p.appendChild(span); } // replace p if necessary if (newChild) { if (childElement) { this.el.replaceChild(p, childElement); } else { this.el.appendChild(p); } } } if (childElements.length > contentLength) { for (let i = contentLength; i < childElements.length; i++) { this.el.removeChild(childElements[i]); } } } scrollSelectionIntoView() { const focusedNode = document.getSelection()?.focusNode; if (!focusedNode || !this.el.contains(focusedNode)) return; const element = focusedNode instanceof HTMLElement ? focusedNode : focusedNode.parentElement; element?.scrollIntoView({ block: "nearest" }); } /** * remove the current selection of the user * */ removeSelection() { let selection = window.getSelection(); selection.removeAllRanges(); } removeAll() { if (this.el) { while (this.el.firstChild) { this.el.removeChild(this.el.firstChild); } } } /** * finds the indexes of the current selection. * */ getCurrentSelection() { let { startElement, endElement, startSelectionOffset, endSelectionOffset } = this.getStartAndEndSelection(); let startSizeBefore = this.findSelectionIndex(startElement, startSelectionOffset); let endSizeBefore = this.findSelectionIndex(endElement, endSelectionOffset); return { start: startSizeBefore, end: endSizeBefore, }; } /** * Computes the text 'index' inside this.el based on the currently selected node and its offset. * The selected node is either a Text node or an Element node. * * case 1 -Text node: * the offset is the number of characters from the start of the node. We have to add this offset to the * content length of all previous nodes. * * case 2 - Element node: * the offset is the number of child nodes before the selected node. We have to add the content length of * all the bnodes prior to the selected node as well as the content of the child node before the offset. * * See the MDN documentation for more details. * https://developer.mozilla.org/en-US/docs/Web/API/Range/startOffset * https://developer.mozilla.org/en-US/docs/Web/API/Range/endOffset * */ findSelectionIndex(nodeToFind, nodeOffset) { let usedCharacters = 0; let it = iterateChildren(this.el); let current = it.next(); let isFirstParagraph = true; while (!current.done && current.value !== nodeToFind) { if (!current.value.hasChildNodes()) { if (current.value.textContent) { usedCharacters += current.value.textContent.length; } } // One new paragraph = one new line character, except for the first paragraph if (current.value.nodeName === "P" || (current.value.nodeName === "DIV" && current.value !== this.el) // On paste, the HTML may contain
instead of

) { if (isFirstParagraph) { isFirstParagraph = false; } else { usedCharacters++; } } current = it.next(); } if (current.value !== nodeToFind) { /** This situation can happen if the code is called while the selection is not currently on the ContentEditableHelper. * In this case, we return 0 because we don't know the size of the text before the selection. * * A known occurence is triggered since the introduction of commit d4663158 (PR #2038). * * FIXME: find a way to test eventhough the selection API is not available in jsDOM. */ return 0; } else { if (!current.value.hasChildNodes()) { usedCharacters += nodeOffset; } else { const children = [...current.value.childNodes].slice(0, nodeOffset); usedCharacters += children.reduce((acc, child, index) => { if (child.textContent !== null) { // need to account for paragraph nodes that implicitely add a new line // except for the last paragraph let chars = child.textContent.length; if (child.nodeName === "P" && index !== children.length - 1) { chars++; } return acc + chars; } else { return acc; } }, 0); } } if (nodeToFind.nodeName === "P" && !isFirstParagraph && nodeToFind.textContent === "") { usedCharacters++; } return usedCharacters; } getStartAndEndSelection() { const selection = document.getSelection(); return { startElement: selection.anchorNode || this.el, startSelectionOffset: selection.anchorOffset, endElement: selection.focusNode || this.el, endSelectionOffset: selection.focusOffset, }; } getText() { let text = ""; let it = iterateChildren(this.el); let current = it.next(); let isFirstParagraph = true; while (!current.done) { if (!current.value.hasChildNodes()) { text += current.value.textContent; } if (current.value.nodeName === "P" || (current.value.nodeName === "DIV" && current.value !== this.el) // On paste, the HTML may contain

instead of

) { if (isFirstParagraph) { isFirstParagraph = false; } else { text += NEWLINE; } } current = it.next(); } return text; } } function compareContentToSpanElement(content, node) { const contentColor = content.color ? toHex(content.color) : ""; const nodeColor = node.style?.color ? toHex(node.style.color) : ""; const sameColor = contentColor === nodeColor; const sameClass = deepEquals([content.class], [...node.classList]); const sameContent = node.innerText === content.value; return sameColor && sameClass && sameContent; } // ----------------------------------------------------------------------------- // Formula Assistant component // ----------------------------------------------------------------------------- css /* scss */ ` .o-formula-assistant { background: #ffffff; .o-formula-assistant-head { background-color: #f2f2f2; padding: 10px; } .collapsed { transform: rotate(180deg); } .o-formula-assistant-core { border-bottom: 1px solid gray; } .o-formula-assistant-arg-description { font-size: 85%; } .o-formula-assistant-focus { div:first-child, span { color: ${COMPOSER_ASSISTANT_COLOR}; text-shadow: 0px 0px 1px ${COMPOSER_ASSISTANT_COLOR}; } div:last-child { color: black; } } .o-formula-assistant-gray { color: gray; } } `; class FunctionDescriptionProvider extends Component { static template = "o-spreadsheet-FunctionDescriptionProvider"; static props = { functionName: String, functionDescription: Object, argToFocus: Number, }; getContext() { return this.props; } get formulaArgSeparator() { return this.env.model.getters.getLocale().formulaArgSeparator + " "; } } const functions$2 = functionRegistry.content; const ASSISTANT_WIDTH = 300; const CLOSE_ICON_RADIUS = 9; const selectionIndicatorClass = "selector-flag"; const selectionIndicatorColor = "#a9a9a9"; const selectionIndicator = "␣"; const functionColor = "#4a4e4d"; const operatorColor = "#3da4ab"; const tokenColors = { OPERATOR: operatorColor, NUMBER: "#02c39a", STRING: "#00a82d", FUNCTION: functionColor, DEBUGGER: operatorColor, LEFT_PAREN: functionColor, RIGHT_PAREN: functionColor, ARG_SEPARATOR: functionColor, MATCHING_PAREN: "#000000", }; css /* scss */ ` .o-composer-container { .o-composer { overflow-y: auto; overflow-x: hidden; word-break: break-all; padding-right: 2px; box-sizing: border-box; caret-color: black; padding-left: 3px; padding-right: 3px; outline: none; p { margin-bottom: 0px; span { white-space: pre-wrap; &.${selectionIndicatorClass}:after { content: "${selectionIndicator}"; color: ${selectionIndicatorColor}; } } } } .o-composer[placeholder]:empty:not(:focus):not(.active)::before { content: attr(placeholder); color: #bdbdbd; position: relative; top: 0%; pointer-events: none; } .fa-stack { /* reset stack size which is doubled by default */ width: ${CLOSE_ICON_RADIUS * 2}px; height: ${CLOSE_ICON_RADIUS * 2}px; line-height: ${CLOSE_ICON_RADIUS * 2}px; } .force-open-assistant { left: -1px; top: -1px; .fa-question-circle { color: ${PRIMARY_BUTTON_BG}; } } .o-composer-assistant { position: absolute; margin: 1px 4px; .o-semi-bold { /** FIXME: to remove in favor of Bootstrap * 'fw-semibold' when we upgrade to Bootstrap 5.2 */ font-weight: 600 !important; } } } `; class Composer extends Component { static template = "o-spreadsheet-Composer"; static props = { focus: { validate: (value) => ["inactive", "cellFocus", "contentFocus"].includes(value), }, inputStyle: { type: String, optional: true }, rect: { type: Object, optional: true }, delimitation: { type: Object, optional: true }, onComposerCellFocused: { type: Function, optional: true }, onComposerContentFocused: Function, isDefaultFocus: { type: Boolean, optional: true }, onInputContextMenu: { type: Function, optional: true }, composerStore: Object, placeholder: { type: String, optional: true }, }; static components = { TextValueProvider, FunctionDescriptionProvider }; static defaultProps = { inputStyle: "", isDefaultFocus: false, }; DOMFocusableElementStore; composerRef = useRef("o_composer"); contentHelper = new ContentEditableHelper(this.composerRef.el); composerState = useState({ positionStart: 0, positionEnd: 0, }); autoCompleteState; functionDescriptionState = useState({ showDescription: false, functionName: "", functionDescription: {}, argToFocus: 0, }); assistant = useState({ forcedClosed: false, }); compositionActive = false; spreadsheetRect = useSpreadsheetRect(); get assistantStyle() { const composerRect = this.composerRef.el.getBoundingClientRect(); const assistantStyle = {}; assistantStyle["min-width"] = `${this.props.rect?.width || ASSISTANT_WIDTH}px`; const proposals = this.autoCompleteState.provider?.proposals; const proposalsHaveDescription = proposals?.some((proposal) => proposal.description); if (this.functionDescriptionState.showDescription || proposalsHaveDescription) { assistantStyle.width = `${ASSISTANT_WIDTH}px`; } if (this.props.delimitation && this.props.rect) { const { x: cellX, y: cellY, height: cellHeight } = this.props.rect; const remainingHeight = this.props.delimitation.height - (cellY + cellHeight); assistantStyle["max-height"] = `${remainingHeight}px`; if (cellY > remainingHeight) { const availableSpaceAbove = cellY; assistantStyle["max-height"] = `${availableSpaceAbove - CLOSE_ICON_RADIUS}px`; // render top // We compensate 2 px of margin on the assistant style + 1px for design reasons assistantStyle.top = `-3px`; assistantStyle.transform = `translate(0, -100%)`; } if (cellX + ASSISTANT_WIDTH > this.props.delimitation.width) { // render left assistantStyle.right = `0px`; } } else { assistantStyle["max-height"] = `${this.spreadsheetRect.height - composerRect.bottom}px`; if (composerRect.left + ASSISTANT_WIDTH + SCROLLBAR_WIDTH + CLOSE_ICON_RADIUS > this.spreadsheetRect.width) { assistantStyle.right = `${CLOSE_ICON_RADIUS}px`; } } return cssPropertiesToCss(assistantStyle); } // we can't allow input events to be triggered while we remove and add back the content of the composer in processContent shouldProcessInputEvents = false; tokens = []; keyMapping = { Enter: (ev) => this.processEnterKey(ev, "down"), "Shift+Enter": (ev) => this.processEnterKey(ev, "up"), "Alt+Enter": this.processNewLineEvent, "Ctrl+Enter": this.processNewLineEvent, Escape: this.processEscapeKey, F2: () => console.warn("Not implemented"), F4: (ev) => this.processF4Key(ev), Tab: (ev) => this.processTabKey(ev, "right"), "Shift+Tab": (ev) => this.processTabKey(ev, "left"), }; keyCodeMapping = { NumpadDecimal: this.processNumpadDecimal, }; setup() { this.DOMFocusableElementStore = useStore(DOMFocusableElementStore); this.autoCompleteState = useLocalStore(AutoCompleteStore); onMounted(() => { const el = this.composerRef.el; if (this.props.isDefaultFocus) { this.DOMFocusableElementStore.setFocusableElement(el); } this.contentHelper.updateEl(el); }); useEffect(() => { this.processContent(); if (document.activeElement === this.contentHelper.el && this.props.composerStore.editionMode === "inactive" && !this.props.isDefaultFocus) { this.DOMFocusableElementStore.focus(); } }); useEffect(() => { this.processTokenAtCursor(); }, () => [this.props.composerStore.editionMode !== "inactive"]); } // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- processArrowKeys(ev) { const tokenAtCursor = this.props.composerStore.tokenAtCursor; if ((this.props.composerStore.isSelectingRange || this.props.composerStore.editionMode === "inactive") && !(["ArrowUp", "ArrowDown"].includes(ev.key) && this.autoCompleteState.provider && tokenAtCursor?.type !== "REFERENCE")) { this.functionDescriptionState.showDescription = false; this.autoCompleteState.hide(); // Prevent the default content editable behavior which moves the cursor ev.preventDefault(); ev.stopPropagation(); updateSelectionWithArrowKeys(ev, this.env.model.selection); return; } const content = this.props.composerStore.currentContent; if (this.props.focus === "cellFocus" && !this.autoCompleteState.provider && !content.startsWith("=")) { this.props.composerStore.stopEdition(); return; } // All arrow keys are processed: up and down should move autocomplete, left // and right should move the cursor. ev.stopPropagation(); this.handleArrowKeysForAutocomplete(ev); } handleArrowKeysForAutocomplete(ev) { // only for arrow up and down if (["ArrowUp", "ArrowDown"].includes(ev.key) && this.autoCompleteState.provider) { ev.preventDefault(); this.autoCompleteState.moveSelection(ev.key === "ArrowDown" ? "next" : "previous"); } } processTabKey(ev, direction) { ev.preventDefault(); ev.stopPropagation(); if (this.props.composerStore.editionMode !== "inactive") { const state = this.autoCompleteState; if (state.provider && state.selectedIndex !== undefined) { const autoCompleteValue = state.provider.proposals[state.selectedIndex]?.text; if (autoCompleteValue) { this.autoComplete(autoCompleteValue); return; } } this.props.composerStore.stopEdition(direction); } } processEnterKey(ev, direction) { ev.preventDefault(); ev.stopPropagation(); const state = this.autoCompleteState; if (state.provider && state.selectedIndex !== undefined) { const autoCompleteValue = state.provider.proposals[state.selectedIndex]?.text; if (autoCompleteValue) { this.autoComplete(autoCompleteValue); return; } } this.props.composerStore.stopEdition(direction); } processNewLineEvent(ev) { ev.preventDefault(); ev.stopPropagation(); const content = this.contentHelper.getText(); const selection = this.contentHelper.getCurrentSelection(); const start = Math.min(selection.start, selection.end); const end = Math.max(selection.start, selection.end); this.props.composerStore.stopComposerRangeSelection(); this.props.composerStore.setCurrentContent(content.slice(0, start) + NEWLINE + content.slice(end), { start: start + 1, end: start + 1, }); this.processContent(); } processEscapeKey(ev) { this.props.composerStore.cancelEdition(); ev.stopPropagation(); ev.preventDefault(); } processF4Key(ev) { ev.stopPropagation(); this.props.composerStore.cycleReferences(); this.processContent(); } processNumpadDecimal(ev) { ev.stopPropagation(); ev.preventDefault(); const locale = this.env.model.getters.getLocale(); const selection = this.contentHelper.getCurrentSelection(); const currentContent = this.props.composerStore.currentContent; const content = currentContent.slice(0, selection.start) + locale.decimalSeparator + currentContent.slice(selection.end); // Update composer even by hand rather than dispatching an InputEvent because untrusted inputs // events aren't handled natively by contentEditable this.props.composerStore.setCurrentContent(content, { start: selection.start + 1, end: selection.start + 1, }); // We need to do the process content here in case there is no render between the keyDown and the // keyUp event this.processContent(); } onCompositionStart() { this.compositionActive = true; } onCompositionEnd() { this.compositionActive = false; } onKeydown(ev) { if (this.props.composerStore.editionMode === "inactive") { return; } if (ev.key.startsWith("Arrow")) { this.processArrowKeys(ev); return; } let handler = this.keyMapping[keyboardEventToShortcutString(ev)] || this.keyCodeMapping[keyboardEventToShortcutString(ev, "code")]; if (handler) { handler.call(this, ev); } else { ev.stopPropagation(); } } onPaste(ev) { if (this.props.composerStore.editionMode !== "inactive") { // let the browser clipboard work ev.stopPropagation(); } else { // the user meant to paste in the sheet, not open the composer with the pasted content // While we're not editing, we still have the focus and should therefore prevent // the native "paste" to occur. ev.preventDefault(); } } /* * Triggered automatically by the content-editable between the keydown and key up * */ onInput(ev) { if (!this.shouldProcessInputEvents) { return; } ev.stopPropagation(); let content; if (this.props.composerStore.editionMode === "inactive") { content = ev.data || ""; } else { content = this.contentHelper.getText(); } if (this.props.focus === "inactive") { return this.props.onComposerCellFocused?.(content); } let selection = this.contentHelper.getCurrentSelection(); this.props.composerStore.stopComposerRangeSelection(); this.props.composerStore.setCurrentContent(content, selection); this.processTokenAtCursor(); } onKeyup(ev) { if (this.contentHelper.el === document.activeElement) { if (this.autoCompleteState.provider && ["ArrowUp", "ArrowDown"].includes(ev.key)) { return; } if (this.props.composerStore.isSelectingRange && ev.key?.startsWith("Arrow")) { return; } const { start: oldStart, end: oldEnd } = this.props.composerStore.composerSelection; const { start, end } = this.contentHelper.getCurrentSelection(); if (start !== oldStart || end !== oldEnd) { this.props.composerStore.changeComposerCursorSelection(start, end); } this.processTokenAtCursor(); } } onBlur(ev) { if (this.props.composerStore.editionMode === "inactive") { return; } const target = ev.relatedTarget; if (!target || !(target instanceof HTMLElement)) { this.props.composerStore.stopEdition(); return; } if (target.attributes.getNamedItem("composerFocusableElement")) { this.contentHelper.el.focus(); return; } if (target.classList.contains("o-composer")) { return; } this.props.composerStore.stopEdition(); } updateAutoCompleteIndex(index) { this.autoCompleteState.selectIndex(clip(0, index, 10)); } /** * This is required to ensure the content helper selection is * properly updated on "onclick" events. Depending on the browser, * the callback onClick from the composer will be executed before * the selection was updated in the dom, which means we capture an * wrong selection which is then forced upon the content helper on * processContent. */ onMousedown(ev) { if (ev.button > 0) { // not main button, probably a context menu return; } this.contentHelper.removeSelection(); } onClick() { if (this.env.model.getters.isReadonly()) { return; } const newSelection = this.contentHelper.getCurrentSelection(); this.props.composerStore.stopComposerRangeSelection(); this.props.onComposerContentFocused(); this.props.composerStore.changeComposerCursorSelection(newSelection.start, newSelection.end); this.processTokenAtCursor(); } onDblClick() { if (this.env.model.getters.isReadonly()) { return; } const composerContent = this.props.composerStore.currentContent; const isValidFormula = composerContent.startsWith("="); if (isValidFormula) { const tokens = this.props.composerStore.currentTokens; const currentSelection = this.contentHelper.getCurrentSelection(); if (currentSelection.start === currentSelection.end) return; const currentSelectedText = composerContent.substring(currentSelection.start, currentSelection.end); const token = tokens.filter((token) => token.value.includes(currentSelectedText) && token.start <= currentSelection.start && token.end >= currentSelection.end)[0]; if (!token) { return; } if (token.type === "REFERENCE") { this.props.composerStore.changeComposerCursorSelection(token.start, token.end); } } } onContextMenu(ev) { if (this.props.composerStore.editionMode === "inactive") { this.props.onInputContextMenu?.(ev); } } closeAssistant() { this.assistant.forcedClosed = true; } openAssistant() { this.assistant.forcedClosed = false; } // --------------------------------------------------------------------------- // Private // --------------------------------------------------------------------------- processContent() { if (this.compositionActive) { return; } this.shouldProcessInputEvents = false; if (this.props.focus !== "inactive" && document.activeElement !== this.contentHelper.el) { this.contentHelper.el.focus(); } const content = this.getContentLines(); this.contentHelper.setText(content); if (content.length !== 0 && content.length[0] !== 0) { if (this.props.focus !== "inactive") { // Put the cursor back where it was before the rendering const { start, end } = this.props.composerStore.composerSelection; this.contentHelper.selectRange(start, end); } this.contentHelper.scrollSelectionIntoView(); } this.shouldProcessInputEvents = true; } /** * Get the HTML content corresponding to the current composer token, divided by lines. */ getContentLines() { let value = this.props.composerStore.currentContent; const isValidFormula = value.startsWith("="); if (value === "") { return []; } else if (isValidFormula && this.props.focus !== "inactive") { return this.splitHtmlContentIntoLines(this.getColoredTokens()); } return this.splitHtmlContentIntoLines([{ value }]); } getColoredTokens() { const tokens = this.props.composerStore.currentTokens; const tokenAtCursor = this.props.composerStore.tokenAtCursor; const result = []; const { end, start } = this.props.composerStore.composerSelection; for (const token of tokens) { switch (token.type) { case "OPERATOR": case "NUMBER": case "ARG_SEPARATOR": case "STRING": result.push({ value: token.value, color: tokenColors[token.type] || "#000" }); break; case "REFERENCE": const { xc, sheetName } = splitReference(token.value); result.push({ value: token.value, color: this.rangeColor(xc, sheetName) || "#000" }); break; case "SYMBOL": const value = token.value; const upperCaseValue = value.toUpperCase(); if (upperCaseValue === "TRUE" || upperCaseValue === "FALSE") { result.push({ value: token.value, color: tokenColors.NUMBER }); } else if (upperCaseValue in functionRegistry.content) { result.push({ value: token.value, color: tokenColors.FUNCTION }); } else { result.push({ value: token.value, color: "#000" }); } break; case "LEFT_PAREN": case "RIGHT_PAREN": // Compute the matching parenthesis if (tokenAtCursor && ["LEFT_PAREN", "RIGHT_PAREN"].includes(tokenAtCursor.type) && tokenAtCursor.parenIndex && tokenAtCursor.parenIndex === token.parenIndex) { result.push({ value: token.value, color: tokenColors.MATCHING_PAREN || "#000" }); } else { result.push({ value: token.value, color: tokenColors[token.type] || "#000" }); } break; default: result.push({ value: token.value, color: "#000" }); break; } if (this.props.composerStore.showSelectionIndicator && end === start && end === token.end) { result[result.length - 1].class = selectionIndicatorClass; } } return result; } /** * Split an array of HTMLContents into lines. Each NEWLINE character encountered will create a new * line. Contents can be split into multiple parts if they contain multiple NEWLINE characters. */ splitHtmlContentIntoLines(contents) { const contentSplitInLines = []; let currentLine = []; for (const content of contents) { if (content.value.includes(NEWLINE)) { const lines = content.value.split(NEWLINE); const lastLine = lines.pop(); for (const line of lines) { currentLine.push({ color: content.color, value: line }); // don't copy class, only last line should keep it contentSplitInLines.push(currentLine); currentLine = []; } currentLine.push({ ...content, value: lastLine }); } else { currentLine.push(content); } } if (currentLine.length) { contentSplitInLines.push(currentLine); } // Remove useless empty contents const filteredLines = []; for (const line of contentSplitInLines) { if (line.every(this.isContentEmpty)) { filteredLines.push([line[0]]); } else { filteredLines.push(line.filter((content) => !this.isContentEmpty(content))); } } return filteredLines; } isContentEmpty(content) { return !(content.value || content.class); } rangeColor(xc, sheetName) { if (this.props.focus === "inactive") { return undefined; } const highlights = this.props.composerStore.highlights; const refSheet = sheetName ? this.env.model.getters.getSheetIdByName(sheetName) : this.props.composerStore.sheetId; const highlight = highlights.find((highlight) => { if (highlight.sheetId !== refSheet) return false; const range = this.env.model.getters.getRangeFromSheetXC(refSheet, xc); let zone = range.zone; zone = getZoneArea(zone) === 1 ? this.env.model.getters.expandZone(refSheet, zone) : zone; return isEqual(zone, highlight.zone); }); return highlight && highlight.color ? highlight.color : undefined; } /** * Compute the state of the composer from the tokenAtCursor. * If the token is a function or symbol (that isn't a cell/range reference) we have to initialize * the autocomplete engine otherwise we initialize the formula assistant. */ processTokenAtCursor() { let content = this.props.composerStore.currentContent; if (this.autoCompleteState.provider) { this.autoCompleteState.hide(); } this.functionDescriptionState.showDescription = false; const autoCompleteProvider = this.props.composerStore.autocompleteProvider; if (autoCompleteProvider) { this.autoCompleteState.useProvider(autoCompleteProvider); } const token = this.props.composerStore.tokenAtCursor; if (content.startsWith("=") && token && token.type !== "SYMBOL") { const tokenContext = token.functionContext; const parentFunction = tokenContext?.parent.toUpperCase(); if (tokenContext && parentFunction && parentFunction in functions$2 && token.type !== "UNKNOWN") { // initialize Formula Assistant const description = functions$2[parentFunction]; const argPosition = tokenContext.argPosition; this.functionDescriptionState.functionName = parentFunction; this.functionDescriptionState.functionDescription = description; this.functionDescriptionState.argToFocus = description.getArgToFocus(argPosition + 1) - 1; this.functionDescriptionState.showDescription = true; } } } autoComplete(value) { if (!value || this.assistant.forcedClosed) { return; } this.autoCompleteState.provider?.selectProposal(value); this.processTokenAtCursor(); } } class FunctionCodeBuilder { scope; code = ""; constructor(scope = new Scope()) { this.scope = scope; } append(...lines) { this.code += lines.map((line) => line.toString()).join("\n") + "\n"; } return(expression) { return new FunctionCodeImpl(this.scope, this.code, expression); } toString() { return indentCode(this.code); } } class FunctionCodeImpl { scope; returnExpression; code; constructor(scope, code, returnExpression) { this.scope = scope; this.returnExpression = returnExpression; this.code = indentCode(code); } toString() { return this.code; } assignResultToVariable() { if (this.scope.isAlreadyDeclared(this.returnExpression)) { return this; } const variableName = this.scope.nextVariableName(); const code = new FunctionCodeBuilder(this.scope); code.append(this.code); code.append(`const ${variableName} = ${this.returnExpression};`); return code.return(variableName); } } class Scope { nextId = 1; declaredVariables = new Set(); nextVariableName() { const name = `_${this.nextId++}`; this.declaredVariables.add(name); return name; } isAlreadyDeclared(name) { return this.declaredVariables.has(name); } } /** * Takes a list of strings that might be single or multiline * and maps them in a list of single line strings. */ function splitLines(str) { return str .split("\n") .map((line) => line.trim()) .filter((line) => line !== ""); } function indentCode(code) { let result = ""; let indentLevel = 0; const lines = splitLines(code); for (const line of lines) { if (line.startsWith("}")) { indentLevel--; } result += "\t".repeat(indentLevel) + line + "\n"; if (line.endsWith("{")) { indentLevel++; } } return result.trim(); } const functions$1 = functionRegistry.content; const OPERATOR_MAP = { // export for test "=": "EQ", "+": "ADD", "-": "MINUS", "*": "MULTIPLY", "/": "DIVIDE", ">=": "GTE", "<>": "NE", ">": "GT", "<=": "LTE", "<": "LT", "^": "POWER", "&": "CONCATENATE", }; const UNARY_OPERATOR_MAP = { // export for test "-": "UMINUS", "+": "UPLUS", "%": "UNARY.PERCENT", }; // this cache contains all compiled function code, grouped by "structure". For // example, "=2*sum(A1:A4)" and "=2*sum(B1:B4)" are compiled into the same // structural function. // It is only exported for testing purposes const functionCache = {}; // ----------------------------------------------------------------------------- // COMPILER // ----------------------------------------------------------------------------- function compile(formula) { const tokens = rangeTokenize(formula); return compileTokens(tokens); } function compileTokens(tokens) { try { return compileTokensOrThrow(tokens); } catch (error) { return { tokens, dependencies: [], execute: function () { return error; }, isBadExpression: true, }; } } function compileTokensOrThrow(tokens) { const { dependencies, constantValues, symbols } = formulaArguments(tokens); const cacheKey = compilationCacheKey(tokens, dependencies, constantValues); if (!functionCache[cacheKey]) { const ast = parseTokens([...tokens]); const scope = new Scope(); if (ast.type === "BIN_OPERATION" && ast.value === ":") { throw new BadExpressionError(_t("Invalid formula")); } if (ast.type === "EMPTY") { throw new BadExpressionError(_t("Invalid formula")); } const compiledAST = compileAST(ast); const code = new FunctionCodeBuilder(); code.append(`// ${cacheKey}`); code.append(compiledAST); code.append(`return ${compiledAST.returnExpression};`); let baseFunction = new Function("deps", // the dependencies in the current formula "ref", // a function to access a certain dependency at a given index "range", // same as above, but guarantee that the result is in the form of a range "getSymbolValue", "ctx", code.toString()); // @ts-ignore functionCache[cacheKey] = baseFunction; /** * This function compile the function arguments. It is mostly straightforward, * except that there is a non trivial transformation in one situation: * * If a function argument is asking for a range, and get a cell, we transform * the cell value into a range. This allow the grid model to differentiate * between a cell value and a non cell value. */ function compileFunctionArgs(ast) { const { args } = ast; const functionName = ast.value.toUpperCase(); const functionDefinition = functions$1[functionName]; if (!functionDefinition) { throw new UnknownFunctionError(_t('Unknown function: "%s"', ast.value)); } assertEnoughArgs(ast); const compiledArgs = []; for (let i = 0; i < args.length; i++) { const argToFocus = functionDefinition.getArgToFocus(i + 1) - 1; const argDefinition = functionDefinition.args[argToFocus]; const currentArg = args[i]; const argTypes = argDefinition.type || []; // detect when an argument need to be evaluated as a meta argument const isMeta = argTypes.includes("META"); const hasRange = argTypes.some((t) => isRangeType(t)); compiledArgs.push(compileAST(currentArg, isMeta, hasRange)); } return compiledArgs; } /** * This function compiles all the information extracted by the parser into an * executable code for the evaluation of the cells content. It uses a cache to * not reevaluate identical code structures. * * The function is sensitive to parameter “isMeta”. This * parameter may vary when compiling function arguments: * isMeta: In some cases the function arguments expects information on the * cell/range other than the associated value(s). For example the COLUMN * function needs to receive as argument the coordinates of a cell rather * than its value. For this we have meta arguments. */ function compileAST(ast, isMeta = false, hasRange = false) { const code = new FunctionCodeBuilder(scope); if (ast.type !== "REFERENCE" && !(ast.type === "BIN_OPERATION" && ast.value === ":")) { if (isMeta) { throw new BadExpressionError(_t("Argument must be a reference to a cell or range.")); } } if (ast.debug) { code.append("debugger;"); } switch (ast.type) { case "BOOLEAN": return code.return(`{ value: ${ast.value} }`); case "NUMBER": return code.return(`{ value: this.constantValues.numbers[${constantValues.numbers.indexOf(ast.value)}] }`); case "STRING": return code.return(`{ value: this.constantValues.strings[${constantValues.strings.indexOf(ast.value)}] }`); case "REFERENCE": const referenceIndex = dependencies.indexOf(ast.value); if ((!isMeta && ast.value.includes(":")) || hasRange) { return code.return(`range(deps[${referenceIndex}])`); } else { return code.return(`ref(deps[${referenceIndex}], ${isMeta ? "true" : "false"})`); } case "FUNCALL": const args = compileFunctionArgs(ast).map((arg) => arg.assignResultToVariable()); code.append(...args); const fnName = ast.value.toUpperCase(); return code.return(`ctx['${fnName}'](${args.map((arg) => arg.returnExpression)})`); case "UNARY_OPERATION": { const fnName = UNARY_OPERATOR_MAP[ast.value]; const operand = compileAST(ast.operand, false, false).assignResultToVariable(); code.append(operand); return code.return(`ctx['${fnName}'](${operand.returnExpression})`); } case "BIN_OPERATION": { const fnName = OPERATOR_MAP[ast.value]; const left = compileAST(ast.left, false, false).assignResultToVariable(); const right = compileAST(ast.right, false, false).assignResultToVariable(); code.append(left); code.append(right); return code.return(`ctx['${fnName}'](${left.returnExpression}, ${right.returnExpression})`); } case "SYMBOL": const symbolIndex = symbols.indexOf(ast.value); return code.return(`getSymbolValue(this.symbols[${symbolIndex}])`); case "EMPTY": return code.return("undefined"); } } } const compiledFormula = { execute: functionCache[cacheKey], dependencies, constantValues, symbols, tokens, isBadExpression: false, }; return compiledFormula; } /** * Compute a cache key for the formula. * References, numbers and strings are replaced with placeholders because * the compiled formula does not depend on their actual value. * Both `=A1+1+"2"` and `=A2+2+"3"` are compiled to the exact same function. * * Spaces are also ignored to compute the cache key. * * A formula `=A1+A2+SUM(2, 2, "2")` have the cache key `=|0|+|1|+SUM(|N0|,|N0|,|S0|)` */ function compilationCacheKey(tokens, dependencies, constantValues, symbols) { let cacheKey = ""; for (const token of tokens) { switch (token.type) { case "STRING": const value = removeStringQuotes(token.value); cacheKey += `|S${constantValues.strings.indexOf(value)}|`; break; case "NUMBER": cacheKey += `|N${constantValues.numbers.indexOf(parseNumber(token.value, DEFAULT_LOCALE))}|`; break; case "REFERENCE": case "INVALID_REFERENCE": if (token.value.includes(":")) { cacheKey += `R|${dependencies.indexOf(token.value)}|`; } else { cacheKey += `C|${dependencies.indexOf(token.value)}|`; } break; case "SPACE": cacheKey += ""; break; default: cacheKey += token.value; break; } } return cacheKey; } /** * Return formula arguments which are references, strings and numbers. */ function formulaArguments(tokens) { const constantValues = { numbers: [], strings: [], }; const dependencies = []; const symbols = []; for (const token of tokens) { switch (token.type) { case "INVALID_REFERENCE": case "REFERENCE": dependencies.push(token.value); break; case "STRING": const value = removeStringQuotes(token.value); if (!constantValues.strings.includes(value)) { constantValues.strings.push(value); } break; case "NUMBER": { const value = parseNumber(token.value, DEFAULT_LOCALE); if (!constantValues.numbers.includes(value)) { constantValues.numbers.push(value); } break; } case "SYMBOL": { // function name symbols are also included here symbols.push(unquote(token.value, "'")); } } } return { dependencies, constantValues, symbols, }; } /** * Check if arguments are supplied in the correct quantities */ function assertEnoughArgs(ast) { const nbrArg = ast.args.length; const functionName = ast.value.toUpperCase(); const functionDefinition = functions$1[functionName]; if (nbrArg < functionDefinition.minArgRequired) { throw new BadExpressionError(_t("Invalid number of arguments for the %s function. Expected %s minimum, but got %s instead.", functionName, functionDefinition.minArgRequired.toString(), nbrArg.toString())); } if (nbrArg > functionDefinition.maxArgPossible) { throw new BadExpressionError(_t("Invalid number of arguments for the %s function. Expected %s maximum, but got %s instead.", functionName, functionDefinition.maxArgPossible.toString(), nbrArg.toString())); } const repeatableArgs = functionDefinition.nbrArgRepeating; if (repeatableArgs > 1) { const unrepeatableArgs = functionDefinition.args.length - repeatableArgs; const repeatingArgs = nbrArg - unrepeatableArgs; if (repeatingArgs % repeatableArgs !== 0) { throw new BadExpressionError(_t("Invalid number of arguments for the %s function. Expected all arguments after position %s to be supplied by groups of %s arguments", functionName, unrepeatableArgs.toString(), repeatableArgs.toString())); } } } function isRangeType(type) { return type.startsWith("RANGE"); } const functions = functionRegistry.content; function isExportableToExcel(tokens) { try { const nonExportableFunctions = iterateAstNodes(parseTokens(tokens)).filter((ast) => ast.type === "FUNCALL" && !functions[ast.value.toUpperCase()]?.isExported); return nonExportableFunctions.length === 0; } catch (error) { return false; } } function getFunctionsFromTokens(tokens, functionNames) { // Parsing is an expensive operation, so we first check if the // formula contains one of the function names if (!tokens.some((t) => t.type === "SYMBOL" && functionNames.includes(t.value.toUpperCase()))) { return []; } let ast; try { ast = parseTokens(tokens); } catch { return []; } return getFunctionsFromAST(ast, functionNames); } function getFunctionsFromAST(ast, functionNames) { return iterateAstNodes(ast) .filter((node) => node.type === "FUNCALL" && functionNames.includes(node.value.toUpperCase())) .map((node) => ({ functionName: node.value.toUpperCase(), args: node.args, })); } const PIVOT_FUNCTIONS = ["PIVOT.VALUE", "PIVOT.HEADER", "PIVOT"]; /** * Create a proposal entry for the compose autowcomplete * to insert a field name string in a formula. */ function makeFieldProposal(field, granularity) { const groupBy = granularity ? `${field.name}:${granularity}` : field.name; const quotedGroupBy = `"${groupBy}"`; const fuzzySearchKey = field.string !== field.name ? field.string + quotedGroupBy // search on translated name and on technical name : quotedGroupBy; return { text: quotedGroupBy, description: field.string + (field.help ? ` (${field.help})` : ""), htmlContent: [{ value: quotedGroupBy, color: tokenColors.STRING }], fuzzySearchKey, }; } function makeMeasureProposal(measure) { const quotedMeasure = `"${measure.id}"`; const fuzzySearchKey = measure.displayName + measure.fieldName + quotedMeasure; return { text: quotedMeasure, description: measure.displayName, htmlContent: [{ value: quotedMeasure, color: tokenColors.STRING }], fuzzySearchKey, }; } /** * Perform the autocomplete of the composer by inserting the value * at the cursor position, replacing the current token if necessary. * Must be bound to the autocomplete provider. */ function insertTokenAfterArgSeparator(tokenAtCursor, value) { let start = tokenAtCursor.end; const end = tokenAtCursor.end; if (tokenAtCursor.type !== "ARG_SEPARATOR") { // replace the whole token start = tokenAtCursor.start; } this.composer.stopComposerRangeSelection(); this.composer.changeComposerCursorSelection(start, end); this.composer.replaceComposerCursorSelection(value); } /** * Perform the autocomplete of the composer by inserting the value * at the cursor position, replacing the current token if necessary. * Must be bound to the autocomplete provider. * @param {EnrichedToken} tokenAtCursor * @param {string} value */ function insertTokenAfterLeftParenthesis(tokenAtCursor, value) { let start = tokenAtCursor.end; const end = tokenAtCursor.end; if (tokenAtCursor.type !== "LEFT_PAREN") { // replace the whole token start = tokenAtCursor.start; } this.composer.stopComposerRangeSelection(); this.composer.changeComposerCursorSelection(start, end); this.composer.replaceComposerCursorSelection(value); } /** * Extract the pivot id (always the first argument) from the function * context of the given token. */ function extractFormulaIdFromToken(tokenAtCursor) { const idAst = tokenAtCursor.functionContext?.args[0]; if (!idAst || !["STRING", "NUMBER"].includes(idAst.type)) { return; } return idAst.value; } /** * Get the first Pivot function description of the given formula. */ function getFirstPivotFunction(tokens) { return getFunctionsFromTokens(tokens, PIVOT_FUNCTIONS)[0]; } /** * Parse a spreadsheet formula and detect the number of PIVOT functions that are * present in the given formula. */ function getNumberOfPivotFunctions(tokens) { return getFunctionsFromTokens(tokens, PIVOT_FUNCTIONS).length; } /** * Registry to enable or disable the support of positional arguments * (with a leading #) in pivot functions * e.g. =PIVOT.VALUE(1,"probability","#stage",1) */ const supportedPivotPositionalFormulaRegistry = new Registry(); supportedPivotPositionalFormulaRegistry.add("SPREADSHEET", false); autoCompleteProviders.add("pivot_ids", { sequence: 50, autoSelectFirstProposal: true, getProposals(tokenAtCursor) { const functionContext = tokenAtCursor.functionContext; const pivotFunction = ["PIVOT.VALUE", "PIVOT.HEADER", "PIVOT"]; if (!functionContext || !pivotFunction.includes(functionContext.parent.toUpperCase()) || functionContext.argPosition !== 0) { return; } const pivotIds = this.getters.getPivotIds(); if (pivotIds.includes(tokenAtCursor.value)) { return; } return pivotIds .map((pivotId) => { const definition = this.getters.getPivotCoreDefinition(pivotId); const formulaId = this.getters.getPivotFormulaId(pivotId); const str = `${formulaId}`; return { text: str, description: definition.name, htmlContent: [{ value: str, color: tokenColors.NUMBER }], fuzzySearchKey: str + definition.name, }; }) .filter(isDefined); }, selectProposal: insertTokenAfterLeftParenthesis, }); autoCompleteProviders.add("pivot_measures", { sequence: 50, autoSelectFirstProposal: true, getProposals(tokenAtCursor) { const functionContext = tokenAtCursor.functionContext; if (functionContext?.parent.toUpperCase() !== "PIVOT.VALUE" || functionContext.argPosition !== 1) { return []; } const pivotFormulaId = extractFormulaIdFromToken(tokenAtCursor); const pivotId = this.getters.getPivotId(pivotFormulaId); if (!pivotId || !this.getters.isExistingPivot(pivotId)) { return []; } const pivot = this.getters.getPivot(pivotId); pivot.init(); if (!pivot.isValid()) { return []; } return pivot.definition.measures .map((measure) => { if (measure.fieldName === "__count") { const text = '"__count"'; return { text, description: _t("Count"), htmlContent: [{ value: text, color: tokenColors.STRING }], fuzzySearchKey: _t("Count") + text, }; } return makeMeasureProposal(measure); }) .filter(isDefined); }, selectProposal: insertTokenAfterArgSeparator, }); autoCompleteProviders.add("pivot_group_fields", { sequence: 50, autoSelectFirstProposal: true, getProposals(tokenAtCursor) { const functionContext = tokenAtCursor.functionContext; if (!functionContext || (!canAutoCompletePivotField(tokenAtCursor) && !canAutoCompletePivotHeaderField(tokenAtCursor))) { return; } const pivotFormulaId = extractFormulaIdFromToken(tokenAtCursor); const pivotId = this.getters.getPivotId(pivotFormulaId); if (!pivotId || !this.getters.isExistingPivot(pivotId)) { return; } const pivot = this.getters.getPivot(pivotId); pivot.init(); const fields = pivot.getFields(); const { columns, rows } = pivot.definition; let args = functionContext.args; if (functionContext?.parent.toUpperCase() === "PIVOT.VALUE") { args = args.filter((ast, index) => index % 2 === 0); // keep only the field names args = args.slice(1, functionContext.argPosition); // remove the first even argument (the pivot id) } else { args = args.filter((ast, index) => index % 2 === 1); // keep only the field names } const argGroupBys = args.map((ast) => ast?.value).filter(isDefined); const colFields = columns.map((groupBy) => groupBy.nameWithGranularity); const rowFields = rows.map((groupBy) => groupBy.nameWithGranularity); const proposals = []; let previousGroupBy = ["ARG_SEPARATOR", "SPACE"].includes(tokenAtCursor.type) ? argGroupBys.at(-1) : argGroupBys.at(-2); const isPositionalSupported = supportedPivotPositionalFormulaRegistry.get(pivot.type); if (isPositionalSupported && previousGroupBy?.startsWith("#")) { previousGroupBy = previousGroupBy.slice(1); } if (previousGroupBy === undefined) { proposals.push(colFields[0]); proposals.push(rowFields[0]); } if (rowFields.includes(previousGroupBy)) { const nextRowGroupBy = rowFields[rowFields.indexOf(previousGroupBy) + 1]; proposals.push(nextRowGroupBy); proposals.push(colFields[0]); } if (colFields.includes(previousGroupBy)) { const nextGroupBy = colFields[colFields.indexOf(previousGroupBy) + 1]; proposals.push(nextGroupBy); } const groupBys = proposals.filter(isDefined); return groupBys .map((groupBy) => { const [fieldName, granularity] = groupBy.split(":"); const field = fields[fieldName]; return field ? makeFieldProposal(field, granularity) : undefined; }) .concat(groupBys.map((groupBy) => { if (!isPositionalSupported) { return undefined; } const fieldName = groupBy.split(":")[0]; const field = fields[fieldName]; if (!field) { return undefined; } const positionalFieldArg = `"#${groupBy}"`; const positionalProposal = { text: positionalFieldArg, description: _t("%s (positional)", field.string) + (field.help ? ` (${field.help})` : ""), htmlContent: [{ value: positionalFieldArg, color: tokenColors.STRING }], fuzzySearchKey: field.string + positionalFieldArg, // search on translated name and on technical name }; return positionalProposal; })) .filter(isDefined); }, selectProposal: insertTokenAfterArgSeparator, }); function canAutoCompletePivotField(tokenAtCursor) { const functionContext = tokenAtCursor.functionContext; return (functionContext?.parent.toUpperCase() === "PIVOT.VALUE" && functionContext.argPosition >= 2 && // the first two arguments are the pivot id and the measure functionContext.argPosition % 2 === 0 // only the even arguments are the group bys ); } function canAutoCompletePivotHeaderField(tokenAtCursor) { const functionContext = tokenAtCursor.functionContext; return (functionContext?.parent.toUpperCase() === "PIVOT.HEADER" && functionContext.argPosition >= 1 && // the first argument is the pivot id functionContext.argPosition % 2 === 1 // only the odd arguments are the group bys ); } autoCompleteProviders.add("pivot_group_values", { sequence: 50, autoSelectFirstProposal: true, getProposals(tokenAtCursor) { const functionContext = tokenAtCursor.functionContext; if (!functionContext || !tokenAtCursor || (!canAutoCompletePivotGroupValue(tokenAtCursor) && !canAutoCompletePivotHeaderGroupValue(tokenAtCursor))) { return; } const pivotFormulaId = extractFormulaIdFromToken(tokenAtCursor); const pivotId = this.getters.getPivotId(pivotFormulaId); if (!pivotId || !this.getters.isExistingPivot(pivotId)) { return; } const pivot = this.getters.getPivot(pivotId); if (!pivot.isValid()) { return; } const argPosition = functionContext.argPosition; const groupByField = tokenAtCursor.functionContext?.args[argPosition - 1]?.value; if (!groupByField) { return; } let dimension; try { dimension = pivot.definition.getDimension(groupByField); } catch (error) { return undefined; } if (dimension.granularity === "month_number") { return Object.values(MONTHS).map((monthDisplayName, index) => ({ text: `${index + 1}`, fuzzySearchKey: monthDisplayName.toString(), description: monthDisplayName.toString(), htmlContent: [{ value: `${index + 1}`, color: tokenColors.NUMBER }], })); } else if (dimension.granularity === "quarter_number") { return [1, 2, 3, 4].map((quarter) => ({ text: `${quarter}`, fuzzySearchKey: `${quarter}`, description: _t("Quarter %s", quarter), htmlContent: [{ value: `${quarter}`, color: tokenColors.NUMBER }], })); } else if (dimension.granularity === "day_of_month") { return range(1, 32).map((dayOfMonth) => ({ text: `${dayOfMonth}`, fuzzySearchKey: `${dayOfMonth}`, description: "", htmlContent: [{ value: `${dayOfMonth}`, color: tokenColors.NUMBER }], })); } else if (dimension.granularity === "iso_week_number") { return range(0, 54).map((isoWeekNumber) => ({ text: `${isoWeekNumber}`, fuzzySearchKey: `${isoWeekNumber}`, description: "", htmlContent: [{ value: `${isoWeekNumber}`, color: tokenColors.NUMBER }], })); } else if (dimension.granularity === "day_of_week") { return range(1, 8).map((dayOfWeekNumber) => ({ text: `${dayOfWeekNumber}`, fuzzySearchKey: `${dayOfWeekNumber}`, description: "", htmlContent: [{ value: `${dayOfWeekNumber}`, color: tokenColors.NUMBER }], })); } else if (dimension.granularity === "hour_number") { return range(0, 24).map((hourNumber) => ({ text: `${hourNumber}`, fuzzySearchKey: `${hourNumber}`, description: "", htmlContent: [{ value: `${hourNumber}`, color: tokenColors.NUMBER }], })); } else if (dimension.granularity === "minute_number") { return range(0, 60).map((minuteNumber) => ({ text: `${minuteNumber}`, fuzzySearchKey: `${minuteNumber}`, description: "", htmlContent: [{ value: `${minuteNumber}`, color: tokenColors.NUMBER }], })); } else if (dimension.granularity === "second_number") { return range(0, 60).map((secondNumber) => ({ text: `${secondNumber}`, fuzzySearchKey: `${secondNumber}`, description: "", htmlContent: [{ value: `${secondNumber}`, color: tokenColors.NUMBER }], })); } return pivot.getPossibleFieldValues(dimension).map(({ value, label }) => { const isString = typeof value === "string"; const text = isString ? `"${value}"` : value.toString(); const color = isString ? tokenColors.STRING : tokenColors.NUMBER; const usedLabel = label === value ? "" : label; return { text, description: usedLabel, htmlContent: [{ value: text, color }], fuzzySearchKey: value + usedLabel, }; }); }, selectProposal: insertTokenAfterArgSeparator, }); function canAutoCompletePivotGroupValue(tokenAtCursor) { const functionContext = tokenAtCursor.functionContext; return (functionContext?.parent.toUpperCase() === "PIVOT.VALUE" && functionContext.argPosition >= 2 && // the first two arguments are the pivot id and the measure functionContext.argPosition % 2 === 1 // only the odd arguments are the group by values ); } function canAutoCompletePivotHeaderGroupValue(tokenAtCursor) { const functionContext = tokenAtCursor.functionContext; return (functionContext?.parent.toUpperCase() === "PIVOT.HEADER" && functionContext.argPosition >= 1 && // the first argument is the pivot id functionContext.argPosition % 2 === 0 // only the even arguments are the group by values ); } autoCompleteProviders.add("sheet_names", { sequence: 150, autoSelectFirstProposal: true, getProposals(tokenAtCursor) { if (tokenAtCursor.type === "SYMBOL" || (tokenAtCursor.type === "UNKNOWN" && tokenAtCursor.value.startsWith("'"))) { return this.getters.getSheetIds().map((sheetId) => { const sheetName = getCanonicalSymbolName(this.getters.getSheetName(sheetId)); return { text: sheetName, fuzzySearchKey: sheetName.startsWith("'") ? sheetName : "'" + sheetName, // typing a single quote is a way to avoid matching function names }; }); } return []; }, selectProposal(tokenAtCursor, value) { const start = tokenAtCursor.start; const end = tokenAtCursor.end; this.composer.changeComposerCursorSelection(start, end); this.composer.replaceComposerCursorSelection(value + "!"); }, }); /** * Add the `https` prefix to the url if it's missing */ function withHttps(url) { return !/^https?:\/\//i.test(url) ? `https://${url}` : url; } const urlRegistry = new Registry(); function createWebLink(url, label) { url = withHttps(url); return { url, label: label || url, isExternal: true, isUrlEditable: true, }; } urlRegistry.add("sheet_URL", { match: (url) => isSheetUrl(url), createLink: (url, label) => { return { label, url, isExternal: false, isUrlEditable: false, }; }, urlRepresentation(url, getters) { const sheetId = parseSheetUrl(url); return getters.tryGetSheetName(sheetId) || _t("Invalid sheet"); }, open(url, env) { const sheetId = parseSheetUrl(url); const result = env.model.dispatch("ACTIVATE_SHEET", { sheetIdFrom: env.model.getters.getActiveSheetId(), sheetIdTo: sheetId, }); if (result.isCancelledBecause("SheetIsHidden" /* CommandResult.SheetIsHidden */)) { env.notifyUser({ type: "warning", sticky: false, text: _t("Cannot open the link because the linked sheet is hidden."), }); } }, sequence: 0, }); const WebUrlSpec = { createLink: createWebLink, match: (url) => isWebLink(url), open: (url) => window.open(url, "_blank"), urlRepresentation: (url) => url, sequence: 0, }; function findMatchingSpec(url) { return (urlRegistry .getAll() .sort((a, b) => a.sequence - b.sequence) .find((urlType) => urlType.match(url)) || WebUrlSpec); } function urlRepresentation(link, getters) { return findMatchingSpec(link.url).urlRepresentation(link.url, getters); } function openLink(link, env) { findMatchingSpec(link.url).open(link.url, env); } function detectLink(value) { if (typeof value !== "string") { return undefined; } if (isMarkdownLink(value)) { const { label, url } = parseMarkdownLink(value); return findMatchingSpec(url).createLink(url, label); } else if (isWebLink(value)) { return createWebLink(value); } return undefined; } function evaluateLiteral(literalCell, localeFormat) { const value = isTextFormat(localeFormat.format) ? literalCell.content : literalCell.parsedValue; const functionResult = { value, format: localeFormat.format }; return createEvaluatedCell(functionResult, localeFormat.locale); } function parseLiteral(content, locale) { if (content.startsWith("=")) { throw new Error(`Cannot parse "${content}" because it's not a literal value. It's a formula`); } if (content === "") { return null; } if (isNumber(content, DEFAULT_LOCALE)) { return parseNumber(content, DEFAULT_LOCALE); } const internalDate = parseDateTime(content, locale); if (internalDate) { return internalDate.value; } if (isBoolean(content)) { return content.toUpperCase() === "TRUE" ? true : false; } return content; } function createEvaluatedCell(functionResult, locale = DEFAULT_LOCALE, cell) { const link = detectLink(functionResult.value); if (!link) { return _createEvaluatedCell(functionResult, locale, cell); } const value = parseLiteral(link.label, locale); const format = functionResult.format || (typeof value === "number" ? detectDateFormat(link.label, locale) || detectNumberFormat(link.label) : undefined); const linkPayload = { value, format, }; return { ..._createEvaluatedCell(linkPayload, locale, cell), link, }; } function _createEvaluatedCell(functionResult, locale, cell) { let { value, format, message } = functionResult; format = cell?.format || format; const formattedValue = formatValue(value, { format, locale }); if (isEvaluationError(value)) { return errorCell(value, message); } if (isTextFormat(format)) { // TO DO: // with the next line, the value of the cell is transformed depending on the format. // This shouldn't happen, by doing this, the formulas handling numbers are not able // to interpret the value as a number. return textCell(toString(value), format, formattedValue); } if (value === null) { return emptyCell(format); } if (typeof value === "number") { if (isDateTimeFormat(format || "")) { return dateTimeCell(value, format, formattedValue); } return numberCell(value, format, formattedValue); } if (typeof value === "boolean") { return booleanCell(value, format, formattedValue); } return textCell(value, format, formattedValue); } function textCell(value, format, formattedValue) { return { value, format, formattedValue, type: CellValueType.text, isAutoSummable: true, defaultAlign: "left", }; } function numberCell(value, format, formattedValue) { return { value: value || 0, // necessary to avoid "-0" and NaN values, format, formattedValue, type: CellValueType.number, isAutoSummable: true, defaultAlign: "right", }; } const emptyCell = memoize(function emptyCell(format) { return { value: null, format, formattedValue: "", type: CellValueType.empty, isAutoSummable: true, defaultAlign: "left", }; }); function dateTimeCell(value, format, formattedValue) { return { value, format, formattedValue, type: CellValueType.number, isAutoSummable: false, defaultAlign: "right", }; } function booleanCell(value, format, formattedValue) { return { value, format, formattedValue, type: CellValueType.boolean, isAutoSummable: false, defaultAlign: "center", }; } function errorCell(value, message) { return { value, formattedValue: value, message, type: CellValueType.error, isAutoSummable: false, defaultAlign: "center", }; } /** * An AutofillModifierImplementation is used to describe how to handle a * AutofillModifier. */ const autofillModifiersRegistry = new Registry(); autofillModifiersRegistry .add("ALPHANUMERIC_INCREMENT_MODIFIER", { apply: (rule, data) => { rule.current += rule.increment; const content = `${rule.prefix}${rule.current .toString() .padStart(rule.numberPostfixLength || 0, "0")}`; return { cellData: { border: data.border, style: data.cell && data.cell.style, format: data.cell && data.cell.format, content, }, tooltip: { props: { content } }, }; }, }) .add("INCREMENT_MODIFIER", { apply: (rule, data, getters) => { rule.current += rule.increment; const content = rule.current.toString(); const locale = getters.getLocale(); const tooltipValue = formatValue(rule.current, { format: data.cell?.format, locale }); return { cellData: { border: data.border, style: data.cell && data.cell.style, format: data.cell && data.cell.format, content, }, tooltip: content ? { props: { content: tooltipValue } } : undefined, }; }, }) .add("DATE_INCREMENT_MODIFIER", { apply: (rule, data, getters) => { const date = toJsDate(rule.current, getters.getLocale()); date.setFullYear(date.getFullYear() + rule.increment.years || 0); date.setMonth(date.getMonth() + rule.increment.months || 0); date.setDate(date.getDate() + rule.increment.days || 0); const value = jsDateToNumber(date); rule.current = value; const locale = getters.getLocale(); const tooltipValue = formatValue(value, { format: data.cell?.format, locale }); return { cellData: { border: data.border, style: data.cell && data.cell.style, format: data.cell && data.cell.format, content: value.toString(), }, tooltip: value ? { props: { content: tooltipValue } } : undefined, }; }, }) .add("COPY_MODIFIER", { apply: (rule, data, getters) => { const content = data.cell?.content || ""; const localeFormat = { locale: getters.getLocale(), format: data.cell?.format }; return { cellData: { border: data.border, style: data.cell && data.cell.style, format: data.cell && data.cell.format, content, }, tooltip: content ? { props: { content: data.cell ? evaluateLiteral(data.cell, localeFormat).formattedValue : "", }, } : undefined, }; }, }) .add("FORMULA_MODIFIER", { apply: (rule, data, getters, direction) => { rule.current += rule.increment; let x = 0; let y = 0; switch (direction) { case "up" /* DIRECTION.UP */: x = 0; y = -rule.current; break; case "down" /* DIRECTION.DOWN */: x = 0; y = rule.current; break; case "left" /* DIRECTION.LEFT */: x = -rule.current; y = 0; break; case "right" /* DIRECTION.RIGHT */: x = rule.current; y = 0; break; } const cell = data.cell; if (!cell || !cell.isFormula) { return { cellData: {} }; } const sheetId = data.sheetId; const content = getters.getTranslatedCellFormula(sheetId, x, y, cell.compiledFormula.tokens); return { cellData: { border: data.border, style: cell.style, format: cell.format, content, }, tooltip: content ? { props: { content } } : undefined, }; }, }); const autofillRulesRegistry = new Registry(); const numberPostfixRegExp = /(\d+)$/; const stringPrefixRegExp = /^(.*\D+)/; const alphaNumericValueRegExp = /^(.*\D+)(\d+)$/; /** * Get the consecutive evaluated cells that can pass the filter function (e.g. certain type filter). * Return the one which contains the given cell */ function getGroup(cell, cells, filter) { let group = []; let found = false; for (let x of cells) { if (x === cell) { found = true; } const cellValue = x === undefined || x.isFormula ? undefined : evaluateLiteral(x, { locale: DEFAULT_LOCALE, format: x.format }); if (cellValue && filter(cellValue)) { group.push(cellValue); } else { if (found) { return group; } group = []; } } return group; } /** * Get the average steps between numbers */ function getAverageIncrement(group) { const averages = []; let last = group[0]; for (let i = 1; i < group.length; i++) { const current = group[i]; averages.push(current - last); last = current; } return averages.reduce((a, b) => a + b, 0) / averages.length; } /** * Get the step for a group */ function calculateIncrementBasedOnGroup(group) { let increment = 1; if (group.length >= 2) { increment = getAverageIncrement(group) * group.length; } return increment; } /** * Iterates on a list of date intervals. * if every interval is the same, return the interval * Otherwise return undefined * */ function getEqualInterval(intervals) { if (intervals.length < 2) { return intervals[0] || { years: 0, months: 0, days: 0 }; } const equal = intervals.every((interval) => interval.years === intervals[0].years && interval.months === intervals[0].months && interval.days === intervals[0].days); return equal ? intervals[0] : undefined; } /** * Based on a group of dates, calculate the increment that should be applied * to the next date. * * This will compute the date difference in calendar terms (years, months, days) * In order to make abstraction of leap years and months with different number of days. * * In case the dates are not equidistant in calendar terms, no rule can be extrapolated * In case of equidistant dates, we either have in that order: * - exact date interval (e.g. +n year OR +n month OR +n day) in which case we increment by the same interval * - exact day interval (e.g. +n days) in which case we increment by the same day interval * - equidistant dates but not the same interval, in which case we return increment of the same interval * * */ function calculateDateIncrementBasedOnGroup(group) { if (group.length < 2) { return 1; } const jsDates = group.map((date) => toJsDate(date, DEFAULT_LOCALE)); const datesIntervals = getDateIntervals(jsDates); const datesEquidistantInterval = getEqualInterval(datesIntervals); if (datesEquidistantInterval === undefined) { // dates are not equidistant in terms of years, months or days, thus no rule can be extrapolated return undefined; } // The dates are apart by an exact interval of years, months or days // but not a combination of them const exactDateInterval = Object.values(datesEquidistantInterval).filter((value) => value !== 0).length === 1; const isSameDay = Object.values(datesEquidistantInterval).every((el) => el === 0); // handles time values (strict decimals) if (!exactDateInterval || isSameDay) { const timeIntervals = jsDates .map((date, index) => { if (index === 0) { return 0; } const previous = jsDates[index - 1]; const days = Math.floor(date.getTime()) - Math.floor(previous.getTime()); return days; }) .slice(1); const equidistantDates = timeIntervals.every((interval) => interval === timeIntervals[0]); if (equidistantDates) { return group.length * (group[1] - group[0]); } } return { years: datesEquidistantInterval.years * group.length, months: datesEquidistantInterval.months * group.length, days: datesEquidistantInterval.days * group.length, }; } autofillRulesRegistry .add("simple_value_copy", { condition: (cell, cells) => { return (cells.length === 1 && !cell.isFormula && !(cell.format && isDateTimeFormat(cell.format))); }, generateRule: () => { return { type: "COPY_MODIFIER" }; }, sequence: 10, }) .add("increment_alphanumeric_value", { condition: (cell) => !cell.isFormula && evaluateLiteral(cell, { locale: DEFAULT_LOCALE }).type === CellValueType.text && alphaNumericValueRegExp.test(cell.content), generateRule: (cell, cells) => { const numberPostfix = parseInt(cell.content.match(numberPostfixRegExp)[0]); const prefix = cell.content.match(stringPrefixRegExp)[0]; const numberPostfixLength = cell.content.length - prefix.length; const group = getGroup(cell, cells, (evaluatedCell) => evaluatedCell.type === CellValueType.text && alphaNumericValueRegExp.test(evaluatedCell.value)) // get consecutive alphanumeric cells, no matter what the prefix is .filter((cell) => prefix === (cell.value ?? "").toString().match(stringPrefixRegExp)[0]) .map((cell) => parseInt((cell.value ?? "").toString().match(numberPostfixRegExp)[0])); const increment = calculateIncrementBasedOnGroup(group); return { type: "ALPHANUMERIC_INCREMENT_MODIFIER", prefix, current: numberPostfix, increment, numberPostfixLength, }; }, sequence: 15, }) .add("copy_text", { condition: (cell) => !cell.isFormula && evaluateLiteral(cell, { locale: DEFAULT_LOCALE }).type === CellValueType.text, generateRule: () => { return { type: "COPY_MODIFIER" }; }, sequence: 20, }) .add("update_formula", { condition: (cell) => cell.isFormula, generateRule: (_, cells) => { return { type: "FORMULA_MODIFIER", increment: cells.length, current: 0 }; }, sequence: 30, }) .add("increment_dates", { condition: (cell, cells) => { return (!cell.isFormula && evaluateLiteral(cell, { locale: DEFAULT_LOCALE }).type === CellValueType.number && !!cell.format && isDateTimeFormat(cell.format)); }, generateRule: (cell, cells) => { const group = getGroup(cell, cells, (evaluatedCell) => evaluatedCell.type === CellValueType.number && !!evaluatedCell.format && isDateTimeFormat(evaluatedCell.format)).map((cell) => Number(cell.value)); const increment = calculateDateIncrementBasedOnGroup(group); if (increment === undefined) { return { type: "COPY_MODIFIER" }; } /** requires to detect the current date (requires to be an integer value with the right format) * detect if year or if month or if day then extrapolate increment required (+1 month, +1 year + 1 day) */ const evaluation = evaluateLiteral(cell, { locale: DEFAULT_LOCALE }); if (typeof increment === "object") { return { type: "DATE_INCREMENT_MODIFIER", increment, current: evaluation.type === CellValueType.number ? evaluation.value : 0, }; } return { type: "INCREMENT_MODIFIER", increment, current: evaluation.type === CellValueType.number ? evaluation.value : 0, }; }, sequence: 25, }) .add("increment_number", { condition: (cell) => !cell.isFormula && evaluateLiteral(cell, { locale: DEFAULT_LOCALE }).type === CellValueType.number, generateRule: (cell, cells) => { const group = getGroup(cell, cells, (evaluatedCell) => evaluatedCell.type === CellValueType.number && !isDateTimeFormat(evaluatedCell.format || "")).map((cell) => Number(cell.value)); const increment = calculateIncrementBasedOnGroup(group); const evaluation = evaluateLiteral(cell, { locale: DEFAULT_LOCALE }); return { type: "INCREMENT_MODIFIER", increment, current: evaluation.type === CellValueType.number ? evaluation.value : 0, }; }, sequence: 40, }); /** * Returns the date intervals between consecutive dates of an array * in the format of { years: number, months: number, days: number } * * The split is necessary to make abstraction of leap years and * months with different number of days. * * @param dates */ function getDateIntervals(dates) { if (dates.length < 2) { return [{ years: 0, months: 0, days: 0 }]; } const res = dates.map((date, index) => { if (index === 0) { return { years: 0, months: 0, days: 0 }; } const previous = DateTime.fromTimestamp(dates[index - 1].getTime()); const years = getTimeDifferenceInWholeYears(previous, date); const months = getTimeDifferenceInWholeMonths(previous, date) % 12; previous.setFullYear(previous.getFullYear() + years); previous.setMonth(previous.getMonth() + months); const days = getTimeDifferenceInWholeDays(previous, date); return { years, months, days, }; }); return res.slice(1); } const cellPopoverRegistry = new Registry(); const GAUGE_PADDING_SIDE = 30; const GAUGE_PADDING_TOP = 10; const GAUGE_PADDING_BOTTOM = 20; const GAUGE_LABELS_FONT_SIZE = 12; const GAUGE_DEFAULT_VALUE_FONT_SIZE = 80; const GAUGE_BACKGROUND_COLOR = "#F3F2F1"; const GAUGE_TEXT_COLOR = "#666666"; const GAUGE_TEXT_COLOR_HIGH_CONTRAST = "#C8C8C8"; const GAUGE_INFLECTION_MARKER_COLOR = "#666666aa"; const GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN = 6; const GAUGE_TITLE_SECTION_HEIGHT = 25; const GAUGE_TITLE_FONT_SIZE = SCORECARD_GAUGE_CHART_FONT_SIZE; const GAUGE_TITLE_PADDING_LEFT = SCORECARD_GAUGE_CHART_PADDING; const GAUGE_TITLE_PADDING_TOP = SCORECARD_GAUGE_CHART_PADDING; function drawGaugeChart(canvas, runtime) { const canvasBoundingRect = canvas.getBoundingClientRect(); canvas.width = canvasBoundingRect.width; canvas.height = canvasBoundingRect.height; const ctx = canvas.getContext("2d"); const config = getGaugeRenderingConfig(canvasBoundingRect, runtime, ctx); drawBackground(ctx, config); drawGauge(ctx, config); drawInflectionValues(ctx, config); drawLabels(ctx, config); drawTitle(ctx, config); } function drawGauge(ctx, config) { ctx.save(); const gauge = config.gauge; const arcCenterX = gauge.rect.x + gauge.rect.width / 2; const arcCenterY = gauge.rect.y + gauge.rect.height; const arcRadius = gauge.rect.height - gauge.arcWidth / 2; if (arcRadius < 0) { return; } const gaugeAngle = gauge.percentage === 1 ? 0 : Math.PI * (1 + gauge.percentage); // Gauge background ctx.strokeStyle = GAUGE_BACKGROUND_COLOR; ctx.beginPath(); ctx.lineWidth = gauge.arcWidth; ctx.arc(arcCenterX, arcCenterY, arcRadius, gaugeAngle, 0); ctx.stroke(); // Gauge value ctx.strokeStyle = gauge.color; ctx.beginPath(); ctx.arc(arcCenterX, arcCenterY, arcRadius, Math.PI, gaugeAngle); ctx.stroke(); ctx.restore(); } function drawBackground(ctx, config) { ctx.save(); ctx.fillStyle = config.backgroundColor; ctx.fillRect(0, 0, config.width, config.height); ctx.restore(); } function drawLabels(ctx, config) { for (const label of [config.minLabel, config.maxLabel, config.gaugeValue]) { ctx.save(); ctx.textAlign = "center"; ctx.fillStyle = label.color; ctx.font = `${label.fontSize}px ${DEFAULT_FONT}`; ctx.fillText(label.label, label.textPosition.x, label.textPosition.y); ctx.restore(); } } function drawInflectionValues(ctx, config) { const { x: rectX, y: rectY, width, height } = config.gauge.rect; for (const inflectionValue of config.inflectionValues) { ctx.save(); ctx.translate(rectX + width / 2 - 0.5, rectY + height - 0.5); // -0.5 for sharper lines. see RendererPlugin.drawBorders comment ctx.rotate(Math.PI / 2 - inflectionValue.rotation); ctx.lineWidth = 2; ctx.strokeStyle = GAUGE_INFLECTION_MARKER_COLOR; ctx.beginPath(); ctx.moveTo(0, -(height - config.gauge.arcWidth)); ctx.lineTo(0, -height - 3); ctx.stroke(); ctx.textAlign = "center"; ctx.font = `${inflectionValue.fontSize}px ${DEFAULT_FONT}`; ctx.fillStyle = inflectionValue.color; const textY = -height - GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN - inflectionValue.offset; ctx.fillText(inflectionValue.label, 0, textY); ctx.restore(); } } function drawTitle(ctx, config) { ctx.save(); const title = config.title; ctx.font = getDefaultContextFont(title.fontSize, title.bold, title.italic); ctx.textBaseline = "middle"; ctx.fillStyle = title.color; ctx.fillText(title.label, title.textPosition.x, title.textPosition.y); ctx.restore(); } function getGaugeRenderingConfig(boundingRect, runtime, ctx) { const maxValue = runtime.maxValue; const minValue = runtime.minValue; const gaugeValue = runtime.gaugeValue; const gaugeRect = getGaugeRect(boundingRect, runtime.title.text); const gaugeArcWidth = gaugeRect.width / 6; const gaugePercentage = gaugeValue ? (gaugeValue.value - minValue.value) / (maxValue.value - minValue.value) : 0; const gaugeValuePosition = { x: boundingRect.width / 2, y: gaugeRect.y + gaugeRect.height - gaugeRect.height / 12, }; let gaugeValueFontSize = GAUGE_DEFAULT_VALUE_FONT_SIZE; // Scale down the font size if the gaugeRect is too small if (gaugeRect.height < 300) { gaugeValueFontSize = gaugeValueFontSize * (gaugeRect.height / 300); } // Scale down the font size if the text is too long const maxTextWidth = gaugeRect.width / 2; const gaugeLabel = gaugeValue?.label || "-"; if (computeTextWidth(ctx, gaugeLabel, { fontSize: gaugeValueFontSize }, "px") > maxTextWidth) { gaugeValueFontSize = getFontSizeMatchingWidth(maxTextWidth, gaugeValueFontSize, (fontSize) => computeTextWidth(ctx, gaugeLabel, { fontSize }, "px")); } const minLabelPosition = { x: gaugeRect.x + gaugeArcWidth / 2, y: gaugeRect.y + gaugeRect.height + GAUGE_LABELS_FONT_SIZE, }; const maxLabelPosition = { x: gaugeRect.x + gaugeRect.width - gaugeArcWidth / 2, y: gaugeRect.y + gaugeRect.height + GAUGE_LABELS_FONT_SIZE, }; const textColor = getContrastedTextColor(runtime.background); const inflectionValues = getInflectionValues(runtime, gaugeRect, textColor, ctx); let x = 0, titleWidth = 0, titleHeight = 0; if (runtime.title.text) { ({ width: titleWidth, height: titleHeight } = computeTextDimension(ctx, runtime.title.text, { ...runtime.title, fontSize: GAUGE_TITLE_FONT_SIZE }, "px")); } switch (runtime.title.align) { case "right": x = boundingRect.width - titleWidth - GAUGE_TITLE_PADDING_LEFT; break; case "center": x = (boundingRect.width - titleWidth) / 2; break; case "left": default: x = GAUGE_TITLE_PADDING_LEFT; break; } return { width: boundingRect.width, height: boundingRect.height, title: { label: runtime.title.text ?? "", fontSize: GAUGE_TITLE_FONT_SIZE, textPosition: { x, y: GAUGE_TITLE_PADDING_TOP + titleHeight / 2, }, color: runtime.title.color ?? textColor, bold: runtime.title.bold, italic: runtime.title.italic, }, backgroundColor: runtime.background, gauge: { rect: gaugeRect, arcWidth: gaugeArcWidth, percentage: clip(gaugePercentage, 0, 1), color: getGaugeColor(runtime), }, inflectionValues, gaugeValue: { label: gaugeLabel, textPosition: gaugeValuePosition, fontSize: gaugeValueFontSize, color: textColor, }, minLabel: { label: runtime.minValue.label, textPosition: minLabelPosition, fontSize: GAUGE_LABELS_FONT_SIZE, color: textColor, }, maxLabel: { label: runtime.maxValue.label, textPosition: maxLabelPosition, fontSize: GAUGE_LABELS_FONT_SIZE, color: textColor, }, }; } /** * Get the rectangle in which the gauge will be drawn, based on the bounding rectangle of the canvas and leaving * space for the title and labels. */ function getGaugeRect(boundingRect, title) { const titleHeight = title ? GAUGE_TITLE_SECTION_HEIGHT : 0; const drawHeight = boundingRect.height - GAUGE_PADDING_BOTTOM - titleHeight - GAUGE_PADDING_TOP; const drawWidth = boundingRect.width - GAUGE_PADDING_SIDE * 2; let gaugeWidth; let gaugeHeight; if (drawWidth > 2 * drawHeight) { gaugeWidth = 2 * drawHeight; gaugeHeight = drawHeight; } else { gaugeWidth = drawWidth; gaugeHeight = drawWidth / 2; } const gaugeX = GAUGE_PADDING_SIDE + (drawWidth - gaugeWidth) / 2; const gaugeY = titleHeight + GAUGE_PADDING_TOP + (drawHeight - gaugeHeight) / 2; return { x: gaugeX, y: gaugeY, width: gaugeWidth, height: gaugeHeight, }; } /** * Get the infliction values of the gauge, and where to draw them (the angle from the center of the gauge at which they are drawn). * * Also compute an offset for the text so that it doesn't overlap with other text. */ function getInflectionValues(runtime, gaugeRect, textColor, ctx) { const maxValue = runtime.maxValue; const minValue = runtime.minValue; const gaugeCircleCenter = { x: gaugeRect.x + gaugeRect.width / 2, y: gaugeRect.y + gaugeRect.height, }; const textStyle = { fontSize: GAUGE_LABELS_FONT_SIZE }; const inflectionValues = []; const inflectionValuesTextRects = []; for (const inflectionValue of runtime.inflectionValues) { const percentage = (inflectionValue.value - minValue.value) / (maxValue.value - minValue.value); const labelWidth = computeTextWidth(ctx, inflectionValue.label, textStyle, "px"); const angle = Math.PI - Math.PI * percentage; const textRect = getRectangleTangentToCircle(angle, // angle between X axis and the point where the rectangle is tangent to the circle gaugeRect.height + GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN, // radius of the gauge circle + margin below text gaugeCircleCenter.x, // center of the gauge circle gaugeCircleCenter.y, // center of the gauge circle labelWidth + 2, // width of the text + some margin GAUGE_LABELS_FONT_SIZE // height of the text ); let offset = inflectionValuesTextRects.some((rect) => doRectanglesIntersect(rect, textRect)) ? GAUGE_LABELS_FONT_SIZE : 0; inflectionValuesTextRects.push(textRect); inflectionValues.push({ rotation: angle, label: inflectionValue.label, fontSize: GAUGE_LABELS_FONT_SIZE, color: textColor, offset, }); } return inflectionValues; } function getGaugeColor(runtime) { const gaugeValue = runtime.gaugeValue?.value; if (gaugeValue === undefined) { return GAUGE_BACKGROUND_COLOR; } for (let i = 0; i < runtime.inflectionValues.length; i++) { const inflectionValue = runtime.inflectionValues[i]; if (inflectionValue.operator === "<" && gaugeValue < inflectionValue.value) { return runtime.colors[i]; } else if (inflectionValue.operator === "<=" && gaugeValue <= inflectionValue.value) { return runtime.colors[i]; } } return runtime.colors.at(-1); } function getContrastedTextColor(backgroundColor) { return relativeLuminance(backgroundColor) > 0.3 ? GAUGE_TEXT_COLOR : GAUGE_TEXT_COLOR_HIGH_CONTRAST; } function getSegmentsOfRectangle(rectangle) { return [ { start: rectangle.topLeft, end: rectangle.topRight }, { start: rectangle.topRight, end: rectangle.bottomRight }, { start: rectangle.bottomRight, end: rectangle.bottomLeft }, { start: rectangle.bottomLeft, end: rectangle.topLeft }, ]; } /** * Check if two segment intersect. The case where the segments are colinear (both segments on the same line) * is not handled. */ function doSegmentIntersect(segment1, segment2) { const A = segment1.start; const B = segment1.end; const C = segment2.start; const D = segment2.end; /** * Line segment intersection algorithm * https://bryceboe.com/2006/10/23/line-segment-intersection-algorithm/ */ function ccw(a, b, c) { return (c.y - a.y) * (b.x - a.x) > (b.y - a.y) * (c.x - a.x); } return ccw(A, C, D) !== ccw(B, C, D) && ccw(A, B, C) !== ccw(A, B, D); } function doRectanglesIntersect(rect1, rect2) { const segments1 = getSegmentsOfRectangle(rect1); const segments2 = getSegmentsOfRectangle(rect2); for (const segment1 of segments1) { for (const segment2 of segments2) { if (doSegmentIntersect(segment1, segment2)) { return true; } } } return false; } /** * Get the rectangle that is tangent to a circle at a given angle. * * @param angle angle between X axis and the point where the rectangle is tangent to the circle */ function getRectangleTangentToCircle(angle, radius, circleCenterX, circleCenterY, rectWidth, rectHeight) { const cos = Math.cos(angle); const sin = Math.sin(angle); // x, y are the distance from the center of the circle to the point where the rectangle is tangent to the circle const x = cos * radius; const y = sin * radius; // x2, y2 are the distance from the point the rectangle is tangent to the circle to the bottom left corner of the rectangle const x2 = sin * (rectWidth / 2); // cos(angle + 90°) = sin(angle) const y2 = cos * (rectWidth / 2); const bottomRight = { x: x + x2 + circleCenterX, y: circleCenterY - (y - y2), }; const bottomLeft = { x: x - x2 + circleCenterX, y: circleCenterY - (y + y2), }; // Same as above but for the top corners of the rectangle (radius + rectangle height instead of radius) const xp = cos * (radius + rectHeight); const yp = sin * (radius + rectHeight); const topLeft = { x: xp - x2 + circleCenterX, y: circleCenterY - (yp + y2), }; const topRight = { x: xp + x2 + circleCenterX, y: circleCenterY - (yp - y2), }; return { bottomLeft, bottomRight, topRight, topLeft }; } class GaugeChartComponent extends Component { static template = "o-spreadsheet-GaugeChartComponent"; canvas = useRef("chartContainer"); get runtime() { return this.env.model.getters.getChartRuntime(this.props.figure.id); } setup() { useEffect(() => drawGaugeChart(this.canvas.el, this.runtime), () => { const canvas = this.canvas.el; const rect = canvas.getBoundingClientRect(); return [rect.width, rect.height, this.runtime, this.canvas.el]; }); } } GaugeChartComponent.props = { figure: Object, }; /** * Convert a JS color hexadecimal to an excel compatible color. * * In Excel the color don't start with a '#' and the format is AARRGGBB instead of RRGGBBAA */ function toXlsxHexColor(color) { color = toHex(color).replace("#", ""); // alpha channel goes first if (color.length === 8) { return color.slice(6) + color.slice(0, 6); } return color; } /** * Represent a raw XML string */ class XMLString { xmlString; /** * @param xmlString should be a well formed, properly escaped XML string */ constructor(xmlString) { this.xmlString = xmlString; } toString() { return this.xmlString; } } const XLSX_CHART_TYPES = [ "areaChart", "area3DChart", "lineChart", "line3DChart", "stockChart", "radarChart", "scatterChart", "pieChart", "pie3DChart", "doughnutChart", "barChart", "bar3DChart", "ofPieChart", "surfaceChart", "surface3DChart", "bubbleChart", "comboChart", ]; /** In XLSX color format (no #) */ const AUTO_COLOR = "000000"; const XLSX_ICONSET_MAP = { arrow: "3Arrows", smiley: "3Symbols", dot: "3TrafficLights1", }; const NAMESPACE = { styleSheet: "http://schemas.openxmlformats.org/spreadsheetml/2006/main", sst: "http://schemas.openxmlformats.org/spreadsheetml/2006/main", Relationships: "http://schemas.openxmlformats.org/package/2006/relationships", Types: "http://schemas.openxmlformats.org/package/2006/content-types", worksheet: "http://schemas.openxmlformats.org/spreadsheetml/2006/main", workbook: "http://schemas.openxmlformats.org/spreadsheetml/2006/main", drawing: "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing", table: "http://schemas.openxmlformats.org/spreadsheetml/2006/main", revision: "http://schemas.microsoft.com/office/spreadsheetml/2014/revision", revision3: "http://schemas.microsoft.com/office/spreadsheetml/2016/revision3", markupCompatibility: "http://schemas.openxmlformats.org/markup-compatibility/2006", }; const DRAWING_NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main"; const DRAWING_NS_C = "http://schemas.openxmlformats.org/drawingml/2006/chart"; const CONTENT_TYPES = { workbook: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml", sheet: "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml", sharedStrings: "application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml", styles: "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml", drawing: "application/vnd.openxmlformats-officedocument.drawing+xml", chart: "application/vnd.openxmlformats-officedocument.drawingml.chart+xml", themes: "application/vnd.openxmlformats-officedocument.theme+xml", table: "application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml", pivot: "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml", externalLink: "application/vnd.openxmlformats-officedocument.spreadsheetml.externalLink+xml", }; const XLSX_RELATION_TYPE = { document: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument", sheet: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet", sharedStrings: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings", styles: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles", drawing: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing", chart: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart", theme: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme", table: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/table", hyperlink: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink", image: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image", }; const RELATIONSHIP_NSR = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; const HEIGHT_FACTOR = 0.75; // 100px => 75 u /** * Excel says its default column width is 8.43 characters (64px) * which makes WIDTH_FACTOR = 0.1317, but it doesn't work well * 0.143 is a value from dev's experiments. */ const WIDTH_FACTOR = 0.143; /** unit : maximum number of characters a column can hold at the standard font size. What. */ const EXCEL_DEFAULT_COL_WIDTH = 8.43; /** unit : points */ const EXCEL_DEFAULT_ROW_HEIGHT = 12.75; const EXCEL_IMPORT_DEFAULT_NUMBER_OF_COLS = 30; const EXCEL_IMPORT_DEFAULT_NUMBER_OF_ROWS = 100; const FIRST_NUMFMT_ID = 164; const FORCE_DEFAULT_ARGS_FUNCTIONS = { FLOOR: [{ type: "NUMBER", value: 1 }], CEILING: [{ type: "NUMBER", value: 1 }], ROUND: [{ type: "NUMBER", value: 0 }], ROUNDUP: [{ type: "NUMBER", value: 0 }], ROUNDDOWN: [{ type: "NUMBER", value: 0 }], }; /** * This list contains all "future" functions that are not compatible with older versions of Excel * For more information, see https://docs.microsoft.com/en-us/openspecs/office_standards/ms-xlsx/5d1b6d44-6fc1-4ecd-8fef-0b27406cc2bf */ const NON_RETROCOMPATIBLE_FUNCTIONS = [ "ACOT", "ACOTH", "AGGREGATE", "ARABIC", "BASE", "BETA.DIST", "BETA.INV", "BINOM.DIST", "BINOM.DIST.RANGE", "BINOM.INV", "BITAND", "BITLSHIFT", "BITOR", "BITRSHIFT", "BITXOR", "BYCOL", "BYROW", "CEILING.MATH", "CEILING.PRECISE", "CHISQ.DIST", "CHISQ.DIST.RT", "CHISQ.INV", "CHISQ.INV.RT", "CHISQ.TEST", "CHOOSECOLS", "CHOOSEROWS", "COMBINA", "CONCAT", "CONFIDENCE.NORM", "CONFIDENCE.T", "COT", "COTH", "COVARIANCE.P", "COVARIANCE.S", "CSC", "CSCH", "DAYS", "DECIMAL", "DROP", "ERF.PRECISE", "ERFC.PRECISE", "EXPAND", "EXPON.DIST", "F.DIST", "F.DIST.RT", "F.INV", "F.INV.RT", "F.TEST", "FIELDVALUE", "FILTERXML", "FLOOR.MATH", "FLOOR.PRECISE", "FORECAST.ETS", "FORECAST.ETS.CONFINT", "FORECAST.ETS.SEASONALITY", "FORECAST.ETS.STAT", "FORECAST.LINEAR", "FORMULATEXT", "GAMMA", "GAMMA.DIST", "GAMMA.INV", "GAMMALN.PRECISE", "GAUSS", "HSTACK", "HYPGEOM.DIST", "IFNA", "IFS", "IMCOSH", "IMCOT", "IMCSC", "IMCSCH", "IMSEC", "IMSECH", "IMSINH", "IMTAN", "ISFORMULA", "ISOMITTED", "ISOWEEKNUM", "LAMBDA", "LET", "LOGNORM.DIST", "LOGNORM.INV", "MAKEARRAY", "MAP", "MAXIFS", "MINIFS", "MODE.MULT", "MODE.SNGL", "MUNIT", "NEGBINOM.DIST", "NORM.DIST", "NORM.INV", "NORM.S.DIST", "NORM.S.INV", "NUMBERVALUE", "PDURATION", "PERCENTILE.EXC", "PERCENTILE.INC", "PERCENTRANK.EXC", "PERCENTRANK.INC", "PERMUTATIONA", "PHI", "POISSON.DIST", "PQSOURCE", "PYTHON_STR", "PYTHON_TYPE", "PYTHON_TYPENAME", "QUARTILE.EXC", "QUARTILE.INC", "QUERYSTRING", "RANDARRAY", "RANK.AVG", "RANK.EQ", "REDUCE", "RRI", "SCAN", "SEC", "SECH", "SEQUENCE", "SHEET", "SHEETS", "SKEW.P", "SORTBY", "STDEV.P", "STDEV.S", "SWITCH", "T.DIST", "T.DIST.2T", "T.DIST.RT", "T.INV", "T.INV.2T", "T.TEST", "TAKE", "TEXTAFTER", "TEXTBEFORE", "TEXTJOIN", "TEXTSPLIT", "TOCOL", "TOROW", "UNICHAR", "UNICODE", "UNIQUE", "VAR.P", "VAR.S", "VSTACK", "WEBSERVICE", "WEIBULL.DIST", "WRAPCOLS", "WRAPROWS", "XLOOKUP", "XOR", "Z.TEST", ]; const CONTENT_TYPES_FILE = "[Content_Types].xml"; /** * Registry to draw icons on cells */ const iconsOnCellRegistry = new Registry(); css /* scss */ ` .o-spreadsheet { .o-icon { display: flex; align-items: center; justify-content: center; width: ${ICON_EDGE_LENGTH}px; height: ${ICON_EDGE_LENGTH}px; font-size: ${ICON_EDGE_LENGTH}px; vertical-align: middle; .small-text { font: bold 9px sans-serif; } .heavy-text { font: bold 16px sans-serif; } } .fa-small { font-size: 14px; } } `; // ----------------------------------------------------------------------------- // We need here the svg of the icons that we need to convert to images for the renderer // ----------------------------------------------------------------------------- const ARROW_DOWN = ''; const ARROW_UP = ''; const ARROW_RIGHT = ''; const SMILE = ''; const MEH = ''; const FROWN = ''; const GREEN_DOT = ''; const YELLOW_DOT = ''; const RED_DOT = ''; function getIconSrc(svg) { /** We have to add xmlns, as it's not added by owl in the canvas */ svg = ` { const icon = getters.getConditionalIcon(position); if (icon) { return ICONS[icon].img; } }); /** * Map of the different types of conversions warnings and their name in error messages */ var WarningTypes; (function (WarningTypes) { WarningTypes["DiagonalBorderNotSupported"] = "Diagonal Borders"; WarningTypes["BorderStyleNotSupported"] = "Border style"; WarningTypes["FillStyleNotSupported"] = "Fill Style"; WarningTypes["FontNotSupported"] = "Font"; WarningTypes["HorizontalAlignmentNotSupported"] = "Horizontal Alignment"; WarningTypes["VerticalAlignmentNotSupported"] = "Vertical Alignments"; WarningTypes["MultipleRulesCfNotSupported"] = "Multiple rules conditional formats"; WarningTypes["CfTypeNotSupported"] = "Conditional format type"; WarningTypes["CfFormatBorderNotSupported"] = "Borders in conditional formats"; WarningTypes["CfFormatAlignmentNotSupported"] = "Alignment in conditional formats"; WarningTypes["CfFormatNumFmtNotSupported"] = "Num formats in conditional formats"; WarningTypes["CfIconSetEmptyIconNotSupported"] = "IconSets with empty icons"; WarningTypes["BadlyFormattedHyperlink"] = "Badly formatted hyperlink"; WarningTypes["NumFmtIdNotSupported"] = "Number format"; })(WarningTypes || (WarningTypes = {})); class XLSXImportWarningManager { _parsingWarnings = new Set(); _conversionWarnings = new Set(); addParsingWarning(warning) { this._parsingWarnings.add(warning); } addConversionWarning(warning) { this._conversionWarnings.add(warning); } get warnings() { return [...this._parsingWarnings, ...this._conversionWarnings]; } /** * Add a warning "... is not supported" to the manager. * * @param type the type of the warning to add * @param name optional, name of the element that was not supported * @param supported optional, list of the supported elements */ generateNotSupportedWarning(type, name, supported) { let warning = `${type} ${name ? '"' + name + '" is' : "are"} not yet supported. `; if (supported) { warning += `Only ${supported.join(", ")} are currently supported.`; } if (!this._conversionWarnings.has(warning)) { this._conversionWarnings.add(warning); } } } const SUPPORTED_BORDER_STYLES = ["thin", "medium", "thick", "dashed", "dotted"]; const SUPPORTED_HORIZONTAL_ALIGNMENTS = [ "general", "left", "center", "right", ]; const SUPPORTED_VERTICAL_ALIGNMENTS = ["top", "center", "bottom"]; const SUPPORTED_FONTS = ["Arial"]; const SUPPORTED_FILL_PATTERNS = ["solid"]; const SUPPORTED_CF_TYPES = [ "expression", "cellIs", "colorScale", "iconSet", "containsText", "notContainsText", "beginsWith", "endsWith", "containsBlanks", "notContainsBlanks", ]; /** Map between cell type in XLSX file and human readable cell type */ const CELL_TYPE_CONVERSION_MAP = { b: "boolean", d: "date", e: "error", inlineStr: "inlineStr", n: "number", s: "sharedString", str: "str", }; /** Conversion map Border Style in XLSX <=> Border style in o_spreadsheet*/ const BORDER_STYLE_CONVERSION_MAP = { dashDot: "thin", dashDotDot: "thin", dashed: "dashed", dotted: "dotted", double: "thin", hair: "thin", medium: "medium", mediumDashDot: "thin", mediumDashDotDot: "thin", mediumDashed: "thin", none: undefined, slantDashDot: "thin", thick: "thick", thin: "thin", }; /** Conversion map Horizontal Alignment in XLSX <=> Horizontal Alignment in o_spreadsheet*/ const H_ALIGNMENT_CONVERSION_MAP = { general: undefined, left: "left", center: "center", right: "right", fill: "left", justify: "left", centerContinuous: "center", distributed: "center", }; /** Conversion map Vertical Alignment in XLSX => Vertical Alignment in o_spreadsheet */ const V_ALIGNMENT_CONVERSION_MAP = { top: "top", center: "middle", bottom: "bottom", justify: "middle", distributed: "middle", }; /** Conversion map Vertical Alignment in o-spreadsheet => Vertical Alignment in XLSX */ const V_ALIGNMENT_EXPORT_CONVERSION_MAP = { top: "top", middle: "center", bottom: "bottom", }; /** Convert the "CellIs" cf operator. * We have all the operators that the xlsx have, but ours begin with a uppercase character */ function convertCFCellIsOperator(xlsxCfOperator) { return (xlsxCfOperator.slice(0, 1).toUpperCase() + xlsxCfOperator.slice(1)); } /** Conversion map CF types in XLSX <=> Cf types in o_spreadsheet */ const CF_TYPE_CONVERSION_MAP = { aboveAverage: undefined, expression: undefined, cellIs: undefined, // exist but isn't an operator in o_spreadsheet colorScale: undefined, // exist but isn't an operator in o_spreadsheet dataBar: undefined, iconSet: undefined, // exist but isn't an operator in o_spreadsheet top10: undefined, uniqueValues: undefined, duplicateValues: undefined, containsText: "ContainsText", notContainsText: "NotContains", beginsWith: "BeginsWith", endsWith: "EndsWith", containsBlanks: "IsEmpty", notContainsBlanks: "IsNotEmpty", containsErrors: undefined, notContainsErrors: undefined, timePeriod: undefined, }; /** Conversion map CF thresholds types in XLSX <=> Cf thresholds types in o_spreadsheet */ const CF_THRESHOLD_CONVERSION_MAP = { num: "number", percent: "percentage", max: "value", min: "value", percentile: "percentile", formula: "formula", }; /** * Conversion map between Excels IconSets and our own IconSets. The string is the key of the iconset in the ICON_SETS constant. * * NoIcons is undefined instead of an empty string because we don't support it and need to mange it separately. */ const ICON_SET_CONVERSION_MAP = { NoIcons: undefined, "3Arrows": "arrows", "3ArrowsGray": "arrows", "3Symbols": "smiley", "3Symbols2": "smiley", "3Signs": "dots", "3Flags": "dots", "3TrafficLights1": "dots", "3TrafficLights2": "dots", "4Arrows": "arrows", "4ArrowsGray": "arrows", "4RedToBlack": "dots", "4Rating": "smiley", "4TrafficLights": "dots", "5Arrows": "arrows", "5ArrowsGray": "arrows", "5Rating": "smiley", "5Quarters": "dots", "3Stars": "smiley", "3Triangles": "arrows", "5Boxes": "dots", }; /** Map between legend position in XLSX file and human readable position */ const DRAWING_LEGEND_POSITION_CONVERSION_MAP = { b: "bottom", t: "top", l: "left", r: "right", tr: "right", }; /** Conversion map chart types in XLSX <=> Cf chart types o_spreadsheet (undefined for unsupported chart types)*/ const CHART_TYPE_CONVERSION_MAP = { areaChart: undefined, area3DChart: undefined, lineChart: "line", line3DChart: undefined, stockChart: undefined, radarChart: undefined, scatterChart: "scatter", pieChart: "pie", pie3DChart: undefined, doughnutChart: "pie", barChart: "bar", bar3DChart: undefined, ofPieChart: undefined, surfaceChart: undefined, surface3DChart: undefined, bubbleChart: undefined, comboChart: "combo", }; /** Conversion map for the SUBTOTAL(index, formula) function in xlsx, index <=> actual function*/ const SUBTOTAL_FUNCTION_CONVERSION_MAP = { "1": "AVERAGE", "2": "COUNT", "3": "COUNTA", "4": "MAX", "5": "MIN", "6": "PRODUCT", "7": "STDEV", "8": "STDEVP", "9": "SUM", "10": "VAR", "11": "VARP", "101": "AVERAGE", "102": "COUNT", "103": "COUNTA", "104": "MAX", "105": "MIN", "106": "PRODUCT", "107": "STDEV", "108": "STDEVP", "109": "SUM", "110": "VAR", "111": "VARP", }; /** Mapping between Excel format indexes (see XLSX_FORMAT_MAP) and some supported formats */ const XLSX_FORMATS_CONVERSION_MAP = { 0: "", 1: "0", 2: "0.00", 3: "#,#00", 4: "#,##0.00", 9: "0%", 10: "0.00%", 11: undefined, 12: undefined, 13: undefined, 14: "m/d/yyyy", 15: "m/d/yyyy", 16: "m/d/yyyy", 17: "m/d/yyyy", 18: "hh:mm:ss a", 19: "hh:mm:ss a", 20: "hhhh:mm:ss", 21: "hhhh:mm:ss", 22: "m/d/yy h:mm", 37: undefined, 38: undefined, 39: undefined, 40: undefined, 45: "hhhh:mm:ss", 46: "hhhh:mm:ss", 47: "hhhh:mm:ss", 48: undefined, 49: "@", }; /** * Mapping format index to format defined by default * * OpenXML $18.8.30 * */ const XLSX_FORMAT_MAP = { "0": 1, "0.00": 2, "#,#00": 3, "#,##0.00": 4, "0%": 9, "0.00%": 10, "0.00E+00": 11, "# ?/?": 12, "# ??/??": 13, "mm-dd-yy": 14, "d-mm-yy": 15, "mm-yy": 16, "mmm-yy": 17, "h:mm AM/PM": 18, "h:mm:ss AM/PM": 19, "h:mm": 20, "h:mm:ss": 21, "m/d/yy h:mm": 22, "#,##0 ;(#,##0)": 37, "#,##0 ;[Red](#,##0)": 38, "#,##0.00;(#,##0.00)": 39, "#,##0.00;[Red](#,##0.00)": 40, "mm:ss": 45, "[h]:mm:ss": 46, "mmss.0": 47, "##0.0E+0": 48, "@": 49, "hh:mm:ss a": 19, // TODO: discuss: this format is not recognized by excel for example (doesn't follow their guidelines I guess) }; /** OpenXML $18.8.27 */ const XLSX_INDEXED_COLORS = { 0: "000000", 1: "FFFFFF", 2: "FF0000", 3: "00FF00", 4: "0000FF", 5: "FFFF00", 6: "FF00FF", 7: "00FFFF", 8: "000000", 9: "FFFFFF", 10: "FF0000", 11: "00FF00", 12: "0000FF", 13: "FFFF00", 14: "FF00FF", 15: "00FFFF", 16: "800000", 17: "008000", 18: "000080", 19: "808000", 20: "800080", 21: "008080", 22: "C0C0C0", 23: "808080", 24: "9999FF", 25: "993366", 26: "FFFFCC", 27: "CCFFFF", 28: "660066", 29: "FF8080", 30: "0066CC", 31: "CCCCFF", 32: "000080", 33: "FF00FF", 34: "FFFF00", 35: "00FFFF", 36: "800080", 37: "800000", 38: "008080", 39: "0000FF", 40: "00CCFF", 41: "CCFFFF", 42: "CCFFCC", 43: "FFFF99", 44: "99CCFF", 45: "FF99CC", 46: "CC99FF", 47: "FFCC99", 48: "3366FF", 49: "33CCCC", 50: "99CC00", 51: "FFCC00", 52: "FF9900", 53: "FF6600", 54: "666699", 55: "969696", 56: "003366", 57: "339966", 58: "003300", 59: "333300", 60: "993300", 61: "993366", 62: "333399", 63: "333333", 64: "000000", // system foreground 65: "FFFFFF", // system background }; const IMAGE_MIMETYPE_TO_EXTENSION_MAPPING = { "image/avif": "avif", "image/bmp": "bmp", "image/gif": "gif", "image/vnd.microsoft.icon": "ico", "image/jpeg": "jpeg", "image/png": "png", "image/tiff": "tiff", "image/webp": "webp", }; const IMAGE_EXTENSION_TO_MIMETYPE_MAPPING = { avif: "image/avif", bmp: "image/bmp", gif: "image/gif", ico: "image/vnd.microsoft.icon", jpeg: "image/jpeg", png: "image/png", tiff: "image/tiff", webp: "image/webp", jpg: "image/jpeg", }; /** * Most of the functions could stay private, but are exported for testing purposes */ /** * * Extract the color referenced inside of an XML element and return it as an hex string #RRGGBBAA (or #RRGGBB * if alpha = FF) * * The color is an attribute of the element that can be : * - rgb : an rgb string * - theme : a reference to a theme element * - auto : automatic coloring. Return const AUTO_COLOR in constants.ts. * - indexed : a legacy indexing scheme for colors. The only value that should be present in a xlsx is * 64 = System Foreground, that we can replace with AUTO_COLOR. */ function convertColor(xlsxColor) { if (!xlsxColor) { return undefined; } let rgb; if (xlsxColor.rgb) { rgb = xlsxColor.rgb; } else if (xlsxColor.auto) { rgb = AUTO_COLOR; } else if (xlsxColor.indexed) { rgb = XLSX_INDEXED_COLORS[xlsxColor.indexed]; } else { return undefined; } rgb = xlsxColorToHEXA(rgb); if (xlsxColor.tint) { rgb = applyTint(rgb, xlsxColor.tint); } rgb = rgb.toUpperCase(); // Remove unnecessary alpha if (rgb.length === 9 && rgb.endsWith("FF")) { rgb = rgb.slice(0, 7); } return rgb; } /** * Convert a hex color AARRGGBB (or RRGGBB)(representation inside XLSX Xmls) to a standard js color * representation #RRGGBBAA */ function xlsxColorToHEXA(color) { if (color.length === 6) return "#" + color + "FF"; return "#" + color.slice(2) + color.slice(0, 2); } /** * Apply tint to a color (see OpenXml spec §18.3.1.15); */ function applyTint(color, tint) { const rgba = colorToRGBA(color); const hsla = rgbaToHSLA(rgba); if (tint < 0) { hsla.l = hsla.l * (1 + tint); } if (tint > 0) { hsla.l = hsla.l * (1 - tint) + (100 - 100 * (1 - tint)); } return rgbaToHex(hslaToRGBA(hsla)); } /** * Convert a hex + alpha color string to an integer representation. Also remove the alpha. * * eg. #FF0000FF => 4278190335 */ function hexaToInt(hex) { if (hex.length === 9) { hex = hex.slice(0, 7); } return parseInt(hex.replace("#", ""), 16); } /** * When defining style (fontColor, borderColor for instance) * Excel will specify rgb="FF000000" * In that case, We should not consider this value as user-defined but * rather like an instruction: "Use your system default" */ const DEFAULT_SYSTEM_COLOR = "FF000000"; /** * Get the relative path between two files * * Eg.: * from "folder1/file1.txt" to "folder2/file2.txt" => "../folder2/file2.txt" */ function getRelativePath(from, to) { const fromPathParts = from.split("/"); const toPathParts = to.split("/"); let relPath = ""; let startIndex = 0; for (let i = 0; i < fromPathParts.length - 1; i++) { if (fromPathParts[i] === toPathParts[i]) { startIndex++; } else { relPath += "../"; } } relPath += toPathParts.slice(startIndex).join("/"); return relPath; } /** * Convert an array of element into an object where the objects keys were the elements position in the array. * Can give an offset as argument, and all the array indexes will we shifted by this offset in the returned object. * * eg. : ["a", "b"] => {0:"a", 1:"b"} */ function arrayToObject(array, indexOffset = 0) { const obj = {}; for (let i = 0; i < array.length; i++) { if (array[i]) { obj[i + indexOffset] = array[i]; } } return obj; } /** * In xlsx we can have string with unicode characters with the format _x00fa_. * Replace with characters understandable by JS */ function fixXlsxUnicode(str) { return str.replace(/_x([0-9a-zA-Z]{4})_/g, (match, code) => { return String.fromCharCode(parseInt(code, 16)); }); } /** Get a header in the SheetData. Create the header if it doesn't exist in the SheetData */ function getSheetDataHeader(sheetData, dimension, index) { if (dimension === "COL") { if (!sheetData.cols[index]) { sheetData.cols[index] = {}; } return sheetData.cols[index]; } if (!sheetData.rows[index]) { sheetData.rows[index] = {}; } return sheetData.rows[index]; } const XLSX_DATE_FORMAT_REGEX = /^(yy|yyyy|m{1,5}|d{1,4}|h{1,2}|s{1,2}|am\/pm|a\/m|\s|-|\/|\.|:)+$/i; /** * Convert excel format to o_spreadsheet format * * Excel format are defined in openXML §18.8.31 */ function convertXlsxFormat(numFmtId, formats, warningManager) { if (numFmtId === 0) { return undefined; } // Format is either defined in the imported data, or the formatId is defined in openXML §18.8.30 let format = XLSX_FORMATS_CONVERSION_MAP[numFmtId] || formats.find((f) => f.id === numFmtId)?.format; if (format) { try { let convertedFormat = format.replace(/\[(.*)-[A-Z0-9]{3}\]/g, "[$1]"); // remove currency and locale/date system/number system info (ECMA §18.8.31) convertedFormat = convertedFormat.replace(/\[\$\]/g, ""); // remove empty bocks convertedFormat = convertedFormat.replace(/_.{1}/g, ""); // _ === ignore width of next char for align purposes. Not supported ATM convertedFormat = convertedFormat.replace(/\*.{1}/g, ""); // * === repeat next character enough to fill the line. Not supported ATM if (isXlsxDateFormat(convertedFormat)) { convertedFormat = convertDateFormat$1(convertedFormat); } if (isFormatSupported(convertedFormat)) { return convertedFormat; } } catch (e) { } } warningManager.generateNotSupportedWarning(WarningTypes.NumFmtIdNotSupported, format || `nmFmtId ${numFmtId}`); return undefined; } function isFormatSupported(format) { try { formatValue(0, { format, locale: DEFAULT_LOCALE }); return true; } catch (e) { return false; } } function isXlsxDateFormat(format) { return XLSX_DATE_FORMAT_REGEX.test(format); } function convertDateFormat$1(format) { // Some of these aren't defined neither in the OpenXML spec not the Xlsx extension of OpenXML, // but can still occur and are supported by Excel/Google sheets format = format.toLowerCase(); format = format.replace(/mmmmm/g, "mmm"); format = format.replace(/am\/pm|a\/m/g, "a"); format = format.replace(/hhhh/g, "hh"); format = format.replace(/\bh\b/g, "hh"); return format; } function convertBorders(data, warningManager) { const borderArray = data.borders.map((border) => { addBorderWarnings(border, warningManager); const b = { top: convertBorderDescr$1(border.top, warningManager), bottom: convertBorderDescr$1(border.bottom, warningManager), left: convertBorderDescr$1(border.left, warningManager), right: convertBorderDescr$1(border.right, warningManager), }; Object.keys(b).forEach((key) => b[key] === undefined && delete b[key]); return b; }); return arrayToObject(borderArray, 1); } function convertBorderDescr$1(borderDescr, warningManager) { if (!borderDescr) return undefined; addBorderDescrWarnings(borderDescr, warningManager); const style = BORDER_STYLE_CONVERSION_MAP[borderDescr.style]; return style ? { style, color: convertColor(borderDescr.color) } : undefined; } function convertStyles(data, warningManager) { const stylesArray = data.styles.map((style) => { return convertStyle({ fontStyle: data.fonts[style.fontId], fillStyle: data.fills[style.fillId], alignment: style.alignment, }, warningManager); }); return arrayToObject(stylesArray, 1); } function convertStyle(styleStruct, warningManager) { addStyleWarnings(styleStruct?.fontStyle, styleStruct?.fillStyle, warningManager); addHorizontalAlignmentWarnings(styleStruct?.alignment?.horizontal, warningManager); addVerticalAlignmentWarnings(styleStruct?.alignment?.vertical, warningManager); return { bold: styleStruct.fontStyle?.bold, italic: styleStruct.fontStyle?.italic, strikethrough: styleStruct.fontStyle?.strike, underline: styleStruct.fontStyle?.underline, verticalAlign: styleStruct.alignment?.vertical ? V_ALIGNMENT_CONVERSION_MAP[styleStruct.alignment.vertical] : undefined, align: styleStruct.alignment?.horizontal ? H_ALIGNMENT_CONVERSION_MAP[styleStruct.alignment.horizontal] : undefined, // In xlsx fills, bgColor is the color of the fill, and fgColor is the color of the pattern above the background, except in solid fills fillColor: styleStruct.fillStyle?.patternType === "solid" ? convertColor(styleStruct.fillStyle?.fgColor) : convertColor(styleStruct.fillStyle?.bgColor), textColor: convertColor(styleStruct.fontStyle?.color), fontSize: styleStruct.fontStyle?.size, wrapping: styleStruct.alignment?.wrapText ? "wrap" : "overflow", }; } function convertFormats(data, warningManager) { const formats = []; for (let style of data.styles) { const format = convertXlsxFormat(style.numFmtId, data.numFmts, warningManager); if (format) { formats[style.numFmtId] = format; } } return arrayToObject(formats, 1); } // --------------------------------------------------------------------------- // Warnings // --------------------------------------------------------------------------- function addStyleWarnings(font, fill, warningManager) { if (font && font.name && !SUPPORTED_FONTS.includes(font.name)) { warningManager.generateNotSupportedWarning(WarningTypes.FontNotSupported, font.name, SUPPORTED_FONTS); } if (fill && fill.patternType && !SUPPORTED_FILL_PATTERNS.includes(fill.patternType)) { warningManager.generateNotSupportedWarning(WarningTypes.FillStyleNotSupported, fill.patternType, SUPPORTED_FILL_PATTERNS); } } function addBorderDescrWarnings(borderDescr, warningManager) { if (!SUPPORTED_BORDER_STYLES.includes(borderDescr.style)) { warningManager.generateNotSupportedWarning(WarningTypes.BorderStyleNotSupported, borderDescr.style, SUPPORTED_BORDER_STYLES); } } function addBorderWarnings(border, warningManager) { if (border.diagonal) { warningManager.generateNotSupportedWarning(WarningTypes.DiagonalBorderNotSupported); } } function addHorizontalAlignmentWarnings(alignment, warningManager) { if (alignment && !SUPPORTED_HORIZONTAL_ALIGNMENTS.includes(alignment)) { warningManager.generateNotSupportedWarning(WarningTypes.HorizontalAlignmentNotSupported, alignment, SUPPORTED_HORIZONTAL_ALIGNMENTS); } } function addVerticalAlignmentWarnings(alignment, warningManager) { if (alignment && !SUPPORTED_VERTICAL_ALIGNMENTS.includes(alignment)) { warningManager.generateNotSupportedWarning(WarningTypes.VerticalAlignmentNotSupported, alignment, SUPPORTED_VERTICAL_ALIGNMENTS); } } function convertConditionalFormats(xlsxCfs, dxfs, warningManager) { const cfs = []; let cfId = 1; for (let cf of xlsxCfs) { if (cf.cfRules.length === 0) continue; addCfConversionWarnings(cf, dxfs, warningManager); const rule = cf.cfRules[0]; let operator; const values = []; if (rule.dxfId === undefined && !(rule.type === "colorScale" || rule.type === "iconSet")) continue; switch (rule.type) { case "aboveAverage": case "containsErrors": case "notContainsErrors": case "dataBar": case "duplicateValues": case "expression": case "top10": case "uniqueValues": case "timePeriod": // Not supported continue; case "colorScale": const colorScale = convertColorScale(cfId++, cf); if (colorScale) { cfs.push(colorScale); } continue; case "iconSet": const iconSet = convertIconSet(cfId++, cf, warningManager); if (iconSet) { cfs.push(iconSet); } continue; case "containsText": case "notContainsText": case "beginsWith": case "endsWith": if (!rule.text) continue; operator = CF_TYPE_CONVERSION_MAP[rule.type]; values.push(rule.text); break; case "containsBlanks": case "notContainsBlanks": operator = CF_TYPE_CONVERSION_MAP[rule.type]; break; case "cellIs": if (!rule.operator || !rule.formula || rule.formula.length === 0) continue; operator = convertCFCellIsOperator(rule.operator); values.push(rule.formula[0]); if (rule.formula.length === 2) { values.push(rule.formula[1]); } break; } if (operator && rule.dxfId !== undefined) { cfs.push({ id: (cfId++).toString(), ranges: cf.sqref, stopIfTrue: rule.stopIfTrue, rule: { type: "CellIsRule", operator: operator, values: values, style: convertStyle({ fontStyle: dxfs[rule.dxfId].font, fillStyle: dxfs[rule.dxfId].fill }, warningManager), }, }); } } return cfs; } function convertColorScale(id, xlsxCf) { const scale = xlsxCf.cfRules[0].colorScale; if (!scale || scale.cfvos.length !== scale.colors.length || scale.cfvos.length < 2 || scale.cfvos.length > 3) { return undefined; } const thresholds = []; for (let i = 0; i < scale.cfvos.length; i++) { thresholds.push({ color: hexaToInt(convertColor(scale.colors[i]) || "#FFFFFF"), type: CF_THRESHOLD_CONVERSION_MAP[scale.cfvos[i].type], value: scale.cfvos[i].value, }); } const minimum = thresholds[0]; const maximum = thresholds.length === 2 ? thresholds[1] : thresholds[2]; const midpoint = thresholds.length === 3 ? thresholds[1] : undefined; return { id: id.toString(), stopIfTrue: xlsxCf.cfRules[0].stopIfTrue, ranges: xlsxCf.sqref, rule: { type: "ColorScaleRule", minimum, midpoint, maximum }, }; } /** * Convert Icons Sets. * * In the Xlsx extension of OpenXml, the IconSets can either be simply an IconSet, or a list of Icons * (ie. their respective IconSet and their id in this set). * * In the case of a list of icons : * - The order of the icons is lower => middle => upper * - The their ids are : 0 : bad, 1 : neutral, 2 : good */ function convertIconSet(id, xlsxCf, warningManager) { const xlsxIconSet = xlsxCf.cfRules[0].iconSet; if (!xlsxIconSet) return undefined; let cfVos = xlsxIconSet.cfvos; let cfIcons = xlsxIconSet.cfIcons; if (cfVos.length < 3 || (cfIcons && cfIcons.length < 3)) { return undefined; } // We don't support icon sets with more than 3 icons, so take the extrema and the middle. if (cfVos.length > 3) { cfVos = [cfVos[0], cfVos[Math.floor(cfVos.length / 2)], cfVos[cfVos.length - 1]]; } if (cfIcons && cfIcons.length > 3) { cfIcons = [cfIcons[0], cfIcons[Math.floor(cfIcons.length / 2)], cfIcons[cfIcons.length - 1]]; } // In xlsx, the thresholds are NOT in the first cfVo, but on the second and third const thresholds = []; for (let i = 1; i <= 2; i++) { const type = CF_THRESHOLD_CONVERSION_MAP[cfVos[i].type]; if (type === "value") { return undefined; } thresholds.push({ value: cfVos[i].value || "", operator: cfVos[i].gte ? "ge" : "gt", type: type, }); } let icons = { lower: cfIcons ? convertIcons(cfIcons[0].iconSet, cfIcons[0].iconId) : convertIcons(xlsxIconSet.iconSet, 0), middle: cfIcons ? convertIcons(cfIcons[1].iconSet, cfIcons[1].iconId) : convertIcons(xlsxIconSet.iconSet, 1), upper: cfIcons ? convertIcons(cfIcons[2].iconSet, cfIcons[2].iconId) : convertIcons(xlsxIconSet.iconSet, 2), }; if (xlsxIconSet.reverse) { icons = { upper: icons.lower, middle: icons.middle, lower: icons.upper }; } // We don't support empty icons in an IconSet, put a dot icon instead for (let key of Object.keys(icons)) { if (!icons[key]) { warningManager.generateNotSupportedWarning(WarningTypes.CfIconSetEmptyIconNotSupported); switch (key) { case "upper": icons[key] = ICON_SETS.dots.good; break; case "middle": icons[key] = ICON_SETS.dots.neutral; break; case "lower": icons[key] = ICON_SETS.dots.bad; break; } } } return { id: id.toString(), stopIfTrue: xlsxCf.cfRules[0].stopIfTrue, ranges: xlsxCf.sqref, rule: { type: "IconSetRule", icons: icons, upperInflectionPoint: thresholds[1], lowerInflectionPoint: thresholds[0], }, }; } /** * Convert an icon from a XLSX. * * The indexes are : 0 : bad, 1 : neutral, 2 : good */ function convertIcons(xlsxIconSet, index) { const iconSet = ICON_SET_CONVERSION_MAP[xlsxIconSet]; if (!iconSet) return ""; return index === 0 ? ICON_SETS[iconSet].bad : index === 1 ? ICON_SETS[iconSet].neutral : ICON_SETS[iconSet].good; } // --------------------------------------------------------------------------- // Warnings // --------------------------------------------------------------------------- function addCfConversionWarnings(cf, dxfs, warningManager) { if (cf.cfRules.length > 1) { warningManager.generateNotSupportedWarning(WarningTypes.MultipleRulesCfNotSupported); } if (!SUPPORTED_CF_TYPES.includes(cf.cfRules[0].type)) { warningManager.generateNotSupportedWarning(WarningTypes.CfTypeNotSupported, cf.cfRules[0].type); } if (cf.cfRules[0].dxfId) { const dxf = dxfs[cf.cfRules[0].dxfId]; if (dxf.border) { warningManager.generateNotSupportedWarning(WarningTypes.CfFormatBorderNotSupported); } if (dxf.alignment) { warningManager.generateNotSupportedWarning(WarningTypes.CfFormatAlignmentNotSupported); } if (dxf.numFmt) { warningManager.generateNotSupportedWarning(WarningTypes.CfFormatNumFmtNotSupported); } } } // ------------------------------------- // CF HELPERS // ------------------------------------- /** * Convert the conditional formatting o-spreadsheet operator to * the corresponding excel operator. * */ function convertOperator(operator) { switch (operator) { case "IsNotEmpty": return "notContainsBlanks"; case "IsEmpty": return "containsBlanks"; case "NotContains": return "notContainsBlanks"; default: return operator.charAt(0).toLowerCase() + operator.slice(1); } } // ------------------------------------- // WORKSHEET HELPERS // ------------------------------------- function getCellType(value) { switch (typeof value) { case "boolean": return "b"; case "string": return "str"; case "number": return "n"; default: return undefined; } } function convertHeightToExcel(height) { return Math.round(HEIGHT_FACTOR * height * 100) / 100; } function convertWidthToExcel(width) { return Math.round(WIDTH_FACTOR * width * 100) / 100; } function convertHeightFromExcel(height) { if (!height) return height; return Math.round((height / HEIGHT_FACTOR) * 100) / 100; } function convertWidthFromExcel(width) { if (!width) return width; return Math.round((width / WIDTH_FACTOR) * 100) / 100; } function extractStyle(cell, data) { let style = {}; if (cell.style) { style = data.styles[cell.style]; } const format = extractFormat(cell, data); const styles = { font: { size: style?.fontSize || DEFAULT_FONT_SIZE, color: { rgb: style?.textColor ? style.textColor : "000000" }, family: 2, name: "Arial", }, fill: style?.fillColor ? { fgColor: { rgb: style.fillColor }, } : { reservedAttribute: "none" }, numFmt: format ? { format: format, id: 0 /* id not used for export */ } : undefined, border: cell.border || 0, alignment: { horizontal: style.align, vertical: style.verticalAlign ? V_ALIGNMENT_EXPORT_CONVERSION_MAP[style.verticalAlign] : undefined, wrapText: style.wrapping === "wrap" || undefined, }, }; styles.font["strike"] = !!style?.strikethrough || undefined; styles.font["underline"] = !!style?.underline || undefined; styles.font["bold"] = !!style?.bold || undefined; styles.font["italic"] = !!style?.italic || undefined; return styles; } function extractFormat(cell, data) { if (cell.format) { return data.formats[cell.format]; } return undefined; } function normalizeStyle(construct, styles) { // Normalize this const numFmtId = convertFormat(styles["numFmt"], construct.numFmts); const style = { fontId: pushElement(styles.font, construct.fonts), fillId: pushElement(styles.fill, construct.fills), borderId: styles.border, numFmtId, alignment: { vertical: styles.alignment.vertical, horizontal: styles.alignment.horizontal, wrapText: styles.alignment.wrapText, }, }; return pushElement(style, construct.styles); } function convertFormat(format, numFmtStructure) { if (!format) { return 0; } let formatId = XLSX_FORMAT_MAP[format.format]; if (!formatId) { formatId = pushElement(format, numFmtStructure) + FIRST_NUMFMT_ID; } return formatId; } /** * Add a relation to the given file and return its id. */ function addRelsToFile(relsFiles, path, rel) { let relsFile = relsFiles.find((file) => file.path === path); // the id is a one-based int casted as string let id; if (!relsFile) { id = "rId1"; relsFiles.push({ path, rels: [{ ...rel, id }] }); } else { id = `rId${(relsFile.rels.length + 1).toString()}`; relsFile.rels.push({ ...rel, id, }); } return id; } function pushElement(property, propertyList) { let len = propertyList.length; const operator = typeof property === "object" ? deepEquals : (a, b) => a === b; for (let i = 0; i < len; i++) { if (operator(property, propertyList[i])) { return i; } } propertyList[propertyList.length] = property; return propertyList.length - 1; } const chartIds = []; /** * Convert a chart o-spreadsheet id to a xlsx id which * are unsigned integers (starting from 1). */ function convertChartId(chartId) { const xlsxId = chartIds.findIndex((id) => id === chartId); if (xlsxId === -1) { chartIds.push(chartId); return chartIds.length; } return xlsxId + 1; } const imageIds = []; /** * Convert a image o-spreadsheet id to a xlsx id which * are unsigned integers (starting from 1). */ function convertImageId(imageId) { const xlsxId = imageIds.findIndex((id) => id === imageId); if (xlsxId === -1) { imageIds.push(imageId); return imageIds.length; } return xlsxId + 1; } /** * Convert a value expressed in dot to EMU. * EMU = English Metrical Unit * There are 914400 EMU per inch. * * /!\ A value expressed in EMU cannot be fractional. * See https://docs.microsoft.com/en-us/windows/win32/vml/msdn-online-vml-units#other-units-of-measurement */ function convertDotValueToEMU(value) { const DPI = 96; return Math.round((value * 914400) / DPI); } function getRangeSize(reference, defaultSheetIndex, data) { let xc = reference; let sheetName = undefined; ({ xc, sheetName } = splitReference(reference)); let rangeSheetIndex; if (sheetName) { const index = data.sheets.findIndex((sheet) => sheet.name === sheetName); if (index < 0) { throw new Error("Unable to find a sheet with the name " + sheetName); } rangeSheetIndex = index; } else { rangeSheetIndex = Number(defaultSheetIndex); } const zone = toUnboundedZone(xc); if (zone.right === undefined) { zone.right = data.sheets[rangeSheetIndex].colNumber; } if (zone.bottom === undefined) { zone.bottom = data.sheets[rangeSheetIndex].rowNumber; } return (zone.right - zone.left + 1) * (zone.bottom - zone.top + 1); } function convertEMUToDotValue(value) { const DPI = 96; return Math.round((value * DPI) / 914400); } /** * Get the position of the start of a column in Excel (in px). */ function getColPosition(colIndex, sheetData) { let position = 0; for (let i = 0; i < colIndex; i++) { const colAtIndex = sheetData.cols.find((col) => i >= col.min && i <= col.max); if (colAtIndex?.width) { position += colAtIndex.width; } else if (sheetData.sheetFormat?.defaultColWidth) { position += sheetData.sheetFormat.defaultColWidth; } else { position += EXCEL_DEFAULT_COL_WIDTH; } } return position / WIDTH_FACTOR; } /** * Get the position of the start of a row in Excel (in px). */ function getRowPosition(rowIndex, sheetData) { let position = 0; for (let i = 0; i < rowIndex; i++) { const rowAtIndex = sheetData.rows[i]; if (rowAtIndex?.height) { position += rowAtIndex.height; } else if (sheetData.sheetFormat?.defaultRowHeight) { position += sheetData.sheetFormat.defaultRowHeight; } else { position += EXCEL_DEFAULT_ROW_HEIGHT; } } return position / HEIGHT_FACTOR; } function convertFigures(sheetData) { let id = 1; return sheetData.figures .map((figure) => convertFigure(figure, (id++).toString(), sheetData)) .filter(isDefined); } function convertFigure(figure, id, sheetData) { let x1, y1; let height, width; if (figure.anchors.length === 1) { // one cell anchor ({ x: x1, y: y1 } = getPositionFromAnchor(figure.anchors[0], sheetData)); width = convertEMUToDotValue(figure.figureSize.cx); height = convertEMUToDotValue(figure.figureSize.cy); } else { ({ x: x1, y: y1 } = getPositionFromAnchor(figure.anchors[0], sheetData)); const { x: x2, y: y2 } = getPositionFromAnchor(figure.anchors[1], sheetData); width = x2 - x1; height = y2 - y1; } const figureData = { id, x: x1, y: y1 }; if (isChartData(figure.data)) { return { ...figureData, width, height, tag: "chart", data: convertChartData(figure.data), }; } else if (isImageData(figure.data)) { return { ...figureData, width: convertEMUToDotValue(figure.data.size.cx), height: convertEMUToDotValue(figure.data.size.cy), tag: "image", data: { path: figure.data.imageSrc, mimetype: figure.data.mimetype, }, }; } return undefined; } function isChartData(data) { return "dataSets" in data; } function isImageData(data) { return "imageSrc" in data; } function convertChartData(chartData) { const dataSetsHaveTitle = chartData.dataSets.some((ds) => "reference" in (ds.label ?? {})); const labelRange = chartData.labelRange ? convertExcelRangeToSheetXC(chartData.labelRange, dataSetsHaveTitle) : undefined; const dataSets = chartData.dataSets.map((data) => { let label = undefined; if (data.label && "text" in data.label) { label = data.label.text; } return { dataRange: convertExcelRangeToSheetXC(data.range, dataSetsHaveTitle), label, backgroundColor: data.backgroundColor, }; }); // For doughnut charts, in chartJS first dataset = outer dataset, in excel first dataset = inner dataset if (chartData.type === "pie") { dataSets.reverse(); } return { dataSets, dataSetsHaveTitle, labelRange, title: chartData.title ?? { text: "" }, type: chartData.type, background: convertColor({ rgb: chartData.backgroundColor }) || "#FFFFFF", legendPosition: chartData.legendPosition, stacked: chartData.stacked || false, aggregated: false, cumulative: chartData.cumulative || false, labelsAsText: false, }; } function convertExcelRangeToSheetXC(range, dataSetsHaveTitle) { let { sheetName, xc } = splitReference(range); let zone = toUnboundedZone(xc); if (dataSetsHaveTitle && zone.bottom !== undefined && zone.right !== undefined) { const height = zone.bottom - zone.top + 1; const width = zone.right - zone.left + 1; if (height === 1) { zone = { ...zone, left: zone.left - 1 }; } else if (width === 1) { zone = { ...zone, top: zone.top - 1 }; } } const dataXC = zoneToXc(zone); return getFullReference(sheetName, dataXC); } function getPositionFromAnchor(anchor, sheetData) { return { x: getColPosition(anchor.col, sheetData) + convertEMUToDotValue(anchor.colOffset), y: getRowPosition(anchor.row, sheetData) + convertEMUToDotValue(anchor.rowOffset), }; } /** * Match external reference (ex. '[1]Sheet 3'!$B$4) * * First match group is the external reference id * Second match group is the sheet id * Third match group is the reference of the cell */ const externalReferenceRegex = new RegExp(/'?\[([0-9]*)\](.*)'?!(\$?[a-zA-Z]*\$?[0-9]*)/g); const subtotalRegex = new RegExp(/SUBTOTAL\(([0-9]*),/g); const cellRegex = new RegExp(cellReference.source, "ig"); function convertFormulasContent(sheet, data) { const sfMap = getSharedFormulasMap(sheet); for (let cell of sheet.rows.map((row) => row.cells).flat()) { if (cell?.formula) { cell.formula.content = cell.formula.sharedIndex !== undefined && !cell.formula.content ? "=" + adaptFormula(cell.xc, sfMap[cell.formula.sharedIndex]) : "=" + cell.formula.content; cell.formula.content = convertFormula(cell.formula.content, data); } } } function getSharedFormulasMap(sheet) { const formulas = {}; for (let row of sheet.rows) { for (let cell of row.cells) { if (cell.formula && cell.formula.sharedIndex !== undefined && cell.formula.content) { formulas[cell.formula.sharedIndex] = { refCellXc: cell.xc, formula: cell.formula.content }; } } } return formulas; } /** * Convert an XLSX formula into something we can evaluate. * - remove _xlfn. flags before function names * - convert the SUBTOTAL(index, formula) function to the function given by its index * - change #REF! into #REF * - convert external references into their value */ function convertFormula(formula, data) { formula = formula.replace("_xlfn.", ""); formula = formula.replace(/#REF!/g, "#REF"); // SUBOTOTAL function, eg. =SUBTOTAL(3, {formula}) formula = formula.replace(subtotalRegex, (match, functionId) => { const convertedFunction = SUBTOTAL_FUNCTION_CONVERSION_MAP[functionId]; return convertedFunction ? convertedFunction + "(" : match; }); // External references, eg. ='[1]Sheet 3'!$B$4 formula = formula.replace(externalReferenceRegex, (match, externalRefId, sheetName, cellRef) => { externalRefId = Number(externalRefId) - 1; cellRef = cellRef.replace(/\$/g, ""); const sheetIndex = data.externalBooks[externalRefId].sheetNames.findIndex((name) => name === sheetName); if (sheetIndex === -1) { return match; } const externalDataset = data.externalBooks[externalRefId].datasets.find((dataset) => dataset.sheetId === sheetIndex)?.data; if (!externalDataset) { return match; } const datasetValue = externalDataset && externalDataset[cellRef]; const convertedValue = Number(datasetValue) ? datasetValue : `"${datasetValue}"`; return convertedValue || match; }); return formula; } /** * Transform a shared formula for the given target. * * This will compute the offset between the original cell of the shared formula and the target cell, * then apply this offset to all the ranges in the formula (taking fixed references into account) */ function adaptFormula(targetCell, sf) { const refPosition = toCartesian(sf.refCellXc); let newFormula = sf.formula.slice(); let match; do { match = cellRegex.exec(newFormula); if (match) { const formulaPosition = toCartesian(match[0].replace("$", "")); const targetPosition = toCartesian(targetCell); const rangePart = { colFixed: match[0].startsWith("$"), rowFixed: match[0].includes("$", 1), }; const offset = { col: targetPosition.col - refPosition.col, row: targetPosition.row - refPosition.row, }; const offsettedPosition = { col: rangePart.colFixed ? formulaPosition.col : formulaPosition.col + offset.col, row: rangePart.rowFixed ? formulaPosition.row : formulaPosition.row + offset.row, }; newFormula = newFormula.slice(0, match.index) + toXC(offsettedPosition.col, offsettedPosition.row, rangePart) + newFormula.slice(match.index + match[0].length); } } while (match); return newFormula; } function convertSheets(data, warningManager) { return data.sheets.map((sheet) => { convertFormulasContent(sheet, data); const sheetDims = getSheetDims(sheet); const sheetOptions = sheet.sheetViews[0]; const rowHeaderGroups = convertHeaderGroup(sheet, "ROW", sheetDims[1]); const colHeaderGroups = convertHeaderGroup(sheet, "COL", sheetDims[0]); return { id: sheet.sheetName, areGridLinesVisible: sheetOptions ? sheetOptions.showGridLines : true, name: sheet.sheetName, colNumber: sheetDims[0], rowNumber: sheetDims[1], ...convertCells(sheet, data, sheetDims, warningManager), merges: sheet.merges, cols: convertCols(sheet, sheetDims[0], colHeaderGroups), rows: convertRows(sheet, sheetDims[1], rowHeaderGroups), conditionalFormats: convertConditionalFormats(sheet.cfs, data.dxfs, warningManager), figures: convertFigures(sheet), isVisible: sheet.isVisible, panes: sheetOptions ? { xSplit: sheetOptions.pane.xSplit, ySplit: sheetOptions.pane.ySplit } : { xSplit: 0, ySplit: 0 }, tables: [], headerGroups: { COL: colHeaderGroups, ROW: rowHeaderGroups }, color: convertColor(sheet.sheetProperties?.tabColor), }; }); } function convertCols(sheet, numberOfCols, headerGroups) { const cols = {}; // Excel begins indexes at 1 for (let i = 1; i < numberOfCols + 1; i++) { const col = sheet.cols.find((col) => col.min <= i && i <= col.max); let colSize; if (col && col.width) colSize = col.width; else if (sheet.sheetFormat?.defaultColWidth) colSize = sheet.sheetFormat.defaultColWidth; else colSize = EXCEL_DEFAULT_COL_WIDTH; // In xlsx there is no difference between hidden columns and columns inside a folded group. // But in o-spreadsheet folded columns are not considered hidden. const colIndex = i - 1; const isColFolded = headerGroups.some((group) => group.isFolded && group.start <= colIndex && colIndex <= group.end); cols[colIndex] = { size: convertWidthFromExcel(colSize), isHidden: !isColFolded && col?.hidden, }; } return cols; } function convertRows(sheet, numberOfRows, headerGroups) { const rows = {}; // Excel begins indexes at 1 for (let i = 1; i < numberOfRows + 1; i++) { const row = sheet.rows.find((row) => row.index === i); let rowSize; if (row && row.height) rowSize = row.height; else if (sheet.sheetFormat?.defaultRowHeight) rowSize = sheet.sheetFormat.defaultRowHeight; else rowSize = EXCEL_DEFAULT_ROW_HEIGHT; // In xlsx there is no difference between hidden rows and rows inside a folded group. // But in o-spreadsheet folded rows are not considered hidden. const rowIndex = i - 1; const isRowFolded = headerGroups.some((group) => group.isFolded && group.start <= rowIndex && rowIndex <= group.end); rows[rowIndex] = { size: convertHeightFromExcel(rowSize), isHidden: !isRowFolded && row?.hidden, }; } return rows; } /** Remove newlines (\n) in shared strings, We do not support them */ function convertSharedStrings(xlsxSharedStrings) { return xlsxSharedStrings.map((str) => str.replace(/\n/g, "")); } function convertCells(sheet, data, sheetDims, warningManager) { const cells = {}; const styles = {}; const formats = {}; const borders = {}; const sharedStrings = convertSharedStrings(data.sharedStrings); const hyperlinkMap = sheet.hyperlinks.reduce((map, link) => { map[link.xc] = link; return map; }, {}); for (let row of sheet.rows) { for (let cell of row.cells) { cells[cell.xc] = { content: getCellValue(cell, hyperlinkMap, sharedStrings, warningManager), }; if (cell.styleIndex) { // + 1 : our indexes for normalized values begin at 1 and not 0 styles[cell.xc] = cell.styleIndex + 1; formats[cell.xc] = data.styles[cell.styleIndex].numFmtId + 1; borders[cell.xc] = data.styles[cell.styleIndex].borderId + 1; } } } // Apply row style for (let row of sheet.rows.filter((row) => row.styleIndex)) { for (let colIndex = 1; colIndex <= sheetDims[0]; colIndex++) { const xc = toXC(colIndex - 1, row.index - 1); // Excel indexes start at 1 let cell = cells[xc]; if (!cell) { cell = {}; cells[xc] = cell; } styles[xc] ??= row.styleIndex + 1; borders[xc] ??= data.styles[row.styleIndex].borderId + 1; formats[xc] ??= data.styles[row.styleIndex].numFmtId + 1; } } // Apply col style for (let col of sheet.cols.filter((col) => col.styleIndex)) { for (let colIndex = col.min; colIndex <= Math.min(col.max, sheetDims[0]); colIndex++) { for (let rowIndex = 1; rowIndex <= sheetDims[1]; rowIndex++) { const xc = toXC(colIndex - 1, rowIndex - 1); // Excel indexes start at 1 let cell = cells[xc]; if (!cell) { cell = {}; cells[xc] = cell; } styles[xc] ??= col.styleIndex + 1; borders[xc] ??= data.styles[col.styleIndex].borderId + 1; formats[xc] ??= data.styles[col.styleIndex].numFmtId + 1; } } } return { cells, styles, formats, borders }; } function getCellValue(cell, hyperLinksMap, sharedStrings, warningManager) { let cellValue; switch (cell.type) { case "sharedString": const ssIndex = parseInt(cell.value, 10); cellValue = sharedStrings[ssIndex]; break; case "boolean": cellValue = Number(cell.value) ? "TRUE" : "FALSE"; break; case "date": // I'm not sure where this is used rather than a number with a format case "error": // I don't think Excel really uses this case "inlineStr": case "number": case "str": cellValue = cell.value; break; } if (cellValue && hyperLinksMap[cell.xc]) { cellValue = convertHyperlink(hyperLinksMap[cell.xc], cellValue, warningManager); } if (cell.formula) { cellValue = cell.formula.content; } return cellValue; } function convertHyperlink(link, cellValue, warningManager) { const label = link.display || cellValue; if (!link.relTarget && !link.location) { warningManager.generateNotSupportedWarning(WarningTypes.BadlyFormattedHyperlink); } const url = link.relTarget ? link.relTarget : buildSheetLink(splitReference(link.location).sheetName); return markdownLink(label, url); } function getSheetDims(sheet) { const dims = [0, 0]; for (let row of sheet.rows) { dims[0] = Math.max(dims[0], largeMax(row.cells.map((cell) => toCartesian(cell.xc).col))); dims[1] = Math.max(dims[1], row.index); } dims[0] = Math.max(dims[0], EXCEL_IMPORT_DEFAULT_NUMBER_OF_COLS); dims[1] = Math.max(dims[1], EXCEL_IMPORT_DEFAULT_NUMBER_OF_ROWS); return dims; } /** * Get the header groups from the XLS file. * * See ASCII art in HeaderGroupingPlugin.exportForExcel() for details on how the groups are defined in the xlsx. */ function convertHeaderGroup(sheet, dim, numberOfHeaders) { const outlineProperties = sheet?.sheetProperties?.outlinePr; const headerGroups = []; let currentLayer = 0; for (let i = 0; i < numberOfHeaders; i++) { const header = getHeader(sheet, dim, i); const headerLayer = header?.outlineLevel || 0; if (headerLayer > currentLayer) { // Whether the flag indicating if the group is collapsed is on the header before or after the group. Default is after. const collapseFlagAfter = (dim === "ROW" ? outlineProperties?.summaryBelow : outlineProperties?.summaryRight) ?? true; const group = computeHeaderGroup(sheet, dim, i, collapseFlagAfter); if (group) { headerGroups.push(group); } } currentLayer = headerLayer; } return headerGroups; } function computeHeaderGroup(sheet, dim, startIndex, collapseFlagAfter) { const startHeader = getHeader(sheet, dim, startIndex); const startLayer = startHeader?.outlineLevel; if (!startLayer || !startLayer) { return undefined; } let currentLayer = startLayer; let currentIndex = startIndex; let currentHeader = startHeader; while (currentHeader && currentLayer >= startLayer) { currentIndex++; currentHeader = getHeader(sheet, dim, currentIndex); currentLayer = currentHeader?.outlineLevel || 0; } const start = startIndex; const end = currentIndex - 1; const collapseFlagHeader = collapseFlagAfter ? getHeader(sheet, dim, end + 1) : getHeader(sheet, dim, start - 1); const isFolded = collapseFlagHeader?.collapsed || false; return { start: start - 1, end: end - 1, isFolded }; // -1 because indices start at 1 in excel and 0 in o-spreadsheet } function getHeader(sheet, dim, index) { return "COL" === dim ? sheet.cols.find((col) => col.min <= index && index <= col.max) : sheet.rows.find((row) => row.index === index); } const TABLE_STYLE_CATEGORIES = { light: _t("Light"), medium: _t("Medium"), dark: _t("Dark"), custom: _t("Custom"), }; const DEFAULT_TABLE_CONFIG = { hasFilters: true, totalRow: false, firstColumn: false, lastColumn: false, numberOfHeaders: 1, bandedRows: true, bandedColumns: false, automaticAutofill: true, styleId: "TableStyleMedium2", }; function generateTableColorSet(name, highlightColor) { return { coloredText: darkenColor(highlightColor, 0.3), light: lightenColor(highlightColor, 0.8), medium: lightenColor(highlightColor, 0.6), dark: darkenColor(highlightColor, 0.3), mediumBorder: lightenColor(highlightColor, 0.45), highlight: highlightColor, name, }; } const COLOR_SETS = { black: { name: _t("Black"), coloredText: "#000000", light: "#D9D9D9", medium: "#A6A6A6", dark: "#404040", mediumBorder: "#000000", highlight: "#000000", }, lightBlue: generateTableColorSet(_t("Light blue"), "#346B90"), red: generateTableColorSet(_t("Red"), "#C53628"), lightGreen: generateTableColorSet(_t("Light green"), "#748747"), purple: generateTableColorSet(_t("Purple"), "#6C4E65"), gray: { name: _t("Gray"), coloredText: "#666666", light: "#EEEEEE", medium: "#DDDDDD", dark: "#767676", mediumBorder: "#D0D0D0", highlight: "#A9A9A9", }, orange: generateTableColorSet(_t("Orange"), "#C37034"), }; const DARK_COLOR_SETS = { black: COLOR_SETS.black, orangeBlue: { ...COLOR_SETS.lightBlue, highlight: COLOR_SETS.orange.highlight }, purpleGreen: { ...COLOR_SETS.lightGreen, highlight: COLOR_SETS.purple.highlight }, redBlue: { ...COLOR_SETS.lightBlue, highlight: COLOR_SETS.red.highlight }, }; const lightColoredText = (colorSet) => ({ category: "light", templateName: "lightColoredText", primaryColor: colorSet.highlight, wholeTable: { style: { textColor: colorSet.coloredText }, border: { top: { color: colorSet.highlight, style: "thin" }, bottom: { color: colorSet.highlight, style: "thin" }, }, }, headerRow: { border: { bottom: { color: colorSet.highlight, style: "thin" } } }, totalRow: { border: { top: { color: colorSet.highlight, style: "thin" } } }, firstRowStripe: { style: { fillColor: colorSet.light } }, }); const lightWithHeader = (colorSet) => ({ category: "light", templateName: "lightWithHeader", primaryColor: colorSet.highlight, wholeTable: { border: { top: { color: colorSet.highlight, style: "thin" }, bottom: { color: colorSet.highlight, style: "thin" }, left: { color: colorSet.highlight, style: "thin" }, right: { color: colorSet.highlight, style: "thin" }, }, }, headerRow: { style: { fillColor: colorSet.highlight, textColor: "#FFFFFF" }, border: { bottom: { color: colorSet.highlight, style: "thin" } }, }, totalRow: { border: { top: { color: colorSet.highlight, style: "medium" } } }, // @compatibility: should be double line firstRowStripe: { border: { bottom: { color: colorSet.highlight, style: "thin" } } }, secondRowStripe: { border: { bottom: { color: colorSet.highlight, style: "thin" } } }, }); const lightAllBorders = (colorSet) => ({ category: "light", templateName: "lightAllBorders", primaryColor: colorSet.highlight, wholeTable: { border: { top: { color: colorSet.highlight, style: "thin" }, bottom: { color: colorSet.highlight, style: "thin" }, left: { color: colorSet.highlight, style: "thin" }, right: { color: colorSet.highlight, style: "thin" }, horizontal: { color: colorSet.highlight, style: "thin" }, vertical: { color: colorSet.highlight, style: "thin" }, }, }, headerRow: { border: { bottom: { color: colorSet.highlight, style: "medium" } } }, totalRow: { border: { top: { color: colorSet.highlight, style: "medium" } } }, // @compatibility: should be double line firstRowStripe: { style: { fillColor: colorSet.light } }, firstColumnStripe: { style: { fillColor: colorSet.light } }, }); const mediumBandedBorders = (colorSet) => ({ category: "medium", templateName: "mediumBandedBorders", primaryColor: colorSet.highlight, wholeTable: { border: { top: { color: colorSet.mediumBorder, style: "thin" }, bottom: { color: colorSet.mediumBorder, style: "thin" }, left: { color: colorSet.mediumBorder, style: "thin" }, right: { color: colorSet.mediumBorder, style: "thin" }, horizontal: { color: colorSet.mediumBorder, style: "thin" }, }, }, headerRow: { style: { fillColor: colorSet.highlight, textColor: "#FFFFFF" }, }, totalRow: { border: { top: { color: colorSet.highlight, style: "medium" } } }, // @compatibility: should be double line firstRowStripe: { style: { fillColor: colorSet.light } }, firstColumnStripe: { style: { fillColor: colorSet.light } }, }); const mediumWhiteBorders = (colorSet) => ({ category: "medium", templateName: "mediumWhiteBorders", primaryColor: colorSet.highlight, wholeTable: { border: { horizontal: { color: "#FFFFFF", style: "thin" }, vertical: { color: "#FFFFFF", style: "thin" }, }, style: { fillColor: colorSet.light }, }, headerRow: { border: { bottom: { color: "#FFFFFF", style: "thick" } }, style: { fillColor: colorSet.highlight, textColor: "#FFFFFF" }, }, totalRow: { border: { top: { color: "#FFFFFF", style: "thick" } }, style: { fillColor: colorSet.highlight, textColor: "#FFFFFF" }, }, firstColumn: { style: { fillColor: colorSet.highlight, textColor: "#FFFFFF" } }, lastColumn: { style: { fillColor: colorSet.highlight, textColor: "#FFFFFF" } }, firstRowStripe: { style: { fillColor: colorSet.medium } }, firstColumnStripe: { style: { fillColor: colorSet.medium } }, }); const mediumMinimalBorders = (colorSet) => ({ category: "medium", templateName: "mediumMinimalBorders", primaryColor: colorSet.highlight, wholeTable: { border: { top: { color: "#000000", style: "medium" }, bottom: { color: "#000000", style: "medium" }, }, }, totalRow: { border: { top: { color: "#000000", style: "medium" } } }, // @compatibility: should be double line headerRow: { style: { fillColor: colorSet.highlight, textColor: "#FFFFFF" }, border: { bottom: { color: "#000000", style: "medium" } }, }, firstColumn: { style: { fillColor: colorSet.highlight, textColor: "#FFFFFF" } }, lastColumn: { style: { fillColor: colorSet.highlight, textColor: "#FFFFFF" } }, firstRowStripe: { style: { fillColor: COLOR_SETS.black.light } }, firstColumnStripe: { style: { fillColor: COLOR_SETS.black.light } }, }); const mediumAllBorders = (colorSet) => ({ category: "medium", templateName: "mediumAllBorders", primaryColor: colorSet.highlight, wholeTable: { border: { top: { color: colorSet.mediumBorder, style: "thin" }, bottom: { color: colorSet.mediumBorder, style: "thin" }, left: { color: colorSet.mediumBorder, style: "thin" }, right: { color: colorSet.mediumBorder, style: "thin" }, horizontal: { color: colorSet.mediumBorder, style: "thin" }, vertical: { color: colorSet.mediumBorder, style: "thin" }, }, style: { fillColor: colorSet.light }, }, totalRow: { border: { top: { color: colorSet.highlight, style: "medium" } } }, // @compatibility: should be double line firstRowStripe: { style: { fillColor: colorSet.medium } }, firstColumnStripe: { style: { fillColor: colorSet.medium } }, }); const dark = (colorSet) => ({ category: "dark", templateName: "dark", primaryColor: colorSet.highlight, wholeTable: { style: { fillColor: colorSet.highlight, textColor: "#FFFFFF" } }, totalRow: { style: { fillColor: colorSet.dark, textColor: "#FFFFFF" }, border: { top: { color: "#FFFFFF", style: "thick" } }, }, headerRow: { style: { fillColor: "#000000" }, border: { bottom: { color: "#FFFFFF", style: "thick" } }, }, firstColumn: { style: { fillColor: colorSet.dark }, border: { right: { color: "#FFFFFF", style: "thick" } }, }, lastColumn: { style: { fillColor: colorSet.dark }, border: { left: { color: "#FFFFFF", style: "thick" } }, }, firstRowStripe: { style: { fillColor: colorSet.dark } }, firstColumnStripe: { style: { fillColor: colorSet.dark } }, }); const darkNoBorders = (colorSet) => ({ category: "dark", templateName: "darkNoBorders", primaryColor: colorSet.highlight, wholeTable: { style: { fillColor: colorSet.light } }, totalRow: { border: { top: { color: "#000000", style: "medium" } } }, // @compatibility: should be double line headerRow: { style: { fillColor: colorSet.highlight, textColor: "#FFFFFF" } }, firstRowStripe: { style: { fillColor: colorSet.medium } }, firstColumnStripe: { style: { fillColor: colorSet.medium } }, }); const darkTemplateInBlack = dark(COLOR_SETS.black); darkTemplateInBlack.wholeTable.style.fillColor = "#737373"; const mediumMinimalBordersInBlack = mediumMinimalBorders(COLOR_SETS.black); mediumMinimalBordersInBlack.wholeTable.border = { ...mediumMinimalBordersInBlack.wholeTable.border, left: { color: "#000000", style: "thin" }, right: { color: "#000000", style: "thin" }, horizontal: { color: "#000000", style: "thin" }, vertical: { color: "#000000", style: "thin" }, }; function buildPreset(name, template, colorSet) { return { ...template(colorSet), displayName: `${colorSet.name}, ${name}` }; } const TABLE_PRESETS = { None: { category: "light", templateName: "none", primaryColor: "", displayName: "none" }, TableStyleLight1: buildPreset("TableStyleLight1", lightColoredText, COLOR_SETS.black), TableStyleLight2: buildPreset("TableStyleLight2", lightColoredText, COLOR_SETS.lightBlue), TableStyleLight3: buildPreset("TableStyleLight3", lightColoredText, COLOR_SETS.red), TableStyleLight4: buildPreset("TableStyleLight4", lightColoredText, COLOR_SETS.lightGreen), TableStyleLight5: buildPreset("TableStyleLight5", lightColoredText, COLOR_SETS.purple), TableStyleLight6: buildPreset("TableStyleLight6", lightColoredText, COLOR_SETS.gray), TableStyleLight7: buildPreset("TableStyleLight7", lightColoredText, COLOR_SETS.orange), TableStyleLight8: buildPreset("TableStyleLight8", lightWithHeader, COLOR_SETS.black), TableStyleLight9: buildPreset("TableStyleLight9", lightWithHeader, COLOR_SETS.lightBlue), TableStyleLight10: buildPreset("TableStyleLight10", lightWithHeader, COLOR_SETS.red), TableStyleLight11: buildPreset("TableStyleLight11", lightWithHeader, COLOR_SETS.lightGreen), TableStyleLight12: buildPreset("TableStyleLight12", lightWithHeader, COLOR_SETS.purple), TableStyleLight13: buildPreset("TableStyleLight13", lightWithHeader, COLOR_SETS.gray), TableStyleLight14: buildPreset("TableStyleLight14", lightWithHeader, COLOR_SETS.orange), TableStyleLight15: buildPreset("TableStyleLight15", lightAllBorders, COLOR_SETS.black), TableStyleLight16: buildPreset("TableStyleLight16", lightAllBorders, COLOR_SETS.lightBlue), TableStyleLight17: buildPreset("TableStyleLight17", lightAllBorders, COLOR_SETS.red), TableStyleLight18: buildPreset("TableStyleLight18", lightAllBorders, COLOR_SETS.lightGreen), TableStyleLight19: buildPreset("TableStyleLight19", lightAllBorders, COLOR_SETS.purple), TableStyleLight20: buildPreset("TableStyleLight20", lightAllBorders, COLOR_SETS.gray), TableStyleLight21: buildPreset("TableStyleLight21", lightAllBorders, COLOR_SETS.orange), TableStyleMedium1: buildPreset("TableStyleMedium1", mediumBandedBorders, COLOR_SETS.black), TableStyleMedium2: buildPreset("TableStyleMedium2", mediumBandedBorders, COLOR_SETS.lightBlue), TableStyleMedium3: buildPreset("TableStyleMedium3", mediumBandedBorders, COLOR_SETS.red), TableStyleMedium4: buildPreset("TableStyleMedium4", mediumBandedBorders, COLOR_SETS.lightGreen), TableStyleMedium5: buildPreset("TableStyleMedium5", mediumBandedBorders, COLOR_SETS.purple), TableStyleMedium6: buildPreset("TableStyleMedium6", mediumBandedBorders, COLOR_SETS.gray), TableStyleMedium7: buildPreset("TableStyleMedium7", mediumBandedBorders, COLOR_SETS.orange), TableStyleMedium8: buildPreset("TableStyleMedium8", mediumWhiteBorders, COLOR_SETS.black), TableStyleMedium9: buildPreset("TableStyleMedium9", mediumWhiteBorders, COLOR_SETS.lightBlue), TableStyleMedium10: buildPreset("TableStyleMedium10", mediumWhiteBorders, COLOR_SETS.red), TableStyleMedium11: buildPreset("TableStyleMedium11", mediumWhiteBorders, COLOR_SETS.lightGreen), TableStyleMedium12: buildPreset("TableStyleMedium12", mediumWhiteBorders, COLOR_SETS.purple), TableStyleMedium13: buildPreset("TableStyleMedium13", mediumWhiteBorders, COLOR_SETS.gray), TableStyleMedium14: buildPreset("TableStyleMedium14", mediumWhiteBorders, COLOR_SETS.orange), TableStyleMedium15: { ...mediumMinimalBordersInBlack, displayName: "Black, TableStyleMedium15" }, TableStyleMedium16: buildPreset("TableStyleMedium16", mediumMinimalBorders, COLOR_SETS.lightBlue), TableStyleMedium17: buildPreset("TableStyleMedium17", mediumMinimalBorders, COLOR_SETS.red), TableStyleMedium18: buildPreset("TableStyleMedium18", mediumMinimalBorders, COLOR_SETS.lightGreen), TableStyleMedium19: buildPreset("TableStyleMedium19", mediumMinimalBorders, COLOR_SETS.purple), TableStyleMedium20: buildPreset("TableStyleMedium20", mediumMinimalBorders, COLOR_SETS.gray), TableStyleMedium21: buildPreset("TableStyleMedium21", mediumMinimalBorders, COLOR_SETS.orange), TableStyleMedium22: buildPreset("TableStyleMedium22", mediumAllBorders, COLOR_SETS.black), TableStyleMedium23: buildPreset("TableStyleMedium23", mediumAllBorders, COLOR_SETS.lightBlue), TableStyleMedium24: buildPreset("TableStyleMedium24", mediumAllBorders, COLOR_SETS.red), TableStyleMedium25: buildPreset("TableStyleMedium25", mediumAllBorders, COLOR_SETS.lightGreen), TableStyleMedium26: buildPreset("TableStyleMedium26", mediumAllBorders, COLOR_SETS.purple), TableStyleMedium27: buildPreset("TableStyleMedium27", mediumAllBorders, COLOR_SETS.gray), TableStyleMedium28: buildPreset("TableStyleMedium28", mediumAllBorders, COLOR_SETS.orange), TableStyleDark1: { ...darkTemplateInBlack, displayName: "Black, TableStyleDark1" }, TableStyleDark2: buildPreset("TableStyleDark2", dark, COLOR_SETS.lightBlue), TableStyleDark3: buildPreset("TableStyleDark3", dark, COLOR_SETS.red), TableStyleDark4: buildPreset("TableStyleDark4", dark, COLOR_SETS.lightGreen), TableStyleDark5: buildPreset("TableStyleDark5", dark, COLOR_SETS.purple), TableStyleDark6: buildPreset("TableStyleDark6", dark, COLOR_SETS.gray), TableStyleDark7: buildPreset("TableStyleDark7", dark, COLOR_SETS.orange), TableStyleDark8: buildPreset("TableStyleDark8", darkNoBorders, DARK_COLOR_SETS.black), TableStyleDark9: buildPreset("TableStyleDark9", darkNoBorders, DARK_COLOR_SETS.redBlue), TableStyleDark10: buildPreset("TableStyleDark10", darkNoBorders, DARK_COLOR_SETS.purpleGreen), TableStyleDark11: buildPreset("TableStyleDark11", darkNoBorders, DARK_COLOR_SETS.orangeBlue), }; const TABLE_STYLES_TEMPLATES = { none: () => ({ category: "none", templateName: "none", primaryColor: "", name: "none" }), lightColoredText: lightColoredText, lightAllBorders: lightAllBorders, mediumAllBorders: mediumAllBorders, lightWithHeader: lightWithHeader, mediumBandedBorders: mediumBandedBorders, mediumMinimalBorders: mediumMinimalBorders, darkNoBorders: darkNoBorders, mediumWhiteBorders: mediumWhiteBorders, dark: dark, }; function buildTableStyle(name, templateName, primaryColor) { const colorSet = generateTableColorSet("", primaryColor); return { ...TABLE_STYLES_TEMPLATES[templateName](colorSet), category: "custom", displayName: name, }; } /** * Convert the imported XLSX tables and pivots convert the table-specific formula references into standard references. * * Change the converted data in-place. */ function convertTables(convertedData, xlsxData) { for (const xlsxSheet of xlsxData.sheets) { const sheet = convertedData.sheets.find((sheet) => sheet.name === xlsxSheet.sheetName); if (!sheet) continue; if (!sheet.tables) sheet.tables = []; for (const table of xlsxSheet.tables) { sheet.tables.push({ range: table.ref, config: convertTableConfig(table) }); } for (const pivotTable of xlsxSheet.pivotTables) { sheet.tables.push({ range: pivotTable.location.ref, config: convertPivotTableConfig(pivotTable), }); } } convertTableFormulaReferences(convertedData.sheets, xlsxData.sheets); } function convertTableConfig(table) { const styleId = table.style?.name || ""; return { hasFilters: table.autoFilter !== undefined, numberOfHeaders: table.headerRowCount, totalRow: table.totalsRowCount > 0, firstColumn: table.style?.showFirstColumn || false, lastColumn: table.style?.showLastColumn || false, bandedRows: table.style?.showRowStripes || false, bandedColumns: table.style?.showColumnStripes || false, styleId: TABLE_PRESETS[styleId] ? styleId : DEFAULT_TABLE_CONFIG.styleId, }; } function convertPivotTableConfig(pivotTable) { return { hasFilters: false, numberOfHeaders: pivotTable.location.firstDataRow, totalRow: pivotTable.rowGrandTotals, firstColumn: true, lastColumn: pivotTable.style?.showLastColumn || false, bandedRows: pivotTable.style?.showRowStripes || false, bandedColumns: pivotTable.style?.showColStripes || false, styleId: DEFAULT_TABLE_CONFIG.styleId, }; } /** * In all the sheets, replace the table-only references in the formula cells with standard references. */ function convertTableFormulaReferences(convertedSheets, xlsxSheets) { for (let sheet of convertedSheets) { const tables = xlsxSheets.find((s) => s.sheetName === sheet.name).tables; for (let table of tables) { const tabRef = table.name + "["; for (let position of positions(toZone(table.ref))) { const xc = toXC(position.col, position.row); const cell = sheet.cells[xc]; if (cell && cell.content && cell.content.startsWith("=")) { let refIndex; while ((refIndex = cell.content.indexOf(tabRef)) !== -1) { let reference = cell.content.slice(refIndex + tabRef.length); // Expression can either be tableName[colName] or tableName[[#This Row], [colName]] let endIndex = reference.indexOf("]"); if (reference.startsWith(`[`)) { endIndex = reference.indexOf("]", endIndex + 1); endIndex = reference.indexOf("]", endIndex + 1); } reference = reference.slice(0, endIndex); const convertedRef = convertTableReference(reference, table, xc); cell.content = cell.content.slice(0, refIndex) + convertedRef + cell.content.slice(tabRef.length + refIndex + endIndex + 1); } } } } } } /** * Convert table-specific references in formulas into standard references. * * A reference in a table can have the form (only the part between brackets should be given to this function): * - tableName[colName] : reference to the whole column "colName" * - tableName[[#keyword], [colName]] : reference to some of the element(s) of the column colName * * The available keywords are : * - #All : all the column (including totals) * - #Data : only the column data (no headers/totals) * - #Headers : only the header of the column * - #Totals : only the totals of the column * - #This Row : only the element in the same row as the cell */ function convertTableReference(expr, table, cellXc) { const refElements = expr.split(","); const tableZone = toZone(table.ref); const refZone = { ...tableZone }; let isReferencedZoneValid = true; // Single column reference if (refElements.length === 1) { const colRelativeIndex = table.cols.findIndex((col) => col.name === refElements[0]); refZone.left = refZone.right = colRelativeIndex + tableZone.left; if (table.headerRowCount) { refZone.top += table.headerRowCount; } if (table.totalsRowCount) { refZone.bottom -= 1; } } // Other references else { switch (refElements[0].slice(1, refElements[0].length - 1)) { case "#All": refZone.top = table.headerRowCount ? tableZone.top + table.headerRowCount : tableZone.top; refZone.bottom = tableZone.bottom; break; case "#Data": refZone.top = table.headerRowCount ? tableZone.top + table.headerRowCount : tableZone.top; refZone.bottom = table.totalsRowCount ? tableZone.bottom + 1 : tableZone.bottom; break; case "#This Row": refZone.top = refZone.bottom = toCartesian(cellXc).row; break; case "#Headers": refZone.top = refZone.bottom = tableZone.top; if (!table.headerRowCount) { isReferencedZoneValid = false; } break; case "#Totals": refZone.top = refZone.bottom = tableZone.bottom; if (!table.totalsRowCount) { isReferencedZoneValid = false; } break; } const colRef = refElements[1].slice(1, refElements[1].length - 1); const colRelativeIndex = table.cols.findIndex((col) => col.name === colRef); refZone.left = refZone.right = colRelativeIndex + tableZone.left; } if (!isReferencedZoneValid) { return CellErrorType.InvalidReference; } return refZone.top !== refZone.bottom ? zoneToXc(refZone) : toXC(refZone.left, refZone.top); } // ------------------------------------- // XML HELPERS // ------------------------------------- function createXMLFile(doc, path, contentType) { return { content: new XMLSerializer().serializeToString(doc), path, contentType, }; } function xmlEscape(str) { return (String(str) .replace(/\&/g, "&") .replace(/\/g, ">") .replace(/\"/g, """) .replace(/\'/g, "'") // Delete all ASCII control characters except for TAB (\x09), LF (\x0A) and CR (\x0D) // They are not valid at all in XML 1.0 (even escaped) .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, "")); } function formatAttributes(attrs) { return new XMLString(attrs.map(([key, val]) => `${key}="${xmlEscape(val)}"`).join(" ")); } function parseXML(xmlString, mimeType = "text/xml") { const document = new DOMParser().parseFromString(xmlString.toString(), mimeType); const parserError = document.querySelector("parsererror"); if (parserError) { const errorString = parserError.innerHTML; const lineNumber = parseInt(errorString.split(":")[0], 10); const xmlStringArray = xmlString.toString().trim().split("\n"); const xmlPreview = xmlStringArray .slice(Math.max(lineNumber - 3, 0), Math.min(lineNumber + 2, xmlStringArray.length)) .join("\n"); throw new Error(`XML string could not be parsed: ${errorString}\n${xmlPreview}`); } return document; } function convertBorderDescr(descr) { if (!descr) { return undefined; } return { style: descr.style, color: { rgb: descr.color }, }; } function getDefaultXLSXStructure(data) { const xlsxBorders = Object.values(data.borders).map((border) => { return { left: convertBorderDescr(border.left), right: convertBorderDescr(border.right), bottom: convertBorderDescr(border.bottom), top: convertBorderDescr(border.top), }; }); const borders = [{}, ...xlsxBorders]; return { relsFiles: [], sharedStrings: [], // default Values that will always be part of the style sheet styles: [ { fontId: 0, fillId: 0, numFmtId: 0, borderId: 0, alignment: {}, }, ], fonts: [ { size: DEFAULT_FONT_SIZE, family: 2, color: { rgb: "000000" }, name: "Arial", }, ], fills: [{ reservedAttribute: "none" }, { reservedAttribute: "gray125" }], borders, numFmts: [], dxfs: [], }; } function createOverride(partName, contentType) { return escapeXml /*xml*/ ` `; } function createDefaultXMLElement(extension, contentType) { return escapeXml /*xml*/ ` `; } function joinXmlNodes(xmlNodes) { return new XMLString(xmlNodes.join("\n")); } /** * Escape interpolated values except if the value is already * a properly escaped XML string. * * ``` * escapeXml`${"This will be escaped"}` * ``` */ function escapeXml(strings, ...expressions) { let str = [strings[0]]; for (let i = 0; i < expressions.length; i++) { const value = expressions[i] instanceof XMLString ? expressions[i] : xmlEscape(expressions[i]); str.push(value + strings[i + 1]); } return new XMLString(concat(str)); } /** * Removes the escaped namespace of all the xml tags in the string. * * Eg. : "NAMESPACEnsNAMESPACEtest a" => "test a" */ function removeTagEscapedNamespaces(tag) { return tag.replace(/NAMESPACE.*NAMESPACE(.*)/, "$1"); } /** * Encase the namespaces in the element's tags with NAMESPACE string * * e.g. becomes * * That's useful because namespaces aren't supported by the HTML specification, so it's arbitrary whether a HTML parser/querySelector * implementation will support namespaces in the tags or not. */ function escapeTagNamespaces(str) { return str.replaceAll(/(<\/?)([a-zA-Z0-9]+):([a-zA-Z0-9]+)/g, "$1" + "NAMESPACE" + "$2" + "NAMESPACE" + "$3"); } function escapeQueryNameSpaces(query) { return query.replaceAll(/([a-zA-Z0-9]+):([a-zA-Z0-9]+)/g, "NAMESPACE" + "$1" + "NAMESPACE" + "$2"); } class AttributeValue { value; constructor(value) { this.value = value; } asString() { return fixXlsxUnicode(String(this.value)); } asBool() { if (this.value === "true") return true; // for files exported from Libre Office if (this.value === "false") return false; return Boolean(Number(this.value)); } asNum() { return Number(this.value); } } class XlsxBaseExtractor { rootFile; xlsxFileStructure; warningManager; relationships; // The xml file we are currently parsing. We should have one Extractor class by XLSXImportFile, but // the XLSXImportFile contains both the main .xml file, and the .rels file currentFile = undefined; /** * /!\ Important : There should be no namespaces in the tags of the XML files. * * This class use native querySelector and querySelectorAll, that's used for HTML (not XML). These aren't supposed to * handled namespaces, as they are not supported by the HTML specification. Some implementations (most browsers) do * actually support namespaces, but some don't (e.g. jsdom). * * The namespace should be escaped as with NAMESPACE string (eg. => ). */ constructor(rootFile, xlsxStructure, warningManager) { this.rootFile = rootFile; this.currentFile = rootFile.file.fileName; this.xlsxFileStructure = xlsxStructure; this.warningManager = warningManager; this.relationships = {}; if (rootFile.rels) { this.extractRelationships(rootFile.rels).map((rel) => { this.relationships[rel.id] = rel; }); } } /** * Extract all the relationships inside a .xml.rels file */ extractRelationships(relFile) { return this.mapOnElements({ parent: relFile.xml, query: "Relationship" }, (relationshipElement) => { return { id: this.extractAttr(relationshipElement, "Id", { required: true }).asString(), target: this.extractAttr(relationshipElement, "Target", { required: true }).asString(), type: this.extractAttr(relationshipElement, "Type", { required: true }).asString(), }; }); } /** * Get the list of all the XLSX files in the XLSX file structure */ getListOfXMLFiles() { const XMLFiles = Object.entries(this.xlsxFileStructure) .filter(([key]) => key !== "images") .map(([_, value]) => value) .flat() .filter(isDefined); return XMLFiles; } /** * Return an array containing the return value of the given function applied to all the XML elements * found using the MapOnElementArgs. * * The arguments contains : * - query : a QuerySelector string to find the elements to apply the function to * - parent : an XML element or XML document in which to find the queried elements * - children : if true, the function is applied on the direct children of the queried element * * This method will also handle the errors thrown in the argument function. */ mapOnElements(args, fct) { const ret = []; const oldWorkingDocument = this.currentFile; let elements; if (args.children) { const children = this.querySelector(args.parent, args.query)?.children; elements = children ? children : []; } else { elements = this.querySelectorAll(args.parent, args.query); } if (elements) { for (let element of elements) { try { ret.push(fct(element)); } catch (e) { this.catchErrorOnElement(e, element); } } } this.currentFile = oldWorkingDocument; return ret; } /** * Log an error caught when parsing an element in the warningManager. */ catchErrorOnElement(error, onElement) { const errorMsg = onElement ? `Error when parsing an element <${onElement.tagName}> of file ${this.currentFile}, skip this element. \n${error.stack}` : `Error when parsing file ${this.currentFile}.`; this.warningManager.addParsingWarning([errorMsg, error.message].join("\n")); } /** * Extract an attribute from an Element. * * If the attribute is required but was not found, will add a warning in the warningManager if it was given a default * value, and throw an error if no default value was given. * * Can only return undefined value for non-required attributes without default value. */ extractAttr(e, attName, optionalArgs) { const attribute = e.attributes[attName]; if (!attribute) this.handleMissingValue(e, `attribute "${attName}"`, optionalArgs); const value = attribute?.value ? attribute.value : optionalArgs?.default; return (value === undefined ? undefined : new AttributeValue(value)); } /** * Extract the text content of an Element. * * If the text content is required but was not found, will add a warning in the warningManager if it was given a default * value, and throw an error if no default value was given. * * Can only return undefined value for non-required text content without default value. */ extractTextContent(element, optionalArgs) { if (optionalArgs?.default !== undefined && typeof optionalArgs.default !== "string") { throw new Error("extractTextContent default value should be a string"); } const shouldPreserveSpaces = element?.attributes["xml:space"]?.value === "preserve"; let textContent = element?.textContent; if (!element || textContent === null) { this.handleMissingValue(element, `text content`, optionalArgs); } if (textContent) { textContent = shouldPreserveSpaces ? textContent : textContent.trim(); } return (textContent ? fixXlsxUnicode(textContent) : optionalArgs?.default); } /** * Extract an attribute of a child of the given element. * * The reference of a child can be a string (tag of the child) or an number (index in the list of children of the element) * * If the attribute is required but either the attribute or the referenced child element was not found, it will * will add a warning in the warningManager if it was given a default value, and throw an error if no default value was given. * * Can only return undefined value for non-required attributes without default value. */ extractChildAttr(e, childRef, attName, optionalArgs) { let child; if (typeof childRef === "number") { child = e.children[childRef]; } else { child = this.querySelector(e, childRef); } if (!child) { this.handleMissingValue(e, typeof childRef === "number" ? `child at index ${childRef}` : `child <${childRef}>`, optionalArgs); } const value = child ? this.extractAttr(child, attName, optionalArgs)?.asString() : optionalArgs?.default; return (value !== undefined ? new AttributeValue(value) : undefined); } /** * Extract the text content of a child of the given element. * * If the text content is required but either the text content or the referenced child element was not found, it will * will add a warning in the warningManager if it was given a default value, and throw an error if no default value was given. * * Can only return undefined value for non-required text content without default value. */ extractChildTextContent(e, childRef, optionalArgs) { if (optionalArgs?.default !== undefined && typeof optionalArgs.default !== "string") { throw new Error("extractTextContent default value should be a string"); } let child = this.querySelector(e, childRef); if (!child) { this.handleMissingValue(e, `child <${childRef}>`, optionalArgs); } return (child ? this.extractTextContent(child, optionalArgs) : optionalArgs?.default); } /** * Should be called if a extractAttr/extractTextContent doesn't find the element it needs to extract. * * If the extractable was required, this function will add a warning in the warningManager if there was a default value, * and throw an error if no default value was given. */ handleMissingValue(parentElement, missingElementName, optionalArgs) { if (optionalArgs?.required) { if (optionalArgs?.default) { this.warningManager.addParsingWarning(`Missing required ${missingElementName} in element <${parentElement.tagName}> of ${this.currentFile}, replacing it by the default value ${optionalArgs.default}`); } else { throw new Error(`Missing required ${missingElementName} in element <${parentElement.tagName}> of ${this.currentFile}, and no default value was set`); } } } /** * Extract a color, extracting it from the theme if needed. * * Will throw an error if the element references a theme, but no theme was provided or the theme it doesn't contain the color. */ extractColor(colorElement, theme, defaultColor) { if (!colorElement) { return defaultColor ? { rgb: defaultColor } : undefined; } const themeIndex = this.extractAttr(colorElement, "theme")?.asString(); let rgb; if (themeIndex !== undefined) { if (!theme || !theme.clrScheme) { throw new Error("Color referencing a theme but no theme was provided"); } rgb = this.getThemeColor(themeIndex, theme.clrScheme); } else { rgb = this.extractAttr(colorElement, "rgb")?.asString(); rgb = rgb === DEFAULT_SYSTEM_COLOR ? undefined : rgb; } const color = { rgb: rgb || defaultColor, auto: this.extractAttr(colorElement, "auto")?.asBool(), indexed: this.extractAttr(colorElement, "indexed")?.asNum(), tint: this.extractAttr(colorElement, "tint")?.asNum(), }; return color; } /** * Returns the xml file targeted by a relationship. */ getTargetXmlFile(relationship) { if (!relationship) throw new Error("Undefined target file"); const target = this.processRelationshipTargetName(relationship.target); // Use "endsWith" because targets are relative paths, and we know the files by their absolute path. const f = this.getListOfXMLFiles().find((f) => f.file.fileName.endsWith(target)); if (!f || !f.file) throw new Error("Cannot find target file"); return f; } /** * Returns the image parameters targeted by a relationship. */ getTargetImageFile(relationship) { if (!relationship) throw new Error("Undefined target file"); const target = this.processRelationshipTargetName(relationship.target); // Use "endsWith" because targets are relative paths, and we know the files by their absolute path. const f = this.xlsxFileStructure.images.find((f) => f.fileName.endsWith(target)); if (!f) throw new Error("Cannot find target file"); return f; } querySelector(element, query) { const escapedQuery = escapeQueryNameSpaces(query); return element.querySelector(escapedQuery); } querySelectorAll(element, query) { const escapedQuery = escapeQueryNameSpaces(query); return element.querySelectorAll(escapedQuery); } /** * Get a color from its id in the Theme's colorScheme. * * Note that Excel don't use the colors from the theme but from its own internal theme, so the displayed * colors will be different in the import than in excel. * . */ getThemeColor(colorId, clrScheme) { switch (colorId) { case "0": // 0 : sysColor window text return "FFFFFF"; case "1": // 1 : sysColor window background return "000000"; // Don't ask me why these 2 are inverted, I cannot find any documentation for it but everyone does it case "2": return clrScheme["3"].value; case "3": return clrScheme["2"].value; default: return clrScheme[colorId].value; } } /** Remove signs of relative path. */ processRelationshipTargetName(targetName) { return targetName.replace(/\.+\//, ""); } } /** * XLSX Extractor class that can be used for either sharedString XML files or theme XML files. * * Since they both are quite simple, it make sense to make a single class to manage them all, to avoid unnecessary file * cluttering. */ class XlsxMiscExtractor extends XlsxBaseExtractor { getTheme() { const clrScheme = this.mapOnElements({ query: "a:clrScheme", parent: this.rootFile.file.xml, children: true }, (element) => { return { name: element.tagName, value: this.extractChildAttr(element, 0, "val", { required: true, default: AUTO_COLOR, }).asString(), lastClr: this.extractChildAttr(element, 0, "lastClr", { default: AUTO_COLOR, }).asString(), }; }); return { clrScheme }; } /** * Get the array of shared strings of the XLSX. * * Worth noting that running a prettier on the xml can mess up some strings, since there is an option in the * xmls to keep the spacing and not trim the string. */ getSharedStrings() { return this.mapOnElements({ parent: this.rootFile.file.xml, query: "si" }, (ssElement) => { // Shared string can either be a simple text, or a rich text (text with formatting, possibly in multiple parts) if (ssElement.children[0].tagName === "t") { return this.extractTextContent(ssElement) || ""; } // We don't support rich text formatting, we'll only extract the text else { return this.mapOnElements({ parent: ssElement, query: "t" }, (textElement) => { return this.extractTextContent(textElement) || ""; }).join(""); } }); } } class XlsxCfExtractor extends XlsxBaseExtractor { theme; constructor(sheetFile, xlsxStructure, warningManager, theme) { super(sheetFile, xlsxStructure, warningManager); this.theme = theme; } extractConditionalFormattings() { const cfs = this.mapOnElements({ parent: this.rootFile.file.xml, query: "worksheet > conditionalFormatting" }, (cfElement) => { return { // sqref = ranges on which the cf applies, separated by spaces sqref: this.extractAttr(cfElement, "sqref", { required: true }).asString().split(" "), pivot: this.extractAttr(cfElement, "pivot")?.asBool(), cfRules: this.extractCFRules(cfElement, this.theme), }; }); // XLSX extension to OpenXml cfs.push(...this.mapOnElements({ parent: this.rootFile.file.xml, query: "extLst x14:conditionalFormatting" }, (cfElement) => { return { sqref: this.extractChildTextContent(cfElement, "xm:sqref", { required: true }).split(" "), pivot: this.extractAttr(cfElement, "xm:pivot")?.asBool(), cfRules: this.extractCFRules(cfElement, this.theme), }; })); return cfs; } extractCFRules(cfElement, theme) { return this.mapOnElements({ parent: cfElement, query: "cfRule, x14:cfRule" }, (cfRuleElement) => { const cfType = this.extractAttr(cfRuleElement, "type", { required: true, }).asString(); if (cfType === "dataBar") { // Databars are an extension to OpenXml and have a different format (XLSX §2.6.30). Do'nt bother // extracting them as we don't support them. throw new Error("Databars conditional formats are not supported."); } return { type: cfType, priority: this.extractAttr(cfRuleElement, "priority", { required: true }).asNum(), colorScale: this.extractCfColorScale(cfRuleElement, theme), formula: this.extractCfFormula(cfRuleElement), iconSet: this.extractCfIconSet(cfRuleElement), dxfId: this.extractAttr(cfRuleElement, "dxfId")?.asNum(), stopIfTrue: this.extractAttr(cfRuleElement, "stopIfTrue")?.asBool(), aboveAverage: this.extractAttr(cfRuleElement, "aboveAverage")?.asBool(), percent: this.extractAttr(cfRuleElement, "percent")?.asBool(), bottom: this.extractAttr(cfRuleElement, "bottom")?.asBool(), operator: this.extractAttr(cfRuleElement, "operator")?.asString(), text: this.extractAttr(cfRuleElement, "text")?.asString(), timePeriod: this.extractAttr(cfRuleElement, "timePeriod")?.asString(), rank: this.extractAttr(cfRuleElement, "rank")?.asNum(), stdDev: this.extractAttr(cfRuleElement, "stdDev")?.asNum(), equalAverage: this.extractAttr(cfRuleElement, "equalAverage")?.asBool(), }; }); } extractCfFormula(cfRulesElement) { return this.mapOnElements({ parent: cfRulesElement, query: "formula" }, (cfFormulaElements) => { return this.extractTextContent(cfFormulaElements, { required: true }); }); } extractCfColorScale(cfRulesElement, theme) { const colorScaleElement = this.querySelector(cfRulesElement, "colorScale"); if (!colorScaleElement) return undefined; return { colors: this.mapOnElements({ parent: colorScaleElement, query: "color" }, (colorElement) => { return this.extractColor(colorElement, theme, "ffffff"); }), cfvos: this.extractCFVos(colorScaleElement), }; } extractCfIconSet(cfRulesElement) { const iconSetElement = this.querySelector(cfRulesElement, "iconSet, x14:iconSet"); if (!iconSetElement) return undefined; return { iconSet: this.extractAttr(iconSetElement, "iconSet", { default: "3TrafficLights1", }).asString(), showValue: this.extractAttr(iconSetElement, "showValue", { default: true }).asBool(), percent: this.extractAttr(iconSetElement, "percent", { default: true }).asBool(), reverse: this.extractAttr(iconSetElement, "reverse")?.asBool(), custom: this.extractAttr(iconSetElement, "custom")?.asBool(), cfvos: this.extractCFVos(iconSetElement), cfIcons: this.extractCfIcons(iconSetElement), }; } extractCfIcons(iconSetElement) { const icons = this.mapOnElements({ parent: iconSetElement, query: "cfIcon, x14:cfIcon" }, (cfIconElement) => { return { iconSet: this.extractAttr(cfIconElement, "iconSet", { required: true, }).asString(), iconId: this.extractAttr(cfIconElement, "iconId", { required: true }).asNum(), }; }); return icons.length === 0 ? undefined : icons; } extractCFVos(parent) { return this.mapOnElements({ parent, query: "cfvo, x14:cfvo" }, (cfVoElement) => { return { type: this.extractAttr(cfVoElement, "type", { required: true, }).asString(), gte: this.extractAttr(cfVoElement, "gte", { default: true })?.asBool(), value: cfVoElement.attributes["val"] ? this.extractAttr(cfVoElement, "val")?.asString() : this.extractChildTextContent(cfVoElement, "f, xm:f"), }; }); } } class XlsxChartExtractor extends XlsxBaseExtractor { extractChart() { return this.mapOnElements({ parent: this.rootFile.file.xml, query: "c:chartSpace" }, (rootChartElement) => { const chartType = this.getChartType(rootChartElement); if (!CHART_TYPE_CONVERSION_MAP[chartType]) { throw new Error(`Unsupported chart type ${chartType}`); } if (CHART_TYPE_CONVERSION_MAP[chartType] === "combo") { return this.extractComboChart(rootChartElement); } // Title can be separated into multiple xml elements (for styling and such), we only import the text const chartTitle = this.mapOnElements({ parent: rootChartElement, query: "c:chart > c:title a:t" }, (textElement) => { return textElement.textContent || ""; }).join(""); const barChartGrouping = this.extractChildAttr(rootChartElement, "c:grouping", "val", { default: "clustered", }).asString(); return { title: { text: chartTitle }, type: CHART_TYPE_CONVERSION_MAP[chartType], dataSets: this.extractChartDatasets(this.querySelectorAll(rootChartElement, `c:${chartType}`), chartType), labelRange: this.extractChildTextContent(rootChartElement, `c:ser ${chartType === "scatterChart" ? "c:numRef" : "c:cat"} c:f`), backgroundColor: this.extractChildAttr(rootChartElement, "c:chartSpace > c:spPr a:srgbClr", "val", { default: "ffffff", }).asString(), legendPosition: DRAWING_LEGEND_POSITION_CONVERSION_MAP[this.extractChildAttr(rootChartElement, "c:legendPos", "val", { default: "b", }).asString()], stacked: barChartGrouping === "stacked", fontColor: "000000", }; })[0]; } extractComboChart(chartElement) { // Title can be separated into multiple xml elements (for styling and such), we only import the text const chartTitle = this.mapOnElements({ parent: chartElement, query: "c:title a:t" }, (textElement) => { return textElement.textContent || ""; }).join(""); const barChartGrouping = this.extractChildAttr(chartElement, "c:grouping", "val", { default: "clustered", }).asString(); return { title: { text: chartTitle }, type: "combo", dataSets: [ ...this.extractChartDatasets(this.querySelectorAll(chartElement, `c:barChart`), "comboChart"), ...this.extractChartDatasets(this.querySelectorAll(chartElement, `c:lineChart`), "comboChart"), ], labelRange: this.extractChildTextContent(chartElement, "c:ser c:cat c:f"), backgroundColor: this.extractChildAttr(chartElement, "c:chartSpace > c:spPr a:srgbClr", "val", { default: "ffffff", }).asString(), legendPosition: DRAWING_LEGEND_POSITION_CONVERSION_MAP[this.extractChildAttr(chartElement, "c:legendPos", "val", { default: "b", }).asString()], stacked: barChartGrouping === "stacked", fontColor: "000000", }; } extractChartDatasets(chartElements, chartType) { return Array.from(chartElements) .map((element) => { if (chartType === "scatterChart") { return this.extractScatterChartDatasets(element); } return this.mapOnElements({ parent: element, query: "c:ser" }, (chartDataElement) => { let label = {}; const reference = this.extractChildTextContent(chartDataElement, "c:tx c:f"); if (reference) { label = { reference }; } else { const text = this.extractChildTextContent(chartDataElement, "c:tx c:v"); if (text) { label = { text }; } } const color = this.extractChildAttr(chartDataElement, "c:spPr a:solidFill a:srgbClr", "val"); return { label, range: this.extractChildTextContent(chartDataElement, "c:val c:f", { required: true, }), backgroundColor: color ? `${toHex(color.asString())}` : undefined, }; }); }) .flat(); } extractScatterChartDatasets(chartElement) { return this.mapOnElements({ parent: chartElement, query: "c:ser" }, (chartDataElement) => { let label = {}; const reference = this.extractChildTextContent(chartDataElement, "c:tx c:f"); if (reference) { label = { reference }; } else { const text = this.extractChildTextContent(chartDataElement, "c:tx c:v"); if (text) { label = { text }; } } return { label, range: this.extractChildTextContent(chartDataElement, "c:yVal c:f", { required: true }), }; }); } /** * The chart type in the XML isn't explicitly defined, but there is an XML element that define the * chart, and this element tag name tells us which type of chart it is. We just need to find this XML element. */ getChartType(chartElement) { const plotAreaElement = this.querySelector(chartElement, "c:plotArea"); if (!plotAreaElement) { throw new Error("Missing plot area in the chart definition."); } let globalTag = undefined; for (let child of plotAreaElement.children) { const tag = removeTagEscapedNamespaces(child.tagName); if (XLSX_CHART_TYPES.some((chartType) => chartType === tag)) { if (!globalTag) { globalTag = tag; } else if (globalTag !== tag) { globalTag = "comboChart"; } } } if (globalTag) { return globalTag; } throw new Error("Unknown chart type"); } } const ONE_CELL_ANCHOR = "oneCellAnchor"; const TWO_CELL_ANCHOR = "twoCellAnchor"; class XlsxFigureExtractor extends XlsxBaseExtractor { extractFigures() { return this.mapOnElements({ parent: this.rootFile.file.xml, query: "xdr:wsDr", children: true }, (figureElement) => { const anchorType = removeTagEscapedNamespaces(figureElement.tagName); const anchors = this.extractFigureAnchorsByType(figureElement, anchorType); const chartElement = this.querySelector(figureElement, "c:chart"); const imageElement = this.querySelector(figureElement, "a:blip"); if (!chartElement && !imageElement) { throw new Error("Only chart and image figures are currently supported."); } return { anchors, data: chartElement ? this.extractChart(chartElement) : this.extractImage(figureElement), figureSize: anchorType === ONE_CELL_ANCHOR ? this.extractFigureSizeFromSizeTag(figureElement, "xdr:ext") : undefined, }; }); } extractFigureAnchorsByType(figureElement, anchorType) { switch (anchorType) { case ONE_CELL_ANCHOR: return [this.extractFigureAnchor("xdr:from", figureElement)]; case TWO_CELL_ANCHOR: return [ this.extractFigureAnchor("xdr:from", figureElement), this.extractFigureAnchor("xdr:to", figureElement), ]; default: throw new Error(`${anchorType} is not supported for xlsx drawings. `); } } extractFigureSizeFromSizeTag(figureElement, sizeTag) { const sizeElement = this.querySelector(figureElement, sizeTag); if (!sizeElement) { throw new Error(`Missing size element '${sizeTag}'`); } return { cx: this.extractAttr(sizeElement, "cx", { required: true }).asNum(), cy: this.extractAttr(sizeElement, "cy", { required: true }).asNum(), }; } extractFigureAnchor(anchorTag, figureElement) { const anchor = this.querySelector(figureElement, anchorTag); if (!anchor) { throw new Error(`Missing anchor element ${anchorTag}`); } return { col: Number(this.extractChildTextContent(anchor, "xdr:col", { required: true })), colOffset: Number(this.extractChildTextContent(anchor, "xdr:colOff", { required: true })), row: Number(this.extractChildTextContent(anchor, "xdr:row", { required: true })), rowOffset: Number(this.extractChildTextContent(anchor, "xdr:rowOff", { required: true })), }; } extractChart(chartElement) { const chartId = this.extractAttr(chartElement, "r:id", { required: true }).asString(); const chartFile = this.getTargetXmlFile(this.relationships[chartId]); const chartDefinition = new XlsxChartExtractor(chartFile, this.xlsxFileStructure, this.warningManager).extractChart(); if (!chartDefinition) { throw new Error("Unable to extract chart definition"); } return chartDefinition; } extractImage(figureElement) { const imageElement = this.querySelector(figureElement, "a:blip"); const imageId = this.extractAttr(imageElement, "r:embed", { required: true }).asString(); const image = this.getTargetImageFile(this.relationships[imageId]); if (!image) { throw new Error("Unable to extract image"); } const extension = image.fileName.split(".").at(-1); const anchorType = removeTagEscapedNamespaces(figureElement.tagName); const sizeElement = anchorType === TWO_CELL_ANCHOR ? this.querySelector(figureElement, "a:xfrm") : figureElement; const sizeTag = anchorType === TWO_CELL_ANCHOR ? "a:ext" : "xdr:ext"; const size = this.extractFigureSizeFromSizeTag(sizeElement, sizeTag); return { imageSrc: image.imageSrc, mimetype: extension ? IMAGE_EXTENSION_TO_MIMETYPE_MAPPING[extension] : undefined, size, }; } } /** * We don't really support pivot tables, we'll just extract them as Tables. */ class XlsxPivotExtractor extends XlsxBaseExtractor { getPivotTable() { return this.mapOnElements( // Use :root instead of "pivotTableDefinition" because others pivotTableDefinition elements are present inside the root // pivotTableDefinition elements. { query: ":root", parent: this.rootFile.file.xml }, (pivotElement) => { return { name: this.extractAttr(pivotElement, "name", { required: true }).asString(), rowGrandTotals: this.extractAttr(pivotElement, "rowGrandTotals", { default: true, }).asBool(), location: this.extractPivotLocation(pivotElement), style: this.extractPivotStyleInfo(pivotElement), }; })[0]; } extractPivotLocation(pivotElement) { return this.mapOnElements({ query: "location", parent: pivotElement }, (pivotStyleElement) => { return { ref: this.extractAttr(pivotStyleElement, "ref", { required: true }).asString(), firstHeaderRow: this.extractAttr(pivotStyleElement, "firstHeaderRow", { required: true, }).asNum(), firstDataRow: this.extractAttr(pivotStyleElement, "firstDataRow", { required: true, }).asNum(), firstDataCol: this.extractAttr(pivotStyleElement, "firstDataCol", { required: true, }).asNum(), }; })[0]; } extractPivotStyleInfo(pivotElement) { return this.mapOnElements({ query: "pivotTableStyleInfo", parent: pivotElement }, (pivotStyleElement) => { return { name: this.extractAttr(pivotStyleElement, "name", { required: true }).asString(), showRowHeaders: this.extractAttr(pivotStyleElement, "showRowHeaders", { required: true, }).asBool(), showColHeaders: this.extractAttr(pivotStyleElement, "showColHeaders", { required: true, }).asBool(), showRowStripes: this.extractAttr(pivotStyleElement, "showRowStripes", { required: true, }).asBool(), showColStripes: this.extractAttr(pivotStyleElement, "showColStripes", { required: true, }).asBool(), showLastColumn: this.extractAttr(pivotStyleElement, "showLastColumn")?.asBool(), }; })[0]; } } class XlsxTableExtractor extends XlsxBaseExtractor { getTable() { return this.mapOnElements({ query: "table", parent: this.rootFile.file.xml }, (tableElement) => { return { displayName: this.extractAttr(tableElement, "displayName", { required: true, }).asString(), name: this.extractAttr(tableElement, "name")?.asString(), id: this.extractAttr(tableElement, "id", { required: true }).asString(), ref: this.extractAttr(tableElement, "ref", { required: true }).asString(), headerRowCount: this.extractAttr(tableElement, "headerRowCount", { default: 1, }).asNum(), totalsRowCount: this.extractAttr(tableElement, "totalsRowCount", { default: 0, }).asNum(), cols: this.extractTableCols(tableElement), style: this.extractTableStyleInfo(tableElement), autoFilter: this.extractTableAutoFilter(tableElement), }; })[0]; } extractTableCols(tableElement) { return this.mapOnElements({ query: "tableColumn", parent: tableElement }, (tableColElement) => { return { id: this.extractAttr(tableColElement, "id", { required: true }).asString(), name: this.extractAttr(tableColElement, "name", { required: true }).asString(), colFormula: this.extractChildTextContent(tableColElement, "calculatedColumnFormula"), }; }); } extractTableStyleInfo(tableElement) { return this.mapOnElements({ query: "tableStyleInfo", parent: tableElement }, (tableStyleElement) => { return { name: this.extractAttr(tableStyleElement, "name")?.asString(), showFirstColumn: this.extractAttr(tableStyleElement, "showFirstColumn")?.asBool(), showLastColumn: this.extractAttr(tableStyleElement, "showLastColumn")?.asBool(), showRowStripes: this.extractAttr(tableStyleElement, "showRowStripes")?.asBool(), showColumnStripes: this.extractAttr(tableStyleElement, "showColumnStripes")?.asBool(), }; })[0]; } extractTableAutoFilter(tableElement) { return this.mapOnElements({ query: "autoFilter", parent: tableElement }, (autoFilterElement) => { return { columns: this.extractFilterColumns(autoFilterElement), zone: this.extractAttr(autoFilterElement, "ref", { required: true }).asString(), }; })[0]; } extractFilterColumns(autoFilterElement) { return this.mapOnElements({ query: "tableColumn", parent: autoFilterElement }, (filterColumnElement) => { return { colId: this.extractAttr(autoFilterElement, "colId", { required: true }).asNum(), hiddenButton: this.extractAttr(autoFilterElement, "hiddenButton", { default: false, }).asBool(), filters: this.extractSimpleFilter(filterColumnElement), }; }); } extractSimpleFilter(filterColumnElement) { return this.mapOnElements({ query: "filter", parent: filterColumnElement }, (filterColumnElement) => { return { val: this.extractAttr(filterColumnElement, "val", { required: true }).asString(), }; }); } } class XlsxSheetExtractor extends XlsxBaseExtractor { theme; constructor(sheetFile, xlsxStructure, warningManager, theme) { super(sheetFile, xlsxStructure, warningManager); this.theme = theme; } getSheet() { return this.mapOnElements({ query: "worksheet", parent: this.rootFile.file.xml }, (sheetElement) => { const sheetWorkbookInfo = this.getSheetWorkbookInfo(); return { sheetName: this.extractSheetName(), sheetViews: this.extractSheetViews(sheetElement), sheetFormat: this.extractSheetFormat(sheetElement), sheetProperties: this.extractSheetProperties(sheetElement), cols: this.extractCols(sheetElement), rows: this.extractRows(sheetElement), sharedFormulas: this.extractSharedFormulas(sheetElement), merges: this.extractMerges(sheetElement), cfs: this.extractConditionalFormats(), figures: this.extractFigures(sheetElement), hyperlinks: this.extractHyperLinks(sheetElement), tables: this.extractTables(sheetElement), pivotTables: this.extractPivotTables(), isVisible: sheetWorkbookInfo.state === "visible" ? true : false, }; })[0]; } extractSheetViews(worksheet) { return this.mapOnElements({ parent: worksheet, query: "sheetView" }, (sheetViewElement) => { const paneElement = this.querySelector(sheetViewElement, "pane"); return { tabSelected: this.extractAttr(sheetViewElement, "tabSelected", { default: false, }).asBool(), showFormulas: this.extractAttr(sheetViewElement, "showFormulas", { default: false, }).asBool(), showGridLines: this.extractAttr(sheetViewElement, "showGridLines", { default: true, }).asBool(), showRowColHeaders: this.extractAttr(sheetViewElement, "showRowColHeaders", { default: true, }).asBool(), pane: { xSplit: paneElement ? this.extractAttr(paneElement, "xSplit", { default: 0 }).asNum() : 0, ySplit: paneElement ? this.extractAttr(paneElement, "ySplit", { default: 0 }).asNum() : 0, }, }; }); } extractSheetName() { const relativePath = getRelativePath(this.xlsxFileStructure.workbook.file.fileName, this.rootFile.file.fileName); const workbookRels = this.extractRelationships(this.xlsxFileStructure.workbook.rels); const relId = workbookRels.find((rel) => rel.target === relativePath).id; // Having a namespace in the attributes names mess with the querySelector, and the behavior is not the same // for every XML parser. So we'll search manually instead of using a querySelector to search for an attribute value. for (let sheetElement of this.querySelectorAll(this.xlsxFileStructure.workbook.file.xml, "sheet")) { if (sheetElement.attributes["r:id"].value === relId) { return sheetElement.attributes["name"].value; } } throw new Error("Missing sheet name"); } getSheetWorkbookInfo() { const relativePath = getRelativePath(this.xlsxFileStructure.workbook.file.fileName, this.rootFile.file.fileName); const workbookRels = this.extractRelationships(this.xlsxFileStructure.workbook.rels); const relId = workbookRels.find((rel) => rel.target === relativePath).id; const workbookSheets = this.mapOnElements({ parent: this.xlsxFileStructure.workbook.file.xml, query: "sheet" }, (sheetElement) => { return { relationshipId: this.extractAttr(sheetElement, "r:id", { required: true }).asString(), sheetId: this.extractAttr(sheetElement, "sheetId", { required: true }).asString(), sheetName: this.extractAttr(sheetElement, "name", { required: true }).asString(), state: this.extractAttr(sheetElement, "state", { default: "visible", }).asString(), }; }); const info = workbookSheets.find((info) => info.relationshipId === relId); if (!info) { throw new Error("Cannot find corresponding workbook sheet"); } return info; } extractConditionalFormats() { return new XlsxCfExtractor(this.rootFile, this.xlsxFileStructure, this.warningManager, this.theme).extractConditionalFormattings(); } extractFigures(worksheet) { const figures = this.mapOnElements({ parent: worksheet, query: "drawing" }, (drawingElement) => { const drawingId = this.extractAttr(drawingElement, "r:id", { required: true })?.asString(); const drawingFile = this.getTargetXmlFile(this.relationships[drawingId]); const figures = new XlsxFigureExtractor(drawingFile, this.xlsxFileStructure, this.warningManager).extractFigures(); return figures; })[0]; return figures || []; } extractTables(worksheet) { return this.mapOnElements({ query: "tablePart", parent: worksheet }, (tablePartElement) => { const tableId = this.extractAttr(tablePartElement, "r:id", { required: true })?.asString(); const tableFile = this.getTargetXmlFile(this.relationships[tableId]); const tableExtractor = new XlsxTableExtractor(tableFile, this.xlsxFileStructure, this.warningManager); return tableExtractor.getTable(); }); } extractPivotTables() { try { return Object.values(this.relationships) .filter((relationship) => relationship.type.endsWith("pivotTable")) .map((pivotRelationship) => { const pivotFile = this.getTargetXmlFile(pivotRelationship); const pivot = new XlsxPivotExtractor(pivotFile, this.xlsxFileStructure, this.warningManager).getPivotTable(); return pivot; }); } catch (e) { this.catchErrorOnElement(e); return []; } } extractMerges(worksheet) { return this.mapOnElements({ parent: worksheet, query: "mergeCell" }, (mergeElement) => { return this.extractAttr(mergeElement, "ref", { required: true }).asString(); }); } extractSheetFormat(worksheet) { const formatElement = this.querySelector(worksheet, "sheetFormatPr"); if (!formatElement) return undefined; return { defaultColWidth: this.extractAttr(formatElement, "defaultColWidth", { default: EXCEL_DEFAULT_COL_WIDTH.toString(), }).asNum(), defaultRowHeight: this.extractAttr(formatElement, "defaultRowHeight", { default: EXCEL_DEFAULT_ROW_HEIGHT.toString(), }).asNum(), }; } extractSheetProperties(worksheet) { const propertiesElement = this.querySelector(worksheet, "sheetPr"); if (!propertiesElement) return undefined; return { outlinePr: this.extractSheetOutlineProperties(propertiesElement), tabColor: this.extractColor(this.querySelector(propertiesElement, "tabColor"), this.theme), }; } extractSheetOutlineProperties(sheetProperties) { const properties = this.querySelector(sheetProperties, "outlinePr"); if (!properties) return undefined; return { summaryBelow: this.extractAttr(properties, "summaryBelow", { default: true }).asBool(), summaryRight: this.extractAttr(properties, "summaryRight", { default: true }).asBool(), }; } extractCols(worksheet) { return this.mapOnElements({ parent: worksheet, query: "cols col" }, (colElement) => { return { width: this.extractAttr(colElement, "width")?.asNum(), customWidth: this.extractAttr(colElement, "customWidth")?.asBool(), bestFit: this.extractAttr(colElement, "bestFit")?.asBool(), hidden: this.extractAttr(colElement, "hidden")?.asBool(), min: this.extractAttr(colElement, "min", { required: true })?.asNum(), max: this.extractAttr(colElement, "max", { required: true })?.asNum(), styleIndex: this.extractAttr(colElement, "style")?.asNum(), outlineLevel: this.extractAttr(colElement, "outlineLevel")?.asNum(), collapsed: this.extractAttr(colElement, "collapsed")?.asBool(), }; }); } extractRows(worksheet) { return this.mapOnElements({ parent: worksheet, query: "sheetData row" }, (rowElement) => { return { index: this.extractAttr(rowElement, "r", { required: true })?.asNum(), cells: this.extractCells(rowElement), height: this.extractAttr(rowElement, "ht")?.asNum(), customHeight: this.extractAttr(rowElement, "customHeight")?.asBool(), hidden: this.extractAttr(rowElement, "hidden")?.asBool(), styleIndex: this.extractAttr(rowElement, "s")?.asNum(), outlineLevel: this.extractAttr(rowElement, "outlineLevel")?.asNum(), collapsed: this.extractAttr(rowElement, "collapsed")?.asBool(), }; }); } extractCells(row) { return this.mapOnElements({ parent: row, query: "c" }, (cellElement) => { return { xc: this.extractAttr(cellElement, "r", { required: true })?.asString(), styleIndex: this.extractAttr(cellElement, "s")?.asNum(), type: CELL_TYPE_CONVERSION_MAP[this.extractAttr(cellElement, "t", { default: "n" })?.asString()], value: this.extractChildTextContent(cellElement, "v"), formula: this.extractCellFormula(cellElement), }; }); } extractCellFormula(cellElement) { const formulaElement = this.querySelector(cellElement, "f"); if (!formulaElement) return undefined; return { content: this.extractTextContent(formulaElement), sharedIndex: this.extractAttr(formulaElement, "si")?.asNum(), ref: this.extractAttr(formulaElement, "ref")?.asString(), }; } extractHyperLinks(worksheet) { return this.mapOnElements({ parent: worksheet, query: "hyperlink" }, (linkElement) => { const relId = this.extractAttr(linkElement, "r:id")?.asString(); return { xc: this.extractAttr(linkElement, "ref", { required: true })?.asString(), location: this.extractAttr(linkElement, "location")?.asString(), display: this.extractAttr(linkElement, "display")?.asString(), relTarget: relId ? this.relationships[relId].target : undefined, }; }); } extractSharedFormulas(worksheet) { const sfElements = this.querySelectorAll(worksheet, `f[si][ref]`); const sfMap = {}; for (let sfElement of sfElements) { const index = this.extractAttr(sfElement, "si", { required: true }).asNum(); const formula = this.extractTextContent(sfElement, { required: true }); sfMap[index] = formula; } const sfs = []; for (let i = 0; i < Object.keys(sfMap).length; i++) { if (!sfMap[i]) { this.warningManager.addParsingWarning(`Missing shared formula ${i}, replacing it by empty formula`); sfs.push(""); } else { sfs.push(sfMap[i]); } } return sfs; } } class XlsxStyleExtractor extends XlsxBaseExtractor { theme; constructor(xlsxStructure, warningManager, theme) { super(xlsxStructure.styles, xlsxStructure, warningManager); this.theme = theme; } getNumFormats() { return this.mapOnElements({ parent: this.rootFile.file.xml, query: "numFmt" }, (numFmtElement) => { return this.extractNumFormats(numFmtElement); }); } extractNumFormats(numFmtElement) { return { id: this.extractAttr(numFmtElement, "numFmtId", { required: true, }).asNum(), format: this.extractAttr(numFmtElement, "formatCode", { required: true, default: "", }).asString(), }; } getFonts() { return this.mapOnElements({ parent: this.rootFile.file.xml, query: "font" }, (font) => { return this.extractFont(font); }); } extractFont(fontElement) { const name = this.extractChildAttr(fontElement, "name", "val", { default: "Arial", }).asString(); const size = this.extractChildAttr(fontElement, "sz", "val", { default: DEFAULT_FONT_SIZE.toString(), }).asNum(); const color = this.extractColor(this.querySelector(fontElement, `color`), this.theme); // The behavior for these is kinda strange. The text is italic if there is either a "italic" tag with no "val" // attribute, or a tag with a "val" attribute = "1" (boolean). const italicElement = this.querySelector(fontElement, `i`) || undefined; const italic = italicElement && italicElement.attributes["val"]?.value !== "0"; const boldElement = this.querySelector(fontElement, `b`) || undefined; const bold = boldElement && boldElement.attributes["val"]?.value !== "0"; const strikeElement = this.querySelector(fontElement, `strike`) || undefined; const strike = strikeElement && strikeElement.attributes["val"]?.value !== "0"; const underlineElement = this.querySelector(fontElement, `u`) || undefined; const underline = underlineElement && underlineElement.attributes["val"]?.value !== "none"; return { name, size, color, italic, bold, underline, strike }; } getFills() { return this.mapOnElements({ parent: this.rootFile.file.xml, query: "fill" }, (fillElement) => { return this.extractFill(fillElement); }); } extractFill(fillElement) { // Fills are either patterns of gradients const fillChild = fillElement.children[0]; if (fillChild.tagName === "patternFill") { return { patternType: fillChild.attributes["patternType"]?.value, bgColor: this.extractColor(this.querySelector(fillChild, "bgColor"), this.theme), fgColor: this.extractColor(this.querySelector(fillChild, "fgColor"), this.theme), }; } else { // We don't support gradients. Take the second gradient color as fill color return { patternType: "solid", fgColor: this.extractColor(this.querySelectorAll(fillChild, "color")[1], this.theme), }; } } getBorders() { return this.mapOnElements({ parent: this.rootFile.file.xml, query: "border" }, (borderElement) => { return this.extractBorder(borderElement); }); } extractBorder(borderElement) { const border = { left: this.extractSingleBorder(borderElement, "left", this.theme), right: this.extractSingleBorder(borderElement, "right", this.theme), top: this.extractSingleBorder(borderElement, "top", this.theme), bottom: this.extractSingleBorder(borderElement, "bottom", this.theme), diagonal: this.extractSingleBorder(borderElement, "diagonal", this.theme), }; if (border.diagonal) { border.diagonalUp = this.extractAttr(borderElement, "diagonalUp")?.asBool(); border.diagonalDown = this.extractAttr(borderElement, "diagonalDown")?.asBool(); } return border; } extractSingleBorder(borderElement, direction, theme) { const directionElement = this.querySelector(borderElement, direction); if (!directionElement || !directionElement.attributes["style"]) return undefined; return { style: this.extractAttr(directionElement, "style", { required: true, default: "thin", }).asString(), color: this.extractColor(directionElement.children[0], theme, "000000"), }; } extractAlignment(alignmentElement) { return { horizontal: this.extractAttr(alignmentElement, "horizontal", { default: "general", }).asString(), vertical: this.extractAttr(alignmentElement, "vertical", { default: "bottom", }).asString(), textRotation: this.extractAttr(alignmentElement, "textRotation")?.asNum(), wrapText: this.extractAttr(alignmentElement, "wrapText")?.asBool(), indent: this.extractAttr(alignmentElement, "indent")?.asNum(), relativeIndent: this.extractAttr(alignmentElement, "relativeIndent")?.asNum(), justifyLastLine: this.extractAttr(alignmentElement, "justifyLastLine")?.asBool(), shrinkToFit: this.extractAttr(alignmentElement, "shrinkToFit")?.asBool(), readingOrder: this.extractAttr(alignmentElement, "readingOrder")?.asNum(), }; } getDxfs() { return this.mapOnElements({ query: "dxf", parent: this.rootFile.file.xml }, (dxfElement) => { const fontElement = this.querySelector(dxfElement, "font"); const fillElement = this.querySelector(dxfElement, "fill"); const borderElement = this.querySelector(dxfElement, "border"); const numFmtElement = this.querySelector(dxfElement, "numFmt"); const alignmentElement = this.querySelector(dxfElement, "alignment"); return { font: fontElement ? this.extractFont(fontElement) : undefined, fill: fillElement ? this.extractFill(fillElement) : undefined, numFmt: numFmtElement ? this.extractNumFormats(numFmtElement) : undefined, alignment: alignmentElement ? this.extractAlignment(alignmentElement) : undefined, border: borderElement ? this.extractBorder(borderElement) : undefined, }; }); } getStyles() { return this.mapOnElements({ query: "cellXfs xf", parent: this.rootFile.file.xml }, (styleElement) => { const alignmentElement = this.querySelector(styleElement, "alignment"); return { fontId: this.extractAttr(styleElement, "fontId", { required: true, default: 0, }).asNum(), fillId: this.extractAttr(styleElement, "fillId", { required: true, default: 0, }).asNum(), borderId: this.extractAttr(styleElement, "borderId", { required: true, default: 0, }).asNum(), numFmtId: this.extractAttr(styleElement, "numFmtId", { required: true, default: 0, }).asNum(), alignment: alignmentElement ? this.extractAlignment(alignmentElement) : undefined, }; }); } } class XlsxExternalBookExtractor extends XlsxBaseExtractor { getExternalBook() { return this.mapOnElements({ parent: this.rootFile.file.xml, query: "externalBook" }, (bookElement) => { return { rId: this.extractAttr(bookElement, "r:id", { required: true }).asString(), sheetNames: this.mapOnElements({ parent: bookElement, query: "sheetName" }, (sheetNameElement) => { return this.extractAttr(sheetNameElement, "val", { required: true }).asString(); }), datasets: this.extractExternalSheetData(bookElement), }; })[0]; } extractExternalSheetData(externalBookElement) { return this.mapOnElements({ parent: externalBookElement, query: "sheetData" }, (sheetDataElement) => { const cellsData = this.mapOnElements({ parent: sheetDataElement, query: "cell" }, (cellElement) => { return { xc: this.extractAttr(cellElement, "r", { required: true }).asString(), value: this.extractChildTextContent(cellElement, "v", { required: true }), }; }); const dataMap = {}; for (let cell of cellsData) { dataMap[cell.xc] = cell.value; } return { sheetId: this.extractAttr(sheetDataElement, "sheetId", { required: true }).asNum(), data: dataMap, }; }); } } /** * Return all the xmls converted to XLSXImportFile corresponding to the given content type. */ function getXLSXFilesOfType(contentType, xmls) { const paths = getPathsOfContent(contentType, xmls); return getXlsxFile(paths, xmls); } /** * From an array of file path, return the equivalents XLSXFiles. An XLSX File is composed of an XML, * and optionally of a relationships XML. */ function getXlsxFile(files, xmls) { const ret = []; for (let file of files) { const rels = getRelationFile(file, xmls); ret.push({ file: { fileName: file, xml: xmls[file] }, rels: rels ? { fileName: rels, xml: xmls[rels] } : undefined, }); } return ret; } /** * Return all the path of the files in a XLSX directory that have content of the given type. */ function getPathsOfContent(contentType, xmls) { const xml = xmls[CONTENT_TYPES_FILE]; const sheetItems = xml.querySelectorAll(`Override[ContentType="${contentType}"]`); const paths = []; for (let item of sheetItems) { const file = item?.attributes["PartName"].value; paths.push(file.substring(1)); // Remove the heading "/" } return paths; } /** * Get the corresponding relationship file for a given xml file in a XLSX directory. */ function getRelationFile(file, xmls) { if (file === CONTENT_TYPES_FILE) { return "_rels/.rels"; } let relsFile = ""; const pathParts = file.split("/"); for (let i = 0; i < pathParts.length - 1; i++) { relsFile += pathParts[i] + "/"; } relsFile += "_rels/"; relsFile += pathParts[pathParts.length - 1] + ".rels"; if (!xmls[relsFile]) { relsFile = undefined; } return relsFile; } const EXCEL_IMPORT_VERSION = 21; class XlsxReader { warningManager; xmls; images; constructor(files) { this.warningManager = new XLSXImportWarningManager(); this.xmls = {}; this.images = []; for (let key of Object.keys(files)) { // Random files can be in xlsx (like a bin file for printer settings) if (key.endsWith(".xml") || key.endsWith(".rels")) { const contentString = escapeTagNamespaces(files[key]); this.xmls[key] = parseXML(new XMLString(contentString)); } else if (key.includes("media/image")) { this.images.push({ fileName: key, imageSrc: files[key]["imageSrc"], }); } } } convertXlsx() { const xlsxData = this.getXlsxData(); const convertedData = this.convertImportedData(xlsxData); return convertedData; } // --------------------------------------------------------------------------- // Parsing XMLs // --------------------------------------------------------------------------- getXlsxData() { const xlsxFileStructure = this.buildXlsxFileStructure(); const theme = xlsxFileStructure.theme ? new XlsxMiscExtractor(xlsxFileStructure.theme, xlsxFileStructure, this.warningManager).getTheme() : undefined; const sharedStrings = xlsxFileStructure.sharedStrings ? new XlsxMiscExtractor(xlsxFileStructure.sharedStrings, xlsxFileStructure, this.warningManager).getSharedStrings() : []; // Sort sheets by file name : the sheets will always be named sheet1.xml, sheet2.xml, ... in order const sheets = xlsxFileStructure.sheets .sort((a, b) => a.file.fileName.localeCompare(b.file.fileName, undefined, { numeric: true })) .map((sheetFile) => { return new XlsxSheetExtractor(sheetFile, xlsxFileStructure, this.warningManager, theme).getSheet(); }); const externalBooks = xlsxFileStructure.externalLinks.map((externalLinkFile) => { return new XlsxExternalBookExtractor(externalLinkFile, xlsxFileStructure, this.warningManager).getExternalBook(); }); const styleExtractor = new XlsxStyleExtractor(xlsxFileStructure, this.warningManager, theme); return { fonts: styleExtractor.getFonts(), fills: styleExtractor.getFills(), borders: styleExtractor.getBorders(), dxfs: styleExtractor.getDxfs(), numFmts: styleExtractor.getNumFormats(), styles: styleExtractor.getStyles(), sheets: sheets, sharedStrings, externalBooks, }; } buildXlsxFileStructure() { const xlsxFileStructure = { sheets: getXLSXFilesOfType(CONTENT_TYPES.sheet, this.xmls), workbook: getXLSXFilesOfType(CONTENT_TYPES.workbook, this.xmls)[0], styles: getXLSXFilesOfType(CONTENT_TYPES.styles, this.xmls)[0], sharedStrings: getXLSXFilesOfType(CONTENT_TYPES.sharedStrings, this.xmls)[0], theme: getXLSXFilesOfType(CONTENT_TYPES.themes, this.xmls)[0], charts: getXLSXFilesOfType(CONTENT_TYPES.chart, this.xmls), figures: getXLSXFilesOfType(CONTENT_TYPES.drawing, this.xmls), tables: getXLSXFilesOfType(CONTENT_TYPES.table, this.xmls), pivots: getXLSXFilesOfType(CONTENT_TYPES.pivot, this.xmls), externalLinks: getXLSXFilesOfType(CONTENT_TYPES.externalLink, this.xmls), images: this.images, }; if (!xlsxFileStructure.workbook.rels) { throw Error(_t("Cannot find workbook relations file")); } return xlsxFileStructure; } // --------------------------------------------------------------------------- // Conversion // --------------------------------------------------------------------------- convertImportedData(data) { const convertedData = { version: EXCEL_IMPORT_VERSION, sheets: convertSheets(data, this.warningManager), styles: convertStyles(data, this.warningManager), formats: convertFormats(data, this.warningManager), borders: convertBorders(data, this.warningManager), revisionId: DEFAULT_REVISION_ID, }; convertTables(convertedData, data); // Remove falsy attributes in styles. Not mandatory, but make objects more readable when debugging Object.keys(data.styles).map((key) => { data.styles[key] = removeFalsyAttributes(data.styles[key]); }); return convertedData; } } /** * parses a formula (as a string) into the same formula, * but with the references to other cells extracted * * =sum(a3:b1) + c3 --> =sum(|0|) + |1| * * @param formula */ function normalizeV9(formula) { const tokens = rangeTokenize(formula); let dependencies = []; let noRefFormula = "".concat(...tokens.map((token) => { if (token.type === "REFERENCE" && cellReference.test(token.value)) { const value = token.value.trim(); if (!dependencies.includes(value)) { dependencies.push(value); } return `${FORMULA_REF_IDENTIFIER}${dependencies.indexOf(value)}${FORMULA_REF_IDENTIFIER}`; } else { return token.value; } })); return { text: noRefFormula, dependencies }; } // FIXME: Remove this map when Firefox supports getWeekInfo // https:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/getWeekInfo const WEEK_START = { am_ET: 7, ar_001: 6, ar_SY: 6, az_AZ: 1, eu_ES: 1, be_BY: 1, bn_IN: 1, bs_BA: 1, bg_BG: 1, ca_ES: 1, zh_CN: 7, zh_HK: 7, zh_TW: 7, hr_HR: 1, cs_CZ: 1, da_DK: 1, nl_BE: 1, nl_NL: 1, en_AU: 7, en_CA: 7, en_GB: 1, en_IN: 7, en_NZ: 7, et_EE: 1, fi_FI: 1, fr_BE: 1, fr_CA: 7, fr_CH: 1, fr_FR: 1, gl_ES: 1, ka_GE: 1, de_DE: 1, de_CH: 1, el_GR: 1, gu_IN: 7, he_IL: 7, hi_IN: 7, hu_HU: 1, id_ID: 7, it_IT: 1, ja_JP: 7, kab_DZ: 6, km_KH: 7, ko_KP: 1, ko_KR: 7, lo_LA: 7, lv_LV: 1, lt_LT: 1, lb_LU: 1, mk_MK: 1, ml_IN: 1, mn_MN: 7, ms_MY: 1, nb_NO: 1, fa_IR: 6, pl_PL: 1, pt_AO: 1, pt_BR: 7, pt_PT: 1, ro_RO: 1, ru_RU: 1, sr_RS: 7, "sr@latin": 7, sk_SK: 1, sl_SI: 1, es_AR: 7, es_BO: 1, es_CL: 1, es_CO: 7, es_CR: 1, es_DO: 1, es_EC: 1, es_GT: 7, es_MX: 7, es_PA: 7, es_PE: 7, es_PY: 7, es_UY: 1, es_VE: 7, sw: 1, sv_SE: 1, th_TH: 7, tl_PH: 1, tr_TR: 1, uk_UA: 1, vi_VN: 1, sq_AL: 1, te_IN: 7, en_US: 7, my_MM: 7, es_ES: 1, es_419: 1, }; const migrationStepRegistry = new Registry(); migrationStepRegistry .add("migration_1", { // add the `activeSheet` field on data versionFrom: "1", migrate(data) { if (data.sheets && data.sheets[0]) { data.activeSheet = data.sheets[0].name; } return data; }, }) .add("migration_2", { // add an id field in each sheet versionFrom: "2", migrate(data) { if (data.sheets && data.sheets.length) { for (let sheet of data.sheets) { sheet.id = sheet.id || sheet.name; } } return data; }, }) .add("migration_3", { // activeSheet is now an id, not the name of a sheet versionFrom: "3", migrate(data) { if (data.sheets && data.activeSheet) { const activeSheet = data.sheets.find((s) => s.name === data.activeSheet); data.activeSheet = activeSheet.id; } return data; }, }) .add("migration_4", { // add figures object in each sheets versionFrom: "4", migrate(data) { for (let sheet of data.sheets || []) { sheet.figures = sheet.figures || []; } return data; }, }) .add("migration_5", { // normalize the content of the cell if it is a formula to avoid parsing all the formula that vary only by the cells they use versionFrom: "5", migrate(data) { for (let sheet of data.sheets || []) { for (let xc in sheet.cells || []) { const cell = sheet.cells[xc]; if (cell.content && cell.content.startsWith("=")) { cell.formula = normalizeV9(cell.content); } } } return data; }, }) .add("migration_6", { // transform chart data structure versionFrom: "6", migrate(data) { for (let sheet of data.sheets || []) { for (let f in sheet.figures || []) { const { dataSets, ...newData } = sheet.figures[f].data; const newDataSets = []; for (let ds of dataSets) { if (ds.labelCell) { const dataRange = toZone(ds.dataRange); const newRange = ds.labelCell + ":" + toXC(dataRange.right, dataRange.bottom); newDataSets.push(newRange); } else { newDataSets.push(ds.dataRange); } } newData.dataSetsHaveTitle = Boolean(dataSets[0].labelCell); newData.dataSets = newDataSets; sheet.figures[f].data = newData; } } return data; }, }) .add("migration_7", { // remove single quotes in sheet names versionFrom: "7", migrate(data) { const namesTaken = []; for (let sheet of data.sheets || []) { if (!sheet.name) { continue; } const oldName = sheet.name; const escapedName = sanitizeSheetName(oldName, "_"); let i = 1; let newName = escapedName; while (namesTaken.includes(newName)) { newName = `${escapedName}${i}`; i++; } sheet.name = newName; namesTaken.push(newName); const replaceName = (str) => { if (str === undefined) { return str; } // replaceAll is only available in next Typescript version let newString = str.replace(oldName, newName); let currentString = str; while (currentString !== newString) { currentString = newString; newString = currentString.replace(oldName, newName); } return currentString; }; //cells for (let xc in sheet.cells) { const cell = sheet.cells[xc]; if (cell.formula) { cell.formula.dependencies = cell.formula.dependencies.map(replaceName); } } //charts for (let figure of sheet.figures || []) { if (figure.type === "chart") { const dataSets = figure.data.dataSets.map(replaceName); const labelRange = replaceName(figure.data.labelRange); figure.data = { ...figure.data, dataSets, labelRange }; } } //ConditionalFormats for (let cf of sheet.conditionalFormats || []) { cf.ranges = cf.ranges.map(replaceName); for (const thresholdName of [ "minimum", "maximum", "midpoint", "upperInflectionPoint", "lowerInflectionPoint", ]) { if (cf.rule[thresholdName]?.type === "formula") { cf.rule[thresholdName].value = replaceName(cf.rule[thresholdName].value); } } } } return data; }, }) .add("migration_8", { // transform chart data structure with design attributes versionFrom: "8", migrate(data) { for (const sheet of data.sheets || []) { for (const chart of sheet.figures || []) { chart.data.background = BACKGROUND_CHART_COLOR; chart.data.verticalAxisPosition = "left"; chart.data.legendPosition = "top"; chart.data.stacked = false; } } return data; }, }) .add("migration_9", { // de-normalize formula to reduce exported json size (~30%) versionFrom: "9", migrate(data) { for (let sheet of data.sheets || []) { for (let xc in sheet.cells || []) { const cell = sheet.cells[xc]; if (cell.formula) { let { text, dependencies } = cell.formula; for (let [index, d] of Object.entries(dependencies)) { const stringPosition = `\\${FORMULA_REF_IDENTIFIER}${index}\\${FORMULA_REF_IDENTIFIER}`; text = text.replace(new RegExp(stringPosition, "g"), d); } cell.content = text; delete cell.formula; } } } return data; }, }) .add("migration_10", { // normalize the formats of the cells versionFrom: "10", migrate(data) { const formats = {}; for (let sheet of data.sheets || []) { for (let xc in sheet.cells || []) { const cell = sheet.cells[xc]; if (cell.format) { cell.format = getItemId(cell.format, formats); } } } data.formats = formats; return data; }, }) .add("migration_11", { // Add isVisible to sheets versionFrom: "11", migrate(data) { for (let sheet of data.sheets || []) { sheet.isVisible = true; } return data; }, }) .add("migration_12", { // Fix data filter duplication versionFrom: "12", migrate(data) { return fixOverlappingFilters(data); }, }) .add("migration_12_5", { // Change Border description structure versionFrom: "12.5", migrate(data) { for (const borderId in data.borders) { const border = data.borders[borderId]; for (const position in border) { if (Array.isArray(border[position])) { border[position] = { style: border[position][0], color: border[position][1], }; } } } return data; }, }) .add("migration_13", { // Add locale to spreadsheet settings versionFrom: "13", migrate(data) { if (!data.settings) { data.settings = {}; } if (!data.settings.locale) { data.settings.locale = DEFAULT_LOCALE; } return data; }, }) .add("migration_14", { // Fix datafilter duplication (post saas-17.1) versionFrom: "14", migrate(data) { return fixOverlappingFilters(data); }, }) .add("migration_14_5", { // Rename filterTable to tables versionFrom: "14.5", migrate(data) { for (const sheetData of data.sheets || []) { sheetData.tables = sheetData.tables || sheetData.filterTables || []; delete sheetData.filterTables; } return data; }, }) .add("migration_15", { // Add pivots versionFrom: "15", migrate(data) { if (!data.pivots) { data.pivots = {}; } if (!data.pivotNextId) { data.pivotNextId = getMaxObjectId(data.pivots) + 1; } return data; }, }) .add("migration_16", { // transform chart data structure (2) versionFrom: "16", migrate(data) { for (const sheet of data.sheets || []) { for (const f in sheet.figures || []) { const figure = sheet.figures[f]; if ("title" in figure.data && typeof figure.data.title === "string") { figure.data.title = { text: figure.data.title }; } const figureType = figure.data.type; if (!["line", "bar", "pie", "scatter", "waterfall", "combo"].includes(figureType)) { continue; } const { dataSets, ...newData } = sheet.figures[f].data; const newDataSets = dataSets.map((dataRange) => ({ dataRange })); newData.dataSets = newDataSets; sheet.figures[f].data = newData; } } return data; }, }) .add("migration_17", { // Empty migration to allow external modules to add their own migration steps // before this version versionFrom: "17", migrate(data) { return data; }, }) .add("migration_18", { // Change measures and dimensions `name` to `fieldName` // Add id to measures versionFrom: "18", migrate(data) { for (const pivot of Object.values(data.pivots || {})) { pivot.measures = pivot.measures.map((measure) => ({ id: measure.name, //Do not set name + aggregator, to support old formulas fieldName: measure.name, aggregator: measure.aggregator, })); pivot.columns = pivot.columns.map((column) => ({ fieldName: column.name, order: column.order, granularity: column.granularity, })); pivot.rows = pivot.rows.map((row) => ({ fieldName: row.name, order: row.order, granularity: row.granularity, })); } return data; }, }) .add("migration_19", { // "Add weekStart to locale", versionFrom: "19", migrate(data) { const locale = data.settings?.locale; if (locale) { const code = locale.code; locale.weekStart = WEEK_START[code] || 1; // Default to Monday; } return data; }, }) .add("migration_20", { // group style and format into zones, versionFrom: "20", migrate(data) { for (const sheet of data.sheets || []) { sheet.styles = {}; sheet.formats = {}; sheet.borders = {}; for (const xc in sheet.cells) { sheet.styles[xc] = sheet.cells[xc].style; sheet.formats[xc] = sheet.cells[xc].format; sheet.borders[xc] = sheet.cells[xc].border; delete sheet.cells[xc].style; delete sheet.cells[xc].format; delete sheet.cells[xc].border; } } return data; }, }) .add("migration_21", { // "Add operator in gauge inflection points", versionFrom: "21", migrate(data) { for (const sheet of data.sheets || []) { for (const figure of sheet.figures || []) { if (figure.tag !== "chart" || figure.data.type !== "gauge") { continue; } const gaugeData = figure.data; if (gaugeData?.sectionRule?.lowerInflectionPoint) { gaugeData.sectionRule.lowerInflectionPoint.operator = "<="; } if (gaugeData?.sectionRule?.upperInflectionPoint) { gaugeData.sectionRule.upperInflectionPoint.operator = "<="; } } } return data; }, }); function fixOverlappingFilters(data) { for (let sheet of data.sheets || []) { let knownDataFilterZones = []; for (let filterTable of sheet.filterTables || []) { const zone = toZone(filterTable.range); // See commit message of https://github.com/odoo/o-spreadsheet/pull/3632 of more details const intersectZoneIndex = knownDataFilterZones.findIndex((knownZone) => overlap(knownZone, zone)); if (intersectZoneIndex !== -1) { knownDataFilterZones[intersectZoneIndex] = zone; } else { knownDataFilterZones.push(zone); } } sheet.filterTables = knownDataFilterZones.map((zone) => ({ range: zoneToXc(zone), })); } return data; } /** * This is the current state version number. It should be incremented each time * a breaking change is made in the way the state is handled, and an upgrade * function should be defined */ const CURRENT_VERSION = 22; const INITIAL_SHEET_ID = "Sheet1"; /** * This function tries to load anything that could look like a valid * workbookData object. It applies any migrations, if needed, and return a * current, complete workbookData object. * * It also ensures that there is at least one sheet. */ function load(data, verboseImport) { if (!data) { return createEmptyWorkbookData(); } console.debug("### Loading data ###"); const start = performance.now(); if (data["[Content_Types].xml"]) { const reader = new XlsxReader(data); data = reader.convertXlsx(); if (verboseImport) { for (let parsingError of reader.warningManager.warnings.sort()) { console.warn(parsingError); } } } // apply migrations, if needed if ("version" in data) { if (data.version < CURRENT_VERSION) { console.debug("Migrating data from version", data.version); data = migrate(data); } } data = repairData(data); console.debug("Data loaded in", performance.now() - start, "ms"); console.debug("###"); return data; } // ----------------------------------------------------------------------------- // Migrations // ----------------------------------------------------------------------------- function compareVersions(v1, v2) { const version1 = v1.split(".").map(Number); const version2 = v2.split(".").map(Number); for (let i = 0; i < Math.max(version1.length, version2.length); i++) { const part1 = version1[i] || 0; const part2 = version2[i] || 0; if (part1 > part2) { return 1; } if (part1 < part2) { return -1; } } return 0; } function migrate(data) { const start = performance.now(); const steps = migrationStepRegistry .getAll() .sort((a, b) => compareVersions(a.versionFrom, b.versionFrom)); const index = steps.findIndex((step) => step.versionFrom === data.version.toString()); for (let i = index; i < steps.length; i++) { data = steps[i].migrate(data); } console.debug("Data migrated in", performance.now() - start, "ms"); return data; } /** * This function is used to repair faulty data independently of the migration. */ function repairData(data) { data = forceUnicityOfFigure(data); data = setDefaults(data); return data; } /** * Force the unicity of figure ids accross sheets */ function forceUnicityOfFigure(data) { if (data.uniqueFigureIds) { return data; } const figureIds = new Set(); const uuidGenerator = new UuidGenerator(); for (const sheet of data.sheets || []) { for (const figure of sheet.figures || []) { if (figureIds.has(figure.id)) { figure.id += uuidGenerator.uuidv4(); } figureIds.add(figure.id); } } data.uniqueFigureIds = true; return data; } /** * sanity check: try to fix missing fields/corrupted state by providing * sensible default values */ function setDefaults(partialData) { const data = Object.assign(createEmptyWorkbookData(), partialData, { version: CURRENT_VERSION, }); data.sheets = data.sheets ? data.sheets.map((s, i) => Object.assign(createEmptySheet(`Sheet${i + 1}`, `Sheet${i + 1}`), s)) : []; if (data.sheets.length === 0) { data.sheets.push(createEmptySheet(INITIAL_SHEET_ID, "Sheet1")); } if (!isValidLocale(data.settings.locale)) { data.settings.locale = DEFAULT_LOCALE; } return data; } /** * The goal of this function is to repair corrupted/wrong initial messages caused by * a bug. * The bug should obviously be fixed, but it's too late for existing spreadsheet. */ function repairInitialMessages(data, initialMessages) { initialMessages = fixTranslatedSheetIds(data, initialMessages); initialMessages = dropCommands(initialMessages, "SORT_CELLS"); initialMessages = dropCommands(initialMessages, "SET_DECIMAL"); initialMessages = fixChartDefinitions(data, initialMessages); return initialMessages; } /** * When the workbook data is originally empty, a new one is generated on-the-fly. * A bug caused the sheet id to be non-deterministic. The sheet id was propagated in * commands. * This function repairs initial commands with a wrong sheetId. */ function fixTranslatedSheetIds(data, initialMessages) { // the fix is only needed when the workbook is generated on-the-fly if (Object.keys(data).length !== 0) { return initialMessages; } const sheetIds = []; const messages = []; const fixSheetId = (cmd) => { if (cmd.type === "CREATE_SHEET") { sheetIds.push(cmd.sheetId); } else if ("sheetId" in cmd && !sheetIds.includes(cmd.sheetId)) { return { ...cmd, sheetId: INITIAL_SHEET_ID }; } return cmd; }; for (const message of initialMessages) { if (message.type === "REMOTE_REVISION") { messages.push({ ...message, commands: message.commands.map(fixSheetId), }); } else { messages.push(message); } } return messages; } function dropCommands(initialMessages, commandType) { const messages = []; for (const message of initialMessages) { if (message.type === "REMOTE_REVISION") { messages.push({ ...message, commands: message.commands.filter((command) => command.type !== commandType), }); } else { messages.push(message); } } return messages; } function fixChartDefinitions(data, initialMessages) { const messages = []; const map = {}; for (const sheet of data.sheets || []) { sheet.figures?.forEach((figure) => { if (figure.tag === "chart") { // chart definition map[figure.id] = figure.data; } }); } for (const message of initialMessages) { if (message.type === "REMOTE_REVISION") { const commands = []; for (const cmd of message.commands) { let command = cmd; switch (cmd.type) { case "CREATE_CHART": map[cmd.id] = cmd.definition; break; case "UPDATE_CHART": if (!map[cmd.id]) { /** the chart does not exist on the map, it might have been created after a duplicate sheet. * We don't have access to the definition, so we skip the command. */ console.log(`Fix chart definition: chart with id ${cmd.id} not found.`); continue; } const definition = map[cmd.id]; const newDefinition = { ...definition, ...cmd.definition }; command = { ...cmd, definition: newDefinition }; map[cmd.id] = newDefinition; break; } commands.push(command); } messages.push({ ...message, commands, }); } else { messages.push(message); } } return messages; } // ----------------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------------- function createEmptySheet(sheetId, name) { return { id: sheetId, name, colNumber: 26, rowNumber: 100, cells: {}, styles: {}, formats: {}, borders: {}, cols: {}, rows: {}, merges: [], conditionalFormats: [], figures: [], tables: [], isVisible: true, }; } function createEmptyWorkbookData(sheetName = "Sheet1") { const data = { version: CURRENT_VERSION, sheets: [createEmptySheet(INITIAL_SHEET_ID, sheetName)], styles: {}, formats: {}, borders: {}, revisionId: DEFAULT_REVISION_ID, uniqueFigureIds: true, settings: { locale: DEFAULT_LOCALE }, pivots: {}, pivotNextId: 1, customTableStyles: {}, }; return data; } function createEmptyExcelSheet(sheetId, name) { return { ...createEmptySheet(sheetId, name), charts: [], images: [], }; } function createEmptyExcelWorkbookData() { return { ...createEmptyWorkbookData(), sheets: [createEmptyExcelSheet(INITIAL_SHEET_ID, "Sheet1")], }; } const PasteInteractiveContent = { wrongPasteSelection: _t("This operation is not allowed with multiple selections."), willRemoveExistingMerge: _t("This operation is not possible due to a merge. Please remove the merges first than try again."), wrongFigurePasteOption: _t("Cannot do a special paste of a figure."), frozenPaneOverlap: _t("This operation is not allowed due to an overlapping frozen pane."), }; function handlePasteResult(env, result) { if (!result.isSuccessful) { if (result.reasons.includes("WrongPasteSelection" /* CommandResult.WrongPasteSelection */)) { env.raiseError(PasteInteractiveContent.wrongPasteSelection); } else if (result.reasons.includes("WillRemoveExistingMerge" /* CommandResult.WillRemoveExistingMerge */)) { env.raiseError(PasteInteractiveContent.willRemoveExistingMerge); } else if (result.reasons.includes("WrongFigurePasteOption" /* CommandResult.WrongFigurePasteOption */)) { env.raiseError(PasteInteractiveContent.wrongFigurePasteOption); } else if (result.reasons.includes("FrozenPaneOverlap" /* CommandResult.FrozenPaneOverlap */)) { env.raiseError(PasteInteractiveContent.frozenPaneOverlap); } } } function interactivePaste(env, target, pasteOption) { const result = env.model.dispatch("PASTE", { target, pasteOption }); handlePasteResult(env, result); } function interactivePasteFromOS(env, target, clipboardContent, pasteOption) { let result; // We do not trust the clipboard content to be accurate and comprehensive. // Therefore, to ensure reliability, we handle unexpected errors that may // arise from content that would not be suitable for the current version. try { result = env.model.dispatch("PASTE_FROM_OS_CLIPBOARD", { target, clipboardContent, pasteOption, }); } catch (error) { const parsedSpreadsheetContent = clipboardContent.data; if (parsedSpreadsheetContent?.version !== CURRENT_VERSION) { env.raiseError(_t("An unexpected error occurred while pasting content.\ This is probably due to a spreadsheet version mismatch.")); } result = env.model.dispatch("PASTE_FROM_OS_CLIPBOARD", { target, clipboardContent: { text: clipboardContent.text, }, pasteOption, }); } handlePasteResult(env, result); } const CfTerms = { Errors: { ["InvalidRange" /* CommandResult.InvalidRange */]: _t("The range is invalid"), ["FirstArgMissing" /* CommandResult.FirstArgMissing */]: _t("The argument is missing. Please provide a value"), ["SecondArgMissing" /* CommandResult.SecondArgMissing */]: _t("The second argument is missing. Please provide a value"), ["MinNaN" /* CommandResult.MinNaN */]: _t("The minpoint must be a number"), ["MidNaN" /* CommandResult.MidNaN */]: _t("The midpoint must be a number"), ["MaxNaN" /* CommandResult.MaxNaN */]: _t("The maxpoint must be a number"), ["ValueUpperInflectionNaN" /* CommandResult.ValueUpperInflectionNaN */]: _t("The first value must be a number"), ["ValueLowerInflectionNaN" /* CommandResult.ValueLowerInflectionNaN */]: _t("The second value must be a number"), ["MinBiggerThanMax" /* CommandResult.MinBiggerThanMax */]: _t("Minimum must be smaller then Maximum"), ["MinBiggerThanMid" /* CommandResult.MinBiggerThanMid */]: _t("Minimum must be smaller then Midpoint"), ["MidBiggerThanMax" /* CommandResult.MidBiggerThanMax */]: _t("Midpoint must be smaller then Maximum"), ["LowerBiggerThanUpper" /* CommandResult.LowerBiggerThanUpper */]: _t("Lower inflection point must be smaller than upper inflection point"), ["MinInvalidFormula" /* CommandResult.MinInvalidFormula */]: _t("Invalid Minpoint formula"), ["MaxInvalidFormula" /* CommandResult.MaxInvalidFormula */]: _t("Invalid Maxpoint formula"), ["MidInvalidFormula" /* CommandResult.MidInvalidFormula */]: _t("Invalid Midpoint formula"), ["ValueUpperInvalidFormula" /* CommandResult.ValueUpperInvalidFormula */]: _t("Invalid upper inflection point formula"), ["ValueLowerInvalidFormula" /* CommandResult.ValueLowerInvalidFormula */]: _t("Invalid lower inflection point formula"), ["EmptyRange" /* CommandResult.EmptyRange */]: _t("A range needs to be defined"), ["ValueCellIsInvalidFormula" /* CommandResult.ValueCellIsInvalidFormula */]: _t("At least one of the provided values is an invalid formula"), Unexpected: _t("The rule is invalid for an unknown reason"), }, ColorScale: _t("Color scale"), IconSet: _t("Icon set"), DataBar: _t("Data bar"), }; const CellIsOperators = { IsEmpty: _t("Is empty"), IsNotEmpty: _t("Is not empty"), ContainsText: _t("Contains"), NotContains: _t("Does not contain"), BeginsWith: _t("Starts with"), EndsWith: _t("Ends with"), Equal: _t("Is equal to"), NotEqual: _t("Is not equal to"), GreaterThan: _t("Is greater than"), GreaterThanOrEqual: _t("Is greater than or equal to"), LessThan: _t("Is less than"), LessThanOrEqual: _t("Is less than or equal to"), Between: _t("Is between"), NotBetween: _t("Is not between"), }; const ChartTerms = { Series: _t("Series"), BackgroundColor: _t("Background color"), StackedBarChart: _t("Stacked bar chart"), StackedLineChart: _t("Stacked line chart"), StackedAreaChart: _t("Stacked area chart"), StackedColumnChart: _t("Stacked column chart"), CumulativeData: _t("Cumulative data"), TreatLabelsAsText: _t("Treat labels as text"), AggregatedChart: _t("Aggregate"), Errors: { Unexpected: _t("The chart definition is invalid for an unknown reason"), // BASIC CHART ERRORS (LINE | BAR | PIE) ["InvalidDataSet" /* CommandResult.InvalidDataSet */]: _t("The dataset is invalid"), ["InvalidLabelRange" /* CommandResult.InvalidLabelRange */]: _t("Labels are invalid"), // SCORECARD CHART ERRORS ["InvalidScorecardKeyValue" /* CommandResult.InvalidScorecardKeyValue */]: _t("The key value is invalid"), ["InvalidScorecardBaseline" /* CommandResult.InvalidScorecardBaseline */]: _t("The baseline value is invalid"), // GAUGE CHART ERRORS ["InvalidGaugeDataRange" /* CommandResult.InvalidGaugeDataRange */]: _t("The data range is invalid"), ["EmptyGaugeRangeMin" /* CommandResult.EmptyGaugeRangeMin */]: _t("A minimum range limit value is needed"), ["GaugeRangeMinNaN" /* CommandResult.GaugeRangeMinNaN */]: _t("The minimum range limit value must be a number"), ["EmptyGaugeRangeMax" /* CommandResult.EmptyGaugeRangeMax */]: _t("A maximum range limit value is needed"), ["GaugeRangeMaxNaN" /* CommandResult.GaugeRangeMaxNaN */]: _t("The maximum range limit value must be a number"), ["GaugeRangeMinBiggerThanRangeMax" /* CommandResult.GaugeRangeMinBiggerThanRangeMax */]: _t("Minimum range limit must be smaller than maximum range limit"), ["GaugeLowerInflectionPointNaN" /* CommandResult.GaugeLowerInflectionPointNaN */]: _t("The lower inflection point value must be a number"), ["GaugeUpperInflectionPointNaN" /* CommandResult.GaugeUpperInflectionPointNaN */]: _t("The upper inflection point value must be a number"), }, }; const CustomCurrencyTerms = { Custom: _t("Custom"), }; const MergeErrorMessage = _t("Merged cells are preventing this operation. Unmerge those cells and try again."); const SplitToColumnsTerms = { Errors: { Unexpected: _t("Cannot split the selection for an unknown reason"), ["NoSplitSeparatorInSelection" /* CommandResult.NoSplitSeparatorInSelection */]: _t("There is no match for the selected separator in the selection"), ["MoreThanOneColumnSelected" /* CommandResult.MoreThanOneColumnSelected */]: _t("Only a selection from a single column can be split"), ["SplitWillOverwriteContent" /* CommandResult.SplitWillOverwriteContent */]: _t("Splitting will overwrite existing content"), }, }; const RemoveDuplicateTerms = { Errors: { Unexpected: _t("Cannot remove duplicates for an unknown reason"), ["MoreThanOneRangeSelected" /* CommandResult.MoreThanOneRangeSelected */]: _t("Please select only one range of cells"), ["EmptyTarget" /* CommandResult.EmptyTarget */]: _t("Please select a range of cells containing values."), ["NoColumnsProvided" /* CommandResult.NoColumnsProvided */]: _t("Please select at latest one column to analyze."), //TODO: Remove it when accept to copy and paste merge cells ["WillRemoveExistingMerge" /* CommandResult.WillRemoveExistingMerge */]: PasteInteractiveContent.willRemoveExistingMerge, }, }; const DVTerms = { DateIs: { today: _t("today"), yesterday: _t("yesterday"), tomorrow: _t("tomorrow"), lastWeek: _t("in the past week"), lastMonth: _t("in the past month"), lastYear: _t("in the past year"), }, DateIsBefore: { today: _t("today"), yesterday: _t("yesterday"), tomorrow: _t("tomorrow"), lastWeek: _t("one week ago"), lastMonth: _t("one month ago"), lastYear: _t("one year ago"), }, CriterionError: { notEmptyValue: _t("The value must not be empty"), numberValue: _t("The value must be a number"), dateValue: _t("The value must be a date"), validRange: _t("The value must be a valid range"), }, }; const TableTerms = { Errors: { Unexpected: _t("The table zone is invalid for an unknown reason"), ["TableOverlap" /* CommandResult.TableOverlap */]: _t("You cannot create overlapping tables."), ["NonContinuousTargets" /* CommandResult.NonContinuousTargets */]: _t("A table can only be created on a continuous selection."), ["InvalidRange" /* CommandResult.InvalidRange */]: _t("The range is invalid"), ["TargetOutOfSheet" /* CommandResult.TargetOutOfSheet */]: _t("The range is out of the sheet"), }, Checkboxes: { hasFilters: _t("Filter button"), headerRow: _t("Header row(s)"), bandedRows: _t("Banded rows"), firstColumn: _t("First column"), lastColumn: _t("Last column"), bandedColumns: _t("Banded columns"), automaticAutofill: _t("Automatically autofill formulas"), totalRow: _t("Total row"), isDynamic: _t("Auto-adjust to formula result"), }, Tooltips: { filterWithoutHeader: _t("Cannot have filters without a header row"), isDynamic: _t("For tables based on array formulas only"), }, }; const measureDisplayTerms = { labels: { no_calculations: _t("No calculations"), "%_of_grand_total": _t("% of grand total"), "%_of_col_total": _t("% of column total"), "%_of_row_total": _t("% of row total"), "%_of": _t("% of"), "%_of_parent_row_total": _t("% of parent row total"), "%_of_parent_col_total": _t("% of parent column total"), "%_of_parent_total": _t("% of parent total"), difference_from: _t("Difference from"), "%_difference_from": _t("% difference from"), running_total: _t("Running total"), "%_running_total": _t("% Running total"), rank_asc: _t("Rank smallest to largest"), rank_desc: _t("Rank largest to smallest"), index: _t("Index"), }, descriptions: { "%_of_grand_total": () => _t("Displayed as % of grand total"), "%_of_col_total": () => _t("Displayed as % of column total"), "%_of_row_total": () => _t("Displayed as % of row total"), "%_of": (field) => _t('Displayed as % of "%s"', field), "%_of_parent_row_total": (field) => _t('Displayed as % of parent row total of "%s"', field), "%_of_parent_col_total": () => _t("Displayed as % of parent column total"), "%_of_parent_total": (field) => _t('Displayed as % of parent "%s" total', field), difference_from: (field) => _t('Displayed as difference from "%s"', field), "%_difference_from": (field) => _t('Displayed as % difference from "%s"', field), running_total: (field) => _t('Displayed as running total based on "%s"', field), "%_running_total": (field) => _t('Displayed as % running total based on "%s"', field), rank_asc: (field) => _t('Displayed as rank from smallest to largest based on "%s"', field), rank_desc: (field) => _t('Displayed as rank largest to smallest based on "%s"', field), index: () => _t("Displayed as index"), }, documentation: { no_calculations: _t("Displays the value that is entered in the field."), "%_of_grand_total": _t("Displays values as a percentage of the grand total of all the values or data points in the report."), "%_of_col_total": _t("Displays all the values in each column or series as a percentage of the total for the column or series."), "%_of_row_total": _t("Displays the value in each row or category as a percentage of the total for the row or category."), "%_of": _t("Displays values as a percentage of the value of the Base item in the Base field."), "%_of_parent_row_total": _t("Calculates values as follows:\n(value for the item) / (value for the parent item on rows)"), "%_of_parent_col_total": _t("Calculates values as follows:\n(value for the item) / (value for the parent item on columns)"), "%_of_parent_total": _t("Calculates values as follows:\n(value for the item) / (value for the parent item of the selected Base field)"), difference_from: _t("Displays values as the difference from the value of the Base item in the Base field."), "%_difference_from": _t("Displays values as the percentage difference from the value of the Base item in the Base field."), running_total: _t("Displays the value for successive items in the Base field as a running total."), "%_running_total": _t("Calculates the value as a percentage for successive items in the Base field that are displayed as a running total."), rank_asc: _t("Displays the rank of selected values in a specific field, listing the smallest item in the field as 1, and each larger value with a higher rank value."), rank_desc: _t("Displays the rank of selected values in a specific field, listing the largest item in the field as 1, and each smaller value with a higher rank value."), index: _t("Calculates values as follows:\n((value in cell) x (Grand Total of Grand Totals)) / ((Grand Row Total) x (Grand Column Total))"), }, }; /** * This file contains helpers that are common to different runtime charts (mainly * line, bar and pie charts) */ /** * Get the data from a dataSet */ function getData(getters, ds) { if (ds.dataRange) { const labelCellZone = ds.labelCell ? [ds.labelCell.zone] : []; const dataZone = recomputeZones([ds.dataRange.zone], labelCellZone)[0]; if (dataZone === undefined) { return []; } const dataRange = getters.getRangeFromZone(ds.dataRange.sheetId, dataZone); return getters.getRangeValues(dataRange).map((value) => (value === "" ? undefined : value)); } return []; } function filterEmptyDataPoints(labels, datasets) { const numberOfDataPoints = Math.max(labels.length, ...datasets.map((dataset) => dataset.data?.length || 0)); const dataPointsIndexes = range(0, numberOfDataPoints).filter((dataPointIndex) => { const label = labels[dataPointIndex]; const values = datasets.map((dataset) => dataset.data?.[dataPointIndex]); return label || values.some((value) => value === 0 || Boolean(value)); }); return { labels: dataPointsIndexes.map((i) => labels[i] || ""), dataSetsValues: datasets.map((dataset) => ({ ...dataset, data: dataPointsIndexes.map((i) => dataset.data[i]), })), }; } /** * Aggregates data based on labels */ function aggregateDataForLabels(labels, datasets) { const parseNumber = (value) => (typeof value === "number" ? value : 0); const labelSet = new Set(labels); const labelMap = {}; labelSet.forEach((label) => { labelMap[label] = new Array(datasets.length).fill(0); }); for (const indexOfLabel of range(0, labels.length)) { const label = labels[indexOfLabel]; for (const indexOfDataset of range(0, datasets.length)) { labelMap[label][indexOfDataset] += parseNumber(datasets[indexOfDataset].data[indexOfLabel]); } } return { labels: Array.from(labelSet), dataSetsValues: datasets.map((dataset, indexOfDataset) => ({ ...dataset, data: Array.from(labelSet).map((label) => labelMap[label][indexOfDataset]), })), }; } function truncateLabel(label) { if (!label) { return ""; } if (label.length > MAX_CHAR_LABEL) { return label.substring(0, MAX_CHAR_LABEL) + "…"; } return label; } /** * Get a default chart js configuration */ function getDefaultChartJsRuntime(chart, labels, fontColor, { format, locale, truncateLabels = true, horizontalChart, }) { const chartTitle = chart.title.text ? chart.title : { ...chart.title, content: "" }; const options = { // https://www.chartjs.org/docs/latest/general/responsive.html responsive: true, // will resize when its container is resized maintainAspectRatio: false, // doesn't maintain the aspect ration (width/height =2 by default) so the user has the choice of the exact layout layout: { padding: { left: DEFAULT_CHART_PADDING, right: DEFAULT_CHART_PADDING, top: chartTitle.text ? DEFAULT_CHART_PADDING / 2 : DEFAULT_CHART_PADDING + 5, bottom: DEFAULT_CHART_PADDING, }, }, elements: { line: { fill: false, // do not fill the area under line charts }, point: { hitRadius: 15, // increased hit radius to display point tooltip when hovering nearby }, }, animation: false, plugins: { title: { display: !!chartTitle.text, text: _t(chartTitle.text), color: chartTitle?.color ?? fontColor, align: chartTitle.align === "center" ? "center" : chartTitle.align === "right" ? "end" : "start", font: { size: DEFAULT_CHART_FONT_SIZE, weight: chartTitle.bold ? "bold" : "normal", style: chartTitle.italic ? "italic" : "normal", }, }, legend: { // Disable default legend onClick (show/hide dataset), to allow us to set a global onClick on the chart container. // If we want to re-enable this in the future, we need to override the default onClick to stop the event propagation onClick: () => { }, }, tooltip: { callbacks: { label: function (tooltipItem) { const xLabel = tooltipItem.dataset?.label || tooltipItem.label; // tooltipItem.parsed can be an object or a number for pie charts let yLabel = horizontalChart ? tooltipItem.parsed.x : tooltipItem.parsed.y; if (yLabel === undefined || yLabel === null) { yLabel = tooltipItem.parsed; } const toolTipFormat = !format && Math.abs(yLabel) >= 1000 ? "#,##" : format; const yLabelStr = formatValue(yLabel, { format: toolTipFormat, locale }); return xLabel ? `${xLabel}: ${yLabelStr}` : yLabelStr; }, }, }, }, }; return { type: chart.type, options, data: { labels: truncateLabels ? labels.map(truncateLabel) : labels, datasets: [], }, platform: undefined, // This key is optional and will be set by chart.js plugins: [], }; } function getChartLabelFormat(getters, range) { if (!range) return undefined; const { sheetId, zone: { left, top, bottom }, } = range; for (let row = top; row <= bottom; row++) { const format = getters.getEvaluatedCell({ sheetId, col: left, row }).format; if (format) { return format; } } return undefined; } function getChartLabelValues(getters, dataSets, labelRange) { let labels = { values: [], formattedValues: [] }; if (labelRange) { const { left } = labelRange.zone; if (!labelRange.invalidXc && !labelRange.invalidSheetName && !getters.isColHidden(labelRange.sheetId, left)) { labels = { formattedValues: getters.getRangeFormattedValues(labelRange), values: getters.getRangeValues(labelRange).map((val) => String(val ?? "")), }; } else if (dataSets[0]) { const ranges = getData(getters, dataSets[0]); labels = { formattedValues: range(0, ranges.length).map((r) => r.toString()), values: labels.formattedValues, }; } } else if (dataSets.length === 1) { for (let i = 0; i < getData(getters, dataSets[0]).length; i++) { labels.formattedValues.push(""); labels.values.push(""); } } else { if (dataSets[0]) { const ranges = getData(getters, dataSets[0]); labels = { formattedValues: range(0, ranges.length).map((r) => r.toString()), values: labels.formattedValues, }; } } return labels; } /** * Get the format to apply to the the dataset values. This format is defined as the first format * found in the dataset ranges that isn't a date format. */ function getChartDatasetFormat(getters, dataSets) { for (const ds of dataSets) { const formatsInDataset = getters.getRangeFormats(ds.dataRange); const format = formatsInDataset.find((f) => f !== undefined && !isDateTimeFormat(f)); if (format) return format; } return undefined; } function getChartDatasetValues(getters, dataSets) { const datasetValues = []; for (const [dsIndex, ds] of Object.entries(dataSets)) { if (getters.isColHidden(ds.dataRange.sheetId, ds.dataRange.zone.left)) { continue; } let label; if (ds.labelCell) { const labelRange = ds.labelCell; const cell = labelRange ? getters.getEvaluatedCell({ sheetId: labelRange.sheetId, col: labelRange.zone.left, row: labelRange.zone.top, }) : undefined; label = cell && labelRange ? truncateLabel(cell.formattedValue) : (label = `${ChartTerms.Series} ${parseInt(dsIndex) + 1}`); } else { label = `${ChartTerms.Series} ${parseInt(dsIndex) + 1}`; } let data = ds.dataRange ? getData(getters, ds) : []; if (data.every((e) => typeof e === "string" && !isEvaluationError(e)) && data.some((e) => e !== "")) { // In this case, we want a chart based on the string occurrences count // This will be done by associating each string with a value of 1 and // then using the classical aggregation method to sum the values. data.fill(1); } else if (data.every((cell) => cell === undefined || cell === null || !isNumber(cell.toString(), getters.getLocale()))) { continue; } datasetValues.push({ data, label }); } return datasetValues; } /** * If the chart is a stacked area chart, we want to fill until the next dataset. * If the chart is a simple area chart, we want to fill until the origin (bottom axis). * * See https://www.chartjs.org/docs/latest/charts/area.html#filling-modes */ function getFillingMode(index, stackedChart) { if (!stackedChart) { return "origin"; } return index === 0 ? "origin" : "-1"; } function chartToImage(runtime, figure, type) { // wrap the canvas in a div with a fixed size because chart.js would // fill the whole page otherwise const div = document.createElement("div"); div.style.width = `${figure.width}px`; div.style.height = `${figure.height}px`; const canvas = document.createElement("canvas"); div.append(canvas); canvas.setAttribute("width", figure.width.toString()); canvas.setAttribute("height", figure.height.toString()); // we have to add the canvas to the DOM otherwise it won't be rendered document.body.append(div); if ("chartJsConfig" in runtime) { const config = deepCopy(runtime.chartJsConfig); config.plugins = [backgroundColorChartJSPlugin]; const chart = new window.Chart(canvas, config); const imgContent = chart.toBase64Image(); chart.destroy(); div.remove(); return imgContent; } else if (type === "scorecard") { const design = getScorecardConfiguration(figure, runtime); drawScoreChart(design, canvas); const imgContent = canvas.toDataURL(); div.remove(); return imgContent; } else if (type === "gauge") { drawGaugeChart(canvas, runtime); const imgContent = canvas.toDataURL(); div.remove(); return imgContent; } return undefined; } /** * Custom chart.js plugin to set the background color of the canvas * https://github.com/chartjs/Chart.js/blob/8fdf76f8f02d31684d34704341a5d9217e977491/docs/configuration/canvas-background.md */ const backgroundColorChartJSPlugin = { id: "customCanvasBackgroundColor", beforeDraw: (chart) => { const { ctx } = chart; ctx.save(); ctx.globalCompositeOperation = "destination-over"; ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, chart.width, chart.height); ctx.restore(); }, }; class BarChart extends AbstractChart { dataSets; labelRange; background; legendPosition; stacked; aggregated; type = "bar"; dataSetsHaveTitle; dataSetDesign; axesDesign; horizontal; showValues; constructor(definition, sheetId, getters) { super(definition, sheetId, getters); this.dataSets = createDataSets(getters, definition.dataSets, sheetId, definition.dataSetsHaveTitle); this.labelRange = createValidRange(getters, sheetId, definition.labelRange); this.background = definition.background; this.legendPosition = definition.legendPosition; this.stacked = definition.stacked; this.aggregated = definition.aggregated; this.dataSetsHaveTitle = definition.dataSetsHaveTitle; this.dataSetDesign = definition.dataSets; this.axesDesign = definition.axesDesign; this.horizontal = definition.horizontal; this.showValues = definition.showValues; } static transformDefinition(definition, executed) { return transformChartDefinitionWithDataSetsWithZone(definition, executed); } static validateChartDefinition(validator, definition) { return validator.checkValidations(definition, checkDataset, checkLabelRange); } static getDefinitionFromContextCreation(context) { return { background: context.background, dataSets: context.range ?? [], dataSetsHaveTitle: context.dataSetsHaveTitle ?? false, stacked: context.stacked ?? false, aggregated: context.aggregated ?? false, legendPosition: context.legendPosition ?? "top", title: context.title || { text: "" }, type: "bar", labelRange: context.auxiliaryRange || undefined, axesDesign: context.axesDesign, showValues: context.showValues, }; } getContextCreation() { const range = []; for (const [i, dataSet] of this.dataSets.entries()) { range.push({ ...this.dataSetDesign?.[i], dataRange: this.getters.getRangeString(dataSet.dataRange, this.sheetId), }); } return { ...this, range, auxiliaryRange: this.labelRange ? this.getters.getRangeString(this.labelRange, this.sheetId) : undefined, }; } copyForSheetId(sheetId) { const dataSets = copyDataSetsWithNewSheetId(this.sheetId, sheetId, this.dataSets); const labelRange = copyLabelRangeWithNewSheetId(this.sheetId, sheetId, this.labelRange); const definition = this.getDefinitionWithSpecificDataSets(dataSets, labelRange, sheetId); return new BarChart(definition, sheetId, this.getters); } copyInSheetId(sheetId) { const definition = this.getDefinitionWithSpecificDataSets(this.dataSets, this.labelRange, sheetId); return new BarChart(definition, sheetId, this.getters); } getDefinition() { return this.getDefinitionWithSpecificDataSets(this.dataSets, this.labelRange); } getDefinitionWithSpecificDataSets(dataSets, labelRange, targetSheetId) { const ranges = []; for (const [i, dataSet] of dataSets.entries()) { ranges.push({ ...this.dataSetDesign?.[i], dataRange: this.getters.getRangeString(dataSet.dataRange, targetSheetId || this.sheetId), }); } return { type: "bar", dataSetsHaveTitle: dataSets.length ? Boolean(dataSets[0].labelCell) : false, background: this.background, dataSets: ranges, legendPosition: this.legendPosition, labelRange: labelRange ? this.getters.getRangeString(labelRange, targetSheetId || this.sheetId) : undefined, title: this.title, stacked: this.stacked, aggregated: this.aggregated, axesDesign: this.axesDesign, horizontal: this.horizontal, showValues: this.showValues, }; } getDefinitionForExcel() { // Excel does not support aggregating labels if (this.aggregated) return undefined; const dataSets = this.dataSets .map((ds) => toExcelDataset(this.getters, ds)) .filter((ds) => ds.range !== "" && ds.range !== CellErrorType.InvalidReference); const labelRange = toExcelLabelRange(this.getters, this.labelRange, shouldRemoveFirstLabel(this.labelRange, this.dataSets[0], this.dataSetsHaveTitle)); const definition = this.getDefinition(); return { ...definition, backgroundColor: toXlsxHexColor(this.background || BACKGROUND_CHART_COLOR), fontColor: toXlsxHexColor(chartFontColor(this.background)), dataSets, labelRange, verticalAxis: getDefinedAxis(definition), }; } updateRanges(applyChange) { const { dataSets, labelRange, isStale } = updateChartRangesWithDataSets(this.getters, applyChange, this.dataSets, this.labelRange); if (!isStale) { return this; } const definition = this.getDefinitionWithSpecificDataSets(dataSets, labelRange); return new BarChart(definition, this.sheetId, this.getters); } } function createBarChartRuntime(chart, getters) { const labelValues = getChartLabelValues(getters, chart.dataSets, chart.labelRange); let labels = labelValues.formattedValues; let dataSetsValues = getChartDatasetValues(getters, chart.dataSets); if (chart.dataSetsHaveTitle && dataSetsValues[0] && labels.length > dataSetsValues[0].data.length) { labels.shift(); } ({ labels, dataSetsValues } = filterEmptyDataPoints(labels, dataSetsValues)); if (chart.aggregated) { ({ labels, dataSetsValues } = aggregateDataForLabels(labels, dataSetsValues)); } const dataSetFormat = getChartDatasetFormat(getters, chart.dataSets); const locale = getters.getLocale(); const localeFormat = { format: dataSetFormat, locale }; const fontColor = chartFontColor(chart.background); const config = getDefaultChartJsRuntime(chart, labels, fontColor, { ...localeFormat, horizontalChart: chart.horizontal, }); const legend = { labels: { color: fontColor }, }; if (chart.legendPosition === "none") { legend.display = false; } else { legend.position = chart.legendPosition; } config.options.plugins.legend = { ...config.options.plugins?.legend, ...legend }; config.options.layout = { padding: computeChartPadding({ displayTitle: !!chart.title.text, displayLegend: chart.legendPosition === "top", }), }; config.options.indexAxis = chart.horizontal ? "y" : "x"; config.options.scales = {}; const labelsAxis = { ticks: { padding: 5, color: fontColor } }; const valuesAxis = { beginAtZero: true, // the origin of the y axis is always zero ticks: { color: fontColor, callback: formatTickValue(localeFormat), }, }; const xAxis = chart.horizontal ? valuesAxis : labelsAxis; const yAxis = chart.horizontal ? labelsAxis : valuesAxis; const { useLeftAxis, useRightAxis } = getDefinedAxis(chart.getDefinition()); config.options.scales.x = { ...xAxis, title: getChartAxisTitleRuntime(chart.axesDesign?.x) }; if (useLeftAxis) { config.options.scales.y = { ...yAxis, position: "left", title: getChartAxisTitleRuntime(chart.axesDesign?.y), }; } if (useRightAxis) { config.options.scales.y1 = { ...yAxis, position: "right", title: getChartAxisTitleRuntime(chart.axesDesign?.y1), }; } if (chart.stacked) { // @ts-ignore chart.js type is broken config.options.scales.x.stacked = true; if (useLeftAxis) { // @ts-ignore chart.js type is broken config.options.scales.y.stacked = true; } if (useRightAxis) { // @ts-ignore chart.js type is broken config.options.scales.y1.stacked = true; } } config.options.plugins.chartShowValuesPlugin = { showValues: chart.showValues, background: chart.background, horizontal: chart.horizontal, callback: formatTickValue(localeFormat), }; const definition = chart.getDefinition(); const colors = getChartColorsGenerator(definition, dataSetsValues.length); const trendDatasets = []; for (const index in dataSetsValues) { const { label, data } = dataSetsValues[index]; const color = colors.next(); const dataset = { label, data, borderColor: definition.background || BACKGROUND_CHART_COLOR, borderWidth: definition.stacked ? 1 : 0, backgroundColor: color, }; config.data.datasets.push(dataset); if (definition.dataSets?.[index]?.label) { const label = definition.dataSets[index].label; dataset.label = label; } if (definition.dataSets?.[index]?.yAxisId && !chart.horizontal) { dataset["yAxisID"] = definition.dataSets[index].yAxisId; } const trend = definition.dataSets?.[index].trend; if (!trend?.display || chart.horizontal) { continue; } const trendDataset = getTrendDatasetForBarChart(trend, dataset); if (trendDataset) { trendDatasets.push(trendDataset); } } if (trendDatasets.length) { /* We add a second x axis here to draw the trend lines, with the labels length being * set so that the second axis points match the classical x axis */ const maxLength = Math.max(...trendDatasets.map((trendDataset) => trendDataset.data.length)); config.options.scales[TREND_LINE_XAXIS_ID] = { ...xAxis, labels: Array(maxLength).fill(""), offset: false, display: false, }; /* These datasets must be inserted after the original * datasets to ensure the way we distinguish the originals and trendLine datasets after */ trendDatasets.forEach((x) => config.data.datasets.push(x)); config.options.plugins.tooltip.callbacks.title = function (tooltipItems) { return tooltipItems.some((item) => item.dataset.xAxisID !== TREND_LINE_XAXIS_ID) ? undefined : ""; }; } return { chartJsConfig: config, background: chart.background || BACKGROUND_CHART_COLOR }; } const UNIT_LENGTH = { second: 1000, minute: 1000 * 60, hour: 1000 * 3600, day: 1000 * 3600 * 24, month: 1000 * 3600 * 24 * 30, year: 1000 * 3600 * 24 * 365, }; const Milliseconds = { inSeconds: function (milliseconds) { return Math.floor(milliseconds / UNIT_LENGTH.second); }, inMinutes: function (milliseconds) { return Math.floor(milliseconds / UNIT_LENGTH.minute); }, inHours: function (milliseconds) { return Math.floor(milliseconds / UNIT_LENGTH.hour); }, inDays: function (milliseconds) { return Math.floor(milliseconds / UNIT_LENGTH.day); }, inMonths: function (milliseconds) { return Math.floor(milliseconds / UNIT_LENGTH.month); }, inYears: function (milliseconds) { return Math.floor(milliseconds / UNIT_LENGTH.year); }, }; /** * Regex to test if a format string is a date format that can be translated into a luxon time format */ const timeFormatLuxonCompatible = /^((d|dd|m|mm|yyyy|yy|hh|h|ss|a)(-|:|\s|\/))*(d|dd|m|mm|yyyy|yy|hh|h|ss|a)$/i; /** Get the time options for the XAxis of ChartJS */ function getChartTimeOptions(labels, labelFormat, locale) { const luxonFormat = convertDateFormatForLuxon(labelFormat); const timeUnit = getBestTimeUnitForScale(labels, luxonFormat, locale); const displayFormats = {}; if (timeUnit) { displayFormats[timeUnit] = luxonFormat; } return { parser: luxonFormat, displayFormats, unit: timeUnit ?? false, }; } /** * Convert the given date format into a format that moment.js understands. * * https://github.com/moment/luxon/blob/master/docs/formatting.md#table-of-tokens */ function convertDateFormatForLuxon(format) { // "m" before "h" === month, "m" after "h" === minute const indexH = format.indexOf("h"); if (indexH >= 0) { format = format.slice(0, indexH).replace(/m/g, "M") + format.slice(indexH); } else { format = format.replace(/m/g, "M"); } // If we have an "a", we should display hours as AM/PM (h), otherwise display 24 hours format (H) if (!format.includes("a")) { format = format.replace(/h/g, "H"); } return format; } /** Get the minimum time unit that the format is able to display */ function getFormatMinDisplayUnit(format) { if (format.includes("s")) { return "second"; } else if (format.includes("m")) { return "minute"; } else if (format.includes("h") || format.includes("H")) { return "hour"; } else if (format.includes("d")) { return "day"; } else if (format.includes("M")) { return "month"; } return "year"; } /** * Returns the best time unit that should be used for the X axis of a chart in order to display all * the labels correctly. * * There is two conditions : * - the format of the labels should be able to display the unit. For example if the format is "DD/MM/YYYY" * it makes no sense to try to use minutes in the X axis * - we want the "best fit" unit. For example if the labels span a period of several days, we want to use days * as a unit, but if they span 200 days, we'd like to use months instead * */ function getBestTimeUnitForScale(labels, format, locale) { const labelDates = labels.map((label) => parseDateTime(label, locale)?.jsDate); if (labelDates.some((date) => date === undefined) || labels.length < 2) { return undefined; } const labelsTimestamps = labelDates.map((date) => date.getTime()); const period = largeMax(labelsTimestamps) - largeMin(labelsTimestamps); const minUnit = getFormatMinDisplayUnit(format); if (UNIT_LENGTH.second >= UNIT_LENGTH[minUnit] && Milliseconds.inSeconds(period) < 180) { return "second"; } else if (UNIT_LENGTH.minute >= UNIT_LENGTH[minUnit] && Milliseconds.inMinutes(period) < 180) { return "minute"; } else if (UNIT_LENGTH.hour >= UNIT_LENGTH[minUnit] && Milliseconds.inHours(period) < 96) { return "hour"; } else if (UNIT_LENGTH.day >= UNIT_LENGTH[minUnit] && Milliseconds.inDays(period) < 90) { return "day"; } else if (UNIT_LENGTH.month >= UNIT_LENGTH[minUnit] && Milliseconds.inMonths(period) < 36) { return "month"; } return "year"; } function fixEmptyLabelsForDateCharts(labels, dataSetsValues) { if (labels.length === 0 || labels.every((label) => !label)) { return { labels, dataSetsValues }; } const newLabels = [...labels]; const newDatasets = deepCopy(dataSetsValues); for (let i = 0; i < newLabels.length; i++) { if (!newLabels[i]) { newLabels[i] = findNextDefinedValue(newLabels, i); for (let ds of newDatasets) { ds.data[i] = undefined; } } } return { labels: newLabels, dataSetsValues: newDatasets }; } function canChartParseLabels(labelRange, getters) { return canBeDateChart(labelRange, getters) || canBeLinearChart(labelRange, getters); } function getChartAxisType(chart, getters) { if (isDateChart(chart, getters) && isLuxonTimeAdapterInstalled()) { return "time"; } if (isLinearChart(chart, getters)) { return "linear"; } return "category"; } function isDateChart(chart, getters) { return !chart.labelsAsText && canBeDateChart(chart.labelRange, getters); } function isLinearChart(chart, getters) { return !chart.labelsAsText && canBeLinearChart(chart.labelRange, getters); } function canBeDateChart(labelRange, getters) { if (!labelRange || !canBeLinearChart(labelRange, getters)) { return false; } const labelFormat = getChartLabelFormat(getters, labelRange); return Boolean(labelFormat && timeFormatLuxonCompatible.test(labelFormat)); } function canBeLinearChart(labelRange, getters) { if (!labelRange) { return false; } const labels = getters.getRangeValues(labelRange); if (labels.some((label) => isNaN(Number(label)) && label)) { return false; } if (labels.every((label) => !label)) { return false; } return true; } let missingTimeAdapterAlreadyWarned = false; function isLuxonTimeAdapterInstalled() { if (!window.Chart) { return false; } // @ts-ignore const adapter = new window.Chart._adapters._date({}); const isInstalled = adapter._id === "luxon"; if (!isInstalled && !missingTimeAdapterAlreadyWarned) { missingTimeAdapterAlreadyWarned = true; console.warn("'chartjs-adapter-luxon' time adapter is not installed. Time scale axes are disabled."); } return isInstalled; } function getTrendDatasetForLineChart(config, dataset, axisType, locale) { const filteredValues = []; const filteredLabels = []; const labels = []; const datasetLength = dataset.data.length; if (datasetLength < 2) { return; } switch (axisType) { case "category": for (let i = 0; i < datasetLength; i++) { if (typeof dataset.data[i] === "number") { filteredValues.push(dataset.data[i]); filteredLabels.push(i + 1); } labels.push(i + 1); } break; case "linear": for (const point of dataset.data) { const label = Number(point.x); if (isNaN(label)) { continue; } if (typeof point.y === "number") { filteredValues.push(point.y); filteredLabels.push(label); } labels.push(label); } break; case "time": for (const point of dataset.data) { const date = toNumber({ value: point.x }, locale); if (point.y !== null) { filteredValues.push(point.y); filteredLabels.push(date); } labels.push(date); } break; } const xmin = Math.min(...labels); const xmax = Math.max(...labels); if (xmax === xmin) { return; } const numberOfStep = 5 * labels.length; const step = (xmax - xmin) / numberOfStep; const newLabels = range(xmin, xmax + step / 2, step); const newValues = interpolateData(config, filteredValues, filteredLabels, newLabels); if (!newValues.length) { return; } return getFullTrendingLineDataSet(dataset, config, newValues); } function createLineOrScatterChartRuntime(chart, getters) { const axisType = getChartAxisType(chart, getters); const labelValues = getChartLabelValues(getters, chart.dataSets, chart.labelRange); let labels = axisType === "linear" ? labelValues.values : labelValues.formattedValues; let dataSetsValues = getChartDatasetValues(getters, chart.dataSets); if (chart.dataSetsHaveTitle && dataSetsValues[0] && labels.length > dataSetsValues[0].data.length) { labels.shift(); } ({ labels, dataSetsValues } = filterEmptyDataPoints(labels, dataSetsValues)); if (axisType === "time") { ({ labels, dataSetsValues } = fixEmptyLabelsForDateCharts(labels, dataSetsValues)); } if (chart.aggregated) { ({ labels, dataSetsValues } = aggregateDataForLabels(labels, dataSetsValues)); } const locale = getters.getLocale(); const truncateLabels = axisType === "category"; const dataSetFormat = getChartDatasetFormat(getters, chart.dataSets); const options = { format: dataSetFormat, locale, truncateLabels }; const fontColor = chartFontColor(chart.background); const config = getDefaultChartJsRuntime(chart, labels, fontColor, options); const legend = { labels: { color: fontColor, generateLabels(chart) { // color the legend labels with the dataset color, without any transparency const { data } = chart; const labels = window.Chart.defaults.plugins.legend.labels.generateLabels(chart); for (const [index, label] of labels.entries()) { label.fillStyle = data.datasets[index].borderColor; } return labels; }, }, }; if (chart.legendPosition === "none") { legend.display = false; } else { legend.position = chart.legendPosition; } Object.assign(config.options.plugins.legend || {}, legend); config.options.layout = { padding: computeChartPadding({ displayTitle: !!chart.title.text, displayLegend: chart.legendPosition === "top", }), }; const xAxis = { ticks: { padding: 5, color: fontColor, }, title: getChartAxisTitleRuntime(chart.axesDesign?.x), }; config.options.scales = { x: xAxis, }; const yAxis = { beginAtZero: true, // the origin of the y axis is always zero ticks: { color: fontColor, callback: formatTickValue(options), }, }; const { useLeftAxis, useRightAxis } = getDefinedAxis(chart.getDefinition()); if (useLeftAxis) { config.options.scales.y = { ...yAxis, position: "left", title: getChartAxisTitleRuntime(chart.axesDesign?.y), }; } if (useRightAxis) { config.options.scales.y1 = { ...yAxis, position: "right", title: getChartAxisTitleRuntime(chart.axesDesign?.y1), }; } if ("stacked" in chart && chart.stacked) { if (useLeftAxis) { // @ts-ignore chart.js type is broken config.options.scales.y.stacked = true; } if (useRightAxis) { // @ts-ignore chart.js type is broken config.options.scales.y1.stacked = true; } } config.options.plugins.chartShowValuesPlugin = { showValues: chart.showValues, background: chart.background, callback: formatTickValue(options), }; if (chart.dataSetsHaveTitle && dataSetsValues[0] && labels.length > dataSetsValues[0].data.length) { labels.shift(); } const labelFormat = getChartLabelFormat(getters, chart.labelRange); if (axisType === "time") { const axis = { type: "time", time: getChartTimeOptions(labels, labelFormat, locale), }; Object.assign(config.options.scales.x, axis); config.options.scales.x.ticks.maxTicksLimit = 15; } else if (axisType === "linear") { config.options.scales.x.type = "linear"; config.options.scales.x.ticks.callback = (value) => formatValue(value, { format: labelFormat, locale }); config.options.plugins.tooltip.callbacks.label = (tooltipItem) => { const dataSetPoint = dataSetsValues[tooltipItem.datasetIndex].data[tooltipItem.dataIndex]; let label = tooltipItem.label || labelValues.values[tooltipItem.dataIndex]; if (isNumber(label, locale)) { label = toNumber(label, locale); } const formattedX = formatValue(label, { locale, format: labelFormat }); const formattedY = formatValue(dataSetPoint, { locale, format: dataSetFormat }); const dataSetTitle = tooltipItem.dataset.label; return formattedX ? `${dataSetTitle}: (${formattedX}, ${formattedY})` : `${dataSetTitle}: ${formattedY}`; }; } const areaChart = "fillArea" in chart ? chart.fillArea : false; const stackedChart = "stacked" in chart ? chart.stacked : false; const cumulative = "cumulative" in chart ? chart.cumulative : false; const definition = chart.getDefinition(); const colors = getChartColorsGenerator(definition, dataSetsValues.length); for (let [index, { label, data }] of dataSetsValues.entries()) { const color = colors.next(); let backgroundRGBA = colorToRGBA(color); if (areaChart) { backgroundRGBA.a = LINE_FILL_TRANSPARENCY; } if (cumulative) { let accumulator = 0; data = data.map((value) => { if (!isNaN(value)) { accumulator += parseFloat(value); return accumulator; } return value; }); } if (["linear", "time"].includes(axisType)) { // Replace empty string labels by undefined to make sure chartJS doesn't decide that "" is the same as 0 data = data.map((y, index) => ({ x: labels[index] || undefined, y })); } const backgroundColor = rgbaToHex(backgroundRGBA); const dataset = { label, data, tension: 0, // 0 -> render straight lines, which is much faster borderColor: color, backgroundColor, pointBackgroundColor: color, fill: areaChart ? getFillingMode(index, stackedChart) : false, }; config.data.datasets.push(dataset); } let maxLength = 0; const trendDatasets = []; for (const [index, dataset] of config.data.datasets.entries()) { if (definition.dataSets?.[index]?.label) { const label = definition.dataSets[index].label; dataset.label = label; } if (definition.dataSets?.[index]?.yAxisId) { dataset["yAxisID"] = definition.dataSets[index].yAxisId; } const trend = definition.dataSets?.[index].trend; if (!trend?.display) { continue; } const trendDataset = getTrendDatasetForLineChart(trend, dataset, axisType, locale); if (trendDataset) { maxLength = Math.max(maxLength, trendDataset.data.length); trendDatasets.push(trendDataset); dataSetsValues.push(trendDataset); } } if (trendDatasets.length) { /* We add a second x axis here to draw the trend lines, with the labels length being * set so that the second axis points match the classical x axis */ config.options.scales[TREND_LINE_XAXIS_ID] = { ...xAxis, type: "category", labels: range(0, maxLength).map((x) => x.toString()), offset: false, display: false, }; /* These datasets must be inserted after the original datasets to ensure the way we * distinguish the originals and trendLine datasets after */ trendDatasets.forEach((x) => config.data.datasets.push(x)); } config.options.plugins.tooltip.callbacks.title = function (tooltipItems) { const displayTooltipTitle = axisType !== "linear" && tooltipItems.some((item) => item.dataset.xAxisID !== TREND_LINE_XAXIS_ID); return displayTooltipTitle ? undefined : ""; }; return { chartJsConfig: config, background: chart.background || BACKGROUND_CHART_COLOR, }; } class ComboChart extends AbstractChart { dataSets; labelRange; background; legendPosition; aggregated; dataSetsHaveTitle; dataSetDesign; axesDesign; type = "combo"; showValues; constructor(definition, sheetId, getters) { super(definition, sheetId, getters); this.dataSets = createDataSets(getters, definition.dataSets, sheetId, definition.dataSetsHaveTitle); this.labelRange = createValidRange(getters, sheetId, definition.labelRange); this.background = definition.background; this.legendPosition = definition.legendPosition; this.aggregated = definition.aggregated; this.dataSetsHaveTitle = definition.dataSetsHaveTitle; this.dataSetDesign = definition.dataSets; this.axesDesign = definition.axesDesign; this.showValues = definition.showValues; } static transformDefinition(definition, executed) { return transformChartDefinitionWithDataSetsWithZone(definition, executed); } static validateChartDefinition(validator, definition) { return validator.checkValidations(definition, checkDataset, checkLabelRange); } getContextCreation() { const range = []; for (const [i, dataSet] of this.dataSets.entries()) { range.push({ ...this.dataSetDesign?.[i], dataRange: this.getters.getRangeString(dataSet.dataRange, this.sheetId), }); } return { ...this, range, auxiliaryRange: this.labelRange ? this.getters.getRangeString(this.labelRange, this.sheetId) : undefined, }; } getDefinition() { return this.getDefinitionWithSpecificDataSets(this.dataSets, this.labelRange); } getDefinitionWithSpecificDataSets(dataSets, labelRange, targetSheetId) { const ranges = []; for (const [i, dataSet] of dataSets.entries()) { ranges.push({ ...this.dataSetDesign?.[i], dataRange: this.getters.getRangeString(dataSet.dataRange, targetSheetId || this.sheetId), type: this.dataSetDesign?.[i]?.type ?? (i ? "line" : "bar"), }); } return { type: "combo", dataSetsHaveTitle: dataSets.length ? Boolean(dataSets[0].labelCell) : false, background: this.background, dataSets: ranges, legendPosition: this.legendPosition, labelRange: labelRange ? this.getters.getRangeString(labelRange, targetSheetId || this.sheetId) : undefined, title: this.title, aggregated: this.aggregated, axesDesign: this.axesDesign, showValues: this.showValues, }; } getDefinitionForExcel() { // Excel does not support aggregating labels if (this.aggregated) { return undefined; } const dataSets = this.dataSets .map((ds) => toExcelDataset(this.getters, ds)) .filter((ds) => ds.range !== "" && ds.range !== CellErrorType.InvalidReference); const labelRange = toExcelLabelRange(this.getters, this.labelRange, shouldRemoveFirstLabel(this.labelRange, this.dataSets[0], this.dataSetsHaveTitle)); const definition = this.getDefinition(); return { ...definition, backgroundColor: toXlsxHexColor(this.background || BACKGROUND_CHART_COLOR), fontColor: toXlsxHexColor(chartFontColor(this.background)), dataSets, labelRange, verticalAxis: getDefinedAxis(definition), }; } updateRanges(applyChange) { const { dataSets, labelRange, isStale } = updateChartRangesWithDataSets(this.getters, applyChange, this.dataSets, this.labelRange); if (!isStale) { return this; } const definition = this.getDefinitionWithSpecificDataSets(dataSets, labelRange); return new ComboChart(definition, this.sheetId, this.getters); } static getDefinitionFromContextCreation(context) { const dataSets = (context.range ?? []).map((ds, index) => ({ ...ds, type: index ? "line" : "bar", })); return { background: context.background, dataSets, dataSetsHaveTitle: context.dataSetsHaveTitle ?? false, aggregated: context.aggregated, legendPosition: context.legendPosition ?? "top", title: context.title || { text: "" }, labelRange: context.auxiliaryRange || undefined, type: "combo", axesDesign: context.axesDesign, showValues: context.showValues, }; } copyForSheetId(sheetId) { const dataSets = copyDataSetsWithNewSheetId(this.sheetId, sheetId, this.dataSets); const labelRange = copyLabelRangeWithNewSheetId(this.sheetId, sheetId, this.labelRange); const definition = this.getDefinitionWithSpecificDataSets(dataSets, labelRange, sheetId); return new ComboChart(definition, sheetId, this.getters); } copyInSheetId(sheetId) { const definition = this.getDefinitionWithSpecificDataSets(this.dataSets, this.labelRange, sheetId); return new ComboChart(definition, sheetId, this.getters); } } function createComboChartRuntime(chart, getters) { const mainDataSetFormat = chart.dataSets.length ? getChartDatasetFormat(getters, [chart.dataSets[0]]) : undefined; const lineDataSetsFormat = getChartDatasetFormat(getters, chart.dataSets.slice(1)); const locale = getters.getLocale(); const labelValues = getChartLabelValues(getters, chart.dataSets, chart.labelRange); let labels = labelValues.formattedValues; let dataSetsValues = getChartDatasetValues(getters, chart.dataSets); if (chart.dataSetsHaveTitle && dataSetsValues[0] && labels.length > dataSetsValues[0].data.length) { labels.shift(); } ({ labels, dataSetsValues } = filterEmptyDataPoints(labels, dataSetsValues)); if (chart.aggregated) { ({ labels, dataSetsValues } = aggregateDataForLabels(labels, dataSetsValues)); } const localeFormat = { format: mainDataSetFormat, locale }; const fontColor = chartFontColor(chart.background); const config = getDefaultChartJsRuntime(chart, labels, fontColor, localeFormat); const legend = { labels: { color: fontColor }, }; if (chart.legendPosition === "none") { legend.display = false; } else { legend.position = chart.legendPosition; } config.options.plugins.legend = { ...config.options.plugins?.legend, ...legend }; config.options.layout = { padding: computeChartPadding({ displayTitle: !!chart.title.text, displayLegend: chart.legendPosition === "top", }), }; config.options.scales = { x: { ticks: { padding: 5, color: fontColor, }, title: getChartAxisTitleRuntime(chart.axesDesign?.x), }, }; const leftVerticalAxis = { beginAtZero: true, // the origin of the y axis is always zero ticks: { color: fontColor, callback: formatTickValue({ format: mainDataSetFormat, locale }), }, }; const rightVerticalAxis = { beginAtZero: true, // the origin of the y axis is always zero ticks: { color: fontColor, callback: formatTickValue({ format: lineDataSetsFormat, locale }), }, }; const definition = chart.getDefinition(); const { useLeftAxis, useRightAxis } = getDefinedAxis(definition); if (useLeftAxis) { config.options.scales.y = { ...leftVerticalAxis, position: "left", title: getChartAxisTitleRuntime(chart.axesDesign?.y), }; } if (useRightAxis) { config.options.scales.y1 = { ...rightVerticalAxis, position: "right", grid: { display: false, }, title: getChartAxisTitleRuntime(chart.axesDesign?.y1), }; } config.options.plugins.chartShowValuesPlugin = { showValues: chart.showValues, background: chart.background, callback: formatTickValue({ format: mainDataSetFormat, locale }), }; const colors = getChartColorsGenerator(definition, dataSetsValues.length); let maxLength = 0; const trendDatasets = []; for (let [index, { label, data }] of dataSetsValues.entries()) { const design = definition.dataSets[index]; const color = colors.next(); const type = design?.type ?? "line"; const dataset = { label: design?.label ?? label, data, borderColor: color, backgroundColor: color, yAxisID: design?.yAxisId ?? "y", type, order: type === "bar" ? dataSetsValues.length + index : index, }; config.data.datasets.push(dataset); const trend = definition.dataSets?.[index].trend; if (!trend?.display) { continue; } maxLength = Math.max(maxLength, data.length); const trendDataset = getTrendDatasetForBarChart(trend, dataset); if (trendDataset) { trendDatasets.push(trendDataset); } } if (trendDatasets.length) { /* We add a second x axis here to draw the trend lines, with the labels length being * set so that the second axis points match the classical x axis */ const trendLinesMaxLength = Math.max(...trendDatasets.map((trend) => trend.data.length)); config.options.scales[TREND_LINE_XAXIS_ID] = { labels: Array(Math.round(trendLinesMaxLength)).fill(""), offset: false, display: false, }; /* These datasets must be inserted after the original datasets to ensure the way we * distinguish the originals and trendLine datasets after */ trendDatasets.forEach((x) => config.data.datasets.push(x)); config.options.plugins.tooltip.callbacks.title = function (tooltipItems) { return tooltipItems.some((item) => item.dataset.xAxisID !== TREND_LINE_XAXIS_ID) ? undefined : ""; }; } return { chartJsConfig: config, background: chart.background || BACKGROUND_CHART_COLOR }; } function isDataRangeValid(definition) { return definition.dataRange && !rangeReference.test(definition.dataRange) ? "InvalidGaugeDataRange" /* CommandResult.InvalidGaugeDataRange */ : "Success" /* CommandResult.Success */; } function checkRangeLimits(check, batchValidations) { return batchValidations((definition) => { if (definition.sectionRule) { return check(definition.sectionRule.rangeMin, "rangeMin"); } return "Success" /* CommandResult.Success */; }, (definition) => { if (definition.sectionRule) { return check(definition.sectionRule.rangeMax, "rangeMax"); } return "Success" /* CommandResult.Success */; }); } function checkInflectionPointsValue(check, batchValidations) { return batchValidations((definition) => { if (definition.sectionRule) { return check(definition.sectionRule.lowerInflectionPoint.value, "lowerInflectionPointValue"); } return "Success" /* CommandResult.Success */; }, (definition) => { if (definition.sectionRule) { return check(definition.sectionRule.upperInflectionPoint.value, "upperInflectionPointValue"); } return "Success" /* CommandResult.Success */; }); } function checkRangeMinBiggerThanRangeMax(definition) { if (definition.sectionRule) { if (Number(definition.sectionRule.rangeMin) >= Number(definition.sectionRule.rangeMax)) { return "GaugeRangeMinBiggerThanRangeMax" /* CommandResult.GaugeRangeMinBiggerThanRangeMax */; } } return "Success" /* CommandResult.Success */; } function checkEmpty(value, valueName) { if (value === "") { switch (valueName) { case "rangeMin": return "EmptyGaugeRangeMin" /* CommandResult.EmptyGaugeRangeMin */; case "rangeMax": return "EmptyGaugeRangeMax" /* CommandResult.EmptyGaugeRangeMax */; } } return "Success" /* CommandResult.Success */; } function checkNaN(value, valueName) { if (isNaN(value)) { switch (valueName) { case "rangeMin": return "GaugeRangeMinNaN" /* CommandResult.GaugeRangeMinNaN */; case "rangeMax": return "GaugeRangeMaxNaN" /* CommandResult.GaugeRangeMaxNaN */; case "lowerInflectionPointValue": return "GaugeLowerInflectionPointNaN" /* CommandResult.GaugeLowerInflectionPointNaN */; case "upperInflectionPointValue": return "GaugeUpperInflectionPointNaN" /* CommandResult.GaugeUpperInflectionPointNaN */; } } return "Success" /* CommandResult.Success */; } class GaugeChart extends AbstractChart { dataRange; sectionRule; background; type = "gauge"; constructor(definition, sheetId, getters) { super(definition, sheetId, getters); this.dataRange = createValidRange(this.getters, this.sheetId, definition.dataRange); this.sectionRule = definition.sectionRule; this.background = definition.background; } static validateChartDefinition(validator, definition) { return validator.checkValidations(definition, isDataRangeValid, validator.chainValidations(checkRangeLimits(checkEmpty, validator.batchValidations), checkRangeLimits(checkNaN, validator.batchValidations), checkRangeMinBiggerThanRangeMax), validator.chainValidations(checkInflectionPointsValue(checkNaN, validator.batchValidations))); } static transformDefinition(definition, executed) { let dataRangeZone; if (definition.dataRange) { dataRangeZone = transformZone(toUnboundedZone(definition.dataRange), executed); } return { ...definition, dataRange: dataRangeZone ? zoneToXc(dataRangeZone) : undefined, }; } static getDefinitionFromContextCreation(context) { return { background: context.background, title: context.title || { text: "" }, type: "gauge", dataRange: context.range ? context.range[0].dataRange : undefined, sectionRule: { colors: { lowerColor: DEFAULT_GAUGE_LOWER_COLOR, middleColor: DEFAULT_GAUGE_MIDDLE_COLOR, upperColor: DEFAULT_GAUGE_UPPER_COLOR, }, rangeMin: "0", rangeMax: "100", lowerInflectionPoint: { type: "percentage", value: "15", operator: "<=", }, upperInflectionPoint: { type: "percentage", value: "40", operator: "<=", }, }, }; } copyForSheetId(sheetId) { const dataRange = copyLabelRangeWithNewSheetId(this.sheetId, sheetId, this.dataRange); const definition = this.getDefinitionWithSpecificRanges(dataRange, sheetId); return new GaugeChart(definition, sheetId, this.getters); } copyInSheetId(sheetId) { const definition = this.getDefinitionWithSpecificRanges(this.dataRange, sheetId); return new GaugeChart(definition, sheetId, this.getters); } getDefinition() { return this.getDefinitionWithSpecificRanges(this.dataRange); } getDefinitionWithSpecificRanges(dataRange, targetSheetId) { return { background: this.background, sectionRule: this.sectionRule, title: this.title, type: "gauge", dataRange: dataRange ? this.getters.getRangeString(dataRange, targetSheetId || this.sheetId) : undefined, }; } getDefinitionForExcel() { // This kind of graph is not exportable in Excel return undefined; } getContextCreation() { return { ...this, range: this.dataRange ? [{ dataRange: this.getters.getRangeString(this.dataRange, this.sheetId) }] : undefined, }; } updateRanges(applyChange) { const range = adaptChartRange(this.dataRange, applyChange); if (this.dataRange === range) { return this; } const definition = this.getDefinitionWithSpecificRanges(range); return new GaugeChart(definition, this.sheetId, this.getters); } } function createGaugeChartRuntime(chart, getters) { const locale = getters.getLocale(); const chartColors = chart.sectionRule.colors; let gaugeValue = undefined; let formattedValue = undefined; let format = undefined; const dataRange = chart.dataRange; if (dataRange !== undefined) { const cell = getters.getEvaluatedCell({ sheetId: dataRange.sheetId, col: dataRange.zone.left, row: dataRange.zone.top, }); if (cell.type === CellValueType.number) { gaugeValue = cell.value; formattedValue = cell.formattedValue; format = cell.format; } } const minValue = Number(chart.sectionRule.rangeMin); const maxValue = Number(chart.sectionRule.rangeMax); const lowerPoint = chart.sectionRule.lowerInflectionPoint; const upperPoint = chart.sectionRule.upperInflectionPoint; const lowerPointValue = getSectionThresholdValue(lowerPoint, minValue, maxValue); const upperPointValue = getSectionThresholdValue(upperPoint, minValue, maxValue); const inflectionValues = []; const colors = []; if (lowerPointValue !== undefined) { inflectionValues.push({ value: lowerPointValue, label: formatValue(lowerPointValue, { locale, format }), operator: lowerPoint.operator, }); colors.push(chartColors.lowerColor); } if (upperPointValue !== undefined && upperPointValue !== lowerPointValue) { inflectionValues.push({ value: upperPointValue, label: formatValue(upperPointValue, { locale, format }), operator: upperPoint.operator, }); colors.push(chartColors.middleColor); } if (upperPointValue !== undefined && lowerPointValue !== undefined && lowerPointValue > upperPointValue) { inflectionValues.reverse(); colors.reverse(); } colors.push(chartColors.upperColor); return { background: getters.getStyleOfSingleCellChart(chart.background, dataRange).background, title: chart.title ?? { text: "" }, minValue: { value: minValue, label: formatValue(minValue, { locale, format }), }, maxValue: { value: maxValue, label: formatValue(maxValue, { locale, format }), }, gaugeValue: gaugeValue !== undefined && formattedValue ? { value: gaugeValue, label: formattedValue } : undefined, inflectionValues, colors, }; } function getSectionThresholdValue(threshold, minValue, maxValue) { if (threshold.value === "" || isNaN(Number(threshold.value))) { return undefined; } const numberValue = Number(threshold.value); const value = threshold.type === "number" ? numberValue : minValue + ((maxValue - minValue) * numberValue) / 100; return clip(value, minValue, maxValue); } class LineChart extends AbstractChart { dataSets; labelRange; background; legendPosition; labelsAsText; stacked; aggregated; type = "line"; dataSetsHaveTitle; cumulative; dataSetDesign; axesDesign; fillArea; showValues; constructor(definition, sheetId, getters) { super(definition, sheetId, getters); this.dataSets = createDataSets(this.getters, definition.dataSets, sheetId, definition.dataSetsHaveTitle); this.labelRange = createValidRange(this.getters, sheetId, definition.labelRange); this.background = definition.background; this.legendPosition = definition.legendPosition; this.labelsAsText = definition.labelsAsText; this.stacked = definition.stacked; this.aggregated = definition.aggregated; this.dataSetsHaveTitle = definition.dataSetsHaveTitle; this.cumulative = definition.cumulative; this.dataSetDesign = definition.dataSets; this.axesDesign = definition.axesDesign; this.fillArea = definition.fillArea; this.showValues = definition.showValues; } static validateChartDefinition(validator, definition) { return validator.checkValidations(definition, checkDataset, checkLabelRange); } static transformDefinition(definition, executed) { return transformChartDefinitionWithDataSetsWithZone(definition, executed); } static getDefinitionFromContextCreation(context) { return { background: context.background, dataSets: context.range ?? [], dataSetsHaveTitle: context.dataSetsHaveTitle ?? false, labelsAsText: context.labelsAsText ?? false, legendPosition: context.legendPosition ?? "top", title: context.title || { text: "" }, type: "line", labelRange: context.auxiliaryRange || undefined, stacked: context.stacked ?? false, aggregated: context.aggregated ?? false, cumulative: context.cumulative ?? false, axesDesign: context.axesDesign, fillArea: context.fillArea, showValues: context.showValues, }; } getDefinition() { return this.getDefinitionWithSpecificDataSets(this.dataSets, this.labelRange); } getDefinitionWithSpecificDataSets(dataSets, labelRange, targetSheetId) { const ranges = []; for (const [i, dataSet] of dataSets.entries()) { ranges.push({ ...this.dataSetDesign?.[i], dataRange: this.getters.getRangeString(dataSet.dataRange, targetSheetId || this.sheetId), }); } return { type: "line", dataSetsHaveTitle: dataSets.length ? Boolean(dataSets[0].labelCell) : false, background: this.background, dataSets: ranges, legendPosition: this.legendPosition, labelRange: labelRange ? this.getters.getRangeString(labelRange, targetSheetId || this.sheetId) : undefined, title: this.title, labelsAsText: this.labelsAsText, stacked: this.stacked, aggregated: this.aggregated, cumulative: this.cumulative, axesDesign: this.axesDesign, fillArea: this.fillArea, showValues: this.showValues, }; } getContextCreation() { const range = []; for (const [i, dataSet] of this.dataSets.entries()) { range.push({ ...this.dataSetDesign?.[i], dataRange: this.getters.getRangeString(dataSet.dataRange, this.sheetId), }); } return { ...this, range, auxiliaryRange: this.labelRange ? this.getters.getRangeString(this.labelRange, this.sheetId) : undefined, }; } updateRanges(applyChange) { const { dataSets, labelRange, isStale } = updateChartRangesWithDataSets(this.getters, applyChange, this.dataSets, this.labelRange); if (!isStale) { return this; } const definition = this.getDefinitionWithSpecificDataSets(dataSets, labelRange); return new LineChart(definition, this.sheetId, this.getters); } getDefinitionForExcel() { // Excel does not support aggregating labels if (this.aggregated) return undefined; const dataSets = this.dataSets .map((ds) => toExcelDataset(this.getters, ds)) .filter((ds) => ds.range !== "" && ds.range !== CellErrorType.InvalidReference); const labelRange = toExcelLabelRange(this.getters, this.labelRange, shouldRemoveFirstLabel(this.labelRange, this.dataSets[0], this.dataSetsHaveTitle)); const definition = this.getDefinition(); return { ...definition, backgroundColor: toXlsxHexColor(this.background || BACKGROUND_CHART_COLOR), fontColor: toXlsxHexColor(chartFontColor(this.background)), dataSets, labelRange, verticalAxis: getDefinedAxis(definition), }; } copyForSheetId(sheetId) { const dataSets = copyDataSetsWithNewSheetId(this.sheetId, sheetId, this.dataSets); const labelRange = copyLabelRangeWithNewSheetId(this.sheetId, sheetId, this.labelRange); const definition = this.getDefinitionWithSpecificDataSets(dataSets, labelRange, sheetId); return new LineChart(definition, sheetId, this.getters); } copyInSheetId(sheetId) { const definition = this.getDefinitionWithSpecificDataSets(this.dataSets, this.labelRange, sheetId); return new LineChart(definition, sheetId, this.getters); } } class PieChart extends AbstractChart { dataSets; labelRange; background; legendPosition; type = "pie"; aggregated; dataSetsHaveTitle; isDoughnut; showValues; constructor(definition, sheetId, getters) { super(definition, sheetId, getters); this.dataSets = createDataSets(getters, definition.dataSets, sheetId, definition.dataSetsHaveTitle); this.labelRange = createValidRange(getters, sheetId, definition.labelRange); this.background = definition.background; this.legendPosition = definition.legendPosition; this.aggregated = definition.aggregated; this.dataSetsHaveTitle = definition.dataSetsHaveTitle; this.isDoughnut = definition.isDoughnut; this.showValues = definition.showValues; } static transformDefinition(definition, executed) { return transformChartDefinitionWithDataSetsWithZone(definition, executed); } static validateChartDefinition(validator, definition) { return validator.checkValidations(definition, checkDataset, checkLabelRange); } static getDefinitionFromContextCreation(context) { return { background: context.background, dataSets: context.range ?? [], dataSetsHaveTitle: context.dataSetsHaveTitle ?? false, legendPosition: context.legendPosition ?? "top", title: context.title || { text: "" }, type: "pie", labelRange: context.auxiliaryRange || undefined, aggregated: context.aggregated ?? false, isDoughnut: false, showValues: context.showValues, }; } getDefinition() { return this.getDefinitionWithSpecificDataSets(this.dataSets, this.labelRange); } getContextCreation() { return { ...this, range: this.dataSets.map((ds) => ({ dataRange: this.getters.getRangeString(ds.dataRange, this.sheetId), })), auxiliaryRange: this.labelRange ? this.getters.getRangeString(this.labelRange, this.sheetId) : undefined, }; } getDefinitionWithSpecificDataSets(dataSets, labelRange, targetSheetId) { return { type: "pie", dataSetsHaveTitle: dataSets.length ? Boolean(dataSets[0].labelCell) : false, background: this.background, dataSets: dataSets.map((ds) => ({ dataRange: this.getters.getRangeString(ds.dataRange, targetSheetId || this.sheetId), })), legendPosition: this.legendPosition, labelRange: labelRange ? this.getters.getRangeString(labelRange, targetSheetId || this.sheetId) : undefined, title: this.title, aggregated: this.aggregated, isDoughnut: this.isDoughnut, showValues: this.showValues, }; } copyForSheetId(sheetId) { const dataSets = copyDataSetsWithNewSheetId(this.sheetId, sheetId, this.dataSets); const labelRange = copyLabelRangeWithNewSheetId(this.sheetId, sheetId, this.labelRange); const definition = this.getDefinitionWithSpecificDataSets(dataSets, labelRange, sheetId); return new PieChart(definition, sheetId, this.getters); } copyInSheetId(sheetId) { const definition = this.getDefinitionWithSpecificDataSets(this.dataSets, this.labelRange, sheetId); return new PieChart(definition, sheetId, this.getters); } getDefinitionForExcel() { // Excel does not support aggregating labels if (this.aggregated) return undefined; const dataSets = this.dataSets .map((ds) => toExcelDataset(this.getters, ds)) .filter((ds) => ds.range !== "" && ds.range !== CellErrorType.InvalidReference); const labelRange = toExcelLabelRange(this.getters, this.labelRange, shouldRemoveFirstLabel(this.labelRange, this.dataSets[0], this.dataSetsHaveTitle)); return { ...this.getDefinition(), backgroundColor: toXlsxHexColor(this.background || BACKGROUND_CHART_COLOR), fontColor: toXlsxHexColor(chartFontColor(this.background)), dataSets, labelRange, }; } updateRanges(applyChange) { const { dataSets, labelRange, isStale } = updateChartRangesWithDataSets(this.getters, applyChange, this.dataSets, this.labelRange); if (!isStale) { return this; } const definition = this.getDefinitionWithSpecificDataSets(dataSets, labelRange); return new PieChart(definition, this.sheetId, this.getters); } } function getPieConfiguration(chart, labels, localeFormat) { const fontColor = chartFontColor(chart.background); const config = getDefaultChartJsRuntime(chart, labels, fontColor, localeFormat); const legend = { labels: { color: fontColor }, }; if ((!chart.labelRange && chart.dataSets.length === 1) || chart.legendPosition === "none") { legend.display = false; } else { legend.position = chart.legendPosition; } Object.assign(config.options.plugins.legend || {}, legend); config.options.layout = { padding: { left: 20, right: 20, top: chart.title ? 10 : 25, bottom: 10 }, }; config.options.plugins.tooltip.callbacks.title = function (tooltipItems) { return tooltipItems[0].dataset.label; }; config.options.plugins.tooltip.callbacks.label = function (tooltipItem) { const { format, locale } = localeFormat; const data = tooltipItem.dataset.data; const dataIndex = tooltipItem.dataIndex; const percentage = calculatePercentage(data, dataIndex); const xLabel = tooltipItem.label || tooltipItem.dataset.label; const yLabel = tooltipItem.parsed.y ?? tooltipItem.parsed; const toolTipFormat = !format && yLabel >= 1000 ? "#,##" : format; const yLabelStr = formatValue(yLabel, { format: toolTipFormat, locale }); return xLabel ? `${xLabel}: ${yLabelStr} (${percentage}%)` : `${yLabelStr} (${percentage}%)`; }; config.options.plugins.chartShowValuesPlugin = { showValues: chart.showValues, callback: formatTickValue(localeFormat), }; return config; } function getPieColors(colors, dataSetsValues) { const pieColors = []; const maxLength = largeMax(dataSetsValues.map((ds) => ds.data.length)); for (let i = 0; i <= maxLength; i++) { pieColors.push(colors.next()); } return pieColors; } function calculatePercentage(dataset, dataIndex) { const numericData = dataset.filter((value) => typeof value === "number"); const total = numericData.reduce((sum, value) => sum + value, 0); if (!total) { return ""; } const percentage = (dataset[dataIndex] / total) * 100; return percentage.toFixed(2); } function filterNegativeValues(labels, datasets) { const dataPointsIndexes = labels.reduce((indexes, label, i) => { const shouldKeep = datasets.some((dataset) => { const dataPoint = dataset.data[i]; return typeof dataPoint !== "number" || dataPoint >= 0; }); if (shouldKeep) { indexes.push(i); } return indexes; }, []); const filteredLabels = dataPointsIndexes.map((i) => labels[i] || ""); const filteredDatasets = datasets.map((dataset) => ({ ...dataset, data: dataPointsIndexes.map((i) => { const dataPoint = dataset.data[i]; return typeof dataPoint !== "number" || dataPoint >= 0 ? dataPoint : 0; }), })); return { labels: filteredLabels, dataSetsValues: filteredDatasets }; } function createPieChartRuntime(chart, getters) { const labelValues = getChartLabelValues(getters, chart.dataSets, chart.labelRange); let labels = labelValues.formattedValues; let dataSetsValues = getChartDatasetValues(getters, chart.dataSets); if (chart.dataSetsHaveTitle && dataSetsValues[0] && labels.length > dataSetsValues[0].data.length) { labels.shift(); } ({ labels, dataSetsValues } = filterEmptyDataPoints(labels, dataSetsValues)); if (chart.aggregated) { ({ labels, dataSetsValues } = aggregateDataForLabels(labels, dataSetsValues)); } ({ dataSetsValues, labels } = filterNegativeValues(labels, dataSetsValues)); const dataSetFormat = getChartDatasetFormat(getters, chart.dataSets); const locale = getters.getLocale(); const config = getPieConfiguration(chart, labels, { format: dataSetFormat, locale }); const dataSetsLength = Math.max(0, ...dataSetsValues.map((ds) => ds?.data?.length ?? 0)); const backgroundColor = getPieColors(new ColorGenerator(dataSetsLength), dataSetsValues); for (const { label, data } of dataSetsValues) { const dataset = { label, data, borderColor: BACKGROUND_CHART_COLOR, backgroundColor, hoverOffset: 30, }; config.data.datasets.push(dataset); } if (chart.isDoughnut) { config.type = "doughnut"; } return { chartJsConfig: config, background: chart.background || BACKGROUND_CHART_COLOR }; } class PyramidChart extends AbstractChart { dataSets; labelRange; background; legendPosition; aggregated; type = "pyramid"; dataSetsHaveTitle; dataSetDesign; axesDesign; horizontal = true; stacked = true; showValues; constructor(definition, sheetId, getters) { super(definition, sheetId, getters); this.dataSets = createDataSets(getters, definition.dataSets, sheetId, definition.dataSetsHaveTitle).slice(0, 2); this.labelRange = createValidRange(getters, sheetId, definition.labelRange); this.background = definition.background; this.legendPosition = definition.legendPosition; this.aggregated = definition.aggregated; this.dataSetsHaveTitle = definition.dataSetsHaveTitle; this.dataSetDesign = definition.dataSets; this.axesDesign = definition.axesDesign; this.showValues = definition.showValues; } static transformDefinition(definition, executed) { return transformChartDefinitionWithDataSetsWithZone(definition, executed); } static validateChartDefinition(validator, definition) { return validator.checkValidations(definition, checkDataset, checkLabelRange); } static getDefinitionFromContextCreation(context) { return { background: context.background, dataSets: context.range ?? [], dataSetsHaveTitle: context.dataSetsHaveTitle ?? false, aggregated: context.aggregated ?? false, legendPosition: context.legendPosition ?? "top", title: context.title || { text: "" }, type: "pyramid", labelRange: context.auxiliaryRange || undefined, axesDesign: context.axesDesign, horizontal: true, stacked: true, showValues: context.showValues, }; } getContextCreation() { const range = []; for (const [i, dataSet] of this.dataSets.entries()) { range.push({ ...this.dataSetDesign?.[i], dataRange: this.getters.getRangeString(dataSet.dataRange, this.sheetId), }); } return { ...this, range, auxiliaryRange: this.labelRange ? this.getters.getRangeString(this.labelRange, this.sheetId) : undefined, }; } copyForSheetId(sheetId) { const dataSets = copyDataSetsWithNewSheetId(this.sheetId, sheetId, this.dataSets); const labelRange = copyLabelRangeWithNewSheetId(this.sheetId, sheetId, this.labelRange); const definition = this.getDefinitionWithSpecificDataSets(dataSets, labelRange, sheetId); return new PyramidChart(definition, sheetId, this.getters); } copyInSheetId(sheetId) { const definition = this.getDefinitionWithSpecificDataSets(this.dataSets, this.labelRange, sheetId); return new PyramidChart(definition, sheetId, this.getters); } getDefinition() { return this.getDefinitionWithSpecificDataSets(this.dataSets, this.labelRange); } getDefinitionWithSpecificDataSets(dataSets, labelRange, targetSheetId) { const ranges = []; for (const [i, dataSet] of dataSets.entries()) { ranges.push({ ...this.dataSetDesign?.[i], dataRange: this.getters.getRangeString(dataSet.dataRange, targetSheetId || this.sheetId), }); } return { type: "pyramid", dataSetsHaveTitle: dataSets.length ? Boolean(dataSets[0].labelCell) : false, background: this.background, dataSets: ranges, legendPosition: this.legendPosition, labelRange: labelRange ? this.getters.getRangeString(labelRange, targetSheetId || this.sheetId) : undefined, title: this.title, aggregated: this.aggregated, axesDesign: this.axesDesign, horizontal: true, stacked: true, showValues: this.showValues, }; } getDefinitionForExcel() { return undefined; } updateRanges(applyChange) { const { dataSets, labelRange, isStale } = updateChartRangesWithDataSets(this.getters, applyChange, this.dataSets, this.labelRange); if (!isStale) { return this; } const definition = this.getDefinitionWithSpecificDataSets(dataSets, labelRange); return new PyramidChart(definition, this.sheetId, this.getters); } } function createPyramidChartRuntime(chart, getters) { const barDef = { ...chart.getDefinition(), type: "bar" }; const barChart = new BarChart(barDef, chart.sheetId, getters); const barRuntime = createBarChartRuntime(barChart, getters); const config = barRuntime.chartJsConfig; let datasets = config.data?.datasets; if (datasets && datasets[0]) { datasets[0].data = datasets[0].data.map((value) => (value > 0 ? value : 0)); } if (datasets && datasets[1]) { datasets[1].data = datasets[1].data.map((value) => (value > 0 ? -value : 0)); } const maxValue = Math.max(...config.data?.datasets.map((dataSet) => Math.max(...dataSet.data.map(Math.abs)))); const scales = config.options.scales; const scalesXCallback = scales.x.ticks.callback; scales.x.ticks.callback = (value) => scalesXCallback(Math.abs(value)); scales.x.suggestedMin = -maxValue; scales.x.suggestedMax = maxValue; const tooltipLabelCallback = config.options.plugins.tooltip.callbacks.label; config.options.plugins.tooltip.callbacks.label = (item) => { const tooltipItem = { ...item, parsed: { y: item.parsed.y, x: Math.abs(item.parsed.x) } }; return tooltipLabelCallback(tooltipItem); }; const callback = config.options.plugins.chartShowValuesPlugin.callback; config.options.plugins.chartShowValuesPlugin.callback = (x) => callback(Math.abs(x)); return { chartJsConfig: config, background: chart.background || BACKGROUND_CHART_COLOR }; } class ScatterChart extends AbstractChart { dataSets; labelRange; background; legendPosition; labelsAsText; aggregated; type = "scatter"; dataSetsHaveTitle; dataSetDesign; axesDesign; showValues; constructor(definition, sheetId, getters) { super(definition, sheetId, getters); this.dataSets = createDataSets(this.getters, definition.dataSets, sheetId, definition.dataSetsHaveTitle); this.labelRange = createValidRange(this.getters, sheetId, definition.labelRange); this.background = definition.background; this.legendPosition = definition.legendPosition; this.labelsAsText = definition.labelsAsText; this.aggregated = definition.aggregated; this.dataSetsHaveTitle = definition.dataSetsHaveTitle; this.dataSetDesign = definition.dataSets; this.axesDesign = definition.axesDesign; this.showValues = definition.showValues; } static validateChartDefinition(validator, definition) { return validator.checkValidations(definition, checkDataset, checkLabelRange); } static transformDefinition(definition, executed) { return transformChartDefinitionWithDataSetsWithZone(definition, executed); } static getDefinitionFromContextCreation(context) { return { background: context.background, dataSets: context.range ?? [], dataSetsHaveTitle: context.dataSetsHaveTitle ?? false, labelsAsText: context.labelsAsText ?? false, legendPosition: context.legendPosition ?? "top", title: context.title || { text: "" }, type: "scatter", labelRange: context.auxiliaryRange || undefined, aggregated: context.aggregated ?? false, axesDesign: context.axesDesign, showValues: context.showValues, }; } getDefinition() { return this.getDefinitionWithSpecificDataSets(this.dataSets, this.labelRange); } getDefinitionWithSpecificDataSets(dataSets, labelRange, targetSheetId) { const ranges = []; for (const [i, dataSet] of dataSets.entries()) { ranges.push({ ...this.dataSetDesign?.[i], dataRange: this.getters.getRangeString(dataSet.dataRange, targetSheetId || this.sheetId), }); } return { type: "scatter", dataSetsHaveTitle: dataSets.length ? Boolean(dataSets[0].labelCell) : false, background: this.background, dataSets: ranges, legendPosition: this.legendPosition, labelRange: labelRange ? this.getters.getRangeString(labelRange, targetSheetId || this.sheetId) : undefined, title: this.title, labelsAsText: this.labelsAsText, aggregated: this.aggregated, axesDesign: this.axesDesign, showValues: this.showValues, }; } getContextCreation() { const range = []; for (const [i, dataSet] of this.dataSets.entries()) { range.push({ ...this.dataSetDesign?.[i], dataRange: this.getters.getRangeString(dataSet.dataRange, this.sheetId), }); } return { ...this, range, auxiliaryRange: this.labelRange ? this.getters.getRangeString(this.labelRange, this.sheetId) : undefined, }; } updateRanges(applyChange) { const { dataSets, labelRange, isStale } = updateChartRangesWithDataSets(this.getters, applyChange, this.dataSets, this.labelRange); if (!isStale) { return this; } const definition = this.getDefinitionWithSpecificDataSets(dataSets, labelRange); return new ScatterChart(definition, this.sheetId, this.getters); } getDefinitionForExcel() { // Excel does not support aggregating labels if (this.aggregated) { return undefined; } const dataSets = this.dataSets .map((ds) => toExcelDataset(this.getters, ds)) .filter((ds) => ds.range !== ""); const labelRange = toExcelLabelRange(this.getters, this.labelRange, shouldRemoveFirstLabel(this.labelRange, this.dataSets[0], this.dataSetsHaveTitle)); const definition = this.getDefinition(); return { ...definition, backgroundColor: toXlsxHexColor(this.background || BACKGROUND_CHART_COLOR), fontColor: toXlsxHexColor(chartFontColor(this.background)), dataSets, labelRange, verticalAxis: getDefinedAxis(definition), }; } copyForSheetId(sheetId) { const dataSets = copyDataSetsWithNewSheetId(this.sheetId, sheetId, this.dataSets); const labelRange = copyLabelRangeWithNewSheetId(this.sheetId, sheetId, this.labelRange); const definition = this.getDefinitionWithSpecificDataSets(dataSets, labelRange, sheetId); return new ScatterChart(definition, sheetId, this.getters); } copyInSheetId(sheetId) { const definition = this.getDefinitionWithSpecificDataSets(this.dataSets, this.labelRange, sheetId); return new ScatterChart(definition, sheetId, this.getters); } } function createScatterChartRuntime(chart, getters) { const { chartJsConfig, background } = createLineOrScatterChartRuntime(chart, getters); // use chartJS line chart and disable the lines instead of chartJS scatter chart. This is because the scatter chart // have less options than the line chart (it only works with linear labels) chartJsConfig.type = "line"; for (const dataSet of chartJsConfig.data.datasets) { dataSet.showLine = "showLine" in dataSet ? dataSet.showLine : false; } return { chartJsConfig, background }; } class WaterfallChart extends AbstractChart { dataSets; labelRange; background; verticalAxisPosition; legendPosition; aggregated; type = "waterfall"; dataSetsHaveTitle; showSubTotals; firstValueAsSubtotal; showConnectorLines; positiveValuesColor; negativeValuesColor; subTotalValuesColor; dataSetDesign; axesDesign; showValues; constructor(definition, sheetId, getters) { super(definition, sheetId, getters); this.dataSets = createDataSets(getters, definition.dataSets, sheetId, definition.dataSetsHaveTitle); this.labelRange = createValidRange(getters, sheetId, definition.labelRange); this.background = definition.background; this.verticalAxisPosition = definition.verticalAxisPosition; this.legendPosition = definition.legendPosition; this.aggregated = definition.aggregated; this.dataSetsHaveTitle = definition.dataSetsHaveTitle; this.showSubTotals = definition.showSubTotals; this.showConnectorLines = definition.showConnectorLines; this.positiveValuesColor = definition.positiveValuesColor; this.negativeValuesColor = definition.negativeValuesColor; this.subTotalValuesColor = definition.subTotalValuesColor; this.firstValueAsSubtotal = definition.firstValueAsSubtotal; this.dataSetDesign = definition.dataSets; this.axesDesign = definition.axesDesign; this.showValues = definition.showValues; } static transformDefinition(definition, executed) { return transformChartDefinitionWithDataSetsWithZone(definition, executed); } static validateChartDefinition(validator, definition) { return validator.checkValidations(definition, checkDataset, checkLabelRange); } static getDefinitionFromContextCreation(context) { return { background: context.background, dataSets: context.range ? context.range : [], dataSetsHaveTitle: context.dataSetsHaveTitle ?? false, aggregated: context.aggregated ?? false, legendPosition: context.legendPosition ?? "top", title: context.title || { text: "" }, type: "waterfall", verticalAxisPosition: "left", labelRange: context.auxiliaryRange || undefined, showSubTotals: context.showSubTotals ?? false, showConnectorLines: context.showConnectorLines ?? true, firstValueAsSubtotal: context.firstValueAsSubtotal ?? false, axesDesign: context.axesDesign, showValues: context.showValues, }; } getContextCreation() { const range = []; for (const [i, dataSet] of this.dataSets.entries()) { range.push({ ...this.dataSetDesign?.[i], dataRange: this.getters.getRangeString(dataSet.dataRange, this.sheetId), }); } return { ...this, range, auxiliaryRange: this.labelRange ? this.getters.getRangeString(this.labelRange, this.sheetId) : undefined, }; } copyForSheetId(sheetId) { const dataSets = copyDataSetsWithNewSheetId(this.sheetId, sheetId, this.dataSets); const labelRange = copyLabelRangeWithNewSheetId(this.sheetId, sheetId, this.labelRange); const definition = this.getDefinitionWithSpecificDataSets(dataSets, labelRange, sheetId); return new WaterfallChart(definition, sheetId, this.getters); } copyInSheetId(sheetId) { const definition = this.getDefinitionWithSpecificDataSets(this.dataSets, this.labelRange, sheetId); return new WaterfallChart(definition, sheetId, this.getters); } getDefinition() { return this.getDefinitionWithSpecificDataSets(this.dataSets, this.labelRange); } getDefinitionWithSpecificDataSets(dataSets, labelRange, targetSheetId) { const ranges = []; for (const [i, dataSet] of dataSets.entries()) { ranges.push({ ...this.dataSetDesign?.[i], dataRange: this.getters.getRangeString(dataSet.dataRange, targetSheetId || this.sheetId), }); } return { type: "waterfall", dataSetsHaveTitle: dataSets.length ? Boolean(dataSets[0].labelCell) : false, background: this.background, dataSets: ranges, legendPosition: this.legendPosition, verticalAxisPosition: this.verticalAxisPosition, labelRange: labelRange ? this.getters.getRangeString(labelRange, targetSheetId || this.sheetId) : undefined, title: this.title, aggregated: this.aggregated, showSubTotals: this.showSubTotals, showConnectorLines: this.showConnectorLines, positiveValuesColor: this.positiveValuesColor, negativeValuesColor: this.negativeValuesColor, subTotalValuesColor: this.subTotalValuesColor, firstValueAsSubtotal: this.firstValueAsSubtotal, axesDesign: this.axesDesign, showValues: this.showValues, }; } getDefinitionForExcel() { // TODO: implement export excel return undefined; } updateRanges(applyChange) { const { dataSets, labelRange, isStale } = updateChartRangesWithDataSets(this.getters, applyChange, this.dataSets, this.labelRange); if (!isStale) { return this; } const definition = this.getDefinitionWithSpecificDataSets(dataSets, labelRange); return new WaterfallChart(definition, this.sheetId, this.getters); } } function getWaterfallConfiguration(chart, labels, dataSeriesLabels, localeFormat) { const { locale, format } = localeFormat; const fontColor = chartFontColor(chart.background); const config = getDefaultChartJsRuntime(chart, labels, fontColor, localeFormat); const negativeColor = chart.negativeValuesColor || CHART_WATERFALL_NEGATIVE_COLOR; const positiveColor = chart.positiveValuesColor || CHART_WATERFALL_POSITIVE_COLOR; const subTotalColor = chart.subTotalValuesColor || CHART_WATERFALL_SUBTOTAL_COLOR; const legend = { labels: { generateLabels: () => { const legendValues = [ { text: _t("Positive values"), fontColor, fillStyle: positiveColor, strokeStyle: positiveColor, }, { text: _t("Negative values"), fontColor, fillStyle: negativeColor, strokeStyle: negativeColor, }, ]; if (chart.showSubTotals || chart.firstValueAsSubtotal) { legendValues.push({ text: _t("Subtotals"), fontColor, fillStyle: subTotalColor, strokeStyle: subTotalColor, }); } return legendValues; }, }, }; if (chart.legendPosition === "none") { legend.display = false; } else { legend.position = chart.legendPosition; } config.options.plugins.legend = { ...config.options.plugins?.legend, ...legend }; config.options.layout = { padding: { left: 20, right: 20, top: chart.title ? 10 : 25, bottom: 10 }, }; config.options.scales = { x: { ticks: { padding: 5, color: fontColor, }, grid: { display: false, }, title: getChartAxisTitleRuntime(chart.axesDesign?.x), }, y: { position: chart.verticalAxisPosition, ticks: { color: fontColor, callback: (value) => { value = Number(value); if (isNaN(value)) return value; return formatValue(value, { locale, format: !format && Math.abs(value) > 1000 ? "#,##" : format, }); }, }, grid: { lineWidth: (context) => { return context.tick.value === 0 ? 2 : 1; }, }, title: getChartAxisTitleRuntime(chart.axesDesign?.y), }, }; config.options.plugins.tooltip = { callbacks: { label: function (tooltipItem) { const [lastValue, currentValue] = tooltipItem.raw; const yLabel = currentValue - lastValue; const dataSeriesIndex = Math.floor(tooltipItem.dataIndex / labels.length); const dataSeriesLabel = dataSeriesLabels[dataSeriesIndex]; const toolTipFormat = !format && Math.abs(yLabel) > 1000 ? "#,##" : format; const yLabelStr = formatValue(yLabel, { format: toolTipFormat, locale }); return dataSeriesLabel ? `${dataSeriesLabel}: ${yLabelStr}` : yLabelStr; }, }, }; config.options.plugins.waterfallLinesPlugin = { showConnectorLines: chart.showConnectorLines }; config.options.plugins.chartShowValuesPlugin = { showValues: chart.showValues, background: chart.background, callback: formatTickValue(localeFormat), }; return config; } function createWaterfallChartRuntime(chart, getters) { const labelValues = getChartLabelValues(getters, chart.dataSets, chart.labelRange); let labels = labelValues.formattedValues; let dataSetsValues = getChartDatasetValues(getters, chart.dataSets); if (chart.dataSetsHaveTitle && dataSetsValues[0] && labels.length > dataSetsValues[0].data.length) { labels.shift(); } ({ labels, dataSetsValues } = filterEmptyDataPoints(labels, dataSetsValues)); if (chart.aggregated) { ({ labels, dataSetsValues } = aggregateDataForLabels(labels, dataSetsValues)); } if (chart.showSubTotals) { labels.push(_t("Subtotal")); } const dataSetFormat = getChartDatasetFormat(getters, chart.dataSets); const locale = getters.getLocale(); const dataSeriesLabels = dataSetsValues.map((dataSet) => dataSet.label); const config = getWaterfallConfiguration(chart, labels, dataSeriesLabels, { format: dataSetFormat, locale, }); config.type = "bar"; const negativeColor = chart.negativeValuesColor || CHART_WATERFALL_NEGATIVE_COLOR; const positiveColor = chart.positiveValuesColor || CHART_WATERFALL_POSITIVE_COLOR; const subTotalColor = chart.subTotalValuesColor || CHART_WATERFALL_SUBTOTAL_COLOR; const backgroundColor = []; const datasetValues = []; const dataset = { label: "", data: datasetValues, backgroundColor, }; const labelsWithSubTotals = []; let lastValue = 0; for (const dataSetsValue of dataSetsValues) { for (let i = 0; i < dataSetsValue.data.length; i++) { const data = dataSetsValue.data[i]; labelsWithSubTotals.push(labels[i]); if (isNaN(Number(data))) { datasetValues.push([lastValue, lastValue]); backgroundColor.push(""); continue; } datasetValues.push([lastValue, data + lastValue]); let color = data >= 0 ? positiveColor : negativeColor; if (i === 0 && dataSetsValue === dataSetsValues[0] && chart.firstValueAsSubtotal) { color = subTotalColor; } backgroundColor.push(color); lastValue += data; } if (chart.showSubTotals) { labelsWithSubTotals.push(_t("Subtotal")); datasetValues.push([0, lastValue]); backgroundColor.push(subTotalColor); } } config.data.datasets.push(dataset); config.data.labels = labelsWithSubTotals.map(truncateLabel); return { chartJsConfig: config, background: chart.background || BACKGROUND_CHART_COLOR }; } /** * This registry is intended to map a cell content (raw string) to * an instance of a cell. */ const chartRegistry = new Registry(); chartRegistry.add("bar", { match: (type) => type === "bar", createChart: (definition, sheetId, getters) => new BarChart(definition, sheetId, getters), getChartRuntime: createBarChartRuntime, validateChartDefinition: BarChart.validateChartDefinition, transformDefinition: BarChart.transformDefinition, getChartDefinitionFromContextCreation: BarChart.getDefinitionFromContextCreation, sequence: 10, }); chartRegistry.add("combo", { match: (type) => type === "combo", createChart: (definition, sheetId, getters) => new ComboChart(definition, sheetId, getters), getChartRuntime: createComboChartRuntime, validateChartDefinition: ComboChart.validateChartDefinition, transformDefinition: ComboChart.transformDefinition, getChartDefinitionFromContextCreation: ComboChart.getDefinitionFromContextCreation, sequence: 15, }); chartRegistry.add("line", { match: (type) => type === "line", createChart: (definition, sheetId, getters) => new LineChart(definition, sheetId, getters), getChartRuntime: createLineOrScatterChartRuntime, validateChartDefinition: LineChart.validateChartDefinition, transformDefinition: LineChart.transformDefinition, getChartDefinitionFromContextCreation: LineChart.getDefinitionFromContextCreation, sequence: 20, }); chartRegistry.add("pie", { match: (type) => type === "pie", createChart: (definition, sheetId, getters) => new PieChart(definition, sheetId, getters), getChartRuntime: createPieChartRuntime, validateChartDefinition: PieChart.validateChartDefinition, transformDefinition: PieChart.transformDefinition, getChartDefinitionFromContextCreation: PieChart.getDefinitionFromContextCreation, sequence: 30, }); chartRegistry.add("scorecard", { match: (type) => type === "scorecard", createChart: (definition, sheetId, getters) => new ScorecardChart$1(definition, sheetId, getters), getChartRuntime: createScorecardChartRuntime, validateChartDefinition: ScorecardChart$1.validateChartDefinition, transformDefinition: ScorecardChart$1.transformDefinition, getChartDefinitionFromContextCreation: ScorecardChart$1.getDefinitionFromContextCreation, sequence: 40, }); chartRegistry.add("gauge", { match: (type) => type === "gauge", createChart: (definition, sheetId, getters) => new GaugeChart(definition, sheetId, getters), getChartRuntime: createGaugeChartRuntime, validateChartDefinition: GaugeChart.validateChartDefinition, transformDefinition: GaugeChart.transformDefinition, getChartDefinitionFromContextCreation: GaugeChart.getDefinitionFromContextCreation, sequence: 50, }); chartRegistry.add("scatter", { match: (type) => type === "scatter", createChart: (definition, sheetId, getters) => new ScatterChart(definition, sheetId, getters), getChartRuntime: createScatterChartRuntime, validateChartDefinition: ScatterChart.validateChartDefinition, transformDefinition: ScatterChart.transformDefinition, getChartDefinitionFromContextCreation: ScatterChart.getDefinitionFromContextCreation, sequence: 60, }); chartRegistry.add("waterfall", { match: (type) => type === "waterfall", createChart: (definition, sheetId, getters) => new WaterfallChart(definition, sheetId, getters), getChartRuntime: createWaterfallChartRuntime, validateChartDefinition: WaterfallChart.validateChartDefinition, transformDefinition: WaterfallChart.transformDefinition, getChartDefinitionFromContextCreation: WaterfallChart.getDefinitionFromContextCreation, sequence: 70, }); chartRegistry.add("pyramid", { match: (type) => type === "pyramid", createChart: (definition, sheetId, getters) => new PyramidChart(definition, sheetId, getters), getChartRuntime: createPyramidChartRuntime, validateChartDefinition: PyramidChart.validateChartDefinition, transformDefinition: PyramidChart.transformDefinition, getChartDefinitionFromContextCreation: PyramidChart.getDefinitionFromContextCreation, sequence: 80, }); const chartComponentRegistry = new Registry(); chartComponentRegistry.add("line", ChartJsComponent); chartComponentRegistry.add("bar", ChartJsComponent); chartComponentRegistry.add("combo", ChartJsComponent); chartComponentRegistry.add("pie", ChartJsComponent); chartComponentRegistry.add("gauge", GaugeChartComponent); chartComponentRegistry.add("scatter", ChartJsComponent); chartComponentRegistry.add("scorecard", ScorecardChart); chartComponentRegistry.add("waterfall", ChartJsComponent); chartComponentRegistry.add("pyramid", ChartJsComponent); const chartCategories = { line: _t("Line"), column: _t("Column"), bar: _t("Bar"), area: _t("Area"), pie: _t("Pie"), misc: _t("Miscellaneous"), }; const chartSubtypeRegistry = new Registry(); chartSubtypeRegistry .add("line", { matcher: (definition) => definition.type === "line" && !definition.stacked && !definition.fillArea, displayName: _t("Line"), chartType: "line", chartSubtype: "line", subtypeDefinition: { stacked: false, fillArea: false }, category: "line", preview: "o-spreadsheet-ChartPreview.LINE_CHART", }) .add("stacked_line", { matcher: (definition) => definition.type === "line" && !definition.fillArea && !!definition.stacked, displayName: _t("Stacked Line"), chartType: "line", chartSubtype: "stacked_line", subtypeDefinition: { stacked: true, fillArea: false }, category: "line", preview: "o-spreadsheet-ChartPreview.STACKED_LINE_CHART", }) .add("area", { matcher: (definition) => definition.type === "line" && !definition.stacked && !!definition.fillArea, displayName: _t("Area"), chartType: "line", chartSubtype: "area", subtypeDefinition: { stacked: false, fillArea: true }, category: "area", preview: "o-spreadsheet-ChartPreview.AREA_CHART", }) .add("stacked_area", { matcher: (definition) => definition.type === "line" && definition.stacked && !!definition.fillArea, displayName: _t("Stacked Area"), chartType: "line", chartSubtype: "stacked_area", subtypeDefinition: { stacked: true, fillArea: true }, category: "area", preview: "o-spreadsheet-ChartPreview.STACKED_AREA_CHART", }) .add("scatter", { displayName: _t("Scatter"), chartType: "scatter", chartSubtype: "scatter", category: "misc", preview: "o-spreadsheet-ChartPreview.SCATTER_CHART", }) .add("column", { matcher: (definition) => definition.type === "bar" && !definition.stacked && !definition.horizontal, displayName: _t("Column"), chartType: "bar", chartSubtype: "column", subtypeDefinition: { stacked: false, horizontal: false }, category: "column", preview: "o-spreadsheet-ChartPreview.COLUMN_CHART", }) .add("stacked_column", { matcher: (definition) => definition.type === "bar" && definition.stacked && !definition.horizontal, displayName: _t("Stacked Column"), chartType: "bar", chartSubtype: "stacked_column", subtypeDefinition: { stacked: true, horizontal: false }, category: "column", preview: "o-spreadsheet-ChartPreview.STACKED_COLUMN_CHART", }) .add("bar", { matcher: (definition) => definition.type === "bar" && !definition.stacked && !!definition.horizontal, displayName: _t("Bar"), chartType: "bar", chartSubtype: "bar", subtypeDefinition: { horizontal: true, stacked: false }, category: "bar", preview: "o-spreadsheet-ChartPreview.BAR_CHART", }) .add("stacked_bar", { matcher: (definition) => definition.type === "bar" && definition.stacked && !!definition.horizontal, displayName: _t("Stacked Bar"), chartType: "bar", chartSubtype: "stacked_bar", subtypeDefinition: { horizontal: true, stacked: true }, category: "bar", preview: "o-spreadsheet-ChartPreview.STACKED_BAR_CHART", }) .add("combo", { displayName: _t("Combo"), chartSubtype: "combo", chartType: "combo", category: "line", preview: "o-spreadsheet-ChartPreview.COMBO_CHART", }) .add("pie", { matcher: (definition) => definition.type === "pie" && !definition.isDoughnut, displayName: _t("Pie"), chartSubtype: "pie", chartType: "pie", subtypeDefinition: { isDoughnut: false }, category: "pie", preview: "o-spreadsheet-ChartPreview.PIE_CHART", }) .add("doughnut", { matcher: (definition) => definition.type === "pie" && !!definition.isDoughnut, displayName: _t("Doughnut"), chartSubtype: "doughnut", chartType: "pie", subtypeDefinition: { isDoughnut: true }, category: "pie", preview: "o-spreadsheet-ChartPreview.DOUGHNUT_CHART", }) .add("gauge", { displayName: _t("Gauge"), chartSubtype: "gauge", chartType: "gauge", category: "misc", preview: "o-spreadsheet-ChartPreview.GAUGE_CHART", }) .add("scorecard", { displayName: _t("Scorecard"), chartSubtype: "scorecard", chartType: "scorecard", category: "misc", preview: "o-spreadsheet-ChartPreview.SCORECARD_CHART", }) .add("waterfall", { displayName: _t("Waterfall"), chartSubtype: "waterfall", chartType: "waterfall", category: "misc", preview: "o-spreadsheet-ChartPreview.WATERFALL_CHART", }) .add("pyramid", { displayName: _t("Population Pyramid"), chartSubtype: "pyramid", chartType: "pyramid", category: "misc", preview: "o-spreadsheet-ChartPreview.POPULATION_PYRAMID_CHART", }); /** * Registry intended to support usual currencies. It is mainly used to create * currency formats that can be selected or modified when customizing formats. */ const currenciesRegistry = new Registry(); // ----------------------------------------------------------------------------- // STYLE // ----------------------------------------------------------------------------- css /* scss */ ` .o-chart-container { width: 100%; height: 100%; position: relative; } `; class ChartFigure extends Component { static template = "o-spreadsheet-ChartFigure"; static props = { figure: Object, onFigureDeleted: Function, }; static components = {}; onDoubleClick() { this.env.model.dispatch("SELECT_FIGURE", { id: this.props.figure.id }); this.env.openSidePanel("ChartPanel"); } get chartType() { return this.env.model.getters.getChartType(this.props.figure.id); } get chartComponent() { const type = this.chartType; const component = chartComponentRegistry.get(type); if (!component) { throw new Error(`Component is not defined for type ${type}`); } return component; } } class ImageFigure extends Component { static template = "o-spreadsheet-ImageFigure"; static props = { figure: Object, onFigureDeleted: Function, }; static components = {}; // --------------------------------------------------------------------------- // Getters // --------------------------------------------------------------------------- get figureId() { return this.props.figure.id; } get getImagePath() { return this.env.model.getters.getImagePath(this.figureId); } } function centerFigurePosition(getters, size) { const { x: offsetCorrectionX, y: offsetCorrectionY } = getters.getMainViewportCoordinates(); const { scrollX, scrollY } = getters.getActiveSheetScrollInfo(); const dim = getters.getSheetViewDimension(); const rect = getters.getVisibleRect(getters.getActiveMainViewport()); const scrollableViewportWidth = Math.min(rect.width, dim.width - offsetCorrectionX); const scrollableViewportHeight = Math.min(rect.height, dim.height - offsetCorrectionY); const position = { x: offsetCorrectionX + scrollX + Math.max(0, (scrollableViewportWidth - size.width) / 2), y: offsetCorrectionY + scrollY + Math.max(0, (scrollableViewportHeight - size.height) / 2), }; // Position at the center of the scrollable viewport return position; } function getMaxFigureSize(getters, figureSize) { const size = deepCopy(figureSize); const dim = getters.getSheetViewDimension(); const maxWidth = dim.width; const maxHeight = dim.height; if (size.width > maxWidth) { const ratio = maxWidth / size.width; size.width = maxWidth; size.height = size.height * ratio; } if (size.height > maxHeight) { const ratio = maxHeight / size.height; size.height = maxHeight; size.width = size.width * ratio; } return size; } const figureRegistry = new Registry(); figureRegistry.add("chart", { Component: ChartFigure, SidePanelComponent: "ChartPanel", menuBuilder: getChartMenu, }); figureRegistry.add("image", { Component: ImageFigure, keepRatio: true, minFigSize: 20, borderWidth: 0, menuBuilder: getImageMenuRegistry, }); function getChartMenu(figureId, onFigureDeleted, env) { const menuItemSpecs = [ { id: "edit", name: _t("Edit"), sequence: 1, execute: () => { env.model.dispatch("SELECT_FIGURE", { id: figureId }); env.openSidePanel("ChartPanel"); }, icon: "o-spreadsheet-Icon.EDIT", }, getCopyMenuItem(figureId, env), getCutMenuItem(figureId, env), getDeleteMenuItem(figureId, onFigureDeleted, env), ]; return createActions(menuItemSpecs); } function getImageMenuRegistry(figureId, onFigureDeleted, env) { const menuItemSpecs = [ getCopyMenuItem(figureId, env), getCutMenuItem(figureId, env), { id: "reset_size", name: _t("Reset size"), sequence: 4, execute: async () => { const imagePath = env.model.getters.getImagePath(figureId); const size = env.model.getters.getImageSize(figureId) ?? (await env.imageProvider?.getImageOriginalSize(imagePath)); if (!env.model.getters.getImageSize(figureId)) { const image = env.model.getters.getImage(figureId); image.size = size; } const { height, width } = getMaxFigureSize(env.model.getters, size); env.model.dispatch("UPDATE_FIGURE", { sheetId: env.model.getters.getActiveSheetId(), id: figureId, height, width, }); }, icon: "o-spreadsheet-Icon.REFRESH", }, getDeleteMenuItem(figureId, onFigureDeleted, env), ]; return createActions(menuItemSpecs); } function getCopyMenuItem(figureId, env) { return { id: "copy", name: _t("Copy"), sequence: 2, description: "Ctrl+C", execute: async () => { env.model.dispatch("SELECT_FIGURE", { id: figureId }); env.model.dispatch("COPY"); await env.clipboard.write(env.model.getters.getClipboardContent()); }, icon: "o-spreadsheet-Icon.COPY", }; } function getCutMenuItem(figureId, env) { return { id: "cut", name: _t("Cut"), sequence: 3, description: "Ctrl+X", execute: async () => { env.model.dispatch("SELECT_FIGURE", { id: figureId }); env.model.dispatch("CUT"); await env.clipboard.write(env.model.getters.getClipboardContent()); }, icon: "o-spreadsheet-Icon.CUT", }; } function getDeleteMenuItem(figureId, onFigureDeleted, env) { return { id: "delete", name: _t("Delete"), sequence: 10, execute: () => { env.model.dispatch("DELETE_FIGURE", { sheetId: env.model.getters.getActiveSheetId(), id: figureId, }); onFigureDeleted(); }, icon: "o-spreadsheet-Icon.TRASH", }; } const inverseCommandRegistry = new Registry() .add("ADD_COLUMNS_ROWS", inverseAddColumnsRows) .add("REMOVE_COLUMNS_ROWS", inverseRemoveColumnsRows) .add("ADD_MERGE", inverseAddMerge) .add("REMOVE_MERGE", inverseRemoveMerge) .add("CREATE_SHEET", inverseCreateSheet) .add("DELETE_SHEET", inverseDeleteSheet) .add("DUPLICATE_SHEET", inverseDuplicateSheet) .add("CREATE_FIGURE", inverseCreateFigure) .add("CREATE_CHART", inverseCreateChart) .add("HIDE_COLUMNS_ROWS", inverseHideColumnsRows) .add("UNHIDE_COLUMNS_ROWS", inverseUnhideColumnsRows) .add("CREATE_TABLE_STYLE", inverseCreateTableStyle) .add("ADD_PIVOT", inverseAddPivot); for (const cmd of coreTypes.values()) { if (!inverseCommandRegistry.contains(cmd)) { inverseCommandRegistry.add(cmd, identity); } } function identity(cmd) { return [cmd]; } function inverseAddPivot(cmd) { return [ { type: "REMOVE_PIVOT", pivotId: cmd.pivotId, }, ]; } function inverseAddColumnsRows(cmd) { const elements = []; let start = cmd.base; if (cmd.position === "after") { start++; } for (let i = 0; i < cmd.quantity; i++) { elements.push(i + start); } return [ { type: "REMOVE_COLUMNS_ROWS", dimension: cmd.dimension, elements, sheetId: cmd.sheetId, }, ]; } function inverseAddMerge(cmd) { return [{ type: "REMOVE_MERGE", sheetId: cmd.sheetId, target: cmd.target }]; } function inverseRemoveMerge(cmd) { return [{ type: "ADD_MERGE", sheetId: cmd.sheetId, target: cmd.target }]; } function inverseCreateSheet(cmd) { return [{ type: "DELETE_SHEET", sheetId: cmd.sheetId }]; } function inverseDuplicateSheet(cmd) { return [{ type: "DELETE_SHEET", sheetId: cmd.sheetIdTo }]; } function inverseRemoveColumnsRows(cmd) { const commands = []; const elements = [...cmd.elements].sort((a, b) => a - b); for (let group of groupConsecutive(elements)) { const column = group[0] === 0 ? 0 : group[0] - 1; const position = group[0] === 0 ? "before" : "after"; commands.push({ type: "ADD_COLUMNS_ROWS", dimension: cmd.dimension, quantity: group.length, base: column, sheetId: cmd.sheetId, position, }); } return commands; } function inverseDeleteSheet(cmd) { return [{ type: "CREATE_SHEET", sheetId: cmd.sheetId, position: 1 }]; } function inverseCreateFigure(cmd) { return [{ type: "DELETE_FIGURE", id: cmd.figure.id, sheetId: cmd.sheetId }]; } function inverseCreateChart(cmd) { return [{ type: "DELETE_FIGURE", id: cmd.id, sheetId: cmd.sheetId }]; } function inverseHideColumnsRows(cmd) { return [ { type: "UNHIDE_COLUMNS_ROWS", sheetId: cmd.sheetId, dimension: cmd.dimension, elements: cmd.elements, }, ]; } function inverseUnhideColumnsRows(cmd) { return [ { type: "HIDE_COLUMNS_ROWS", sheetId: cmd.sheetId, dimension: cmd.dimension, elements: cmd.elements, }, ]; } function inverseCreateTableStyle(cmd) { return [{ type: "REMOVE_TABLE_STYLE", tableStyleId: cmd.tableStyleId }]; } /** * The class Registry is extended in order to add the function addChild * */ class MenuItemRegistry extends Registry { /** * @override */ add(key, value) { if (value.id === undefined) { value.id = key; } this.content[key] = value; return this; } /** * Add a subitem to an existing item * @param path Path of items to add this subitem * @param value Subitem to add */ addChild(key, path, value, options = { force: false }) { if (typeof value !== "function" && value.id === undefined) { value.id = key; } const root = path.splice(0, 1)[0]; let node = this.content[root]; if (!node) { throw new Error(`Path ${root + ":" + path.join(":")} not found`); } for (let p of path) { const children = node.children; if (!children || typeof children === "function") { throw new Error(`${p} is either not a node or it's dynamically computed`); } node = children.find((elt) => elt.id === p); if (!node) { throw new Error(`Path ${root + ":" + path.join(":")} not found`); } } if (!node.children) { node.children = []; } const children = node.children; if (!children || typeof children === "function") { throw new Error(`${path} is either not a node or it's dynamically computed`); } if ("id" in value) { const valueIndex = children.findIndex((elt) => "id" in elt && elt.id === value.id); if (valueIndex > -1) { if (!options.force) throw new Error(`A child with the id "${value.id}" already exists.`); node.children.splice(valueIndex, 1, value); return this; } } node.children.push(value); return this; } getMenuItems() { return createActions(this.getAll()); } } function interactiveCut(env) { const result = env.model.dispatch("CUT"); if (!result.isSuccessful) { if (result.isCancelledBecause("WrongCutSelection" /* CommandResult.WrongCutSelection */)) { env.raiseError(_t("This operation is not allowed with multiple selections.")); } } } const AddMergeInteractiveContent = { MergeIsDestructive: _t("Merging these cells will only preserve the top-leftmost value. Merge anyway?"), MergeInFilter: _t("You can't merge cells inside of an existing filter."), }; function interactiveAddMerge(env, sheetId, target) { const result = env.model.dispatch("ADD_MERGE", { sheetId, target }); if (result.isCancelledBecause("MergeInTable" /* CommandResult.MergeInTable */)) { env.raiseError(AddMergeInteractiveContent.MergeInFilter); } else if (result.isCancelledBecause("MergeIsDestructive" /* CommandResult.MergeIsDestructive */)) { env.askConfirmation(AddMergeInteractiveContent.MergeIsDestructive, () => { env.model.dispatch("ADD_MERGE", { sheetId, target, force: true }); }); } } class HoveredCellStore extends SpreadsheetStore { mutators = ["clear", "hover"]; col; row; handle(cmd) { switch (cmd.type) { case "ACTIVATE_SHEET": this.clear(); } } hover(position) { this.col = position.col; this.row = position.row; } clear() { this.col = undefined; this.row = undefined; } } class CellPopoverStore extends SpreadsheetStore { mutators = ["open", "close"]; persistentPopover; hoveredCell = this.get(HoveredCellStore); handle(cmd) { switch (cmd.type) { case "ACTIVATE_SHEET": this.close(); } } open({ col, row }, type) { const sheetId = this.getters.getActiveSheetId(); if (!cellPopoverRegistry.contains(type)) { return; } this.persistentPopover = { col, row, sheetId, type }; } close() { this.persistentPopover = undefined; } get persistentCellPopover() { return ((this.persistentPopover && { isOpen: true, ...this.persistentPopover }) || { isOpen: false }); } get isOpen() { return this.persistentPopover !== undefined; } get cellPopover() { const sheetId = this.getters.getActiveSheetId(); if (this.persistentPopover && this.getters.isVisibleInViewport(this.persistentPopover)) { const position = this.getters.getMainCellPosition(this.persistentPopover); const popover = cellPopoverRegistry .get(this.persistentPopover.type) .onOpen?.(position, this.getters); return !popover?.isOpen ? { isOpen: false } : { ...popover, anchorRect: this.computePopoverAnchorRect(this.persistentPopover), }; } const { col, row } = this.hoveredCell; if (col === undefined || row === undefined || !this.getters.isVisibleInViewport({ sheetId, col, row })) { return { isOpen: false }; } const position = this.getters.getMainCellPosition({ sheetId, col, row }); const popover = cellPopoverRegistry .getAll() .map((matcher) => matcher.onHover?.(position, this.getters)) .find((popover) => popover?.isOpen); return !popover?.isOpen ? { isOpen: false } : { ...popover, anchorRect: this.computePopoverAnchorRect(position), }; } computePopoverAnchorRect({ col, row }) { const sheetId = this.getters.getActiveSheetId(); const merge = this.getters.getMerge({ sheetId, col, row }); if (merge) { return this.getters.getVisibleRect(merge); } return this.getters.getVisibleRect(positionToZone({ col, row })); } } /** * Compute the intersection of two rectangles. Returns nothing if the two rectangles don't overlap */ function rectIntersection(rect1, rect2) { return zoneToRect(intersection(rectToZone(rect1), rectToZone(rect2))); } /** Compute the union of the rectangles, ie. the smallest rectangle that contain them all */ function rectUnion(...rects) { return zoneToRect(union(...rects.map(rectToZone))); } function rectToZone(rect) { return { left: rect.x, top: rect.y, right: rect.x + rect.width, bottom: rect.y + rect.height, }; } function zoneToRect(zone) { if (!zone) return undefined; return { x: zone.left, y: zone.top, width: zone.right - zone.left, height: zone.bottom - zone.top, }; } css /* scss */ ` .o-popover { position: absolute; z-index: ${ComponentsImportance.Popover}; overflow: auto; box-shadow: 1px 2px 5px 2px rgb(51 51 51 / 15%); width: fit-content; height: fit-content; } `; class Popover extends Component { static template = "o-spreadsheet-Popover"; static props = { anchorRect: Object, containerRect: { type: Object, optional: true }, positioning: { type: String, optional: true }, maxWidth: { type: Number, optional: true }, maxHeight: { type: Number, optional: true }, verticalOffset: { type: Number, optional: true }, onMouseWheel: { type: Function, optional: true }, onPopoverHidden: { type: Function, optional: true }, onPopoverMoved: { type: Function, optional: true }, zIndex: { type: Number, optional: true }, slots: Object, }; static defaultProps = { positioning: "BottomLeft", verticalOffset: 0, onMouseWheel: () => { }, onPopoverMoved: () => { }, onPopoverHidden: () => { }, zIndex: ComponentsImportance.Popover, }; popoverRef = useRef("popover"); currentPosition = undefined; currentDisplayValue = undefined; spreadsheetRect = useSpreadsheetRect(); containerRect; setup() { this.containerRect = usePopoverContainer(); // useEffect occurs after the DOM is created and the element width/height are computed, but before // the element in rendered, so we can still set its position useEffect(() => { if (!this.containerRect) throw new Error("Popover container is not defined"); const el = this.popoverRef.el; const anchor = rectIntersection(this.props.anchorRect, this.containerRect); const newDisplay = anchor ? "block" : "none"; if (this.currentDisplayValue !== "none" && newDisplay === "none") { this.props.onPopoverHidden?.(); } el.style.display = newDisplay; this.currentDisplayValue = newDisplay; if (!anchor) return; const propsMaxSize = { width: this.props.maxWidth, height: this.props.maxHeight }; let elDims = { width: el.getBoundingClientRect().width, height: el.getBoundingClientRect().height, }; const spreadsheetRect = this.spreadsheetRect; const popoverPositionHelper = this.props.positioning === "BottomLeft" ? new BottomLeftPopoverContext(anchor, this.containerRect, propsMaxSize, spreadsheetRect) : new TopRightPopoverContext(anchor, this.containerRect, propsMaxSize, spreadsheetRect); el.style["max-height"] = popoverPositionHelper.getMaxHeight(elDims.height) + "px"; el.style["max-width"] = popoverPositionHelper.getMaxWidth(elDims.width) + "px"; // Re-compute the dimensions after setting the max-width and max-height elDims = { width: el.getBoundingClientRect().width, height: el.getBoundingClientRect().height, }; let style = popoverPositionHelper.getCss(elDims, this.props.verticalOffset); for (const property of Object.keys(style)) { el.style[property] = style[property]; } const newPosition = popoverPositionHelper.getCurrentPosition(elDims); if (this.currentPosition && newPosition !== this.currentPosition) { this.props.onPopoverMoved?.(); } this.currentPosition = newPosition; }); } get popoverStyle() { return cssPropertiesToCss({ "z-index": `${this.props.zIndex}`, }); } } class PopoverPositionContext { anchorRect; containerRect; propsMaxSize; spreadsheetOffset; constructor(anchorRect, containerRect, propsMaxSize, spreadsheetOffset) { this.anchorRect = anchorRect; this.containerRect = containerRect; this.propsMaxSize = propsMaxSize; this.spreadsheetOffset = spreadsheetOffset; } /** Check if there is enough space for the popover to be rendered at the bottom of the anchorRect */ shouldRenderAtBottom(elementHeight) { return (elementHeight <= this.availableHeightDown || this.availableHeightDown >= this.availableHeightUp); } /** Check if there is enough space for the popover to be rendered at the right of the anchorRect */ shouldRenderAtRight(elementWidth) { return (elementWidth <= this.availableWidthRight || this.availableWidthRight >= this.availableWidthLeft); } getMaxHeight(elementHeight) { const shouldRenderAtBottom = this.shouldRenderAtBottom(elementHeight); const availableHeight = shouldRenderAtBottom ? this.availableHeightDown : this.availableHeightUp; return this.propsMaxSize.height ? Math.min(availableHeight, this.propsMaxSize.height) : availableHeight; } getMaxWidth(elementWidth) { const shouldRenderAtRight = this.shouldRenderAtRight(elementWidth); const availableWidth = shouldRenderAtRight ? this.availableWidthRight : this.availableWidthLeft; return this.propsMaxSize.width ? Math.min(availableWidth, this.propsMaxSize.width) : availableWidth; } getCss(elDims, verticalOffset) { const maxHeight = this.getMaxHeight(elDims.height); const maxWidth = this.getMaxWidth(elDims.width); const actualHeight = Math.min(maxHeight, elDims.height); const actualWidth = Math.min(maxWidth, elDims.width); const shouldRenderAtBottom = this.shouldRenderAtBottom(elDims.height); const shouldRenderAtRight = this.shouldRenderAtRight(elDims.width); verticalOffset = shouldRenderAtBottom ? verticalOffset : -verticalOffset; const cssProperties = { top: this.getTopCoordinate(actualHeight, shouldRenderAtBottom) - this.spreadsheetOffset.y - verticalOffset + "px", left: this.getLeftCoordinate(actualWidth, shouldRenderAtRight) - this.spreadsheetOffset.x + "px", }; return cssProperties; } getCurrentPosition(elDims) { const shouldRenderAtBottom = this.shouldRenderAtBottom(elDims.height); const shouldRenderAtRight = this.shouldRenderAtRight(elDims.width); if (shouldRenderAtBottom && shouldRenderAtRight) return "BottomRight"; if (shouldRenderAtBottom && !shouldRenderAtRight) return "BottomLeft"; if (!shouldRenderAtBottom && shouldRenderAtRight) return "TopRight"; return "TopLeft"; } } class BottomLeftPopoverContext extends PopoverPositionContext { get availableHeightUp() { return this.anchorRect.y - this.containerRect.y; } get availableHeightDown() { return this.containerRect.height - this.availableHeightUp - this.anchorRect.height; } get availableWidthRight() { return this.containerRect.x + this.containerRect.width - this.anchorRect.x; } get availableWidthLeft() { return this.anchorRect.x + this.anchorRect.width - this.containerRect.x; } getTopCoordinate(elementHeight, shouldRenderAtBottom) { if (shouldRenderAtBottom) { return this.anchorRect.y + this.anchorRect.height; } else { return this.anchorRect.y - elementHeight; } } getLeftCoordinate(elementWidth, shouldRenderAtRight) { if (shouldRenderAtRight) { return this.anchorRect.x; } else { return this.anchorRect.x + this.anchorRect.width - elementWidth; } } } class TopRightPopoverContext extends PopoverPositionContext { get availableHeightUp() { return this.anchorRect.y + this.anchorRect.height - this.containerRect.y; } get availableHeightDown() { return this.containerRect.y + this.containerRect.height - this.anchorRect.y; } get availableWidthRight() { return this.containerRect.width - this.anchorRect.width - this.availableWidthLeft; } get availableWidthLeft() { return this.anchorRect.x - this.containerRect.x; } getTopCoordinate(elementHeight, shouldRenderAtBottom) { if (shouldRenderAtBottom) { return this.anchorRect.y; } else { return this.anchorRect.y + this.anchorRect.height - elementHeight; } } getLeftCoordinate(elementWidth, shouldRenderAtRight) { if (shouldRenderAtRight) { return this.anchorRect.x + this.anchorRect.width; } else { return this.anchorRect.x - elementWidth; } } } const ERROR_TOOLTIP_MAX_HEIGHT = 80; const ERROR_TOOLTIP_WIDTH = 180; css /* scss */ ` .o-error-tooltip { font-size: 13px; background-color: white; border-left: 3px solid red; padding: 10px; width: ${ERROR_TOOLTIP_WIDTH}px; box-sizing: border-box !important; overflow-wrap: break-word; .o-error-tooltip-message { overflow: hidden; } } `; class ErrorToolTip extends Component { static maxSize = { maxHeight: ERROR_TOOLTIP_MAX_HEIGHT }; static template = "o-spreadsheet-ErrorToolTip"; static props = { errors: Array, onClosed: { type: Function, optional: true }, }; } const ErrorToolTipPopoverBuilder = { onHover: (position, getters) => { const cell = getters.getEvaluatedCell(position); const errors = []; if (cell.type === CellValueType.error && !!cell.message) { errors.push({ title: _t("Error"), message: cell.message, }); } const validationErrorMessage = getters.getInvalidDataValidationMessage(position); if (validationErrorMessage) { errors.push({ title: _t("Invalid"), message: validationErrorMessage, }); } if (!errors.length) { return { isOpen: false }; } return { isOpen: true, props: { errors: errors }, Component: ErrorToolTip, cellCorner: "TopRight", }; }, }; css /*SCSS*/ ` .o-filter-menu-value { padding: 4px; line-height: 20px; height: 28px; .o-filter-menu-value-checked { width: 20px; } } `; class FilterMenuValueItem extends Component { static template = "o-spreadsheet-FilterMenuValueItem"; static props = { value: String, isChecked: Boolean, isSelected: Boolean, onMouseMove: Function, onClick: Function, scrolledTo: { type: String, optional: true }, }; itemRef = useRef("menuValueItem"); setup() { onWillPatch(() => { if (this.props.scrolledTo) { this.scrollListToSelectedValue(); } }); } scrollListToSelectedValue() { if (!this.itemRef.el) { return; } this.itemRef.el.scrollIntoView?.({ block: this.props.scrolledTo === "bottom" ? "end" : "start", }); } } const FILTER_MENU_HEIGHT = 295; const CSS = css /* scss */ ` .o-filter-menu { box-sizing: border-box; padding: 8px 16px; height: ${FILTER_MENU_HEIGHT}px; line-height: 1; .o-filter-menu-item { display: flex; box-sizing: border-box; height: ${MENU_ITEM_HEIGHT}px; padding: 4px 4px 4px 0px; cursor: pointer; user-select: none; &.selected { background-color: rgba(0, 0, 0, 0.08); } } input { box-sizing: border-box; margin-bottom: 5px; border: 1px solid #949494; height: 24px; padding-right: 28px; } .o-search-icon { right: 5px; top: 3px; opacity: 0.4; svg { height: 16px; width: 16px; vertical-align: middle; } } .o-filter-menu-actions { display: flex; flex-direction: row; margin-bottom: 4px; .o-filter-menu-action-text { cursor: pointer; margin-right: 10px; color: blue; text-decoration: underline; } } .o-filter-menu-list { flex: auto; overflow-y: auto; border: 1px solid #949494; .o-filter-menu-no-values { color: #949494; font-style: italic; } } .o-filter-menu-buttons { margin-top: 9px; .o-button { height: 26px; } } } `; class FilterMenu extends Component { static template = "o-spreadsheet-FilterMenu"; static props = { filterPosition: Object, onClosed: { type: Function, optional: true }, }; static style = CSS; static components = { FilterMenuValueItem }; state = useState({ values: [], textFilter: "", selectedValue: undefined, }); searchBar = useRef("filterMenuSearchBar"); setup() { onWillUpdateProps((nextProps) => { if (!deepEquals(nextProps.filterPosition, this.props.filterPosition)) { this.state.values = this.getFilterHiddenValues(nextProps.filterPosition); } }); this.state.values = this.getFilterHiddenValues(this.props.filterPosition); } get isSortable() { if (!this.table) { return false; } const coreTable = this.env.model.getters.getCoreTableMatchingTopLeft(this.table.range.sheetId, this.table.range.zone); return !this.env.model.getters.isReadonly() && coreTable?.type !== "dynamic"; } getFilterHiddenValues(position) { const sheetId = this.env.model.getters.getActiveSheetId(); const filter = this.env.model.getters.getFilter({ sheetId, ...position }); if (!filter) { return []; } const cellValues = (filter.filteredRange ? positions(filter.filteredRange.zone) : []) .filter(({ row }) => !this.env.model.getters.isRowHidden(sheetId, row)) .map(({ col, row }) => this.env.model.getters.getEvaluatedCell({ sheetId, col, row }).formattedValue); const filterValues = this.env.model.getters.getFilterHiddenValues({ sheetId, ...position }); const strValues = [...cellValues, ...filterValues]; const normalizedFilteredValues = filterValues.map(toLowerCase); // Set with lowercase values to avoid duplicates const normalizedValues = [...new Set(strValues.map(toLowerCase))]; const sortedValues = normalizedValues.sort((val1, val2) => val1.localeCompare(val2, undefined, { numeric: true, sensitivity: "base" })); return sortedValues.map((normalizedValue) => { const checked = normalizedFilteredValues.findIndex((filteredValue) => filteredValue === normalizedValue) === -1; return { checked, string: strValues.find((val) => toLowerCase(val) === normalizedValue) || "", }; }); } checkValue(value) { this.state.selectedValue = value.string; value.checked = !value.checked; this.searchBar.el?.focus(); } onMouseMove(value) { this.state.selectedValue = value.string; } selectAll() { this.displayedValues.forEach((value) => (value.checked = true)); } clearAll() { this.displayedValues.forEach((value) => (value.checked = false)); } get table() { const sheetId = this.env.model.getters.getActiveSheetId(); const position = this.props.filterPosition; return this.env.model.getters.getTable({ sheetId, ...position }); } get displayedValues() { if (!this.state.textFilter) { return this.state.values; } return fuzzyLookup(this.state.textFilter, this.state.values, (val) => val.string); } confirm() { const position = this.props.filterPosition; this.env.model.dispatch("UPDATE_FILTER", { ...position, sheetId: this.env.model.getters.getActiveSheetId(), hiddenValues: this.state.values.filter((val) => !val.checked).map((val) => val.string), }); this.props.onClosed?.(); } cancel() { this.props.onClosed?.(); } onKeyDown(ev) { const displayedValues = this.displayedValues; if (displayedValues.length === 0) return; let selectedIndex = undefined; if (this.state.selectedValue !== undefined) { const index = displayedValues.findIndex((val) => val.string === this.state.selectedValue); selectedIndex = index === -1 ? undefined : index; } switch (ev.key) { case "ArrowDown": if (selectedIndex === undefined) { selectedIndex = 0; } else { selectedIndex = Math.min(selectedIndex + 1, displayedValues.length - 1); } ev.preventDefault(); ev.stopPropagation(); break; case "ArrowUp": if (selectedIndex === undefined) { selectedIndex = displayedValues.length - 1; } else { selectedIndex = Math.max(selectedIndex - 1, 0); } ev.preventDefault(); ev.stopPropagation(); break; case "Enter": if (selectedIndex !== undefined) { this.checkValue(displayedValues[selectedIndex]); } ev.stopPropagation(); ev.preventDefault(); break; } this.state.selectedValue = selectedIndex !== undefined ? displayedValues[selectedIndex].string : undefined; if (ev.key === "ArrowUp" || ev.key === "ArrowDown") { this.scrollListToSelectedValue(ev.key); } } clearScrolledToValue() { this.state.values.forEach((val) => (val.scrolledTo = undefined)); } scrollListToSelectedValue(arrow) { this.clearScrolledToValue(); const selectedValue = this.state.values.find((val) => val.string === this.state.selectedValue); if (selectedValue) { selectedValue.scrolledTo = arrow === "ArrowUp" ? "top" : "bottom"; } } sortFilterZone(sortDirection) { const filterPosition = this.props.filterPosition; const table = this.table; const tableZone = table?.range.zone; if (!filterPosition || !tableZone || tableZone.top === tableZone.bottom) { return; } const sheetId = this.env.model.getters.getActiveSheetId(); const contentZone = { ...tableZone, top: tableZone.top + 1 }; this.env.model.dispatch("SORT_CELLS", { sheetId, col: filterPosition.col, row: contentZone.top, zone: contentZone, sortDirection, sortOptions: { emptyCellAsZero: true, sortHeaders: true }, }); this.props.onClosed?.(); } } const FilterMenuPopoverBuilder = { onOpen: (position, getters) => { return { isOpen: true, props: { filterPosition: position }, Component: FilterMenu, cellCorner: "BottomLeft", }; }, }; const LINK_TOOLTIP_HEIGHT = 32; const LINK_TOOLTIP_WIDTH = 220; css /* scss */ ` .o-link-tool { font-size: 13px; background-color: white; box-shadow: 0 1px 4px 3px rgba(60, 64, 67, 0.15); padding: 6px 12px; border-radius: 4px; display: flex; justify-content: space-between; height: ${LINK_TOOLTIP_HEIGHT}px; width: ${LINK_TOOLTIP_WIDTH}px; box-sizing: border-box !important; img { margin-right: 3px; width: 16px; height: 16px; } a.o-link { color: ${LINK_COLOR}; text-decoration: none; flex-grow: 2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } a.o-link:hover { text-decoration: none; color: #001d1f; cursor: pointer; } } .o-link-icon { float: right; padding-left: 5px; .o-icon { height: 16px; } } .o-link-icon .o-icon { height: 13px; } .o-link-icon:hover { cursor: pointer; color: #000; } `; class LinkDisplay extends Component { static template = "o-spreadsheet-LinkDisplay"; static props = { cellPosition: Object, onClosed: { type: Function, optional: true }, }; cellPopovers; setup() { this.cellPopovers = useStore(CellPopoverStore); } get cell() { const { col, row } = this.props.cellPosition; const sheetId = this.env.model.getters.getActiveSheetId(); return this.env.model.getters.getEvaluatedCell({ sheetId, col, row }); } get link() { if (this.cell.link) { return this.cell.link; } const { col, row } = this.props.cellPosition; throw new Error(`LinkDisplay Component can only be used with link cells. ${toXC(col, row)} is not a link.`); } getUrlRepresentation(link) { return urlRepresentation(link, this.env.model.getters); } openLink() { openLink(this.link, this.env); } edit() { const { col, row } = this.props.cellPosition; this.env.model.selection.selectCell(col, row); this.cellPopovers.open({ col, row }, "LinkEditor"); } unlink() { const sheetId = this.env.model.getters.getActiveSheetId(); const { col, row } = this.props.cellPosition; const style = this.env.model.getters.getCellComputedStyle({ sheetId, col, row }); const textColor = style?.textColor === LINK_COLOR ? undefined : style?.textColor; this.env.model.dispatch("UPDATE_CELL", { col, row, sheetId, content: this.link.label, style: { ...style, textColor, underline: undefined }, }); } } const LinkCellPopoverBuilder = { onHover: (position, getters) => { const cell = getters.getEvaluatedCell(position); const shouldDisplayLink = !getters.isDashboard() && cell.link && getters.isVisibleInViewport(position); if (!shouldDisplayLink) return { isOpen: false }; return { isOpen: true, Component: LinkDisplay, props: { cellPosition: position }, cellCorner: "BottomLeft", }; }, }; const linkSheet = { name: _t("Link sheet"), children: [ (env) => { const sheets = env.model.getters .getSheetIds() .map((sheetId) => env.model.getters.getSheet(sheetId)); return sheets.map((sheet) => ({ id: sheet.id, name: sheet.name, execute: () => markdownLink(sheet.name, buildSheetLink(sheet.id)), })); }, ], }; const deleteSheet = { name: _t("Delete"), isVisible: (env) => { return env.model.getters.getSheetIds().length > 1; }, execute: (env) => env.askConfirmation(_t("Are you sure you want to delete this sheet?"), () => { env.model.dispatch("DELETE_SHEET", { sheetId: env.model.getters.getActiveSheetId() }); }), icon: "o-spreadsheet-Icon.TRASH", }; const duplicateSheet = { name: _t("Duplicate"), execute: (env) => { const sheetIdFrom = env.model.getters.getActiveSheetId(); const sheetIdTo = env.model.uuidGenerator.uuidv4(); env.model.dispatch("DUPLICATE_SHEET", { sheetId: sheetIdFrom, sheetIdTo, }); env.model.dispatch("ACTIVATE_SHEET", { sheetIdFrom, sheetIdTo }); }, icon: "o-spreadsheet-Icon.COPY", }; const renameSheet = (args) => { return { name: _t("Rename"), execute: args.renameSheetCallback, icon: "o-spreadsheet-Icon.RENAME_SHEET", }; }; const changeSheetColor = (args) => { return { name: _t("Change color"), execute: args.openSheetColorPickerCallback, icon: "o-spreadsheet-Icon.PAINT_FORMAT", }; }; const sheetMoveRight = { name: _t("Move right"), isVisible: (env) => { const sheetId = env.model.getters.getActiveSheetId(); const sheetIds = env.model.getters.getVisibleSheetIds(); return sheetIds.indexOf(sheetId) !== sheetIds.length - 1; }, execute: (env) => env.model.dispatch("MOVE_SHEET", { sheetId: env.model.getters.getActiveSheetId(), delta: 1, }), icon: "o-spreadsheet-Icon.MOVE_SHEET_RIGHT", }; const sheetMoveLeft = { name: _t("Move left"), isVisible: (env) => { const sheetId = env.model.getters.getActiveSheetId(); return env.model.getters.getVisibleSheetIds()[0] !== sheetId; }, execute: (env) => env.model.dispatch("MOVE_SHEET", { sheetId: env.model.getters.getActiveSheetId(), delta: -1, }), icon: "o-spreadsheet-Icon.MOVE_SHEET_LEFT", }; const hideSheet = { name: _t("Hide sheet"), isVisible: (env) => env.model.getters.getVisibleSheetIds().length !== 1, execute: (env) => env.model.dispatch("HIDE_SHEET", { sheetId: env.model.getters.getActiveSheetId() }), icon: "o-spreadsheet-Icon.HIDE_SHEET", }; //------------------------------------------------------------------------------ // Link Menu Registry //------------------------------------------------------------------------------ const linkMenuRegistry = new MenuItemRegistry(); linkMenuRegistry.add("sheet", { ...linkSheet, sequence: 10, }); /** * Repeatedly calls a callback function with a time delay between calls. */ function useInterval(callback, delay) { let intervalId; const { setInterval, clearInterval } = window; useEffect(() => { intervalId = setInterval(callback, delay); return () => clearInterval(intervalId); }, () => [delay]); return { pause: () => { clearInterval(intervalId); intervalId = undefined; }, resume: () => { if (intervalId === undefined) { intervalId = setInterval(callback, delay); } }, }; } /** * Calls a callback function with a time delay */ function useTimeOut() { let timeOutId; function clear() { if (timeOutId !== undefined) { clearTimeout(timeOutId); timeOutId = undefined; } } function schedule(callback, delay) { clear(); timeOutId = setTimeout(callback, delay); } onWillUnmount(clear); return { clear, schedule, }; } //------------------------------------------------------------------------------ // Context Menu Component //------------------------------------------------------------------------------ css /* scss */ ` .o-menu { background-color: white; padding: ${MENU_VERTICAL_PADDING}px 0px; width: ${MENU_WIDTH}px; box-sizing: border-box !important; user-select: none; .o-menu-item { box-sizing: border-box; height: ${MENU_ITEM_HEIGHT}px; padding: ${MENU_ITEM_PADDING_VERTICAL}px ${MENU_ITEM_PADDING_HORIZONTAL}px; cursor: pointer; user-select: none; .o-menu-item-name { min-width: 40%; } .o-menu-item-icon { display: inline-block; margin: 0px 8px 0px 0px; width: ${MENU_ITEM_HEIGHT - 2 * MENU_ITEM_PADDING_VERTICAL}px; line-height: ${MENU_ITEM_HEIGHT - 2 * MENU_ITEM_PADDING_VERTICAL}px; } &:not(.disabled) { &:hover, &.o-menu-item-active { background-color: ${BUTTON_ACTIVE_BG}; color: ${BUTTON_ACTIVE_TEXT_COLOR}; } .o-menu-item-description { color: grey; } .o-menu-item-icon { .o-icon { color: ${ICONS_COLOR}; } } } &.disabled { color: ${DISABLED_TEXT_COLOR}; cursor: not-allowed; } } } `; const TIMEOUT_DELAY = 250; class Menu extends Component { static template = "o-spreadsheet-Menu"; static props = { position: Object, menuItems: Array, depth: { type: Number, optional: true }, maxHeight: { type: Number, optional: true }, onClose: Function, onMenuClicked: { type: Function, optional: true }, menuId: { type: String, optional: true }, onMouseOver: { type: Function, optional: true }, width: { type: Number, optional: true }, }; static components = { Menu, Popover }; static defaultProps = { depth: 1, }; subMenu = useState({ isOpen: false, position: null, scrollOffset: 0, menuItems: [], isHoveringChild: false, }); menuRef = useRef("menu"); hoveredMenu = undefined; position = useAbsoluteBoundingRect(this.menuRef); openingTimeOut = useTimeOut(); setup() { useExternalListener(window, "click", this.onExternalClick, { capture: true }); useExternalListener(window, "contextmenu", this.onExternalClick, { capture: true }); onWillUpdateProps((nextProps) => { if (nextProps.menuItems !== this.props.menuItems) { this.closeSubMenu(); } }); onWillUnmount(() => { this.hoveredMenu?.onStopHover?.(this.env); }); } get menuItemsAndSeparators() { const menuItemsAndSeparators = []; for (let i = 0; i < this.props.menuItems.length; i++) { const menuItem = this.props.menuItems[i]; if (menuItem.isVisible(this.env)) { menuItemsAndSeparators.push(menuItem); } if (menuItem.separator && i !== this.props.menuItems.length - 1 && // no separator at the end menuItemsAndSeparators[menuItemsAndSeparators.length - 1] !== "separator" // no double separator ) { menuItemsAndSeparators.push("separator"); } } if (menuItemsAndSeparators[menuItemsAndSeparators.length - 1] === "separator") { menuItemsAndSeparators.pop(); } if (menuItemsAndSeparators.length === 1 && menuItemsAndSeparators[0] === "separator") { return []; } return menuItemsAndSeparators; } get subMenuPosition() { const position = Object.assign({}, this.subMenu.position); position.y -= this.subMenu.scrollOffset || 0; return position; } get popoverProps() { const isRoot = this.props.depth === 1; return { anchorRect: { x: this.props.position.x, y: this.props.position.y, width: isRoot ? 0 : this.props.width || MENU_WIDTH, height: isRoot ? 0 : MENU_ITEM_HEIGHT, }, positioning: "TopRight", verticalOffset: isRoot ? 0 : MENU_VERTICAL_PADDING, onPopoverHidden: () => this.closeSubMenu(), onPopoverMoved: () => this.closeSubMenu(), }; } get childrenHaveIcon() { return this.props.menuItems.some((menuItem) => !!this.getIconName(menuItem)); } getIconName(menu) { if (menu.icon(this.env)) { return menu.icon(this.env); } if (menu.isActive?.(this.env)) { return "o-spreadsheet-Icon.CHECK"; } return ""; } getColor(menu) { return cssPropertiesToCss({ color: menu.textColor }); } getIconColor(menu) { return cssPropertiesToCss({ color: menu.iconColor }); } async activateMenu(menu) { const result = await menu.execute?.(this.env); this.close(); this.props.onMenuClicked?.({ detail: result }); } close() { this.closeSubMenu(); this.props.onClose(); } onExternalClick(ev) { // Don't close a root menu when clicked to open the submenus. const el = this.menuRef.el; if (el && getOpenedMenus().some((el) => isChildEvent(el, ev))) { return; } ev.closedMenuId = this.props.menuId; this.close(); } getName(menu) { return menu.name(this.env); } isRoot(menu) { return !menu.execute; } isEnabled(menu) { if (menu.isEnabled(this.env)) { return this.env.model.getters.isReadonly() ? menu.isReadonlyAllowed : true; } return false; } isActive(menuItem) { return (this.subMenu?.isHoveringChild || false) && this.isParentMenu(this.subMenu, menuItem); } onScroll(ev) { this.subMenu.scrollOffset = ev.target.scrollTop; } /** * If the given menu is not disabled, open it's submenu at the * correct position according to available surrounding space. */ openSubMenu(menu, parentMenuEl) { if (!parentMenuEl) { return; } const y = parentMenuEl.getBoundingClientRect().top; this.subMenu.position = { x: this.position.x, y: y - (this.subMenu.scrollOffset || 0), }; this.subMenu.menuItems = menu.children(this.env); this.subMenu.isOpen = true; this.subMenu.parentMenu = menu; } isParentMenu(subMenu, menuItem) { return subMenu.parentMenu?.id === menuItem.id; } closeSubMenu() { if (this.subMenu.isHoveringChild) { return; } this.subMenu.isOpen = false; this.subMenu.parentMenu = undefined; } onClickMenu(menu, ev) { if (this.isEnabled(menu)) { if (this.isRoot(menu)) { this.openSubMenu(menu, ev.currentTarget); } else { this.activateMenu(menu); } } } onMouseOver(menu, ev) { if (this.isEnabled(menu)) { if (this.isParentMenu(this.subMenu, menu)) { this.openingTimeOut.clear(); return; } const currentTarget = ev.currentTarget; if (this.isRoot(menu)) { this.openingTimeOut.schedule(() => { this.openSubMenu(menu, currentTarget); }, TIMEOUT_DELAY); } } } onMouseOverMainMenu() { this.props.onMouseOver?.(); this.subMenu.isHoveringChild = false; } onMouseOverChildMenu() { this.subMenu.isHoveringChild = true; this.openingTimeOut.clear(); } onMouseEnter(menu, ev) { this.hoveredMenu = menu; menu.onStartHover?.(this.env); } onMouseLeave(menu) { this.openingTimeOut.schedule(this.closeSubMenu.bind(this), TIMEOUT_DELAY); this.hoveredMenu = undefined; menu.onStopHover?.(this.env); } get menuStyle() { return this.props.width ? cssPropertiesToCss({ width: this.props.width + "px" }) : ""; } } const MENU_OFFSET_X = 320; const MENU_OFFSET_Y = 100; const PADDING = 12; const LINK_EDITOR_WIDTH = 340; css /* scss */ ` .o-link-editor { font-size: 13px; background-color: white; box-shadow: 0 1px 4px 3px rgba(60, 64, 67, 0.15); padding: ${PADDING}px; display: flex; flex-direction: column; border-radius: 4px; width: ${LINK_EDITOR_WIDTH}px; .o-section { .o-section-title { font-weight: bold; margin-bottom: 5px; } } .o-buttons { padding-left: 16px; padding-top: 16px; text-align: right; } input.o-input { box-sizing: border-box; width: 100%; padding: 0 23px 4px 0; } .o-link-url { position: relative; flex-grow: 1; button { position: absolute; right: 0px; top: 0px; border: none; height: 20px; width: 20px; background-color: #fff; margin: 2px 3px 1px 0px; padding: 0px 1px 0px 0px; } button:hover { cursor: pointer; } } } `; class LinkEditor extends Component { static template = "o-spreadsheet-LinkEditor"; static props = { cellPosition: Object, onClosed: { type: Function, optional: true }, }; static components = { Menu }; menuItems = linkMenuRegistry.getMenuItems(); link = useState(this.defaultState); menu = useState({ isOpen: false, }); linkEditorRef = useRef("linkEditor"); position = useAbsoluteBoundingRect(this.linkEditorRef); urlInput = useRef("urlInput"); setup() { onMounted(() => this.urlInput.el?.focus()); } get defaultState() { const { col, row } = this.props.cellPosition; const sheetId = this.env.model.getters.getActiveSheetId(); const cell = this.env.model.getters.getEvaluatedCell({ sheetId, col, row }); if (cell.link) { return { url: cell.link.url, label: cell.formattedValue, isUrlEditable: cell.link.isUrlEditable, }; } return { label: cell.formattedValue, url: "", isUrlEditable: true, }; } get menuPosition() { return { x: this.position.x + MENU_OFFSET_X - PADDING - 2, y: this.position.y + MENU_OFFSET_Y, }; } onSpecialLink(ev) { const { detail: markdownLink } = ev; const link = detectLink(markdownLink); if (!link) { return; } this.link.url = link.url; this.link.label = link.label; this.link.isUrlEditable = link.isUrlEditable; } getUrlRepresentation(link) { return urlRepresentation(link, this.env.model.getters); } openMenu() { this.menu.isOpen = true; } removeLink() { this.link.url = ""; this.link.isUrlEditable = true; } save() { const { col, row } = this.props.cellPosition; const locale = this.env.model.getters.getLocale(); const label = this.link.label ? canonicalizeNumberContent(this.link.label, locale) : this.link.url; this.env.model.dispatch("UPDATE_CELL", { col: col, row: row, sheetId: this.env.model.getters.getActiveSheetId(), content: markdownLink(label, this.link.url), }); this.props.onClosed?.(); } cancel() { this.props.onClosed?.(); } onKeyDown(ev) { switch (ev.key) { case "Enter": if (this.link.url) { this.save(); } ev.stopPropagation(); ev.preventDefault(); break; case "Escape": this.cancel(); ev.stopPropagation(); break; } } } const LinkEditorPopoverBuilder = { onOpen: (position, getters) => { return { isOpen: true, props: { cellPosition: position }, Component: LinkEditor, cellCorner: "BottomLeft", }; }, }; cellPopoverRegistry .add("ErrorToolTip", ErrorToolTipPopoverBuilder) .add("LinkCell", LinkCellPopoverBuilder) .add("LinkEditor", LinkEditorPopoverBuilder) .add("FilterMenu", FilterMenuPopoverBuilder); /** * Create a function used to create a Chart based on the definition */ function chartFactory(getters) { const builders = chartRegistry.getAll().sort((a, b) => a.sequence - b.sequence); function createChart(id, definition, sheetId) { const builder = builders.find((builder) => builder.match(definition.type)); if (!builder) { throw new Error(`No builder for this chart: ${definition.type}`); } return builder.createChart(definition, sheetId, getters); } return createChart; } /** * Create a function used to create a Chart Runtime based on the chart class * instance */ function chartRuntimeFactory(getters) { const builders = chartRegistry.getAll().sort((a, b) => a.sequence - b.sequence); function createRuntimeChart(chart) { const builder = builders.find((builder) => builder.match(chart.type)); if (!builder) { throw new Error("No runtime builder for this chart."); } return builder.getChartRuntime(chart, getters); } return createRuntimeChart; } /** * Validate the chart definition given in arguments */ function validateChartDefinition(validator, definition) { const validators = chartRegistry.getAll().find((validator) => validator.match(definition.type)); if (!validators) { throw new Error("Unknown chart type."); } return validators.validateChartDefinition(validator, definition); } /** * Get a new chart definition transformed with the executed command. This * functions will be called during operational transform process */ function transformDefinition(definition, executed) { const transformation = chartRegistry.getAll().find((factory) => factory.match(definition.type)); if (!transformation) { throw new Error("Unknown chart type."); } return transformation.transformDefinition(definition, executed); } /** * Return a "smart" chart definition in the given zone. The definition is "smart" because it will * use the best type of chart to display the data of the zone. * * It will also try to find labels and datasets in the range, and try to find title for the datasets. * * The type of chart will be : * - If the zone is a single non-empty cell, returns a scorecard * - If the all the labels are numbers/date, returns a line chart * - Else returns a bar chart */ function getSmartChartDefinition(zone, getters) { let dataSetZone = zone; const singleColumn = zoneToDimension(zone).numberOfCols === 1; if (!singleColumn) { dataSetZone = { ...zone, left: zone.left + 1 }; } const dataRange = zoneToXc(dataSetZone); const dataSets = [{ dataRange, yAxisId: "y" }]; const sheetId = getters.getActiveSheetId(); const topLeftCell = getters.getCell({ sheetId, col: zone.left, row: zone.top }); if (getZoneArea(zone) === 1 && topLeftCell?.content) { return { type: "scorecard", title: {}, background: topLeftCell.style?.fillColor || undefined, keyValue: zoneToXc(zone), baselineMode: DEFAULT_SCORECARD_BASELINE_MODE, baselineColorUp: DEFAULT_SCORECARD_BASELINE_COLOR_UP, baselineColorDown: DEFAULT_SCORECARD_BASELINE_COLOR_DOWN, }; } const cellsInFirstRow = getters.getEvaluatedCellsInZone(sheetId, { ...dataSetZone, bottom: dataSetZone.top, }); const dataSetsHaveTitle = !!cellsInFirstRow.find((cell) => cell.type !== CellValueType.empty && cell.type !== CellValueType.number); let labelRangeXc; if (!singleColumn) { labelRangeXc = zoneToXc({ ...zone, right: zone.left, }); } // Only display legend for several datasets. const newLegendPos = dataSetZone.right === dataSetZone.left ? "none" : "top"; const labelRange = labelRangeXc ? getters.getRangeFromSheetXC(sheetId, labelRangeXc) : undefined; if (canChartParseLabels(labelRange, getters)) { return { title: {}, dataSets, labelsAsText: false, stacked: false, aggregated: false, cumulative: false, labelRange: labelRangeXc, type: "line", dataSetsHaveTitle, legendPosition: newLegendPos, }; } const _dataSets = createDataSets(getters, dataSets, sheetId, dataSetsHaveTitle); if (singleColumn && getData(getters, _dataSets[0]).every((e) => typeof e === "string" && !isEvaluationError(e))) { return { title: {}, dataSets: [{ dataRange }], aggregated: true, labelRange: dataRange, type: "pie", legendPosition: "top", dataSetsHaveTitle: false, }; } return { title: {}, dataSets, labelRange: labelRangeXc, type: "bar", stacked: false, aggregated: false, dataSetsHaveTitle, legendPosition: newLegendPos, }; } /** * Create a table on the selected zone, with UI warnings to the user if the creation fails. * If a single cell is selected, expand the selection to non-empty adjacent cells to create a table. */ function interactiveCreateTable(env, sheetId, tableConfig) { let target = env.model.getters.getSelectedZones(); let isDynamic = env.model.getters.canCreateDynamicTableOnZones(sheetId, target); if (target.length === 1 && !isDynamic && getZoneArea(target[0]) === 1) { env.model.selection.selectTableAroundSelection(); target = env.model.getters.getSelectedZones(); isDynamic = env.model.getters.canCreateDynamicTableOnZones(sheetId, target); } const ranges = target.map((zone) => env.model.getters.getRangeDataFromZone(sheetId, zone)); const result = env.model.dispatch("CREATE_TABLE", { ranges, sheetId, config: tableConfig, tableType: isDynamic ? "dynamic" : "static", }); if (result.isCancelledBecause("TableOverlap" /* CommandResult.TableOverlap */)) { env.raiseError(TableTerms.Errors.TableOverlap); } else if (result.isCancelledBecause("NonContinuousTargets" /* CommandResult.NonContinuousTargets */)) { env.raiseError(TableTerms.Errors.NonContinuousTargets); } return result; } //------------------------------------------------------------------------------ // Helpers //------------------------------------------------------------------------------ function setFormatter(env, format) { env.model.dispatch("SET_FORMATTING_WITH_PIVOT", { sheetId: env.model.getters.getActiveSheetId(), target: env.model.getters.getSelectedZones(), format, }); } function setStyle(env, style) { env.model.dispatch("SET_FORMATTING", { sheetId: env.model.getters.getActiveSheetId(), target: env.model.getters.getSelectedZones(), style, }); } //------------------------------------------------------------------------------ // Simple actions //------------------------------------------------------------------------------ const PASTE_ACTION = async (env) => paste$1(env); const PASTE_AS_VALUE_ACTION = async (env) => paste$1(env, "asValue"); async function paste$1(env, pasteOption) { const osClipboard = await env.clipboard.read(); switch (osClipboard.status) { case "ok": const clipboardContent = parseOSClipboardContent(osClipboard.content); const clipboardId = clipboardContent.data?.clipboardId; const target = env.model.getters.getSelectedZones(); if (env.model.getters.getClipboardId() !== clipboardId) { interactivePasteFromOS(env, target, clipboardContent, pasteOption); } else { interactivePaste(env, target, pasteOption); } if (env.model.getters.isCutOperation() && pasteOption !== "asValue") { await env.clipboard.write({ [ClipboardMIMEType.PlainText]: "" }); } break; case "notImplemented": env.raiseError(_t("Pasting from the context menu is not supported in this browser. Use keyboard shortcuts ctrl+c / ctrl+v instead.")); break; case "permissionDenied": env.raiseError(_t("Access to the clipboard denied by the browser. Please enable clipboard permission for this page in your browser settings.")); break; } } const PASTE_FORMAT_ACTION = (env) => paste$1(env, "onlyFormat"); //------------------------------------------------------------------------------ // Grid manipulations //------------------------------------------------------------------------------ const DELETE_CONTENT_ROWS_NAME = (env) => { if (env.model.getters.getSelectedZones().length > 1) { return _t("Clear rows"); } let first; let last; const activesRows = env.model.getters.getActiveRows(); if (activesRows.size !== 0) { first = largeMin([...activesRows]); last = largeMax([...activesRows]); } else { const zone = env.model.getters.getSelectedZones()[0]; first = zone.top; last = zone.bottom; } if (first === last) { return _t("Clear row %s", (first + 1).toString()); } return _t("Clear rows %s - %s", (first + 1).toString(), (last + 1).toString()); }; const DELETE_CONTENT_ROWS_ACTION = (env) => { const sheetId = env.model.getters.getActiveSheetId(); const target = [...env.model.getters.getActiveRows()].map((index) => env.model.getters.getRowsZone(sheetId, index, index)); env.model.dispatch("DELETE_CONTENT", { target, sheetId: env.model.getters.getActiveSheetId(), }); }; const DELETE_CONTENT_COLUMNS_NAME = (env) => { if (env.model.getters.getSelectedZones().length > 1) { return _t("Clear columns"); } let first; let last; const activeCols = env.model.getters.getActiveCols(); if (activeCols.size !== 0) { first = largeMin([...activeCols]); last = largeMax([...activeCols]); } else { const zone = env.model.getters.getSelectedZones()[0]; first = zone.left; last = zone.right; } if (first === last) { return _t("Clear column %s", numberToLetters(first)); } return _t("Clear columns %s - %s", numberToLetters(first), numberToLetters(last)); }; const DELETE_CONTENT_COLUMNS_ACTION = (env) => { const sheetId = env.model.getters.getActiveSheetId(); const target = [...env.model.getters.getActiveCols()].map((index) => env.model.getters.getColsZone(sheetId, index, index)); env.model.dispatch("DELETE_CONTENT", { target, sheetId: env.model.getters.getActiveSheetId(), }); }; const REMOVE_ROWS_NAME = (env) => { if (env.model.getters.getSelectedZones().length > 1) { return _t("Delete rows"); } let first; let last; const activesRows = env.model.getters.getActiveRows(); if (activesRows.size !== 0) { first = largeMin([...activesRows]); last = largeMax([...activesRows]); } else { const zone = env.model.getters.getSelectedZones()[0]; first = zone.top; last = zone.bottom; } if (first === last) { return _t("Delete row %s", (first + 1).toString()); } return _t("Delete rows %s - %s", (first + 1).toString(), (last + 1).toString()); }; const REMOVE_ROWS_ACTION = (env) => { let rows = [...env.model.getters.getActiveRows()]; if (!rows.length) { const zone = env.model.getters.getSelectedZones()[0]; for (let i = zone.top; i <= zone.bottom; i++) { rows.push(i); } } env.model.dispatch("REMOVE_COLUMNS_ROWS", { sheetId: env.model.getters.getActiveSheetId(), dimension: "ROW", elements: rows, }); }; const CAN_REMOVE_COLUMNS_ROWS = (dimension, env) => { const sheetId = env.model.getters.getActiveSheetId(); const selectedElements = env.model.getters.getElementsFromSelection(dimension); const includesAllVisibleHeaders = env.model.getters.checkElementsIncludeAllVisibleHeaders(sheetId, dimension, selectedElements); const includesAllNonFrozenHeaders = env.model.getters.checkElementsIncludeAllNonFrozenHeaders(sheetId, dimension, selectedElements); return !includesAllVisibleHeaders && !includesAllNonFrozenHeaders; }; const REMOVE_COLUMNS_NAME = (env) => { if (env.model.getters.getSelectedZones().length > 1) { return _t("Delete columns"); } let first; let last; const activeCols = env.model.getters.getActiveCols(); if (activeCols.size !== 0) { first = largeMin([...activeCols]); last = largeMax([...activeCols]); } else { const zone = env.model.getters.getSelectedZones()[0]; first = zone.left; last = zone.right; } if (first === last) { return _t("Delete column %s", numberToLetters(first)); } return _t("Delete columns %s - %s", numberToLetters(first), numberToLetters(last)); }; const NOT_ALL_VISIBLE_ROWS_SELECTED = (env) => { const sheetId = env.model.getters.getActiveSheetId(); const selectedRows = env.model.getters.getElementsFromSelection("ROW"); return !env.model.getters.checkElementsIncludeAllVisibleHeaders(sheetId, "ROW", selectedRows); }; const REMOVE_COLUMNS_ACTION = (env) => { let columns = [...env.model.getters.getActiveCols()]; if (!columns.length) { const zone = env.model.getters.getSelectedZones()[0]; for (let i = zone.left; i <= zone.right; i++) { columns.push(i); } } env.model.dispatch("REMOVE_COLUMNS_ROWS", { sheetId: env.model.getters.getActiveSheetId(), dimension: "COL", elements: columns, }); }; const NOT_ALL_VISIBLE_COLS_SELECTED = (env) => { const sheetId = env.model.getters.getActiveSheetId(); const selectedCols = env.model.getters.getElementsFromSelection("COL"); return !env.model.getters.checkElementsIncludeAllVisibleHeaders(sheetId, "COL", selectedCols); }; const INSERT_ROWS_BEFORE_ACTION = (env) => { const activeRows = env.model.getters.getActiveRows(); let row; let quantity; if (activeRows.size) { row = largeMin([...activeRows]); quantity = activeRows.size; } else { const zone = env.model.getters.getSelectedZones()[0]; row = zone.top; quantity = zone.bottom - zone.top + 1; } env.model.dispatch("ADD_COLUMNS_ROWS", { sheetId: env.model.getters.getActiveSheetId(), position: "before", base: row, quantity, dimension: "ROW", }); }; const INSERT_ROWS_AFTER_ACTION = (env) => { const activeRows = env.model.getters.getActiveRows(); let row; let quantity; if (activeRows.size) { row = largeMax([...activeRows]); quantity = activeRows.size; } else { const zone = env.model.getters.getSelectedZones()[0]; row = zone.bottom; quantity = zone.bottom - zone.top + 1; } env.model.dispatch("ADD_COLUMNS_ROWS", { sheetId: env.model.getters.getActiveSheetId(), position: "after", base: row, quantity, dimension: "ROW", }); }; const INSERT_COLUMNS_BEFORE_ACTION = (env) => { const activeCols = env.model.getters.getActiveCols(); let column; let quantity; if (activeCols.size) { column = largeMin([...activeCols]); quantity = activeCols.size; } else { const zone = env.model.getters.getSelectedZones()[0]; column = zone.left; quantity = zone.right - zone.left + 1; } env.model.dispatch("ADD_COLUMNS_ROWS", { sheetId: env.model.getters.getActiveSheetId(), position: "before", dimension: "COL", base: column, quantity, }); }; const INSERT_COLUMNS_AFTER_ACTION = (env) => { const activeCols = env.model.getters.getActiveCols(); let column; let quantity; if (activeCols.size) { column = largeMax([...activeCols]); quantity = activeCols.size; } else { const zone = env.model.getters.getSelectedZones()[0]; column = zone.right; quantity = zone.right - zone.left + 1; } env.model.dispatch("ADD_COLUMNS_ROWS", { sheetId: env.model.getters.getActiveSheetId(), position: "after", dimension: "COL", base: column, quantity, }); }; const HIDE_COLUMNS_NAME = (env) => { const cols = env.model.getters.getElementsFromSelection("COL"); let first = cols[0]; let last = cols[cols.length - 1]; if (cols.length === 1) { return _t("Hide column %s", numberToLetters(first).toString()); } else if (last - first + 1 === cols.length) { return _t("Hide columns %s - %s", numberToLetters(first).toString(), numberToLetters(last).toString()); } else { return _t("Hide columns"); } }; const HIDE_ROWS_NAME = (env) => { const rows = env.model.getters.getElementsFromSelection("ROW"); let first = rows[0]; let last = rows[rows.length - 1]; if (rows.length === 1) { return _t("Hide row %s", (first + 1).toString()); } else if (last - first + 1 === rows.length) { return _t("Hide rows %s - %s", (first + 1).toString(), (last + 1).toString()); } else { return _t("Hide rows"); } }; //------------------------------------------------------------------------------ // Charts //------------------------------------------------------------------------------ const CREATE_CHART = (env) => { const getters = env.model.getters; const id = env.model.uuidGenerator.uuidv4(); const sheetId = getters.getActiveSheetId(); if (getZoneArea(env.model.getters.getSelectedZone()) === 1) { env.model.selection.selectTableAroundSelection(); } const size = { width: DEFAULT_FIGURE_WIDTH, height: DEFAULT_FIGURE_HEIGHT }; const position = getChartPositionAtCenterOfViewport(getters, size); const result = env.model.dispatch("CREATE_CHART", { sheetId, id, position, size, definition: getSmartChartDefinition(env.model.getters.getSelectedZone(), env.model.getters), }); if (result.isSuccessful) { env.model.dispatch("SELECT_FIGURE", { id }); env.openSidePanel("ChartPanel"); } }; //------------------------------------------------------------------------------ // Pivots //------------------------------------------------------------------------------ const CREATE_PIVOT = (env) => { const pivotId = env.model.uuidGenerator.uuidv4(); const newSheetId = env.model.uuidGenerator.uuidv4(); const result = env.model.dispatch("INSERT_NEW_PIVOT", { pivotId, newSheetId }); if (result.isSuccessful) { env.openSidePanel("PivotSidePanel", { pivotId }); } }; const REINSERT_DYNAMIC_PIVOT_CHILDREN = (env) => env.model.getters.getPivotIds().map((pivotId, index) => ({ id: `reinsert_dynamic_pivot_${env.model.getters.getPivotFormulaId(pivotId)}`, name: env.model.getters.getPivotDisplayName(pivotId), sequence: index, execute: (env) => { const zone = env.model.getters.getSelectedZone(); const table = env.model.getters.getPivot(pivotId).getTableStructure().export(); env.model.dispatch("INSERT_PIVOT_WITH_TABLE", { pivotId, table, col: zone.left, row: zone.top, sheetId: env.model.getters.getActiveSheetId(), pivotMode: "dynamic", }); env.model.dispatch("REFRESH_PIVOT", { id: pivotId }); }, isVisible: (env) => env.model.getters.getPivot(pivotId).isValid(), })); const REINSERT_STATIC_PIVOT_CHILDREN = (env) => env.model.getters.getPivotIds().map((pivotId, index) => ({ id: `reinsert_static_pivot_${env.model.getters.getPivotFormulaId(pivotId)}`, name: env.model.getters.getPivotDisplayName(pivotId), sequence: index, execute: (env) => { const zone = env.model.getters.getSelectedZone(); const table = env.model.getters.getPivot(pivotId).getTableStructure().export(); env.model.dispatch("INSERT_PIVOT_WITH_TABLE", { pivotId, table, col: zone.left, row: zone.top, sheetId: env.model.getters.getActiveSheetId(), pivotMode: "static", }); env.model.dispatch("REFRESH_PIVOT", { id: pivotId }); }, isVisible: (env) => env.model.getters.getPivot(pivotId).isValid(), })); //------------------------------------------------------------------------------ // Image //------------------------------------------------------------------------------ async function requestImage(env) { try { return await env.imageProvider.requestImage(); } catch { env.raiseError(_t("An unexpected error occurred during the image transfer")); return undefined; } } const CREATE_IMAGE = async (env) => { if (env.imageProvider) { const sheetId = env.model.getters.getActiveSheetId(); const figureId = env.model.uuidGenerator.uuidv4(); const image = await requestImage(env); if (!image) { throw new Error("No image provider was given to the environment"); } const size = getMaxFigureSize(env.model.getters, image.size); const position = centerFigurePosition(env.model.getters, size); env.model.dispatch("CREATE_IMAGE", { sheetId, figureId, position, size, definition: image, }); } }; //------------------------------------------------------------------------------ // Style/Format //------------------------------------------------------------------------------ const FORMAT_PERCENT_ACTION = (env) => setFormatter(env, "0.00%"); //------------------------------------------------------------------------------ // Side panel //------------------------------------------------------------------------------ const OPEN_CF_SIDEPANEL_ACTION = (env) => { env.openSidePanel("ConditionalFormatting", { selection: env.model.getters.getSelectedZones() }); }; const INSERT_LINK = (env) => { let { col, row } = env.model.getters.getActivePosition(); env.getStore(CellPopoverStore).open({ col, row }, "LinkEditor"); }; const INSERT_LINK_NAME = (env) => { const sheetId = env.model.getters.getActiveSheetId(); const { col, row } = env.model.getters.getActivePosition(); const cell = env.model.getters.getEvaluatedCell({ sheetId, col, row }); return cell && cell.link ? _t("Edit link") : _t("Insert link"); }; //------------------------------------------------------------------------------ // Filters action //------------------------------------------------------------------------------ const SELECTED_TABLE_HAS_FILTERS = (env) => { const table = env.model.getters.getFirstTableInSelection(); return table?.config.hasFilters || false; }; const SELECTION_CONTAINS_SINGLE_TABLE = (env) => { const sheetId = env.model.getters.getActiveSheetId(); const selectedZones = env.model.getters.getSelectedZones(); return env.model.getters.getTablesOverlappingZones(sheetId, selectedZones).length === 1; }; const IS_SELECTION_CONTINUOUS = (env) => { return areZonesContinuous(env.model.getters.getSelectedZones()); }; const ADD_DATA_FILTER = (env) => { const sheetId = env.model.getters.getActiveSheetId(); const table = env.model.getters.getFirstTableInSelection(); if (table) { env.model.dispatch("UPDATE_TABLE", { sheetId, zone: table.range.zone, config: { hasFilters: true }, }); } else { const tableConfig = { ...DEFAULT_TABLE_CONFIG, hasFilters: true, bandedRows: false, styleId: "TableStyleLight11", }; interactiveCreateTable(env, sheetId, tableConfig); } }; const REMOVE_DATA_FILTER = (env) => { const sheetId = env.model.getters.getActiveSheetId(); const table = env.model.getters.getFirstTableInSelection(); if (!table) { return; } env.model.dispatch("UPDATE_TABLE", { sheetId, zone: table.range.zone, config: { hasFilters: false }, }); }; const INSERT_TABLE = (env) => { const sheetId = env.model.getters.getActiveSheetId(); const result = interactiveCreateTable(env, sheetId); if (result.isSuccessful) { env.openSidePanel("TableSidePanel", {}); } }; const DELETE_SELECTED_TABLE = (env) => { const position = env.model.getters.getActivePosition(); const table = env.model.getters.getTable(position); if (!table) { return; } env.model.dispatch("REMOVE_TABLE", { sheetId: position.sheetId, target: [table.range.zone], }); }; //------------------------------------------------------------------------------ // Sorting action //------------------------------------------------------------------------------ const IS_ONLY_ONE_RANGE = (env) => { return env.model.getters.getSelectedZones().length === 1; }; const CAN_INSERT_HEADER = (env, dimension) => { if (!IS_ONLY_ONE_RANGE(env)) { return false; } const activeHeaders = dimension === "COL" ? env.model.getters.getActiveCols() : env.model.getters.getActiveRows(); const ortogonalActiveHeaders = dimension === "COL" ? env.model.getters.getActiveRows() : env.model.getters.getActiveCols(); const sheetId = env.model.getters.getActiveSheetId(); const zone = env.model.getters.getSelectedZone(); const allSheetSelected = isEqual(zone, env.model.getters.getSheetZone(sheetId)); return isConsecutive(activeHeaders) && (ortogonalActiveHeaders.size === 0 || allSheetSelected); }; const CREATE_OR_REMOVE_FILTER_ACTION = { name: (env) => SELECTED_TABLE_HAS_FILTERS(env) ? _t("Remove selected filters") : _t("Add filters"), isEnabled: (env) => IS_SELECTION_CONTINUOUS(env), execute: (env) => SELECTED_TABLE_HAS_FILTERS(env) ? REMOVE_DATA_FILTER(env) : ADD_DATA_FILTER(env), icon: "o-spreadsheet-Icon.FILTER_ICON_ACTIVE", }; const undo = { name: _t("Undo"), description: "Ctrl+Z", execute: (env) => env.model.dispatch("REQUEST_UNDO"), isEnabled: (env) => env.model.getters.canUndo(), icon: "o-spreadsheet-Icon.UNDO", }; const redo = { name: _t("Redo"), description: "Ctrl+Y", execute: (env) => env.model.dispatch("REQUEST_REDO"), isEnabled: (env) => env.model.getters.canRedo(), icon: "o-spreadsheet-Icon.REDO", }; const copy = { name: _t("Copy"), description: "Ctrl+C", isReadonlyAllowed: true, execute: async (env) => { env.model.dispatch("COPY"); await env.clipboard.write(env.model.getters.getClipboardContent()); }, icon: "o-spreadsheet-Icon.COPY", }; const cut = { name: _t("Cut"), description: "Ctrl+X", execute: async (env) => { interactiveCut(env); await env.clipboard.write(env.model.getters.getClipboardContent()); }, icon: "o-spreadsheet-Icon.CUT", }; const paste = { name: _t("Paste"), description: "Ctrl+V", execute: PASTE_ACTION, icon: "o-spreadsheet-Icon.PASTE", }; const pasteSpecial = { name: _t("Paste special"), isVisible: (env) => { return !env.model.getters.isCutOperation(); }, icon: "o-spreadsheet-Icon.PASTE", }; const pasteSpecialValue = { name: _t("Paste as value"), description: "Ctrl+Shift+V", execute: PASTE_AS_VALUE_ACTION, }; const pasteSpecialFormat = { name: _t("Paste format only"), execute: PASTE_FORMAT_ACTION, }; const findAndReplace = { name: _t("Find and replace"), description: "Ctrl+H", isReadonlyAllowed: true, execute: (env) => { env.openSidePanel("FindAndReplace", {}); }, icon: "o-spreadsheet-Icon.SEARCH", }; const deleteValues = { name: _t("Delete values"), execute: (env) => env.model.dispatch("DELETE_CONTENT", { sheetId: env.model.getters.getActiveSheetId(), target: env.model.getters.getSelectedZones(), }), }; const deleteRows = { name: REMOVE_ROWS_NAME, execute: REMOVE_ROWS_ACTION, isVisible: (env) => CAN_REMOVE_COLUMNS_ROWS("ROW", env), }; const deleteRow = { ...deleteRows, isVisible: IS_ONLY_ONE_RANGE, }; const clearRows = { name: DELETE_CONTENT_ROWS_NAME, execute: DELETE_CONTENT_ROWS_ACTION, }; const deleteCols = { name: REMOVE_COLUMNS_NAME, execute: REMOVE_COLUMNS_ACTION, isVisible: (env) => CAN_REMOVE_COLUMNS_ROWS("COL", env), }; const deleteCol = { ...deleteCols, isVisible: IS_ONLY_ONE_RANGE, }; const clearCols = { name: DELETE_CONTENT_COLUMNS_NAME, execute: DELETE_CONTENT_COLUMNS_ACTION, }; const deleteCells = { name: _t("Delete cells"), isVisible: IS_ONLY_ONE_RANGE, }; const deleteCellShiftUp = { name: _t("Delete cell and shift up"), execute: (env) => { const zone = env.model.getters.getSelectedZone(); const result = env.model.dispatch("DELETE_CELL", { zone, shiftDimension: "ROW" }); handlePasteResult(env, result); }, }; const deleteCellShiftLeft = { name: _t("Delete cell and shift left"), execute: (env) => { const zone = env.model.getters.getSelectedZone(); const result = env.model.dispatch("DELETE_CELL", { zone, shiftDimension: "COL" }); handlePasteResult(env, result); }, }; const mergeCells = { name: _t("Merge cells"), isEnabled: (env) => !cannotMerge(env), isActive: (env) => isInMerge(env), execute: (env) => toggleMerge(env), icon: "o-spreadsheet-Icon.MERGE_CELL", }; const editTable = { name: () => _t("Edit table"), execute: (env) => env.openSidePanel("TableSidePanel", {}), icon: "o-spreadsheet-Icon.EDIT_TABLE", }; const deleteTable = { name: () => _t("Delete table"), execute: DELETE_SELECTED_TABLE, icon: "o-spreadsheet-Icon.DELETE_TABLE", }; function cannotMerge(env) { const zones = env.model.getters.getSelectedZones(); const { top, left, right, bottom } = env.model.getters.getSelectedZone(); const { sheetId } = env.model.getters.getActivePosition(); const { xSplit, ySplit } = env.model.getters.getPaneDivisions(sheetId); return (zones.length > 1 || (top === bottom && left === right) || (left < xSplit && xSplit <= right) || (top < ySplit && ySplit <= bottom)); } function isInMerge(env) { if (!cannotMerge(env)) { const zones = env.model.getters.getSelectedZones(); const { col, row, sheetId } = env.model.getters.getActivePosition(); const zone = env.model.getters.expandZone(sheetId, positionToZone({ col, row })); return isEqual(zones[0], zone); } return false; } function toggleMerge(env) { if (cannotMerge(env)) { return; } const zones = env.model.getters.getSelectedZones(); const target = [zones[zones.length - 1]]; const sheetId = env.model.getters.getActiveSheetId(); if (isInMerge(env)) { env.model.dispatch("REMOVE_MERGE", { sheetId, target }); } else { interactiveAddMerge(env, sheetId, target); } } var ACTION_EDIT = /*#__PURE__*/Object.freeze({ __proto__: null, clearCols: clearCols, clearRows: clearRows, copy: copy, cut: cut, deleteCellShiftLeft: deleteCellShiftLeft, deleteCellShiftUp: deleteCellShiftUp, deleteCells: deleteCells, deleteCol: deleteCol, deleteCols: deleteCols, deleteRow: deleteRow, deleteRows: deleteRows, deleteTable: deleteTable, deleteValues: deleteValues, editTable: editTable, findAndReplace: findAndReplace, mergeCells: mergeCells, paste: paste, pasteSpecial: pasteSpecial, pasteSpecialFormat: pasteSpecialFormat, pasteSpecialValue: pasteSpecialValue, redo: redo, undo: undo }); const insertRow = { name: (env) => { const number = getRowsNumber(env); return number === 1 ? _t("Insert row") : _t("Insert %s rows", number.toString()); }, isVisible: (env) => CAN_INSERT_HEADER(env, "ROW"), icon: "o-spreadsheet-Icon.INSERT_ROW", }; const rowInsertRowBefore = { name: (env) => { const number = getRowsNumber(env); return number === 1 ? _t("Insert row above") : _t("Insert %s rows above", number.toString()); }, execute: INSERT_ROWS_BEFORE_ACTION, isVisible: (env) => CAN_INSERT_HEADER(env, "ROW"), icon: "o-spreadsheet-Icon.INSERT_ROW_BEFORE", }; const topBarInsertRowsBefore = { ...rowInsertRowBefore, name: (env) => { const number = getRowsNumber(env); if (number === 1) { return _t("Row above"); } return _t("%s Rows above", number.toString()); }, }; const cellInsertRowsBefore = { ...rowInsertRowBefore, name: (env) => { const number = getRowsNumber(env); if (number === 1) { return _t("Insert row"); } return _t("Insert %s rows", number.toString()); }, isVisible: IS_ONLY_ONE_RANGE, icon: "o-spreadsheet-Icon.INSERT_ROW_BEFORE", }; const rowInsertRowsAfter = { execute: INSERT_ROWS_AFTER_ACTION, name: (env) => { const number = getRowsNumber(env); return number === 1 ? _t("Insert row below") : _t("Insert %s rows below", number.toString()); }, isVisible: (env) => CAN_INSERT_HEADER(env, "ROW"), icon: "o-spreadsheet-Icon.INSERT_ROW_AFTER", }; const topBarInsertRowsAfter = { ...rowInsertRowsAfter, name: (env) => { const number = getRowsNumber(env); if (number === 1) { return _t("Row below"); } return _t("%s Rows below", number.toString()); }, }; const insertCol = { name: (env) => { const number = getColumnsNumber(env); return number === 1 ? _t("Insert column") : _t("Insert %s columns", number.toString()); }, isVisible: (env) => CAN_INSERT_HEADER(env, "COL"), icon: "o-spreadsheet-Icon.INSERT_COL", }; const colInsertColsBefore = { name: (env) => { const number = getColumnsNumber(env); return number === 1 ? _t("Insert column left") : _t("Insert %s columns left", number.toString()); }, execute: INSERT_COLUMNS_BEFORE_ACTION, isVisible: (env) => CAN_INSERT_HEADER(env, "COL"), icon: "o-spreadsheet-Icon.INSERT_COL_BEFORE", }; const topBarInsertColsBefore = { ...colInsertColsBefore, name: (env) => { const number = getColumnsNumber(env); if (number === 1) { return _t("Column left"); } return _t("%s Columns left", number.toString()); }, }; const cellInsertColsBefore = { ...colInsertColsBefore, name: (env) => { const number = getColumnsNumber(env); if (number === 1) { return _t("Insert column"); } return _t("Insert %s columns", number.toString()); }, isVisible: IS_ONLY_ONE_RANGE, icon: "o-spreadsheet-Icon.INSERT_COL_BEFORE", }; const colInsertColsAfter = { name: (env) => { const number = getColumnsNumber(env); return number === 1 ? _t("Insert column right") : _t("Insert %s columns right", number.toString()); }, execute: INSERT_COLUMNS_AFTER_ACTION, isVisible: (env) => CAN_INSERT_HEADER(env, "COL"), icon: "o-spreadsheet-Icon.INSERT_COL_AFTER", }; const topBarInsertColsAfter = { ...colInsertColsAfter, name: (env) => { const number = getColumnsNumber(env); if (number === 1) { return _t("Column right"); } return _t("%s Columns right", number.toString()); }, execute: INSERT_COLUMNS_AFTER_ACTION, }; const insertCell = { name: _t("Insert cells"), isVisible: (env) => IS_ONLY_ONE_RANGE(env) && env.model.getters.getActiveCols().size === 0 && env.model.getters.getActiveRows().size === 0, icon: "o-spreadsheet-Icon.INSERT_CELL", }; const insertCellShiftDown = { name: _t("Insert cells and shift down"), execute: (env) => { const zone = env.model.getters.getSelectedZone(); const result = env.model.dispatch("INSERT_CELL", { zone, shiftDimension: "ROW" }); handlePasteResult(env, result); }, isVisible: (env) => env.model.getters.getActiveRows().size === 0 && env.model.getters.getActiveCols().size === 0, icon: "o-spreadsheet-Icon.INSERT_CELL_SHIFT_DOWN", }; const insertCellShiftRight = { name: _t("Insert cells and shift right"), execute: (env) => { const zone = env.model.getters.getSelectedZone(); const result = env.model.dispatch("INSERT_CELL", { zone, shiftDimension: "COL" }); handlePasteResult(env, result); }, isVisible: (env) => env.model.getters.getActiveRows().size === 0 && env.model.getters.getActiveCols().size === 0, icon: "o-spreadsheet-Icon.INSERT_CELL_SHIFT_RIGHT", }; const insertChart = { name: _t("Chart"), execute: CREATE_CHART, icon: "o-spreadsheet-Icon.INSERT_CHART", }; const insertPivot = { name: _t("Pivot table"), execute: CREATE_PIVOT, icon: "o-spreadsheet-Icon.PIVOT", }; const insertImage = { name: _t("Image"), description: "Ctrl+O", execute: CREATE_IMAGE, isVisible: (env) => env.imageProvider !== undefined, icon: "o-spreadsheet-Icon.INSERT_IMAGE", }; const insertTable = { name: () => _t("Table"), execute: INSERT_TABLE, isVisible: (env) => IS_SELECTION_CONTINUOUS(env) && !env.model.getters.getFirstTableInSelection(), icon: "o-spreadsheet-Icon.PAINT_TABLE", }; const insertFunction = { name: _t("Function"), icon: "o-spreadsheet-Icon.FORMULA", }; const insertFunctionSum = { name: _t("SUM"), execute: (env) => env.startCellEdition(`=SUM(`), }; const insertFunctionAverage = { name: _t("AVERAGE"), execute: (env) => env.startCellEdition(`=AVERAGE(`), }; const insertFunctionCount = { name: _t("COUNT"), execute: (env) => env.startCellEdition(`=COUNT(`), }; const insertFunctionMax = { name: _t("MAX"), execute: (env) => env.startCellEdition(`=MAX(`), }; const insertFunctionMin = { name: _t("MIN"), execute: (env) => env.startCellEdition(`=MIN(`), }; const categorieFunctionAll = { name: _t("All"), children: [allFunctionListMenuBuilder], }; function allFunctionListMenuBuilder() { const fnNames = functionRegistry.getKeys().filter((key) => !functionRegistry.get(key).hidden); return createFormulaFunctions(fnNames); } const categoriesFunctionListMenuBuilder = () => { const functions = functionRegistry.content; const categories = [ ...new Set(functionRegistry .getAll() .filter((fn) => !fn.hidden) .map((fn) => fn.category)), ].filter(isDefined); return categories.sort().map((category, i) => { const functionsInCategory = Object.keys(functions).filter((key) => functions[key].category === category && !functions[key].hidden); return { name: category, children: createFormulaFunctions(functionsInCategory), }; }); }; const insertLink = { name: _t("Link"), execute: INSERT_LINK, icon: "o-spreadsheet-Icon.INSERT_LINK", }; const insertCheckbox = { name: _t("Checkbox"), execute: (env) => { const zones = env.model.getters.getSelectedZones(); const sheetId = env.model.getters.getActiveSheetId(); const ranges = zones.map((zone) => env.model.getters.getRangeDataFromZone(sheetId, zone)); env.model.dispatch("ADD_DATA_VALIDATION_RULE", { ranges, sheetId, rule: { id: env.model.uuidGenerator.uuidv4(), criterion: { type: "isBoolean", values: [], }, }, }); }, icon: "o-spreadsheet-Icon.INSERT_CHECKBOX", }; const insertDropdown = { name: _t("Dropdown list"), execute: (env) => { const zones = env.model.getters.getSelectedZones(); const sheetId = env.model.getters.getActiveSheetId(); const ranges = zones.map((zone) => env.model.getters.getRangeDataFromZone(sheetId, zone)); const ruleID = env.model.uuidGenerator.uuidv4(); env.model.dispatch("ADD_DATA_VALIDATION_RULE", { ranges, sheetId, rule: { id: ruleID, criterion: { type: "isValueInList", values: [], displayStyle: "arrow", }, }, }); const rule = env.model.getters.getDataValidationRule(sheetId, ruleID); if (!rule) { return; } env.openSidePanel("DataValidationEditor", { rule: localizeDataValidationRule(rule, env.model.getters.getLocale()), onExit: () => { env.openSidePanel("DataValidation"); }, }); }, icon: "o-spreadsheet-Icon.INSERT_DROPDOWN", }; const insertSheet = { name: _t("Insert sheet"), execute: (env) => { const activeSheetId = env.model.getters.getActiveSheetId(); const position = env.model.getters.getSheetIds().indexOf(activeSheetId) + 1; const sheetId = env.model.uuidGenerator.uuidv4(); env.model.dispatch("CREATE_SHEET", { sheetId, position }); env.model.dispatch("ACTIVATE_SHEET", { sheetIdFrom: activeSheetId, sheetIdTo: sheetId }); }, icon: "o-spreadsheet-Icon.INSERT_SHEET", }; function createFormulaFunctions(fnNames) { return fnNames.sort().map((fnName, i) => { return { name: fnName, sequence: i * 10, execute: (env) => env.startCellEdition(`=${fnName}(`), }; }); } function getRowsNumber(env) { const activeRows = env.model.getters.getActiveRows(); if (activeRows.size) { return activeRows.size; } else { const zone = env.model.getters.getSelectedZones()[0]; return zone.bottom - zone.top + 1; } } function getColumnsNumber(env) { const activeCols = env.model.getters.getActiveCols(); if (activeCols.size) { return activeCols.size; } else { const zone = env.model.getters.getSelectedZones()[0]; return zone.right - zone.left + 1; } } const pivotProperties = { name: _t("Edit Pivot"), execute(env) { const position = env.model.getters.getActivePosition(); const pivotId = env.model.getters.getPivotIdFromPosition(position); env.openSidePanel("PivotSidePanel", { pivotId }); }, isVisible: (env) => { const position = env.model.getters.getActivePosition(); const pivotId = env.model.getters.getPivotIdFromPosition(position); return (pivotId && env.model.getters.isExistingPivot(pivotId)) || false; }, icon: "o-spreadsheet-Icon.PIVOT", }; const FIX_FORMULAS = { name: _t("Convert to individual formulas"), execute(env) { const position = env.model.getters.getActivePosition(); const cell = env.model.getters.getCorrespondingFormulaCell(position); const pivotId = env.model.getters.getPivotIdFromPosition(position); if (!cell || !pivotId) { return; } const { sheetId, col, row } = env.model.getters.getCellPosition(cell.id); const pivot = env.model.getters.getPivot(pivotId); pivot.init(); if (!pivot.isValid()) { return; } env.model.dispatch("SPLIT_PIVOT_FORMULA", { sheetId, col, row, pivotId, }); }, isVisible: (env) => { const position = env.model.getters.getActivePosition(); const pivotId = env.model.getters.getPivotIdFromPosition(position); if (!pivotId) { return false; } const pivot = env.model.getters.getPivot(pivotId); const cell = env.model.getters.getEvaluatedCell(position); return (pivot.isValid() && env.model.getters.isSpillPivotFormula(position) && cell.type !== CellValueType.error); }, icon: "o-spreadsheet-Icon.PIVOT", }; //------------------------------------------------------------------------------ // Context Menu Registry //------------------------------------------------------------------------------ const cellMenuRegistry = new MenuItemRegistry(); cellMenuRegistry .add("cut", { ...cut, sequence: 10, }) .add("copy", { ...copy, sequence: 20, }) .add("paste", { ...paste, sequence: 30, }) .add("paste_special", { ...pasteSpecial, sequence: 40, separator: true, }) .addChild("paste_value_only", ["paste_special"], { ...pasteSpecialValue, sequence: 10, }) .addChild("paste_format_only", ["paste_special"], { ...pasteSpecialFormat, sequence: 20, }) .add("add_row_before", { ...cellInsertRowsBefore, sequence: 70, }) .add("add_column_before", { ...cellInsertColsBefore, sequence: 90, }) .add("insert_cell", { ...insertCell, sequence: 100, separator: true, }) .addChild("insert_cell_down", ["insert_cell"], { ...insertCellShiftDown, name: _t("Shift down"), sequence: 10, }) .addChild("insert_cell_right", ["insert_cell"], { ...insertCellShiftRight, name: _t("Shift right"), sequence: 20, }) .add("delete_row", { ...deleteRow, sequence: 110, icon: "o-spreadsheet-Icon.TRASH", }) .add("delete_column", { ...deleteCol, sequence: 120, icon: "o-spreadsheet-Icon.TRASH", }) .add("delete_cell", { ...deleteCells, sequence: 130, separator: true, icon: "o-spreadsheet-Icon.TRASH", }) .addChild("delete_cell_up", ["delete_cell"], { ...deleteCellShiftUp, name: _t("Shift up"), sequence: 10, icon: "o-spreadsheet-Icon.DELETE_CELL_SHIFT_UP", }) .addChild("delete_cell_left", ["delete_cell"], { ...deleteCellShiftLeft, name: _t("Shift left"), sequence: 20, icon: "o-spreadsheet-Icon.DELETE_CELL_SHIFT_LEFT", }) .add("edit_table", { ...editTable, isVisible: SELECTION_CONTAINS_SINGLE_TABLE, sequence: 140, }) .add("delete_table", { ...deleteTable, isVisible: SELECTION_CONTAINS_SINGLE_TABLE, sequence: 145, separator: true, }) .add("insert_link", { ...insertLink, name: INSERT_LINK_NAME, sequence: 150, separator: true, }) .add("pivot_fix_formulas", { ...FIX_FORMULAS, sequence: 155, }) .add("pivot_properties", { ...pivotProperties, sequence: 160, separator: true, }); const sortRange = { name: _t("Sort range"), isVisible: IS_ONLY_ONE_RANGE, icon: "o-spreadsheet-Icon.SORT_RANGE", }; const sortAscending = { name: _t("Ascending (A ⟶ Z)"), execute: (env) => { const { anchor, zones } = env.model.getters.getSelection(); const sheetId = env.model.getters.getActiveSheetId(); interactiveSortSelection(env, sheetId, anchor.cell, zones[0], "ascending"); }, icon: "o-spreadsheet-Icon.SORT_ASCENDING", }; const dataCleanup = { name: _t("Data cleanup"), icon: "o-spreadsheet-Icon.DATA_CLEANUP", }; const removeDuplicates = { name: _t("Remove duplicates"), execute: (env) => { if (getZoneArea(env.model.getters.getSelectedZone()) === 1) { env.model.selection.selectTableAroundSelection(); } env.openSidePanel("RemoveDuplicates", {}); }, }; const trimWhitespace = { name: _t("Trim whitespace"), execute: (env) => { env.model.dispatch("TRIM_WHITESPACE"); }, }; const sortDescending = { name: _t("Descending (Z ⟶ A)"), execute: (env) => { const { anchor, zones } = env.model.getters.getSelection(); const sheetId = env.model.getters.getActiveSheetId(); interactiveSortSelection(env, sheetId, anchor.cell, zones[0], "descending"); }, icon: "o-spreadsheet-Icon.SORT_DESCENDING", }; const createRemoveFilter = { ...CREATE_OR_REMOVE_FILTER_ACTION, }; const createRemoveFilterTool = { ...CREATE_OR_REMOVE_FILTER_ACTION, isActive: (env) => SELECTED_TABLE_HAS_FILTERS(env), }; const splitToColumns = { name: _t("Split text to columns"), sequence: 1, execute: (env) => env.openSidePanel("SplitToColumns", {}), isEnabled: (env) => env.model.getters.isSingleColSelected(), icon: "o-spreadsheet-Icon.SPLIT_TEXT", }; const reinsertDynamicPivotMenu = { id: "reinsert_dynamic_pivot", name: _t("Re-insert dynamic pivot"), sequence: 1020, icon: "o-spreadsheet-Icon.INSERT_PIVOT", children: [REINSERT_DYNAMIC_PIVOT_CHILDREN], isVisible: (env) => env.model.getters.getPivotIds().some((id) => env.model.getters.getPivot(id).isValid()), }; const reinsertStaticPivotMenu = { id: "reinsert_static_pivot", name: _t("Re-insert static pivot"), sequence: 1020, icon: "o-spreadsheet-Icon.INSERT_PIVOT", children: [REINSERT_STATIC_PIVOT_CHILDREN], isVisible: (env) => env.model.getters.getPivotIds().some((id) => env.model.getters.getPivot(id).isValid()), }; var ACTION_DATA = /*#__PURE__*/Object.freeze({ __proto__: null, createRemoveFilter: createRemoveFilter, createRemoveFilterTool: createRemoveFilterTool, dataCleanup: dataCleanup, reinsertDynamicPivotMenu: reinsertDynamicPivotMenu, reinsertStaticPivotMenu: reinsertStaticPivotMenu, removeDuplicates: removeDuplicates, sortAscending: sortAscending, sortDescending: sortDescending, sortRange: sortRange, splitToColumns: splitToColumns, trimWhitespace: trimWhitespace }); /** * Create a format action specification for a given format. * The format can be dynamically computed from the environment. */ function createFormatActionSpec({ name, format, descriptionValue, }) { const formatCallback = typeof format === "function" ? format : () => format; return { name, description: (env) => formatValue(descriptionValue, { format: formatCallback(env), locale: env.model.getters.getLocale(), }), execute: (env) => setFormatter(env, formatCallback(env)), isActive: (env) => isFormatSelected(env, formatCallback(env)), format, }; } const formatNumberAutomatic = { name: _t("Automatic"), execute: (env) => setFormatter(env, ""), isActive: (env) => isAutomaticFormatSelected(env), }; const formatNumberPlainText = { name: _t("Plain text"), execute: (env) => setFormatter(env, "@"), isActive: (env) => isFormatSelected(env, "@"), }; const formatNumberNumber = createFormatActionSpec({ name: _t("Number"), descriptionValue: 1000.12, format: "#,##0.00", }); const formatPercent = { name: _t("Format as percent"), execute: FORMAT_PERCENT_ACTION, icon: "o-spreadsheet-Icon.PERCENT", }; const formatNumberPercent = createFormatActionSpec({ name: _t("Percent"), descriptionValue: 0.1012, format: "0.00%", }); const formatNumberCurrency = createFormatActionSpec({ name: _t("Currency"), descriptionValue: 1000.12, format: (env) => createCurrencyFormat(env.model.config.defaultCurrency || DEFAULT_CURRENCY), }); const formatNumberCurrencyRounded = { ...createFormatActionSpec({ name: _t("Currency rounded"), descriptionValue: 1000, format: (env) => roundFormat(createCurrencyFormat(env.model.config.defaultCurrency || DEFAULT_CURRENCY)), }), isVisible: (env) => { const currencyFormat = createCurrencyFormat(env.model.config.defaultCurrency || DEFAULT_CURRENCY); const roundedFormat = roundFormat(currencyFormat); return currencyFormat !== roundedFormat; }, }; const formatNumberAccounting = createFormatActionSpec({ name: _t("Accounting"), descriptionValue: -1000.12, format: (env) => createAccountingFormat(env.model.config.defaultCurrency || DEFAULT_CURRENCY), }); const EXAMPLE_DATE = parseLiteral("2023/09/26 10:43:00 PM", DEFAULT_LOCALE); const formatCustomCurrency = { name: _t("Custom currency"), isVisible: (env) => env.loadCurrencies !== undefined, execute: (env) => env.openSidePanel("CustomCurrency", {}), }; const formatNumberDate = createFormatActionSpec({ name: _t("Date"), descriptionValue: EXAMPLE_DATE, format: (env) => env.model.getters.getLocale().dateFormat, }); const formatNumberTime = createFormatActionSpec({ name: _t("Time"), descriptionValue: EXAMPLE_DATE, format: (env) => env.model.getters.getLocale().timeFormat, }); const formatNumberDateTime = createFormatActionSpec({ name: _t("Date time"), descriptionValue: EXAMPLE_DATE, format: (env) => { const locale = env.model.getters.getLocale(); return getDateTimeFormat(locale); }, }); const formatNumberDuration = createFormatActionSpec({ name: _t("Duration"), descriptionValue: "27:51:38", format: "hhhh:mm:ss", }); const formatNumberQuarter = createFormatActionSpec({ name: _t("Quarter"), descriptionValue: EXAMPLE_DATE, format: "qq yyyy", }); const formatNumberFullQuarter = createFormatActionSpec({ name: _t("Full quarter"), descriptionValue: EXAMPLE_DATE, format: "qqqq yyyy", }); const moreFormats = { name: _t("More date formats"), execute: (env) => env.openSidePanel("MoreFormats", {}), }; const formatNumberFullDateTime = createFormatActionSpec({ name: _t("Full date time"), format: "dddd d mmmm yyyy hh:mm:ss a", descriptionValue: EXAMPLE_DATE, }); const formatNumberFullWeekDayAndMonth = createFormatActionSpec({ name: _t("Full week day and month"), format: "dddd d mmmm yyyy", descriptionValue: EXAMPLE_DATE, }); const formatNumberDayAndFullMonth = createFormatActionSpec({ name: _t("Day and full month"), format: "d mmmm yyyy", descriptionValue: EXAMPLE_DATE, }); const formatNumberShortWeekDay = createFormatActionSpec({ name: _t("Short week day"), format: "ddd d mmm yyyy", descriptionValue: EXAMPLE_DATE, }); const formatNumberDayAndShortMonth = createFormatActionSpec({ name: _t("Day and short month"), format: "d mmm yyyy", descriptionValue: EXAMPLE_DATE, }); const formatNumberFullMonth = createFormatActionSpec({ name: _t("Full month"), format: "mmmm yyyy", descriptionValue: EXAMPLE_DATE, }); const formatNumberShortMonth = createFormatActionSpec({ name: _t("Short month"), format: "mmm yyyy", descriptionValue: EXAMPLE_DATE, }); const incraseDecimalPlaces = { name: _t("Increase decimal places"), icon: "o-spreadsheet-Icon.INCREASE_DECIMAL", execute: (env) => env.model.dispatch("SET_DECIMAL", { sheetId: env.model.getters.getActiveSheetId(), target: env.model.getters.getSelectedZones(), step: 1, }), }; const decraseDecimalPlaces = { name: _t("Decrease decimal places"), icon: "o-spreadsheet-Icon.DECRASE_DECIMAL", execute: (env) => env.model.dispatch("SET_DECIMAL", { sheetId: env.model.getters.getActiveSheetId(), target: env.model.getters.getSelectedZones(), step: -1, }), }; const formatBold = { name: _t("Bold"), description: "Ctrl+B", execute: (env) => setStyle(env, { bold: !env.model.getters.getCurrentStyle().bold }), icon: "o-spreadsheet-Icon.BOLD", isActive: (env) => !!env.model.getters.getCurrentStyle().bold, }; const formatItalic = { name: _t("Italic"), description: "Ctrl+I", execute: (env) => setStyle(env, { italic: !env.model.getters.getCurrentStyle().italic }), icon: "o-spreadsheet-Icon.ITALIC", isActive: (env) => !!env.model.getters.getCurrentStyle().italic, }; const formatUnderline = { name: _t("Underline"), description: "Ctrl+U", execute: (env) => setStyle(env, { underline: !env.model.getters.getCurrentStyle().underline }), icon: "o-spreadsheet-Icon.UNDERLINE", isActive: (env) => !!env.model.getters.getCurrentStyle().underline, }; const formatStrikethrough = { name: _t("Strikethrough"), execute: (env) => setStyle(env, { strikethrough: !env.model.getters.getCurrentStyle().strikethrough }), icon: "o-spreadsheet-Icon.STRIKE", isActive: (env) => !!env.model.getters.getCurrentStyle().strikethrough, }; const formatFontSize = { name: _t("Font size"), children: fontSizeMenuBuilder(), icon: "o-spreadsheet-Icon.FONT_SIZE", }; const formatAlignment = { name: _t("Alignment"), icon: "o-spreadsheet-Icon.ALIGN_LEFT", }; const formatAlignmentHorizontal = { name: _t("Horizontal align"), icon: (env) => getHorizontalAlignmentIcon(env), }; const formatAlignmentLeft = { name: _t("Left"), description: "Ctrl+Shift+L", execute: (env) => setStyle(env, { align: "left" }), isActive: (env) => getHorizontalAlign(env) === "left", icon: "o-spreadsheet-Icon.ALIGN_LEFT", }; const formatAlignmentCenter = { name: _t("Center"), description: "Ctrl+Shift+E", execute: (env) => setStyle(env, { align: "center" }), isActive: (env) => getHorizontalAlign(env) === "center", icon: "o-spreadsheet-Icon.ALIGN_CENTER", }; const formatAlignmentRight = { name: _t("Right"), description: "Ctrl+Shift+R", execute: (env) => setStyle(env, { align: "right" }), isActive: (env) => getHorizontalAlign(env) === "right", icon: "o-spreadsheet-Icon.ALIGN_RIGHT", }; const formatAlignmentVertical = { name: _t("Vertical align"), icon: (env) => getVerticalAlignmentIcon(env), }; const formatAlignmentTop = { name: _t("Top"), execute: (env) => setStyle(env, { verticalAlign: "top" }), isActive: (env) => getVerticalAlign(env) === "top", icon: "o-spreadsheet-Icon.ALIGN_TOP", }; const formatAlignmentMiddle = { name: _t("Middle"), execute: (env) => setStyle(env, { verticalAlign: "middle" }), isActive: (env) => getVerticalAlign(env) === "middle", icon: "o-spreadsheet-Icon.ALIGN_MIDDLE", }; const formatAlignmentBottom = { name: _t("Bottom"), execute: (env) => setStyle(env, { verticalAlign: "bottom" }), isActive: (env) => getVerticalAlign(env) === "bottom", icon: "o-spreadsheet-Icon.ALIGN_BOTTOM", }; const formatWrappingIcon = { name: _t("Wrapping"), icon: "o-spreadsheet-Icon.WRAPPING_OVERFLOW", }; const formatWrapping = { name: _t("Wrapping"), icon: (env) => getWrapModeIcon(env), }; const formatWrappingOverflow = { name: _t("Overflow"), execute: (env) => setStyle(env, { wrapping: "overflow" }), isActive: (env) => getWrappingMode(env) === "overflow", icon: "o-spreadsheet-Icon.WRAPPING_OVERFLOW", }; const formatWrappingWrap = { name: _t("Wrap"), execute: (env) => setStyle(env, { wrapping: "wrap" }), isActive: (env) => getWrappingMode(env) === "wrap", icon: "o-spreadsheet-Icon.WRAPPING_WRAP", }; const formatWrappingClip = { name: _t("Clip"), execute: (env) => setStyle(env, { wrapping: "clip" }), isActive: (env) => getWrappingMode(env) === "clip", icon: "o-spreadsheet-Icon.WRAPPING_CLIP", }; const textColor = { name: _t("Text Color"), icon: "o-spreadsheet-Icon.TEXT_COLOR", }; const fillColor = { name: _t("Fill Color"), icon: "o-spreadsheet-Icon.FILL_COLOR", }; const formatCF = { name: _t("Conditional formatting"), execute: OPEN_CF_SIDEPANEL_ACTION, icon: "o-spreadsheet-Icon.CONDITIONAL_FORMAT", }; const clearFormat = { name: _t("Clear formatting"), description: "Ctrl+<", execute: (env) => env.model.dispatch("CLEAR_FORMATTING", { sheetId: env.model.getters.getActiveSheetId(), target: env.model.getters.getSelectedZones(), }), icon: "o-spreadsheet-Icon.CLEAR_FORMAT", }; function fontSizeMenuBuilder() { return FONT_SIZES.map((fs) => { return { name: fs.toString(), sequence: fs, id: `font_size_${fs}`, execute: (env) => setStyle(env, { fontSize: fs }), isActive: (env) => isFontSizeSelected(env, fs), }; }); } function isAutomaticFormatSelected(env) { const activeCell = env.model.getters.getCell(env.model.getters.getActivePosition()); return !activeCell || !activeCell.format; } function isFormatSelected(env, format) { const activeCell = env.model.getters.getCell(env.model.getters.getActivePosition()); return activeCell?.format === format; } function isFontSizeSelected(env, fontSize) { const currentFontSize = env.model.getters.getCurrentStyle().fontSize || DEFAULT_FONT_SIZE; return currentFontSize === fontSize; } function getHorizontalAlign(env) { const style = env.model.getters.getCurrentStyle(); if (style.align) { return style.align; } const cell = env.model.getters.getActiveCell(); return cell.defaultAlign; } function getVerticalAlign(env) { const style = env.model.getters.getCurrentStyle(); if (style.verticalAlign) { return style.verticalAlign; } return DEFAULT_VERTICAL_ALIGN; } function getWrappingMode(env) { const style = env.model.getters.getCurrentStyle(); if (style.wrapping) { return style.wrapping; } return DEFAULT_WRAPPING_MODE; } function getHorizontalAlignmentIcon(env) { const horizontalAlign = getHorizontalAlign(env); switch (horizontalAlign) { case "right": return "o-spreadsheet-Icon.ALIGN_RIGHT"; case "center": return "o-spreadsheet-Icon.ALIGN_CENTER"; default: return "o-spreadsheet-Icon.ALIGN_LEFT"; } } function getVerticalAlignmentIcon(env) { const verticalAlign = getVerticalAlign(env); switch (verticalAlign) { case "top": return "o-spreadsheet-Icon.ALIGN_TOP"; case "middle": return "o-spreadsheet-Icon.ALIGN_MIDDLE"; default: return "o-spreadsheet-Icon.ALIGN_BOTTOM"; } } function getWrapModeIcon(env) { const wrapMode = getWrappingMode(env); switch (wrapMode) { case "wrap": return "o-spreadsheet-Icon.WRAPPING_WRAP"; case "clip": return "o-spreadsheet-Icon.WRAPPING_CLIP"; default: return "o-spreadsheet-Icon.WRAPPING_OVERFLOW"; } } var ACTION_FORMAT = /*#__PURE__*/Object.freeze({ __proto__: null, EXAMPLE_DATE: EXAMPLE_DATE, clearFormat: clearFormat, createFormatActionSpec: createFormatActionSpec, decraseDecimalPlaces: decraseDecimalPlaces, fillColor: fillColor, formatAlignment: formatAlignment, formatAlignmentBottom: formatAlignmentBottom, formatAlignmentCenter: formatAlignmentCenter, formatAlignmentHorizontal: formatAlignmentHorizontal, formatAlignmentLeft: formatAlignmentLeft, formatAlignmentMiddle: formatAlignmentMiddle, formatAlignmentRight: formatAlignmentRight, formatAlignmentTop: formatAlignmentTop, formatAlignmentVertical: formatAlignmentVertical, formatBold: formatBold, formatCF: formatCF, formatCustomCurrency: formatCustomCurrency, formatFontSize: formatFontSize, formatItalic: formatItalic, formatNumberAccounting: formatNumberAccounting, formatNumberAutomatic: formatNumberAutomatic, formatNumberCurrency: formatNumberCurrency, formatNumberCurrencyRounded: formatNumberCurrencyRounded, formatNumberDate: formatNumberDate, formatNumberDateTime: formatNumberDateTime, formatNumberDayAndFullMonth: formatNumberDayAndFullMonth, formatNumberDayAndShortMonth: formatNumberDayAndShortMonth, formatNumberDuration: formatNumberDuration, formatNumberFullDateTime: formatNumberFullDateTime, formatNumberFullMonth: formatNumberFullMonth, formatNumberFullQuarter: formatNumberFullQuarter, formatNumberFullWeekDayAndMonth: formatNumberFullWeekDayAndMonth, formatNumberNumber: formatNumberNumber, formatNumberPercent: formatNumberPercent, formatNumberPlainText: formatNumberPlainText, formatNumberQuarter: formatNumberQuarter, formatNumberShortMonth: formatNumberShortMonth, formatNumberShortWeekDay: formatNumberShortWeekDay, formatNumberTime: formatNumberTime, formatPercent: formatPercent, formatStrikethrough: formatStrikethrough, formatUnderline: formatUnderline, formatWrapping: formatWrapping, formatWrappingClip: formatWrappingClip, formatWrappingIcon: formatWrappingIcon, formatWrappingOverflow: formatWrappingOverflow, formatWrappingWrap: formatWrappingWrap, incraseDecimalPlaces: incraseDecimalPlaces, moreFormats: moreFormats, textColor: textColor }); function interactiveFreezeColumnsRows(env, dimension, base) { const sheetId = env.model.getters.getActiveSheetId(); const cmd = dimension === "COL" ? "FREEZE_COLUMNS" : "FREEZE_ROWS"; const result = env.model.dispatch(cmd, { sheetId, quantity: base }); if (result.isCancelledBecause("MergeOverlap" /* CommandResult.MergeOverlap */)) { env.raiseError(MergeErrorMessage); } } const hideCols = { name: HIDE_COLUMNS_NAME, execute: (env) => { const columns = env.model.getters.getElementsFromSelection("COL"); env.model.dispatch("HIDE_COLUMNS_ROWS", { sheetId: env.model.getters.getActiveSheetId(), dimension: "COL", elements: columns, }); }, isVisible: NOT_ALL_VISIBLE_COLS_SELECTED, icon: "o-spreadsheet-Icon.HIDE_COL", }; const unhideCols = { name: _t("Unhide columns"), execute: (env) => { const columns = env.model.getters.getElementsFromSelection("COL"); env.model.dispatch("UNHIDE_COLUMNS_ROWS", { sheetId: env.model.getters.getActiveSheetId(), dimension: "COL", elements: columns, }); }, isVisible: (env) => { const hiddenCols = env.model.getters .getHiddenColsGroups(env.model.getters.getActiveSheetId()) .flat(); const currentCols = env.model.getters.getElementsFromSelection("COL"); return currentCols.some((col) => hiddenCols.includes(col)); }, icon: "o-spreadsheet-Icon.UNHIDE_COL", }; const unhideAllCols = { name: _t("Unhide all columns"), execute: (env) => { const sheetId = env.model.getters.getActiveSheetId(); env.model.dispatch("UNHIDE_COLUMNS_ROWS", { sheetId, dimension: "COL", elements: Array.from(Array(env.model.getters.getNumberCols(sheetId)).keys()), }); }, isVisible: (env) => env.model.getters.getHiddenColsGroups(env.model.getters.getActiveSheetId()).length > 0, icon: "o-spreadsheet-Icon.UNHIDE_COL", }; const hideRows = { name: HIDE_ROWS_NAME, execute: (env) => { const rows = env.model.getters.getElementsFromSelection("ROW"); env.model.dispatch("HIDE_COLUMNS_ROWS", { sheetId: env.model.getters.getActiveSheetId(), dimension: "ROW", elements: rows, }); }, isVisible: NOT_ALL_VISIBLE_ROWS_SELECTED, icon: "o-spreadsheet-Icon.HIDE_ROW", }; const unhideRows = { name: _t("Unhide rows"), execute: (env) => { const columns = env.model.getters.getElementsFromSelection("ROW"); env.model.dispatch("UNHIDE_COLUMNS_ROWS", { sheetId: env.model.getters.getActiveSheetId(), dimension: "ROW", elements: columns, }); }, isVisible: (env) => { const hiddenRows = env.model.getters .getHiddenRowsGroups(env.model.getters.getActiveSheetId()) .flat(); const currentRows = env.model.getters.getElementsFromSelection("ROW"); return currentRows.some((col) => hiddenRows.includes(col)); }, icon: "o-spreadsheet-Icon.UNHIDE_ROW", }; const unhideAllRows = { name: _t("Unhide all rows"), execute: (env) => { const sheetId = env.model.getters.getActiveSheetId(); env.model.dispatch("UNHIDE_COLUMNS_ROWS", { sheetId, dimension: "ROW", elements: Array.from(Array(env.model.getters.getNumberRows(sheetId)).keys()), }); }, isVisible: (env) => env.model.getters.getHiddenRowsGroups(env.model.getters.getActiveSheetId()).length > 0, icon: "o-spreadsheet-Icon.UNHIDE_ROW", }; const unFreezePane = { name: _t("Unfreeze"), isVisible: (env) => { const { xSplit, ySplit } = env.model.getters.getPaneDivisions(env.model.getters.getActiveSheetId()); return xSplit + ySplit > 0; }, execute: (env) => env.model.dispatch("UNFREEZE_COLUMNS_ROWS", { sheetId: env.model.getters.getActiveSheetId(), }), icon: "o-spreadsheet-Icon.UNFREEZE", }; const freezePane = { name: _t("Freeze"), icon: "o-spreadsheet-Icon.FREEZE", }; const unFreezeRows = { name: _t("No rows"), execute: (env) => env.model.dispatch("UNFREEZE_ROWS", { sheetId: env.model.getters.getActiveSheetId(), }), isReadonlyAllowed: true, isVisible: (env) => !!env.model.getters.getPaneDivisions(env.model.getters.getActiveSheetId()).ySplit, }; const freezeFirstRow = { name: _t("1 row"), execute: (env) => interactiveFreezeColumnsRows(env, "ROW", 1), isReadonlyAllowed: true, }; const freezeSecondRow = { name: _t("2 rows"), execute: (env) => interactiveFreezeColumnsRows(env, "ROW", 2), isReadonlyAllowed: true, }; const freezeCurrentRow = { name: _t("Up to current row"), execute: (env) => { const { bottom } = env.model.getters.getSelectedZone(); interactiveFreezeColumnsRows(env, "ROW", bottom + 1); }, isReadonlyAllowed: true, }; const unFreezeCols = { name: _t("No columns"), execute: (env) => env.model.dispatch("UNFREEZE_COLUMNS", { sheetId: env.model.getters.getActiveSheetId(), }), isReadonlyAllowed: true, isVisible: (env) => !!env.model.getters.getPaneDivisions(env.model.getters.getActiveSheetId()).xSplit, }; const freezeFirstCol = { name: _t("1 column"), execute: (env) => interactiveFreezeColumnsRows(env, "COL", 1), isReadonlyAllowed: true, }; const freezeSecondCol = { name: _t("2 columns"), execute: (env) => interactiveFreezeColumnsRows(env, "COL", 2), isReadonlyAllowed: true, }; const freezeCurrentCol = { name: _t("Up to current column"), execute: (env) => { const { right } = env.model.getters.getSelectedZone(); interactiveFreezeColumnsRows(env, "COL", right + 1); }, isReadonlyAllowed: true, }; const viewGridlines = { name: _t("Gridlines"), execute: (env) => { const sheetId = env.model.getters.getActiveSheetId(); env.model.dispatch("SET_GRID_LINES_VISIBILITY", { sheetId, areGridLinesVisible: !env.model.getters.getGridLinesVisibility(sheetId), }); }, isActive: (env) => { const sheetId = env.model.getters.getActiveSheetId(); return env.model.getters.getGridLinesVisibility(sheetId); }, }; const viewFormulas = { name: _t("Formulas"), isActive: (env) => env.model.getters.shouldShowFormulas(), execute: (env) => env.model.dispatch("SET_FORMULA_VISIBILITY", { show: !env.model.getters.shouldShowFormulas() }), isReadonlyAllowed: true, }; const groupColumns = { name: (env) => { const selection = env.model.getters.getSelectedZone(); if (selection.left === selection.right) { return _t("Group column %s", numberToLetters(selection.left)); } return _t("Group columns %s - %s", numberToLetters(selection.left), numberToLetters(selection.right)); }, execute: (env) => groupHeadersAction(env, "COL"), isVisible: (env) => { const sheetId = env.model.getters.getActiveSheetId(); const selection = env.model.getters.getSelectedZone(); const groups = env.model.getters.getHeaderGroupsInZone(sheetId, "COL", selection); return (IS_ONLY_ONE_RANGE(env) && !groups.some((group) => group.start === selection.left && group.end === selection.right)); }, icon: "o-spreadsheet-Icon.GROUP_COLUMNS", }; const groupRows = { name: (env) => { const selection = env.model.getters.getSelectedZone(); if (selection.top === selection.bottom) { return _t("Group row %s", String(selection.top + 1)); } return _t("Group rows %s - %s", String(selection.top + 1), String(selection.bottom + 1)); }, execute: (env) => groupHeadersAction(env, "ROW"), isVisible: (env) => { const sheetId = env.model.getters.getActiveSheetId(); const selection = env.model.getters.getSelectedZone(); const groups = env.model.getters.getHeaderGroupsInZone(sheetId, "ROW", selection); return (IS_ONLY_ONE_RANGE(env) && !groups.some((group) => group.start === selection.top && group.end === selection.bottom)); }, icon: "o-spreadsheet-Icon.GROUP_ROWS", }; const ungroupColumns = { name: (env) => { const selection = env.model.getters.getSelectedZone(); if (selection.left === selection.right) { return _t("Ungroup column %s", numberToLetters(selection.left)); } return _t("Ungroup columns %s - %s", numberToLetters(selection.left), numberToLetters(selection.right)); }, execute: (env) => ungroupHeaders(env, "COL"), icon: "o-spreadsheet-Icon.UNGROUP_COLUMNS", }; const ungroupRows = { name: (env) => { const selection = env.model.getters.getSelectedZone(); if (selection.top === selection.bottom) { return _t("Ungroup row %s", String(selection.top + 1)); } return _t("Ungroup rows %s - %s", String(selection.top + 1), String(selection.bottom + 1)); }, execute: (env) => ungroupHeaders(env, "ROW"), icon: "o-spreadsheet-Icon.UNGROUP_ROWS", }; function groupHeadersAction(env, dim) { const selection = env.model.getters.getSelectedZone(); const sheetId = env.model.getters.getActiveSheetId(); env.model.dispatch("GROUP_HEADERS", { sheetId, dimension: dim, start: dim === "COL" ? selection.left : selection.top, end: dim === "COL" ? selection.right : selection.bottom, }); } function ungroupHeaders(env, dim) { const selection = env.model.getters.getSelectedZone(); const sheetId = env.model.getters.getActiveSheetId(); env.model.dispatch("UNGROUP_HEADERS", { sheetId, dimension: dim, start: dim === "COL" ? selection.left : selection.top, end: dim === "COL" ? selection.right : selection.bottom, }); } function canUngroupHeaders(env, dimension) { const sheetId = env.model.getters.getActiveSheetId(); const selection = env.model.getters.getSelectedZones(); return (selection.length === 1 && env.model.getters.getHeaderGroupsInZone(sheetId, dimension, selection[0]).length > 0); } const colMenuRegistry = new MenuItemRegistry(); colMenuRegistry .add("cut", { ...cut, sequence: 10, }) .add("copy", { ...copy, sequence: 20, }) .add("paste", { ...paste, sequence: 30, }) .add("paste_special", { ...pasteSpecial, sequence: 40, separator: true, }) .addChild("paste_value_only", ["paste_special"], { ...pasteSpecialValue, sequence: 10, }) .addChild("paste_format_only", ["paste_special"], { ...pasteSpecialFormat, sequence: 20, }) .add("sort_columns", { ...sortRange, name: (env) => env.model.getters.getActiveCols().size > 1 ? _t("Sort columns") : _t("Sort column"), sequence: 50, separator: true, }) .addChild("sort_ascending", ["sort_columns"], { ...sortAscending, sequence: 10, }) .addChild("sort_descending", ["sort_columns"], { ...sortDescending, sequence: 20, }) .add("add_column_before", { ...colInsertColsBefore, sequence: 70, }) .add("add_column_after", { ...colInsertColsAfter, sequence: 80, }) .add("delete_column", { ...deleteCols, sequence: 90, icon: "o-spreadsheet-Icon.TRASH", }) .add("clear_column", { ...clearCols, sequence: 100, icon: "o-spreadsheet-Icon.CLEAR", }) .add("hide_columns", { ...hideCols, sequence: 105, separator: true, }) .add("unhide_columns", { ...unhideCols, sequence: 106, separator: true, }) .add("conditional_formatting", { ...formatCF, sequence: 110, separator: true, }) .add("edit_table", { ...editTable, isVisible: SELECTION_CONTAINS_SINGLE_TABLE, sequence: 120, }) .add("delete_table", { ...deleteTable, isVisible: SELECTION_CONTAINS_SINGLE_TABLE, sequence: 125, separator: true, }) .add("group_columns", { sequence: 150, ...groupColumns, }) .add("ungroup_columns", { sequence: 155, ...ungroupColumns, isVisible: (env) => canUngroupHeaders(env, "COL"), }); const numberFormatMenuRegistry = new Registry(); numberFormatMenuRegistry .add("format_number_automatic", { ...formatNumberAutomatic, id: "format_number_automatic", sequence: 10, }) .add("format_number_plain_text", { ...formatNumberPlainText, id: "format_number_plain_text", sequence: 15, separator: true, }) .add("format_number_number", { ...formatNumberNumber, id: "format_number_number", sequence: 20, }) .add("format_number_percent", { ...formatNumberPercent, id: "format_number_percent", sequence: 30, separator: true, }) .add("format_number_currency", { ...formatNumberCurrency, id: "format_number_currency", sequence: 40, }) .add("format_number_accounting", { ...formatNumberAccounting, id: "format_number_accounting", sequence: 45, }) .add("format_number_currency_rounded", { ...formatNumberCurrencyRounded, id: "format_number_currency_rounded", sequence: 50, }) .add("format_custom_currency", { ...formatCustomCurrency, id: "format_custom_currency", sequence: 60, separator: true, }) .add("format_number_date", { ...formatNumberDate, id: "format_number_date", sequence: 70, }) .add("format_number_time", { ...formatNumberTime, id: "format_number_time", sequence: 80, }) .add("format_number_date_time", { ...formatNumberDateTime, id: "format_number_date_time", sequence: 90, }) .add("format_number_duration", { ...formatNumberDuration, id: "format_number_duration", sequence: 100, separator: true, }) .add("more_formats", { ...moreFormats, id: "more_formats", sequence: 120, }); function getCustomNumberFormats(env) { const defaultFormats = new Set(numberFormatMenuRegistry .getAll() .map((f) => (typeof f.format === "function" ? f.format(env) : f.format))); const customFormats = new Map(); for (const sheetId of env.model.getters.getSheetIds()) { const cells = env.model.getters.getEvaluatedCells(sheetId); for (const cellId in cells) { const cell = cells[cellId]; if (cell.format && !customFormats.has(cell.format) && !defaultFormats.has(cell.format)) { const formatType = getNumberFormatType(cell.format); if (formatType === "date" || formatType === "currency") { customFormats.set(cell.format, createFormatActionSpec({ descriptionValue: formatType === "currency" ? 1000 : EXAMPLE_DATE, format: cell.format, name: cell.format, })); } } } } return [...customFormats.values()]; } const getNumberFormatType = memoize((format) => { if (isDateTimeFormat(format)) { return "date"; } else if (format.includes("[$")) { return "currency"; } return "number"; }); const formatNumberMenuItemSpec = { name: _t("More formats"), icon: "o-spreadsheet-Icon.NUMBER_FORMATS", children: [ (env) => { const customFormats = getCustomNumberFormats(env).map((action) => ({ ...action, sequence: 110, })); if (customFormats.length > 0) { customFormats[customFormats.length - 1].separator = true; } return createActions([...numberFormatMenuRegistry.getAll(), ...customFormats]); }, ], }; const rowMenuRegistry = new MenuItemRegistry(); rowMenuRegistry .add("cut", { ...cut, sequence: 10, }) .add("copy", { ...copy, sequence: 20, }) .add("paste", { ...paste, sequence: 30, }) .add("paste_special", { ...pasteSpecial, sequence: 40, separator: true, }) .addChild("paste_value_only", ["paste_special"], { ...pasteSpecialValue, sequence: 10, }) .addChild("paste_format_only", ["paste_special"], { ...pasteSpecialFormat, sequence: 20, }) .add("add_row_before", { ...rowInsertRowBefore, sequence: 50, }) .add("add_row_after", { ...rowInsertRowsAfter, sequence: 60, }) .add("delete_row", { ...deleteRows, sequence: 70, icon: "o-spreadsheet-Icon.TRASH", }) .add("clear_row", { ...clearRows, sequence: 80, icon: "o-spreadsheet-Icon.CLEAR", }) .add("hide_rows", { ...hideRows, sequence: 85, separator: true, }) .add("unhide_rows", { ...unhideRows, sequence: 86, separator: true, }) .add("conditional_formatting", { ...formatCF, sequence: 90, separator: true, }) .add("group_rows", { sequence: 100, ...groupRows, }) .add("ungroup_rows", { sequence: 110, ...ungroupRows, isVisible: (env) => canUngroupHeaders(env, "ROW"), }); function getSheetMenuRegistry(args) { const sheetMenuRegistry = new MenuItemRegistry(); sheetMenuRegistry .add("delete", { ...deleteSheet, sequence: 10, }) .add("hide_sheet", { ...hideSheet, sequence: 20, }) .add("duplicate", { ...duplicateSheet, sequence: 30, separator: true, }) .add("rename", { ...renameSheet(args), sequence: 40, }) .add("change_color", { ...changeSheetColor(args), sequence: 50, separator: true, }) .add("move_right", { ...sheetMoveRight, sequence: 60, }) .add("move_left", { ...sheetMoveLeft, sequence: 70, }); return sheetMenuRegistry; } function getPivotHighlights(getters, pivotId) { const sheetId = getters.getActiveSheetId(); const pivotCellPositions = getVisiblePivotCellPositions(getters, pivotId); const mergedZones = mergeContiguousZones(pivotCellPositions.map(positionToZone)); return mergedZones.map((zone) => ({ sheetId, zone, noFill: true, color: HIGHLIGHT_COLOR })); } function getVisiblePivotCellPositions(getters, pivotId) { const positions = []; const sheetId = getters.getActiveSheetId(); for (const col of getters.getSheetViewVisibleCols()) { for (const row of getters.getSheetViewVisibleRows()) { const position = { sheetId, col, row }; const cellPivotId = getters.getPivotIdFromPosition(position); if (pivotId === cellPivotId) { positions.push(position); } } } return positions; } function drawHighlight(renderingContext, highlight, rect) { const { x, y, width, height } = rect; if (width < 0 || height < 0) { return; } const color = highlight.color || HIGHLIGHT_COLOR; const { ctx } = renderingContext; ctx.save(); if (!highlight.noBorder) { if (highlight.dashed) { ctx.setLineDash([5, 3]); } ctx.strokeStyle = color; if (highlight.thinLine) { ctx.lineWidth = 1; ctx.strokeRect(x, y, width, height); } else { ctx.lineWidth = 2; /** + 0.5 offset to have sharp lines. See comment in {@link RendererPlugin#drawBorder} for more details */ ctx.strokeRect(x + 0.5, y + 0.5, width, height); } } if (!highlight.noFill) { ctx.fillStyle = setColorAlpha(toHex(color), highlight.fillAlpha ?? 0.12); ctx.fillRect(x, y, width, height); } ctx.restore(); } class HighlightStore extends SpreadsheetStore { mutators = ["register", "unRegister"]; providers = []; constructor(get) { super(get); this.onDispose(() => { this.providers = []; }); } get renderingLayers() { return ["Highlights"]; } get highlights() { const activeSheetId = this.getters.getActiveSheetId(); return this.providers .flatMap((h) => h.highlights) .filter((h) => h.sheetId === activeSheetId) .map((highlight) => { const { numberOfRows, numberOfCols } = zoneToDimension(highlight.zone); const zone = numberOfRows * numberOfCols === 1 ? this.getters.expandZone(highlight.sheetId, highlight.zone) : highlight.zone; return { ...highlight, zone, }; }); } register(highlightProvider) { this.providers.push(highlightProvider); } unRegister(highlightProvider) { this.providers = this.providers.filter((h) => h !== highlightProvider); } drawLayer(ctx, layer) { if (layer === "Highlights") { for (const highlight of this.highlights) { const rect = this.getters.getVisibleRect(highlight.zone); drawHighlight(ctx, highlight, rect); } } } } const topbarMenuRegistry = new MenuItemRegistry(); topbarMenuRegistry // --------------------------------------------------------------------- // FILE MENU ITEMS // --------------------------------------------------------------------- .add("file", { name: _t("File"), sequence: 10, }) .addChild("settings", ["file"], { name: _t("Settings"), sequence: 100, execute: (env) => env.openSidePanel("Settings"), icon: "o-spreadsheet-Icon.COG", }) // --------------------------------------------------------------------- // EDIT MENU ITEMS // --------------------------------------------------------------------- .add("edit", { name: _t("Edit"), sequence: 20, }) .addChild("undo", ["edit"], { ...undo, sequence: 10, }) .addChild("redo", ["edit"], { ...redo, sequence: 20, separator: true, }) .addChild("copy", ["edit"], { ...copy, sequence: 30, }) .addChild("cut", ["edit"], { ...cut, sequence: 40, }) .addChild("paste", ["edit"], { ...paste, sequence: 50, }) .addChild("paste_special", ["edit"], { ...pasteSpecial, sequence: 60, separator: true, }) .addChild("paste_special_value", ["edit", "paste_special"], { ...pasteSpecialValue, sequence: 10, }) .addChild("paste_special_format", ["edit", "paste_special"], { ...pasteSpecialFormat, sequence: 20, }) .addChild("edit_table", ["edit"], { ...editTable, isVisible: SELECTION_CONTAINS_SINGLE_TABLE, sequence: 60, }) .addChild("find_and_replace", ["edit"], { ...findAndReplace, sequence: 65, separator: true, }) .addChild("delete", ["edit"], { name: _t("Delete"), icon: "o-spreadsheet-Icon.TRASH", sequence: 70, }) .addChild("edit_delete_cell_values", ["edit", "delete"], { ...deleteValues, sequence: 10, }) .addChild("edit_delete_row", ["edit", "delete"], { ...deleteRows, sequence: 20, }) .addChild("edit_delete_column", ["edit", "delete"], { ...deleteCols, sequence: 30, }) .addChild("edit_delete_cell_shift_up", ["edit", "delete"], { ...deleteCellShiftUp, sequence: 40, }) .addChild("edit_delete_cell_shift_left", ["edit", "delete"], { ...deleteCellShiftLeft, sequence: 50, }) .addChild("edit_unhide_columns", ["edit"], { ...unhideAllCols, sequence: 80, }) .addChild("edit_unhide_rows", ["edit"], { ...unhideAllRows, sequence: 80, }) // --------------------------------------------------------------------- // VIEW MENU ITEMS // --------------------------------------------------------------------- .add("view", { name: _t("View"), sequence: 30, }) .addChild("unfreeze_panes", ["view"], { ...unFreezePane, sequence: 4, }) .addChild("freeze_panes", ["view"], { ...freezePane, sequence: 5, }) .addChild("unfreeze_rows", ["view", "freeze_panes"], { ...unFreezeRows, sequence: 5, }) .addChild("freeze_first_row", ["view", "freeze_panes"], { ...freezeFirstRow, sequence: 10, }) .addChild("freeze_second_row", ["view", "freeze_panes"], { ...freezeSecondRow, sequence: 15, }) .addChild("freeze_current_row", ["view", "freeze_panes"], { ...freezeCurrentRow, sequence: 20, separator: true, }) .addChild("unfreeze_columns", ["view", "freeze_panes"], { ...unFreezeCols, sequence: 25, }) .addChild("freeze_first_col", ["view", "freeze_panes"], { ...freezeFirstCol, sequence: 30, }) .addChild("freeze_second_col", ["view", "freeze_panes"], { ...freezeSecondCol, sequence: 35, }) .addChild("freeze_current_col", ["view", "freeze_panes"], { ...freezeCurrentCol, sequence: 40, }) .addChild("group_headers", ["view"], { name: _t("Group"), sequence: 15, separator: true, icon: "o-spreadsheet-Icon.PLUS_IN_BOX", isVisible: IS_ONLY_ONE_RANGE, }) .addChild("group_columns", ["view", "group_headers"], { ...groupColumns, sequence: 5, }) .addChild("ungroup_columns", ["view", "group_headers"], { ...ungroupColumns, isVisible: (env) => canUngroupHeaders(env, "COL"), sequence: 10, }) .addChild("group_rows", ["view", "group_headers"], { ...groupRows, sequence: 15, }) .addChild("ungroup_rows", ["view", "group_headers"], { ...ungroupRows, isVisible: (env) => canUngroupHeaders(env, "ROW"), sequence: 20, }) .addChild("show", ["view"], { name: _t("Show"), sequence: 1, icon: "o-spreadsheet-Icon.SHOW", }) .addChild("view_gridlines", ["view", "show"], { ...viewGridlines, sequence: 5, }) .addChild("view_formulas", ["view", "show"], { ...viewFormulas, sequence: 10, }) // --------------------------------------------------------------------- // INSERT MENU ITEMS // --------------------------------------------------------------------- .add("insert", { name: _t("Insert"), sequence: 40, }) .addChild("insert_row", ["insert"], { ...insertRow, sequence: 10, }) .addChild("insert_row_before", ["insert", "insert_row"], { ...topBarInsertRowsBefore, sequence: 10, }) .addChild("insert_row_after", ["insert", "insert_row"], { ...topBarInsertRowsAfter, sequence: 20, }) .addChild("insert_column", ["insert"], { ...insertCol, sequence: 20, }) .addChild("insert_column_before", ["insert", "insert_column"], { ...topBarInsertColsBefore, sequence: 10, }) .addChild("insert_column_after", ["insert", "insert_column"], { ...topBarInsertColsAfter, sequence: 20, }) .addChild("insert_cell", ["insert"], { ...insertCell, sequence: 30, }) .addChild("insert_cell_down", ["insert", "insert_cell"], { ...insertCellShiftDown, name: _t("Shift down"), sequence: 10, }) .addChild("insert_cell_right", ["insert", "insert_cell"], { ...insertCellShiftRight, name: _t("Shift right"), sequence: 20, }) .addChild("insert_sheet", ["insert"], { ...insertSheet, sequence: 40, separator: true, }) .addChild("insert_chart", ["insert"], { ...insertChart, sequence: 50, }) .addChild("insert_pivot", ["insert"], { ...insertPivot, sequence: 52, }) .addChild("insert_image", ["insert"], { ...insertImage, sequence: 55, }) .addChild("insert_table", ["insert"], { ...insertTable, sequence: 57, }) .addChild("insert_function", ["insert"], { ...insertFunction, sequence: 60, }) .addChild("insert_function_sum", ["insert", "insert_function"], { ...insertFunctionSum, sequence: 0, }) .addChild("insert_function_average", ["insert", "insert_function"], { ...insertFunctionAverage, sequence: 10, }) .addChild("insert_function_count", ["insert", "insert_function"], { ...insertFunctionCount, sequence: 20, }) .addChild("insert_function_max", ["insert", "insert_function"], { ...insertFunctionMax, sequence: 30, }) .addChild("insert_function_min", ["insert", "insert_function"], { ...insertFunctionMin, sequence: 40, separator: true, }) .addChild("categorie_function_all", ["insert", "insert_function"], { ...categorieFunctionAll, sequence: 50, }) .addChild("categories_function_list", ["insert", "insert_function"], categoriesFunctionListMenuBuilder) .addChild("insert_link", ["insert"], { ...insertLink, separator: true, sequence: 70, }) .addChild("insert_checkbox", ["insert"], { ...insertCheckbox, sequence: 80, }) .addChild("insert_dropdown", ["insert"], { ...insertDropdown, separator: true, sequence: 90, }) // --------------------------------------------------------------------- // FORMAT MENU ITEMS // --------------------------------------------------------------------- .add("format", { name: _t("Format"), sequence: 50 }) .addChild("format_number", ["format"], { ...formatNumberMenuItemSpec, name: _t("Number"), sequence: 10, separator: true, }) .addChild("format_bold", ["format"], { ...formatBold, sequence: 20, }) .addChild("format_italic", ["format"], { ...formatItalic, sequence: 30, }) .addChild("format_underline", ["format"], { ...formatUnderline, sequence: 40, }) .addChild("format_strikethrough", ["format"], { ...formatStrikethrough, sequence: 50, separator: true, }) .addChild("format_font_size", ["format"], { ...formatFontSize, sequence: 60, separator: true, }) .addChild("format_alignment", ["format"], { ...formatAlignment, sequence: 70, }) .addChild("format_alignment_left", ["format", "format_alignment"], { ...formatAlignmentLeft, sequence: 10, }) .addChild("format_alignment_center", ["format", "format_alignment"], { ...formatAlignmentCenter, sequence: 20, }) .addChild("format_alignment_right", ["format", "format_alignment"], { ...formatAlignmentRight, sequence: 30, separator: true, }) .addChild("format_alignment_top", ["format", "format_alignment"], { ...formatAlignmentTop, sequence: 40, }) .addChild("format_alignment_middle", ["format", "format_alignment"], { ...formatAlignmentMiddle, sequence: 50, }) .addChild("format_alignment_bottom", ["format", "format_alignment"], { ...formatAlignmentBottom, sequence: 60, separator: true, }) .addChild("format_wrapping", ["format"], { ...formatWrappingIcon, sequence: 80, separator: true, }) .addChild("format_wrapping_overflow", ["format", "format_wrapping"], { ...formatWrappingOverflow, sequence: 10, }) .addChild("format_wrapping_wrap", ["format", "format_wrapping"], { ...formatWrappingWrap, sequence: 20, }) .addChild("format_wrapping_clip", ["format", "format_wrapping"], { ...formatWrappingClip, sequence: 30, }) .addChild("format_cf", ["format"], { ...formatCF, sequence: 90, separator: true, }) .addChild("format_clearFormat", ["format"], { ...clearFormat, sequence: 100, separator: true, }) // --------------------------------------------------------------------- // DATA MENU ITEMS // --------------------------------------------------------------------- .add("data", { name: _t("Data"), sequence: 60, }) .addChild("sort_range", ["data"], { ...sortRange, sequence: 10, separator: true, }) .addChild("sort_ascending", ["data", "sort_range"], { ...sortAscending, sequence: 10, }) .addChild("sort_descending", ["data", "sort_range"], { ...sortDescending, sequence: 20, }) .addChild("data_cleanup", ["data"], { ...dataCleanup, sequence: 15, }) .addChild("remove_duplicates", ["data", "data_cleanup"], { ...removeDuplicates, sequence: 10, }) .addChild("trim_whitespace", ["data", "data_cleanup"], { ...trimWhitespace, sequence: 20, }) .addChild("split_to_columns", ["data"], { ...splitToColumns, sequence: 20, }) .addChild("data_validation", ["data"], { name: _t("Data Validation"), execute: (env) => { env.openSidePanel("DataValidation"); }, icon: "o-spreadsheet-Icon.DATA_VALIDATION", sequence: 30, separator: true, }) .addChild("add_remove_data_filter", ["data"], { ...createRemoveFilter, sequence: 40, separator: true, }) .addChild("data_sources_data", ["data"], (env) => { const sequence = 50; return env.model.getters.getPivotIds().map((pivotId, index) => { const highlightProvider = { get highlights() { return getPivotHighlights(env.model.getters, pivotId); }, }; return { id: `item_pivot_${env.model.getters.getPivotFormulaId(pivotId)}`, name: env.model.getters.getPivotDisplayName(pivotId), sequence: sequence + index, execute: (env) => env.openSidePanel("PivotSidePanel", { pivotId }), onStartHover: (env) => env.getStore(HighlightStore).register(highlightProvider), onStopHover: (env) => env.getStore(HighlightStore).unRegister(highlightProvider), icon: "o-spreadsheet-Icon.PIVOT", separator: index === env.model.getters.getPivotIds().length - 1, secondaryIcon: (env) => env.model.getters.isPivotUnused(pivotId) ? "o-spreadsheet-Icon.UNUSED_PIVOT_WARNING" : undefined, }; }); }) .addChild("reinsert_dynamic_pivot", ["data"], reinsertDynamicPivotMenu) .addChild("reinsert_static_pivot", ["data"], reinsertStaticPivotMenu); class OTRegistry extends Registry { /** * Add a transformation function to the registry. When the executed command * happened, all the commands in toTransforms should be transformed using the * transformation function given */ addTransformation(executed, toTransforms, fn) { for (let toTransform of toTransforms) { if (!this.content[toTransform]) { this.content[toTransform] = new Map(); } this.content[toTransform].set(executed, fn); } return this; } /** * Get the transformation function to transform the command toTransform, after * that the executed command happened. */ getTransformation(toTransform, executed) { return this.content[toTransform] && this.content[toTransform].get(executed); } } const otRegistry = new OTRegistry(); const CHECK_SVG = /*xml*/ ` `; const CHECKBOX_WIDTH = 14; css /* scss */ ` label.o-checkbox { input { appearance: none; -webkit-appearance: none; -moz-appearance: none; border-radius: 0; width: ${CHECKBOX_WIDTH}px; height: ${CHECKBOX_WIDTH}px; vertical-align: top; box-sizing: border-box; outline: none; border: 1px solid ${GRAY_300}; &:hover { border-color: ${ACTION_COLOR}; } &:checked { background: url("data:image/svg+xml,${encodeURIComponent(CHECK_SVG)}"); background-color: ${ACTION_COLOR}; border-color: ${ACTION_COLOR}; } &:focus { outline: none; box-shadow: 0 0 0 0.25rem rgba(113, 75, 103, 0.25); border-color: ${ACTION_COLOR}; } } } `; class Checkbox extends Component { static template = "o-spreadsheet.Checkbox"; static props = { label: { type: String, optional: true }, value: { type: Boolean, optional: true }, className: { type: String, optional: true }, name: { type: String, optional: true }, title: { type: String, optional: true }, disabled: { type: Boolean, optional: true }, onChange: Function, }; static defaultProps = { value: false }; onChange(ev) { const value = ev.target.checked; this.props.onChange(value); } } class Section extends Component { static template = "o_spreadsheet.Section"; static props = { class: { type: String, optional: true }, slots: Object, }; } // The name is misleading and can be confused with the DOM focus. class FocusStore { mutators = ["focus", "unfocus"]; focusedElement = null; focus(element) { this.focusedElement = element; } unfocus(element) { if (this.focusedElement && this.focusedElement === element) { this.focusedElement = null; } } } /** * Selection input Plugin * * The SelectionInput component input and output are both arrays of strings, but * it requires an intermediary internal state to work. * This plugin handles this internal state. */ class SelectionInputStore extends SpreadsheetStore { initialRanges; inputHasSingleRange; colors; mutators = [ "resetWithRanges", "focusById", "unfocus", "addEmptyRange", "removeRange", "changeRange", "reset", "confirm", ]; ranges = []; focusedRangeIndex = null; inputSheetId; focusStore = this.get(FocusStore); highlightStore = this.get(HighlightStore); constructor(get, initialRanges = [], inputHasSingleRange = false, colors = []) { super(get); this.initialRanges = initialRanges; this.inputHasSingleRange = inputHasSingleRange; this.colors = colors; if (inputHasSingleRange && initialRanges.length > 1) { throw new Error("Input with a single range cannot be instantiated with several range references."); } this.inputSheetId = this.getters.getActiveSheetId(); this.resetWithRanges(initialRanges); this.highlightStore.register(this); this.onDispose(() => { this.unfocus(); this.highlightStore.unRegister(this); }); } handleEvent(event) { if (this.focusedRangeIndex === null) { return; } const inputSheetId = this.inputSheetId; const activeSheetId = this.getters.getActiveSheetId(); const zone = event.options.unbounded ? this.getters.getUnboundedZone(activeSheetId, event.anchor.zone) : event.anchor.zone; const range = this.getters.getRangeFromZone(activeSheetId, zone); const willAddNewRange = event.mode === "newAnchor" && !this.inputHasSingleRange && this.ranges[this.focusedRangeIndex].xc.trim() !== ""; if (willAddNewRange) { const xc = this.getters.getSelectionRangeString(range, inputSheetId); this.insertNewRange(this.ranges.length, [xc]); this.focusLast(); } else { let parts = range.parts; const previousXc = this.ranges[this.focusedRangeIndex].xc.trim(); if (previousXc) { parts = this.getters.getRangeFromSheetXC(inputSheetId, previousXc).parts; } const newRange = range.clone({ parts }); const xc = this.getters.getSelectionRangeString(newRange, inputSheetId); this.setRange(this.focusedRangeIndex, [xc]); } } handle(cmd) { switch (cmd.type) { case "ACTIVATE_SHEET": { if (cmd.sheetIdFrom !== cmd.sheetIdTo) { const { col, row } = this.getters.getNextVisibleCellPosition({ sheetId: cmd.sheetIdTo, col: 0, row: 0, }); const zone = this.getters.expandZone(cmd.sheetIdTo, positionToZone({ col, row })); this.model.selection.resetAnchor(this, { cell: { col, row }, zone }); } break; } case "START_CHANGE_HIGHLIGHT": const activeSheetId = this.getters.getActiveSheetId(); const newZone = this.getters.expandZone(activeSheetId, cmd.zone); const focusIndex = this.ranges.findIndex((range) => { const { xc, sheetName: sheet } = splitReference(range.xc); const sheetName = sheet || this.getters.getSheetName(this.inputSheetId); if (this.getters.getSheetName(activeSheetId) !== sheetName) { return false; } const refRange = this.getters.getRangeFromSheetXC(activeSheetId, xc); return isEqual(this.getters.expandZone(activeSheetId, refRange.zone), newZone); }); if (focusIndex !== -1) { this.focus(focusIndex); const { left, top } = newZone; this.model.selection.resetAnchor(this, { cell: { col: left, row: top }, zone: newZone, }); } break; } } changeRange(rangeId, value) { if (this.inputHasSingleRange && value.split(",").length > 1) { return; } const index = this.getIndex(rangeId); if (index !== null && this.focusedRangeIndex !== index) { this.focus(index); } if (index !== null) { const valueWithoutLeadingComma = value.replace(/^,+/, ""); const values = valueWithoutLeadingComma.split(",").map((reference) => reference.trim()); this.setRange(index, values); this.captureSelection(); } } addEmptyRange() { if (this.inputHasSingleRange && this.ranges.length === 1) { return; } this.insertNewRange(this.ranges.length, [""]); this.focusLast(); } removeRange(rangeId) { if (this.ranges.length === 1) { return; } const index = this.getIndex(rangeId); if (index !== null) { this.removeRangeByIndex(index); } } confirm() { for (const range of this.selectionInputs) { if (range.xc === "") { this.removeRange(range.id); } } const activeSheetId = this.getters.getActiveSheetId(); if (this.inputSheetId !== activeSheetId) { this.model.dispatch("ACTIVATE_SHEET", { sheetIdFrom: activeSheetId, sheetIdTo: this.inputSheetId, }); } if (this.selectionInputValues.join() !== this.initialRanges.join()) { this.resetWithRanges(this.selectionInputValues); } this.initialRanges = this.selectionInputValues; this.unfocus(); } reset() { this.resetWithRanges(this.initialRanges); this.confirm(); } get selectionInputValues() { return this.cleanInputs(this.ranges.map((range) => { return range.xc ? range.xc : ""; })); } /** * Return a list of all valid XCs. * e.g. ["A1", "Sheet2!B3", "E12"] */ get selectionInputs() { const generator = new ColorGenerator(this.ranges.length, this.colors); return this.ranges.map((input, index) => Object.assign({}, input, { color: this.hasMainFocus && this.focusedRangeIndex !== null && this.getters.isRangeValid(input.xc) ? generator.next() : null, isFocused: this.hasMainFocus && this.focusedRangeIndex === index, isValidRange: input.xc === "" || this.getters.isRangeValid(input.xc), })); } get isResettable() { return this.initialRanges.join() !== this.ranges.map((r) => r.xc).join(); } get isConfirmable() { return this.selectionInputs.every((range) => range.isValidRange); } get hasFocus() { return this.selectionInputs.some((i) => i.isFocused); } get hasMainFocus() { const focusedElement = this.focusStore.focusedElement; return !!focusedElement && focusedElement === this; } get highlights() { if (!this.hasMainFocus) { return []; } // TODO expand zone globally return this.ranges.map((input) => this.inputToHighlights(input)).flat(); } // --------------------------------------------------------------------------- // Other // --------------------------------------------------------------------------- focusById(rangeId) { this.focus(this.getIndex(rangeId)); } /** * Focus a given range or remove the focus. */ focus(index) { this.focusStore.focus(this); this.focusedRangeIndex = index; this.captureSelection(); } focusLast() { this.focus(this.ranges.length - 1); } unfocus() { this.focusedRangeIndex = null; this.focusStore.unfocus(this); this.model.selection.release(this); } captureSelection() { if (this.focusedRangeIndex === null) { return; } const range = this.ranges[this.focusedRangeIndex]; const sheetId = this.getters.getActiveSheetId(); const zone = this.getters.getRangeFromSheetXC(sheetId, range?.xc || "A1").zone; this.model.selection.capture(this, { cell: { col: zone.left, row: zone.top }, zone }, { handleEvent: this.handleEvent.bind(this), release: this.unfocus.bind(this), }); } resetWithRanges(ranges) { this.ranges = []; this.insertNewRange(0, ranges); if (this.ranges.length === 0) { this.insertNewRange(this.ranges.length, [""]); this.focusLast(); } } setContent(index, xc) { this.ranges[index] = { ...this.ranges[index], xc, }; } /** * Insert new inputs after the given index. */ insertNewRange(index, values) { const currentMaxId = Math.max(0, ...this.ranges.map((range) => Number(range.id))); const colors = new ColorGenerator(this.ranges.length, this.colors); for (let i = 0; i < index; i++) { colors.next(); } this.ranges.splice(index, 0, ...values.map((xc, i) => ({ xc, id: currentMaxId + i + 1, color: colors.next(), }))); } /** * Set a new value in a given range input. If more than one value is provided, * new inputs will be added. */ setRange(index, values) { const [, ...additionalValues] = values; this.setContent(index, values[0]); this.insertNewRange(index + 1, additionalValues); // focus the last newly added range if (additionalValues.length) { this.focus(index + additionalValues.length); } } removeRangeByIndex(index) { this.ranges.splice(index, 1); if (this.focusedRangeIndex !== null) { this.focusLast(); } } /** * Converts highlights input format to the command format. * The first xc in the input range will keep its color. * Invalid ranges and ranges from other sheets than the active sheets * are ignored. */ inputToHighlights({ xc, color }) { const XCs = this.cleanInputs([xc]) .filter((range) => this.getters.isRangeValid(range)) .filter((reference) => this.shouldBeHighlighted(this.inputSheetId, reference)); return XCs.map((xc) => { const { sheetName } = splitReference(xc); return { zone: this.getters.getRangeFromSheetXC(this.inputSheetId, xc).zone, sheetId: (sheetName && this.getters.getSheetIdByName(sheetName)) || this.inputSheetId, color, interactive: true, }; }); } cleanInputs(ranges) { return ranges .map((xc) => xc.split(",")) .flat() .map((xc) => xc.trim()) .filter((xc) => xc !== ""); } /** * Check if a cell or range reference should be highlighted. * It should be highlighted if it references the current active sheet. * Note that if no sheet name is given in the reference ("A1"), it refers to the * active sheet when the selection input was enabled which might be different from * the current active sheet. */ shouldBeHighlighted(inputSheetId, reference) { const { sheetName } = splitReference(reference); const sheetId = this.getters.getSheetIdByName(sheetName); const activeSheetId = this.getters.getActiveSheet().id; const valid = this.getters.isRangeValid(reference); return (valid && (sheetId === activeSheetId || (sheetId === undefined && activeSheetId === inputSheetId))); } /** * Return the index of a range given its id * or `null` if the range is not found. */ getIndex(rangeId) { const index = this.ranges.findIndex((range) => range.id === rangeId); return index >= 0 ? index : null; } } css /* scss */ ` .o-selection { .o-selection-input { padding: 2px 0px; input.o-invalid { background-color: ${ALERT_DANGER_BG}; } .error-icon { right: 7px; top: 4px; } } .o-button { height: 28px; flex-grow: 0; } /** Make the character a bit bigger compared to its neighbor INPUT box */ .o-remove-selection { font-size: calc(100% + 4px); } } `; /** * This component can be used when the user needs to input some * ranges. He can either input the ranges with the regular DOM `` * displayed or by selecting zones on the grid. * * onSelectionChanged is called every time the input value * changes. */ class SelectionInput extends Component { static template = "o-spreadsheet-SelectionInput"; static props = { ranges: Array, hasSingleRange: { type: Boolean, optional: true }, required: { type: Boolean, optional: true }, isInvalid: { type: Boolean, optional: true }, class: { type: String, optional: true }, onSelectionChanged: { type: Function, optional: true }, onSelectionConfirmed: { type: Function, optional: true }, colors: { type: Array, optional: true, default: [] }, }; state = useState({ isMissing: false, mode: "select-range", }); focusedInput = useRef("focusedInput"); store; get ranges() { return this.store.selectionInputs; } get canAddRange() { return !this.props.hasSingleRange; } get isInvalid() { return this.props.isInvalid || this.state.isMissing; } get isConfirmable() { return this.store.isConfirmable; } get isResettable() { return this.store.isResettable; } setup() { useEffect(() => this.focusedInput.el?.focus(), () => [this.focusedInput.el]); this.store = useLocalStore(SelectionInputStore, this.props.ranges, this.props.hasSingleRange || false, this.props.colors); onWillUpdateProps((nextProps) => { if (nextProps.ranges.join() !== this.store.selectionInputValues.join()) { this.triggerChange(); } if (nextProps.ranges.join() !== this.props.ranges.join() && nextProps.ranges.join() !== this.store.selectionInputValues.join()) { this.store.resetWithRanges(nextProps.ranges); } }); } getColor(range) { if (!range.color) { return ""; } return cssPropertiesToCss({ color: range.color }); } triggerChange() { const ranges = this.store.selectionInputValues; this.props.onSelectionChanged?.(ranges); } onKeydown(ev) { if (ev.key === "F2") { ev.preventDefault(); ev.stopPropagation(); this.state.mode = this.state.mode === "select-range" ? "text-edit" : "select-range"; } else if (ev.key.startsWith("Arrow")) { ev.stopPropagation(); if (this.state.mode === "select-range") { ev.preventDefault(); updateSelectionWithArrowKeys(ev, this.env.model.selection); } } else if (ev.key === "Enter") { const target = ev.target; target.blur(); this.confirm(); } } extractRanges(value) { return this.props.hasSingleRange ? value.split(",")[0] : value; } focus(rangeId) { this.state.isMissing = false; this.state.mode = "select-range"; this.store.focusById(rangeId); } addEmptyInput() { this.store.addEmptyRange(); } removeInput(rangeId) { this.store.removeRange(rangeId); this.triggerChange(); this.props.onSelectionConfirmed?.(); } onInputChanged(rangeId, ev) { const target = ev.target; const value = this.extractRanges(target.value); this.store.changeRange(rangeId, value); this.triggerChange(); } reset() { this.store.reset(); this.triggerChange(); } confirm() { this.store.confirm(); const anyValidInput = this.store.selectionInputs.some((range) => this.env.model.getters.isRangeValid(range.xc)); if (this.props.required && !anyValidInput) { this.state.isMissing = true; } this.props.onSelectionChanged?.(this.store.selectionInputValues); this.props.onSelectionConfirmed?.(); } } class ChartDataSeries extends Component { static template = "o-spreadsheet.ChartDataSeries"; static components = { SelectionInput, Section }; static props = { ranges: Array, hasSingleRange: { type: Boolean, optional: true }, onSelectionChanged: Function, onSelectionConfirmed: Function, }; get ranges() { return this.props.ranges.map((r) => r.dataRange); } get colors() { return this.props.ranges.map((r) => r.backgroundColor); } get title() { return this.props.hasSingleRange ? _t("Data range") : _t("Data series"); } } css /* scss */ ` .o-validation { border-radius: 4px; border-width: 0 0 0 3px; border-style: solid; gap: 3px; .o-icon { margin-right: 5px; height: 1.2em; width: 1.2em; } } .o-validation-warning { border-color: ${ALERT_WARNING_BORDER}; color: ${ALERT_WARNING_TEXT_COLOR}; background-color: ${ALERT_WARNING_BG}; } .o-validation-error { border-color: ${ALERT_DANGER_BORDER}; color: ${ALERT_DANGER_TEXT_COLOR}; background-color: ${ALERT_DANGER_BG}; } .o-validation-info { border-color: ${ALERT_INFO_BORDER}; color: ${ALERT_INFO_TEXT_COLOR}; background-color: ${ALERT_INFO_BG}; } `; class ValidationMessages extends Component { static template = "o-spreadsheet-ValidationMessages"; static props = { messages: Array, msgType: String, singleBox: { type: Boolean, optional: true }, }; get divClasses() { if (this.props.msgType === "warning") { return "o-validation-warning"; } if (this.props.msgType === "info") { return "o-validation-info"; } return "o-validation-error"; } get alertBoxes() { return this.props.singleBox ? [this.props.messages] : this.props.messages.map((msg) => [msg]); } } class ChartErrorSection extends Component { static template = "o-spreadsheet.ChartErrorSection"; static components = { Section, ValidationMessages }; static props = { messages: { type: Array, element: String } }; } class ChartLabelRange extends Component { static template = "o-spreadsheet.ChartLabelRange"; static components = { SelectionInput, Checkbox, Section }; static props = { title: { type: String, optional: true }, range: String, isInvalid: Boolean, onSelectionChanged: Function, onSelectionConfirmed: Function, options: { type: Array, optional: true }, }; static defaultProps = { title: _t("Categories / Labels"), options: [], }; } class GenericChartConfigPanel extends Component { static template = "o-spreadsheet-GenericChartConfigPanel"; static components = { ChartDataSeries, ChartLabelRange, Section, Checkbox, ChartErrorSection, }; static props = { figureId: String, definition: Object, updateChart: Function, canUpdateChart: Function, }; state = useState({ datasetDispatchResult: undefined, labelsDispatchResult: undefined, }); dataSeriesRanges = []; labelRange; chartTerms = ChartTerms; setup() { this.dataSeriesRanges = this.props.definition.dataSets; this.labelRange = this.props.definition.labelRange; } get errorMessages() { const cancelledReasons = [ ...(this.state.datasetDispatchResult?.reasons || []), ...(this.state.labelsDispatchResult?.reasons || []), ]; return cancelledReasons.map((error) => ChartTerms.Errors[error] || ChartTerms.Errors.Unexpected); } get isDatasetInvalid() { return !!this.state.datasetDispatchResult?.isCancelledBecause("InvalidDataSet" /* CommandResult.InvalidDataSet */); } get isLabelInvalid() { return !!this.state.labelsDispatchResult?.isCancelledBecause("InvalidLabelRange" /* CommandResult.InvalidLabelRange */); } get dataSetsHaveTitleLabel() { return _t("Use row %s as headers", this.calculateHeaderPosition() || ""); } getLabelRangeOptions() { return [ { name: "aggregated", label: this.chartTerms.AggregatedChart, value: this.props.definition.aggregated ?? false, onChange: this.onUpdateAggregated.bind(this), }, { name: "dataSetsHaveTitle", label: this.dataSetsHaveTitleLabel, value: this.props.definition.dataSetsHaveTitle, onChange: this.onUpdateDataSetsHaveTitle.bind(this), }, ]; } onUpdateDataSetsHaveTitle(dataSetsHaveTitle) { this.props.updateChart(this.props.figureId, { dataSetsHaveTitle, }); } /** * Change the local dataSeriesRanges. The model should be updated when the * button "confirm" is clicked */ onDataSeriesRangesChanged(ranges) { this.dataSeriesRanges = ranges.map((dataRange, i) => ({ ...this.dataSeriesRanges?.[i], dataRange, })); this.state.datasetDispatchResult = this.props.canUpdateChart(this.props.figureId, { dataSets: this.dataSeriesRanges, }); } onDataSeriesConfirmed() { this.dataSeriesRanges = spreadRange(this.env.model.getters, this.dataSeriesRanges); this.state.datasetDispatchResult = this.props.updateChart(this.props.figureId, { dataSets: this.dataSeriesRanges, }); } getDataSeriesRanges() { return this.dataSeriesRanges; } /** * Change the local labelRange. The model should be updated when the * button "confirm" is clicked */ onLabelRangeChanged(ranges) { this.labelRange = ranges[0]; this.state.labelsDispatchResult = this.props.canUpdateChart(this.props.figureId, { labelRange: this.labelRange, }); } onLabelRangeConfirmed() { this.state.labelsDispatchResult = this.props.updateChart(this.props.figureId, { labelRange: this.labelRange, }); } getLabelRange() { return this.labelRange || ""; } onUpdateAggregated(aggregated) { this.props.updateChart(this.props.figureId, { aggregated, }); } calculateHeaderPosition() { if (this.isDatasetInvalid || this.isLabelInvalid) { return undefined; } const getters = this.env.model.getters; const sheetId = getters.getActiveSheetId(); const labelRange = createValidRange(getters, sheetId, this.labelRange); const dataSets = createDataSets(getters, this.dataSeriesRanges, sheetId, this.props.definition.dataSetsHaveTitle); if (dataSets.length) { return dataSets[0].dataRange.zone.top + 1; } else if (labelRange) { return labelRange.zone.top + 1; } return undefined; } } class BarConfigPanel extends GenericChartConfigPanel { static template = "o-spreadsheet-BarConfigPanel"; get stackedLabel() { const definition = this.props.definition; return definition.horizontal ? this.chartTerms.StackedBarChart : this.chartTerms.StackedColumnChart; } onUpdateStacked(stacked) { this.props.updateChart(this.props.figureId, { stacked, }); } onUpdateAggregated(aggregated) { this.props.updateChart(this.props.figureId, { aggregated, }); } } css /* scss */ ` .o_side_panel_collapsible_title { font-size: 16px; cursor: pointer; padding: 6px 0px 6px 6px !important; .collapsor-arrow { transform: rotate(-90deg); display: inline-block; transform-origin: 8px 11px; transition: transform 0.2s ease-in-out; .o-icon { width: 16px; height: 22px; } } .collapsor:not(.collapsed) .collapsor-arrow { transform: rotate(0); } .collapsor { width: 100%; transition-delay: 0.35s; transition-property: all; } } .collapsible_section { &.collapsing { transition: height 0.35s, background-color 0.35s !important; } } `; let CURRENT_COLLAPSIBLE_ID = 0; class SidePanelCollapsible extends Component { static template = "o-spreadsheet-SidePanelCollapsible"; static props = { slots: Object, collapsedAtInit: { type: Boolean, optional: true }, class: { type: String, optional: true }, }; currentId = (CURRENT_COLLAPSIBLE_ID++).toString(); } const CIRCLE_SVG = /*xml*/ ` `; css /* scss */ ` .o-radio { input { appearance: none; -webkit-appearance: none; -moz-appearance: none; width: 14px; height: 14px; border: 1px solid ${GRAY_300}; box-sizing: border-box; outline: none; border-radius: 8px; &:checked { background: url("data:image/svg+xml,${encodeURIComponent(CIRCLE_SVG)}"); background-color: ${ACTION_COLOR}; border-color: ${ACTION_COLOR}; } } } `; class RadioSelection extends Component { static template = "o-spreadsheet.RadioSelection"; static props = { choices: Array, onChange: Function, selectedValue: { optional: false }, name: String, direction: { type: String, optional: true }, }; static defaultProps = { direction: "horizontal", }; } /** * Start listening to pointer events and apply the given callbacks. * * @returns A function to remove the listeners. */ function startDnd(onMouseMove, onMouseUp, onMouseDown = () => { }) { const removeListeners = () => { window.removeEventListener("pointerdown", onMouseDown); window.removeEventListener("pointerup", _onMouseUp); window.removeEventListener("dragstart", _onDragStart); window.removeEventListener("pointermove", onMouseMove); window.removeEventListener("wheel", onMouseMove); }; const _onMouseUp = (ev) => { onMouseUp(ev); removeListeners(); }; function _onDragStart(ev) { ev.preventDefault(); } window.addEventListener("pointerdown", onMouseDown); window.addEventListener("pointerup", _onMouseUp); window.addEventListener("dragstart", _onDragStart); window.addEventListener("pointermove", onMouseMove); // mouse wheel on window is by default a passive event. // preventDefault() is not allowed in passive event handler. // https://chromestatus.com/feature/6662647093133312 window.addEventListener("wheel", onMouseMove, { passive: false }); return removeListeners; } /** * Function to be used during a pointerdown event, this function allows to * perform actions related to the pointermove and pointerup events and adjusts the viewport * when the new position related to the pointermove event is outside of it. * Among inputs are two callback functions. First intended for actions performed during * the pointermove event, it receives as parameters the current position of the pointermove * (occurrence of the current column and the current row). Second intended for actions * performed during the pointerup event. */ function dragAndDropBeyondTheViewport(env, cbMouseMove, cbMouseUp, only = false) { let timeOutId = null; let currentEv; let previousEv; let startingEv; let startingX; let startingY; const getters = env.model.getters; const sheetId = getters.getActiveSheetId(); const position = gridOverlayPosition(); let colIndex; let rowIndex; const onMouseDown = (ev) => { previousEv = ev; startingEv = ev; startingX = startingEv.clientX - position.left; startingY = startingEv.clientY - position.top; }; const onMouseMove = (ev) => { currentEv = ev; if (timeOutId) { return; } const { x: offsetCorrectionX, y: offsetCorrectionY } = getters.getMainViewportCoordinates(); let { top, left, bottom, right } = getters.getActiveMainViewport(); let { scrollX, scrollY } = getters.getActiveSheetDOMScrollInfo(); const { xSplit, ySplit } = getters.getPaneDivisions(sheetId); let canEdgeScroll = false; let timeoutDelay = MAX_DELAY; const x = currentEv.clientX - position.left; colIndex = getters.getColIndex(x); if (only !== "vertical") { const previousX = previousEv.clientX - position.left; const edgeScrollInfoX = getters.getEdgeScrollCol(x, previousX, startingX); if (edgeScrollInfoX.canEdgeScroll) { canEdgeScroll = true; timeoutDelay = Math.min(timeoutDelay, edgeScrollInfoX.delay); let newTarget; switch (edgeScrollInfoX.direction) { case "reset": colIndex = xSplit; newTarget = xSplit; break; case 1: colIndex = right; newTarget = left + 1; break; case -1: colIndex = left - 1; while (env.model.getters.isColHidden(sheetId, colIndex)) { colIndex--; } newTarget = colIndex; break; } scrollX = getters.getColDimensions(sheetId, newTarget).start - offsetCorrectionX; } } const y = currentEv.clientY - position.top; rowIndex = getters.getRowIndex(y); if (only !== "horizontal") { const previousY = previousEv.clientY - position.top; const edgeScrollInfoY = getters.getEdgeScrollRow(y, previousY, startingY); if (edgeScrollInfoY.canEdgeScroll) { canEdgeScroll = true; timeoutDelay = Math.min(timeoutDelay, edgeScrollInfoY.delay); let newTarget; switch (edgeScrollInfoY.direction) { case "reset": rowIndex = ySplit; newTarget = ySplit; break; case 1: rowIndex = bottom; newTarget = top + edgeScrollInfoY.direction; break; case -1: rowIndex = top - 1; while (env.model.getters.isRowHidden(sheetId, rowIndex)) { rowIndex--; } newTarget = rowIndex; break; } scrollY = env.model.getters.getRowDimensions(sheetId, newTarget).start - offsetCorrectionY; } } if (!canEdgeScroll) { if (rowIndex === -1) { rowIndex = y < 0 ? 0 : getters.getNumberRows(sheetId) - 1; } if (colIndex === -1 && x < 0) { colIndex = x < 0 ? 0 : getters.getNumberCols(sheetId) - 1; } } cbMouseMove(colIndex, rowIndex, currentEv); if (canEdgeScroll) { env.model.dispatch("SET_VIEWPORT_OFFSET", { offsetX: scrollX, offsetY: scrollY }); timeOutId = setTimeout(() => { timeOutId = null; onMouseMove(currentEv); }, Math.round(timeoutDelay)); } previousEv = currentEv; }; const onMouseUp = () => { clearTimeout(timeOutId); cbMouseUp(); }; startDnd(onMouseMove, onMouseUp, onMouseDown); } const LINE_VERTICAL_PADDING = 1; const PICKER_PADDING = 8; const ITEM_BORDER_WIDTH = 1; const ITEM_EDGE_LENGTH = 18; const ITEMS_PER_LINE = 10; const MAGNIFIER_EDGE = 16; const ITEM_GAP = 2; const CONTENT_WIDTH = ITEMS_PER_LINE * (ITEM_EDGE_LENGTH + 2 * ITEM_BORDER_WIDTH) + (ITEMS_PER_LINE - 1) * ITEM_GAP; const INNER_GRADIENT_WIDTH = CONTENT_WIDTH - 2 * ITEM_BORDER_WIDTH; const INNER_GRADIENT_HEIGHT = CONTENT_WIDTH - 30 - 2 * ITEM_BORDER_WIDTH; const CONTAINER_WIDTH = CONTENT_WIDTH + 2 * PICKER_PADDING; css /* scss */ ` .o-color-picker { padding: ${PICKER_PADDING}px 0; /** FIXME: this is useless, overiden by the popover container */ box-shadow: 1px 2px 5px 2px rgba(51, 51, 51, 0.15); background-color: white; line-height: 1.2; overflow-y: auto; overflow-x: hidden; width: ${CONTAINER_WIDTH}px; .o-color-picker-section-name { margin: 0px ${ITEM_BORDER_WIDTH}px; padding: 4px ${PICKER_PADDING}px; } .colors-grid { display: grid; padding: ${LINE_VERTICAL_PADDING}px ${PICKER_PADDING}px; grid-template-columns: repeat(${ITEMS_PER_LINE}, 1fr); grid-gap: ${ITEM_GAP}px; } .o-color-picker-toggler-button { display: flex; .o-color-picker-toggler-sign { display: flex; margin: auto auto; width: 55%; height: 55%; .o-icon { width: 100%; height: 100%; } } } .o-color-picker-line-item { width: ${ITEM_EDGE_LENGTH}px; height: ${ITEM_EDGE_LENGTH}px; margin: 0px; border-radius: 50px; border: ${ITEM_BORDER_WIDTH}px solid #666666; padding: 0px; font-size: 16px; background: white; &:hover { background-color: rgba(0, 0, 0, 0.08); outline: 1px solid gray; cursor: pointer; } } .o-buttons { padding: ${PICKER_PADDING}px; display: flex; .o-cancel { border: ${ITEM_BORDER_WIDTH}px solid #c0c0c0; width: 100%; padding: 5px; font-size: 14px; background: white; border-radius: 4px; box-sizing: border-box; &:hover:enabled { background-color: rgba(0, 0, 0, 0.08); } } } .o-add-button { border: ${ITEM_BORDER_WIDTH}px solid #c0c0c0; padding: 4px; background: white; border-radius: 4px; &:hover:enabled { background-color: rgba(0, 0, 0, 0.08); } } .o-separator { border-bottom: ${MENU_SEPARATOR_BORDER_WIDTH}px solid ${SEPARATOR_COLOR}; margin-top: ${MENU_SEPARATOR_PADDING}px; margin-bottom: ${MENU_SEPARATOR_PADDING}px; } .o-custom-selector { padding: ${PICKER_PADDING + 2}px ${PICKER_PADDING}px; position: relative; .o-gradient { margin-bottom: ${MAGNIFIER_EDGE / 2}px; border: ${ITEM_BORDER_WIDTH}px solid #c0c0c0; box-sizing: border-box; width: ${INNER_GRADIENT_WIDTH + 2 * ITEM_BORDER_WIDTH}px; height: ${INNER_GRADIENT_HEIGHT + 2 * ITEM_BORDER_WIDTH}px; position: relative; } .magnifier { height: ${MAGNIFIER_EDGE}px; width: ${MAGNIFIER_EDGE}px; box-sizing: border-box; border-radius: 50%; border: 2px solid #fff; box-shadow: 0px 0px 3px #c0c0c0; position: absolute; z-index: 2; } .saturation { background: linear-gradient(to right, #fff 0%, transparent 100%); } .lightness { background: linear-gradient(to top, #000 0%, transparent 100%); } .o-hue-picker { border: ${ITEM_BORDER_WIDTH}px solid #c0c0c0; box-sizing: border-box; width: 100%; height: 12px; border-radius: 4px; background: linear-gradient( to right, hsl(0 100% 50%) 0%, hsl(0.2turn 100% 50%) 20%, hsl(0.3turn 100% 50%) 30%, hsl(0.4turn 100% 50%) 40%, hsl(0.5turn 100% 50%) 50%, hsl(0.6turn 100% 50%) 60%, hsl(0.7turn 100% 50%) 70%, hsl(0.8turn 100% 50%) 80%, hsl(0.9turn 100% 50%) 90%, hsl(1turn 100% 50%) 100% ); position: relative; cursor: crosshair; } .o-hue-slider { margin-top: -3px; } .o-custom-input-preview { padding: 2px 0px; display: flex; input { box-sizing: border-box; width: 50%; border-radius: 4px; padding: 4px 23px 4px 10px; height: 24px; border: 1px solid #c0c0c0; margin-right: 2px; } .o-wrong-color { /** FIXME bootstrap class instead? */ outline-color: red; border-color: red; &:focus { outline-style: solid; outline-width: 1px; } } } .o-custom-input-buttons { padding: 2px 0px; display: flex; justify-content: end; } .o-color-preview { border: 1px solid #c0c0c0; border-radius: 4px; width: 50%; } } } `; class ColorPicker extends Component { static template = "o-spreadsheet-ColorPicker"; static props = { onColorPicked: Function, currentColor: { type: String, optional: true }, maxHeight: { type: Number, optional: true }, anchorRect: Object, disableNoColor: { type: Boolean, optional: true }, }; static defaultProps = { currentColor: "" }; static components = { Popover }; COLORS = COLOR_PICKER_DEFAULTS; state = useState({ showGradient: false, currentHslaColor: isColorValid(this.props.currentColor) ? { ...hexToHSLA(this.props.currentColor), a: 1 } : { h: 0, s: 100, l: 100, a: 1 }, customHexColor: isColorValid(this.props.currentColor) ? toHex(this.props.currentColor) : "", }); get colorPickerStyle() { if (this.props.maxHeight !== undefined && this.props.maxHeight <= 0) { return cssPropertiesToCss({ display: "none" }); } return ""; } get popoverProps() { return { anchorRect: this.props.anchorRect, maxHeight: this.props.maxHeight, positioning: "BottomLeft", verticalOffset: 0, }; } get gradientHueStyle() { const hue = this.state.currentHslaColor?.h || 0; return cssPropertiesToCss({ background: `hsl(${hue} 100% 50%)`, }); } get sliderStyle() { const hue = this.state.currentHslaColor?.h || 0; const delta = Math.round((hue / 360) * INNER_GRADIENT_WIDTH); const left = clip(delta, 1, INNER_GRADIENT_WIDTH) - ICON_EDGE_LENGTH / 2; return cssPropertiesToCss({ "margin-left": `${left}px`, }); } get pointerStyle() { const { s, l } = this.state.currentHslaColor || { s: 0, l: 0 }; const left = Math.round(INNER_GRADIENT_WIDTH * clip(s / 100, 0, 1)); const top = Math.round(INNER_GRADIENT_HEIGHT * clip(1 - (2 * l) / (200 - s), 0, 1)); return cssPropertiesToCss({ left: `${-MAGNIFIER_EDGE / 2 + left}px`, top: `${-MAGNIFIER_EDGE / 2 + top}px`, background: hslaToHex(this.state.currentHslaColor), }); } get colorPreviewStyle() { return cssPropertiesToCss({ "background-color": hslaToHex(this.state.currentHslaColor), }); } get checkmarkColor() { return chartFontColor(this.props.currentColor); } get isHexColorInputValid() { return !this.state.customHexColor || isColorValid(this.state.customHexColor); } setCustomGradient({ x, y }) { const offsetX = clip(x, 0, INNER_GRADIENT_WIDTH); const offsetY = clip(y, 0, INNER_GRADIENT_HEIGHT); const deltaX = offsetX / INNER_GRADIENT_WIDTH; const deltaY = offsetY / INNER_GRADIENT_HEIGHT; const s = 100 * deltaX; const l = 100 * (1 - deltaY) * (1 - 0.5 * deltaX); this.updateColor({ s, l }); } setCustomHue(x) { // needs to be capped such that h is in [0°, 359°] const h = Math.round(clip((360 * x) / INNER_GRADIENT_WIDTH, 0, 359)); this.updateColor({ h }); } updateColor(newHsl) { this.state.currentHslaColor = { ...this.state.currentHslaColor, ...newHsl }; this.state.customHexColor = hslaToHex(this.state.currentHslaColor); } onColorClick(color) { if (color) { this.props.onColorPicked(toHex(color)); } } resetColor() { this.props.onColorPicked(""); } toggleColorPicker() { this.state.showGradient = !this.state.showGradient; } dragGradientPointer(ev) { const initialGradientCoordinates = { x: ev.offsetX, y: ev.offsetY }; this.setCustomGradient(initialGradientCoordinates); const initialMousePosition = { x: ev.clientX, y: ev.clientY }; const onMouseMove = (ev) => { const currentMousePosition = { x: ev.clientX, y: ev.clientY }; const deltaX = currentMousePosition.x - initialMousePosition.x; const deltaY = currentMousePosition.y - initialMousePosition.y; const currentGradientCoordinates = { x: initialGradientCoordinates.x + deltaX, y: initialGradientCoordinates.y + deltaY, }; this.setCustomGradient(currentGradientCoordinates); }; startDnd(onMouseMove, () => { }); } dragHuePointer(ev) { const initialX = ev.offsetX; const initialMouseX = ev.clientX; this.setCustomHue(initialX); const onMouseMove = (ev) => { const currentMouseX = ev.clientX; const deltaX = currentMouseX - initialMouseX; const x = initialX + deltaX; this.setCustomHue(x); }; startDnd(onMouseMove, () => { }); } setHexColor(ev) { // only support HEX code input const val = ev.target.value.slice(0, 7); this.state.customHexColor = val; if (!isColorValid(val)) ; else { this.state.currentHslaColor = { ...hexToHSLA(val), a: 1 }; } } addCustomColor(ev) { if (!isHSLAValid(this.state.currentHslaColor) || !isColorValid(this.state.customHexColor)) { return; } this.props.onColorPicked(toHex(this.state.customHexColor)); } isSameColor(color1, color2) { return isSameColor(color1, color2); } } css /* scss */ ` .o-color-picker-widget { display: flex; position: relative; align-items: center; height: 30px; .o-color-picker-button-style { display: flex; justify-content: center; align-items: center; margin: 2px; padding: 3px; border-radius: 2px; cursor: pointer; &:not([disabled]):hover { background-color: rgba(0, 0, 0, 0.08); } } .o-color-picker-button { > span { border-bottom: 4px solid; height: 16px; margin-top: 2px; display: block; } &[disabled] { pointer-events: none; opacity: 0.3; } } } `; class ColorPickerWidget extends Component { static template = "o-spreadsheet-ColorPickerWidget"; static props = { currentColor: { type: String, optional: true }, toggleColorPicker: Function, showColorPicker: Boolean, onColorPicked: Function, icon: String, title: { type: String, optional: true }, disabled: { type: Boolean, optional: true }, dropdownMaxHeight: { type: Number, optional: true }, class: { type: String, optional: true }, }; static components = { ColorPicker }; colorPickerButtonRef = useRef("colorPickerButton"); get iconStyle() { return this.props.currentColor ? `border-color: ${this.props.currentColor}` : "border-bottom-style: hidden"; } get colorPickerAnchorRect() { const button = this.colorPickerButtonRef.el; const buttonRect = button.getBoundingClientRect(); return { x: buttonRect.x, y: buttonRect.y, width: buttonRect.width, height: buttonRect.height, }; } } const TRANSPARENT_BACKGROUND_SVG = /*xml*/ ` `; css /* scss */ ` .o-round-color-picker-button { width: 18px; height: 18px; cursor: pointer; border: 1px solid ${GRAY_300}; background-position: 1px 1px; background-image: url("data:image/svg+xml,${encodeURIComponent(TRANSPARENT_BACKGROUND_SVG)}"); } `; class RoundColorPicker extends Component { static template = "o-spreadsheet.RoundColorPicker"; static components = { ColorPickerWidget, Section, ColorPicker }; static props = { currentColor: { type: String, optional: true }, title: { type: String, optional: true }, onColorPicked: Function, disableNoColor: { type: Boolean, optional: true }, }; colorPickerButtonRef = useRef("colorPickerButton"); state; setup() { this.state = useState({ pickerOpened: false }); useExternalListener(window, "click", this.closePicker); } closePicker() { this.state.pickerOpened = false; } togglePicker() { this.state.pickerOpened = !this.state.pickerOpened; } onColorPicked(color) { this.props.onColorPicked(color); this.state.pickerOpened = false; } get colorPickerAnchorRect() { const button = this.colorPickerButtonRef.el; return getBoundingRectAsPOJO(button); } get buttonStyle() { return cssPropertiesToCss({ background: this.props.currentColor, }); } } css /* scss */ ` .o-badge-selection { gap: 1px; button.o-button { border-radius: 0; &.selected { color: ${GRAY_900}; border-color: ${ACTION_COLOR}; background: ${BADGE_SELECTED_COLOR}; font-weight: 600; } &:first-child { border-radius: 4px 0 0 4px; } &:last-child { border-radius: 0 4px 4px 0; } } } `; class BadgeSelection extends Component { static template = "o-spreadsheet.BadgeSelection"; static props = { choices: Array, onChange: Function, selectedValue: String, }; } css /* scss */ ` .o-chart-title-designer { > span { height: 30px; } .o-divider { border-right: 1px solid ${GRAY_300}; margin: 0px 4px; height: 14px; } .o-menu-item-button.active { background-color: #e6f4ea; color: #188038; } .o-dropdown-content { overflow-y: auto; overflow-x: hidden; padding: 2px; z-index: 100; box-shadow: 1px 2px 5px 2px rgba(51, 51, 51, 0.15); .o-dropdown-line { > span { padding: 4px; } } } } `; class ChartTitle extends Component { static template = "o-spreadsheet.ChartTitle"; static components = { Section, ColorPickerWidget }; static props = { title: { type: String, optional: true }, updateTitle: Function, name: { type: String, optional: true }, toggleItalic: { type: Function, optional: true }, toggleBold: { type: Function, optional: true }, updateAlignment: { type: Function, optional: true }, updateColor: { type: Function, optional: true }, style: { type: Object, optional: true }, }; static defaultProps = { title: "", }; openedEl = null; setup() { useExternalListener(window, "click", this.onExternalClick); } state = useState({ activeTool: "", }); updateTitle(ev) { this.props.updateTitle(ev.target.value); } toggleDropdownTool(tool, ev) { const isOpen = this.state.activeTool === tool; this.closeMenus(); this.state.activeTool = isOpen ? "" : tool; this.openedEl = isOpen ? null : ev.target; } /** * TODO: This is clearly not a goot way to handle external click, but * we currently have no other way to do it ... Should be done in * another task to handle the fact we want only one menu opened at a * time with something like a menuStore ? */ onExternalClick(ev) { if (this.openedEl === ev.target) { return; } this.closeMenus(); } onColorPicked(color) { this.props.updateColor?.(color); this.closeMenus(); } updateAlignment(aligment) { this.props.updateAlignment?.(aligment); this.closeMenus(); } closeMenus() { this.state.activeTool = ""; this.openedEl = null; } } class AxisDesignEditor extends Component { static template = "o-spreadsheet-AxisDesignEditor"; static components = { Section, ChartTitle, BadgeSelection }; static props = { figureId: String, definition: Object, updateChart: Function, axesList: Array }; state = useState({ currentAxis: "x" }); get axisTitleStyle() { const axisDesign = this.props.definition.axesDesign?.[this.state.currentAxis] ?? {}; return { color: "", align: "center", ...axisDesign.title, }; } get badgeAxes() { return this.props.axesList.map((axis) => ({ value: axis.id, label: axis.name })); } updateAxisTitleColor(color) { const axesDesign = this.props.definition.axesDesign ?? {}; axesDesign[this.state.currentAxis] = { ...axesDesign[this.state.currentAxis], title: { ...(axesDesign[this.state.currentAxis]?.title ?? {}), color, }, }; this.props.updateChart(this.props.figureId, { axesDesign }); } toggleBoldAxisTitle() { const axesDesign = this.props.definition.axesDesign ?? {}; const title = axesDesign[this.state.currentAxis]?.title ?? {}; axesDesign[this.state.currentAxis] = { ...axesDesign[this.state.currentAxis], title: { ...title, bold: !title?.bold, }, }; this.props.updateChart(this.props.figureId, { axesDesign }); } toggleItalicAxisTitle() { const axesDesign = this.props.definition.axesDesign ?? {}; const title = axesDesign[this.state.currentAxis]?.title ?? {}; axesDesign[this.state.currentAxis] = { ...axesDesign[this.state.currentAxis], title: { ...title, italic: !title?.italic, }, }; this.props.updateChart(this.props.figureId, { axesDesign }); } updateAxisTitleAlignment(align) { const axesDesign = this.props.definition.axesDesign ?? {}; const title = axesDesign[this.state.currentAxis]?.title ?? {}; axesDesign[this.state.currentAxis] = { ...axesDesign[this.state.currentAxis], title: { ...title, align, }, }; this.props.updateChart(this.props.figureId, { axesDesign }); } updateAxisEditor(ev) { const axis = ev.target.value; this.state.currentAxis = axis; } getAxisTitle() { const axesDesign = this.props.definition.axesDesign ?? {}; return axesDesign[this.state.currentAxis]?.title.text || ""; } updateAxisTitle(text) { const axesDesign = this.props.definition.axesDesign ?? {}; axesDesign[this.state.currentAxis] = { ...axesDesign[this.state.currentAxis], title: { ...axesDesign?.[this.state.currentAxis]?.title, text, }, }; this.props.updateChart(this.props.figureId, { axesDesign }); } } class GeneralDesignEditor extends Component { static template = "o-spreadsheet-GeneralDesignEditor"; static components = { RoundColorPicker, ChartTitle, Section, SidePanelCollapsible, }; static props = { figureId: String, definition: Object, updateChart: Function, slots: { type: Object, optional: true }, }; state; setup() { this.state = useState({ activeTool: "", }); } get title() { return this.props.definition.title; } toggleDropdownTool(tool, ev) { const isOpen = this.state.activeTool === tool; this.state.activeTool = isOpen ? "" : tool; } updateBackgroundColor(color) { this.props.updateChart(this.props.figureId, { background: color, }); } updateTitle(newTitle) { const title = { ...this.title, text: newTitle }; this.props.updateChart(this.props.figureId, { title }); } get titleStyle() { return { align: "left", ...this.title, }; } updateChartTitleColor(color) { const title = { ...this.title, color }; this.props.updateChart(this.props.figureId, { title }); this.state.activeTool = ""; } toggleBoldChartTitle() { let title = this.title; title = { ...title, bold: !title.bold }; this.props.updateChart(this.props.figureId, { title }); } toggleItalicChartTitle() { let title = this.title; title = { ...title, italic: !title.italic }; this.props.updateChart(this.props.figureId, { title }); } updateChartTitleAlignment(align) { const title = { ...this.title, align }; this.props.updateChart(this.props.figureId, { title }); this.state.activeTool = ""; } } class ChartWithAxisDesignPanel extends Component { static template = "o-spreadsheet-ChartWithAxisDesignPanel"; static components = { GeneralDesignEditor, SidePanelCollapsible, Section, AxisDesignEditor, RoundColorPicker, Checkbox, RadioSelection, }; static props = { figureId: String, definition: Object, canUpdateChart: Function, updateChart: Function, }; axisChoices = CHART_AXIS_CHOICES; state = useState({ index: 0 }); get axesList() { const { useLeftAxis, useRightAxis } = getDefinedAxis(this.props.definition); let axes = [{ id: "x", name: _t("Horizontal axis") }]; if (useLeftAxis) { axes.push({ id: "y", name: useRightAxis ? _t("Left axis") : _t("Vertical axis") }); } if (useRightAxis) { axes.push({ id: "y1", name: useLeftAxis ? _t("Right axis") : _t("Vertical axis") }); } return axes; } updateLegendPosition(ev) { this.props.updateChart(this.props.figureId, { legendPosition: ev.target.value, }); } getDataSeries() { return this.props.definition.dataSets.map((d, i) => d.label ?? `${ChartTerms.Series} ${i + 1}`); } getPolynomialDegrees() { return range(1, this.getMaxPolynomialDegree() + 1); } updateSerieEditor(ev) { const chartId = this.props.figureId; const selectedIndex = ev.target.selectedIndex; const runtime = this.env.model.getters.getChartRuntime(chartId); if (!runtime) { return; } this.state.index = selectedIndex; } updateDataSeriesColor(color) { const dataSets = [...this.props.definition.dataSets]; if (!dataSets?.[this.state.index]) { return; } dataSets[this.state.index] = { ...dataSets[this.state.index], backgroundColor: color, }; this.props.updateChart(this.props.figureId, { dataSets }); } getDataSerieColor() { const dataSets = this.props.definition.dataSets; if (!dataSets?.[this.state.index]) { return ""; } const color = dataSets[this.state.index].backgroundColor; return color ? toHex(color) : getNthColor(this.state.index, getColorsPalette(dataSets.length)); } updateDataSeriesAxis(axis) { const dataSets = [...this.props.definition.dataSets]; if (!dataSets?.[this.state.index]) { return; } dataSets[this.state.index] = { ...dataSets[this.state.index], yAxisId: axis === "left" ? "y" : "y1", }; this.props.updateChart(this.props.figureId, { dataSets }); } getDataSerieAxis() { const dataSets = this.props.definition.dataSets; if (!dataSets?.[this.state.index]) { return "left"; } return dataSets[this.state.index].yAxisId === "y1" ? "right" : "left"; } get canHaveTwoVerticalAxis() { return "horizontal" in this.props.definition ? !this.props.definition.horizontal : true; } updateDataSeriesLabel(ev) { const label = ev.target.value; const dataSets = [...this.props.definition.dataSets]; if (!dataSets?.[this.state.index]) { return; } dataSets[this.state.index] = { ...dataSets[this.state.index], label, }; this.props.updateChart(this.props.figureId, { dataSets }); } getDataSerieLabel() { const dataSets = this.props.definition.dataSets; return dataSets[this.state.index]?.label || this.getDataSeries()[this.state.index]; } updateShowValues(showValues) { this.props.updateChart(this.props.figureId, { showValues }); } toggleDataTrend(display) { const dataSets = [...this.props.definition.dataSets]; if (!dataSets?.[this.state.index]) { return; } dataSets[this.state.index] = { ...dataSets[this.state.index], trend: { type: "polynomial", order: 1, ...dataSets[this.state.index].trend, display, }, }; this.props.updateChart(this.props.figureId, { dataSets }); } getTrendLineConfiguration() { const dataSets = this.props.definition.dataSets; return dataSets?.[this.state.index]?.trend; } getTrendType(config) { if (!config) { return ""; } return config.type === "polynomial" && config.order === 1 ? "linear" : config.type; } onChangeTrendType(ev) { const type = ev.target.value; let config; switch (type) { case "linear": case "polynomial": config = { type: "polynomial", order: type === "linear" ? 1 : this.getMaxPolynomialDegree(), }; break; case "exponential": case "logarithmic": config = { type }; break; default: return; } this.updateTrendLineValue(config); } onChangePolynomialDegree(ev) { const element = ev.target; this.updateTrendLineValue({ order: parseInt(element.value) }); } getTrendLineColor() { return this.getTrendLineConfiguration()?.color ?? setColorAlpha(this.getDataSerieColor(), 0.5); } updateTrendLineColor(color) { this.updateTrendLineValue({ color }); } updateTrendLineValue(config) { const dataSets = [...this.props.definition.dataSets]; if (!dataSets?.[this.state.index]) { return; } dataSets[this.state.index] = { ...dataSets[this.state.index], trend: { ...dataSets[this.state.index].trend, ...config, }, }; this.props.updateChart(this.props.figureId, { dataSets }); } getMaxPolynomialDegree() { const runtime = this.env.model.getters.getChartRuntime(this.props.figureId); return Math.min(10, runtime.chartJsConfig.data.datasets[this.state.index].data.length - 1); } } class ComboChartDesignPanel extends ChartWithAxisDesignPanel { static template = "o-spreadsheet-ComboChartDesignPanel"; seriesTypeChoices = [ { value: "bar", label: _t("Bar") }, { value: "line", label: _t("Line") }, ]; updateDataSeriesType(type) { const dataSets = [...this.props.definition.dataSets]; if (!dataSets?.[this.state.index]) { return; } dataSets[this.state.index] = { ...dataSets[this.state.index], type, }; this.props.updateChart(this.props.figureId, { dataSets }); } getDataSeriesType() { const dataSets = this.props.definition.dataSets; if (!dataSets?.[this.state.index]) { return "bar"; } return dataSets[this.state.index].type ?? "line"; } } class GaugeChartConfigPanel extends Component { static template = "o-spreadsheet-GaugeChartConfigPanel"; static components = { ChartErrorSection, ChartDataSeries }; static props = { figureId: String, definition: Object, updateChart: Function, canUpdateChart: Function, }; state = useState({ dataRangeDispatchResult: undefined, }); dataRange = this.props.definition.dataRange; get configurationErrorMessages() { const cancelledReasons = [...(this.state.dataRangeDispatchResult?.reasons || [])]; return cancelledReasons.map((error) => ChartTerms.Errors[error] || ChartTerms.Errors.Unexpected); } get isDataRangeInvalid() { return !!this.state.dataRangeDispatchResult?.isCancelledBecause("InvalidGaugeDataRange" /* CommandResult.InvalidGaugeDataRange */); } onDataRangeChanged(ranges) { this.dataRange = ranges[0]; this.state.dataRangeDispatchResult = this.props.canUpdateChart(this.props.figureId, { dataRange: this.dataRange, }); } updateDataRange() { this.state.dataRangeDispatchResult = this.props.updateChart(this.props.figureId, { dataRange: this.dataRange, }); } getDataRange() { return { dataRange: this.dataRange || "" }; } } css /* scss */ ` .o-gauge-color-set { table { table-layout: fixed; margin-top: 2%; display: table; text-align: left; font-size: 12px; line-height: 18px; width: 100%; font-size: 12px; } td { box-sizing: border-box; height: 30px; padding: 6px 0; } th.o-gauge-color-set-colorPicker { width: 8%; } th.o-gauge-color-set-text { width: 25%; } th.o-gauge-color-set-operator { width: 10%; } th.o-gauge-color-set-value { width: 22%; } th.o-gauge-color-set-type { width: 30%; } input, select { width: 100%; height: 100%; box-sizing: border-box; } } `; class GaugeChartDesignPanel extends Component { static template = "o-spreadsheet-GaugeChartDesignPanel"; static components = { SidePanelCollapsible, Section, RoundColorPicker, GeneralDesignEditor, ChartErrorSection, }; static props = { figureId: String, definition: Object, updateChart: Function, canUpdateChart: { type: Function, optional: true }, }; state; setup() { this.state = useState({ sectionRuleDispatchResult: undefined, sectionRule: deepCopy(this.props.definition.sectionRule), }); } get designErrorMessages() { const cancelledReasons = [...(this.state.sectionRuleDispatchResult?.reasons || [])]; return cancelledReasons.map((error) => ChartTerms.Errors[error] || ChartTerms.Errors.Unexpected); } isRangeMinInvalid() { return !!(this.state.sectionRuleDispatchResult?.isCancelledBecause("EmptyGaugeRangeMin" /* CommandResult.EmptyGaugeRangeMin */) || this.state.sectionRuleDispatchResult?.isCancelledBecause("GaugeRangeMinNaN" /* CommandResult.GaugeRangeMinNaN */) || this.state.sectionRuleDispatchResult?.isCancelledBecause("GaugeRangeMinBiggerThanRangeMax" /* CommandResult.GaugeRangeMinBiggerThanRangeMax */)); } isRangeMaxInvalid() { return !!(this.state.sectionRuleDispatchResult?.isCancelledBecause("EmptyGaugeRangeMax" /* CommandResult.EmptyGaugeRangeMax */) || this.state.sectionRuleDispatchResult?.isCancelledBecause("GaugeRangeMaxNaN" /* CommandResult.GaugeRangeMaxNaN */) || this.state.sectionRuleDispatchResult?.isCancelledBecause("GaugeRangeMinBiggerThanRangeMax" /* CommandResult.GaugeRangeMinBiggerThanRangeMax */)); } // --------------------------------------------------------------------------- // COLOR_SECTION_TEMPLATE // --------------------------------------------------------------------------- get isLowerInflectionPointInvalid() { return !!(this.state.sectionRuleDispatchResult?.isCancelledBecause("GaugeLowerInflectionPointNaN" /* CommandResult.GaugeLowerInflectionPointNaN */) || this.state.sectionRuleDispatchResult?.isCancelledBecause("GaugeLowerBiggerThanUpper" /* CommandResult.GaugeLowerBiggerThanUpper */)); } get isUpperInflectionPointInvalid() { return !!(this.state.sectionRuleDispatchResult?.isCancelledBecause("GaugeUpperInflectionPointNaN" /* CommandResult.GaugeUpperInflectionPointNaN */) || this.state.sectionRuleDispatchResult?.isCancelledBecause("GaugeLowerBiggerThanUpper" /* CommandResult.GaugeLowerBiggerThanUpper */)); } updateSectionColor(target, color) { const sectionRule = deepCopy(this.state.sectionRule); sectionRule.colors[target] = color; this.updateSectionRule(sectionRule); } updateSectionRule(sectionRule) { this.state.sectionRuleDispatchResult = this.props.updateChart(this.props.figureId, { sectionRule, }); if (this.state.sectionRuleDispatchResult.isSuccessful) { this.state.sectionRule = deepCopy(sectionRule); } } canUpdateSectionRule(sectionRule) { this.state.sectionRuleDispatchResult = this.props.canUpdateChart(this.props.figureId, { sectionRule, }); } } class LineConfigPanel extends GenericChartConfigPanel { static template = "o-spreadsheet-LineConfigPanel"; get canTreatLabelsAsText() { const chart = this.env.model.getters.getChart(this.props.figureId); if (chart && chart instanceof LineChart) { return canChartParseLabels(chart.labelRange, this.env.model.getters); } return false; } get stackedLabel() { const definition = this.props.definition; return definition.fillArea ? this.chartTerms.StackedAreaChart : this.chartTerms.StackedLineChart; } getLabelRangeOptions() { const options = super.getLabelRangeOptions(); if (this.canTreatLabelsAsText) { options.push({ name: "labelsAsText", value: this.props.definition.labelsAsText, label: this.chartTerms.TreatLabelsAsText, onChange: this.onUpdateLabelsAsText.bind(this), }); } return options; } onUpdateLabelsAsText(labelsAsText) { this.props.updateChart(this.props.figureId, { labelsAsText, }); } onUpdateStacked(stacked) { this.props.updateChart(this.props.figureId, { stacked, }); } onUpdateAggregated(aggregated) { this.props.updateChart(this.props.figureId, { aggregated, }); } onUpdateCumulative(cumulative) { this.props.updateChart(this.props.figureId, { cumulative, }); } } class PieChartDesignPanel extends Component { static template = "o-spreadsheet-PieChartDesignPanel"; static components = { GeneralDesignEditor, Section, Checkbox, }; static props = { figureId: String, definition: Object, updateChart: Function, canUpdateChart: { type: Function, optional: true }, }; updateLegendPosition(ev) { this.props.updateChart(this.props.figureId, { legendPosition: ev.target.value, }); } } class ScatterConfigPanel extends GenericChartConfigPanel { static template = "o-spreadsheet-ScatterConfigPanel"; get canTreatLabelsAsText() { const chart = this.env.model.getters.getChart(this.props.figureId); if (chart && chart instanceof ScatterChart) { return canChartParseLabels(chart.labelRange, this.env.model.getters); } return false; } onUpdateLabelsAsText(labelsAsText) { this.props.updateChart(this.props.figureId, { labelsAsText, }); } getLabelRangeOptions() { const options = super.getLabelRangeOptions(); if (this.canTreatLabelsAsText) { options.push({ name: "labelsAsText", value: this.props.definition.labelsAsText, label: this.chartTerms.TreatLabelsAsText, onChange: this.onUpdateLabelsAsText.bind(this), }); } return options; } } class ScorecardChartConfigPanel extends Component { static template = "o-spreadsheet-ScorecardChartConfigPanel"; static components = { SelectionInput, ChartErrorSection, Section }; static props = { figureId: String, definition: Object, updateChart: Function, canUpdateChart: Function, }; state = useState({ keyValueDispatchResult: undefined, baselineDispatchResult: undefined, }); keyValue = this.props.definition.keyValue; baseline = this.props.definition.baseline; get errorMessages() { const cancelledReasons = [ ...(this.state.keyValueDispatchResult?.reasons || []), ...(this.state.baselineDispatchResult?.reasons || []), ]; return cancelledReasons.map((error) => ChartTerms.Errors[error] || ChartTerms.Errors.Unexpected); } get isKeyValueInvalid() { return !!this.state.keyValueDispatchResult?.isCancelledBecause("InvalidScorecardKeyValue" /* CommandResult.InvalidScorecardKeyValue */); } get isBaselineInvalid() { return !!this.state.keyValueDispatchResult?.isCancelledBecause("InvalidScorecardBaseline" /* CommandResult.InvalidScorecardBaseline */); } onKeyValueRangeChanged(ranges) { this.keyValue = ranges[0]; this.state.keyValueDispatchResult = this.props.canUpdateChart(this.props.figureId, { keyValue: this.keyValue, }); } updateKeyValueRange() { this.state.keyValueDispatchResult = this.props.updateChart(this.props.figureId, { keyValue: this.keyValue, }); } getKeyValueRange() { return this.keyValue || ""; } onBaselineRangeChanged(ranges) { this.baseline = ranges[0]; this.state.baselineDispatchResult = this.props.canUpdateChart(this.props.figureId, { baseline: this.baseline, }); } updateBaselineRange() { this.state.baselineDispatchResult = this.props.updateChart(this.props.figureId, { baseline: this.baseline, }); } getBaselineRange() { return this.baseline || ""; } updateBaselineMode(ev) { this.props.updateChart(this.props.figureId, { baselineMode: ev.target.value }); } } class ScorecardChartDesignPanel extends Component { static template = "o-spreadsheet-ScorecardChartDesignPanel"; static components = { GeneralDesignEditor, RoundColorPicker, SidePanelCollapsible, Section, Checkbox, }; static props = { figureId: String, definition: Object, updateChart: Function, canUpdateChart: { type: Function, optional: true }, }; get colorsSectionTitle() { return this.props.definition.baselineMode === "progress" ? _t("Progress bar colors") : _t("Baseline colors"); } get humanizeNumbersLabel() { return _t("Humanize numbers"); } updateHumanizeNumbers(humanize) { this.props.updateChart(this.props.figureId, { humanize }); } translate(term) { return _t(term); } updateBaselineDescr(ev) { this.props.updateChart(this.props.figureId, { baselineDescr: ev.target.value }); } setColor(color, colorPickerId) { switch (colorPickerId) { case "backgroundColor": this.props.updateChart(this.props.figureId, { background: color }); break; case "baselineColorDown": this.props.updateChart(this.props.figureId, { baselineColorDown: color }); break; case "baselineColorUp": this.props.updateChart(this.props.figureId, { baselineColorUp: color }); break; } } } class WaterfallChartDesignPanel extends Component { static template = "o-spreadsheet-WaterfallChartDesignPanel"; static components = { GeneralDesignEditor, Checkbox, SidePanelCollapsible, Section, RoundColorPicker, AxisDesignEditor, RadioSelection, }; static props = { figureId: String, definition: Object, updateChart: Function, canUpdateChart: { type: Function, optional: true }, }; axisChoices = CHART_AXIS_CHOICES; onUpdateShowSubTotals(showSubTotals) { this.props.updateChart(this.props.figureId, { showSubTotals }); } onUpdateShowConnectorLines(showConnectorLines) { this.props.updateChart(this.props.figureId, { showConnectorLines }); } onUpdateFirstValueAsSubtotal(firstValueAsSubtotal) { this.props.updateChart(this.props.figureId, { firstValueAsSubtotal }); } updateColor(colorName, color) { this.props.updateChart(this.props.figureId, { [colorName]: color }); } get axesList() { return [ { id: "x", name: _t("Horizontal axis") }, { id: "y", name: _t("Vertical axis") }, ]; } get positiveValuesColor() { return (this.props.definition.positiveValuesColor || CHART_WATERFALL_POSITIVE_COLOR); } get negativeValuesColor() { return (this.props.definition.negativeValuesColor || CHART_WATERFALL_NEGATIVE_COLOR); } get subTotalValuesColor() { return (this.props.definition.subTotalValuesColor || CHART_WATERFALL_SUBTOTAL_COLOR); } updateLegendPosition(ev) { this.props.updateChart(this.props.figureId, { legendPosition: ev.target.value, }); } updateVerticalAxisPosition(value) { this.props.updateChart(this.props.figureId, { verticalAxisPosition: value, }); } updateShowValues(showValues) { this.props.updateChart(this.props.figureId, { showValues }); } } const chartSidePanelComponentRegistry = new Registry(); chartSidePanelComponentRegistry .add("line", { configuration: LineConfigPanel, design: ChartWithAxisDesignPanel, }) .add("scatter", { configuration: ScatterConfigPanel, design: ChartWithAxisDesignPanel, }) .add("bar", { configuration: BarConfigPanel, design: ChartWithAxisDesignPanel, }) .add("combo", { configuration: GenericChartConfigPanel, design: ComboChartDesignPanel, }) .add("pie", { configuration: GenericChartConfigPanel, design: PieChartDesignPanel, }) .add("gauge", { configuration: GaugeChartConfigPanel, design: GaugeChartDesignPanel, }) .add("scorecard", { configuration: ScorecardChartConfigPanel, design: ScorecardChartDesignPanel, }) .add("waterfall", { configuration: GenericChartConfigPanel, design: WaterfallChartDesignPanel, }) .add("pyramid", { configuration: GenericChartConfigPanel, design: ChartWithAxisDesignPanel, }); css /* scss */ ` .o-section .o-input.o-type-selector { height: 30px; padding-left: 35px; padding-top: 5px; } .o-type-selector-preview { left: 5px; top: 3px; .o-chart-preview { width: 24px; height: 24px; } } .o-popover .o-chart-select-popover { box-sizing: border-box; background: #fff; .o-chart-type-item { cursor: pointer; padding: 3px 6px; margin: 1px 2px; &.selected, &:hover { border: 1px solid ${ACTION_COLOR}; background: ${BADGE_SELECTED_COLOR}; padding: 2px 5px; } .o-chart-preview { width: 48px; height: 48px; } } } `; class ChartTypePicker extends Component { static template = "o-spreadsheet-ChartTypePicker"; static components = { Section, Popover }; static props = { figureId: String, chartPanelStore: Object }; categories = chartCategories; chartTypeByCategories = {}; popoverRef = useRef("popoverRef"); selectRef = useRef("selectRef"); state = useState({ popoverProps: undefined, popoverStyle: "" }); setup() { useExternalListener(window, "pointerdown", this.onExternalClick, { capture: true }); for (const subtypeProperties of chartSubtypeRegistry.getAll()) { if (this.chartTypeByCategories[subtypeProperties.category]) { this.chartTypeByCategories[subtypeProperties.category].push(subtypeProperties); } else { this.chartTypeByCategories[subtypeProperties.category] = [subtypeProperties]; } } } onExternalClick(ev) { if (isChildEvent(this.popoverRef.el?.parentElement, ev) || isChildEvent(this.selectRef.el, ev)) { return; } this.closePopover(); } onTypeChange(type) { this.props.chartPanelStore.changeChartType(this.props.figureId, type); this.closePopover(); } getChartDefinition(figureId) { return this.env.model.getters.getChartDefinition(figureId); } getSelectedChartSubtypeProperties() { const definition = this.getChartDefinition(this.props.figureId); const matchedChart = chartSubtypeRegistry .getAll() .find((c) => c.matcher?.(definition) || false); return matchedChart || chartSubtypeRegistry.get(definition.type); } onPointerDown(ev) { if (this.state.popoverProps) { this.closePopover(); return; } const target = ev.currentTarget; const { bottom, right, width } = target.getBoundingClientRect(); this.state.popoverProps = { anchorRect: { x: right, y: bottom, width: 0, height: 0 }, positioning: "TopRight", verticalOffset: 0, }; this.state.popoverStyle = cssPropertiesToCss({ width: `${width}px` }); } closePopover() { this.state.popoverProps = undefined; } } class MainChartPanelStore extends SpreadsheetStore { mutators = ["activatePanel", "changeChartType"]; panel = "configuration"; creationContext = {}; activatePanel(panel) { this.panel = panel; } changeChartType(figureId, newDisplayType) { this.creationContext = { ...this.creationContext, ...this.getters.getContextCreationChart(figureId), }; const sheetId = this.getters.getFigureSheetId(figureId); if (!sheetId) { return; } const definition = this.getChartDefinitionFromContextCreation(figureId, newDisplayType); this.model.dispatch("UPDATE_CHART", { definition, id: figureId, sheetId, }); } getChartDefinitionFromContextCreation(figureId, newDisplayType) { const newChartInfo = chartSubtypeRegistry.get(newDisplayType); const ChartClass = chartRegistry.get(newChartInfo.chartType); const contextCreation = { ...this.creationContext, ...this.getters.getContextCreationChart(figureId), }; return { ...ChartClass.getChartDefinitionFromContextCreation(contextCreation), ...newChartInfo.subtypeDefinition, }; } } css /* scss */ ` .o-chart { .o-panel { display: flex; .o-panel-element { flex: 1 0 auto; padding: 8px 0px; text-align: center; cursor: pointer; border-right: 1px solid ${GRAY_300}; &.inactive { color: ${TEXT_BODY}; background-color: ${GRAY_100}; border-bottom: 1px solid ${GRAY_300}; } &:not(.inactive) { color: ${TEXT_HEADING}; border-bottom: 1px solid #fff; } .fa { margin-right: 4px; } } .o-panel-element:last-child { border-right: none; } } } `; class ChartPanel extends Component { static template = "o-spreadsheet-ChartPanel"; static components = { Section, ChartTypePicker }; static props = { onCloseSidePanel: Function, figureId: String }; store; get figureId() { return this.props.figureId; } setup() { this.store = useLocalStore(MainChartPanelStore); } updateChart(figureId, updateDefinition) { if (figureId !== this.figureId) { return; } const definition = { ...this.getChartDefinition(this.figureId), ...updateDefinition, }; return this.env.model.dispatch("UPDATE_CHART", { definition, id: figureId, sheetId: this.env.model.getters.getFigureSheetId(figureId), }); } canUpdateChart(figureId, updateDefinition) { if (figureId !== this.figureId || !this.env.model.getters.isChartDefined(figureId)) { return; } const definition = { ...this.getChartDefinition(this.figureId), ...updateDefinition, }; return this.env.model.canDispatch("UPDATE_CHART", { definition, id: figureId, sheetId: this.env.model.getters.getFigureSheetId(figureId), }); } onTypeChange(type) { if (!this.figureId) { return; } this.store.changeChartType(this.figureId, type); } get chartPanel() { if (!this.figureId) { throw new Error("Chart not defined."); } const type = this.env.model.getters.getChartType(this.figureId); if (!type) { throw new Error("Chart not defined."); } const chartPanel = chartSidePanelComponentRegistry.get(type); if (!chartPanel) { throw new Error(`Component is not defined for type ${type}`); } return chartPanel; } getChartDefinition(figureId) { return this.env.model.getters.getChartDefinition(figureId); } } class NotificationStore { mutators = [ "notifyUser", "raiseError", "askConfirmation", "updateNotificationCallbacks", ]; notifyUser = (notification) => window.alert(notification.text); askConfirmation = (content, confirm, cancel) => { if (window.confirm(content)) { confirm(); } else { cancel?.(); } }; raiseError = (text, callback) => { window.alert(text); callback?.(); }; updateNotificationCallbacks(methods) { this.notifyUser = methods.notifyUser || this.notifyUser; this.raiseError = methods.raiseError || this.raiseError; this.askConfirmation = methods.askConfirmation || this.askConfirmation; } } class AbstractComposerStore extends SpreadsheetStore { mutators = [ "startEdition", "setCurrentContent", "stopEdition", "stopComposerRangeSelection", "cancelEdition", "cycleReferences", "changeComposerCursorSelection", "replaceComposerCursorSelection", ]; col = 0; row = 0; editionMode = "inactive"; sheetId = ""; _currentContent = ""; currentTokens = []; selectionStart = 0; selectionEnd = 0; initialContent = ""; colorIndexByRange = {}; notificationStore = this.get(NotificationStore); highlightStore = this.get(HighlightStore); constructor(get) { super(get); this.highlightStore.register(this); this.onDispose(() => { this.highlightStore.unRegister(this); }); } handleEvent(event) { const sheetId = this.getters.getActiveSheetId(); let unboundedZone; if (event.options.unbounded) { unboundedZone = this.getters.getUnboundedZone(sheetId, event.anchor.zone); } else { unboundedZone = event.anchor.zone; } switch (event.mode) { case "newAnchor": if (this.editionMode === "selecting") { this.insertSelectedRange(unboundedZone); } break; default: if (this.editionMode === "selecting") { this.replaceSelectedRange(unboundedZone); } else { this.updateComposerRange(event.previousAnchor.zone, unboundedZone); } break; } } changeComposerCursorSelection(start, end) { if (!this.isSelectionValid(this._currentContent.length, start, end)) { return; } this.selectionStart = start; this.selectionEnd = end; } stopComposerRangeSelection() { if (this.isSelectingRange) { this.editionMode = "editing"; } } startEdition(text, selection) { if (selection) { const content = text || this.getComposerContent(this.getters.getActivePosition()); const validSelection = this.isSelectionValid(content.length, selection.start, selection.end); if (!validSelection) { return; } } const { col, row } = this.getters.getActivePosition(); this.model.dispatch("SELECT_FIGURE", { id: null }); this.model.dispatch("SCROLL_TO_CELL", { col, row }); if (this.editionMode !== "inactive" && text) { this.setContent(text, selection); } else { this._startEdition(text, selection); } this.updateRangeColor(); } cancelEdition() { this.cancelEditionAndActivateSheet(); this.resetContent(); } setCurrentContent(content, selection) { if (selection && !this.isSelectionValid(content.length, selection.start, selection.end)) { return; } this.setContent(content, selection, true); this.updateRangeColor(); } replaceComposerCursorSelection(text) { this.replaceSelection(text); } handle(cmd) { switch (cmd.type) { case "SELECT_FIGURE": if (cmd.id) { this.cancelEditionAndActivateSheet(); this.resetContent(); } break; case "START_CHANGE_HIGHLIGHT": const { left, top } = cmd.zone; // changing the highlight can conflit with the 'selecting' mode if (this.isSelectingRange) { this.editionMode = "editing"; } this.model.selection.resetAnchor(this, { cell: { col: left, row: top }, zone: cmd.zone, }); break; } } // --------------------------------------------------------------------------- // Getters // --------------------------------------------------------------------------- get currentContent() { if (this.editionMode === "inactive") { return this.getComposerContent(this.getters.getActivePosition()); } return this._currentContent; } get composerSelection() { return { start: this.selectionStart, end: this.selectionEnd, }; } get isSelectingRange() { return this.editionMode === "selecting"; } get showSelectionIndicator() { return this.isSelectingRange && this.canStartComposerRangeSelection(); } /** * Return the (enriched) token just before the cursor. */ get tokenAtCursor() { const start = Math.min(this.selectionStart, this.selectionEnd); const end = Math.max(this.selectionStart, this.selectionEnd); if (start === end && end === 0) { return undefined; } else { return this.currentTokens.find((t) => t.start <= start && t.end >= end); } } cycleReferences() { const locale = this.getters.getLocale(); const updated = cycleFixedReference(this.composerSelection, this._currentContent, locale); if (updated === undefined) { return; } this.setCurrentContent(updated.content, updated.selection); } isSelectionValid(length, start, end) { return start >= 0 && start <= length && end >= 0 && end <= length; } /** * Enable the selecting mode */ startComposerRangeSelection() { if (this.sheetId === this.getters.getActiveSheetId()) { const zone = positionToZone({ col: this.col, row: this.row }); this.model.selection.resetAnchor(this, { cell: { col: this.col, row: this.row }, zone, }); } this.editionMode = "selecting"; } /** * start the edition of a cell * @param str the key that is used to start the edition if it is a "content" key like a letter or number * @param selection * @private */ _startEdition(str, selection) { const evaluatedCell = this.getters.getActiveCell(); const locale = this.getters.getLocale(); if (str && evaluatedCell.format?.includes("%") && isNumber(str, locale)) { selection = selection || { start: str.length, end: str.length }; str = `${str}%`; } const { col, row, sheetId } = this.getters.getActivePosition(); this.col = col; this.sheetId = sheetId; this.row = row; this.initialContent = this.getComposerContent({ sheetId, col, row }); this.editionMode = "editing"; this.setContent(str || this.initialContent, selection); this.colorIndexByRange = {}; const zone = positionToZone({ col: this.col, row: this.row }); this.model.selection.capture(this, { cell: { col: this.col, row: this.row }, zone }, { handleEvent: this.handleEvent.bind(this), release: () => { this._stopEdition(); }, }); } _stopEdition() { if (this.editionMode !== "inactive") { this.cancelEditionAndActivateSheet(); let content = this.getCurrentCanonicalContent(); const didChange = this.initialContent !== content; if (!didChange) { return; } if (content) { if (content.startsWith("=")) { const left = this.currentTokens.filter((t) => t.type === "LEFT_PAREN").length; const right = this.currentTokens.filter((t) => t.type === "RIGHT_PAREN").length; const missing = left - right; if (missing > 0) { content += concat(new Array(missing).fill(")")); } } } this.confirmEdition(content); } } getCurrentCanonicalContent() { return canonicalizeNumberContent(this._currentContent, this.getters.getLocale()); } cancelEditionAndActivateSheet() { if (this.editionMode === "inactive") { return; } this._cancelEdition(); const sheetId = this.getters.getActiveSheetId(); if (sheetId !== this.sheetId) { this.model.dispatch("ACTIVATE_SHEET", { sheetIdFrom: this.getters.getActiveSheetId(), sheetIdTo: this.sheetId, }); } } _cancelEdition() { if (this.editionMode === "inactive") { return; } this.editionMode = "inactive"; this.model.selection.release(this); this.colorIndexByRange = {}; } /** * Reset the current content to the active cell content */ resetContent() { this.setContent(this.initialContent || ""); } setContent(text, selection, raise) { const isNewCurrentContent = this._currentContent !== text; this._currentContent = text; if (selection) { this.selectionStart = selection.start; this.selectionEnd = selection.end; } else { this.selectionStart = this.selectionEnd = text.length; } if (isNewCurrentContent || this.editionMode !== "inactive") { const locale = this.getters.getLocale(); this.currentTokens = text.startsWith("=") ? composerTokenize(text, locale) : []; if (this.currentTokens.length > 100) { if (raise) { this.notificationStore.raiseError(_t("This formula has over 100 parts. It can't be processed properly, consider splitting it into multiple cells")); } } } if (this.canStartComposerRangeSelection()) { this.startComposerRangeSelection(); } } getAutoCompleteProviders() { return autoCompleteProviders.getAll(); } insertSelectedRange(zone) { // infer if range selected or selecting range from cursor position const start = Math.min(this.selectionStart, this.selectionEnd); const ref = this.getZoneReference(zone); if (this.canStartComposerRangeSelection()) { this.insertText(ref, start); } else { this.insertText("," + ref, start); } } /** * Replace the current reference selected by the new one. * */ replaceSelectedRange(zone) { const ref = this.getZoneReference(zone); const currentToken = this.tokenAtCursor; const start = currentToken?.type === "REFERENCE" ? currentToken.start : this.selectionStart; this.replaceText(ref, start, this.selectionEnd); } /** * Replace the reference of the old zone by the new one. */ updateComposerRange(oldZone, newZone) { const activeSheetId = this.getters.getActiveSheetId(); const tokentAtCursor = this.tokenAtCursor; const tokens = tokentAtCursor ? [tokentAtCursor, ...this.currentTokens] : this.currentTokens; const previousRefToken = tokens .filter((token) => token.type === "REFERENCE") .find((token) => { const { xc, sheetName: sheet } = splitReference(token.value); const sheetName = sheet || this.getters.getSheetName(this.sheetId); if (this.getters.getSheetName(activeSheetId) !== sheetName) { return false; } const refRange = this.getters.getRangeFromSheetXC(activeSheetId, xc); return isEqual(this.getters.expandZone(activeSheetId, refRange.zone), oldZone); }); if (!previousRefToken) { return; } const previousRange = this.getters.getRangeFromSheetXC(activeSheetId, previousRefToken.value); this.selectionStart = previousRefToken.start; this.selectionEnd = this.selectionStart + previousRefToken.value.length; const newRange = this.getters.getRangeFromZone(activeSheetId, newZone); const newRef = this.getRangeReference(newRange, previousRange.parts); this.replaceSelection(newRef); } getZoneReference(zone) { const inputSheetId = this.sheetId; const sheetId = this.getters.getActiveSheetId(); const range = this.getters.getRangeFromZone(sheetId, zone); return this.getters.getSelectionRangeString(range, inputSheetId); } getRangeReference(range, fixedParts) { let _fixedParts = [...fixedParts]; const newRange = range.clone({ parts: _fixedParts }); return this.getters.getSelectionRangeString(newRange, this.sheetId); } /** * Replace the current selection by a new text. * The cursor is then set at the end of the text. */ replaceSelection(text) { const start = Math.min(this.selectionStart, this.selectionEnd); const end = Math.max(this.selectionStart, this.selectionEnd); this.replaceText(text, start, end); } replaceText(text, start, end) { this._currentContent = this._currentContent.slice(0, start) + this._currentContent.slice(end, this._currentContent.length); this.insertText(text, start); } /** * Insert a text at the given position. * The cursor is then set at the end of the text. */ insertText(text, start) { const content = this._currentContent.slice(0, start) + text + this._currentContent.slice(start); const end = start + text.length; this.setCurrentContent(content, { start: end, end }); } updateRangeColor() { if (!this._currentContent.startsWith("=") || this.editionMode === "inactive") { return; } const editionSheetId = this.sheetId; const XCs = this.getReferencedRanges().map((range) => this.getters.getRangeString(range, editionSheetId)); const colorsToKeep = {}; for (const xc of XCs) { if (this.colorIndexByRange[xc] !== undefined) { colorsToKeep[xc] = this.colorIndexByRange[xc]; } } const usedIndexes = new Set(Object.values(colorsToKeep)); let currentIndex = 0; const nextIndex = () => { while (usedIndexes.has(currentIndex)) currentIndex++; usedIndexes.add(currentIndex); return currentIndex; }; for (const xc of XCs) { const colorIndex = xc in colorsToKeep ? colorsToKeep[xc] : nextIndex(); colorsToKeep[xc] = colorIndex; } this.colorIndexByRange = colorsToKeep; } /** * Highlight all ranges that can be found in the composer content. */ get highlights() { if (!this.currentContent.startsWith("=") || this.editionMode === "inactive") { return []; } const editionSheetId = this.sheetId; const rangeColor = (rangeString) => { const colorIndex = this.colorIndexByRange[rangeString]; return colors$1[colorIndex % colors$1.length]; }; return this.getReferencedRanges().map((range) => { const rangeString = this.getters.getRangeString(range, editionSheetId); const { numberOfRows, numberOfCols } = zoneToDimension(range.zone); const zone = numberOfRows * numberOfCols === 1 ? this.getters.expandZone(range.sheetId, range.zone) : range.zone; return { zone, color: rangeColor(rangeString), sheetId: range.sheetId, interactive: true, }; }); } /** * Return ranges currently referenced in the composer */ getReferencedRanges() { const editionSheetId = this.sheetId; const referenceRanges = this.currentTokens .filter((token) => token.type === "REFERENCE") .map((token) => this.getters.getRangeFromSheetXC(editionSheetId, token.value)); return referenceRanges.filter((range) => !range.invalidSheetName && !range.invalidXc); } get autocompleteProvider() { const content = this.currentContent; const tokenAtCursor = content.startsWith("=") ? this.tokenAtCursor : { type: "STRING", value: content }; if (this.editionMode === "inactive" || !tokenAtCursor || ["TRUE", "FALSE"].includes(tokenAtCursor.value.toUpperCase()) || !(this.canStartComposerRangeSelection() || ["SYMBOL", "STRING", "UNKNOWN"].includes(tokenAtCursor.type))) { return; } const thisCtx = { composer: this, getters: this.getters }; const providersDefinitions = this.getAutoCompleteProviders(); const providers = providersDefinitions .sort((a, b) => (a.sequence ?? Infinity) - (b.sequence ?? Infinity)) .map((provider) => ({ ...provider, getProposals: provider.getProposals.bind(thisCtx, tokenAtCursor, content), selectProposal: provider.selectProposal.bind(thisCtx, tokenAtCursor), })); for (const provider of providers) { let proposals = provider.getProposals(); const exactMatch = proposals?.find((p) => p.text === tokenAtCursor.value); // remove tokens that are likely to be other parts of the formula that slipped in the token if it's a string const searchTerm = tokenAtCursor.value.replace(/[ ,\(\)]/g, ""); if (exactMatch && this._currentContent !== this.initialContent) { // this means the user has chosen a proposal return; } if (searchTerm && proposals && !["ARG_SEPARATOR", "LEFT_PAREN", "OPERATOR"].includes(tokenAtCursor.type)) { const filteredProposals = fuzzyLookup(searchTerm, proposals, (p) => p.fuzzySearchKey || p.text); if (!exactMatch || filteredProposals.length > 1) { proposals = filteredProposals; } } if (provider.maxDisplayedProposals) { proposals = proposals?.slice(0, provider.maxDisplayedProposals); } if (proposals?.length) { return { proposals, selectProposal: provider.selectProposal, autoSelectFirstProposal: provider.autoSelectFirstProposal ?? false, }; } } return; } /** * Function used to determine when composer selection can start. * Three conditions are necessary: * - the previous token is among ["ARG_SEPARATOR", "LEFT_PAREN", "OPERATOR"], and is not a postfix unary operator * - the next token is missing or is among ["ARG_SEPARATOR", "RIGHT_PAREN", "OPERATOR"] * - Previous and next tokens can be separated by spaces */ canStartComposerRangeSelection() { if (this._currentContent.startsWith("=")) { const tokenAtCursor = this.tokenAtCursor; if (!tokenAtCursor) { return false; } const tokenIdex = this.currentTokens.map((token) => token.start).indexOf(tokenAtCursor.start); let count = tokenIdex; let currentToken = tokenAtCursor; // check previous token while (!["ARG_SEPARATOR", "LEFT_PAREN", "OPERATOR"].includes(currentToken.type) || POSTFIX_UNARY_OPERATORS.includes(currentToken.value)) { if (currentToken.type !== "SPACE" || count < 1) { return false; } count--; currentToken = this.currentTokens[count]; } count = tokenIdex + 1; currentToken = this.currentTokens[count]; // check next token while (currentToken && !["ARG_SEPARATOR", "RIGHT_PAREN", "OPERATOR"].includes(currentToken.type)) { if (currentToken.type !== "SPACE") { return false; } count++; currentToken = this.currentTokens[count]; } return true; } return false; } } class StandaloneComposerStore extends AbstractComposerStore { args; constructor(get, args) { super(get); this.args = args; this._currentContent = this.getComposerContent(); } getAutoCompleteProviders() { const providersDefinitions = super.getAutoCompleteProviders(); const contextualAutocomplete = this.args().contextualAutocomplete; if (contextualAutocomplete) { providersDefinitions.push(contextualAutocomplete); } return providersDefinitions; } getComposerContent() { if (this.editionMode === "inactive") { // References in the content might not be linked to the current active sheet // We here force the sheet name prefix for all references that are not in // the current active sheet const defaultRangeSheetId = this.args().defaultRangeSheetId; return rangeTokenize(this.args().content) .map((token) => { if (token.type === "REFERENCE") { const range = this.getters.getRangeFromSheetXC(defaultRangeSheetId, token.value); return this.getters.getRangeString(range, this.getters.getActiveSheetId()); } return token.value; }) .join(""); } return this._currentContent; } stopEdition() { this._stopEdition(); } confirmEdition(content) { this.args().onConfirm(content); } } css /* scss */ ` .o-spreadsheet { .o-standalone-composer { min-height: 24px; box-sizing: border-box; border-bottom: 1px solid; border-color: ${GRAY_300}; &.active { border-color: ${SELECTION_BORDER_COLOR}; } &.o-invalid { border-bottom: 2px solid red; } /* As the standalone composer is potentially very small (eg. in a side panel), we remove the scrollbar display */ scrollbar-width: none; /* Firefox */ &::-webkit-scrollbar { display: none; } } } `; class StandaloneComposer extends Component { static template = "o-spreadsheet-StandaloneComposer"; static props = { composerContent: { type: String, optional: true }, defaultRangeSheetId: { type: String, optional: true }, onConfirm: Function, contextualAutocomplete: { type: Object, optional: true }, placeholder: { type: String, optional: true }, class: { type: String, optional: true }, invalid: { type: Boolean, optional: true }, }; static components = { Composer }; static defaultProps = { composerContent: "", }; composerFocusStore; standaloneComposerStore; composerInterface; spreadsheetRect = useSpreadsheetRect(); setup() { this.composerFocusStore = useStore(ComposerFocusStore); const standaloneComposerStore = useLocalStore(StandaloneComposerStore, () => ({ onConfirm: this.props.onConfirm, content: this.props.composerContent, contextualAutocomplete: this.props.contextualAutocomplete, defaultRangeSheetId: this.props.defaultRangeSheetId, })); this.standaloneComposerStore = standaloneComposerStore; this.composerInterface = { id: "standaloneComposer", get editionMode() { return standaloneComposerStore.editionMode; }, startEdition: this.standaloneComposerStore.startEdition, setCurrentContent: this.standaloneComposerStore.setCurrentContent, stopEdition: this.standaloneComposerStore.stopEdition, }; } get focus() { return this.composerFocusStore.activeComposer === this.composerInterface ? this.composerFocusStore.focusMode : "inactive"; } get composerStyle() { return this.props.invalid ? cssPropertiesToCss({ padding: "1px 0px 0px 0px" }) : cssPropertiesToCss({ padding: "1px 0px" }); } get containerClass() { const classes = [ this.focus === "inactive" ? "" : "active", this.props.invalid ? "o-invalid" : "", this.props.class || "", ]; return classes.join(" "); } onFocus(selection) { this.composerFocusStore.focusComposer(this.composerInterface, { selection }); } } css /* scss */ ` .o-icon-picker { position: absolute; z-index: ${ComponentsImportance.IconPicker}; box-shadow: 1px 2px 5px 2px rgba(51, 51, 51, 0.15); background-color: white; padding: 2px 1px; } .o-cf-icon-line { display: flex; padding: 0 6px; } .o-icon-picker-item { cursor: pointer; &:hover { background-color: ${BADGE_SELECTED_COLOR}; outline: ${ACTION_COLOR} solid 1px; } } `; class IconPicker extends Component { static template = "o-spreadsheet-IconPicker"; static props = { onIconPicked: Function, }; icons = ICONS; iconSets = ICON_SETS; onIconClick(icon) { if (icon) { this.props.onIconPicked(icon); } } } function useDragAndDropListItems() { let dndHelper; const previousCursor = document.body.style.cursor; let cleanupFns = []; const cleanUp = () => { dndHelper = undefined; document.body.style.cursor = previousCursor; cleanupFns.forEach((fn) => fn()); cleanupFns = []; }; const start = (direction, args) => { const onChange = () => { document.body.style.cursor = "move"; if (!dndHelper) return; Object.assign(state.itemsStyle, dndHelper.getItemStyles()); args.onChange?.(); }; state.cancel = () => { state.draggedItemId = undefined; state.itemsStyle = {}; document.body.style.cursor = previousCursor; args.onCancel?.(); cleanUp(); }; const onDragEnd = (itemId, indexAtEnd) => { state.draggedItemId = undefined; state.itemsStyle = {}; document.body.style.cursor = previousCursor; args.onDragEnd?.(itemId, indexAtEnd); cleanUp(); }; document.body.style.cursor = "move"; state.draggedItemId = args.draggedItemId; const container = direction === "horizontal" ? new HorizontalContainer(args.containerEl) : new VerticalContainer(args.containerEl); dndHelper = new DOMDndHelper({ ...args, container, onChange, onDragEnd, onCancel: state.cancel, }); const stopListening = startDnd(dndHelper.onMouseMove.bind(dndHelper), dndHelper.onMouseUp.bind(dndHelper)); cleanupFns.push(stopListening); const onScroll = dndHelper.onScroll.bind(dndHelper); args.containerEl.addEventListener("scroll", onScroll); cleanupFns.push(() => args.containerEl.removeEventListener("scroll", onScroll)); cleanupFns.push(dndHelper.destroy.bind(dndHelper)); }; onWillUnmount(() => { cleanUp(); }); const state = useState({ itemsStyle: {}, draggedItemId: undefined, start, cancel: () => { }, }); return state; } class DOMDndHelper { draggedItemId; items; container; initialMousePosition; currentMousePosition; initialScroll; minPosition; maxPosition; edgeScrollIntervalId; onChange; onCancel; onDragEnd; /** * The dead zone is an area in which the pointermove events are ignored. * * This is useful when swapping the dragged item with a larger item. After the swap, * the mouse is still hovering on the item we just swapped with. In this case, we don't want * a mouse move to trigger another swap the other way around, so we create a dead zone. We will clear * the dead zone when the mouse leaves the swapped item. */ deadZone; constructor(args) { this.items = args.items.map((item) => ({ ...item, positionAtStart: item.position })); this.draggedItemId = args.draggedItemId; this.container = args.container; this.onChange = args.onChange; this.onCancel = args.onCancel; this.onDragEnd = args.onDragEnd; this.initialMousePosition = args.initialMousePosition; this.currentMousePosition = args.initialMousePosition; this.initialScroll = this.container.scroll; this.minPosition = this.items[0].position; this.maxPosition = this.items[this.items.length - 1].position + this.items[this.items.length - 1].size; } getItemStyles() { const styles = {}; for (let item of this.items) { styles[item.id] = this.getItemStyle(item.id); } return styles; } getItemStyle(itemId) { const position = this.container.cssPositionProperty; const style = {}; style.position = "relative"; style[position] = (this.getItemsPositions()[itemId] || 0) + "px"; style.transition = `${position} 0.5s`; style["pointer-events"] = "none"; if (this.draggedItemId === itemId) { style.transition = `${position} 0s`; style["z-index"] = "1000"; } return cssPropertiesToCss(style); } onScroll() { this.moveDraggedItemToPosition(this.currentMousePosition + this.scrollOffset); } onMouseMove(ev) { if (ev.button > 1) { this.onCancel(); return; } const mousePosition = this.container.getMousePosition(ev); this.currentMousePosition = mousePosition; if (mousePosition < this.container.start || mousePosition > this.container.end) { this.startEdgeScroll(mousePosition < this.container.start ? -1 : 1); return; } else { this.stopEdgeScroll(); } this.moveDraggedItemToPosition(mousePosition + this.scrollOffset); } moveDraggedItemToPosition(position) { const hoveredItemIndex = this.getHoveredItemIndex(position, this.items); const draggedItemIndex = this.items.findIndex((item) => item.id === this.draggedItemId); const draggedItem = this.items[draggedItemIndex]; if (this.deadZone && this.isInZone(position, this.deadZone)) { this.onChange(this.getItemsPositions()); return; } else if (this.isInZone(position, { start: draggedItem.position, end: draggedItem.position + draggedItem.size, })) { this.deadZone = undefined; } if (draggedItemIndex === hoveredItemIndex) { this.onChange(this.getItemsPositions()); return; } const startIndex = Math.min(draggedItemIndex, hoveredItemIndex); const endIndex = Math.max(draggedItemIndex, hoveredItemIndex); const direction = Math.sign(hoveredItemIndex - draggedItemIndex); let draggedItemMoveSize = 0; for (let i = startIndex; i <= endIndex; i++) { if (i === draggedItemIndex) { continue; } this.items[i].position -= direction * draggedItem.size; draggedItemMoveSize += this.items[i].size; } draggedItem.position += direction * draggedItemMoveSize; this.items.sort((item1, item2) => item1.position - item2.position); this.deadZone = direction > 0 ? { start: position, end: draggedItem.position } : { start: draggedItem.position + draggedItem.size, end: position }; this.onChange(this.getItemsPositions()); } onMouseUp(ev) { if (ev.button !== 0) { this.onCancel(); } ev.stopPropagation(); ev.preventDefault(); const targetItemIndex = this.items.findIndex((item) => item.id === this.draggedItemId); this.onDragEnd(this.draggedItemId, targetItemIndex); this.stopEdgeScroll(); return false; } startEdgeScroll(direction) { if (this.edgeScrollIntervalId) return; this.edgeScrollIntervalId = window.setInterval(() => { const offset = direction * 3; let newPosition = this.currentMousePosition + offset; if (newPosition < Math.min(this.container.start, this.minPosition)) { newPosition = Math.min(this.container.start, this.minPosition); } else if (newPosition > Math.max(this.container.end, this.maxPosition)) { newPosition = Math.max(this.container.end, this.maxPosition); } this.container.scroll += offset; }, 5); } stopEdgeScroll() { window.clearInterval(this.edgeScrollIntervalId); this.edgeScrollIntervalId = undefined; } /** * Get the index of the item the given mouse position is inside. * If the mouse is outside the container, return the first or last item index. */ getHoveredItemIndex(mousePosition, items) { if (mousePosition <= this.minPosition) return 0; if (mousePosition >= this.maxPosition) return items.length - 1; return items.findIndex((item) => item.position + item.size >= mousePosition); } getItemsPositions() { const positions = {}; for (let item of this.items) { if (item.id !== this.draggedItemId) { positions[item.id] = item.position - item.positionAtStart; continue; } const mouseOffset = this.currentMousePosition - this.initialMousePosition; let start = mouseOffset + this.scrollOffset; start = Math.max(this.minPosition - item.positionAtStart, start); start = Math.min(this.maxPosition - item.positionAtStart - item.size, start); positions[item.id] = start; } return positions; } isInZone(position, zone) { return position >= zone.start && position <= zone.end; } get scrollOffset() { return this.container.scroll - this.initialScroll; } destroy() { this.stopEdgeScroll(); } } class ContainerWrapper { el; constructor(el) { this.el = el; } get containerRect() { return this.el.getBoundingClientRect(); } } class VerticalContainer extends ContainerWrapper { get start() { return this.containerRect.top; } get end() { return this.containerRect.bottom; } get cssPositionProperty() { return "top"; } get scroll() { return this.el.scrollTop; } set scroll(scroll) { this.el.scrollTop = scroll; } getMousePosition(ev) { return ev.clientY; } } class HorizontalContainer extends ContainerWrapper { get start() { return this.containerRect.left; } get end() { return this.containerRect.right; } get cssPositionProperty() { return "left"; } get scroll() { return this.el.scrollLeft; } set scroll(scroll) { this.el.scrollLeft = scroll; } getMousePosition(ev) { return ev.clientX; } } /** * Manages an event listener on a ref. Useful for hooks that want to manage * event listeners, especially more than one. Prefer using t-on directly in * components. If your hook only needs a single event listener, consider simply * returning it from the hook and letting the user attach it with t-on. * * Adapted from Odoo Community - See https://github.com/odoo/odoo/blob/saas-16.2/addons/web/static/src/core/utils/hooks.js */ function useRefListener(ref, ...listener) { useEffect((el) => { el?.addEventListener(...listener); return () => el?.removeEventListener(...listener); }, () => [ref.el]); } function useHoveredElement(ref) { const state = useState({ hovered: false }); useRefListener(ref, "mouseenter", () => (state.hovered = true)); useRefListener(ref, "mouseleave", () => (state.hovered = false)); return state; } function useHighlightsOnHover(ref, highlightProvider) { const hoverState = useHoveredElement(ref); useHighlights({ get highlights() { return hoverState.hovered ? highlightProvider.highlights : []; }, }); } function useHighlights(highlightProvider) { const stores = useStoreProvider(); const store = useLocalStore(HighlightStore); onMounted(() => { store.register(highlightProvider); }); let currentHighlights = highlightProvider.highlights; useEffect((highlights) => { if (!deepEquals(highlights, currentHighlights)) { currentHighlights = highlights; stores.trigger("store-updated"); } }, () => [highlightProvider.highlights]); } css /* scss */ ` .o-cf-preview { &.o-cf-cursor-ptr { cursor: pointer; } border-bottom: 1px solid ${GRAY_300}; height: 60px; padding: 10px; position: relative; cursor: pointer; &:hover, &.o-cf-dragging { background-color: ${GRAY_200}; } &:not(:hover) .o-cf-delete-button { display: none; } .o-cf-preview-icon { border: 1px solid ${GRAY_300}; background-color: #fff; position: absolute; height: 50px; width: 50px; .o-icon { width: ${CF_ICON_EDGE_LENGTH}px; height: ${CF_ICON_EDGE_LENGTH}px; } } .o-cf-preview-description { left: 65px; margin-bottom: auto; margin-right: 8px; margin-top: auto; position: relative; width: 142px; .o-cf-preview-description-rule { margin-bottom: 4px; max-height: 2.8em; line-height: 1.4em; } .o-cf-preview-range { font-size: 12px; } } .o-cf-delete { left: 90%; top: 39%; position: absolute; } &:not(:hover):not(.o-cf-dragging) .o-cf-drag-handle { display: none !important; } .o-cf-drag-handle { left: -8px; cursor: move; .o-icon { width: 6px; height: 30px; } } .o-icon.arrow-down { color: #e06666; } .o-icon.arrow-up { color: #6aa84f; } } `; class ConditionalFormatPreview extends Component { static template = "o-spreadsheet-ConditionalFormatPreview"; icons = ICONS; ref = useRef("cfPreview"); setup() { useHighlightsOnHover(this.ref, this); } getPreviewImageStyle() { const rule = this.props.conditionalFormat.rule; if (rule.type === "CellIsRule") { return cssPropertiesToCss(cellStyleToCss(rule.style)); } else if (rule.type === "ColorScaleRule") { const minColor = colorNumberString(rule.minimum.color); const midColor = rule.midpoint ? colorNumberString(rule.midpoint.color) : null; const maxColor = colorNumberString(rule.maximum.color); const baseString = "background-image: linear-gradient(to right, "; return midColor ? baseString + minColor + ", " + midColor + ", " + maxColor + ")" : baseString + minColor + ", " + maxColor + ")"; } else if (rule.type === "DataBarRule") { const color = colorNumberString(rule.color); return `background-image: linear-gradient(to right, ${color} 50%, white 50%)`; } return ""; } getDescription() { const cf = this.props.conditionalFormat; switch (cf.rule.type) { case "CellIsRule": const description = CellIsOperators[cf.rule.operator]; if (cf.rule.values.length === 1) { return `${description} ${cf.rule.values[0]}`; } if (cf.rule.values.length === 2) { return _t("%s %s and %s", description, cf.rule.values[0], cf.rule.values[1]); } return description; case "ColorScaleRule": return CfTerms.ColorScale; case "IconSetRule": return CfTerms.IconSet; case "DataBarRule": return CfTerms.DataBar; } } deleteConditionalFormat() { this.env.model.dispatch("REMOVE_CONDITIONAL_FORMAT", { id: this.props.conditionalFormat.id, sheetId: this.env.model.getters.getActiveSheetId(), }); } onMouseDown(event) { this.props.onMouseDown(event); } get highlights() { const sheetId = this.env.model.getters.getActiveSheetId(); return this.props.conditionalFormat.ranges.map((range) => ({ sheetId, zone: this.env.model.getters.getRangeFromSheetXC(sheetId, range).zone, color: HIGHLIGHT_COLOR, fillAlpha: 0.06, })); } } ConditionalFormatPreview.props = { conditionalFormat: Object, onPreviewClick: Function, onMouseDown: Function, class: String, }; class ConditionalFormatPreviewList extends Component { static template = "o-spreadsheet-ConditionalFormatPreviewList"; static props = { conditionalFormats: Array, onPreviewClick: Function, onAddConditionalFormat: Function, }; static components = { ConditionalFormatPreview }; icons = ICONS; dragAndDrop = useDragAndDropListItems(); cfListRef = useRef("cfList"); setup() { onWillUpdateProps((nextProps) => { if (!deepEquals(this.props.conditionalFormats, nextProps.conditionalFormats)) { this.dragAndDrop.cancel(); } }); } getPreviewDivStyle(cf) { return this.dragAndDrop.itemsStyle[cf.id] || ""; } onPreviewMouseDown(cf, event) { if (event.button !== 0) return; const previewRects = Array.from(this.cfListRef.el.children).map((previewEl) => getBoundingRectAsPOJO(previewEl)); const items = this.props.conditionalFormats.map((cf, index) => ({ id: cf.id, size: previewRects[index].height, position: previewRects[index].y, })); this.dragAndDrop.start("vertical", { draggedItemId: cf.id, initialMousePosition: event.clientY, items: items, containerEl: this.cfListRef.el, onDragEnd: (cfId, finalIndex) => this.onDragEnd(cfId, finalIndex), }); } onDragEnd(cfId, finalIndex) { const originalIndex = this.props.conditionalFormats.findIndex((sheet) => sheet.id === cfId); const delta = originalIndex - finalIndex; if (delta !== 0) { this.env.model.dispatch("CHANGE_CONDITIONAL_FORMAT_PRIORITY", { cfId, delta, sheetId: this.env.model.getters.getActiveSheetId(), }); } } } css /* scss */ ` .o-cf-ruleEditor { .o-cf-preview-display { border: 1px solid ${GRAY_300}; padding: 10px; } .o-cf-cell-is-rule { .o-divider { border-right: 1px solid ${GRAY_300}; margin: 4px 6px; } } .o-cf-color-scale-editor { .o-threshold { .o-select-with-input { max-width: 150px; } .o-threshold-value { flex-grow: 1; flex-basis: 60%; min-width: 0px; // input overflows in Firefox otherwise } .o-threshold-value input:disabled { background-color: #edebed; } } } .o-cf-iconset-rule { .o-cf-clickable-icon { border: 1px solid ${GRAY_200}; border-radius: 4px; cursor: pointer; &:hover { border-color: ${ACTION_COLOR}; background-color: ${BADGE_SELECTED_COLOR}; } .o-icon { width: ${CF_ICON_EDGE_LENGTH}px; height: ${CF_ICON_EDGE_LENGTH}px; } } .o-cf-iconsets { gap: 11px; .o-cf-iconset { padding: 7px 8px; width: 95px; .o-icon { margin: 0 3px; } svg { vertical-align: baseline; } } } .o-inflection { .o-cf-icon-button { padding: 4px 10px; } table { font-size: 13px; td { padding: 6px 0; } th.o-cf-iconset-icons { width: 25px; } th.o-cf-iconset-text { width: 82px; } th.o-cf-iconset-operator { width: 20px; } .o-cf-iconset-type { min-width: 80px; } } } } .o-icon.arrow-down { color: #e06666; } .o-icon.arrow-up { color: #6aa84f; } } `; class ConditionalFormattingEditor extends Component { static template = "o-spreadsheet-ConditionalFormattingEditor"; static props = { editedCf: { type: Object, optional: true }, onExitEdition: Function, }; static components = { SelectionInput, IconPicker, ColorPickerWidget, ConditionalFormatPreviewList, Section, RoundColorPicker, StandaloneComposer: StandaloneComposer, BadgeSelection, ValidationMessages, }; icons = ICONS; cellIsOperators = CellIsOperators; iconSets = ICON_SETS; getTextDecoration = getTextDecoration; colorNumberString = colorNumberString; state; setup() { const cf = this.props.editedCf || { id: this.env.model.uuidGenerator.uuidv4(), ranges: this.env.model.getters .getSelectedZones() .map((zone) => this.env.model.getters.zoneToXC(this.env.model.getters.getActiveSheetId(), zone)), }; this.state = useState({ currentCF: cf, currentCFType: this.props.editedCf?.rule.type || "CellIsRule", errors: [], rules: this.getDefaultRules(), }); if (this.props.editedCf) { switch (this.props.editedCf.rule.type) { case "CellIsRule": this.state.rules.cellIs = this.props.editedCf.rule; break; case "ColorScaleRule": this.state.rules.colorScale = this.props.editedCf.rule; break; case "IconSetRule": this.state.rules.iconSet = this.props.editedCf.rule; break; case "DataBarRule": this.state.rules.dataBar = this.props.editedCf.rule; break; } } useExternalListener(window, "click", this.closeMenus); } get isRangeValid() { return this.state.errors.includes("EmptyRange" /* CommandResult.EmptyRange */); } get errorMessages() { return this.state.errors.map((error) => CfTerms.Errors[error] || CfTerms.Errors.Unexpected); } get cfTypesValues() { return [ { value: "CellIsRule", label: _t("Single color") }, { value: "ColorScaleRule", label: _t("Color scale") }, { value: "IconSetRule", label: _t("Icon set") }, { value: "DataBarRule", label: _t("Data bar") }, ]; } saveConditionalFormat() { if (this.state.currentCF) { const invalidRanges = this.state.currentCF.ranges.some((xc) => !xc.match(rangeReference)); if (invalidRanges) { this.state.errors = ["InvalidRange" /* CommandResult.InvalidRange */]; return; } const sheetId = this.env.model.getters.getActiveSheetId(); const locale = this.env.model.getters.getLocale(); const result = this.env.model.dispatch("ADD_CONDITIONAL_FORMAT", { cf: { rule: canonicalizeCFRule(this.getEditorRule(), locale), id: this.state.currentCF.id, }, ranges: this.state.currentCF.ranges.map((xc) => this.env.model.getters.getRangeDataFromXc(sheetId, xc)), sheetId, }); if (!result.isSuccessful) { this.state.errors = result.reasons; } else { this.props.onExitEdition(); } } } /** * Get the rule currently edited with the editor */ getEditorRule() { switch (this.state.currentCFType) { case "CellIsRule": return this.state.rules.cellIs; case "ColorScaleRule": return this.state.rules.colorScale; case "IconSetRule": return this.state.rules.iconSet; case "DataBarRule": return this.state.rules.dataBar; } } getDefaultRules() { return { cellIs: { type: "CellIsRule", operator: "IsNotEmpty", values: [], style: { fillColor: "#b6d7a8" }, }, colorScale: { type: "ColorScaleRule", minimum: { type: "value", color: 0xffffff }, midpoint: undefined, maximum: { type: "value", color: 0x6aa84f }, }, iconSet: { type: "IconSetRule", icons: { upper: "arrowGood", middle: "arrowNeutral", lower: "arrowBad", }, upperInflectionPoint: { type: "percentage", value: "66", operator: "gt", }, lowerInflectionPoint: { type: "percentage", value: "33", operator: "gt", }, }, dataBar: { type: "DataBarRule", color: 0xd9ead3, }, }; } changeRuleType(ruleType) { if (this.state.currentCFType === ruleType || !this.state.rules) { return; } this.state.errors = []; this.state.currentCFType = ruleType; } onRangesChanged(ranges) { if (this.state.currentCF) { this.state.currentCF.ranges = ranges; } } /***************************************************************************** * Common ****************************************************************************/ toggleMenu(menu) { const isSelected = this.state.openedMenu === menu; this.closeMenus(); if (!isSelected) { this.state.openedMenu = menu; } } closeMenus() { this.state.openedMenu = undefined; } /***************************************************************************** * Cell Is Rule ****************************************************************************/ get isValue1Invalid() { return (this.state.errors.includes("FirstArgMissing" /* CommandResult.FirstArgMissing */) || this.state.errors.includes("ValueCellIsInvalidFormula" /* CommandResult.ValueCellIsInvalidFormula */)); } get isValue2Invalid() { return this.state.errors.includes("SecondArgMissing" /* CommandResult.SecondArgMissing */); } toggleStyle(tool) { const style = this.state.rules.cellIs.style; style[tool] = !style[tool]; this.closeMenus(); } onKeydown(event) { if (event.key === "F4") { const target = event.target; const update = cycleFixedReference({ start: target.selectionStart ?? 0, end: target.selectionEnd ?? 0 }, target.value, this.env.model.getters.getLocale()); if (!update) { return; } target.value = update.content; target.setSelectionRange(update.selection.start, update.selection.end); target.dispatchEvent(new Event("input")); } } setColor(target, color) { this.state.rules.cellIs.style[target] = color; this.closeMenus(); } /***************************************************************************** * Color Scale Rule ****************************************************************************/ isValueInvalid(threshold) { switch (threshold) { case "minimum": return (this.state.errors.includes("MinInvalidFormula" /* CommandResult.MinInvalidFormula */) || this.state.errors.includes("MinBiggerThanMid" /* CommandResult.MinBiggerThanMid */) || this.state.errors.includes("MinBiggerThanMax" /* CommandResult.MinBiggerThanMax */) || this.state.errors.includes("MinNaN" /* CommandResult.MinNaN */)); case "midpoint": return (this.state.errors.includes("MidInvalidFormula" /* CommandResult.MidInvalidFormula */) || this.state.errors.includes("MidNaN" /* CommandResult.MidNaN */) || this.state.errors.includes("MidBiggerThanMax" /* CommandResult.MidBiggerThanMax */)); case "maximum": return (this.state.errors.includes("MaxInvalidFormula" /* CommandResult.MaxInvalidFormula */) || this.state.errors.includes("MaxNaN" /* CommandResult.MaxNaN */)); default: return false; } } setColorScaleColor(target, color) { if (!isColorValid(color)) { return; } const point = this.state.rules.colorScale[target]; if (point) { point.color = Number.parseInt(color.substr(1), 16); } this.closeMenus(); } getPreviewGradient() { const rule = this.state.rules.colorScale; const minColor = colorNumberString(rule.minimum.color); const midColor = colorNumberString(rule.midpoint?.color || DEFAULT_COLOR_SCALE_MIDPOINT_COLOR); const maxColor = colorNumberString(rule.maximum.color); const baseString = "background-image: linear-gradient(to right, "; return rule.midpoint === undefined ? baseString + minColor + ", " + maxColor + ")" : baseString + minColor + ", " + midColor + ", " + maxColor + ")"; } getThresholdColor(threshold) { return threshold ? colorNumberString(threshold.color) : colorNumberString(DEFAULT_COLOR_SCALE_MIDPOINT_COLOR); } onMidpointChange(ev) { const type = ev.target.value; const rule = this.state.rules.colorScale; if (type === "none") { rule.midpoint = undefined; } else { rule.midpoint = { color: DEFAULT_COLOR_SCALE_MIDPOINT_COLOR, value: "", ...rule.midpoint, type, }; } } /***************************************************************************** * Icon Set ****************************************************************************/ isInflectionPointInvalid(inflectionPoint) { switch (inflectionPoint) { case "lowerInflectionPoint": return (this.state.errors.includes("ValueLowerInflectionNaN" /* CommandResult.ValueLowerInflectionNaN */) || this.state.errors.includes("ValueLowerInvalidFormula" /* CommandResult.ValueLowerInvalidFormula */) || this.state.errors.includes("LowerBiggerThanUpper" /* CommandResult.LowerBiggerThanUpper */)); case "upperInflectionPoint": return (this.state.errors.includes("ValueUpperInflectionNaN" /* CommandResult.ValueUpperInflectionNaN */) || this.state.errors.includes("ValueUpperInvalidFormula" /* CommandResult.ValueUpperInvalidFormula */) || this.state.errors.includes("LowerBiggerThanUpper" /* CommandResult.LowerBiggerThanUpper */)); default: return true; } } reverseIcons() { const icons = this.state.rules.iconSet.icons; const upper = icons.upper; icons.upper = icons.lower; icons.lower = upper; } setIconSet(iconSet) { const icons = this.state.rules.iconSet.icons; icons.upper = this.iconSets[iconSet].good; icons.middle = this.iconSets[iconSet].neutral; icons.lower = this.iconSets[iconSet].bad; } setIcon(target, icon) { this.state.rules.iconSet.icons[target] = icon; } getCellIsRuleComposerProps(valueIndex) { const isInvalid = valueIndex === 0 ? this.isValue1Invalid : this.isValue2Invalid; return { onConfirm: (str) => (this.state.rules.cellIs.values[valueIndex] = str), composerContent: this.state.rules.cellIs.values[valueIndex], placeholder: _t("Value or formula"), invalid: isInvalid, class: "o-sidePanel-composer", defaultRangeSheetId: this.env.model.getters.getActiveSheetId(), }; } getColorScaleComposerProps(thresholdType) { const threshold = this.state.rules.colorScale[thresholdType]; if (!threshold) { throw new Error("Threshold not found"); } const isInvalid = this.isValueInvalid(thresholdType); return { onConfirm: (str) => (threshold.value = str), composerContent: threshold.value || "", placeholder: _t("Formula"), invalid: isInvalid, class: "o-sidePanel-composer", defaultRangeSheetId: this.env.model.getters.getActiveSheetId(), }; } getColorIconSetComposerProps(inflectionPoint) { const inflection = this.state.rules.iconSet[inflectionPoint]; const isInvalid = this.isInflectionPointInvalid(inflectionPoint); return { onConfirm: (str) => (inflection.value = str), composerContent: inflection.value || "", placeholder: _t("Formula"), invalid: isInvalid, class: "o-sidePanel-composer", defaultRangeSheetId: this.env.model.getters.getActiveSheetId(), }; } /***************************************************************************** * DataBar ****************************************************************************/ getRangeValues() { return [this.state.rules.dataBar.rangeValues || ""]; } updateDataBarColor(color) { if (!isColorValid(color)) { return; } this.state.rules.dataBar.color = Number.parseInt(color.substr(1), 16); } onDataBarRangeUpdate(ranges) { this.state.rules.dataBar.rangeValues = ranges[0]; } } class ConditionalFormattingPanel extends Component { static template = "o-spreadsheet-ConditionalFormattingPanel"; static props = { selection: { type: Object, optional: true }, onCloseSidePanel: Function, }; static components = { ConditionalFormatPreviewList, ConditionalFormattingEditor, }; activeSheetId; state = useState({ mode: "list", }); setup() { this.activeSheetId = this.env.model.getters.getActiveSheetId(); const sheetId = this.env.model.getters.getActiveSheetId(); const rules = this.env.model.getters.getRulesSelection(sheetId, this.props.selection || []); if (rules.length === 1) { const cf = this.conditionalFormats.find((c) => c.id === rules[0]); if (cf) { this.editConditionalFormat(cf); } } onWillUpdateProps((nextProps) => { const newActiveSheetId = this.env.model.getters.getActiveSheetId(); if (newActiveSheetId !== this.activeSheetId) { this.activeSheetId = newActiveSheetId; this.switchToList(); } else if (nextProps.selection !== this.props.selection) { const sheetId = this.env.model.getters.getActiveSheetId(); const rules = this.env.model.getters.getRulesSelection(sheetId, nextProps.selection || []); if (rules.length === 1) { const cf = this.conditionalFormats.find((c) => c.id === rules[0]); if (cf) { this.editConditionalFormat(cf); } } else { this.switchToList(); } } }); } get conditionalFormats() { const cfs = this.env.model.getters.getConditionalFormats(this.env.model.getters.getActiveSheetId()); return cfs.map((cf) => ({ ...cf, rule: localizeCFRule(cf.rule, this.env.model.getters.getLocale()), })); } switchToList() { this.state.mode = "list"; this.state.editedCf = undefined; } addConditionalFormat() { this.state.mode = "edit"; } editConditionalFormat(cf) { this.state.mode = "edit"; this.state.editedCf = cf; } } css /* scss */ ` .o-custom-currency { .o-format-proposals { color: black; } .o-format-examples { background: #f9fafb; padding: 8px; border-radius: 4px; border: 1px solid #d8dadd; color: #374151; } } `; class CustomCurrencyPanel extends Component { static template = "o-spreadsheet-CustomCurrencyPanel"; static components = { Section, Checkbox }; static props = { onCloseSidePanel: Function }; availableCurrencies; state; setup() { this.availableCurrencies = []; this.state = useState({ selectedCurrencyIndex: 0, currencyCode: "", currencySymbol: "", selectedFormatIndex: 0, isAccountingFormat: false, }); onWillStart(() => this.updateAvailableCurrencies()); } get formatProposals() { const baseCurrency = this.availableCurrencies[this.state.selectedCurrencyIndex]; const position = baseCurrency.position; const opposite = baseCurrency.position === "before" ? "after" : "before"; const symbol = this.state.currencySymbol.trim() ? this.state.currencySymbol : ""; const code = this.state.currencyCode.trim() ? this.state.currencyCode : ""; const decimalPlaces = baseCurrency.decimalPlaces; if (!symbol && !code) { return []; } const simple = { symbol, position, decimalPlaces }; const rounded = { symbol, position, decimalPlaces: 0 }; const simpleWithCode = { symbol, position, decimalPlaces, code }; const roundedWithCode = { symbol, position, decimalPlaces: 0, code }; const simpleOpposite = { symbol, position: opposite, decimalPlaces }; const roundedOpposite = { symbol, position: opposite, decimalPlaces: 0 }; const simpleOppositeWithCode = { symbol, position: opposite, decimalPlaces, code }; const roundedOppositeWithCode = { symbol, position: opposite, decimalPlaces: 0, code }; const currencies = [ rounded, simple, roundedWithCode, simpleWithCode, roundedOpposite, simpleOpposite, roundedOppositeWithCode, simpleOppositeWithCode, ]; const usedFormats = new Set(); const locale = this.env.model.getters.getLocale(); return currencies .map((currency) => { const format = createCurrencyFormat(currency); if ((!currency.symbol && !currency.code) || usedFormats.has(format)) { return undefined; } usedFormats.add(format); return { format, accountingFormat: createAccountingFormat(currency), example: formatValue(1000.0, { format, locale }), }; }) .filter(isDefined); } get isSameFormat() { return this.selectedFormat ? this.selectedFormat === this.getCommonFormat() : false; } async updateAvailableCurrencies() { if (currenciesRegistry.getAll().length === 0) { const currencies = (await this.env.loadCurrencies?.()) || []; currencies.forEach((currency, index) => { currenciesRegistry.add(index.toString(), currency); }); } const emptyCurrency = { name: _t(CustomCurrencyTerms.Custom), code: "", symbol: "", decimalPlaces: 2, position: "after", }; this.availableCurrencies = [emptyCurrency, ...currenciesRegistry.getAll()]; } updateSelectCurrency(ev) { const target = ev.target; this.state.selectedCurrencyIndex = parseInt(target.value, 10); const currency = this.availableCurrencies[this.state.selectedCurrencyIndex]; this.state.currencyCode = currency.code; this.state.currencySymbol = currency.symbol; } updateCode(ev) { const target = ev.target; this.state.currencyCode = target.value; this.initAvailableCurrencies(); } updateSymbol(ev) { const target = ev.target; this.state.currencySymbol = target.value; this.initAvailableCurrencies(); } updateSelectFormat(ev) { const target = ev.target; this.state.selectedFormatIndex = parseInt(target.value, 10); } apply() { this.env.model.dispatch("SET_FORMATTING_WITH_PIVOT", { sheetId: this.env.model.getters.getActiveSheetId(), target: this.env.model.getters.getSelectedZones(), format: this.selectedFormat, }); } // --------------------------------------------------------------------------- // Private // --------------------------------------------------------------------------- initAvailableCurrencies() { this.state.selectedCurrencyIndex = 0; } getCommonFormat() { const selectedZones = this.env.model.getters.getSelectedZones(); const sheetId = this.env.model.getters.getActiveSheetId(); const cells = selectedZones .map((zone) => this.env.model.getters.getEvaluatedCellsInZone(sheetId, zone)) .flat(); const firstFormat = cells[0].format; return cells.every((cell) => cell.format === firstFormat) ? firstFormat : undefined; } currencyDisplayName(currency) { return currency.name + (currency.code ? ` (${currency.code})` : ""); } toggleAccountingFormat() { this.state.isAccountingFormat = !this.state.isAccountingFormat; } getFormatExamples() { const format = this.selectedFormat; const locale = this.env.model.getters.getLocale(); return [ { label: _t("positive") + ":", value: formatValue(1234.56, { format, locale }) }, { label: _t("negative") + ":", value: formatValue(-1234.56, { format, locale }) }, { label: _t("zero") + ":", value: formatValue(0, { format, locale }) }, ]; } get selectedFormat() { const proposal = this.formatProposals[this.state.selectedFormatIndex]; return this.state.isAccountingFormat ? proposal?.accountingFormat : proposal?.format; } } const dataValidationEvaluatorRegistry = new Registry(); dataValidationEvaluatorRegistry.add("textContains", { type: "textContains", isValueValid: (value, criterion) => { const strValue = String(value); return strValue.toLowerCase().includes(criterion.values[0].toLowerCase()); }, getErrorString: (criterion) => { return _t('The value must be a text that contains "%s"', criterion.values[0]); }, isCriterionValueValid: (value) => !!value, criterionValueErrorString: DVTerms.CriterionError.notEmptyValue, numberOfValues: () => 1, name: _t("Text contains"), getPreview: (criterion) => _t('Text contains "%s"', criterion.values[0]), }); dataValidationEvaluatorRegistry.add("textNotContains", { type: "textNotContains", isValueValid: (value, criterion) => { const strValue = String(value); return !strValue.toLowerCase().includes(criterion.values[0].toLowerCase()); }, getErrorString: (criterion) => { return _t('The value must be a text that does not contain "%s"', criterion.values[0]); }, isCriterionValueValid: (value) => !!value, criterionValueErrorString: DVTerms.CriterionError.notEmptyValue, numberOfValues: () => 1, name: _t("Text does not contains"), getPreview: (criterion) => _t('Text does not contain "%s"', criterion.values[0]), }); dataValidationEvaluatorRegistry.add("textIs", { type: "textIs", isValueValid: (value, criterion) => { const strValue = String(value); return strValue.toLowerCase() === criterion.values[0].toLowerCase(); }, getErrorString: (criterion) => { return _t('The value must be exactly "%s"', criterion.values[0]); }, isCriterionValueValid: (value) => !!value, criterionValueErrorString: DVTerms.CriterionError.notEmptyValue, numberOfValues: () => 1, name: _t("Text is exactly"), getPreview: (criterion) => _t('Text is exactly "%s"', criterion.values[0]), }); /** Note: this regex doesn't allow for all the RFC-compliant mail addresses but should be enough for our purpose. */ const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$/; dataValidationEvaluatorRegistry.add("textIsEmail", { type: "textIsEmail", isValueValid: (value) => typeof value === "string" && emailRegex.test(value), getErrorString: () => _t("The value must be a valid email address"), isCriterionValueValid: () => true, criterionValueErrorString: "", numberOfValues: () => 0, name: _t("Text is valid email"), getPreview: () => _t("Text is valid email"), }); dataValidationEvaluatorRegistry.add("textIsLink", { type: "textIsLink", isValueValid: (value) => detectLink(value) !== undefined, getErrorString: () => _t("The value must be a valid link"), isCriterionValueValid: () => true, criterionValueErrorString: "", numberOfValues: () => 0, name: _t("Text is valid link"), getPreview: () => _t("Text is valid link"), }); dataValidationEvaluatorRegistry.add("dateIs", { type: "dateIs", isValueValid: (value, criterion) => { const criterionValue = getDateNumberCriterionValues(criterion, DEFAULT_LOCALE)[0]; const dateValue = valueToDateNumber(value, DEFAULT_LOCALE); if (dateValue === undefined || criterionValue === undefined) { return false; } if (["lastWeek", "lastMonth", "lastYear"].includes(criterion.dateValue)) { const today = jsDateToRoundNumber(DateTime.now()); return isDateBetween(dateValue, today, criterionValue); } return areDatesSameDay(dateValue, criterionValue); }, getErrorString: (criterion, getters) => { const locale = getters.getLocale(); return criterion.dateValue === "exactDate" ? _t("The value must be the date %s", getDateCriterionLocalizedValues(criterion, locale)[0]) : _t("The value must be %s", DVTerms.DateIs[criterion.dateValue]); }, isCriterionValueValid: (value) => checkValueIsDate(value), criterionValueErrorString: DVTerms.CriterionError.dateValue, numberOfValues: (criterion) => (criterion.dateValue === "exactDate" ? 1 : 0), name: _t("Date is"), getPreview: (criterion, getters) => { return criterion.dateValue === "exactDate" ? _t("Date is %s", getDateCriterionFormattedValues(criterion, getters)[0]) : _t("Date is %s", DVTerms.DateIs[criterion.dateValue]); }, }); dataValidationEvaluatorRegistry.add("dateIsBefore", { type: "dateIsBefore", isValueValid: (value, criterion) => { const criterionValue = getDateNumberCriterionValues(criterion, DEFAULT_LOCALE)[0]; const dateValue = valueToDateNumber(value, DEFAULT_LOCALE); return (dateValue !== undefined && criterionValue !== undefined && isDateStrictlyBefore(dateValue, criterionValue)); }, getErrorString: (criterion, getters) => { const locale = getters.getLocale(); return criterion.dateValue === "exactDate" ? _t("The value must be a date before %s", getDateCriterionLocalizedValues(criterion, locale)[0]) : _t("The value must be a date before %s", DVTerms.DateIsBefore[criterion.dateValue]); }, isCriterionValueValid: (value) => checkValueIsDate(value), criterionValueErrorString: DVTerms.CriterionError.dateValue, numberOfValues: (criterion) => (criterion.dateValue === "exactDate" ? 1 : 0), name: _t("Date is before"), getPreview: (criterion, getters) => { return criterion.dateValue === "exactDate" ? _t("Date is before %s", getDateCriterionFormattedValues(criterion, getters)[0]) : _t("Date is before %s", DVTerms.DateIsBefore[criterion.dateValue]); }, }); dataValidationEvaluatorRegistry.add("dateIsOnOrBefore", { type: "dateIsOnOrBefore", isValueValid: (value, criterion) => { const criterionValue = getDateNumberCriterionValues(criterion, DEFAULT_LOCALE)[0]; const dateValue = valueToDateNumber(value, DEFAULT_LOCALE); return (dateValue !== undefined && criterionValue !== undefined && isDateBefore(dateValue, criterionValue)); }, getErrorString: (criterion, getters) => { const locale = getters.getLocale(); return criterion.dateValue === "exactDate" ? _t("The value must be a date on or before %s", getDateCriterionLocalizedValues(criterion, locale)[0]) : _t("The value must be a date on or before %s", DVTerms.DateIsBefore[criterion.dateValue]); }, isCriterionValueValid: (value) => checkValueIsDate(value), criterionValueErrorString: DVTerms.CriterionError.dateValue, numberOfValues: (criterion) => (criterion.dateValue === "exactDate" ? 1 : 0), name: _t("Date is on or before"), getPreview: (criterion, getters) => { return criterion.dateValue === "exactDate" ? _t("Date is on or before %s", getDateCriterionFormattedValues(criterion, getters)[0]) : _t("Date is on or before %s", DVTerms.DateIsBefore[criterion.dateValue]); }, }); dataValidationEvaluatorRegistry.add("dateIsAfter", { type: "dateIsAfter", isValueValid: (value, criterion) => { const criterionValue = getDateNumberCriterionValues(criterion, DEFAULT_LOCALE)[0]; const dateValue = valueToDateNumber(value, DEFAULT_LOCALE); return (dateValue !== undefined && criterionValue !== undefined && isDateStrictlyAfter(dateValue, criterionValue)); }, getErrorString: (criterion, getters) => { const locale = getters.getLocale(); return criterion.dateValue === "exactDate" ? _t("The value must be a date after %s", getDateCriterionLocalizedValues(criterion, locale)[0]) : _t("The value must be a date after %s", DVTerms.DateIsBefore[criterion.dateValue]); }, isCriterionValueValid: (value) => checkValueIsDate(value), criterionValueErrorString: DVTerms.CriterionError.dateValue, numberOfValues: (criterion) => (criterion.dateValue === "exactDate" ? 1 : 0), name: _t("Date is after"), getPreview: (criterion, getters) => { return criterion.dateValue === "exactDate" ? _t("Date is after %s", getDateCriterionFormattedValues(criterion, getters)[0]) : _t("Date is after %s", DVTerms.DateIsBefore[criterion.dateValue]); }, }); dataValidationEvaluatorRegistry.add("dateIsOnOrAfter", { type: "dateIsOnOrAfter", isValueValid: (value, criterion) => { const criterionValue = getDateNumberCriterionValues(criterion, DEFAULT_LOCALE)[0]; const dateValue = valueToDateNumber(value, DEFAULT_LOCALE); return (dateValue !== undefined && criterionValue !== undefined && isDateAfter(dateValue, criterionValue)); }, getErrorString: (criterion, getters) => { const locale = getters.getLocale(); return criterion.dateValue === "exactDate" ? _t("The value must be a date on or after %s", getDateCriterionLocalizedValues(criterion, locale)[0]) : _t("The value must be a date on or after %s", DVTerms.DateIsBefore[criterion.dateValue]); }, isCriterionValueValid: (value) => checkValueIsDate(value), criterionValueErrorString: DVTerms.CriterionError.dateValue, numberOfValues: (criterion) => (criterion.dateValue === "exactDate" ? 1 : 0), name: _t("Date is on or after"), getPreview: (criterion, getters) => { return criterion.dateValue === "exactDate" ? _t("Date is on or after %s", getDateCriterionFormattedValues(criterion, getters)[0]) : _t("Date is on or after %s", DVTerms.DateIsBefore[criterion.dateValue]); }, }); dataValidationEvaluatorRegistry.add("dateIsBetween", { type: "dateIsBetween", isValueValid: (value, criterion) => { const criterionValues = getDateNumberCriterionValues(criterion, DEFAULT_LOCALE); const dateValue = valueToDateNumber(value, DEFAULT_LOCALE); if (dateValue === undefined || criterionValues[0] === undefined || criterionValues[1] === undefined) { return false; } return isDateBetween(dateValue, criterionValues[0], criterionValues[1]); }, getErrorString: (criterion, getters) => { const locale = getters.getLocale(); const criterionValues = getDateCriterionLocalizedValues(criterion, locale); return _t("The value must be a date between %s and %s", criterionValues[0], criterionValues[1]); }, isCriterionValueValid: (value) => checkValueIsDate(value), criterionValueErrorString: DVTerms.CriterionError.dateValue, numberOfValues: () => 2, name: _t("Date is between"), getPreview: (criterion, getters) => { const values = getDateCriterionFormattedValues(criterion, getters); return _t("Date is between %s and %s", values[0], values[1]); }, }); dataValidationEvaluatorRegistry.add("dateIsNotBetween", { type: "dateIsNotBetween", isValueValid: (value, criterion) => { const criterionValues = getDateNumberCriterionValues(criterion, DEFAULT_LOCALE); const dateValue = valueToDateNumber(value, DEFAULT_LOCALE); if (dateValue === undefined || criterionValues[0] === undefined || criterionValues[1] === undefined) { return false; } return !isDateBetween(dateValue, criterionValues[0], criterionValues[1]); }, getErrorString: (criterion, getters) => { const locale = getters.getLocale(); const criterionValues = getDateCriterionLocalizedValues(criterion, locale); return _t("The value must be a date not between %s and %s", criterionValues[0], criterionValues[1]); }, isCriterionValueValid: (value) => checkValueIsDate(value), criterionValueErrorString: DVTerms.CriterionError.dateValue, numberOfValues: () => 2, name: _t("Date is not between"), getPreview: (criterion, getters) => { const values = getDateCriterionFormattedValues(criterion, getters); return _t("Date is not between %s and %s", values[0], values[1]); }, }); dataValidationEvaluatorRegistry.add("dateIsValid", { type: "dateIsValid", isValueValid: (value) => { return valueToDateNumber(value, DEFAULT_LOCALE) !== undefined; }, getErrorString: () => _t("The value must be a valid date"), isCriterionValueValid: (value) => checkValueIsDate(value), criterionValueErrorString: "", numberOfValues: () => 0, name: _t("Is valid date"), getPreview: () => _t("Date is valid"), }); dataValidationEvaluatorRegistry.add("isEqual", { type: "isEqual", isValueValid: (value, criterion) => { if (typeof value !== "number") { return false; } const criterionValue = getCriterionValuesAsNumber(criterion, DEFAULT_LOCALE)[0]; if (criterionValue === undefined) { return false; } return value === criterionValue; }, getErrorString: (criterion, getters) => { const locale = getters.getLocale(); const values = getNumberCriterionlocalizedValues(criterion, locale); return _t("The value must be equal to %s", values[0]); }, isCriterionValueValid: (value) => checkValueIsNumber(value), criterionValueErrorString: DVTerms.CriterionError.numberValue, numberOfValues: () => 1, name: _t("Is equal to"), getPreview: (criterion) => _t("Value is equal to %s", criterion.values[0]), }); dataValidationEvaluatorRegistry.add("isNotEqual", { type: "isNotEqual", isValueValid: (value, criterion) => { if (typeof value !== "number") { return false; } const criterionValue = getCriterionValuesAsNumber(criterion, DEFAULT_LOCALE)[0]; if (criterionValue === undefined) { return false; } return value !== criterionValue; }, getErrorString: (criterion, getters) => { const locale = getters.getLocale(); const values = getNumberCriterionlocalizedValues(criterion, locale); return _t("The value must not be equal to %s", values[0]); }, isCriterionValueValid: (value) => checkValueIsNumber(value), criterionValueErrorString: DVTerms.CriterionError.numberValue, numberOfValues: () => 1, name: _t("Is not equal to"), getPreview: (criterion) => _t("Value is not equal to %s", criterion.values[0]), }); dataValidationEvaluatorRegistry.add("isGreaterThan", { type: "isGreaterThan", isValueValid: (value, criterion) => { if (typeof value !== "number") { return false; } const criterionValue = getCriterionValuesAsNumber(criterion, DEFAULT_LOCALE)[0]; if (criterionValue === undefined) { return false; } return value > criterionValue; }, getErrorString: (criterion, getters) => { const locale = getters.getLocale(); const values = getNumberCriterionlocalizedValues(criterion, locale); return _t("The value must be greater than %s", values[0]); }, isCriterionValueValid: (value) => checkValueIsNumber(value), criterionValueErrorString: DVTerms.CriterionError.numberValue, numberOfValues: () => 1, name: _t("Is greater than"), getPreview: (criterion) => _t("Value is greater than %s", criterion.values[0]), }); dataValidationEvaluatorRegistry.add("isGreaterOrEqualTo", { type: "isGreaterOrEqualTo", isValueValid: (value, criterion) => { if (typeof value !== "number") { return false; } const criterionValue = getCriterionValuesAsNumber(criterion, DEFAULT_LOCALE)[0]; if (criterionValue === undefined) { return false; } return value >= criterionValue; }, getErrorString: (criterion, getters) => { const locale = getters.getLocale(); const values = getNumberCriterionlocalizedValues(criterion, locale); return _t("The value must be greater or equal to %s", values[0]); }, isCriterionValueValid: (value) => checkValueIsNumber(value), criterionValueErrorString: DVTerms.CriterionError.numberValue, numberOfValues: () => 1, name: _t("Is greater or equal to"), getPreview: (criterion) => _t("Value is greater or equal to %s", criterion.values[0]), }); dataValidationEvaluatorRegistry.add("isLessThan", { type: "isLessThan", isValueValid: (value, criterion) => { if (typeof value !== "number") { return false; } const criterionValue = getCriterionValuesAsNumber(criterion, DEFAULT_LOCALE)[0]; if (criterionValue === undefined) { return false; } return value < criterionValue; }, getErrorString: (criterion, getters) => { const locale = getters.getLocale(); const values = getNumberCriterionlocalizedValues(criterion, locale); return _t("The value must be less than %s", values[0]); }, isCriterionValueValid: (value) => checkValueIsNumber(value), criterionValueErrorString: DVTerms.CriterionError.numberValue, numberOfValues: () => 1, name: _t("Is less than"), getPreview: (criterion) => _t("Value is less than %s", criterion.values[0]), }); dataValidationEvaluatorRegistry.add("isLessOrEqualTo", { type: "isLessOrEqualTo", isValueValid: (value, criterion) => { if (typeof value !== "number") { return false; } const criterionValue = getCriterionValuesAsNumber(criterion, DEFAULT_LOCALE)[0]; if (criterionValue === undefined) { return false; } return value <= criterionValue; }, getErrorString: (criterion, getters) => { const locale = getters.getLocale(); const values = getNumberCriterionlocalizedValues(criterion, locale); return _t("The value must be less or equal to %s", values[0]); }, isCriterionValueValid: (value) => checkValueIsNumber(value), criterionValueErrorString: DVTerms.CriterionError.numberValue, numberOfValues: () => 1, name: _t("Is less or equal to"), getPreview: (criterion) => _t("Value is less or equal to %s", criterion.values[0]), }); dataValidationEvaluatorRegistry.add("isBetween", { type: "isBetween", isValueValid: (value, criterion) => { if (typeof value !== "number") { return false; } const criterionValues = getCriterionValuesAsNumber(criterion, DEFAULT_LOCALE); if (criterionValues[0] === undefined || criterionValues[1] === undefined) { return false; } return isNumberBetween(value, criterionValues[0], criterionValues[1]); }, getErrorString: (criterion, getters) => { const locale = getters.getLocale(); const values = getNumberCriterionlocalizedValues(criterion, locale); return _t("The value must be between %s and %s", values[0], values[1]); }, isCriterionValueValid: (value) => checkValueIsNumber(value), criterionValueErrorString: DVTerms.CriterionError.numberValue, numberOfValues: () => 2, name: _t("Is between"), getPreview: (criterion) => _t("Value is between %s and %s", criterion.values[0], criterion.values[1]), }); dataValidationEvaluatorRegistry.add("isNotBetween", { type: "isNotBetween", isValueValid: (value, criterion) => { if (typeof value !== "number") { return false; } const criterionValues = getCriterionValuesAsNumber(criterion, DEFAULT_LOCALE); if (criterionValues[0] === undefined || criterionValues[1] === undefined) { return false; } return !isNumberBetween(value, criterionValues[0], criterionValues[1]); }, getErrorString: (criterion, getters) => { const locale = getters.getLocale(); const values = getNumberCriterionlocalizedValues(criterion, locale); return _t("The value must not be between %s and %s", values[0], values[1]); }, isCriterionValueValid: (value) => checkValueIsNumber(value), criterionValueErrorString: DVTerms.CriterionError.numberValue, numberOfValues: () => 2, name: _t("Is not between"), getPreview: (criterion) => _t("Value is not between %s and %s", criterion.values[0], criterion.values[1]), }); dataValidationEvaluatorRegistry.add("isBoolean", { type: "isBoolean", isValueValid: (value) => value === "" || typeof value === "boolean", getErrorString: () => _t("The value must be a boolean"), isCriterionValueValid: () => true, criterionValueErrorString: "", numberOfValues: () => 0, name: _t("Checkbox"), getPreview: () => _t("Checkbox"), }); dataValidationEvaluatorRegistry.add("isValueInList", { type: "isValueInList", isValueValid: (value, criterion) => { if (value === null) { return false; } return criterion.values .map((str) => str.toLowerCase()) .includes(value.toString().toLowerCase()); }, getErrorString: (criterion) => _t("The value must be one of: %s", criterion.values.join(", ")), isCriterionValueValid: () => true, criterionValueErrorString: "", numberOfValues: () => undefined, allowedValues: "onlyLiterals", name: _t("Value in list"), getPreview: (criterion) => _t("Value one of: %s", criterion.values.join(", ")), }); dataValidationEvaluatorRegistry.add("isValueInRange", { type: "isValueInList", isValueValid: (value, criterion, getters, sheetId) => { if (!value) { return false; } const range = getters.getRangeFromSheetXC(sheetId, criterion.values[0]); const criterionValues = getters.getRangeValues(range); return criterionValues .filter(isNotNull) .map((value) => value.toString().toLowerCase()) .includes(value.toString().toLowerCase()); }, getErrorString: (criterion) => _t("The value must be a value in the range %s", criterion.values[0]), isCriterionValueValid: (value) => rangeReference.test(value), criterionValueErrorString: DVTerms.CriterionError.validRange, numberOfValues: () => 1, allowedValues: "onlyLiterals", name: _t("Value in range"), getPreview: (criterion) => _t("Value in range %s", criterion.values[0]), }); dataValidationEvaluatorRegistry.add("customFormula", { type: "customFormula", isValueValid: (value, criterion) => { const parsedValue = parseLiteral(criterion.values[0], DEFAULT_LOCALE); if (typeof parsedValue === "number" || typeof parsedValue === "boolean") { return !!parsedValue; } return false; }, getErrorString: () => _t("The value does not match the custom formula data validation rule"), isCriterionValueValid: () => true, criterionValueErrorString: "", numberOfValues: () => 1, allowedValues: "onlyFormulas", name: _t("Custom formula"), getPreview: (criterion) => _t("Custom formula %s", criterion.values[0]), }); function getNumberCriterionlocalizedValues(criterion, locale) { return criterion.values.map((value) => value !== undefined ? localizeContent(value, locale) : CellErrorType.InvalidReference); } function getDateCriterionLocalizedValues(criterion, locale) { const values = getDateNumberCriterionValues(criterion, DEFAULT_LOCALE); return values.map((value) => value !== undefined ? formatValue(value, { locale, format: locale.dateFormat }) : CellErrorType.InvalidReference); } function checkValueIsDate(value) { const valueAsNumber = valueToDateNumber(value, DEFAULT_LOCALE); return valueAsNumber !== undefined; } function checkValueIsNumber(value) { const valueAsNumber = tryToNumber(value, DEFAULT_LOCALE); return valueAsNumber !== undefined; } function getDateCriterionFormattedValues(criterion, getters) { const locale = getters.getLocale(); return criterion.values.map((valueStr) => { if (valueStr.startsWith("=")) { return valueStr; } const value = parseLiteral(valueStr, locale); if (typeof value === "number") { return formatValue(value, { format: locale.dateFormat, locale }); } return ""; }); } /** This component looks like a select input, but on click it opens a Menu with the items given as props instead of a dropdown */ class SelectMenu extends Component { static template = "o-spreadsheet-SelectMenu"; static props = { menuItems: Array, selectedValue: String, class: { type: String, optional: true }, }; static components = { Menu }; menuId = new UuidGenerator().uuidv4(); selectRef = useRef("select"); selectRect = useAbsoluteBoundingRect(this.selectRef); state = useState({ isMenuOpen: false, }); onClick(ev) { if (ev.closedMenuId === this.menuId) { return; } this.state.isMenuOpen = !this.state.isMenuOpen; } onMenuClosed() { this.state.isMenuOpen = false; } get menuPosition() { return { x: this.selectRect.x, y: this.selectRect.y + this.selectRect.height, }; } } class DataValidationCriterionForm extends Component { static props = { criterion: Object, onCriterionChanged: Function, }; setup() { const composerFocusStore = useStore(ComposerFocusStore); onMounted(() => { composerFocusStore.activeComposer.stopEdition(); }); } updateCriterion(criterion) { const filteredCriterion = { ...this.props.criterion, ...criterion, }; this.props.onCriterionChanged(filteredCriterion); } } css /* scss */ ` .o-dv-input { .o-invalid { background-color: #ffdddd; } .error-icon { right: 7px; top: 7px; } } `; class DataValidationInput extends Component { static template = "o-spreadsheet-DataValidationInput"; static props = { value: { type: String, optional: true }, criterionType: String, onValueChanged: Function, onKeyDown: { type: Function, optional: true }, focused: { type: Boolean, optional: true }, onBlur: { type: Function, optional: true }, onFocus: { type: Function, optional: true }, }; static defaultProps = { value: "", onKeyDown: () => { }, focused: false, onBlur: () => { }, }; inputRef = useRef("input"); setup() { useEffect(() => { if (this.props.focused) { this.inputRef.el.focus(); } }, () => [this.props.focused, this.inputRef.el]); } state = useState({ shouldDisplayError: !!this.props.value, // Don't display error if user inputted nothing yet }); onValueChanged(ev) { this.state.shouldDisplayError = true; this.props.onValueChanged(ev.target.value); } get placeholder() { const evaluator = dataValidationEvaluatorRegistry.get(this.props.criterionType); if (evaluator.allowedValues === "onlyFormulas") { return _t("Formula"); } else if (evaluator.allowedValues === "onlyLiterals") { return _t("Value"); } return _t("Value or formula"); } get errorMessage() { if (!this.state.shouldDisplayError) { return undefined; } return this.env.model.getters.getDataValidationInvalidCriterionValueMessage(this.props.criterionType, canonicalizeContent(this.props.value, this.env.model.getters.getLocale())); } } const DATES_VALUES = { today: _t("today"), yesterday: _t("yesterday"), tomorrow: _t("tomorrow"), lastWeek: _t("in the past week"), lastMonth: _t("in the past month"), lastYear: _t("in the past year"), exactDate: _t("exact date"), }; class DataValidationDateCriterionForm extends DataValidationCriterionForm { static template = "o-spreadsheet-DataValidationDateCriterion"; static components = { DataValidationInput }; setup() { super.setup(); const setupDefault = (props) => { if (props.criterion.dateValue === undefined) { this.updateCriterion({ dateValue: "exactDate" }); } }; onWillUpdateProps(setupDefault); onWillStart(() => setupDefault(this.props)); } onValueChanged(value) { this.updateCriterion({ values: [value] }); } onDateValueChanged(ev) { const dateValue = ev.target.value; this.updateCriterion({ dateValue }); } get dateValues() { return Object.keys(DATES_VALUES).map((key) => ({ value: key, title: DATES_VALUES[key], })); } } class DataValidationDoubleInputCriterionForm extends DataValidationCriterionForm { static template = "o-spreadsheet-DataValidationDoubleInput"; static components = { DataValidationInput }; onFirstValueChanged(value) { const values = this.props.criterion.values; this.updateCriterion({ values: [value, values[1]], }); } onSecondValueChanged(value) { const values = this.props.criterion.values; this.updateCriterion({ values: [values[0], value], }); } } class DataValidationSingleInputCriterionForm extends DataValidationCriterionForm { static template = "o-spreadsheet-DataValidationSingleInput"; static components = { DataValidationInput }; onValueChanged(value) { const criterion = deepCopy(this.props.criterion); criterion.values[0] = value; this.updateCriterion(criterion); } } css /* scss */ ` .o-dv-list-item-delete { color: #666666; cursor: pointer; } `; class DataValidationListCriterionForm extends DataValidationCriterionForm { static template = "o-spreadsheet-DataValidationListCriterionForm"; static components = { DataValidationInput }; state = useState({ numberOfValues: Math.max(this.props.criterion.values.length, 2), }); setup() { super.setup(); const setupDefault = (props) => { if (props.criterion.displayStyle === undefined) { this.updateCriterion({ displayStyle: "arrow" }); } }; onWillUpdateProps(setupDefault); onWillStart(() => setupDefault(this.props)); } onValueChanged(value, index) { const values = [...this.displayedValues]; values[index] = value; this.updateCriterion({ values }); } onAddAnotherValue() { this.state.numberOfValues++; } removeItem(index) { const values = [...this.displayedValues]; values.splice(index, 1); this.state.numberOfValues--; this.updateCriterion({ values }); } onChangedDisplayStyle(ev) { const displayStyle = ev.target.value; this.updateCriterion({ displayStyle }); } onKeyDown(ev, index) { if ((ev.key === "Enter" || ev.key === "Tab") && index === this.state.numberOfValues - 1) { this.onAddAnotherValue(); this.state.focusedValueIndex = index + 1; ev.preventDefault(); } else if (ev.key === "Enter") { this.state.focusedValueIndex = index + 1; } } onBlurInput() { this.state.focusedValueIndex = undefined; } get displayedValues() { const values = []; for (let i = 0; i < this.state.numberOfValues; i++) { values.push(this.props.criterion.values[i] || ""); } return values; } } class DataValidationValueInRangeCriterionForm extends DataValidationCriterionForm { static template = "o-spreadsheet-DataValidationValueInRangeCriterionForm"; static components = { SelectionInput }; setup() { super.setup(); const setupDefault = (props) => { if (props.criterion.displayStyle === undefined) { this.updateCriterion({ displayStyle: "arrow" }); } }; onWillUpdateProps(setupDefault); onWillStart(() => setupDefault(this.props)); } onRangeChanged(rangeXc) { this.updateCriterion({ values: [rangeXc] }); } onChangedDisplayStyle(ev) { const displayStyle = ev.target.value; this.updateCriterion({ displayStyle }); } } const dvCriterionCategoriesSequences = { list: 10, text: 20, date: 30, number: 40, misc: 50, }; const dataValidationPanelCriteriaRegistry = new Registry(); dataValidationPanelCriteriaRegistry.add("textContains", { type: "textContains", component: DataValidationSingleInputCriterionForm, category: "text", sequence: 10, }); dataValidationPanelCriteriaRegistry.add("textNotContains", { type: "textNotContains", component: DataValidationSingleInputCriterionForm, category: "text", sequence: 20, }); dataValidationPanelCriteriaRegistry.add("textIs", { type: "textIs", component: DataValidationSingleInputCriterionForm, category: "text", sequence: 30, }); dataValidationPanelCriteriaRegistry.add("textIsEmail", { type: "textIsEmail", component: undefined, category: "text", sequence: 40, }); dataValidationPanelCriteriaRegistry.add("textIsLink", { type: "textIsLink", component: undefined, category: "text", sequence: 50, }); dataValidationPanelCriteriaRegistry.add("dateIs", { type: "dateIs", component: DataValidationDateCriterionForm, category: "date", sequence: 20, }); dataValidationPanelCriteriaRegistry.add("dateIsBefore", { type: "dateIsBefore", component: DataValidationDateCriterionForm, category: "date", sequence: 30, }); dataValidationPanelCriteriaRegistry.add("dateIsOnOrBefore", { type: "dateIsOnOrBefore", component: DataValidationDateCriterionForm, category: "date", sequence: 40, }); dataValidationPanelCriteriaRegistry.add("dateIsAfter", { type: "dateIsAfter", component: DataValidationDateCriterionForm, category: "date", sequence: 50, }); dataValidationPanelCriteriaRegistry.add("dateIsOnOrAfter", { type: "dateIsOnOrAfter", component: DataValidationDateCriterionForm, category: "date", sequence: 60, }); dataValidationPanelCriteriaRegistry.add("dateIsBetween", { type: "dateIsBetween", component: DataValidationDoubleInputCriterionForm, category: "date", sequence: 70, }); dataValidationPanelCriteriaRegistry.add("dateIsNotBetween", { type: "dateIsNotBetween", component: DataValidationDoubleInputCriterionForm, category: "date", sequence: 80, }); dataValidationPanelCriteriaRegistry.add("dateIsValid", { type: "dateIsValid", component: undefined, category: "date", sequence: 10, }); dataValidationPanelCriteriaRegistry.add("isEqual", { type: "isEqual", component: DataValidationSingleInputCriterionForm, category: "number", sequence: 10, }); dataValidationPanelCriteriaRegistry.add("isNotEqual", { type: "isNotEqual", component: DataValidationSingleInputCriterionForm, category: "number", sequence: 20, }); dataValidationPanelCriteriaRegistry.add("isGreaterThan", { type: "isGreaterThan", component: DataValidationSingleInputCriterionForm, category: "number", sequence: 50, }); dataValidationPanelCriteriaRegistry.add("isGreaterOrEqualTo", { type: "isGreaterOrEqualTo", component: DataValidationSingleInputCriterionForm, category: "number", sequence: 60, }); dataValidationPanelCriteriaRegistry.add("isLessThan", { type: "isLessThan", component: DataValidationSingleInputCriterionForm, category: "number", sequence: 30, }); dataValidationPanelCriteriaRegistry.add("isLessOrEqualTo", { type: "isLessOrEqualTo", component: DataValidationSingleInputCriterionForm, category: "number", sequence: 40, }); dataValidationPanelCriteriaRegistry.add("isBetween", { type: "isBetween", component: DataValidationDoubleInputCriterionForm, category: "number", sequence: 70, }); dataValidationPanelCriteriaRegistry.add("isNotBetween", { type: "isNotBetween", component: DataValidationDoubleInputCriterionForm, category: "number", sequence: 80, }); dataValidationPanelCriteriaRegistry.add("isBoolean", { type: "isBoolean", component: undefined, category: "misc", sequence: 10, }); dataValidationPanelCriteriaRegistry.add("isValueInList", { type: "isValueInList", component: DataValidationListCriterionForm, category: "list", sequence: 10, }); dataValidationPanelCriteriaRegistry.add("isValueInRange", { type: "isValueInRange", component: DataValidationValueInRangeCriterionForm, category: "list", sequence: 20, }); dataValidationPanelCriteriaRegistry.add("customFormula", { type: "customFormula", component: DataValidationSingleInputCriterionForm, category: "misc", sequence: 20, }); function getDataValidationCriterionMenuItems(callback) { const items = dataValidationPanelCriteriaRegistry.getAll().sort((a, b) => { if (a.category === b.category) { return a.sequence - b.sequence; } return dvCriterionCategoriesSequences[a.category] - dvCriterionCategoriesSequences[b.category]; }); const actionSpecs = items.map((item, index) => { const evaluator = dataValidationEvaluatorRegistry.get(item.type); return { name: evaluator.name, id: item.type, separator: item.category !== items[index + 1]?.category, execute: () => callback(item.type), }; }); return createActions(actionSpecs); } class DataValidationEditor extends Component { static template = "o-spreadsheet-DataValidationEditor"; static components = { SelectionInput, SelectMenu, Section }; static props = { rule: { type: Object, optional: true }, onExit: Function, onCloseSidePanel: { type: Function, optional: true }, }; state = useState({ rule: this.defaultDataValidationRule }); setup() { if (this.props.rule) { const sheetId = this.env.model.getters.getActiveSheetId(); this.state.rule = { ...this.props.rule, ranges: this.props.rule.ranges.map((range) => this.env.model.getters.getRangeString(range, sheetId)), }; this.state.rule.criterion.type = this.props.rule.criterion.type; } } onCriterionTypeChanged(type) { this.state.rule.criterion.type = type; } onRangesChanged(ranges) { this.state.rule.ranges = ranges; } onCriterionChanged(criterion) { this.state.rule.criterion = criterion; } changeRuleIsBlocking(ev) { const isBlocking = ev.target.value; this.state.rule.isBlocking = isBlocking === "true"; } onSave() { if (!this.canSave) { return; } this.env.model.dispatch("ADD_DATA_VALIDATION_RULE", this.dispatchPayload); this.props.onExit(); } get canSave() { return this.env.model.canDispatch("ADD_DATA_VALIDATION_RULE", this.dispatchPayload) .isSuccessful; } get dispatchPayload() { const rule = { ...this.state.rule, ranges: undefined }; const locale = this.env.model.getters.getLocale(); const criterion = rule.criterion; const criterionEvaluator = dataValidationEvaluatorRegistry.get(criterion.type); const sheetId = this.env.model.getters.getActiveSheetId(); const values = criterion.values .slice(0, criterionEvaluator.numberOfValues(criterion)) .map((value) => value?.trim()) .filter((value) => value !== "" && value !== undefined) .map((value) => canonicalizeContent(value, locale)); rule.criterion = { ...criterion, values }; return { sheetId, ranges: this.state.rule.ranges.map((xc) => this.env.model.getters.getRangeDataFromXc(sheetId, xc)), rule, }; } get dvCriterionMenuItems() { return getDataValidationCriterionMenuItems((type) => this.onCriterionTypeChanged(type)); } get selectedCriterionName() { const selectedType = this.state.rule.criterion.type; return dataValidationEvaluatorRegistry.get(selectedType).name; } get defaultDataValidationRule() { const sheetId = this.env.model.getters.getActiveSheetId(); const ranges = this.env.model.getters .getSelectedZones() .map((zone) => zoneToXc(this.env.model.getters.getUnboundedZone(sheetId, zone))); return { id: this.env.model.uuidGenerator.uuidv4(), criterion: { type: "textContains", values: [""] }, ranges, }; } get criterionComponent() { return dataValidationPanelCriteriaRegistry.get(this.state.rule.criterion.type).component; } } css /* scss */ ` .o-sidePanel { .o-dv-preview { height: 70px; box-sizing: border-box; cursor: pointer; border-bottom: 1px solid ${FIGURE_BORDER_COLOR}; .o-dv-container { min-width: 0; // otherwise flex won't shrink correctly } .o-dv-preview-description { font-size: 13px; } &:hover { background-color: rgba(0, 0, 0, 0.08); } &:not(:hover) .o-dv-preview-delete { display: none !important; } } } `; class DataValidationPreview extends Component { static template = "o-spreadsheet-DataValidationPreview"; static props = { onClick: Function, rule: Object, }; ref = useRef("dvPreview"); setup() { useHighlightsOnHover(this.ref, this); } deleteDataValidation() { const sheetId = this.env.model.getters.getActiveSheetId(); this.env.model.dispatch("REMOVE_DATA_VALIDATION_RULE", { sheetId, id: this.props.rule.id }); } get highlights() { return this.props.rule.ranges.map((range) => ({ sheetId: this.env.model.getters.getActiveSheetId(), zone: range.zone, color: HIGHLIGHT_COLOR, fillAlpha: 0.06, })); } get rangesString() { const sheetId = this.env.model.getters.getActiveSheetId(); return this.props.rule.ranges .map((range) => this.env.model.getters.getRangeString(range, sheetId)) .join(", "); } get descriptionString() { return dataValidationEvaluatorRegistry .get(this.props.rule.criterion.type) .getPreview(this.props.rule.criterion, this.env.model.getters); } } class DataValidationPanel extends Component { static template = "o-spreadsheet-DataValidationPanel"; static props = { onCloseSidePanel: Function, }; static components = { DataValidationPreview, DataValidationEditor }; state = useState({ mode: "list", activeRule: undefined }); onPreviewClick(id) { const sheetId = this.env.model.getters.getActiveSheetId(); const rule = this.env.model.getters.getDataValidationRule(sheetId, id); if (rule) { this.state.mode = "edit"; this.state.activeRule = rule; } } addDataValidationRule() { this.state.mode = "edit"; this.state.activeRule = undefined; } onExitEditMode() { this.state.mode = "list"; this.state.activeRule = undefined; } localizeDVRule(rule) { if (!rule) return rule; const locale = this.env.model.getters.getLocale(); return localizeDataValidationRule(rule, locale); } get validationRules() { const sheetId = this.env.model.getters.getActiveSheetId(); return this.env.model.getters.getDataValidationRules(sheetId); } } const FIND_AND_REPLACE_HIGHLIGHT_COLOR = "#8B008B"; var Direction; (function (Direction) { Direction[Direction["previous"] = -1] = "previous"; Direction[Direction["current"] = 0] = "current"; Direction[Direction["next"] = 1] = "next"; })(Direction || (Direction = {})); class FindAndReplaceStore extends SpreadsheetStore { mutators = [ "updateSearchOptions", "updateSearchContent", "searchFormulas", "selectPreviousMatch", "selectNextMatch", "replace", ]; allSheetsMatches = []; activeSheetMatches = []; specificRangeMatches = []; currentSearchRegex = null; isSearchDirty = false; initialShowFormulaState; preserveSelectedMatchIndex = false; irreplaceableMatchCount = 0; notificationStore = this.get(NotificationStore); // fixme: why do we make selectedMatchIndex on top of a selected // property in the matches? selectedMatchIndex = null; toSearch = ""; toReplace = ""; searchOptions = { matchCase: false, exactMatch: false, searchFormulas: false, searchScope: "activeSheet", specificRange: undefined, }; constructor(get) { super(get); this.initialShowFormulaState = this.model.getters.shouldShowFormulas(); this.searchOptions.searchFormulas = this.initialShowFormulaState; const highlightStore = get(HighlightStore); highlightStore.register(this); this.onDispose(() => { this.model.dispatch("SET_FORMULA_VISIBILITY", { show: this.initialShowFormulaState }); highlightStore.unRegister(this); }); } get searchMatches() { switch (this.searchOptions.searchScope) { case "allSheets": return this.allSheetsMatches; case "activeSheet": return this.activeSheetMatches; case "specificRange": return this.specificRangeMatches; } } updateSearchContent(toSearch) { this._updateSearch(toSearch, this.searchOptions); } updateSearchOptions(searchOptions) { this._updateSearch(this.toSearch, { ...this.searchOptions, ...searchOptions }); } searchFormulas(showFormula) { this.model.dispatch("SET_FORMULA_VISIBILITY", { show: showFormula }); this.updateSearchOptions({ searchFormulas: showFormula }); } selectPreviousMatch() { this.selectNextCell(Direction.previous); } selectNextMatch() { this.selectNextCell(Direction.next); } handle(cmd) { switch (cmd.type) { case "SET_FORMULA_VISIBILITY": this.updateSearchOptions({ searchFormulas: cmd.show }); break; case "UNDO": case "REDO": case "REMOVE_TABLE": case "UPDATE_FILTER": case "REMOVE_COLUMNS_ROWS": case "HIDE_COLUMNS_ROWS": case "UNHIDE_COLUMNS_ROWS": case "ADD_COLUMNS_ROWS": case "EVALUATE_CELLS": case "UPDATE_CELL": case "ACTIVATE_SHEET": this.isSearchDirty = true; break; case "REPLACE_SEARCH": for (const match of cmd.matches) { this.replaceMatch(match, cmd.searchString, cmd.replaceWith, cmd.searchOptions); } if (this.irreplaceableMatchCount > 0) { this.showReplaceWarningMessage(cmd.matches.length, this.irreplaceableMatchCount); } this.irreplaceableMatchCount = 0; break; } } finalize() { if (this.isSearchDirty) { this.refreshSearch(false); this.isSearchDirty = false; } } get allSheetMatchesCount() { return this.allSheetsMatches.length; } get activeSheetMatchesCount() { return this.activeSheetMatches.length; } get specificRangeMatchesCount() { return this.specificRangeMatches.length; } // --------------------------------------------------------------------------- // Search // --------------------------------------------------------------------------- /** * Will update the current searchOptions and accordingly update the regex. * It will then search for matches using the regex and store them. */ _updateSearch(toSearch, searchOptions) { this.searchOptions = searchOptions; if (toSearch !== this.toSearch) { this.selectedMatchIndex = null; } this.toSearch = toSearch; this.currentSearchRegex = getSearchRegex(this.toSearch, this.searchOptions); this.refreshSearch(); } /** * refresh the matches according to the current search options */ refreshSearch(jumpToMatchSheet = true) { if (!this.preserveSelectedMatchIndex) { this.selectedMatchIndex = null; } this.findMatches(); this.selectNextCell(Direction.current, jumpToMatchSheet); } getSheetsInSearchOrder() { switch (this.searchOptions.searchScope) { case "allSheets": const sheetIds = this.getters.getSheetIds(); const activeSheetIndex = sheetIds.findIndex((id) => id === this.getters.getActiveSheetId()); return [ sheetIds[activeSheetIndex], ...sheetIds.slice(activeSheetIndex + 1), ...sheetIds.slice(0, activeSheetIndex), ]; case "activeSheet": return [this.getters.getActiveSheetId()]; case "specificRange": const specificRange = this.searchOptions.specificRange; if (!specificRange) { return []; } return specificRange ? [specificRange.sheetId] : []; } } /** * Find matches using the current regex */ findMatches() { const matches = []; if (this.toSearch) { for (const sheetId of this.getters.getSheetIds()) { matches.push(...this.findMatchesInSheet(sheetId)); } } // set results this.allSheetsMatches = matches; this.activeSheetMatches = matches.filter((match) => match.sheetId === this.getters.getActiveSheetId()); if (this.searchOptions.specificRange) { const { sheetId, zone } = this.searchOptions.specificRange; this.specificRangeMatches = matches.filter((match) => match.sheetId === sheetId && isInside(match.col, match.row, zone)); } else { this.specificRangeMatches = []; } } findMatchesInSheet(sheetId) { const matches = []; const { left, right, top, bottom } = this.getters.getSheetZone(sheetId); for (let row = top; row <= bottom; row++) { for (let col = left; col <= right; col++) { const isColHidden = this.getters.isColHidden(sheetId, col); const isRowHidden = this.getters.isRowHidden(sheetId, row); if (isColHidden || isRowHidden) { continue; } const cellPosition = { sheetId, col, row }; if (this.currentSearchRegex?.test(this.getSearchableString(cellPosition))) { const match = { sheetId, col, row }; matches.push(match); } } } return matches; } /** * Changes the selected search cell. Given a direction it will * Change the selection to the previous, current or nextCell, * if it exists otherwise it will set the selectedMatchIndex to null. * It will also reset the index to 0 if the search has changed. * It is also used to keep coherence between the selected searchMatch * and selectedMatchIndex. */ selectNextCell(indexChange, jumpToMatchSheet = true) { const matches = this.searchMatches; if (!matches.length) { this.selectedMatchIndex = null; return; } let nextIndex; if (this.selectedMatchIndex === null) { let nextMatchIndex = -1; // if search is not available in current sheet will select in next sheet for (const sheetId of this.getSheetsInSearchOrder()) { nextMatchIndex = matches.findIndex((match) => match.sheetId === sheetId); if (nextMatchIndex !== -1) { break; } } nextIndex = nextMatchIndex; } else { nextIndex = this.selectedMatchIndex + indexChange; } // loop index value inside the array (index -1 => last index) nextIndex = (nextIndex + matches.length) % matches.length; this.selectedMatchIndex = nextIndex; const selectedMatch = matches[nextIndex]; // Switch to the sheet where the match is located if (jumpToMatchSheet && this.getters.getActiveSheetId() !== selectedMatch.sheetId) { // We set `preserveSelectedMatchIndex` to true to avoid resetting the selected search // index in the `refreshSearch` function when a new sheet is activated. The reason being // that, when we automatically go back to previous sheet while performing a search, the // search index is reset to the first occurrence each time. this.preserveSelectedMatchIndex = true; this.model.dispatch("ACTIVATE_SHEET", { sheetIdFrom: this.getters.getActiveSheetId(), sheetIdTo: selectedMatch.sheetId, }); this.preserveSelectedMatchIndex = false; // We do not want to reset the selection at finalize in this case this.isSearchDirty = false; } // we want grid selection to capture the selection stream this.model.selection.getBackToDefault(); this.model.selection.selectCell(selectedMatch.col, selectedMatch.row); } /** * Replace the value of the currently selected match */ replace() { if (this.selectedMatchIndex === null) { return; } this.model.dispatch("REPLACE_SEARCH", { searchString: this.toSearch, replaceWith: this.toReplace, matches: [this.searchMatches[this.selectedMatchIndex]], searchOptions: this.searchOptions, }); this.selectNextCell(Direction.next); } /** * Apply the replace function to all the matches one time. */ replaceAll() { this.model.dispatch("REPLACE_SEARCH", { searchString: this.toSearch, replaceWith: this.toReplace, matches: this.searchMatches, searchOptions: this.searchOptions, }); } /** * Show a warning message based on the number of matches replaced and irreplaceable. */ showReplaceWarningMessage(totalMatches, irreplaceableMatches) { const replaceableMatches = totalMatches - irreplaceableMatches; if (replaceableMatches === 0) { this.notificationStore.notifyUser({ type: "warning", sticky: false, text: _t("Match(es) cannot be replaced as they are part of a formula."), }); } else { this.notificationStore.notifyUser({ type: "warning", sticky: false, text: _t("%(replaceable_count)s match(es) replaced. %(irreplaceable_count)s match(es) cannot be replaced as they are part of a formula.", { replaceable_count: replaceableMatches, irreplaceable_count: irreplaceableMatches, }), }); } } replaceMatch(selectedMatch, searchString, replaceWith, searchOptions) { const cell = this.getters.getCell(selectedMatch); if (!cell?.content) { return; } if (cell?.isFormula && !searchOptions.searchFormulas) { this.irreplaceableMatchCount++; return; } const searchRegex = getSearchRegex(searchString, searchOptions); const replaceRegex = new RegExp(searchRegex.source, searchRegex.flags + "g"); const toReplace = this.getters.getCellText(selectedMatch, { showFormula: searchOptions.searchFormulas, }); const content = toReplace.replace(replaceRegex, replaceWith); const canonicalContent = canonicalizeNumberContent(content, this.getters.getLocale()); this.model.dispatch("UPDATE_CELL", { ...selectedMatch, content: canonicalContent }); } getSearchableString(position) { return this.getters.getCellText(position, { showFormula: this.searchOptions.searchFormulas }); } // --------------------------------------------------------------------------- // Grid rendering // --------------------------------------------------------------------------- get highlights() { const highlights = []; const sheetId = this.getters.getActiveSheetId(); for (const [index, match] of this.searchMatches.entries()) { if (match.sheetId !== sheetId) { continue; // Skip drawing matches from other sheets } const zone = positionToZone(match); const zoneWithMerge = this.getters.expandZone(sheetId, zone); const { width, height } = this.getters.getVisibleRect(zoneWithMerge); if (width > 0 && height > 0) { highlights.push({ sheetId, zone: zoneWithMerge, color: FIND_AND_REPLACE_HIGHLIGHT_COLOR, noBorder: index !== this.selectedMatchIndex, thinLine: true, fillAlpha: 0.2, }); } } if (this.searchOptions.searchScope === "specificRange") { const range = this.searchOptions.specificRange; if (range && range.sheetId === sheetId) { highlights.push({ sheetId, zone: range.zone, color: FIND_AND_REPLACE_HIGHLIGHT_COLOR, noFill: true, thinLine: true, }); } } return highlights; } } css /* scss */ ` .o-find-and-replace { outline: none; height: 100%; .o-input-search-container { display: flex; .o-input-with-count { flex-grow: 1; width: auto; } .o-input-without-count { width: 100%; } .o-input-count { width: fit-content; padding: 4px 0 4px 4px; white-space: nowrap; } } .o-result-buttons { .o-button { height: 19px; width: 19px; .o-icon { height: 14px; width: 14px; } } } } `; class FindAndReplacePanel extends Component { static template = "o-spreadsheet-FindAndReplacePanel"; static components = { SelectionInput, Section, Checkbox, ValidationMessages }; static props = { onCloseSidePanel: Function, }; searchInput = useRef("searchInput"); store; state; updateSearchContent; get hasSearchResult() { return this.store.selectedMatchIndex !== null; } get searchOptions() { return this.store.searchOptions; } get allSheetsMatchesCount() { return _t("%s matches in all sheets", this.store.allSheetMatchesCount); } get currentSheetMatchesCount() { return _t("%(matches)s matches in %(sheetName)s", { matches: this.store.activeSheetMatchesCount, sheetName: this.env.model.getters.getSheetName(this.env.model.getters.getActiveSheetId()), }); } get specificRangeMatchesCount() { const range = this.searchOptions.specificRange; if (!range) { return ""; } const { sheetId, zone } = range; return _t("%(matches)s matches in range %(range)s of %(sheetName)s", { matches: this.store.specificRangeMatchesCount, range: zoneToXc(zone), sheetName: this.env.model.getters.getSheetName(sheetId), }); } get searchInfo() { if (!this.store.toSearch) { return []; } return [ this.specificRangeMatchesCount, this.currentSheetMatchesCount, this.allSheetsMatchesCount, ]; } setup() { this.store = useLocalStore(FindAndReplaceStore); this.state = useState({ dataRange: "" }); onMounted(() => this.searchInput.el?.focus()); onWillUnmount(() => this.updateSearchContent.stopDebounce()); this.updateSearchContent = debounce(this.store.updateSearchContent, 200); } onFocusSearch() { this.updateDataRange(); } onSearchInput(ev) { this.updateSearchContent(ev.target.value); } onKeydownSearch(ev) { if (ev.key === "Enter") { ev.preventDefault(); ev.stopPropagation(); ev.shiftKey ? this.store.selectPreviousMatch() : this.store.selectNextMatch(); } } onKeydownReplace(ev) { if (ev.key === "Enter") { ev.preventDefault(); ev.stopPropagation(); this.store.replace(); } } searchFormulas(searchFormulas) { this.store.searchFormulas(searchFormulas); } searchExactMatch(exactMatch) { this.store.updateSearchOptions({ exactMatch }); } searchMatchCase(matchCase) { this.store.updateSearchOptions({ matchCase }); } changeSearchScope(ev) { const searchScope = ev.target.value; this.store.updateSearchOptions({ searchScope }); } onSearchRangeChanged(ranges) { this.state.dataRange = ranges[0]; } updateDataRange() { if (!this.state.dataRange || this.searchOptions.searchScope !== "specificRange") { return; } const specificRange = this.env.model.getters.getRangeFromSheetXC(this.env.model.getters.getActiveSheetId(), this.state.dataRange); this.store.updateSearchOptions({ specificRange }); } get pendingSearch() { return this.updateSearchContent.isDebouncePending(); } } css /* scss */ ` .o-more-formats-panel { .format-preview { height: 48px; background-color: white; cursor: pointer; &:hover { background-color: rgba(0, 0, 0, 0.08); } } .check-icon { width: 24px; } } `; const DATE_FORMAT_ACTIONS = createActions([ formatNumberFullDateTime, formatNumberFullWeekDayAndMonth, formatNumberDayAndFullMonth, formatNumberShortWeekDay, formatNumberDayAndShortMonth, formatNumberFullMonth, formatNumberShortMonth, formatNumberDate, formatNumberTime, formatNumberDateTime, formatNumberDuration, formatNumberQuarter, formatNumberFullQuarter, ]); class MoreFormatsPanel extends Component { static template = "o-spreadsheet-MoreFormatsPanel"; static props = { onCloseSidePanel: Function, }; get dateFormatsActions() { return DATE_FORMAT_ACTIONS; } } class PivotMeasureDisplayPanelStore extends SpreadsheetStore { pivotId; initialMeasure; mutators = [ "cancelMeasureDisplayEdition", "updateMeasureDisplayType", "updateMeasureDisplayField", "updateMeasureDisplayValue", ]; measureDisplay; constructor(get, pivotId, initialMeasure) { super(get); this.pivotId = pivotId; this.initialMeasure = initialMeasure; this.measureDisplay = initialMeasure.display || { type: "no_calculations" }; } updateMeasureDisplayType(measureDisplayType) { this.updatePivotMeasureDisplay(this.getMeasureDisplay(measureDisplayType, this.measureDisplay.fieldNameWithGranularity, this.measureDisplay.value)); } updateMeasureDisplayField(fieldNameWithGranularity) { this.updatePivotMeasureDisplay(this.getMeasureDisplay(this.measureDisplay.type, fieldNameWithGranularity, this.measureDisplay.value)); } updateMeasureDisplayValue(value) { this.updatePivotMeasureDisplay(this.getMeasureDisplay(this.measureDisplay.type, this.measureDisplay.fieldNameWithGranularity, value)); } updatePivotMeasureDisplay(newDisplay) { const pivotDefinition = deepCopy(this.model.getters.getPivotCoreDefinition(this.pivotId)); const measureIndex = this.getMeasureIndex(this.initialMeasure.id, pivotDefinition); const newMeasure = { ...pivotDefinition.measures[measureIndex], display: newDisplay }; pivotDefinition.measures[measureIndex] = newMeasure; const result = this.model.dispatch("UPDATE_PIVOT", { pivot: pivotDefinition, pivotId: this.pivotId, }); if (result.isSuccessful) { this.measureDisplay = newDisplay; } } getMeasureDisplay(measureDisplayType, fieldNameWithGranularity, value) { switch (measureDisplayType) { case "no_calculations": case "%_of_grand_total": case "%_of_col_total": case "%_of_row_total": case "%_of_parent_row_total": case "%_of_parent_col_total": case "index": return { type: measureDisplayType }; case "%_of_parent_total": case "running_total": case "%_running_total": case "rank_asc": case "rank_desc": if (!fieldNameWithGranularity) { fieldNameWithGranularity = this.fields[0]?.nameWithGranularity; } return { type: measureDisplayType, fieldNameWithGranularity }; case "%_of": case "difference_from": case "%_difference_from": if (!fieldNameWithGranularity) { fieldNameWithGranularity = this.fields[0]?.nameWithGranularity; } const possibleValues = this.getPossibleValues(fieldNameWithGranularity); if (value === undefined || !possibleValues.find((v) => v.value === value)) { value = PREVIOUS_VALUE; } return { type: measureDisplayType, fieldNameWithGranularity, value: value ?? PREVIOUS_VALUE, }; } } getMeasureIndex(measureId, pivotDefinition) { const measureIndex = pivotDefinition.measures.findIndex((m) => m.id === measureId); if (measureIndex === -1) { throw new Error(`Measure with id ${measureId} not found in pivot.`); } return measureIndex; } get doesDisplayNeedsField() { return (["%_of_parent_total", "running_total", "%_running_total", "rank_asc", "rank_desc"].includes(this.measureDisplay.type) || this.doesDisplayNeedsValue); } get fields() { const definition = this.getters.getPivot(this.pivotId).definition; return [...definition.columns, ...definition.rows].map((f) => ({ ...f, displayName: getFieldDisplayName(f), })); } get doesDisplayNeedsValue() { return this.isDisplayValueDependant(this.measureDisplay); } isDisplayValueDependant(display) { return ["%_of", "difference_from", "%_difference_from"].includes(display.type); } get values() { const display = this.measureDisplay; if (!this.isDisplayValueDependant(display)) { return []; } return this.getPossibleValues(display.fieldNameWithGranularity); } getPossibleValues(fieldNameWithGranularity) { const baseValues = [ { value: PREVIOUS_VALUE, label: _t("(previous)") }, { value: NEXT_VALUE, label: _t("(next)") }, ]; const field = this.fields.find((f) => f.nameWithGranularity === fieldNameWithGranularity); if (!field) { return []; } const values = this.getters.getPivot(this.pivotId).getPossibleFieldValues(field); return [...baseValues, ...values]; } cancelMeasureDisplayEdition() { const pivotDefinition = deepCopy(this.model.getters.getPivotCoreDefinition(this.pivotId)); const measureIndex = this.getMeasureIndex(this.initialMeasure.id, pivotDefinition); pivotDefinition.measures[measureIndex] = { ...pivotDefinition.measures[measureIndex], display: this.initialMeasure.display, }; this.model.dispatch("UPDATE_PIVOT", { pivot: pivotDefinition, pivotId: this.pivotId, }); } } css /* scss */ ` .o-sidePanel { .o-pivot-measure-display-field, .o-pivot-measure-display-value { box-sizing: border-box; border: solid 1px ${GRAY_300}; border-radius: 3px; } .o-pivot-measure-display-description { white-space: pre-wrap; color: dimgray; border-left: 2px solid ${GRAY_300}; } } `; class PivotMeasureDisplayPanel extends Component { static template = "o-spreadsheet-PivotMeasureDisplayPanel"; static props = { onCloseSidePanel: Function, pivotId: String, measure: Object, }; static components = { Section, Checkbox, RadioSelection }; measureDisplayTypeLabels = measureDisplayTerms.labels; measureDisplayDescription = measureDisplayTerms.documentation; store; setup() { this.store = useLocalStore(PivotMeasureDisplayPanelStore, this.props.pivotId, this.props.measure); } onSave() { this.env.openSidePanel("PivotSidePanel", { pivotId: this.props.pivotId }); } onCancel() { this.store.cancelMeasureDisplayEdition(); this.env.openSidePanel("PivotSidePanel", { pivotId: this.props.pivotId }); } get fieldChoices() { return this.store.fields.map((field) => ({ value: field.nameWithGranularity, label: field.displayName, })); } } css /* scss */ ` .pivot-defer-update { min-height: 35px; } `; class PivotDeferUpdate extends Component { static template = "o-spreadsheet-PivotDeferUpdate"; static props = { deferUpdate: Boolean, isDirty: Boolean, toggleDeferUpdate: Function, discard: Function, apply: Function, }; static components = { Section, Checkbox, }; get deferUpdatesLabel() { return _t("Defer updates"); } get deferUpdatesTooltip() { return _t("Changing the pivot definition requires to reload the data. It may take some time."); } } function useAutofocus({ refName }) { const ref = useRef(refName); useEffect((el) => { el?.focus(); }, () => [ref.el]); } css /* scss */ ` input.pivot-dimension-search-field:focus { outline: none; } .pivot-dimension-search-field-icon svg { width: 13px; height: 13px; } .pivot-dimension-search { background-color: white; } .add-dimension.o-button { padding: 2px 7px; font-weight: 400; font-size: 12px; flex-grow: 0; height: inherit; } `; class AddDimensionButton extends Component { static template = "o-spreadsheet-AddDimensionButton"; static components = { Popover, TextValueProvider }; static props = { onFieldPicked: Function, fields: Array, slots: { type: Object, optional: true }, }; buttonRef = useRef("button"); popover = useState({ isOpen: false }); search = useState({ input: "" }); autoComplete; // TODO navigation keys. (this looks a lot like auto-complete list. Could maybe be factorized) setup() { this.autoComplete = useLocalStore(AutoCompleteStore); this.autoComplete.useProvider(this.getProvider()); useExternalListener(window, "click", (ev) => { if (ev.target !== this.buttonRef.el) { this.popover.isOpen = false; } }); useAutofocus({ refName: "autofocus" }); } getProvider() { return { proposals: this.proposals, autoSelectFirstProposal: false, selectProposal: (value) => { const field = this.props.fields.find((field) => field.string === value); if (field) { this.pickField(field); } }, }; } get proposals() { let fields; if (this.search.input) { fields = fuzzyLookup(this.search.input, this.props.fields, (field) => field.string); } else { fields = this.props.fields; } return fields.map((field) => { const text = field.string; return { text, fuzzySearchKey: text, htmlContent: getHtmlContentFromPattern(this.search.input, text, COMPOSER_ASSISTANT_COLOR, "o-semi-bold"), }; }); } get popoverProps() { const { x, y, width, height } = this.buttonRef.el.getBoundingClientRect(); return { anchorRect: { x, y, width, height }, positioning: "BottomLeft", }; } updateSearch(searchInput) { this.search.input = searchInput; this.autoComplete.useProvider(this.getProvider()); } pickField(field) { this.props.onFieldPicked(field.name); this.togglePopover(); } togglePopover() { this.popover.isOpen = !this.popover.isOpen; this.search.input = ""; this.autoComplete.useProvider(this.getProvider()); } onKeyDown(ev) { switch (ev.key) { case "Enter": const proposals = this.autoComplete.provider?.proposals; if (proposals?.length === 1) { this.autoComplete.provider?.selectProposal(proposals[0].text || ""); } const proposal = this.autoComplete.selectedProposal; this.autoComplete.provider?.selectProposal(proposal?.text || ""); break; case "ArrowUp": case "ArrowDown": this.autoComplete.moveSelection(ev.key === "ArrowDown" ? "next" : "previous"); break; case "Escape": this.popover.isOpen = false; break; } } } css /* scss */ ` .o-spreadsheet { .os-input { box-sizing: border-box; border-width: 0 0 1px 0; border-color: transparent; outline: none; text-overflow: ellipsis; color: ${TEXT_BODY}; } .os-input:hover, .os-input:focus { border-color: ${GRAY_300}; } } `; class TextInput extends Component { static template = "o-spreadsheet-TextInput"; static props = { value: String, onChange: Function, class: { type: String, optional: true, }, id: { type: String, optional: true, }, placeholder: { type: String, optional: true, }, }; inputRef = useRef("input"); setup() { useExternalListener(window, "click", (ev) => { if (ev.target !== this.inputRef.el && this.inputRef.el?.value !== this.props.value) { this.save(); } }, { capture: true }); } onKeyDown(ev) { switch (ev.key) { case "Enter": this.save(); ev.preventDefault(); ev.stopPropagation(); break; case "Escape": if (this.inputRef.el) { this.inputRef.el.value = this.props.value; this.inputRef.el.blur(); } ev.preventDefault(); ev.stopPropagation(); break; } } save() { const currentValue = (this.inputRef.el?.value || "").trim(); if (currentValue !== this.props.value) { this.props.onChange(currentValue); } this.inputRef.el?.blur(); } focusInputAndSelectContent() { const inputEl = this.inputRef.el; if (!inputEl) return; // The onFocus event selects all text in the input. // The subsequent mouseup event can deselect this text, // so t-on-mouseup.prevent.stop is used to prevent this // default behavior and preserve the selection. inputEl.focus(); inputEl.select(); } } class CogWheelMenu extends Component { static template = "o-spreadsheet-CogWheelMenu"; static components = { Menu }; static props = { items: Array, }; buttonRef = useRef("button"); menuState = useState({ isOpen: false, position: null, menuItems: [] }); menuId = this.env.model.uuidGenerator.uuidv4(); toggleMenu(ev) { if (ev.closedMenuId === this.menuId) { return; } const { x, y } = this.buttonRef.el.getBoundingClientRect(); this.menuState.isOpen = !this.menuState.isOpen; this.menuState.position = { x, y }; this.menuState.menuItems = createActions(this.props.items); } } // don't use bg-white since it's flipped to dark in dark mode and we don't support dark mode css /* scss */ ` .pivot-dimension { background-color: white; border: 1px solid ${GRAY_300}; border-radius: 4px; select.o-input { height: inherit; } select > option { background-color: white; } .pivot-dim-operator-label { min-width: 120px; } &.pivot-dimension-invalid { background-color: #ffdddd; border-color: red !important; select { background-color: #ffdddd; } } } `; class PivotDimension extends Component { static template = "o-spreadsheet-PivotDimension"; static props = { dimension: Object, onRemoved: { type: Function, optional: true }, onNameUpdated: { type: Function, optional: true }, slots: { type: Object, optional: true }, }; static components = { CogWheelMenu, TextInput }; updateName(name) { this.props.onNameUpdated?.(this.props.dimension, name === "" || name.startsWith("=") ? undefined : name); } } class PivotDimensionGranularity extends Component { static template = "o-spreadsheet-PivotDimensionGranularity"; static props = { dimension: Object, onUpdated: Function, availableGranularities: Set, allGranularities: Array, }; periods = ALL_PERIODS; } class PivotDimensionOrder extends Component { static template = "o-spreadsheet-PivotDimensionOrder"; static props = { dimension: Object, onUpdated: Function, }; } function createMeasureAutoComplete(pivot, forComputedMeasure) { return { sequence: 0, autoSelectFirstProposal: true, getProposals(tokenAtCursor) { const measureProposals = pivot.measures .filter((m) => m !== forComputedMeasure) .map((measure) => { const text = getCanonicalSymbolName(measure.id); return { text: text, description: measure.displayName, htmlContent: [{ value: text, color: tokenColors.FUNCTION }], fuzzySearchKey: measure.displayName + text + measure.fieldName, }; }); const dimensionsProposals = pivot.rows.concat(pivot.columns).map((dimension) => { const text = getCanonicalSymbolName(dimension.nameWithGranularity); return { text: text, description: dimension.displayName, htmlContent: [{ value: text, color: tokenColors.FUNCTION }], fuzzySearchKey: dimension.displayName + text + dimension.fieldName, }; }); return measureProposals.concat(dimensionsProposals); }, selectProposal(tokenAtCursor, value) { let start = tokenAtCursor.end; if (tokenAtCursor.type === "SYMBOL") { start = tokenAtCursor.start; } const end = tokenAtCursor.end; this.composer.changeComposerCursorSelection(start, end); this.composer.replaceComposerCursorSelection(value); }, }; } class PivotMeasureEditor extends Component { static template = "o-spreadsheet-PivotMeasureEditor"; static components = { PivotDimension, StandaloneComposer, }; static props = { definition: Object, measure: Object, onMeasureUpdated: Function, onRemoved: Function, generateMeasureId: Function, aggregators: Object, pivotId: String, }; getMeasureAutocomplete() { return createMeasureAutoComplete(this.props.definition, this.props.measure); } updateMeasureFormula(formula) { this.props.onMeasureUpdated({ ...this.props.measure, computedBy: { sheetId: this.env.model.getters.getActiveSheetId(), formula: formula[0] === "=" ? formula : "=" + formula, }, }); } updateAggregator(aggregator) { this.props.onMeasureUpdated({ ...this.props.measure, aggregator, id: this.props.generateMeasureId(this.props.measure.fieldName, aggregator), }); } updateName(measure, userDefinedName) { if (this.props.measure.computedBy && userDefinedName) { this.props.onMeasureUpdated({ ...this.props.measure, userDefinedName, id: this.props.generateMeasureId(userDefinedName, this.props.measure.aggregator), fieldName: userDefinedName, }); } else { this.props.onMeasureUpdated({ ...this.props.measure, userDefinedName, }); } } toggleMeasureVisibility() { this.props.onMeasureUpdated({ ...this.props.measure, isHidden: !this.props.measure.isHidden, }); } openShowValuesAs() { this.env.openSidePanel("PivotMeasureDisplayPanel", { pivotId: this.props.pivotId, measure: this.props.measure, }); } } css /* scss */ ` .add-calculated-measure { cursor: pointer; } `; class PivotLayoutConfigurator extends Component { static template = "o-spreadsheet-PivotLayoutConfigurator"; static components = { AddDimensionButton, PivotDimension, PivotDimensionOrder, PivotDimensionGranularity, PivotMeasureEditor, }; static props = { definition: Object, onDimensionsUpdated: Function, unusedGroupableFields: Array, measureFields: Array, unusedGranularities: Object, dateGranularities: Array, datetimeGranularities: Array, pivotId: String, }; dimensionsRef = useRef("pivot-dimensions"); dragAndDrop = useDragAndDropListItems(); AGGREGATORS = AGGREGATORS; composerFocus; isDateOrDatetimeField = isDateOrDatetimeField; setup() { this.composerFocus = useStore(ComposerFocusStore); } startDragAndDrop(dimension, event) { if (event.button !== 0 || event.target.tagName === "SELECT") { return; } const rects = this.getDimensionElementsRects(); const definition = this.props.definition; const { columns, rows } = definition; const draggableIds = [ ...columns.map((col) => col.nameWithGranularity), "__rows_title__", ...rows.map((row) => row.nameWithGranularity), ]; const allDimensions = columns.concat(rows); const offset = 1; // column title const draggableItems = draggableIds.map((id, index) => ({ id, size: rects[index + offset].height, position: rects[index + offset].y, })); this.dragAndDrop.start("vertical", { draggedItemId: dimension.nameWithGranularity, initialMousePosition: event.clientY, items: draggableItems, containerEl: this.dimensionsRef.el, onDragEnd: (dimensionName, finalIndex) => { const originalIndex = draggableIds.findIndex((id) => id === dimensionName); if (originalIndex === finalIndex) { return; } const draggedItems = [...draggableIds]; draggedItems.splice(originalIndex, 1); draggedItems.splice(finalIndex, 0, dimensionName); const columns = draggedItems.slice(0, draggedItems.indexOf("__rows_title__")); const rows = draggedItems.slice(draggedItems.indexOf("__rows_title__") + 1); this.props.onDimensionsUpdated({ columns: columns .map((nameWithGranularity) => allDimensions.find((dimension) => dimension.nameWithGranularity === nameWithGranularity)) .filter(isDefined), rows: rows .map((nameWithGranularity) => allDimensions.find((dimension) => dimension.nameWithGranularity === nameWithGranularity)) .filter(isDefined), }); }, }); } getGranularitiesFor(field) { if (!isDateOrDatetimeField(field)) { return []; } return field.type === "date" ? this.props.dateGranularities : this.props.datetimeGranularities; } startDragAndDropMeasures(measure, event) { if (event.button !== 0 || event.target.tagName === "SELECT" || event.target.tagName === "INPUT" || this.composerFocus.focusMode !== "inactive") { return; } const rects = this.getDimensionElementsRects(); const definition = this.props.definition; const { measures, columns, rows } = definition; const draggableIds = measures.map((m) => m.id); const offset = 3 + columns.length + rows.length; // column title, row title, measure title const draggableItems = draggableIds.map((id, index) => ({ id, size: rects[index + offset].height, position: rects[index + offset].y, })); this.dragAndDrop.start("vertical", { draggedItemId: measure.id, initialMousePosition: event.clientY, items: draggableItems, containerEl: this.dimensionsRef.el, onDragEnd: (measureName, finalIndex) => { const originalIndex = draggableIds.findIndex((id) => id === measureName); if (originalIndex === finalIndex) { return; } const draggedItems = [...draggableIds]; draggedItems.splice(originalIndex, 1); draggedItems.splice(finalIndex, 0, measureName); this.props.onDimensionsUpdated({ measures: draggedItems .map((measureId) => measures.find((measure) => measure.id === measureId)) .filter(isDefined), }); }, }); } getDimensionElementsRects() { return Array.from(this.dimensionsRef.el.children).map((el) => { const style = getComputedStyle(el); const rect = el.getBoundingClientRect(); return { x: rect.x, y: rect.y, width: rect.width + parseInt(style.marginLeft || "0") + parseInt(style.marginRight || "0"), height: rect.height + parseInt(style.marginTop || "0") + parseInt(style.marginBottom || "0"), }; }); } removeDimension(dimension) { const { columns, rows } = this.props.definition; this.props.onDimensionsUpdated({ columns: columns.filter((col) => col.nameWithGranularity !== dimension.nameWithGranularity), rows: rows.filter((row) => row.nameWithGranularity !== dimension.nameWithGranularity), }); } removeMeasureDimension(measure) { const { measures } = this.props.definition; this.props.onDimensionsUpdated({ measures: measures.filter((m) => m.id !== measure.id), }); } addColumnDimension(fieldName) { const { columns } = this.props.definition; this.props.onDimensionsUpdated({ columns: columns.concat([{ fieldName: fieldName, order: "asc" }]), }); } addRowDimension(fieldName) { const { rows } = this.props.definition; this.props.onDimensionsUpdated({ rows: rows.concat([{ fieldName: fieldName, order: "asc" }]), }); } addMeasureDimension(fieldName) { const { measures } = this.props.definition; const aggregator = this.getDefaultMeasureAggregator(fieldName); this.props.onDimensionsUpdated({ measures: measures.concat([ { id: this.getMeasureId(fieldName, aggregator), fieldName, aggregator }, ]), }); } updateMeasure(measure, newMeasure) { const { measures } = this.props.definition; this.props.onDimensionsUpdated({ measures: measures.map((m) => (m.id === measure.id ? newMeasure : m)), }); } getMeasureId(fieldName, aggregator) { const baseId = fieldName + (aggregator ? `:${aggregator}` : ""); let id = baseId; let i = 2; while (this.props.definition.measures.some((m) => m.id === id)) { id = `${baseId}:${i}`; i++; } return id; } getDefaultMeasureAggregator(fieldName) { const field = this.props.measureFields.find((f) => f.name === fieldName); return field?.aggregator ? field.aggregator : "count"; } addCalculatedMeasure() { const { measures } = this.props.definition; const measureName = this.env.model.getters.generateNewCalculatedMeasureName(measures); this.props.onDimensionsUpdated({ measures: measures.concat([ { id: this.getMeasureId(measureName), fieldName: measureName, aggregator: "sum", computedBy: { sheetId: this.env.model.getters.getActiveSheetId(), formula: "=0", }, }, ]), }); } updateOrder(updateDimension, order) { const { rows, columns } = this.props.definition; this.props.onDimensionsUpdated({ rows: rows.map((row) => { if (row.nameWithGranularity === updateDimension.nameWithGranularity) { return { ...row, order: order || undefined }; } return row; }), columns: columns.map((col) => { if (col.nameWithGranularity === updateDimension.nameWithGranularity) { return { ...col, order: order || undefined }; } return col; }), }); } updateGranularity(dimension, granularity) { const { rows, columns } = this.props.definition; this.props.onDimensionsUpdated({ rows: rows.map((row) => { if (row.nameWithGranularity === dimension.nameWithGranularity) { return { ...row, granularity }; } return row; }), columns: columns.map((col) => { if (col.nameWithGranularity === dimension.nameWithGranularity) { return { ...col, granularity }; } return col; }), }); } getMeasureDescription(measure) { const measureDisplay = measure.display; if (!measureDisplay || measureDisplay.type === "no_calculations") { return ""; } const pivot = this.env.model.getters.getPivot(this.props.pivotId); const field = [...pivot.definition.columns, ...pivot.definition.rows].find((f) => f.nameWithGranularity === measureDisplay.fieldNameWithGranularity); const fieldName = field ? getFieldDisplayName(field) : ""; return measureDisplayTerms.descriptions[measureDisplay.type](fieldName); } } class PivotTitleSection extends Component { static template = "o-spreadsheet-PivotTitleSection"; static components = { CogWheelMenu, Section, TextInput }; static props = { pivotId: String, flipAxis: Function, }; get cogWheelMenuItems() { return [ { name: _t("Flip axes"), icon: "o-spreadsheet-Icon.EXCHANGE", execute: this.props.flipAxis, }, { name: _t("Duplicate"), icon: "o-spreadsheet-Icon.COPY", execute: () => this.duplicatePivot(), }, { name: _t("Delete"), icon: "o-spreadsheet-Icon.TRASH", execute: () => this.delete(), }, ]; } get name() { return this.env.model.getters.getPivotName(this.props.pivotId); } get displayName() { return this.env.model.getters.getPivotDisplayName(this.props.pivotId); } duplicatePivot() { const newPivotId = this.env.model.uuidGenerator.uuidv4(); const newSheetId = this.env.model.uuidGenerator.uuidv4(); const result = this.env.model.dispatch("DUPLICATE_PIVOT_IN_NEW_SHEET", { pivotId: this.props.pivotId, newPivotId, newSheetId, }); const text = result.isSuccessful ? _t("Pivot duplicated.") : _t("Pivot duplication failed"); const type = result.isSuccessful ? "success" : "danger"; this.env.notifyUser({ text, sticky: false, type, }); if (result.isSuccessful) { this.env.openSidePanel("PivotSidePanel", { pivotId: newPivotId }); } } delete() { this.env.askConfirmation(_t("Are you sure you want to delete this pivot?"), () => { this.env.model.dispatch("REMOVE_PIVOT", { pivotId: this.props.pivotId }); }); } onNameChanged(name) { const pivot = this.env.model.getters.getPivotCoreDefinition(this.props.pivotId); this.env.model.dispatch("UPDATE_PIVOT", { pivotId: this.props.pivotId, pivot: { ...pivot, name, }, }); } } /** * Represent a pivot runtime definition. A pivot runtime definition is a pivot * definition that has been enriched to include the display name of its attributes * (measures, columns, rows). */ class PivotRuntimeDefinition { measures; columns; rows; constructor(definition, fields) { this.measures = definition.measures.map((measure) => createMeasure(fields, measure)); this.columns = definition.columns.map((dimension) => createPivotDimension(fields, dimension)); this.rows = definition.rows.map((dimension) => createPivotDimension(fields, dimension)); } getDimension(nameWithGranularity) { const dimension = this.columns.find((d) => d.nameWithGranularity === nameWithGranularity) || this.rows.find((d) => d.nameWithGranularity === nameWithGranularity); if (!dimension) { throw new EvaluationError(_t("Dimension %s does not exist", nameWithGranularity)); } return dimension; } getMeasure(id) { const measure = this.measures.find((measure) => measure.id === id); if (!measure) { throw new EvaluationError(_t("Field %s is not a measure", id)); } return measure; } } function createMeasure(fields, measure) { const fieldName = measure.fieldName; const field = fieldName === "__count" ? { name: "__count", string: _t("Count"), type: "integer", aggregator: "sum" } : fields[fieldName]; const aggregator = measure.aggregator; return { /** * Get the id of the measure, as it is stored in the pivot formula */ id: measure.id, /** * Display name of the measure * e.g. "__count" -> "Count", "amount_total" -> "Total Amount" */ get displayName() { return measure.userDefinedName ?? field?.string ?? measure.fieldName; }, userDefinedName: measure.userDefinedName, /** * Get the name of the field of the measure */ fieldName, /** * Get the aggregator of the measure */ aggregator, /** * Get the type of the measure field * e.g. "stage_id" -> "many2one", "create_date:month" -> "date" */ type: fieldName === "__count" ? "integer" : field?.type ?? "integer", isValid: !!(field || measure.computedBy), isHidden: measure.isHidden, format: measure.format, computedBy: measure.computedBy, display: measure.display, }; } function createPivotDimension(fields, dimension) { const field = fields[dimension.fieldName]; const type = field?.type ?? "integer"; const granularity = field && isDateOrDatetimeField(field) ? dimension.granularity : undefined; return { /** * Get the display name of the dimension * e.g. "stage_id" -> "Stage", "create_date:month" -> "Create Date" */ displayName: field?.string ?? dimension.fieldName, /** * Get the name of the dimension, as it is stored in the pivot formula * e.g. "stage_id", "create_date:month" */ nameWithGranularity: dimension.fieldName + (granularity ? `:${granularity}` : ""), /** * Get the name of the field of the dimension * e.g. "stage_id" -> "stage_id", "create_date:month" -> "create_date" */ fieldName: dimension.fieldName, /** * Get the aggregate operator of the dimension * e.g. "stage_id" -> undefined, "create_date:month" -> "month" */ granularity, /** * Get the type of the field of the dimension * e.g. "stage_id" -> "many2one", "create_date:month" -> "date" */ type, order: dimension.order, isValid: !!field, }; } class SpreadsheetPivotRuntimeDefinition extends PivotRuntimeDefinition { range; constructor(definition, fields, getters) { super(definition, fields); if (definition.dataSet) { const { sheetId, zone } = definition.dataSet; this.range = getters.getRangeFromZone(sheetId, zone); } } } /** * Class used to ease the construction of a pivot table. * Let's consider the following example, with: * - columns groupBy: [sales_team, create_date] * - rows groupBy: [continent, city] * - measures: [revenues] * _____________________________________________________________________________________| ----| * | | Sale Team 1 | Sale Team 2 | | | * | |___________________________|_________________________|_____________| | * | | May 2020 | June 2020 | May 2020 | June 2020 | Total | |<---- `cols` * | |______________|____________|____________|____________|_____________| | ----| * | | Revenues | Revenues | Revenues | Revenues | Revenues | | |<--- `measureRow` * |________________|______________|____________|____________|____________|_____________| ----| ----| * |Europe | 25 | 35 | 40 | 30 | 65 | ----| * | Brussels | 0 | 15 | 30 | 30 | 30 | | * | Paris | 25 | 20 | 10 | 0 | 35 | | * |North America | 60 | 75 | | | 60 | |<---- `body` * | Washington | 60 | 75 | | | 60 | | * |Total | 85 | 110 | 40 | 30 | 125 | | * |________________|______________|____________|____________|____________|_____________| ----| * * | | * |----------------| * | * | * `rows` * * `rows` is an array of cells, each cells contains the indent level, the fields used for the group by and the values for theses fields. * For example: * `Europe`: { indent: 1, fields: ["continent"], values: ["id_of_Europe"]} * `Brussels`: { indent: 2, fields: ["continent", "city"], values: ["id_of_Europe", "id_of_Brussels"]} * `Total`: { indent: 0, fields: [], values: []} * * `columns` is an double array, first by row and then by cell. So, in this example, it looks like: * [[row1], [row2], [measureRow]] * Each cell of a column's row contains the width (span) of the cells, the fields used for the group by and the values for theses fields. * For example: * `Sale Team 1`: { width: 2, fields: ["sales_team"], values: ["id_of_SaleTeam1"]} * `May 2020` (the one under Sale Team 2): { width: 1, fields: ["sales_team", "create_date"], values: ["id_of_SaleTeam2", "May 2020"]} * `Revenues` (the one under Total): { width: 1, fields: ["measure"], values: ["revenues"]} * */ class SpreadsheetPivotTable { columns; rows; measures; fieldsType; maxIndent; pivotCells = {}; rowTree; colTree; constructor(columns, rows, measures, fieldsType) { this.columns = columns.map((row) => { // offset in the pivot table // starts at 1 because the first column is the row title let offset = 1; return row.map((col) => { col = { ...col, offset }; offset += col.width; return col; }); }); this.rows = rows; this.measures = measures; this.fieldsType = fieldsType; this.maxIndent = Math.max(...this.rows.map((row) => row.indent)); this.rowTree = lazy(() => this.buildRowsTree()); this.colTree = lazy(() => this.buildColumnsTree()); } /** * Get the number of columns leafs (i.e. the number of the last row of columns) */ getNumberOfDataColumns() { return this.columns.at(-1)?.length || 0; } getPivotCells(includeTotal = true, includeColumnHeaders = true) { const key = JSON.stringify({ includeTotal, includeColumnHeaders }); if (!this.pivotCells[key]) { const numberOfDataRows = this.rows.length; const numberOfDataColumns = this.getNumberOfDataColumns(); let pivotHeight = this.columns.length + numberOfDataRows; let pivotWidth = 1 /*(row headers)*/ + numberOfDataColumns; if (!includeTotal && numberOfDataRows !== 1) { pivotHeight -= 1; } if (!includeTotal && numberOfDataColumns !== this.measures.length) { pivotWidth -= this.measures.length; } const domainArray = []; const startRow = includeColumnHeaders ? 0 : this.columns.length; for (let col = 0; col < pivotWidth; col++) { domainArray.push([]); for (let row = startRow; row < pivotHeight; row++) { if (!includeTotal && row === pivotHeight) { continue; } domainArray[col].push(this.getPivotCell(col, row, includeTotal)); } } this.pivotCells[key] = domainArray; } return this.pivotCells[key]; } getRowTree() { return this.rowTree(); } getColTree() { return this.colTree(); } isTotalRow(index) { return this.rows[index].indent !== this.maxIndent; } getPivotCell(col, row, includeTotal = true) { const colHeadersHeight = this.columns.length; if (col > 0 && row === colHeadersHeight - 1) { const domain = this.getColHeaderDomain(col, row); if (!domain) { return EMPTY_PIVOT_CELL; } const measure = domain.at(-1)?.value?.toString() || ""; return { type: "MEASURE_HEADER", domain: domain.slice(0, -1), measure }; } else if (row <= colHeadersHeight - 1) { const domain = this.getColHeaderDomain(col, row); return domain ? { type: "HEADER", domain } : EMPTY_PIVOT_CELL; } else if (col === 0) { const rowIndex = row - colHeadersHeight; const domain = this.getRowDomain(rowIndex); return { type: "HEADER", domain }; } else { const rowIndex = row - colHeadersHeight; if (!includeTotal && this.isTotalRow(rowIndex)) { return EMPTY_PIVOT_CELL; } const domain = [...this.getRowDomain(rowIndex), ...this.getColDomain(col)]; const measure = this.getColMeasure(col); return { type: "VALUE", domain, measure }; } } getColHeaderDomain(col, row) { if (col === 0) { return undefined; } const domain = []; const pivotCol = this.columns[row].find((pivotCol) => pivotCol.offset === col); if (!pivotCol) { return undefined; } for (let i = 0; i < pivotCol.fields.length; i++) { const fieldWithGranularity = pivotCol.fields[i]; if (fieldWithGranularity === "measure") { domain.push({ type: "char", field: fieldWithGranularity, value: toNormalizedPivotValue({ displayName: "measure", type: "char" }, pivotCol.values[i]), }); } else { const { fieldName, granularity } = parseDimension(fieldWithGranularity); const type = this.fieldsType[fieldName] || "char"; domain.push({ type, field: fieldWithGranularity, value: toNormalizedPivotValue({ displayName: fieldName, type, granularity }, pivotCol.values[i]), }); } } return domain; } getColDomain(col) { const domain = this.getColHeaderDomain(col, this.columns.length - 1); return domain ? domain.slice(0, -1) : []; // slice: remove measure and value } getColMeasure(col) { const domain = this.getColHeaderDomain(col, this.columns.length - 1); const measure = domain?.at(-1)?.value; if (measure === undefined || measure === null) { throw new Error("Measure is missing"); } return measure.toString(); } getRowDomain(row) { const domain = []; for (let i = 0; i < this.rows[row].fields.length; i++) { const fieldWithGranularity = this.rows[row].fields[i]; const { fieldName, granularity } = parseDimension(fieldWithGranularity); const type = this.fieldsType[fieldName] || "char"; domain.push({ type, field: fieldWithGranularity, value: toNormalizedPivotValue({ displayName: fieldName, type, granularity }, this.rows[row].values[i]), }); } return domain; } buildRowsTree() { const tree = []; let depth = 0; const treesAtDepth = {}; treesAtDepth[0] = tree; for (const row of this.rows) { if (row.fields.length === 0 || row.values.length === 0) { return tree; } const rowDepth = row.fields.length - 1; const fieldWithGranularity = row.fields[rowDepth]; const { fieldName, granularity } = parseDimension(fieldWithGranularity); const type = this.fieldsType[fieldName] ?? "char"; const value = toNormalizedPivotValue({ displayName: fieldName, type, granularity }, row.values[rowDepth]); if (rowDepth > depth) { depth = rowDepth; treesAtDepth[depth] = []; const parentNode = treesAtDepth[depth - 1].at(-1); if (parentNode) { parentNode.children = treesAtDepth[depth]; } } depth = rowDepth; const node = { value, field: row.fields[rowDepth], children: [], width: 0, // not used }; treesAtDepth[depth].push(node); } return tree; } buildColumnsTree() { const tree = []; const columns = this.columns.at(-2) || []; const treesAtDepth = {}; treesAtDepth[0] = tree; for (const leaf of columns) { for (let depth = 0; depth < leaf.fields.length; depth++) { const fieldWithGranularity = leaf.fields[depth]; const { fieldName, granularity } = parseDimension(fieldWithGranularity); const type = this.fieldsType[fieldName] ?? "char"; const value = toNormalizedPivotValue({ displayName: fieldName, type, granularity }, leaf.values[depth]); const node = { value, field: leaf.fields[depth], children: [], width: leaf.width, }; if (treesAtDepth[depth]?.at(-1)?.value !== value) { treesAtDepth[depth + 1] = []; node.children = treesAtDepth[depth + 1]; treesAtDepth[depth].push(node); } } } return tree; } export() { return { cols: this.columns, rows: this.rows, measures: this.measures, fieldsType: this.fieldsType, }; } } const EMPTY_PIVOT_CELL = { type: "EMPTY" }; /** * This function converts a list of data entry into a spreadsheet pivot table. */ function dataEntriesToSpreadsheetPivotTable(dataEntries, definition) { const measureIds = definition.measures.filter((measure) => !measure.isHidden).map((m) => m.id); const columnsTree = dataEntriesToColumnsTree(dataEntries, definition.columns, 0); computeWidthOfColumnsNodes(columnsTree, measureIds.length); const cols = columnsTreeToColumns(columnsTree, definition); const rows = dataEntriesToRows(dataEntries, 0, definition.rows, [], []); // Add the total row rows.push({ fields: [], values: [], indent: 0, }); const fieldsType = {}; for (const columns of definition.columns) { fieldsType[columns.fieldName] = columns.type; } for (const row of definition.rows) { fieldsType[row.fieldName] = row.type; } return new SpreadsheetPivotTable(cols, rows, measureIds, fieldsType); } // ----------------------------------------------------------------------------- // ROWS // ----------------------------------------------------------------------------- /** * Create the rows from the data entries. This function is called recursively * for each level of rows dimensions. */ function dataEntriesToRows(dataEntries, index, rows, fields, values) { if (index >= rows.length) { return []; } const row = rows[index]; const rowName = row.nameWithGranularity; const groups = groupPivotDataEntriesBy(dataEntries, row); const orderedKeys = orderDataEntriesKeys(groups, row); const pivotTableRows = []; const _fields = fields.concat(rowName); for (const value of orderedKeys) { const _values = values.concat(value); pivotTableRows.push({ fields: _fields, values: _values, indent: index, }); const record = groups[value]; if (record) { pivotTableRows.push(...dataEntriesToRows(record, index + 1, rows, _fields, _values)); } } return pivotTableRows; } // ----------------------------------------------------------------------------- // COLUMNS // ----------------------------------------------------------------------------- /** * Create the columns tree from data entries. */ function dataEntriesToColumnsTree(dataEntries, columns, index) { if (index >= columns.length) { return []; } const column = columns[index]; const colName = columns[index].nameWithGranularity; const groups = groupPivotDataEntriesBy(dataEntries, column); const orderedKeys = orderDataEntriesKeys(groups, columns[index]); return orderedKeys.map((key) => { return { value: groups[key]?.[0]?.[column.nameWithGranularity]?.value ?? null, field: colName, children: dataEntriesToColumnsTree(groups[key] || [], columns, index + 1), width: 0, }; }); } /** * Compute the width of each node in the column tree. * The width of a node is the sum of the width of its children. * For leaf nodes, the width is the number of measures. */ function computeWidthOfColumnsNodes(tree, measureCount) { for (const key in tree) { const node = tree[key]; if (node.children.length === 0) { node.width = measureCount; } else { computeWidthOfColumnsNodes(node.children, measureCount); node.width = node.children.reduce((acc, child) => acc + child.width, 0); } } } /** * Convert the columns tree to the columns */ function columnsTreeToColumns(mainTree, definition) { const columnNames = definition.columns.map((col) => col.nameWithGranularity); const height = columnNames.length; const measures = definition.measures.filter((measure) => !measure.isHidden); const measureCount = measures.length; const headers = new Array(height).fill(0).map(() => []); function generateTreeHeaders(tree, rowIndex, val) { const row = headers[rowIndex]; for (const node of tree) { const localVal = val.concat([node.value]); const cell = { fields: columnNames.slice(0, rowIndex + 1), values: localVal, width: node.width, offset: 0, }; row.push(cell); if (rowIndex <= height - 1) { generateTreeHeaders(node.children, rowIndex + 1, localVal); } } } generateTreeHeaders(mainTree, 0, []); const hasColGroupBys = columnNames.length > 0; // 2) generate measures row const measureRow = []; if (hasColGroupBys) { headers[headers.length - 1].forEach((cell) => { measures.forEach((measure) => { const measureCell = { fields: [...cell.fields, "measure"], values: [...cell.values, measure.id], width: 1, offset: 0, }; measureRow.push(measureCell); }); }); } // Add the totals of the measures measures.forEach((measure) => { const measureCell = { fields: ["measure"], values: [measure.id], width: 1, offset: 0, }; measureRow.push(measureCell); }); headers.push(measureRow); // 3) Add the total cell if (headers.length === 1) { headers.unshift([]); // Will add the total there } headers[headers.length - 2].push({ fields: [], values: [], width: measureCount, offset: 0, }); return headers; } // ----------------------------------------------------------------------------- // HELPERS // ----------------------------------------------------------------------------- /** * Group the dataEntries based on the given dimension */ function groupPivotDataEntriesBy(dataEntries, dimension) { return Object.groupBy(dataEntries, keySelector(dimension)); } /** * Function used to identify the key that should be used to group dataEntries */ function keySelector(dimension) { const name = dimension.nameWithGranularity; return (item) => { return `${item[name]?.value ?? null}`; }; } /** * Order the keys of the given data entries, based on the given dimension */ function orderDataEntriesKeys(groups, dimension) { const order = dimension.order; if (!order) { return Object.keys(groups); } return Object.keys(groups).sort((a, b) => compareDimensionValues(dimension, a, b)); } /** * Function used to compare two values, based on the type of the given dimension. * Used to order two values */ function compareDimensionValues(dimension, a, b) { if (a === "null") { return dimension.order === "asc" ? 1 : -1; } if (b === "null") { return dimension.order === "asc" ? -1 : 1; } if (dimension.type === "integer" || dimension.type === "datetime") { return dimension.order === "asc" ? Number(a) - Number(b) : Number(b) - Number(a); } return dimension.order === "asc" ? a.localeCompare(b) : b.localeCompare(a); } const NULL_SYMBOL = Symbol("NULL"); function createDate(dimension, value, locale) { const granularity = dimension.granularity; if (!granularity || !(granularity in MAP_VALUE_DIMENSION_DATE)) { throw new Error(`Unknown date granularity: ${granularity}`); } const keyInMap = typeof value === "number" || typeof value === "string" ? value : NULL_SYMBOL; if (!MAP_VALUE_DIMENSION_DATE[granularity].set.has(value)) { MAP_VALUE_DIMENSION_DATE[granularity].set.add(value); let number = null; if (typeof value === "number" || typeof value === "string") { const date = toJsDate(value, locale); switch (granularity) { case "year": number = date.getFullYear(); break; case "quarter_number": number = Math.floor(date.getMonth() / 3) + 1; break; case "month_number": number = date.getMonth() + 1; break; case "iso_week_number": number = date.getIsoWeek(); break; case "day_of_month": number = date.getDate(); break; case "day": number = Math.floor(toNumber(value, locale)); break; case "day_of_week": /** * getDay() returns the day of the week in the range 0-6, with 0 * being Sunday. We need to normalize this to the range 1-7, with 1 * being the first day of the week depending on the locale. * Normalized value: fr_FR: 1: Monday, 7: Sunday (weekStart = 1) * en_US: 1: Sunday, 7: Saturday (weekStart = 7) */ number = ((date.getDay() + 7 - locale.weekStart) % 7) + 1; break; case "hour_number": number = date.getHours(); break; case "minute_number": number = date.getMinutes(); break; case "second_number": number = date.getSeconds(); break; } } MAP_VALUE_DIMENSION_DATE[granularity].values[keyInMap] = toNormalizedPivotValue(dimension, number); } return MAP_VALUE_DIMENSION_DATE[granularity].values[keyInMap]; } /** * This map is used to cache the different values of a pivot date value * 43_831 -> 01/01/2012 * Example: { * year: { * set: { 43_831 }, * values: { '43_831': 2012 } * }, * quarter_number: { * set: { 43_831 }, * values: { '43_831': 1 } * }, * month_number: { * set: { 43_831 }, * values: { '43_831': 0 } * }, * iso_week_number: { * set: { 43_831 }, * values: { '43_831': 1 } * }, * day_of_month: { * set: { 43_831 }, * values: { '43_831': 1 } * }, * day: { * set: { 43_831 }, * values: { '43_831': 43_831 } * } * day_of_week: { * set: { 45_387 }, * values: { '45_387': 6 } (in locale with startWeek = 7) * } * hour_number: { * set: { 45_387.13 }, * values: { '45_387.13': 3 } * } * minute_number: { * set: { 45_387.13 }, * values: { '45_387.13': 7 } * } * second_number: { * set: { 45_387.13 }, * values: { '45_387.13': 12 } * } * } */ const MAP_VALUE_DIMENSION_DATE = { year: { set: new Set(), values: {}, }, quarter_number: { set: new Set(), values: {}, }, month_number: { set: new Set(), values: {}, }, iso_week_number: { set: new Set(), values: {}, }, day_of_month: { set: new Set(), values: {}, }, day: { set: new Set(), values: {}, }, day_of_week: { set: new Set(), values: {}, }, hour_number: { set: new Set(), values: {}, }, minute_number: { set: new Set(), values: {}, }, second_number: { set: new Set(), values: {}, }, }; /** * Reset the cache of the pivot date values. */ function resetMapValueDimensionDate() { for (const key in MAP_VALUE_DIMENSION_DATE) { MAP_VALUE_DIMENSION_DATE[key].set.clear(); MAP_VALUE_DIMENSION_DATE[key].values = {}; } } var ReloadType; (function (ReloadType) { ReloadType[ReloadType["NONE"] = 0] = "NONE"; ReloadType[ReloadType["TABLE"] = 1] = "TABLE"; ReloadType[ReloadType["DATA"] = 2] = "DATA"; ReloadType[ReloadType["DEFINITION"] = 3] = "DEFINITION"; ReloadType[ReloadType["ALL"] = 4] = "ALL"; })(ReloadType || (ReloadType = {})); /** * This class represents a pivot table that is created from a range of cells. * It will extract the fields from the first row of the range and the data from * the rest of the rows. */ class SpreadsheetPivot { type = "SPREADSHEET"; getters; _definition; coreDefinition; metaData = { fields: {}, fieldKeys: [] }; /** * This array contains the data entries of the pivot. Each entry is an object * that contains the values of the fields for a row. */ dataEntries = []; /** * This object contains the pivot table structure. It is created from the * data entries and the pivot definition. */ table; /** * This error is set when the range is invalid. It is used to show an error * message to the user. */ invalidRangeError; /** * This flag is used to know when the pivot needs to be reloaded. It's only * used in the evaluation process. At the end of each cycle, the flag is set * to true so the pivot is reloaded in the next cycle. */ needsReevaluation = true; constructor(custom, params) { this.getters = params.getters; this.coreDefinition = params.definition; } init(params = {}) { if (!this._definition || params.reload) { this.reload(ReloadType.ALL); this.needsReevaluation = false; } } reload(type) { if (type === ReloadType.ALL) { this.metaData = this.loadMetaData(); } if (type >= ReloadType.DEFINITION) { this._definition = this.loadRuntimeDefinition(); } if (type >= ReloadType.DATA) { this.dataEntries = this.loadData(); } if (type >= ReloadType.TABLE) { this.table = undefined; } } onDefinitionChange(nextDefinition) { const actualDefinition = this.coreDefinition; this.coreDefinition = nextDefinition; if (this._definition) { const reloadType = Math.max(this.computeShouldReload(actualDefinition, nextDefinition), ReloadType.NONE); this.reload(reloadType); } } computeShouldReload(actualDefinition, nextDefinition) { if (deepEquals(actualDefinition.dataSet, nextDefinition.dataSet)) { return ReloadType.DEFINITION; } return ReloadType.ALL; } get isInvalidRange() { return !!this.invalidRangeError; } get invalidRangeMessage() { return this.invalidRangeError?.message; } get definition() { if (!this._definition) { this.init(); } if (!this._definition) { throw new Error("Pivot definition should be defined at this point."); } return this._definition; } isValid() { if (this.invalidRangeError || !this.definition) { return false; } for (const measure of this.definition.measures) { if (!measure.isValid) { return false; } } for (const column of this.definition.columns) { if (!column.isValid) { return false; } } for (const row of this.definition.rows) { if (!row.isValid) { return false; } } return true; } assertIsValid({ throwOnError }) { if (!this.isValid()) { if (throwOnError) { if (this.invalidRangeError) { throw this.invalidRangeError; } else { throw new EvaluationError(_t("At least one measure and/or dimension is not correct.")); } } return { value: CellErrorType.GenericError, message: this.invalidRangeError?.message ?? _t("At least one measure and/or dimension is not correct."), }; } return undefined; } areDomainArgsFieldsValid(args) { let dimensions = args.filter((_, index) => index % 2 === 0).map(toString); if (dimensions.length && dimensions.at(-1) === "measure") { dimensions = dimensions.slice(0, -1); } return areDomainArgsFieldsValid(dimensions, this.definition); } parseArgsToPivotDomain(args) { const domain = []; for (let i = 0; i < args.length - 1; i += 2) { const fieldWithGranularity = toString(args[i]); const type = this.getTypeOfDimension(fieldWithGranularity); const normalizedValue = fieldWithGranularity === "measure" ? toString(args[i + 1]) : toNormalizedPivotValue(this.getDimension(fieldWithGranularity), args[i + 1]); domain.push({ field: fieldWithGranularity, value: normalizedValue, type }); } return domain; } markAsDirtyForEvaluation() { this.needsReevaluation = true; } getMeasure(id) { return this.definition.getMeasure(id); } getPivotMeasureValue(id) { return { value: this.getMeasure(id).displayName, }; } getPivotHeaderValueAndFormat(domain) { const lastNode = domain.at(-1); if (!lastNode) { return { value: _t("Total") }; } const dimension = this.getDimension(lastNode.field); const cells = this.filterDataEntriesFromDomain(this.dataEntries, domain); const finalCell = cells[0]?.[dimension.nameWithGranularity]; if (dimension.type === "datetime") { const adapter = pivotTimeAdapter(dimension.granularity); return adapter.toValueAndFormat(lastNode.value, this.getters.getLocale()); } if (!finalCell) { return { value: "" }; } if (finalCell.value === null) { return { value: _t("(Undefined)") }; } return { value: finalCell.value, format: finalCell.format, }; } getPivotCellValueAndFormat(measureId, domain) { const dataEntries = this.filterDataEntriesFromDomain(this.dataEntries, domain); if (dataEntries.length === 0) { return { value: "" }; } const measure = this.getMeasure(measureId); const allValues = dataEntries.map((value) => value[measure.fieldName]).filter(isDefined); const values = allValues.filter((cell) => cell.type !== CellValueType.empty); const aggregator = measure.aggregator; const operator = AGGREGATORS_FN[aggregator]; if (!operator) { throw new Error(`Aggregator ${aggregator} does not exist`); } try { const result = operator([allValues], this.getters.getLocale()); if (values.length === 0) { return { ...result, value: "" }; } return result; } catch (e) { return handleError(e, aggregator.toUpperCase()); } } getPossibleFieldValues(dimension) { const values = []; const groups = groupPivotDataEntriesBy(this.dataEntries, dimension); const orderedKeys = orderDataEntriesKeys(groups, dimension); for (const key of orderedKeys) { values.push({ value: groups[key]?.[0]?.[dimension.nameWithGranularity]?.value ?? "", label: groups[key]?.[0]?.[dimension.nameWithGranularity]?.formattedValue || "", }); } return values; } getTableStructure() { if (!this.isValid()) { throw new Error("Pivot is not valid !"); } if (!this.table) { this.table = dataEntriesToSpreadsheetPivotTable(this.dataEntries, this.definition); } return this.table; } getFields() { return this.metaData.fields; } get fields() { return this.getFields(); } loadMetaData() { this.invalidRangeError = undefined; if (this.coreDefinition.dataSet) { const { zone, sheetId } = this.coreDefinition.dataSet; const range = this.getters.getRangeFromZone(sheetId, zone); try { return this.extractFieldsFromRange(range); } catch (e) { this.invalidRangeError = e; return { fields: {}, fieldKeys: [] }; } } else { this.invalidRangeError = new EvaluationError(_t("The pivot cannot be created because the dataset is missing.")); return { fields: {}, fieldKeys: [] }; } } loadRuntimeDefinition() { return new SpreadsheetPivotRuntimeDefinition(this.coreDefinition, this.fields, this.getters); } loadData() { const range = this._definition?.range; return this.isValid() && range ? this.extractDataEntriesFromRange(range) : []; } getTypeOfDimension(fieldWithGranularity) { if (fieldWithGranularity === "measure") { return "char"; } const { fieldName } = parseDimension(fieldWithGranularity); const type = this.fields[fieldName]?.type; if (!type) { throw new Error(`Field ${fieldName} does not exist`); } return type; } filterDataEntriesFromDomain(dataEntries, domain) { return domain.reduce((current, acc) => this.filterDataEntriesFromDomainNode(current, acc), dataEntries); } filterDataEntriesFromDomainNode(dataEntries, domain) { const { field, value } = domain; const { nameWithGranularity } = this.getDimension(field); return dataEntries.filter((entry) => entry[nameWithGranularity]?.value === value); } getDimension(nameWithGranularity) { return this.definition.getDimension(nameWithGranularity); } getTypeFromZone(sheetId, zone) { const cells = this.getters.getEvaluatedCellsInZone(sheetId, zone); const nonEmptyCells = cells.filter((cell) => cell.type !== CellValueType.empty); if (nonEmptyCells.length === 0) { return "integer"; } if (nonEmptyCells.every((cell) => cell.format && isDateTimeFormat(cell.format))) { return "datetime"; } if (nonEmptyCells.every((cell) => cell.type === CellValueType.boolean)) { return "boolean"; } if (nonEmptyCells.some((cell) => cell.type === CellValueType.text)) { return "char"; } return "integer"; } assertCellIsValidField(col, row, cell) { if (cell.type === CellValueType.error) { throw new EvaluationError(_t("The pivot cannot be created because cell %s contains an error", toXC(col, row))); } if (cell.type === CellValueType.empty || cell.value === "") { throw new EvaluationError(_t("The pivot cannot be created because cell %s is empty", toXC(col, row))); } if (cell.value === "__count") { throw new EvaluationError(_t("The pivot cannot be created because cell %s contains a reserved value", toXC(col, row))); } } /** * Create the fields from the given range. It will extract all the fields from * the first row of the range. */ extractFieldsFromRange(range) { const fields = {}; const fieldKeys = []; const sheetId = range.sheetId; const row = range.zone.top; for (let col = range.zone.left; col <= range.zone.right; col++) { const cell = this.getters.getEvaluatedCell({ sheetId, col, row }); this.assertCellIsValidField(col, row, cell); const field = cell.value?.toString(); if (field) { const type = this.getTypeFromZone(sheetId, { top: range.zone.top + 1, left: col, bottom: range.zone.bottom, right: col, }); const string = this.findName(field, fields); fields[string] = { name: string, type, string, aggregator: type === "integer" ? "sum" : "count", }; fieldKeys.push(string); } } return { fields, fieldKeys }; } /** * Take cares of double names */ findName(name, fields) { let increment = 1; const initialName = name; while (name in fields) { name = `${initialName}${++increment}`; } return name; } extractDataEntriesFromRange(range) { const dataEntries = []; for (let row = range.zone.top + 1; row <= range.zone.bottom; row++) { const zone = { top: row, bottom: row, left: range.zone.left, right: range.zone.right }; const cells = this.getters.getEvaluatedCellsInZone(range.sheetId, zone); const entry = {}; for (const index in cells) { const cell = cells[index]; const field = this.fields[this.metaData.fieldKeys[index]]; if (!field) { throw new Error(`Field ${this.metaData.fieldKeys[index]} does not exist`); } if (cell.value === "") { entry[field.name] = { value: null, type: CellValueType.empty, formattedValue: "" }; } else { entry[field.name] = cell; } } entry["__count"] = { value: 1, type: CellValueType.number, formattedValue: "1" }; dataEntries.push(entry); } const dateDimensions = this.definition.columns .concat(this.definition.rows) .filter((d) => d.type === "datetime"); if (dateDimensions.length) { const locale = this.getters.getLocale(); for (const entry of dataEntries) { for (const dimension of dateDimensions) { const value = createDate(dimension, entry[dimension.fieldName]?.value || null, this.getters.getLocale()); const adapter = pivotTimeAdapter(dimension.granularity); const { format, value: valueToFormat } = adapter.toValueAndFormat(value, locale); entry[dimension.nameWithGranularity] = { value, type: entry[dimension.fieldName]?.type || CellValueType.empty, format: entry[dimension.fieldName]?.format, formattedValue: formatValue(valueToFormat, { locale, format }), }; } } } return dataEntries; } } const pivotRegistry = new Registry(); const dateGranularities = [ "year", "quarter_number", "month_number", "iso_week_number", "day_of_month", "day", "day_of_week", ]; pivotRegistry.add("SPREADSHEET", { ui: SpreadsheetPivot, definition: SpreadsheetPivotRuntimeDefinition, externalData: false, onIterationEndEvaluation: (pivot) => pivot.markAsDirtyForEvaluation(), dateGranularities: [...dateGranularities], datetimeGranularities: [...dateGranularities, "hour_number", "minute_number", "second_number"], isMeasureCandidate: (field) => !["datetime", "boolean"].includes(field.type), isGroupable: () => true, }); class PivotSidePanelStore extends SpreadsheetStore { pivotId; mutators = ["reset", "deferUpdates", "applyUpdate", "discardPendingUpdate", "update"]; updatesAreDeferred = false; draft = null; notification = this.get(NotificationStore); alreadyNotified = false; constructor(get, pivotId) { super(get); this.pivotId = pivotId; } handle(cmd) { switch (cmd.type) { case "UPDATE_PIVOT": if (cmd.pivotId === this.pivotId) { this.getters.getPivot(this.pivotId).init(); } } } get fields() { return this.pivot.getFields(); } get pivot() { return this.getters.getPivot(this.pivotId); } get definition() { const Definition = pivotRegistry.get(this.pivot.type).definition; return this.draft ? new Definition(this.draft, this.fields, this.getters) : this.pivot.definition; } get isDirty() { return !!this.draft; } get measureFields() { const measureFields = [ { name: "__count", string: _t("Count"), type: "integer", aggregator: "sum", }, ]; const fields = this.fields; for (const fieldName in fields) { const field = fields[fieldName]; if (!field) { continue; } if (pivotRegistry.get(this.pivot.type).isMeasureCandidate(field)) { measureFields.push(field); } } return measureFields.sort((a, b) => a.string.localeCompare(b.string)); } get unusedGroupableFields() { const groupableFields = []; const fields = this.fields; for (const fieldName in fields) { const field = fields[fieldName]; if (!field) { continue; } if (pivotRegistry.get(this.pivot.type).isGroupable(field)) { groupableFields.push(field); } } const { columns, rows, measures } = this.definition; const currentlyUsed = measures .concat(rows) .concat(columns) .map((field) => field.fieldName); const unusedGranularities = this.unusedGranularities; return groupableFields .filter((field) => { if (isDateOrDatetimeField(field)) { return !currentlyUsed.includes(field.name) || unusedGranularities[field.name].size > 0; } return !currentlyUsed.includes(field.name); }) .sort((a, b) => a.string.localeCompare(b.string)); } get datetimeGranularities() { return pivotRegistry.get(this.pivot.type).datetimeGranularities; } get dateGranularities() { return pivotRegistry.get(this.pivot.type).dateGranularities; } get unusedGranularities() { return this.getUnusedGranularities(this.fields, this.draft ?? this.getters.getPivotCoreDefinition(this.pivotId)); } reset(pivotId) { this.pivotId = pivotId; this.updatesAreDeferred = true; this.draft = null; } deferUpdates(shouldDefer) { this.updatesAreDeferred = shouldDefer; if (shouldDefer === false && this.draft) { this.applyUpdate(); } } applyUpdate() { if (this.draft) { this.model.dispatch("UPDATE_PIVOT", { pivotId: this.pivotId, pivot: this.draft, }); this.draft = null; if (!this.alreadyNotified && !this.isDynamicPivotInViewport()) { const formulaId = this.getters.getPivotFormulaId(this.pivotId); const pivotExample = `=PIVOT(${formulaId})`; this.alreadyNotified = true; this.notification.notifyUser({ type: "info", text: _t("Pivot updates only work with dynamic pivot tables. Use %s or re-insert the static pivot from the Data menu.", pivotExample), sticky: false, }); } } } discardPendingUpdate() { this.draft = null; } update(definitionUpdate) { const coreDefinition = this.getters.getPivotCoreDefinition(this.pivotId); const definition = { ...coreDefinition, ...this.draft, ...definitionUpdate, }; // clean to make sure we only keep the core properties const cleanedDefinition = { ...definition, columns: definition.columns.map((col) => ({ fieldName: col.fieldName, order: col.order, granularity: col.granularity, })), rows: definition.rows.map((row) => ({ fieldName: row.fieldName, order: row.order, granularity: row.granularity, })), measures: definition.measures.map((measure) => ({ id: measure.id, fieldName: measure.fieldName, aggregator: measure.aggregator, userDefinedName: measure.userDefinedName, computedBy: measure.computedBy, isHidden: measure.isHidden, format: measure.format, display: measure.display, })), }; if (!this.draft && deepEquals(coreDefinition, cleanedDefinition)) { return; } const cleanedWithGranularity = this.addDefaultDateTimeGranularity(this.fields, cleanedDefinition); this.draft = cleanedWithGranularity; if (!this.updatesAreDeferred) { this.applyUpdate(); } } isDynamicPivotInViewport() { const sheetId = this.getters.getActiveSheetId(); for (const col of this.getters.getSheetViewVisibleCols()) { for (const row of this.getters.getSheetViewVisibleRows()) { const isDynamicPivot = this.getters.isSpillPivotFormula({ sheetId, col, row }); if (isDynamicPivot) { return true; } } } return false; } addDefaultDateTimeGranularity(fields, definition) { const { columns, rows } = definition; const columnsWithGranularity = deepCopy(columns); const rowsWithGranularity = deepCopy(rows); const unusedGranularities = this.getUnusedGranularities(fields, definition); for (const dimension of columnsWithGranularity.concat(rowsWithGranularity)) { const fieldType = fields[dimension.fieldName]?.type; if ((fieldType === "date" || fieldType === "datetime") && !dimension.granularity) { const granularity = unusedGranularities[dimension.fieldName]?.values().next().value || "year"; unusedGranularities[dimension.fieldName]?.delete(granularity); dimension.granularity = granularity; } } return { ...definition, columns: columnsWithGranularity, rows: rowsWithGranularity, }; } getUnusedGranularities(fields, definition) { const { columns, rows } = definition; const dateFields = columns.concat(rows).filter((dimension) => { const fieldType = fields[dimension.fieldName]?.type; return fieldType === "date" || fieldType === "datetime"; }); const granularitiesPerFields = {}; for (const field of dateFields) { granularitiesPerFields[field.fieldName] = new Set(fields[field.fieldName]?.type === "date" ? this.dateGranularities : this.datetimeGranularities); } for (const field of dateFields) { granularitiesPerFields[field.fieldName].delete(field.granularity); } return granularitiesPerFields; } } class PivotSpreadsheetSidePanel extends Component { static template = "o-spreadsheet-PivotSpreadsheetSidePanel"; static props = { pivotId: String, onCloseSidePanel: Function, }; static components = { PivotLayoutConfigurator, Section, SelectionInput, Checkbox, PivotDeferUpdate, PivotTitleSection, }; store; state; setup() { this.store = useLocalStore(PivotSidePanelStore, this.props.pivotId); this.state = useState({ range: undefined, rangeHasChanged: false, }); } get shouldDisplayInvalidRangeError() { if (this.store.isDirty && this.state.rangeHasChanged) { return false; } return this.pivot.isInvalidRange; } get ranges() { if (this.state.range) { return [this.state.range]; } if (this.definition.range) { return [this.env.model.getters.getRangeString(this.definition.range, "forceSheetReference")]; } return []; } get pivot() { return this.store.pivot; } get definition() { return this.store.definition; } onSelectionChanged(ranges) { this.state.rangeHasChanged = true; this.state.range = ranges[0]; } onSelectionConfirmed() { if (this.state.range) { const range = this.env.model.getters.getRangeFromSheetXC(this.env.model.getters.getActiveSheetId(), this.state.range); if (range.invalidSheetName || range.invalidXc) { return; } const dataSet = { sheetId: range.sheetId, zone: range.zone }; this.store.update({ dataSet }); // Immediately apply the update to recompute the pivot fields this.store.applyUpdate(); } } flipAxis() { const { rows, columns } = this.definition; this.onDimensionsUpdated({ rows: columns, columns: rows, }); } onDimensionsUpdated(definition) { this.store.update(definition); } } const pivotSidePanelRegistry = new Registry(); pivotSidePanelRegistry.add("SPREADSHEET", { editor: PivotSpreadsheetSidePanel, }); class PivotSidePanel extends Component { static template = "o-spreadsheet-PivotSidePanel"; static props = { pivotId: String, onCloseSidePanel: Function, }; static components = { PivotLayoutConfigurator, Section, }; get sidePanelEditor() { const pivot = this.env.model.getters.getPivotCoreDefinition(this.props.pivotId); if (!pivot) { throw new Error("pivotId does not correspond to a pivot."); } return pivotSidePanelRegistry.get(pivot.type).editor; } } css /* scss */ ` .o-checkbox-selection { max-height: 300px; } `; class RemoveDuplicatesPanel extends Component { static template = "o-spreadsheet-RemoveDuplicatesPanel"; static components = { ValidationMessages, Section, Checkbox }; static props = { onCloseSidePanel: Function }; state = useState({ hasHeader: false, columns: {}, }); setup() { onWillUpdateProps(() => this.updateColumns()); } toggleHasHeader() { this.state.hasHeader = !this.state.hasHeader; } toggleAllColumns() { const newState = !this.isEveryColumnSelected; for (const index in this.state.columns) { this.state.columns[index] = newState; } } toggleColumn(colIndex) { this.state.columns[colIndex] = !this.state.columns[colIndex]; } onRemoveDuplicates() { this.env.model.dispatch("REMOVE_DUPLICATES", { hasHeader: this.state.hasHeader, columns: this.getColsToAnalyze(), }); } getColLabel(colKey) { const col = parseInt(colKey); let colLabel = _t("Column %s", numberToLetters(col)); if (this.state.hasHeader) { const sheetId = this.env.model.getters.getActiveSheetId(); const row = this.env.model.getters.getSelectedZone().top; const colHeader = this.env.model.getters.getEvaluatedCell({ sheetId, col, row }); if (colHeader.type !== "empty") { colLabel += ` - ${colHeader.value}`; } } return colLabel; } get isEveryColumnSelected() { return Object.values(this.state.columns).every((value) => value === true); } get errorMessages() { const cancelledReasons = this.env.model.canDispatch("REMOVE_DUPLICATES", { hasHeader: this.state.hasHeader, columns: this.getColsToAnalyze(), }).reasons; const errors = new Set(); for (const reason of cancelledReasons) { errors.add(RemoveDuplicateTerms.Errors[reason] || RemoveDuplicateTerms.Errors.Unexpected); } return Array.from(errors); } get selectionStatisticalInformation() { const dimension = zoneToDimension(this.env.model.getters.getSelectedZone()); return _t("%(row_count)s rows and %(column_count)s columns selected", { row_count: dimension.numberOfRows, column_count: dimension.numberOfCols, }); } get canConfirm() { return this.errorMessages.length === 0; } // --------------------------------------------------------------------------- // Private // --------------------------------------------------------------------------- updateColumns() { const zone = this.env.model.getters.getSelectedZone(); const oldColumns = this.state.columns; const newColumns = {}; for (let i = zone.left; i <= zone.right; i++) { newColumns[i] = i in oldColumns ? oldColumns[i] : true; } this.state.columns = newColumns; } getColsToAnalyze() { return Object.keys(this.state.columns) .filter((colIndex) => this.state.columns[colIndex]) .map((colIndex) => parseInt(colIndex)); } } css /* scss */ ` .o-locale-preview { border: 1px solid ${GRAY_300}; background-color: ${GRAY_100}; } `; class SettingsPanel extends Component { static template = "o-spreadsheet-SettingsPanel"; static components = { Section, ValidationMessages }; static props = { onCloseSidePanel: Function }; loadedLocales = []; setup() { onWillStart(() => this.loadLocales()); } onLocaleChange(code) { const locale = this.loadedLocales.find((l) => l.code === code); if (!locale) return; this.env.model.dispatch("UPDATE_LOCALE", { locale }); } async loadLocales() { this.loadedLocales = (await this.env.loadLocales()) .filter((locale) => { const isValid = isValidLocale(locale); if (!isValid) { console.warn(`Invalid locale: ${locale["code"]} ${locale}`); } return isValid; }) .sort((a, b) => a.name.localeCompare(b.name)); } get numberFormatPreview() { const locale = this.env.model.getters.getLocale(); return formatValue(1234567.89, { format: "#,##0.00", locale }); } get dateFormatPreview() { const locale = this.env.model.getters.getLocale(); return formatValue(1.6, { format: locale.dateFormat, locale }); } get dateTimeFormatPreview() { const locale = this.env.model.getters.getLocale(); const dateTimeFormat = getDateTimeFormat(locale); return formatValue(1.6, { format: dateTimeFormat, locale }); } get firstDayOfWeek() { const locale = this.env.model.getters.getLocale(); const weekStart = locale.weekStart; // Week start: 1 = Monday, 7 = Sunday // Days: 0 = Sunday, 6 = Saturday return DAYS$1[weekStart % 7]; } get currentLocale() { return this.env.model.getters.getLocale(); } get supportedLocales() { const currentLocale = this.currentLocale; const localeInLoadedLocales = this.loadedLocales.find((l) => l.code === currentLocale.code); if (!localeInLoadedLocales) { const locales = [...this.loadedLocales, currentLocale].sort((a, b) => a.name.localeCompare(b.name)); return locales; } else if (!deepEquals(currentLocale, localeInLoadedLocales)) { const index = this.loadedLocales.indexOf(localeInLoadedLocales); const locales = [...this.loadedLocales]; locales[index] = currentLocale; locales.sort((a, b) => a.name.localeCompare(b.name)); return locales; } return this.loadedLocales; } } const SplitToColumnsInteractiveContent = { SplitIsDestructive: _t("This will overwrite data in the subsequent columns. Split anyway?"), }; function interactiveSplitToColumns(env, separator, addNewColumns) { let result = env.model.dispatch("SPLIT_TEXT_INTO_COLUMNS", { separator, addNewColumns }); if (result.isCancelledBecause("SplitWillOverwriteContent" /* CommandResult.SplitWillOverwriteContent */)) { env.askConfirmation(SplitToColumnsInteractiveContent.SplitIsDestructive, () => { result = env.model.dispatch("SPLIT_TEXT_INTO_COLUMNS", { separator, addNewColumns, force: true, }); }); } return result; } const SEPARATORS = [ { name: _t("Detect automatically"), value: "auto" }, { name: _t("Custom separator"), value: "custom" }, { name: _t("Space"), value: " " }, { name: _t("Comma"), value: "," }, { name: _t("Semicolon"), value: ";" }, { name: _t("Line Break"), value: NEWLINE }, ]; class SplitIntoColumnsPanel extends Component { static template = "o-spreadsheet-SplitIntoColumnsPanel"; static components = { ValidationMessages, Section, Checkbox }; static props = { onCloseSidePanel: Function }; state = useState({ separatorValue: "auto", addNewColumns: false, customSeparator: "" }); setup() { const composerFocusStore = useStore(ComposerFocusStore); // The feature makes no sense if we are editing a cell, because then the selection isn't active // Stop the edition when the panel is mounted, and close the panel if the user start editing a cell useEffect((editionMode) => { if (editionMode !== "inactive") { this.props.onCloseSidePanel(); } }, () => [composerFocusStore.focusMode]); onMounted(() => { composerFocusStore.activeComposer.stopEdition(); }); } onSeparatorChange(value) { this.state.separatorValue = value; } updateCustomSeparator(ev) { if (!ev.target) return; this.state.customSeparator = ev.target.value; } updateAddNewColumnsCheckbox(addNewColumns) { this.state.addNewColumns = addNewColumns; } confirm() { const result = interactiveSplitToColumns(this.env, this.separatorValue, this.state.addNewColumns); if (result.isSuccessful) { this.props.onCloseSidePanel(); } } get errorMessages() { const cancelledReasons = this.env.model.canDispatch("SPLIT_TEXT_INTO_COLUMNS", { separator: this.separatorValue, addNewColumns: this.state.addNewColumns, force: true, }).reasons; const errors = new Set(); for (const reason of cancelledReasons) { switch (reason) { case "SplitWillOverwriteContent" /* CommandResult.SplitWillOverwriteContent */: case "EmptySplitSeparator" /* CommandResult.EmptySplitSeparator */: break; default: errors.add(SplitToColumnsTerms.Errors[reason] || SplitToColumnsTerms.Errors.Unexpected); } } return Array.from(errors); } get warningMessages() { const warnings = []; const cancelledReasons = this.env.model.canDispatch("SPLIT_TEXT_INTO_COLUMNS", { separator: this.separatorValue, addNewColumns: this.state.addNewColumns, force: false, }).reasons; if (cancelledReasons.includes("SplitWillOverwriteContent" /* CommandResult.SplitWillOverwriteContent */)) { warnings.push(SplitToColumnsTerms.Errors["SplitWillOverwriteContent" /* CommandResult.SplitWillOverwriteContent */]); } return warnings; } get separatorValue() { if (this.state.separatorValue === "custom") { return this.state.customSeparator; } else if (this.state.separatorValue === "auto") { return this.env.model.getters.getAutomaticSeparator(); } return this.state.separatorValue; } get separators() { return SEPARATORS; } get isConfirmDisabled() { return !this.separatorValue || this.errorMessages.length > 0; } } const TABLE_ELEMENTS_BY_PRIORITY = [ "wholeTable", "firstColumnStripe", "secondColumnStripe", "firstRowStripe", "secondRowStripe", "firstColumn", "lastColumn", "headerRow", "totalRow", ]; /** Return the content zone of the table, ie. the table zone without the headers */ function getTableContentZone(tableZone, tableConfig) { const numberOfHeaders = tableConfig.numberOfHeaders; const contentZone = { ...tableZone, top: tableZone.top + numberOfHeaders }; return contentZone.top <= contentZone.bottom ? contentZone : undefined; } function getTableTopLeft(table) { const range = table.range; return { row: range.zone.top, col: range.zone.left, sheetId: range.sheetId }; } function createFilter(id, range, config, createRange) { const zone = range.zone; if (zone.left !== zone.right) { throw new Error("Can only define a filter on a single column"); } const filteredZone = { ...zone, top: zone.top + config.numberOfHeaders }; const filteredRange = createRange(range.sheetId, filteredZone); return { id, rangeWithHeaders: range, col: zone.left, filteredRange: filteredZone.top > filteredZone.bottom ? undefined : filteredRange, }; } function isStaticTable(table) { return table.type === "static" || table.type === "forceStatic"; } function getComputedTableStyle(tableConfig, style, numberOfCols, numberOfRows) { return { borders: getAllTableBorders(tableConfig, style, numberOfCols, numberOfRows), styles: getAllTableStyles(tableConfig, style, numberOfCols, numberOfRows), }; } function getAllTableBorders(tableConfig, style, nOfCols, nOfRows) { const borders = generateMatrix(nOfCols, nOfRows, () => ({})); for (const tableElement of TABLE_ELEMENTS_BY_PRIORITY) { const styleBorder = style[tableElement]?.border; if (!styleBorder) continue; const zones = getTableElementZones(tableElement, tableConfig, nOfCols, nOfRows); for (const zone of zones) { for (let col = zone.left; col <= zone.right; col++) { for (let row = zone.top; row <= zone.bottom; row++) { // Special case: we don't want borders inside the headers rows const noInsideBorder = tableElement === "wholeTable" && row <= tableConfig.numberOfHeaders - 1; if (row === zone.top && styleBorder?.top) { setBorderDescr(borders, "top", styleBorder.top, col, row, nOfCols, nOfRows); } else if (row !== zone.top && !noInsideBorder && styleBorder?.horizontal) { setBorderDescr(borders, "top", styleBorder.horizontal, col, row, nOfCols, nOfRows); } if (row === zone.bottom && styleBorder?.bottom) { setBorderDescr(borders, "bottom", styleBorder.bottom, col, row, nOfCols, nOfRows); } if (col === zone.left && styleBorder?.left) { setBorderDescr(borders, "left", styleBorder.left, col, row, nOfCols, nOfRows); } if (col === zone.right && styleBorder?.right) { setBorderDescr(borders, "right", styleBorder.right, col, row, nOfCols, nOfRows); } else if (col !== zone.right && !noInsideBorder && styleBorder?.vertical) { setBorderDescr(borders, "right", styleBorder.vertical, col, row, nOfCols, nOfRows); } } } } } return borders; } /** * Set the border description for a given border direction (top, bottom, left, right) in the computedBorders array. * Also set the corresponding borders of adjacent cells (eg. if the border is set on the top of a cell, the bottom * border of the cell above is set). */ function setBorderDescr(computedBorders, dir, borderDescr, col, row, numberOfCols, numberOfRows) { switch (dir) { case "top": computedBorders[col][row].top = borderDescr; if (row !== 0) { computedBorders[col][row - 1].bottom = borderDescr; } return; case "bottom": computedBorders[col][row].bottom = borderDescr; if (row !== numberOfRows - 1) { computedBorders[col][row + 1].top = borderDescr; } return; case "left": computedBorders[col][row].left = borderDescr; if (col !== 0) { computedBorders[col - 1][row].right = borderDescr; } return; case "right": computedBorders[col][row].right = borderDescr; if (col !== numberOfCols - 1) { computedBorders[col + 1][row].left = borderDescr; } return; } } function getAllTableStyles(tableConfig, style, numberOfCols, numberOfRows) { const styles = generateMatrix(numberOfCols, numberOfRows, () => ({})); for (const tableElement of TABLE_ELEMENTS_BY_PRIORITY) { const tableElStyle = style[tableElement]; const bold = isTableElementInBold(tableElement); if (!tableElStyle && !bold) { continue; } const zones = getTableElementZones(tableElement, tableConfig, numberOfCols, numberOfRows); for (const zone of zones) { for (let col = zone.left; col <= zone.right; col++) { for (let row = zone.top; row <= zone.bottom; row++) { if (!styles[col][row]) { styles[col][row] = {}; } styles[col][row] = { ...styles[col][row], ...tableElStyle?.style, }; if (bold) { styles[col][row].bold = true; } } } } } return styles; } function isTableElementInBold(tableElement) { return (tableElement === "firstColumn" || tableElement === "lastColumn" || tableElement === "headerRow" || tableElement === "totalRow"); } function getTableElementZones(el, tableConfig, numberOfCols, numberOfRows) { const zones = []; const headerRows = Math.min(tableConfig.numberOfHeaders, numberOfRows); const totalRows = tableConfig.totalRow ? 1 : 0; const lastCol = numberOfCols - 1; const lastRow = numberOfRows - 1; switch (el) { case "wholeTable": zones.push({ top: 0, left: 0, bottom: lastRow, right: lastCol }); break; case "firstColumn": if (!tableConfig.firstColumn) break; zones.push({ top: 0, left: 0, bottom: lastRow, right: 0 }); break; case "lastColumn": if (!tableConfig.lastColumn) break; zones.push({ top: 0, left: lastCol, bottom: lastRow, right: lastCol }); break; case "headerRow": if (!tableConfig.numberOfHeaders) break; zones.push({ top: 0, left: 0, bottom: headerRows - 1, right: lastCol }); break; case "totalRow": if (!tableConfig.totalRow) break; zones.push({ top: lastRow, left: 0, bottom: lastRow, right: lastCol }); break; case "firstRowStripe": if (!tableConfig.bandedRows) break; for (let i = headerRows; i < numberOfRows - totalRows; i += 2) { zones.push({ top: i, left: 0, bottom: i, right: lastCol }); } break; case "secondRowStripe": if (!tableConfig.bandedRows) break; for (let i = headerRows + 1; i < numberOfRows - totalRows; i += 2) { zones.push({ top: i, left: 0, bottom: i, right: lastCol }); } break; case "firstColumnStripe": if (!tableConfig.bandedColumns) break; for (let i = 0; i < numberOfCols; i += 2) { zones.push({ top: headerRows, left: i, bottom: lastRow - totalRows, right: i }); } break; case "secondColumnStripe": if (!tableConfig.bandedColumns) break; for (let i = 1; i < numberOfCols; i += 2) { zones.push({ top: headerRows, left: i, bottom: lastRow - totalRows, right: i }); } break; } return zones; } function createTableStyleContextMenuActions(env, styleId) { if (!env.model.getters.isTableStyleEditable(styleId)) { return []; } return createActions([ { id: "editTableStyle", name: _t("Edit table style"), execute: (env) => env.openSidePanel("TableStyleEditorPanel", { styleId }), icon: "o-spreadsheet-Icon.EDIT", }, { id: "deleteTableStyle", name: _t("Delete table style"), execute: (env) => env.model.dispatch("REMOVE_TABLE_STYLE", { tableStyleId: styleId }), icon: "o-spreadsheet-Icon.TRASH", }, ]); } function drawPreviewTable(ctx, tableStyle, colWidth, rowHeight) { ctx.resetTransform(); drawBackgrounds(ctx, tableStyle, colWidth, rowHeight); drawBorders(ctx, tableStyle, colWidth, rowHeight); drawTexts(ctx, tableStyle, colWidth, rowHeight); } function drawBackgrounds(ctx, tableStyle, colWidth, rowHeight) { ctx.save(); for (let col = 0; col < 5; col++) { for (let row = 0; row < 5; row++) { ctx.fillStyle = tableStyle.styles[col][row].fillColor || "#fff"; ctx.fillRect(col * colWidth, row * rowHeight, colWidth, rowHeight); } } ctx.restore(); } function drawBorders(ctx, tableStyle, colWidth, rowHeight) { ctx.save(); ctx.translate(0, 0.5); ctx.lineWidth = 1; for (let col = 0; col < 5; col++) { for (let row = 0; row < 5; row++) { const borders = tableStyle.borders[col][row]; if (borders.top) { ctx.strokeStyle = borders.top.color; ctx.beginPath(); ctx.moveTo(col * colWidth, row * rowHeight); ctx.lineTo(col * colWidth + colWidth, row * rowHeight); ctx.stroke(); } if (borders.bottom) { ctx.strokeStyle = borders.bottom.color; ctx.beginPath(); ctx.moveTo(col * colWidth, row * rowHeight + rowHeight); ctx.lineTo(col * colWidth + colWidth, row * rowHeight + rowHeight); ctx.stroke(); } } } ctx.resetTransform(); ctx.translate(0.5, 0); for (let col = 0; col < 5; col++) { for (let row = 0; row < 5; row++) { const borders = tableStyle.borders[col][row]; if (borders.left) { ctx.strokeStyle = borders.left.color; ctx.beginPath(); ctx.moveTo(col * colWidth, row * rowHeight); ctx.lineTo(col * colWidth, row * rowHeight + rowHeight); ctx.stroke(); } if (borders.right) { ctx.strokeStyle = borders.right.color; ctx.beginPath(); ctx.moveTo(col * colWidth + colWidth, row * rowHeight); ctx.lineTo(col * colWidth + colWidth, row * rowHeight + rowHeight + 1); // +1 to draw on the bottom-right pixel of the table ctx.stroke(); } } } ctx.restore(); } function drawTexts(ctx, tableStyle, colWidth, rowHeight) { ctx.save(); ctx.translate(0, 0.5); ctx.lineWidth = 1; const xPadding = Math.floor(colWidth / 4); const yPadding = Math.floor(rowHeight / 2); for (let col = 0; col < 5; col++) { for (let row = 0; row < 5; row++) { ctx.strokeStyle = tableStyle.styles[col][row].textColor || "#000"; ctx.beginPath(); ctx.moveTo(col * colWidth + xPadding + 1, row * rowHeight + yPadding); ctx.lineTo(col * colWidth + colWidth - xPadding, row * rowHeight + yPadding); ctx.stroke(); } } ctx.restore(); } css /* scss */ ` .o-table-style-list-item { border: 1px solid transparent; border-radius: 4px; &.selected { border: 1px solid ${ACTION_COLOR}; background: ${BADGE_SELECTED_COLOR}; } &:hover { background: #ddd; .o-table-style-edit-button { display: block !important; right: 0; top: 0; background: #fff; cursor: pointer; border: 1px solid #ddd; padding: 1px 1px 1px 2px; .o-icon { font-size: 12px; width: 12px; height: 12px; } } } } `; class TableStylePreview extends Component { static template = "o-spreadsheet-TableStylePreview"; static components = { Menu }; static props = { tableConfig: Object, tableStyle: Object, class: String, styleId: { type: String, optional: true }, selected: { type: Boolean, optional: true }, onClick: { type: Function, optional: true }, }; canvasRef = useRef("canvas"); menu = useState({ isOpen: false, position: null, menuItems: [] }); setup() { onWillUpdateProps((nextProps) => { if (!deepEquals(this.props.tableConfig, nextProps.tableConfig) || !deepEquals(this.props.tableStyle, nextProps.tableStyle)) { this.drawTable(nextProps); } }); onMounted(() => this.drawTable(this.props)); } drawTable(props) { const ctx = this.canvasRef.el.getContext("2d"); const { width, height } = this.canvasRef.el.getBoundingClientRect(); this.canvasRef.el.width = width; this.canvasRef.el.height = height; const computedStyle = getComputedTableStyle(props.tableConfig, props.tableStyle, 5, 5); drawPreviewTable(ctx, computedStyle, (width - 1) / 5, (height - 1) / 5); } onContextMenu(event) { if (!this.props.styleId) { return; } this.menu.menuItems = createTableStyleContextMenuActions(this.env, this.props.styleId); this.menu.isOpen = true; this.menu.position = { x: event.clientX, y: event.clientY }; } closeMenu() { this.menu.isOpen = false; this.menu.position = null; this.menu.menuItems = []; } get styleName() { if (!this.props.styleId) { return ""; } return this.env.model.getters.getTableStyle(this.props.styleId).displayName; } get isStyleEditable() { if (!this.props.styleId) { return false; } return this.env.model.getters.isTableStyleEditable(this.props.styleId); } editTableStyle() { this.env.openSidePanel("TableStyleEditorPanel", { styleId: this.props.styleId }); } } css /* scss */ ` .o-table-style-popover { /** 7 tables preview + padding by line */ width: calc((66px + 4px * 2) * 7 + 1.5rem * 2); background: #fff; font-size: 14px; user-select: none; .o-notebook { border-bottom: 1px solid ${GRAY_300}; .o-notebook-tab { padding: 5px 15px; border: 1px solid ${GRAY_300}; margin-bottom: -1px; margin-left: -1px; color: ${TEXT_BODY}; cursor: pointer; transition: color 0.2s, border-color 0.2s; &.selected { border-bottom-color: #fff; border-top-color: ${PRIMARY_BUTTON_BG}; color: ${TEXT_HEADING}; } } } .o-table-style-list-item { padding: 3px; } .o-table-style-popover-preview { width: 66px; height: 51px; } .o-new-table-style { font-size: 36px; color: #666; &:hover { background: #f5f5f5; } } } `; class TableStylesPopover extends Component { static template = "o-spreadsheet-TableStylesPopover"; static components = { Popover, TableStylePreview }; static props = { tableConfig: Object, popoverProps: { type: Object, optional: true }, closePopover: Function, onStylePicked: Function, selectedStyleId: { type: String, optional: true }, }; categories = TABLE_STYLE_CATEGORIES; tableStyleListRef = useRef("tableStyleList"); state = useState({ selectedCategory: this.initialSelectedCategory }); menu = useState({ isOpen: false, position: null, menuItems: [] }); setup() { useExternalListener(window, "click", this.onExternalClick, { capture: true }); } onExternalClick(ev) { if (this.tableStyleListRef.el && !isChildEvent(this.tableStyleListRef.el, ev)) { this.props.closePopover(); ev.hasClosedTableStylesPopover = true; } } get displayedStyles() { const styles = this.env.model.getters.getTableStyles(); return Object.keys(styles).filter((styleId) => styles[styleId].category === this.state.selectedCategory); } get initialSelectedCategory() { return this.props.selectedStyleId ? this.env.model.getters.getTableStyle(this.props.selectedStyleId).category : "medium"; } newTableStyle() { this.props.closePopover(); this.env.openSidePanel("TableStyleEditorPanel", { onStylePicked: this.props.onStylePicked, }); } } css /* scss */ ` .o-table-style-picker { box-sizing: border-box; border: 1px solid ${GRAY_300}; border-radius: 3px; .o-table-style-picker-arrow { border-left: 1px solid ${GRAY_300}; &:hover { background: #f5f5f5; cursor: pointer; } } .o-table-style-list-item { padding: 5px 6px; margin: 5px 2px; .o-table-style-picker-preview { width: 51px; height: 36px; } } } `; class TableStylePicker extends Component { static template = "o-spreadsheet-TableStylePicker"; static components = { TableStylesPopover, TableStylePreview }; static props = { table: Object }; state = useState({ popoverProps: undefined }); getDisplayedTableStyles() { const allStyles = this.env.model.getters.getTableStyles(); const selectedStyleCategory = allStyles[this.props.table.config.styleId].category; const styles = Object.keys(allStyles).filter((key) => allStyles[key].category === selectedStyleCategory); const selectedStyleIndex = styles.indexOf(this.props.table.config.styleId); if (selectedStyleIndex === -1) { return selectedStyleIndex; } const index = Math.floor(selectedStyleIndex / 4) * 4; return styles.slice(index); } onStylePicked(styleId) { const sheetId = this.env.model.getters.getActiveSheetId(); this.env.model.dispatch("UPDATE_TABLE", { sheetId, zone: this.props.table.range.zone, config: { styleId: styleId }, }); this.closePopover(); } onArrowButtonClick(ev) { if (ev.hasClosedTableStylesPopover || this.state.popoverProps) { this.closePopover(); return; } const target = ev.currentTarget; const { bottom, right } = target.getBoundingClientRect(); this.state.popoverProps = { anchorRect: { x: right, y: bottom, width: 0, height: 0 }, positioning: "TopRight", verticalOffset: 0, }; } closePopover() { this.state.popoverProps = undefined; } } css /* scss */ ` .o-table-panel { input.o-table-n-of-headers { width: 14px; text-align: center; } .o-info-icon { width: 14px; height: 14px; } } `; class TablePanel extends Component { static template = "o-spreadsheet-TablePanel"; static components = { TableStylePicker, SelectionInput, ValidationMessages, Checkbox, Section }; static props = { onCloseSidePanel: Function, table: Object }; state; setup() { const sheetId = this.env.model.getters.getActiveSheetId(); this.state = useState({ tableZoneErrors: [], tableXc: this.env.model.getters.getRangeString(this.props.table.range, sheetId), filtersEnabledIfPossible: this.props.table.config.hasFilters, }); } updateHasFilters(hasFilters) { this.state.filtersEnabledIfPossible = hasFilters; this.updateTableConfig("hasFilters", hasFilters); } updateTableConfig(attName, value) { const sheetId = this.env.model.getters.getActiveSheetId(); return this.env.model.dispatch("UPDATE_TABLE", { sheetId, zone: this.props.table.range.zone, config: { [attName]: value }, }); } updateHasHeaders(hasHeaders) { const numberOfHeaders = hasHeaders ? 1 : 0; this.updateNumberOfHeaders(numberOfHeaders); } updateTableIsDynamic(isDynamic) { const newTableType = isDynamic ? "dynamic" : "forceStatic"; if (newTableType === this.props.table.type) { return; } const uiTable = this.env.model.getters.getTable(getTableTopLeft(this.props.table)); if (!uiTable) { return; } const sheetId = this.env.model.getters.getActiveSheetId(); const result = this.env.model.dispatch("UPDATE_TABLE", { sheetId, zone: this.props.table.range.zone, newTableRange: uiTable.range.rangeData, tableType: newTableType, }); const updatedTable = this.env.model.getters.getCoreTable(getTableTopLeft(this.props.table)); if (result.isSuccessful && updatedTable) { const newTableRange = updatedTable.range; this.state.tableXc = this.env.model.getters.getRangeString(newTableRange, sheetId); this.state.tableZoneErrors = []; } } onChangeNumberOfHeaders(ev) { const input = ev.target; const numberOfHeaders = parseInt(input.value); const result = this.updateNumberOfHeaders(numberOfHeaders); if (!result.isSuccessful) { input.value = this.props.table.config.numberOfHeaders.toString(); } } updateNumberOfHeaders(numberOfHeaders) { const hasFilters = numberOfHeaders > 0 && (this.tableConfig.hasFilters || this.state.filtersEnabledIfPossible); return this.env.model.dispatch("UPDATE_TABLE", { sheetId: this.env.model.getters.getActiveSheetId(), zone: this.props.table.range.zone, config: { numberOfHeaders, hasFilters }, }); } onRangeChanged(ranges) { const sheetId = this.env.model.getters.getActiveSheetId(); this.state.tableXc = ranges[0]; const newTableRange = this.env.model.getters.getRangeFromSheetXC(sheetId, this.state.tableXc); this.state.tableZoneErrors = this.env.model.canDispatch("UPDATE_TABLE", { sheetId, zone: this.props.table.range.zone, newTableRange: this.env.model.getters.getRangeDataFromXc(sheetId, this.state.tableXc), tableType: this.getNewTableType(newTableRange.zone), }).reasons; } onRangeConfirmed() { const sheetId = this.env.model.getters.getActiveSheetId(); let newRange = this.env.model.getters.getRangeFromSheetXC(sheetId, this.state.tableXc); if (getZoneArea(newRange.zone) === 1) { const extendedZone = this.env.model.getters.getContiguousZone(sheetId, newRange.zone); newRange = this.env.model.getters.getRangeFromZone(sheetId, extendedZone); } const newTableZone = newRange.zone; const oldTableZone = this.props.table.range.zone; const cmdToCall = newTableZone.top === oldTableZone.top && newTableZone.left === oldTableZone.left ? "RESIZE_TABLE" : "UPDATE_TABLE"; const result = this.env.model.dispatch(cmdToCall, { sheetId, zone: this.props.table.range.zone, newTableRange: newRange.rangeData, tableType: this.getNewTableType(newRange.zone), }); const position = { sheetId, col: newRange.zone.left, row: newRange.zone.top }; const updatedTable = this.env.model.getters.getCoreTable(position); if (result.isSuccessful && updatedTable) { const newTopLeft = getTableTopLeft(updatedTable); this.env.model.selection.selectZone({ zone: positionToZone(newTopLeft), cell: newTopLeft, }); const newTableRange = updatedTable.range; this.state.tableXc = this.env.model.getters.getRangeString(newTableRange, sheetId); } } deleteTable() { const sheetId = this.env.model.getters.getActiveSheetId(); this.env.model.dispatch("REMOVE_TABLE", { sheetId, target: [this.props.table.range.zone], }); } getNewTableType(newTableZone) { if (this.props.table.type === "forceStatic") { return "forceStatic"; } const sheetId = this.env.model.getters.getActiveSheetId(); return this.env.model.getters.canCreateDynamicTableOnZones(sheetId, [newTableZone]) ? "dynamic" : "static"; } get tableConfig() { return this.props.table.config; } get errorMessages() { const cancelledReasons = this.state.tableZoneErrors || []; return cancelledReasons.map((error) => TableTerms.Errors[error] || TableTerms.Errors.Unexpected); } getCheckboxLabel(attName) { return TableTerms.Checkboxes[attName]; } get canHaveFilters() { return this.tableConfig.numberOfHeaders > 0; } get hasFilterCheckboxTooltip() { return this.canHaveFilters ? undefined : TableTerms.Tooltips.filterWithoutHeader; } get canBeDynamic() { const sheetId = this.env.model.getters.getActiveSheetId(); return (this.props.table.type === "dynamic" || this.env.model.getters.canCreateDynamicTableOnZones(sheetId, [this.props.table.range.zone])); } get dynamicTableTooltip() { return TableTerms.Tooltips.isDynamic; } } css /* scss */ ` .o-table-style-editor-panel { .o-table-style-list-item { margin: 2px 7px; padding: 6px 9px; .o-table-style-edit-template-preview { width: 71px; height: 51px; } } } `; const DEFAULT_TABLE_STYLE_COLOR = "#3C78D8"; class TableStyleEditorPanel extends Component { static template = "o-spreadsheet-TableStyleEditorPanel"; static components = { Section, RoundColorPicker, TableStylePreview }; static props = { onCloseSidePanel: Function, onStylePicked: { type: Function, optional: true }, styleId: { type: String, optional: true }, }; state = useState(this.getInitialState()); setup() { useExternalListener(window, "click", () => (this.state.pickerOpened = false)); } getInitialState() { const editedStyle = this.props.styleId ? this.env.model.getters.getTableStyle(this.props.styleId) : null; return { pickerOpened: false, primaryColor: editedStyle?.primaryColor || DEFAULT_TABLE_STYLE_COLOR, selectedTemplateName: editedStyle?.templateName || "lightColoredText", styleName: editedStyle?.displayName || this.env.model.getters.getNewCustomTableStyleName(), }; } togglePicker() { this.state.pickerOpened = !this.state.pickerOpened; } onColorPicked(color) { this.state.primaryColor = isColorValid(color) ? color : DEFAULT_TABLE_STYLE_COLOR; this.state.pickerOpened = false; } onTemplatePicked(templateName) { this.state.selectedTemplateName = templateName; } onConfirm() { const tableStyleId = this.props.styleId || this.env.model.uuidGenerator.uuidv4(); this.env.model.dispatch("CREATE_TABLE_STYLE", { tableStyleId, tableStyleName: this.state.styleName, templateName: this.state.selectedTemplateName, primaryColor: this.state.primaryColor, }); this.props.onStylePicked?.(tableStyleId); this.props.onCloseSidePanel(); } onCancel() { this.props.onCloseSidePanel(); } onDelete() { if (!this.props.styleId) { return; } this.env.model.dispatch("REMOVE_TABLE_STYLE", { tableStyleId: this.props.styleId }); this.props.onCloseSidePanel(); } get colorPreviewStyle() { return cssPropertiesToCss({ background: this.state.primaryColor }); } get tableTemplates() { return Object.keys(TABLE_STYLES_TEMPLATES).filter((templateName) => templateName !== "none"); } get previewTableConfig() { return { bandedColumns: false, bandedRows: true, firstColumn: false, lastColumn: false, numberOfHeaders: 1, totalRow: true, hasFilters: true, styleId: "", }; } get selectedStyle() { return this.computeTableStyle(this.state.selectedTemplateName); } computeTableStyle(templateName) { return buildTableStyle(this.state.styleName, templateName, this.state.primaryColor); } } const sidePanelRegistry = new Registry(); //------------------------------------------------------------------------------ // Side Panel Registry //------------------------------------------------------------------------------ sidePanelRegistry.add("ConditionalFormatting", { title: _t("Conditional formatting"), Body: ConditionalFormattingPanel, }); sidePanelRegistry.add("ChartPanel", { title: _t("Chart"), Body: ChartPanel, computeState: (getters, initialProps) => { const figureId = getters.getSelectedFigureId() ?? initialProps.figureId; if (!getters.isChartDefined(figureId)) { return { isOpen: false }; } return { isOpen: true, props: { figureId } }; }, }); sidePanelRegistry.add("FindAndReplace", { title: _t("Find and Replace"), Body: FindAndReplacePanel, }); sidePanelRegistry.add("CustomCurrency", { title: _t("Custom currency format"), Body: CustomCurrencyPanel, }); sidePanelRegistry.add("SplitToColumns", { title: _t("Split text into columns"), Body: SplitIntoColumnsPanel, }); sidePanelRegistry.add("Settings", { title: _t("Spreadsheet settings"), Body: SettingsPanel, }); sidePanelRegistry.add("RemoveDuplicates", { title: _t("Remove duplicates"), Body: RemoveDuplicatesPanel, }); sidePanelRegistry.add("DataValidation", { title: _t("Data validation"), Body: DataValidationPanel, }); sidePanelRegistry.add("DataValidationEditor", { title: _t("Data validation"), Body: DataValidationEditor, }); sidePanelRegistry.add("MoreFormats", { title: _t("More date formats"), Body: MoreFormatsPanel, }); sidePanelRegistry.add("TableSidePanel", { title: _t("Edit table"), Body: TablePanel, computeState: (getters) => { const table = getters.getFirstTableInSelection(); if (!table) { return { isOpen: false }; } const coreTable = getters.getCoreTable(getTableTopLeft(table)); return { isOpen: true, props: { table: coreTable }, key: table.id }; }, }); sidePanelRegistry.add("TableStyleEditorPanel", { title: _t("Create custom table style"), Body: TableStyleEditorPanel, computeState: (getters, initialProps) => { return { isOpen: true, props: { ...initialProps }, key: initialProps.styleId ?? "new", }; }, }); sidePanelRegistry.add("PivotSidePanel", { title: (env, props) => { return _t("Pivot #%s", env.model.getters.getPivotFormulaId(props.pivotId)); }, Body: PivotSidePanel, computeState: (getters, props) => { return { isOpen: getters.isExistingPivot(props.pivotId), props, key: `pivot_key_${props.pivotId}`, }; }, }); sidePanelRegistry.add("PivotMeasureDisplayPanel", { title: (env, props) => { const measure = env.model.getters.getPivot(props.pivotId).getMeasure(props.measure.id); return _t('Measure "%s" options', measure.displayName); }, Body: PivotMeasureDisplayPanel, computeState: (getters, props) => { try { // This will throw if the pivot or measure does not exist getters.getPivot(props.pivotId).getMeasure(props.measure.id); return { isOpen: true, props, key: "pivot_measure_display" }; } catch (e) { return { isOpen: false }; } }, }); class TopBarComponentRegistry extends Registry { mapping = {}; uuidGenerator = new UuidGenerator(); add(name, value) { const component = { ...value, id: this.uuidGenerator.uuidv4() }; return super.add(name, component); } getAllOrdered() { return this.getAll().sort((a, b) => a.sequence - b.sequence); } } const topbarComponentRegistry = new TopBarComponentRegistry(); // ----------------------------------------------------------------------------- // STYLE // ----------------------------------------------------------------------------- const ANCHOR_SIZE = 8; const BORDER_WIDTH = 1; const ACTIVE_BORDER_WIDTH = 2; css /*SCSS*/ ` div.o-figure { box-sizing: border-box; position: absolute; width: 100%; height: 100%; user-select: none; &:focus { outline: none; } } div.o-figure-border { box-sizing: border-box; z-index: 1; } .o-figure-wrapper { position: absolute; box-sizing: content-box; .o-fig-anchor { z-index: ${ComponentsImportance.FigureAnchor}; position: absolute; width: ${ANCHOR_SIZE}px; height: ${ANCHOR_SIZE}px; background-color: #1a73e8; outline: ${BORDER_WIDTH}px solid white; &.o-top { cursor: n-resize; } &.o-topRight { cursor: ne-resize; } &.o-right { cursor: e-resize; } &.o-bottomRight { cursor: se-resize; } &.o-bottom { cursor: s-resize; } &.o-bottomLeft { cursor: sw-resize; } &.o-left { cursor: w-resize; } &.o-topLeft { cursor: nw-resize; } } .o-figure-menu { right: 0px; top: 0px; display: none; } .o-figure-menu-item { cursor: pointer; } .o-figure.active:focus, .o-figure:hover { .o-figure-menu { display: flex; } } } `; class FigureComponent extends Component { static template = "o-spreadsheet-FigureComponent"; static props = { figure: Object, style: { type: String, optional: true }, onFigureDeleted: { type: Function, optional: true }, onMouseDown: { type: Function, optional: true }, onClickAnchor: { type: Function, optional: true }, }; static components = { Menu }; static defaultProps = { onFigureDeleted: () => { }, onMouseDown: () => { }, onClickAnchor: () => { }, }; menuState = useState({ isOpen: false, position: null, menuItems: [] }); figureRef = useRef("figure"); menuButtonRef = useRef("menuButton"); menuButtonRect = useAbsoluteBoundingRect(this.menuButtonRef); borderWidth; get isSelected() { return this.env.model.getters.getSelectedFigureId() === this.props.figure.id; } get figureRegistry() { return figureRegistry; } getBorderWidth() { if (this.env.isDashboard()) return 0; return this.isSelected ? ACTIVE_BORDER_WIDTH : this.borderWidth; } get borderStyle() { const borderWidth = this.getBorderWidth(); const borderColor = this.isSelected ? SELECTION_BORDER_COLOR : FIGURE_BORDER_COLOR; return `border: ${borderWidth}px solid ${borderColor};`; } get wrapperStyle() { const { x, y, width, height } = this.props.figure; return cssPropertiesToCss({ left: `${x}px`, top: `${y}px`, width: `${width}px`, height: `${height}px`, "z-index": String(ComponentsImportance.Figure + (this.isSelected ? 1 : 0)), }); } getResizerPosition(resizer) { const anchorCenteringOffset = (ANCHOR_SIZE - ACTIVE_BORDER_WIDTH) / 2; let style = {}; if (resizer.includes("top")) { style.top = `${-anchorCenteringOffset}px`; } else if (resizer.includes("bottom")) { style.bottom = `${-anchorCenteringOffset}px`; } else { style.bottom = `calc(50% - ${anchorCenteringOffset}px)`; } if (resizer.includes("left")) { style.left = `${-anchorCenteringOffset}px`; } else if (resizer.includes("right")) { style.right = `${-anchorCenteringOffset}px`; } else { style.right = `calc(50% - ${anchorCenteringOffset}px)`; } return cssPropertiesToCss(style); } setup() { const borderWidth = figureRegistry.get(this.props.figure.tag).borderWidth; this.borderWidth = borderWidth !== undefined ? borderWidth : BORDER_WIDTH; useEffect((selectedFigureId, thisFigureId, el) => { if (selectedFigureId === thisFigureId) { /** Scrolling on a newly inserted figure that overflows outside the viewport * will break the whole layout. * NOTE: `preventScroll`does not work on mobile but then again, * mobile is not really supported ATM. * * TODO: When implementing proper mobile, we will need to scroll the viewport * correctly (and render?) before focusing the element. */ el?.focus({ preventScroll: true }); } }, () => [this.env.model.getters.getSelectedFigureId(), this.props.figure.id, this.figureRef.el]); onWillUnmount(() => { this.props.onFigureDeleted(); }); } clickAnchor(dirX, dirY, ev) { this.props.onClickAnchor(dirX, dirY, ev); } onMouseDown(ev) { this.props.onMouseDown(ev); } onKeyDown(ev) { const figure = this.props.figure; const keyDownShortcut = keyboardEventToShortcutString(ev); switch (keyDownShortcut) { case "Delete": case "Backspace": this.env.model.dispatch("DELETE_FIGURE", { sheetId: this.env.model.getters.getActiveSheetId(), id: figure.id, }); this.props.onFigureDeleted(); ev.preventDefault(); ev.stopPropagation(); break; case "ArrowDown": case "ArrowLeft": case "ArrowRight": case "ArrowUp": const deltaMap = { ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], }; const delta = deltaMap[ev.key]; this.env.model.dispatch("UPDATE_FIGURE", { sheetId: this.env.model.getters.getActiveSheetId(), id: figure.id, x: figure.x + delta[0], y: figure.y + delta[1], }); ev.preventDefault(); ev.stopPropagation(); break; case "Ctrl+A": // Maybe in the future we will implement a way to select all figures ev.preventDefault(); ev.stopPropagation(); break; case "Ctrl+Y": case "Ctrl+Z": if (keyDownShortcut === "Ctrl+Y") { this.env.model.dispatch("REQUEST_REDO"); } else if (keyDownShortcut === "Ctrl+Z") { this.env.model.dispatch("REQUEST_UNDO"); } ev.preventDefault(); ev.stopPropagation(); break; } } onContextMenu(ev) { if (this.env.isDashboard()) return; const position = { x: ev.clientX, y: ev.clientY, }; this.openContextMenu(position); } showMenu() { const { x, y, width } = this.menuButtonRect; const menuPosition = { x: x >= MENU_WIDTH ? x - MENU_WIDTH : x + width, y: y, }; this.openContextMenu(menuPosition); } openContextMenu(position) { this.menuState.isOpen = true; this.menuState.position = position; this.menuState.menuItems = figureRegistry .get(this.props.figure.tag) .menuBuilder(this.props.figure.id, this.props.onFigureDeleted, this.env); } } const ToggleGroupInteractiveContent = { CannotHideAllRows: _t("Cannot hide all the rows of a sheet."), CannotHideAllColumns: _t("Cannot hide all the columns of a sheet."), }; function interactiveToggleGroup(env, sheetId, dimension, start, end) { const group = env.model.getters.getHeaderGroup(sheetId, dimension, start, end); if (!group) { return; } const command = group.isFolded ? "UNFOLD_HEADER_GROUP" : "FOLD_HEADER_GROUP"; const result = env.model.dispatch(command, { sheetId, dimension, start: group.start, end: group.end, }); if (!result.isSuccessful) { if (result.isCancelledBecause("NotEnoughElements" /* CommandResult.NotEnoughElements */)) { const errorMessage = dimension === "ROW" ? ToggleGroupInteractiveContent.CannotHideAllRows : ToggleGroupInteractiveContent.CannotHideAllColumns; env.raiseError(errorMessage); } } } function createHeaderGroupContainerContextMenu(sheetId, dimension) { return createActions([ { id: "unfold_all", name: dimension === "ROW" ? _t("Expand all row groups") : _t("Expand all column groups"), execute: (env) => { env.model.dispatch("UNFOLD_ALL_HEADER_GROUPS", { sheetId, dimension }); }, }, { id: "fold_all", name: dimension === "ROW" ? _t("Collapse all row groups") : _t("Collapse all column groups"), execute: (env) => { env.model.dispatch("FOLD_ALL_HEADER_GROUPS", { sheetId, dimension }); }, }, ]); } function getHeaderGroupContextMenu(sheetId, dimension, start, end) { const groupActions = createActions([ { id: "toggle_group", name: (env) => { const sheetId = env.model.getters.getActiveSheetId(); const groupIsFolded = env.model.getters.isGroupFolded(sheetId, dimension, start, end); if (groupIsFolded) { return dimension === "ROW" ? _t("Expand row group") : _t("Expand column group"); } else { return dimension === "ROW" ? _t("Collapse row group") : _t("Collapse column group"); } }, execute: (env) => { const sheetId = env.model.getters.getActiveSheetId(); interactiveToggleGroup(env, sheetId, dimension, start, end); }, }, { id: "remove_group", name: dimension === "ROW" ? _t("Remove row group") : _t("Remove column group"), execute: (env) => { const sheetId = env.model.getters.getActiveSheetId(); env.model.dispatch("UNGROUP_HEADERS", { sheetId, dimension, start, end }); }, separator: true, }, ]); return [...groupActions, ...createHeaderGroupContainerContextMenu(sheetId, dimension)]; } const groupHeadersMenuRegistry = new MenuItemRegistry(); groupHeadersMenuRegistry .add("group_columns", { sequence: 10, ...groupColumns, isVisible: () => true, isEnabled: groupColumns.isVisible, }) .add("group_rows", { sequence: 20, ...groupRows, isVisible: () => true, isEnabled: groupRows.isVisible, }); const unGroupHeadersMenuRegistry = new MenuItemRegistry(); unGroupHeadersMenuRegistry .add("ungroup_columns", { sequence: 10, ...ungroupColumns, isEnabled: (env) => canUngroupHeaders(env, "COL"), }) .add("ungroup_rows", { sequence: 20, ...ungroupRows, isEnabled: (env) => canUngroupHeaders(env, "ROW"), }); class ArrayFormulaHighlight extends SpreadsheetStore { highlightStore = this.get(HighlightStore); constructor(get) { super(get); this.highlightStore.register(this); } get highlights() { let zone; const position = this.model.getters.getActivePosition(); const cell = this.getters.getEvaluatedCell(position); const spreader = this.model.getters.getArrayFormulaSpreadingOn(position); zone = spreader ? this.model.getters.getSpreadZone(spreader, { ignoreSpillError: true }) : this.model.getters.getSpreadZone(position, { ignoreSpillError: true }); if (!zone) { return []; } return [ { sheetId: position.sheetId, zone, dashed: cell.value === CellErrorType.SpilledBlocked, color: "#17A2B8", noFill: true, thinLine: true, }, ]; } } // ----------------------------------------------------------------------------- // Autofill // ----------------------------------------------------------------------------- css /* scss */ ` .o-autofill { position: absolute; height: ${AUTOFILL_EDGE_LENGTH}px; width: ${AUTOFILL_EDGE_LENGTH}px; border: 1px solid white; box-sizing: border-box !important; background-color: #1a73e8; } .o-autofill-handler { position: absolute; height: ${AUTOFILL_EDGE_LENGTH}px; width: ${AUTOFILL_EDGE_LENGTH}px; &:hover { cursor: crosshair; } } .o-autofill-nextvalue { position: absolute; background-color: #ffffff; border: 1px solid black; padding: 5px; font-size: 12px; pointer-events: none; white-space: nowrap; } `; class Autofill extends Component { static template = "o-spreadsheet-Autofill"; static props = { position: Object, isVisible: Boolean, }; state = useState({ position: { left: 0, top: 0 }, handler: false, }); get style() { const { left, top } = this.props.position; return cssPropertiesToCss({ top: `${top}px`, left: `${left}px`, visibility: this.props.isVisible ? "visible" : "hidden", }); } get handlerStyle() { const { left, top } = this.state.handler ? this.state.position : this.props.position; return cssPropertiesToCss({ top: `${top}px`, left: `${left}px`, }); } get styleNextValue() { const { left, top } = this.state.position; return cssPropertiesToCss({ top: `${top + 5}px`, left: `${left + 15}px`, }); } getTooltip() { const tooltip = this.env.model.getters.getAutofillTooltip(); if (tooltip && !tooltip.component) { tooltip.component = TooltipComponent; } return tooltip; } onMouseDown(ev) { this.state.handler = true; let lastCol; let lastRow; const start = { left: ev.clientX - this.props.position.left, top: ev.clientY - this.props.position.top, }; const onMouseUp = () => { this.state.handler = false; this.state.position = { ...this.props.position }; this.env.model.dispatch("AUTOFILL"); }; const onMouseMove = (col, row, ev) => { this.state.position = { left: ev.clientX - start.left, top: ev.clientY - start.top, }; if (lastCol !== col || lastRow !== row) { const activeSheetId = this.env.model.getters.getActiveSheetId(); const numberOfCols = this.env.model.getters.getNumberCols(activeSheetId); const numberOfRows = this.env.model.getters.getNumberRows(activeSheetId); lastCol = col === -1 ? lastCol : clip(col, 0, numberOfCols); lastRow = row === -1 ? lastRow : clip(row, 0, numberOfRows); if (lastCol !== undefined && lastRow !== undefined) { this.env.model.dispatch("AUTOFILL_SELECT", { col: lastCol, row: lastRow }); } } }; dragAndDropBeyondTheViewport(this.env, onMouseMove, onMouseUp); } onDblClick() { this.env.model.dispatch("AUTOFILL_AUTO"); } } class TooltipComponent extends Component { static props = { content: String, }; static template = xml /* xml */ `

`; } css /* scss */ ` .o-client-tag { position: absolute; border-top-left-radius: 4px; border-top-right-radius: 4px; font-size: ${DEFAULT_FONT_SIZE}; color: white; pointer-events: none; } `; class ClientTag extends Component { static template = "o-spreadsheet-ClientTag"; static props = { active: Boolean, name: String, color: String, col: Number, row: Number, }; get tagStyle() { const { col, row, color } = this.props; const { height } = this.env.model.getters.getSheetViewDimensionWithHeaders(); const { x, y } = this.env.model.getters.getVisibleRect({ left: col, top: row, right: col, bottom: row, }); return cssPropertiesToCss({ bottom: `${height - y + 15}px`, left: `${x - 1}px`, border: `1px solid ${color}`, "background-color": color, }); } } const CELL_DELETED_MESSAGE = _t("The cell you are trying to edit has been deleted."); class CellComposerStore extends AbstractComposerStore { canStopEdition() { if (this.editionMode === "inactive") { return true; } return this.checkDataValidation(); } stopEdition(direction) { const canStopEdition = this.canStopEdition(); if (canStopEdition) { this._stopEdition(); if (direction) { this.model.selection.moveAnchorCell(direction, 1); } return; } const editedCell = this.currentEditedCell; const cellXc = toXC(editedCell.col, editedCell.row); const rule = this.getters.getValidationRuleForCell(editedCell); if (!rule) { return; } const evaluator = dataValidationEvaluatorRegistry.get(rule.criterion.type); const errorStr = evaluator.getErrorString(rule.criterion, this.getters, editedCell.sheetId); this.notificationStore.raiseError(_t("The data you entered in %s violates the data validation rule set on the cell:\n%s", cellXc, errorStr)); this.cancelEdition(); } handle(cmd) { super.handle(cmd); switch (cmd.type) { case "SET_FORMATTING": this.cancelEdition(); break; case "ADD_COLUMNS_ROWS": this.onAddElements(cmd); break; case "REMOVE_COLUMNS_ROWS": if (cmd.dimension === "COL") { this.onColumnsRemoved(cmd); } else { this.onRowsRemoved(cmd); } break; case "ACTIVATE_SHEET": if (!this._currentContent.startsWith("=")) { this._cancelEdition(); this.resetContent(); } if (cmd.sheetIdFrom !== cmd.sheetIdTo) { const activePosition = this.getters.getActivePosition(); const { col, row } = this.getters.getNextVisibleCellPosition({ sheetId: cmd.sheetIdTo, col: activePosition.col, row: activePosition.row, }); const zone = this.getters.expandZone(cmd.sheetIdTo, positionToZone({ col, row })); this.model.selection.resetAnchor(this, { cell: { col, row }, zone }); } break; case "DELETE_SHEET": case "UNDO": case "REDO": const sheetIdExists = !!this.getters.tryGetSheet(this.sheetId); if (!sheetIdExists && this.editionMode !== "inactive") { this.sheetId = this.getters.getActiveSheetId(); this.cancelEditionAndActivateSheet(); this.resetContent(); this.notificationStore.raiseError(CELL_DELETED_MESSAGE); } break; } } // --------------------------------------------------------------------------- // Getters // --------------------------------------------------------------------------- get placeholder() { const position = this.getters.getActivePosition(); const spreader = this.model.getters.getArrayFormulaSpreadingOn(position); if (!spreader) { return undefined; } const cell = this.getters.getCell(spreader); return cell?.content; } get currentEditedCell() { return { sheetId: this.sheetId, col: this.col, row: this.row, }; } onColumnsRemoved(cmd) { if (cmd.elements.includes(this.col) && this.editionMode !== "inactive") { this.cancelEdition(); this.notificationStore.raiseError(CELL_DELETED_MESSAGE); return; } const { top, left } = updateSelectionOnDeletion({ left: this.col, right: this.col, top: this.row, bottom: this.row }, "left", [...cmd.elements]); this.col = left; this.row = top; } onRowsRemoved(cmd) { if (cmd.elements.includes(this.row) && this.editionMode !== "inactive") { this.cancelEdition(); this.notificationStore.raiseError(CELL_DELETED_MESSAGE); return; } const { top, left } = updateSelectionOnDeletion({ left: this.col, right: this.col, top: this.row, bottom: this.row }, "top", [...cmd.elements]); this.col = left; this.row = top; } onAddElements(cmd) { const { top, left } = updateSelectionOnInsertion({ left: this.col, right: this.col, top: this.row, bottom: this.row }, cmd.dimension === "COL" ? "left" : "top", cmd.base, cmd.position, cmd.quantity); this.col = left; this.row = top; } confirmEdition(content) { if (content) { const sheetId = this.getters.getActiveSheetId(); const cell = this.getters.getEvaluatedCell({ sheetId, col: this.col, row: this.row }); if (cell.link && !content.startsWith("=")) { content = markdownLink(content, cell.link.url); } this.addHeadersForSpreadingFormula(content); this.model.dispatch("UPDATE_CELL", { ...this.currentEditedCell, content, }); } else { this.model.dispatch("UPDATE_CELL", { ...this.currentEditedCell, content: "", }); } this.model.dispatch("AUTOFILL_TABLE_COLUMN", { ...this.currentEditedCell }); this.setContent(""); } getComposerContent(position) { const locale = this.getters.getLocale(); const cell = this.getters.getCell(position); if (cell?.isFormula) { return localizeFormula(cell.content, locale); } const spreader = this.model.getters.getArrayFormulaSpreadingOn(position); if (spreader) { return ""; } const { format, value, type, formattedValue } = this.getters.getEvaluatedCell(position); switch (type) { case CellValueType.empty: return ""; case CellValueType.text: case CellValueType.error: return value; case CellValueType.boolean: return formattedValue; case CellValueType.number: if (format && isDateTimeFormat(format)) { if (parseDateTime(formattedValue, locale) !== null) { // formatted string can be parsed again return formattedValue; } // display a simplified and parsable string otherwise const timeFormat = Number.isInteger(value) ? locale.dateFormat : getDateTimeFormat(locale); return formatValue(value, { locale, format: timeFormat }); } return this.numberComposerContent(value, format, locale); } } numberComposerContent(value, format, locale) { if (format?.includes("%")) { return `${numberToString(value * 100, locale.decimalSeparator)}%`; } return numberToString(value, locale.decimalSeparator); } /** Add headers at the end of the sheet so the formula in the composer has enough space to spread */ addHeadersForSpreadingFormula(content) { if (!content.startsWith("=")) { return; } const evaluated = this.getters.evaluateFormula(this.sheetId, content); if (!isMatrix(evaluated)) { return; } const numberOfRows = this.getters.getNumberRows(this.sheetId); const numberOfCols = this.getters.getNumberCols(this.sheetId); const missingRows = this.row + evaluated[0].length - numberOfRows; const missingCols = this.col + evaluated.length - numberOfCols; if (missingCols > 0) { this.model.dispatch("ADD_COLUMNS_ROWS", { sheetId: this.sheetId, dimension: "COL", base: numberOfCols - 1, position: "after", quantity: missingCols + 20, }); } if (missingRows > 0) { this.model.dispatch("ADD_COLUMNS_ROWS", { sheetId: this.sheetId, dimension: "ROW", base: numberOfRows - 1, position: "after", quantity: missingRows + 50, }); } } checkDataValidation() { const cellPosition = { sheetId: this.sheetId, col: this.col, row: this.row }; const content = this.getCurrentCanonicalContent(); const cellValue = content.startsWith("=") ? this.getters.evaluateFormula(this.sheetId, content) : parseLiteral(content, this.getters.getLocale()); if (isMatrix(cellValue)) { return true; } const validationResult = this.getters.getValidationResultForCellValue(cellValue, cellPosition); if (!validationResult.isValid && validationResult.rule.isBlocking) { return false; } return true; } } const COMPOSER_BORDER_WIDTH = 3 * 0.4 * window.devicePixelRatio || 1; const GRID_CELL_REFERENCE_TOP_OFFSET = 28; css /* scss */ ` div.o-grid-composer { z-index: ${ComponentsImportance.GridComposer}; box-sizing: border-box; position: absolute; border: ${COMPOSER_BORDER_WIDTH}px solid ${SELECTION_BORDER_COLOR}; font-family: ${DEFAULT_FONT}; display: flex; align-items: center; } div.o-cell-reference { position: absolute; z-index: ${ComponentsImportance.GridComposer}; background: ${SELECTION_BORDER_COLOR}; color: white; font-size: 12px; line-height: 14px; padding: 6px 7px; border-radius: 4px; } `; /** * This component is a composer which positions itself on the grid at the anchor cell. * It also applies the style of the cell to the composer input. */ class GridComposer extends Component { static template = "o-spreadsheet-GridComposer"; static props = { gridDims: Object, onInputContextMenu: Function, }; static components = { Composer }; rect = this.defaultRect; isEditing = false; isCellReferenceVisible = false; composerStore; composerFocusStore; composerInterface; get defaultRect() { return { x: 0, y: 0, width: 0, height: 0 }; } setup() { const composerStore = useStore(CellComposerStore); this.composerStore = composerStore; this.composerFocusStore = useStore(ComposerFocusStore); this.composerInterface = { id: "gridComposer", get editionMode() { return composerStore.editionMode; }, startEdition: this.composerStore.startEdition, setCurrentContent: this.composerStore.setCurrentContent, stopEdition: this.composerStore.stopEdition, }; this.composerFocusStore.focusComposer(this.composerInterface, { focusMode: "inactive" }); onWillUpdateProps(() => { this.updateComponentPosition(); this.updateCellReferenceVisibility(); }); } get shouldDisplayCellReference() { return this.isCellReferenceVisible; } get cellReference() { const { col, row, sheetId } = this.composerStore.currentEditedCell; const prefixSheet = sheetId !== this.env.model.getters.getActiveSheetId(); return getFullReference(prefixSheet ? this.env.model.getters.getSheetName(sheetId) : undefined, toXC(col, row)); } get cellReferenceStyle() { const { x: left, y: top } = this.rect; return cssPropertiesToCss({ left: `${left - COMPOSER_BORDER_WIDTH}px`, top: `${top - GRID_CELL_REFERENCE_TOP_OFFSET}px`, }); } get focus() { const focus = this.composerFocusStore.activeComposer === this.composerInterface ? this.composerFocusStore.focusMode : "inactive"; return focus; } get composerProps() { const { width, height } = this.env.model.getters.getSheetViewDimensionWithHeaders(); return { rect: { ...this.rect }, delimitation: { width, height, }, focus: this.focus, isDefaultFocus: true, onComposerContentFocused: () => this.composerFocusStore.focusComposer(this.composerInterface, { focusMode: "contentFocus", }), onComposerCellFocused: (content) => this.composerFocusStore.focusComposer(this.composerInterface, { focusMode: "cellFocus", content, }), onInputContextMenu: this.props.onInputContextMenu, composerStore: this.composerStore, }; } get containerStyle() { if (this.composerStore.editionMode === "inactive") { return `z-index: -1000;`; } const isFormula = this.composerStore.currentContent.startsWith("="); const cell = this.env.model.getters.getActiveCell(); const position = this.env.model.getters.getActivePosition(); const style = this.env.model.getters.getCellComputedStyle(position); // position style const { x: left, y: top, width, height } = this.rect; // color style const background = (!isFormula && style.fillColor) || "#ffffff"; const color = (!isFormula && style.textColor) || "#000000"; // font style const fontSize = (!isFormula && style.fontSize) || 10; const fontWeight = !isFormula && style.bold ? "bold" : undefined; const fontStyle = !isFormula && style.italic ? "italic" : "normal"; const textDecoration = !isFormula ? getTextDecoration(style) : "none"; // align style let textAlign = "left"; if (!isFormula) { textAlign = style.align || cell.defaultAlign; } const maxHeight = this.props.gridDims.height - this.rect.y; const maxWidth = this.props.gridDims.width - this.rect.x; /** * min-size is on the container, not the composer element, because we want to have the same size as the cell by default, * including all the paddings/margins of the composer * * The +-1 are there to include cell borders in the composer sizing/positioning */ return cssPropertiesToCss({ left: `${left - 1}px`, top: `${top}px`, "min-width": `${width + 1}px`, "min-height": `${height + 1}px`, "max-width": `${maxWidth}px`, "max-height": `${maxHeight}px`, background, color, "font-size": `${fontSizeInPixels(fontSize)}px`, "font-weight": fontWeight, "font-style": fontStyle, "text-decoration": textDecoration, "text-align": textAlign, }); } updateComponentPosition() { const isEditing = this.composerFocusStore.activeComposer.editionMode !== "inactive"; if (!isEditing && this.composerFocusStore.activeComposer !== this.composerInterface) { this.composerFocusStore.focusComposer(this.composerInterface, { focusMode: "inactive" }); } if (this.isEditing !== isEditing) { this.isEditing = isEditing; if (!isEditing) { this.rect = this.defaultRect; return; } const position = this.env.model.getters.getActivePosition(); const zone = this.env.model.getters.expandZone(position.sheetId, positionToZone(position)); this.rect = this.env.model.getters.getVisibleRect(zone); } } updateCellReferenceVisibility() { if (this.composerStore.editionMode === "inactive") { this.isCellReferenceVisible = false; return; } if (this.isCellReferenceVisible) { return; } const sheetId = this.env.model.getters.getActiveSheetId(); const zone = this.env.model.getters.getSelectedZone(); const rect = this.env.model.getters.getVisibleRect(zone); if (!deepEquals(rect, this.rect) || sheetId !== this.composerStore.currentEditedCell.sheetId) { this.isCellReferenceVisible = true; } } onFocus() { this.composerFocusStore.focusComposer(this.composerInterface, { focusMode: "contentFocus" }); } } css /* scss */ ` .o-grid-cell-icon { width: ${GRID_ICON_EDGE_LENGTH}px; height: ${GRID_ICON_EDGE_LENGTH}px; } `; class GridCellIcon extends Component { static template = "o-spreadsheet-GridCellIcon"; static props = { cellPosition: Object, horizontalAlign: { type: String, optional: true }, verticalAlign: { type: String, optional: true }, slots: Object, }; get iconStyle() { const cellPosition = this.props.cellPosition; const merge = this.env.model.getters.getMerge(cellPosition); const zone = merge || positionToZone(cellPosition); const rect = this.env.model.getters.getVisibleRectWithoutHeaders(zone); const x = this.getIconHorizontalPosition(rect, cellPosition); const y = this.getIconVerticalPosition(rect, cellPosition); return cssPropertiesToCss({ top: `${y}px`, left: `${x}px`, }); } getIconVerticalPosition(rect, cellPosition) { const start = rect.y; const end = rect.y + rect.height; const cell = this.env.model.getters.getCell(cellPosition); const align = this.props.verticalAlign || cell?.style?.verticalAlign || DEFAULT_VERTICAL_ALIGN; switch (align) { case "bottom": return end - GRID_ICON_MARGIN - GRID_ICON_EDGE_LENGTH; case "top": return start + GRID_ICON_MARGIN; default: const centeringOffset = Math.floor((end - start - GRID_ICON_EDGE_LENGTH) / 2); return end - GRID_ICON_EDGE_LENGTH - centeringOffset; } } getIconHorizontalPosition(rect, cellPosition) { const start = rect.x; const end = rect.x + rect.width; const cell = this.env.model.getters.getCell(cellPosition); const evaluatedCell = this.env.model.getters.getEvaluatedCell(cellPosition); const align = this.props.horizontalAlign || cell?.style?.align || evaluatedCell.defaultAlign; switch (align) { case "right": return end - GRID_ICON_MARGIN - GRID_ICON_EDGE_LENGTH; case "left": return start + GRID_ICON_MARGIN; default: const centeringOffset = Math.floor((end - start - GRID_ICON_EDGE_LENGTH) / 2); return end - GRID_ICON_EDGE_LENGTH - centeringOffset; } } isPositionVisible(position) { const rect = this.env.model.getters.getVisibleRect(positionToZone(position)); return !(rect.width === 0 || rect.height === 0); } } const MARGIN = (GRID_ICON_EDGE_LENGTH - CHECKBOX_WIDTH) / 2; css /* scss */ ` .o-dv-checkbox { box-sizing: border-box !important; accent-color: #808080; margin: ${MARGIN}px; /** required to prevent the checkbox position to be sensible to the font-size (affects Firefox) */ position: absolute; } `; class DataValidationCheckbox extends Component { static template = "o-spreadsheet-DataValidationCheckbox"; static components = { Checkbox, }; static props = { cellPosition: Object, }; onCheckboxChange(value) { const { sheetId, col, row } = this.props.cellPosition; const cellContent = value ? "TRUE" : "FALSE"; this.env.model.dispatch("UPDATE_CELL", { sheetId, col, row, content: cellContent }); } get checkBoxValue() { return !!this.env.model.getters.getEvaluatedCell(this.props.cellPosition).value; } get isDisabled() { const cell = this.env.model.getters.getCell(this.props.cellPosition); return this.env.model.getters.isReadonly() || !!cell?.isFormula; } } const ICON_WIDTH = 13; css /* scss */ ` .o-dv-list-icon { color: ${TEXT_BODY_MUTED}; border-radius: 1px; height: ${GRID_ICON_EDGE_LENGTH}px; width: ${GRID_ICON_EDGE_LENGTH}px; &:hover { color: #ffffff; background-color: ${TEXT_BODY_MUTED}; } svg { width: ${ICON_WIDTH}px; height: ${ICON_WIDTH}px; } } `; class DataValidationListIcon extends Component { static template = "o-spreadsheet-DataValidationListIcon"; static props = { cellPosition: Object, }; onClick() { const { col, row } = this.props.cellPosition; this.env.model.selection.selectCell(col, row); this.env.startCellEdition(); } } class DataValidationOverlay extends Component { static template = "o-spreadsheet-DataValidationOverlay"; static props = {}; static components = { GridCellIcon, DataValidationCheckbox, DataValidationListIcon }; get checkBoxCellPositions() { return this.env.model.getters .getVisibleCellPositions() .filter((position) => this.env.model.getters.isCellValidCheckbox(position) && !this.env.model.getters.isFilterHeader(position)); } get listIconsCellPositions() { if (this.env.model.getters.isReadonly()) { return []; } return this.env.model.getters .getVisibleCellPositions() .filter((position) => this.env.model.getters.cellHasListDataValidationIcon(position) && !this.env.model.getters.isFilterHeader(position)); } } /** * Transform a figure with coordinates from the model, to coordinates as they are shown on the screen, * taking into account the scroll position of the active sheet and the frozen panes. */ function internalFigureToScreen(getters, fig) { return { ...fig, ...internalToScreenCoordinates(getters, { x: fig.x, y: fig.y }) }; } /** * Transform a figure with coordinates as they are shown on the screen, to coordinates as they are in the model, * taking into account the scroll position of the active sheet and the frozen panes. * * Note that this isn't exactly the reverse operation as internalFigureToScreen, because the figure will always be on top * of the frozen panes. */ function screenFigureToInternal(getters, fig) { return { ...fig, ...screenCoordinatesToInternal(getters, { x: fig.x, y: fig.y }) }; } function internalToScreenCoordinates(getters, { x, y }) { const { x: viewportX, y: viewportY } = getters.getMainViewportCoordinates(); const { scrollX, scrollY } = getters.getActiveSheetScrollInfo(); x = x < viewportX ? x : x - scrollX; y = y < viewportY ? y : y - scrollY; return { x, y }; } function screenCoordinatesToInternal(getters, { x, y }) { const { x: viewportX, y: viewportY } = getters.getMainViewportCoordinates(); const { scrollX, scrollY } = getters.getActiveSheetScrollInfo(); x = viewportX && x < viewportX ? x : x + scrollX; y = viewportY && y < viewportY ? y : y + scrollY; return { x, y }; } function dragFigureForMove({ x: mouseX, y: mouseY }, { x: mouseInitialX, y: mouseInitialY }, initialFigure, { x: viewportX, y: viewportY }, { maxX, maxY }, { scrollX, scrollY }) { const minX = viewportX ? 0 : -scrollX; const minY = viewportY ? 0 : -scrollY; const deltaX = mouseX - mouseInitialX; const newX = clip(initialFigure.x + deltaX, minX, maxX - initialFigure.width - scrollX); const deltaY = mouseY - mouseInitialY; const newY = clip(initialFigure.y + deltaY, minY, maxY - initialFigure.height - scrollY); return { ...initialFigure, x: newX, y: newY }; } function dragFigureForResize(initialFigure, dirX, dirY, { x: mouseX, y: mouseY }, { x: mouseInitialX, y: mouseInitialY }, keepRatio, minFigSize, { scrollX, scrollY }) { let { x, y, width, height } = initialFigure; if (keepRatio && dirX != 0 && dirY != 0) { const deltaX = Math.min(dirX * (mouseInitialX - mouseX), initialFigure.width - minFigSize); const deltaY = Math.min(dirY * (mouseInitialY - mouseY), initialFigure.height - minFigSize); const fraction = Math.min(deltaX / initialFigure.width, deltaY / initialFigure.height); width = initialFigure.width * (1 - fraction); height = initialFigure.height * (1 - fraction); if (dirX < 0) { x = initialFigure.x + initialFigure.width * fraction; } if (dirY < 0) { y = initialFigure.y + initialFigure.height * fraction; } } else { const deltaX = Math.max(dirX * (mouseX - mouseInitialX), minFigSize - initialFigure.width); const deltaY = Math.max(dirY * (mouseY - mouseInitialY), minFigSize - initialFigure.height); width = initialFigure.width + deltaX; height = initialFigure.height + deltaY; if (dirX < 0) { x = initialFigure.x - deltaX; } if (dirY < 0) { y = initialFigure.y - deltaY; } } // Adjusts figure dimensions to ensure it remains within header boundaries and viewport during resizing. if (x + scrollX <= 0) { width = width + x + scrollX; x = -scrollX; } if (y + scrollY <= 0) { height = height + y + scrollY; y = -scrollY; } return { ...initialFigure, x, y, width, height }; } const SNAP_MARGIN = 5; /** * Try to snap the given figure to other figures when moving the figure, and return the snapped * figure and the possible snap lines, if any were found */ function snapForMove(getters, figureToSnap, otherFigures) { const snappedFigure = { ...figureToSnap }; const verticalSnapLine = getSnapLine(getters, snappedFigure, ["hCenter", "right", "left"], otherFigures, ["hCenter", "right", "left"]); const horizontalSnapLine = getSnapLine(getters, snappedFigure, ["vCenter", "bottom", "top"], otherFigures, ["vCenter", "bottom", "top"]); const { y: viewportY, x: viewportX } = getters.getMainViewportCoordinates(); const { scrollY, scrollX } = getters.getActiveSheetScrollInfo(); // If the snap cause the figure to change pane, we need to also apply the scroll as an offset if (horizontalSnapLine) { snappedFigure.y -= horizontalSnapLine.snapOffset; const isBaseFigFrozenY = figureToSnap.y < viewportY; const isSnappedFrozenY = snappedFigure.y < viewportY; if (isBaseFigFrozenY && !isSnappedFrozenY) snappedFigure.y += scrollY; else if (!isBaseFigFrozenY && isSnappedFrozenY) snappedFigure.y -= scrollY; } if (verticalSnapLine) { snappedFigure.x -= verticalSnapLine.snapOffset; const isBaseFigFrozenX = figureToSnap.x < viewportX; const isSnappedFrozenX = snappedFigure.x < viewportX; if (isBaseFigFrozenX && !isSnappedFrozenX) snappedFigure.x += scrollX; else if (!isBaseFigFrozenX && isSnappedFrozenX) snappedFigure.x -= scrollX; } return { snappedFigure, verticalSnapLine, horizontalSnapLine }; } /** * Try to snap the given figure to the other figures when resizing the figure, and return the snapped * figure and the possible snap lines, if any were found */ function snapForResize(getters, resizeDirX, resizeDirY, figureToSnap, otherFigures) { const snappedFigure = { ...figureToSnap }; // Vertical snap line const verticalSnapLine = getSnapLine(getters, snappedFigure, [resizeDirX === -1 ? "left" : "right"], otherFigures, ["right", "left"]); if (verticalSnapLine) { if (resizeDirX === 1) { snappedFigure.width -= verticalSnapLine.snapOffset; } else if (resizeDirX === -1) { snappedFigure.x -= verticalSnapLine.snapOffset; snappedFigure.width += verticalSnapLine.snapOffset; } } // Horizontal snap line const horizontalSnapLine = getSnapLine(getters, snappedFigure, [resizeDirY === -1 ? "top" : "bottom"], otherFigures, ["bottom", "top"]); if (horizontalSnapLine) { if (resizeDirY === 1) { snappedFigure.height -= horizontalSnapLine.snapOffset; } else if (resizeDirY === -1) { snappedFigure.y -= horizontalSnapLine.snapOffset; snappedFigure.height += horizontalSnapLine.snapOffset; } } snappedFigure.x = Math.round(snappedFigure.x); snappedFigure.y = Math.round(snappedFigure.y); snappedFigure.height = Math.round(snappedFigure.height); snappedFigure.width = Math.round(snappedFigure.width); return { snappedFigure, verticalSnapLine, horizontalSnapLine }; } /** * Get the position of snap axes for the given figure * * @param figure the figure * @param axesTypes the list of axis types to return the positions of */ function getVisibleAxes(getters, figure, axesTypes) { const axes = axesTypes.map((axisType) => getAxis(figure, axisType)); return axes .filter((axis) => isAxisVisible(getters, figure, axis)) .map((axis) => getAxisScreenPosition(getters, figure, axis)); } /** * We need two positions for the figure axis : * - the position (core) of the axis in the figure. This is used to know whether or not the axis is * displayed, or is hidden by the scroll/the frozen panes * - the position in the screen, which is used to find snap matches. We cannot use the core position for this, * because figures partially in frozen panes aren't displayed at their actual coordinates */ function getAxisScreenPosition(getters, figure, figureAxis) { const screenFigure = internalFigureToScreen(getters, figure); return getAxis(screenFigure, figureAxis.axisType); } function isAxisVisible(getters, figure, axis) { const { x: mainViewportX, y: mainViewportY } = getters.getMainViewportCoordinates(); const axisStartEndPositions = []; switch (axis.axisType) { case "top": case "bottom": case "vCenter": if (figure.y < mainViewportY) return true; axisStartEndPositions.push({ x: figure.x, y: axis.position }); axisStartEndPositions.push({ x: figure.x + figure.width, y: axis.position }); break; case "left": case "right": case "hCenter": if (figure.x < mainViewportX) return true; axisStartEndPositions.push({ x: axis.position, y: figure.y }); axisStartEndPositions.push({ x: axis.position, y: figure.y + figure.height }); break; } return axisStartEndPositions.some(getters.isPositionVisible); } /** * Get a snap line for the given figure, if the figure can snap to any other figure * * @param figureToSnap figure to get the snap line for * @param figAxesTypes figure axes of the given figure to be considered to find a snap line * @param otherFigures figures to match against the snapped figure to find a snap line * @param otherAxesTypes figure axes of the other figures to be considered to find a snap line */ function getSnapLine(getters, figureToSnap, figAxesTypes, otherFigures, otherAxesTypes) { const axesOfFigure = getVisibleAxes(getters, figureToSnap, figAxesTypes); let closestMatch = undefined; for (const otherFigure of otherFigures) { const axesOfOtherFig = getVisibleAxes(getters, otherFigure, otherAxesTypes); for (const axisOfFigure of axesOfFigure) { for (const axisOfOtherFig of axesOfOtherFig) { if (!canSnap(axisOfFigure.position, axisOfOtherFig.position)) continue; const snapOffset = axisOfFigure.position - axisOfOtherFig.position; if (closestMatch && snapOffset === closestMatch.snapOffset) { closestMatch.matchedFigIds.push(otherFigure.id); } else if (!closestMatch || Math.abs(snapOffset) <= Math.abs(closestMatch.snapOffset)) { closestMatch = { matchedFigIds: [otherFigure.id], snapOffset, snappedAxisType: axisOfFigure.axisType, position: axisOfOtherFig.position, }; } } } } return closestMatch; } /** Check if two axes are close enough to snap */ function canSnap(axisPosition1, axisPosition2) { return Math.abs(axisPosition1 - axisPosition2) <= SNAP_MARGIN; } function getAxis(fig, axisType) { let position = 0; switch (axisType) { case "top": position = fig.y; break; case "bottom": position = fig.y + fig.height - FIGURE_BORDER_WIDTH; break; case "vCenter": position = fig.y + Math.floor(fig.height / 2) - FIGURE_BORDER_WIDTH; break; case "left": position = fig.x; break; case "right": position = fig.x + fig.width - FIGURE_BORDER_WIDTH; break; case "hCenter": position = fig.x + Math.floor(fig.width / 2) - FIGURE_BORDER_WIDTH; break; } return { position, axisType: axisType }; } css /*SCSS*/ ` .o-figure-snap-line { position: relative; z-index: ${ComponentsImportance.FigureSnapLine}; &.vertical { width: 0px; border-left: 1px dashed black; } &.horizontal { border-top: 1px dashed black; height: 0px; } } .o-figure-container { -webkit-user-select: none; // safari user-select: none; } `; /** * Each figure ⭐ is positioned inside a container `div` placed and sized * according to the split pane the figure is part of, or a separate container for the figure * currently drag & dropped. Any part of the figure outside of the container is hidden * thanks to its `overflow: hidden` property. * * Additionally, the figure is placed inside a "inverse viewport" `div` 🟥. * Its position represents the viewport position in the grid: its top/left * corner represents the top/left corner of the grid. * * It allows to position the figure inside this div regardless of the * (possibly freezed) viewports and the scrolling position. * * --: container limits * 🟥: inverse viewport * ⭐: figure top/left position * * container * ↓ * |🟥-------------------------------------------- * | \ | * | \ | * | \ | * | \ visible area | no scroll * | ⭐ | * | | * | | * ----------------------------------------------- * * the scrolling of the pane is applied as an inverse offset * to the div which will in turn move the figure up and down * inside the container. * Hence, once the figure position is (resp. partly) out of * the container dimensions, it will be (resp. partly) hidden. * * The same reasoning applies to the horizontal axis. * * 🟥 ························ * \ ↑ * \ | * \ | inverse viewport = -1 * scroll of pane * \ | * ⭐ <- not visible | * ↓ * ----------------------------------------------- * | | * | | * | | * | visible area | * | | * | | * | | * ----------------------------------------------- * * In the case the d&d figure container, the container is the same as the "topLeft" container for * frozen pane (unaffected by scroll and always visible). The figure coordinates are transformed * for this container at the start of the d&d, and transformed back at the end to adapt to the scroll * that occurred during the drag & drop, and to position the figure on the correct pane. * */ class FiguresContainer extends Component { static template = "o-spreadsheet-FiguresContainer"; static props = { onFigureDeleted: Function, }; static components = { FigureComponent }; dnd = useState({ draggedFigure: undefined, horizontalSnap: undefined, verticalSnap: undefined, cancelDnd: undefined, }); setup() { onMounted(() => { // horrible, but necessary // the following line ensures that we render the figures with the correct // viewport. The reason is that whenever we initialize the grid // component, we do not know yet the actual size of the viewport, so the // first owl rendering is done with an empty viewport. Only then we can // compute which figures should be displayed, so we have to force a // new rendering this.render(); }); onWillUpdateProps(() => { const sheetId = this.env.model.getters.getActiveSheetId(); const draggedFigureId = this.dnd.draggedFigure?.id; if (draggedFigureId && !this.env.model.getters.getFigure(sheetId, draggedFigureId)) { if (this.dnd.cancelDnd) { this.dnd.cancelDnd(); } this.dnd.draggedFigure = undefined; this.dnd.horizontalSnap = undefined; this.dnd.verticalSnap = undefined; this.dnd.cancelDnd = undefined; } }); } getVisibleFigures() { const visibleFigures = this.env.model.getters.getVisibleFigures(); if (this.dnd.draggedFigure && !visibleFigures.some((figure) => figure.id === this.dnd.draggedFigure?.id)) { const draggedFigure = this.env.model.getters.getFigure(this.env.model.getters.getActiveSheetId(), this.dnd.draggedFigure?.id); if (draggedFigure) { visibleFigures.push(draggedFigure); } } return visibleFigures; } get containers() { const visibleFigures = this.getVisibleFigures(); const containers = []; for (const containerType of [ "topLeft", "topRight", "bottomLeft", "bottomRight", ]) { const containerFigures = visibleFigures.filter((figure) => this.getFigureContainer(figure) === containerType); if (containerFigures.length > 0) { containers.push({ type: containerType, figures: containerFigures, style: this.getContainerStyle(containerType), inverseViewportStyle: this.getInverseViewportPositionStyle(containerType), }); } } if (this.dnd.draggedFigure) { containers.push({ type: "dnd", figures: [this.getDndFigure()], style: this.getContainerStyle("dnd"), inverseViewportStyle: this.getInverseViewportPositionStyle("dnd"), }); } return containers; } getContainerStyle(container) { return this.rectToCss(this.getContainerRect(container)); } rectToCss(rect) { return cssPropertiesToCss({ left: `${rect.x}px`, top: `${rect.y}px`, width: `${rect.width}px`, height: `${rect.height}px`, }); } getContainerRect(container) { const { width: viewWidth, height: viewHeight } = this.env.model.getters.getSheetViewDimension(); const { x: viewportX, y: viewportY } = this.env.model.getters.getMainViewportCoordinates(); const x = ["bottomRight", "topRight"].includes(container) ? viewportX : 0; const width = viewWidth - x; const y = ["bottomRight", "bottomLeft"].includes(container) ? viewportY : 0; const height = viewHeight - y; return { x, y, width, height }; } getInverseViewportPositionStyle(container) { const { scrollX, scrollY } = this.env.model.getters.getActiveSheetScrollInfo(); const { x: viewportX, y: viewportY } = this.env.model.getters.getMainViewportCoordinates(); const left = ["bottomRight", "topRight"].includes(container) ? -(viewportX + scrollX) : 0; const top = ["bottomRight", "bottomLeft"].includes(container) ? -(viewportY + scrollY) : 0; return cssPropertiesToCss({ left: `${left}px`, top: `${top}px`, }); } getFigureContainer(figure) { const { x: viewportX, y: viewportY } = this.env.model.getters.getMainViewportCoordinates(); if (figure.id === this.dnd.draggedFigure?.id) { return "dnd"; } else if (figure.x < viewportX && figure.y < viewportY) { return "topLeft"; } else if (figure.x < viewportX) { return "bottomLeft"; } else if (figure.y < viewportY) { return "topRight"; } else { return "bottomRight"; } } startDraggingFigure(figure, ev) { if (ev.button > 0 || this.env.model.getters.isReadonly()) { // not main button, probably a context menu and no d&d in readonly mode return; } const selectResult = this.env.model.dispatch("SELECT_FIGURE", { id: figure.id }); if (!selectResult.isSuccessful) { return; } const sheetId = this.env.model.getters.getActiveSheetId(); const initialMousePosition = { x: ev.clientX, y: ev.clientY }; const maxDimensions = { maxX: this.env.model.getters.getColDimensions(sheetId, this.env.model.getters.getNumberCols(sheetId) - 1).end, maxY: this.env.model.getters.getRowDimensions(sheetId, this.env.model.getters.getNumberRows(sheetId) - 1).end, }; const { x, y } = internalFigureToScreen(this.env.model.getters, figure); const initialFig = { ...figure, x, y }; const onMouseMove = (ev) => { const getters = this.env.model.getters; const currentMousePosition = { x: ev.clientX, y: ev.clientY }; const draggedFigure = dragFigureForMove(currentMousePosition, initialMousePosition, initialFig, this.env.model.getters.getMainViewportCoordinates(), maxDimensions, getters.getActiveSheetScrollInfo()); const otherFigures = this.getOtherFigures(figure.id); const internalDragged = screenFigureToInternal(getters, draggedFigure); const snapResult = snapForMove(getters, internalDragged, otherFigures); this.dnd.draggedFigure = internalFigureToScreen(getters, snapResult.snappedFigure); this.dnd.horizontalSnap = this.getSnap(snapResult.horizontalSnapLine); this.dnd.verticalSnap = this.getSnap(snapResult.verticalSnapLine); }; const onMouseUp = (ev) => { if (!this.dnd.draggedFigure) { return; } let { x, y } = screenFigureToInternal(this.env.model.getters, this.dnd.draggedFigure); this.dnd.draggedFigure = undefined; this.dnd.horizontalSnap = undefined; this.dnd.verticalSnap = undefined; this.env.model.dispatch("UPDATE_FIGURE", { sheetId, id: figure.id, x, y }); }; this.dnd.cancelDnd = startDnd(onMouseMove, onMouseUp); } /** * Initialize the resize of a figure with mouse movements * * @param dirX X direction of the resize. -1 : resize from the left border of the figure, 0 : no resize in X, 1 : * resize from the right border of the figure * @param dirY Y direction of the resize. -1 : resize from the top border of the figure, 0 : no resize in Y, 1 : * resize from the bottom border of the figure * @param ev Mouse Event */ startResize(figure, dirX, dirY, ev) { ev.stopPropagation(); const initialMousePosition = { x: ev.clientX, y: ev.clientY }; const { x, y } = internalFigureToScreen(this.env.model.getters, figure); const initialFig = { ...figure, x, y }; const keepRatio = figureRegistry.get(figure.tag).keepRatio || false; const minFigSize = figureRegistry.get(figure.tag).minFigSize || MIN_FIG_SIZE; const onMouseMove = (ev) => { const currentMousePosition = { x: ev.clientX, y: ev.clientY }; const draggedFigure = dragFigureForResize(initialFig, dirX, dirY, currentMousePosition, initialMousePosition, keepRatio, minFigSize, this.env.model.getters.getActiveSheetScrollInfo()); const otherFigures = this.getOtherFigures(figure.id); const snapResult = snapForResize(this.env.model.getters, dirX, dirY, draggedFigure, otherFigures); this.dnd.draggedFigure = snapResult.snappedFigure; this.dnd.horizontalSnap = this.getSnap(snapResult.horizontalSnapLine); this.dnd.verticalSnap = this.getSnap(snapResult.verticalSnapLine); }; const onMouseUp = (ev) => { if (!this.dnd.draggedFigure) { return; } let { x, y } = screenFigureToInternal(this.env.model.getters, this.dnd.draggedFigure); const update = { x, y }; if (dirX) { update.width = this.dnd.draggedFigure.width; } if (dirY) { update.height = this.dnd.draggedFigure.height; } this.env.model.dispatch("UPDATE_FIGURE", { sheetId: this.env.model.getters.getActiveSheetId(), id: figure.id, ...update, }); this.dnd.draggedFigure = undefined; this.dnd.horizontalSnap = undefined; this.dnd.verticalSnap = undefined; }; this.dnd.cancelDnd = startDnd(onMouseMove, onMouseUp); } getOtherFigures(figId) { return this.getVisibleFigures().filter((f) => f.id !== figId); } getDndFigure() { const figure = this.getVisibleFigures().find((fig) => fig.id === this.dnd.draggedFigure?.id); if (!figure) throw new Error("Dnd figure not found"); return { ...figure, ...this.dnd.draggedFigure, }; } getFigureStyle(figure) { if (figure.id !== this.dnd.draggedFigure?.id) return ""; return cssPropertiesToCss({ opacity: "0.9", cursor: "grabbing", }); } getSnap(snapLine) { if (!snapLine || !this.dnd.draggedFigure) return undefined; const figureVisibleRects = snapLine.matchedFigIds .map((id) => this.getVisibleFigures().find((fig) => fig.id === id)) .filter(isDefined) .map((fig) => { const figOnSCreen = internalFigureToScreen(this.env.model.getters, fig); const container = this.getFigureContainer(fig); return rectIntersection(figOnSCreen, this.getContainerRect(container)); }) .filter(isDefined); const containerRect = rectUnion(this.dnd.draggedFigure, ...figureVisibleRects); return { line: snapLine, containerStyle: this.rectToCss(containerRect), lineStyle: this.getSnapLineStyle(snapLine, containerRect), }; } getSnapLineStyle(snapLine, containerRect) { if (!snapLine) return ""; if (["top", "vCenter", "bottom"].includes(snapLine.snappedAxisType)) { return cssPropertiesToCss({ top: `${snapLine.position - containerRect.y}px`, left: `0px`, width: `100%`, }); } else { return cssPropertiesToCss({ top: `0px`, left: `${snapLine.position - containerRect.x}px`, height: `100%`, }); } } } css /* scss */ ` .o-filter-icon { color: ${FILTERS_COLOR}; display: flex; align-items: center; justify-content: center; width: ${GRID_ICON_EDGE_LENGTH}px; height: ${GRID_ICON_EDGE_LENGTH}px; &:hover { background: ${FILTERS_COLOR}; color: #fff; } &.o-high-contrast { color: #defade; } &.o-high-contrast:hover { color: ${FILTERS_COLOR}; background: #fff; } } .o-filter-icon:hover { background: ${FILTERS_COLOR}; color: #fff; } `; class FilterIcon extends Component { static template = "o-spreadsheet-FilterIcon"; static props = { cellPosition: Object, }; cellPopovers; setup() { this.cellPopovers = useStore(CellPopoverStore); } onClick() { const position = this.props.cellPosition; const activePopover = this.cellPopovers.persistentCellPopover; const { col, row } = position; if (activePopover.isOpen && activePopover.col === col && activePopover.row === row && activePopover.type === "FilterMenu") { this.cellPopovers.close(); return; } this.cellPopovers.open({ col, row }, "FilterMenu"); } get isFilterActive() { return this.env.model.getters.isFilterActive(this.props.cellPosition); } get iconClass() { const cellStyle = this.env.model.getters.getCellComputedStyle(this.props.cellPosition); const luminance = relativeLuminance(cellStyle.fillColor || "#fff"); return luminance < 0.45 ? "o-high-contrast" : ""; } } class FilterIconsOverlay extends Component { static template = "o-spreadsheet-FilterIconsOverlay"; static props = {}; static components = { GridCellIcon, FilterIcon, }; getFilterHeadersPositions() { const sheetId = this.env.model.getters.getActiveSheetId(); return this.env.model.getters.getFilterHeaders(sheetId); } } css /* scss */ ` .o-grid-add-rows { input.o-input { box-sizing: border-box; width: 60px; height: 30px; } .o-validation-error { display: inline-block !important; margin-top: 0; margin-left: 8px; } } `; class GridAddRowsFooter extends Component { static template = "o-spreadsheet-GridAddRowsFooter"; static props = { focusGrid: Function, }; static components = { ValidationMessages }; inputRef = useRef("inputRef"); state = useState({ inputValue: "100", errorFlag: false, }); setup() { useExternalListener(window, "click", this.onExternalClick, { capture: true }); } get addRowsPosition() { const activeSheetId = this.env.model.getters.getActiveSheetId(); const { numberOfRows } = this.env.model.getters.getSheetSize(activeSheetId); const { scrollY } = this.env.model.getters.getActiveSheetScrollInfo(); const rowDimensions = this.env.model.getters.getRowDimensions(activeSheetId, numberOfRows - 1); const top = rowDimensions.end - scrollY; return cssPropertiesToCss({ top: `${top}px`, }); } get errorMessages() { return [_t("Please enter a number between 0 and 10000.")]; } onKeydown(ev) { if (ev.key.toUpperCase() === "ESCAPE") { this.props.focusGrid(); } else if (ev.key.toUpperCase() === "ENTER") { this.onConfirm(); } } onInput(ev) { const value = ev.target.value; this.state.inputValue = value; const quantity = Number(value); this.state.errorFlag = Number.isNaN(quantity) || quantity <= 0 || quantity > 10000; } onConfirm() { if (this.state.errorFlag) { return; } const quantity = Number(this.state.inputValue); const activeSheetId = this.env.model.getters.getActiveSheetId(); const rowNumber = this.env.model.getters.getNumberRows(activeSheetId); this.env.model.dispatch("ADD_COLUMNS_ROWS", { sheetId: activeSheetId, position: "after", base: rowNumber - 1, quantity, dimension: "ROW", }); this.props.focusGrid(); // After adding new rows, scroll down to the new last row const { scrollX } = this.env.model.getters.getActiveSheetDOMScrollInfo(); const { end } = this.env.model.getters.getRowDimensions(activeSheetId, rowNumber + quantity - 1); this.env.model.dispatch("SET_VIEWPORT_OFFSET", { offsetX: scrollX, offsetY: end, }); } onExternalClick(ev) { if (this.inputRef.el !== document.activeElement || ev.target === this.inputRef.el) { return; } this.props.focusGrid(); } } class PaintFormatStore extends SpreadsheetStore { mutators = ["activate", "cancel", "pasteFormat"]; highlightStore = this.get(HighlightStore); clipboardHandlers = [ new CellClipboardHandler(this.getters, this.model.dispatch), new BorderClipboardHandler(this.getters, this.model.dispatch), new TableClipboardHandler(this.getters, this.model.dispatch), new ConditionalFormatClipboardHandler(this.getters, this.model.dispatch), ]; status = "inactive"; copiedData; constructor(get) { super(get); this.highlightStore.register(this); this.onDispose(() => { this.highlightStore.unRegister(this); }); } handle(cmd) { switch (cmd.type) { case "PAINT_FORMAT": this.paintFormat(cmd.sheetId, cmd.target); break; } } activate(args) { this.copiedData = this.copyFormats(); this.status = args.persistent ? "persistent" : "oneOff"; } cancel() { this.status = "inactive"; this.copiedData = undefined; } pasteFormat(target) { this.model.dispatch("PAINT_FORMAT", { target, sheetId: this.getters.getActiveSheetId() }); } get isActive() { return this.status !== "inactive"; } copyFormats() { const sheetId = this.getters.getActiveSheetId(); const zones = this.getters.getSelectedZones(); const copiedData = {}; for (const handler of this.clipboardHandlers) { Object.assign(copiedData, handler.copy(getClipboardDataPositions(sheetId, zones))); } return copiedData; } paintFormat(sheetId, target) { if (this.copiedData) { for (const handler of this.clipboardHandlers) { handler.paste({ zones: target, sheetId }, this.copiedData, { isCutOperation: false, pasteOption: "onlyFormat", }); } } if (this.status === "oneOff") { this.cancel(); } } get highlights() { const data = this.copiedData; if (!data) { return []; } return data.zones.map((zone) => ({ zone, color: SELECTION_BORDER_COLOR, dashed: true, sheetId: data.sheetId, noFill: true, thinLine: true, interactive: false, })); } } const CURSOR_SVG = /*xml*/ ` `; css /* scss */ ` .o-paint-format-cursor { cursor: url("data:image/svg+xml,${encodeURIComponent(CURSOR_SVG)}"), auto; } `; function useCellHovered(env, gridRef, callback) { let hoveredPosition = { col: undefined, row: undefined, }; const { Date } = window; let x = undefined; let y = undefined; let lastMoved = 0; function getPosition() { if (x === undefined || y === undefined) { return { col: -1, row: -1 }; } const col = env.model.getters.getColIndex(x); const row = env.model.getters.getRowIndex(y); return { col, row }; } const { pause, resume } = useInterval(checkTiming, 200); function checkTiming() { const { col, row } = getPosition(); const delta = Date.now() - lastMoved; if (delta > 300 && (col !== hoveredPosition.col || row !== hoveredPosition.row)) { setPosition(undefined, undefined); } if (delta > 300) { if (col < 0 || row < 0) { return; } setPosition(col, row); } } function updateMousePosition(e) { if (gridRef.el === e.target) { x = e.offsetX; y = e.offsetY; lastMoved = Date.now(); } } function recompute() { const { col, row } = getPosition(); if (col !== hoveredPosition.col || row !== hoveredPosition.row) { setPosition(undefined, undefined); } } function onMouseLeave(e) { const x = e.offsetX; const y = e.offsetY; const gridRect = getBoundingRectAsPOJO(gridRef.el); if (y < 0 || y > gridRect.height || x < 0 || x > gridRect.width) { return updateMousePosition(e); } else { return pause(); } } useRefListener(gridRef, "pointermove", updateMousePosition); useRefListener(gridRef, "mouseleave", onMouseLeave); useRefListener(gridRef, "mouseenter", resume); useRefListener(gridRef, "pointerdown", recompute); useExternalListener(window, "click", handleGlobalClick); function handleGlobalClick(e) { const target = e.target; const grid = gridRef.el; if (!grid.contains(target)) { setPosition(undefined, undefined); } } function setPosition(col, row) { if (col !== hoveredPosition.col || row !== hoveredPosition.row) { hoveredPosition.col = col; hoveredPosition.row = row; callback({ col, row }); } } return hoveredPosition; } function useTouchMove(gridRef, handler, canMoveUp) { let x = null; let y = null; function onTouchStart(ev) { if (ev.touches.length !== 1) return; x = ev.touches[0].clientX; y = ev.touches[0].clientY; } function onTouchEnd() { x = null; y = null; } function onTouchMove(ev) { if (ev.touches.length !== 1) return; // On mobile browsers, swiping down is often associated with "pull to refresh". // We only want this behavior if the grid is already at the top. // Otherwise we only want to move the canvas up, without triggering any refresh. if (canMoveUp()) { ev.preventDefault(); ev.stopPropagation(); } const currentX = ev.touches[0].clientX; const currentY = ev.touches[0].clientY; handler(x - currentX, y - currentY); x = currentX; y = currentY; } useRefListener(gridRef, "touchstart", onTouchStart); useRefListener(gridRef, "touchend", onTouchEnd); useRefListener(gridRef, "touchmove", onTouchMove); } class GridOverlay extends Component { static template = "o-spreadsheet-GridOverlay"; static props = { onCellHovered: { type: Function, optional: true }, onCellDoubleClicked: { type: Function, optional: true }, onCellClicked: { type: Function, optional: true }, onCellRightClicked: { type: Function, optional: true }, onGridResized: { type: Function, optional: true }, onFigureDeleted: { type: Function, optional: true }, onGridMoved: Function, gridOverlayDimensions: String, }; static components = { FiguresContainer, DataValidationOverlay, GridAddRowsFooter, FilterIconsOverlay, }; static defaultProps = { onCellHovered: () => { }, onCellDoubleClicked: () => { }, onCellClicked: () => { }, onCellRightClicked: () => { }, onGridResized: () => { }, onFigureDeleted: () => { }, }; gridOverlay = useRef("gridOverlay"); gridOverlayRect = useAbsoluteBoundingRect(this.gridOverlay); cellPopovers; paintFormatStore; setup() { useCellHovered(this.env, this.gridOverlay, this.props.onCellHovered); const resizeObserver = new ResizeObserver(() => { const boundingRect = this.gridOverlayEl.getBoundingClientRect(); this.props.onGridResized({ x: boundingRect.left, y: boundingRect.top, height: this.gridOverlayEl.clientHeight, width: this.gridOverlayEl.clientWidth, }); }); onMounted(() => { resizeObserver.observe(this.gridOverlayEl); }); onWillUnmount(() => { resizeObserver.disconnect(); }); useTouchMove(this.gridOverlay, this.props.onGridMoved, () => { const { scrollY } = this.env.model.getters.getActiveSheetDOMScrollInfo(); return scrollY > 0; }); this.cellPopovers = useStore(CellPopoverStore); this.paintFormatStore = useStore(PaintFormatStore); } get gridOverlayEl() { if (!this.gridOverlay.el) { throw new Error("GridOverlay el is not defined."); } return this.gridOverlay.el; } get style() { return this.props.gridOverlayDimensions; } get isPaintingFormat() { return this.paintFormatStore.isActive; } onMouseDown(ev) { if (ev.button > 0) { // not main button, probably a context menu return; } if (ev.target === this.gridOverlay.el && this.cellPopovers.isOpen) { this.cellPopovers.close(); } const [col, row] = this.getCartesianCoordinates(ev); this.props.onCellClicked(col, row, { expandZone: ev.shiftKey, addZone: isCtrlKey(ev), }); } onDoubleClick(ev) { const [col, row] = this.getCartesianCoordinates(ev); this.props.onCellDoubleClicked(col, row); } onContextMenu(ev) { const [col, row] = this.getCartesianCoordinates(ev); this.props.onCellRightClicked(col, row, { x: ev.clientX, y: ev.clientY }); } getCartesianCoordinates(ev) { const x = ev.clientX - this.gridOverlayRect.x; const y = ev.clientY - this.gridOverlayRect.y; const colIndex = this.env.model.getters.getColIndex(x); const rowIndex = this.env.model.getters.getRowIndex(y); return [colIndex, rowIndex]; } } class GridPopover extends Component { static template = "o-spreadsheet-GridPopover"; static props = { onClosePopover: Function, onMouseWheel: Function, gridRect: Object, }; static components = { Popover }; cellPopovers; zIndex = ComponentsImportance.GridPopover; setup() { this.cellPopovers = useStore(CellPopoverStore); } get cellPopover() { const popover = this.cellPopovers.cellPopover; if (!popover.isOpen) { return { isOpen: false }; } const anchorRect = popover.anchorRect; return { ...popover, // transform from the "canvas coordinate system" to the "body coordinate system" anchorRect: { ...anchorRect, x: anchorRect.x + this.props.gridRect.x, y: anchorRect.y + this.props.gridRect.y, }, }; } } class AbstractResizer extends Component { static props = { onOpenContextMenu: Function, }; composerFocusStore; PADDING = 0; MAX_SIZE_MARGIN = 0; MIN_ELEMENT_SIZE = 0; lastSelectedElementIndex = null; state = useState({ resizerIsActive: false, isResizing: false, isMoving: false, isSelecting: false, waitingForMove: false, activeElement: 0, draggerLinePosition: 0, draggerShadowPosition: 0, draggerShadowThickness: 0, delta: 0, base: 0, position: "before", }); setup() { this.composerFocusStore = useStore(ComposerFocusStore); } _computeHandleDisplay(ev) { const position = this._getEvOffset(ev); const elementIndex = this._getElementIndex(position); if (elementIndex < 0) { return; } const dimensions = this._getDimensionsInViewport(elementIndex); if (position - dimensions.start < this.PADDING && elementIndex !== this._getViewportOffset()) { this.state.resizerIsActive = true; this.state.draggerLinePosition = dimensions.start; this.state.activeElement = this._getPreviousVisibleElement(elementIndex); } else if (dimensions.end - position < this.PADDING) { this.state.resizerIsActive = true; this.state.draggerLinePosition = dimensions.end; this.state.activeElement = elementIndex; } else { this.state.resizerIsActive = false; } } _computeGrabDisplay(ev) { const index = this._getElementIndex(this._getEvOffset(ev)); const activeElements = this._getActiveElements(); const selectedZoneStart = this._getSelectedZoneStart(); const selectedZoneEnd = this._getSelectedZoneEnd(); if (activeElements.has(selectedZoneStart)) { if (selectedZoneStart <= index && index <= selectedZoneEnd) { this.state.waitingForMove = true; return; } } this.state.waitingForMove = false; } onMouseMove(ev) { if (this.state.isResizing || this.state.isMoving || this.state.isSelecting) { return; } this._computeHandleDisplay(ev); this._computeGrabDisplay(ev); } onMouseLeave() { this.state.resizerIsActive = this.state.isResizing; this.state.waitingForMove = false; } onDblClick(ev) { this._fitElementSize(this.state.activeElement); this.state.isResizing = false; this._computeHandleDisplay(ev); this._computeGrabDisplay(ev); } onMouseDown(ev) { this.state.isResizing = true; this.state.delta = 0; const initialPosition = this._getClientPosition(ev); const styleValue = this.state.draggerLinePosition; const size = this._getElementSize(this.state.activeElement); const minSize = styleValue - size + this.MIN_ELEMENT_SIZE; const maxSize = this._getMaxSize(); const onMouseUp = (ev) => { this.state.isResizing = false; if (this.state.delta !== 0) { this._updateSize(); } }; const onMouseMove = (ev) => { this.state.delta = this._getClientPosition(ev) - initialPosition; this.state.draggerLinePosition = styleValue + this.state.delta; if (this.state.draggerLinePosition < minSize) { this.state.draggerLinePosition = minSize; this.state.delta = this.MIN_ELEMENT_SIZE - size; } if (this.state.draggerLinePosition > maxSize) { this.state.draggerLinePosition = maxSize; this.state.delta = maxSize - styleValue; } }; startDnd(onMouseMove, onMouseUp); } select(ev) { if (ev.button > 0) { // not main button, probably a context menu return; } const index = this._getElementIndex(this._getEvOffset(ev)); if (index < 0) { return; } if (this.state.waitingForMove === true) { if (!this.env.model.getters.isGridSelectionActive()) { this._selectElement(index, false); } else { // FIXME: Consider reintroducing this feature for all type of selection if we find // a way to have the grid selection follow the other selections evolution this.startMovement(ev); } return; } if (this.composerFocusStore.activeComposer.editionMode === "editing") { this.env.model.selection.getBackToDefault(); } this.startSelection(ev, index); } startMovement(ev) { this.state.waitingForMove = false; this.state.isMoving = true; const startDimensions = this._getDimensionsInViewport(this._getSelectedZoneStart()); const endDimensions = this._getDimensionsInViewport(this._getSelectedZoneEnd()); const defaultPosition = startDimensions.start; this.state.draggerLinePosition = defaultPosition; this.state.base = this._getSelectedZoneStart(); this.state.draggerShadowPosition = defaultPosition; this.state.draggerShadowThickness = endDimensions.end - startDimensions.start; const mouseMoveMovement = (col, row) => { let elementIndex = this._getType() === "COL" ? col : row; if (elementIndex >= 0) { // define draggerLinePosition const dimensions = this._getDimensionsInViewport(elementIndex); if (elementIndex <= this._getSelectedZoneStart()) { this.state.draggerLinePosition = dimensions.start; this.state.draggerShadowPosition = dimensions.start; this.state.base = elementIndex; this.state.position = "before"; } else if (this._getSelectedZoneEnd() < elementIndex) { this.state.draggerLinePosition = dimensions.end; this.state.draggerShadowPosition = dimensions.end - this.state.draggerShadowThickness; this.state.base = elementIndex; this.state.position = "after"; } else { this.state.draggerLinePosition = startDimensions.start; this.state.draggerShadowPosition = startDimensions.start; this.state.base = this._getSelectedZoneStart(); } } }; const mouseUpMovement = () => { this.state.isMoving = false; if (this.state.base !== this._getSelectedZoneStart()) { this._moveElements(); } this._computeGrabDisplay(ev); }; dragAndDropBeyondTheViewport(this.env, mouseMoveMovement, mouseUpMovement); } startSelection(ev, index) { this.state.isSelecting = true; if (ev.shiftKey) { this._increaseSelection(index); } else { this._selectElement(index, isCtrlKey(ev)); } this.lastSelectedElementIndex = index; const mouseMoveSelect = (col, row) => { let newIndex = this._getType() === "COL" ? col : row; if (newIndex !== this.lastSelectedElementIndex && newIndex !== -1) { this._increaseSelection(newIndex); this.lastSelectedElementIndex = newIndex; } }; const mouseUpSelect = () => { this.state.isSelecting = false; this.lastSelectedElementIndex = null; this._computeGrabDisplay(ev); }; dragAndDropBeyondTheViewport(this.env, mouseMoveSelect, mouseUpSelect); } onMouseUp(ev) { this.lastSelectedElementIndex = null; } onContextMenu(ev) { ev.preventDefault(); const index = this._getElementIndex(this._getEvOffset(ev)); if (index < 0) return; if (!this._getActiveElements().has(index)) { this._selectElement(index, false); } const type = this._getType(); this.props.onOpenContextMenu(type, ev.clientX, ev.clientY); } } css /* scss */ ` .o-col-resizer { position: absolute; top: 0; left: ${HEADER_WIDTH}px; right: 0; height: ${HEADER_HEIGHT}px; &.o-dragging { cursor: grabbing; } &.o-grab { cursor: grab; } .dragging-col-line { top: ${HEADER_HEIGHT}px; position: absolute; width: 2px; height: 10000px; background-color: black; } .dragging-col-shadow { top: ${HEADER_HEIGHT}px; position: absolute; height: 10000px; background-color: black; opacity: 0.1; } .o-handle { position: absolute; height: ${HEADER_HEIGHT}px; width: 4px; cursor: e-resize; background-color: ${SELECTION_BORDER_COLOR}; } .dragging-resizer { top: ${HEADER_HEIGHT}px; position: absolute; margin-left: 2px; width: 1px; height: 10000px; background-color: ${SELECTION_BORDER_COLOR}; } .o-unhide { color: ${ICONS_COLOR}; } .o-unhide:hover { z-index: ${ComponentsImportance.Grid + 1}; background-color: lightgrey; } } `; class ColResizer extends AbstractResizer { static props = { onOpenContextMenu: Function, }; static template = "o-spreadsheet-ColResizer"; colResizerRef; setup() { super.setup(); this.colResizerRef = useRef("colResizer"); this.PADDING = 15; this.MAX_SIZE_MARGIN = 90; this.MIN_ELEMENT_SIZE = MIN_COL_WIDTH; } _getEvOffset(ev) { return ev.offsetX; } _getViewportOffset() { return this.env.model.getters.getActiveMainViewport().left; } _getClientPosition(ev) { return ev.clientX; } _getElementIndex(position) { return this.env.model.getters.getColIndex(position); } _getSelectedZoneStart() { return this.env.model.getters.getSelectedZone().left; } _getSelectedZoneEnd() { return this.env.model.getters.getSelectedZone().right; } _getEdgeScroll(position) { return this.env.model.getters.getEdgeScrollCol(position, position, position); } _getDimensionsInViewport(index) { return this.env.model.getters.getColDimensionsInViewport(this.env.model.getters.getActiveSheetId(), index); } _getElementSize(index) { return this.env.model.getters.getColSize(this.env.model.getters.getActiveSheetId(), index); } _getMaxSize() { return this.colResizerRef.el.clientWidth; } _updateSize() { const index = this.state.activeElement; const size = this.state.delta + this._getElementSize(index); const cols = this.env.model.getters.getActiveCols(); this.env.model.dispatch("RESIZE_COLUMNS_ROWS", { dimension: "COL", sheetId: this.env.model.getters.getActiveSheetId(), elements: cols.has(index) ? [...cols] : [index], size, }); } _moveElements() { const elements = []; const start = this._getSelectedZoneStart(); const end = this._getSelectedZoneEnd(); for (let colIndex = start; colIndex <= end; colIndex++) { elements.push(colIndex); } const result = this.env.model.dispatch("MOVE_COLUMNS_ROWS", { sheetId: this.env.model.getters.getActiveSheetId(), dimension: "COL", base: this.state.base, elements, position: this.state.position, }); if (!result.isSuccessful && result.reasons.includes("WillRemoveExistingMerge" /* CommandResult.WillRemoveExistingMerge */)) { this.env.raiseError(MergeErrorMessage); } } _selectElement(index, addDistinctHeader) { this.env.model.selection.selectColumn(index, addDistinctHeader ? "newAnchor" : "overrideSelection"); } _increaseSelection(index) { this.env.model.selection.selectColumn(index, "updateAnchor"); } _fitElementSize(index) { const cols = this.env.model.getters.getActiveCols(); this.env.model.dispatch("AUTORESIZE_COLUMNS", { sheetId: this.env.model.getters.getActiveSheetId(), cols: cols.has(index) ? [...cols] : [index], }); } _getType() { return "COL"; } _getActiveElements() { return this.env.model.getters.getActiveCols(); } _getPreviousVisibleElement(index) { const sheetId = this.env.model.getters.getActiveSheetId(); let row; for (row = index - 1; row >= 0; row--) { if (!this.env.model.getters.isColHidden(sheetId, row)) { break; } } return row; } unhide(hiddenElements) { this.env.model.dispatch("UNHIDE_COLUMNS_ROWS", { sheetId: this.env.model.getters.getActiveSheetId(), elements: hiddenElements, dimension: "COL", }); } getUnhideButtonStyle(hiddenIndex) { return cssPropertiesToCss({ left: this._getDimensionsInViewport(hiddenIndex).start + "px" }); } } css /* scss */ ` .o-row-resizer { position: absolute; top: ${HEADER_HEIGHT}px; left: 0; right: 0; width: ${HEADER_WIDTH}px; height: 100%; &.o-dragging { cursor: grabbing; } &.o-grab { cursor: grab; } .dragging-row-line { left: ${HEADER_WIDTH}px; position: absolute; width: 10000px; height: 2px; background-color: black; } .dragging-row-shadow { left: ${HEADER_WIDTH}px; position: absolute; width: 10000px; background-color: black; opacity: 0.1; } .o-handle { position: absolute; height: 4px; width: ${HEADER_WIDTH}px; cursor: n-resize; background-color: ${SELECTION_BORDER_COLOR}; } .dragging-resizer { left: ${HEADER_WIDTH}px; position: absolute; margin-top: 2px; width: 10000px; height: 1px; background-color: ${SELECTION_BORDER_COLOR}; } .o-unhide { color: ${ICONS_COLOR}; } .o-unhide:hover { z-index: ${ComponentsImportance.Grid + 1}; background-color: lightgrey; } } `; class RowResizer extends AbstractResizer { static props = { onOpenContextMenu: Function, }; static template = "o-spreadsheet-RowResizer"; setup() { super.setup(); this.rowResizerRef = useRef("rowResizer"); this.PADDING = 5; this.MAX_SIZE_MARGIN = 60; this.MIN_ELEMENT_SIZE = MIN_ROW_HEIGHT; } rowResizerRef; _getEvOffset(ev) { return ev.offsetY; } _getViewportOffset() { return this.env.model.getters.getActiveMainViewport().top; } _getClientPosition(ev) { return ev.clientY; } _getElementIndex(position) { return this.env.model.getters.getRowIndex(position); } _getSelectedZoneStart() { return this.env.model.getters.getSelectedZone().top; } _getSelectedZoneEnd() { return this.env.model.getters.getSelectedZone().bottom; } _getEdgeScroll(position) { return this.env.model.getters.getEdgeScrollRow(position, position, position); } _getDimensionsInViewport(index) { return this.env.model.getters.getRowDimensionsInViewport(this.env.model.getters.getActiveSheetId(), index); } _getElementSize(index) { return this.env.model.getters.getRowSize(this.env.model.getters.getActiveSheetId(), index); } _getMaxSize() { return this.rowResizerRef.el.clientHeight; } _updateSize() { const index = this.state.activeElement; const size = this.state.delta + this._getElementSize(index); const rows = this.env.model.getters.getActiveRows(); this.env.model.dispatch("RESIZE_COLUMNS_ROWS", { dimension: "ROW", sheetId: this.env.model.getters.getActiveSheetId(), elements: rows.has(index) ? [...rows] : [index], size, }); } _moveElements() { const elements = []; const start = this._getSelectedZoneStart(); const end = this._getSelectedZoneEnd(); for (let rowIndex = start; rowIndex <= end; rowIndex++) { elements.push(rowIndex); } const result = this.env.model.dispatch("MOVE_COLUMNS_ROWS", { sheetId: this.env.model.getters.getActiveSheetId(), dimension: "ROW", base: this.state.base, elements, position: this.state.position, }); if (!result.isSuccessful && result.reasons.includes("WillRemoveExistingMerge" /* CommandResult.WillRemoveExistingMerge */)) { this.env.raiseError(MergeErrorMessage); } } _selectElement(index, addDistinctHeader) { this.env.model.selection.selectRow(index, addDistinctHeader ? "newAnchor" : "overrideSelection"); } _increaseSelection(index) { this.env.model.selection.selectRow(index, "updateAnchor"); } _fitElementSize(index) { const rows = this.env.model.getters.getActiveRows(); this.env.model.dispatch("AUTORESIZE_ROWS", { sheetId: this.env.model.getters.getActiveSheetId(), rows: rows.has(index) ? [...rows] : [index], }); } _getType() { return "ROW"; } _getActiveElements() { return this.env.model.getters.getActiveRows(); } _getPreviousVisibleElement(index) { const sheetId = this.env.model.getters.getActiveSheetId(); let row; for (row = index - 1; row >= 0; row--) { if (!this.env.model.getters.isRowHidden(sheetId, row)) { break; } } return row; } unhide(hiddenElements) { this.env.model.dispatch("UNHIDE_COLUMNS_ROWS", { sheetId: this.env.model.getters.getActiveSheetId(), dimension: "ROW", elements: hiddenElements, }); } getUnhideButtonStyle(hiddenIndex) { return cssPropertiesToCss({ top: this._getDimensionsInViewport(hiddenIndex).start + "px" }); } } css /* scss */ ` .o-overlay { .all { position: absolute; top: 0; left: 0; right: 0; width: ${HEADER_WIDTH}px; height: ${HEADER_HEIGHT}px; } } `; class HeadersOverlay extends Component { static props = { onOpenContextMenu: Function, }; static template = "o-spreadsheet-HeadersOverlay"; static components = { ColResizer, RowResizer }; selectAll() { this.env.model.selection.selectAll(); } } class GridRenderer { getters; renderer; constructor(get) { this.getters = get(ModelStore).getters; this.renderer = get(RendererStore); this.renderer.register(this); } get renderingLayers() { return ["Background", "Headers"]; } /** * Get the offset of a header (see getColRowOffsetInViewport), adjusted with the header * size (HEADER_HEIGHT and HEADER_WIDTH) */ getHeaderOffset(dimension, start, index) { let size = this.getters.getColRowOffsetInViewport(dimension, start, index); if (!this.getters.isDashboard()) { size += dimension === "ROW" ? HEADER_HEIGHT : HEADER_WIDTH; } return size; } // --------------------------------------------------------------------------- // Grid rendering // --------------------------------------------------------------------------- drawLayer(renderingContext, layer) { switch (layer) { case "Background": const boxes = this.getGridBoxes(); this.drawBackground(renderingContext, boxes); this.drawOverflowingCellBackground(renderingContext, boxes); this.drawCellBackground(renderingContext, boxes); this.drawBorders(renderingContext, boxes); this.drawTexts(renderingContext, boxes); this.drawIcon(renderingContext, boxes); this.drawFrozenPanes(renderingContext); break; case "Headers": if (!this.getters.isDashboard()) { this.drawHeaders(renderingContext); this.drawFrozenPanesHeaders(renderingContext); } break; } } drawBackground(renderingContext, boxes) { const { ctx, thinLineWidth } = renderingContext; const { width, height } = this.getters.getSheetViewDimensionWithHeaders(); // white background ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, width + CANVAS_SHIFT, height + CANVAS_SHIFT); const areGridLinesVisible = !this.getters.isDashboard() && this.getters.getGridLinesVisibility(this.getters.getActiveSheetId()); const inset = areGridLinesVisible ? 0.1 * thinLineWidth : 0; if (areGridLinesVisible) { for (const box of boxes) { ctx.strokeStyle = CELL_BORDER_COLOR; ctx.lineWidth = thinLineWidth; ctx.strokeRect(box.x + inset, box.y + inset, box.width - 2 * inset, box.height - 2 * inset); } } } drawCellBackground(renderingContext, boxes) { const { ctx } = renderingContext; for (const box of boxes) { let style = box.style; if (style.fillColor && style.fillColor !== "#ffffff") { ctx.fillStyle = style.fillColor || "#ffffff"; ctx.fillRect(box.x, box.y, box.width, box.height); } if (box.dataBarFill) { ctx.fillStyle = box.dataBarFill.color; const percentage = box.dataBarFill.percentage; const width = box.width * (percentage / 100); ctx.fillRect(box.x, box.y, width, box.height); } if (box.isError) { ctx.fillStyle = "red"; ctx.beginPath(); ctx.moveTo(box.x + box.width - 5, box.y); ctx.lineTo(box.x + box.width, box.y); ctx.lineTo(box.x + box.width, box.y + 5); ctx.fill(); } } } drawOverflowingCellBackground(renderingContext, boxes) { const { ctx, thinLineWidth } = renderingContext; for (const box of boxes) { if (box.content && box.isOverflow) { const align = box.content.align || "left"; let x; let width; const y = box.y + thinLineWidth / 2; const height = box.height - thinLineWidth; const clipWidth = Math.min(box.clipRect?.width || Infinity, box.content.width); if (align === "left") { x = box.x + thinLineWidth / 2; width = clipWidth - 2 * thinLineWidth; } else if (align === "right") { x = box.x + box.width - thinLineWidth / 2; width = -clipWidth + 2 * thinLineWidth; } else { x = (box.clipRect?.x || box.x + box.width / 2 - box.content.width / 2) + thinLineWidth / 2; width = clipWidth - 2 * thinLineWidth; } ctx.fillStyle = "#ffffff"; ctx.fillRect(x, y, width, height); } } } drawBorders(renderingContext, boxes) { const { ctx } = renderingContext; for (let box of boxes) { const border = box.border; if (border) { const { x, y, width, height } = box; if (border.left) { drawBorder(border.left, x, y, x, y + height); } if (border.top) { drawBorder(border.top, x, y, x + width, y); } if (border.right) { drawBorder(border.right, x + width, y, x + width, y + height); } if (border.bottom) { drawBorder(border.bottom, x, y + height, x + width, y + height); } } } /** * Following https://usefulangle.com/post/17/html5-canvas-drawing-1px-crisp-straight-lines, * we need to make sure that a "single" pixel line is drawn on a "half" pixel coordinate, * while a "double" pixel line is drawn on a "full" pixel coordinate. As, in the rendering * process, we always had 0.5 before rendering line (to make sure it is drawn on a "half" * pixel), we need to correct this behavior for the "medium" and the "dotted" styles, as * they are drawing a two pixels width line. * We also adapt here the coordinates of the line to make sure corner are correctly drawn, * avoiding a "round corners" effect. This is done by subtracting 1 pixel to the origin of * each line and adding 1 pixel to the end of each line (depending on the direction of the * line). */ function drawBorder({ style, color }, x1, y1, x2, y2) { ctx.strokeStyle = color; switch (style) { case "medium": ctx.lineWidth = 2; x1 += y1 === y2 ? -0.5 : 0.5; x2 += y1 === y2 ? 1.5 : 0.5; y1 += x1 === x2 ? -0.5 : 0.5; y2 += x1 === x2 ? 1.5 : 0.5; break; case "thick": ctx.lineWidth = 3; if (y1 === y2) { x1--; x2++; } if (x1 === x2) { y1--; y2++; } break; case "dashed": ctx.lineWidth = 1; ctx.setLineDash([1, 3]); break; case "dotted": ctx.lineWidth = 1; if (y1 === y2) { x1 += 0.5; x2 += 0.5; } if (x1 === x2) { y1 += 0.5; y2 += 0.5; } ctx.setLineDash([1, 1]); break; case "thin": default: ctx.lineWidth = 1; break; } ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); ctx.lineWidth = 1; ctx.setLineDash([]); } } drawTexts(renderingContext, boxes) { const { ctx } = renderingContext; ctx.textBaseline = "top"; let currentFont; for (let box of boxes) { if (box.content) { const style = box.style || {}; const align = box.content.align || "left"; // compute font and textColor const font = computeTextFont(style); if (font !== currentFont) { currentFont = font; ctx.font = font; } ctx.fillStyle = style.textColor || "#000"; // compute horizontal align start point parameter let x = box.x; if (align === "left") { x += MIN_CELL_TEXT_MARGIN + (box.image ? box.image.size + MIN_CF_ICON_MARGIN : 0); } else if (align === "right") { x += box.width - MIN_CELL_TEXT_MARGIN - (box.hasIcon ? GRID_ICON_EDGE_LENGTH + GRID_ICON_MARGIN : 0); } else { x += box.width / 2; } // horizontal align text direction ctx.textAlign = align; // clip rect if needed if (box.clipRect) { ctx.save(); ctx.beginPath(); const { x, y, width, height } = box.clipRect; ctx.rect(x, y, width, height); ctx.clip(); } // compute vertical align start point parameter: const textLineHeight = computeTextFontSizeInPixels(style); const numberOfLines = box.content.textLines.length; let y = this.computeTextYCoordinate(box, textLineHeight, numberOfLines); // use the horizontal and the vertical start points to: // fill text / fill strikethrough / fill underline for (let brokenLine of box.content.textLines) { drawDecoratedText(ctx, brokenLine, { x: Math.round(x), y: Math.round(y) }, style.underline, style.strikethrough); y += MIN_CELL_TEXT_MARGIN + textLineHeight; } if (box.clipRect) { ctx.restore(); } } } } drawIcon(renderingContext, boxes) { const { ctx } = renderingContext; for (const box of boxes) { if (box.image) { const icon = box.image.image; if (box.image.clipIcon) { ctx.save(); ctx.beginPath(); const { x, y, width, height } = box.image.clipIcon; ctx.rect(x, y, width, height); ctx.clip(); } const iconSize = box.image.size; const y = this.computeTextYCoordinate(box, iconSize); ctx.drawImage(icon, box.x + MIN_CF_ICON_MARGIN, y, iconSize, iconSize); if (box.image.clipIcon) { ctx.restore(); } } } } /** Computes the vertical start point from which a text line should be draw. * * Note that in case the cell does not have enough spaces to display its text lines, * (wrapping cell case) then the vertical align should be at the top. * */ computeTextYCoordinate(box, textLineHeight, numberOfLines = 1) { const y = box.y + 1; const textHeight = computeTextLinesHeight(textLineHeight, numberOfLines); const hasEnoughSpaces = box.height > textHeight + MIN_CELL_TEXT_MARGIN * 2; const verticalAlign = box.verticalAlign || DEFAULT_VERTICAL_ALIGN; if (hasEnoughSpaces) { if (verticalAlign === "middle") { return y + (box.height - textHeight) / 2; } if (verticalAlign === "bottom") { return y + box.height - textHeight - MIN_CELL_TEXT_MARGIN; } } return y + MIN_CELL_TEXT_MARGIN; } drawHeaders(renderingContext) { const { ctx, thinLineWidth } = renderingContext; const visibleCols = this.getters.getSheetViewVisibleCols(); const left = visibleCols[0]; const right = visibleCols[visibleCols.length - 1]; const visibleRows = this.getters.getSheetViewVisibleRows(); const top = visibleRows[0]; const bottom = visibleRows[visibleRows.length - 1]; const { width, height } = this.getters.getSheetViewDimensionWithHeaders(); const selection = this.getters.getSelectedZones(); const selectedCols = getZonesCols(selection); const selectedRows = getZonesRows(selection); const sheetId = this.getters.getActiveSheetId(); const numberOfCols = this.getters.getNumberCols(sheetId); const numberOfRows = this.getters.getNumberRows(sheetId); const activeCols = this.getters.getActiveCols(); const activeRows = this.getters.getActiveRows(); ctx.font = `400 ${HEADER_FONT_SIZE}px ${DEFAULT_FONT}`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.lineWidth = thinLineWidth; ctx.strokeStyle = "#333"; // Columns headers background for (let col = left; col <= right; col++) { const colZone = { left: col, right: col, top: 0, bottom: numberOfRows - 1 }; const { x, width } = this.getters.getVisibleRect(colZone); const isColActive = activeCols.has(col); const isColSelected = selectedCols.has(col); if (isColActive) { ctx.fillStyle = BACKGROUND_HEADER_ACTIVE_COLOR; } else if (isColSelected) { ctx.fillStyle = BACKGROUND_HEADER_SELECTED_COLOR; } else { ctx.fillStyle = BACKGROUND_HEADER_COLOR; } ctx.fillRect(x, 0, width, HEADER_HEIGHT); } // Rows headers background for (let row = top; row <= bottom; row++) { const rowZone = { top: row, bottom: row, left: 0, right: numberOfCols - 1 }; const { y, height } = this.getters.getVisibleRect(rowZone); const isRowActive = activeRows.has(row); const isRowSelected = selectedRows.has(row); if (isRowActive) { ctx.fillStyle = BACKGROUND_HEADER_ACTIVE_COLOR; } else if (isRowSelected) { ctx.fillStyle = BACKGROUND_HEADER_SELECTED_COLOR; } else { ctx.fillStyle = BACKGROUND_HEADER_COLOR; } ctx.fillRect(0, y, HEADER_WIDTH, height); } // 2 main lines ctx.beginPath(); ctx.moveTo(HEADER_WIDTH, 0); ctx.lineTo(HEADER_WIDTH, height); ctx.moveTo(0, HEADER_HEIGHT); ctx.lineTo(width, HEADER_HEIGHT); ctx.strokeStyle = HEADER_BORDER_COLOR; ctx.stroke(); ctx.beginPath(); // column text + separator for (const i of visibleCols) { const colSize = this.getters.getColSize(sheetId, i); const colName = numberToLetters(i); ctx.fillStyle = activeCols.has(i) ? "#fff" : TEXT_HEADER_COLOR; let colStart = this.getHeaderOffset("COL", left, i); ctx.fillText(colName, colStart + colSize / 2, HEADER_HEIGHT / 2); ctx.moveTo(colStart + colSize, 0); ctx.lineTo(colStart + colSize, HEADER_HEIGHT); } // row text + separator for (const i of visibleRows) { const rowSize = this.getters.getRowSize(sheetId, i); ctx.fillStyle = activeRows.has(i) ? "#fff" : TEXT_HEADER_COLOR; let rowStart = this.getHeaderOffset("ROW", top, i); ctx.fillText(String(i + 1), HEADER_WIDTH / 2, rowStart + rowSize / 2); ctx.moveTo(0, rowStart + rowSize); ctx.lineTo(HEADER_WIDTH, rowStart + rowSize); } ctx.stroke(); } drawFrozenPanesHeaders(renderingContext) { const { ctx, thinLineWidth } = renderingContext; const { x: offsetCorrectionX, y: offsetCorrectionY } = this.getters.getMainViewportCoordinates(); const widthCorrection = this.getters.isDashboard() ? 0 : HEADER_WIDTH; const heightCorrection = this.getters.isDashboard() ? 0 : HEADER_HEIGHT; ctx.lineWidth = 6 * thinLineWidth; ctx.strokeStyle = FROZEN_PANE_HEADER_BORDER_COLOR; ctx.beginPath(); if (offsetCorrectionX) { ctx.moveTo(widthCorrection + offsetCorrectionX, 0); ctx.lineTo(widthCorrection + offsetCorrectionX, heightCorrection); } if (offsetCorrectionY) { ctx.moveTo(0, heightCorrection + offsetCorrectionY); ctx.lineTo(widthCorrection, heightCorrection + offsetCorrectionY); } ctx.stroke(); } drawFrozenPanes(renderingContext) { const { ctx, thinLineWidth } = renderingContext; const { x: offsetCorrectionX, y: offsetCorrectionY } = this.getters.getMainViewportCoordinates(); const visibleCols = this.getters.getSheetViewVisibleCols(); const left = visibleCols[0]; const right = visibleCols[visibleCols.length - 1]; const visibleRows = this.getters.getSheetViewVisibleRows(); const top = visibleRows[0]; const bottom = visibleRows[visibleRows.length - 1]; const viewport = { left, right, top, bottom }; const rect = this.getters.getVisibleRect(viewport); const widthCorrection = this.getters.isDashboard() ? 0 : HEADER_WIDTH; const heightCorrection = this.getters.isDashboard() ? 0 : HEADER_HEIGHT; ctx.lineWidth = 6 * thinLineWidth; ctx.strokeStyle = FROZEN_PANE_BORDER_COLOR; ctx.beginPath(); if (offsetCorrectionX) { ctx.moveTo(widthCorrection + offsetCorrectionX, heightCorrection); ctx.lineTo(widthCorrection + offsetCorrectionX, rect.height + heightCorrection); } if (offsetCorrectionY) { ctx.moveTo(widthCorrection, heightCorrection + offsetCorrectionY); ctx.lineTo(rect.width + widthCorrection, heightCorrection + offsetCorrectionY); } ctx.stroke(); } findNextEmptyCol(base, max, row) { const sheetId = this.getters.getActiveSheetId(); let col = base; while (col < max) { const position = { sheetId, col: col + 1, row }; const nextCell = this.getters.getEvaluatedCell(position); const nextCellBorder = this.getters.getCellComputedBorder(position); const cellHasIcon = this.getters.doesCellHaveGridIcon(position); const cellHasCheckbox = this.getters.isCellValidCheckbox(position); if (nextCell.type !== CellValueType.empty || this.getters.isInMerge(position) || nextCellBorder?.left || cellHasIcon || cellHasCheckbox) { return col; } col++; } return col; } findPreviousEmptyCol(base, min, row) { const sheetId = this.getters.getActiveSheetId(); let col = base; while (col > min) { const position = { sheetId, col: col - 1, row }; const previousCell = this.getters.getEvaluatedCell(position); const previousCellBorder = this.getters.getCellComputedBorder(position); const cellHasIcon = this.getters.doesCellHaveGridIcon(position); const cellHasCheckbox = this.getters.isCellValidCheckbox(position); if (previousCell.type !== CellValueType.empty || this.getters.isInMerge(position) || previousCellBorder?.right || cellHasIcon || cellHasCheckbox) { return col; } col--; } return col; } computeCellAlignment(position, isOverflowing) { const cell = this.getters.getCell(position); if (cell?.isFormula && this.getters.shouldShowFormulas()) { return "left"; } const { align } = this.getters.getCellStyle(position); const evaluatedCell = this.getters.getEvaluatedCell(position); if (isOverflowing && evaluatedCell.type === CellValueType.number) { return align !== "center" ? "left" : align; } return align || evaluatedCell.defaultAlign; } createZoneBox(sheetId, zone, viewport) { const { left, right } = viewport; const col = zone.left; const row = zone.top; const position = { sheetId, col, row }; const cell = this.getters.getEvaluatedCell(position); const showFormula = this.getters.shouldShowFormulas(); const { x, y, width, height } = this.getters.getVisibleRect(zone); const { verticalAlign } = this.getters.getCellStyle(position); const box = { x, y, width, height, border: this.getters.getCellComputedBorder(position) || undefined, style: this.getters.getCellComputedStyle(position), dataBarFill: this.getters.getConditionalDataBar(position), verticalAlign, isError: (cell.type === CellValueType.error && !!cell.message) || this.getters.isDataValidationInvalid(position), }; /** Icon */ const iconSrc = this.getters.getCellIconSrc(position); const fontSizePX = computeTextFontSizeInPixels(box.style); const iconBoxWidth = iconSrc ? MIN_CF_ICON_MARGIN + fontSizePX : 0; if (iconSrc) { const imageHtmlElement = loadIconImage(iconSrc); box.image = { type: "icon", size: fontSizePX, clipIcon: { x: box.x, y: box.y, width: Math.min(iconBoxWidth, width), height }, image: imageHtmlElement, }; } if (cell.type === CellValueType.empty || this.getters.isCellValidCheckbox(position)) { return box; } /** Filter Header or data validation icon */ box.hasIcon = this.getters.doesCellHaveGridIcon(position); const headerIconWidth = box.hasIcon ? GRID_ICON_EDGE_LENGTH + GRID_ICON_MARGIN : 0; /** Content */ const style = this.getters.getCellComputedStyle(position); const wrapping = style.wrapping || "overflow"; const wrapText = wrapping === "wrap" && !showFormula; const maxWidth = width - 2 * MIN_CELL_TEXT_MARGIN; const multiLineText = this.getters.getCellMultiLineText(position, { maxWidth, wrapText }); const textWidth = Math.max(...multiLineText.map((line) => this.getters.getTextWidth(line, style) + MIN_CELL_TEXT_MARGIN)); const contentWidth = iconBoxWidth + textWidth + headerIconWidth; const align = this.computeCellAlignment(position, contentWidth > width); box.content = { textLines: multiLineText, width: wrapping === "overflow" ? textWidth : width, align, }; /** ClipRect */ const isOverflowing = contentWidth > width || fontSizePX > height; if (iconSrc || box.hasIcon) { box.clipRect = { x: box.x + iconBoxWidth, y: box.y, width: Math.max(0, width - iconBoxWidth - headerIconWidth), height, }; } else if (isOverflowing && wrapping === "overflow") { let nextColIndex, previousColIndex; const isCellInMerge = this.getters.isInMerge(position); if (isCellInMerge) { // Always clip merges nextColIndex = this.getters.getMerge(position).right; previousColIndex = col; } else { nextColIndex = this.findNextEmptyCol(col, right, row); previousColIndex = this.findPreviousEmptyCol(col, left, row); box.isOverflow = true; } switch (align) { case "left": { const emptyZoneOnTheLeft = positionToZone({ col: nextColIndex, row }); const { x, y, width, height } = this.getters.getVisibleRect(union(zone, emptyZoneOnTheLeft)); if (width < contentWidth || fontSizePX > height || multiLineText.length > 1) { box.clipRect = { x, y, width, height }; } break; } case "right": { const emptyZoneOnTheRight = positionToZone({ col: previousColIndex, row }); const { x, y, width, height } = this.getters.getVisibleRect(union(zone, emptyZoneOnTheRight)); if (width < contentWidth || fontSizePX > height || multiLineText.length > 1) { box.clipRect = { x, y, width, height }; } break; } case "center": { const emptyZone = { ...zone, left: previousColIndex, right: nextColIndex, }; const { x, y, height, width } = this.getters.getVisibleRect(emptyZone); const halfContentWidth = contentWidth / 2; const boxMiddle = box.x + box.width / 2; if (x + width < boxMiddle + halfContentWidth || x > boxMiddle - halfContentWidth || fontSizePX > height || multiLineText.length > 1) { const clipX = x > boxMiddle - halfContentWidth ? x : boxMiddle - halfContentWidth; const clipWidth = x + width - clipX; box.clipRect = { x: clipX, y, width: clipWidth, height }; } break; } } } else if (wrapping === "clip" || wrapping === "wrap" || multiLineText.length > 1) { box.clipRect = { x: box.x, y: box.y, width, height, }; } return box; } getGridBoxes() { const boxes = []; const visibleCols = this.getters.getSheetViewVisibleCols(); const left = visibleCols[0]; const right = visibleCols[visibleCols.length - 1]; const visibleRows = this.getters.getSheetViewVisibleRows(); const top = visibleRows[0]; const bottom = visibleRows[visibleRows.length - 1]; const viewport = { left, right, top, bottom }; const sheetId = this.getters.getActiveSheetId(); for (const row of visibleRows) { for (const col of visibleCols) { const position = { sheetId, col, row }; if (this.getters.isInMerge(position)) { continue; } boxes.push(this.createZoneBox(sheetId, positionToZone(position), viewport)); } } for (const merge of this.getters.getMerges(sheetId)) { if (this.getters.isMergeHidden(sheetId, merge)) { continue; } if (overlap(merge, viewport)) { const box = this.createZoneBox(sheetId, merge, viewport); const borderBottomRight = this.getters.getCellComputedBorder({ sheetId, col: merge.right, row: merge.bottom, }); box.border = { ...box.border, bottom: borderBottomRight ? borderBottomRight.bottom : undefined, right: borderBottomRight ? borderBottomRight.right : undefined, }; box.isMerge = true; boxes.push(box); } } return boxes; } } const loadIconImage = memoize(function loadIconImage(src) { const image = new Image(); image.src = src; return image; }); function useGridDrawing(refName, model, canvasSize) { const canvasRef = useRef(refName); useEffect(drawGrid); const rendererStore = useStore(RendererStore); useStore(GridRenderer); function drawGrid() { const canvas = canvasRef.el; const dpr = window.devicePixelRatio || 1; const ctx = canvas.getContext("2d", { alpha: false }); const thinLineWidth = 0.4 * dpr; const renderingContext = { ctx, dpr, thinLineWidth, }; const { width, height } = canvasSize(); canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; canvas.width = width * dpr; canvas.height = height * dpr; canvas.setAttribute("style", `width:${width}px;height:${height}px;`); // Imagine each pixel as a large square. The whole-number coordinates (0, 1, 2…) // are the edges of the squares. If you draw a one-unit-wide line between whole-number // coordinates, it will overlap opposite sides of the pixel square, and the resulting // line will be drawn two pixels wide. To draw a line that is only one pixel wide, // you need to shift the coordinates by 0.5 perpendicular to the line's direction. // http://diveintohtml5.info/canvas.html#pixel-madness ctx.translate(-CANVAS_SHIFT, -CANVAS_SHIFT); ctx.scale(dpr, dpr); for (const layer of OrderedLayers()) { model.drawLayer(renderingContext, layer); // @ts-ignore 'drawLayer' is not declated as a mutator because: // it does not mutate anything. Most importantly it's used // during rendering. Invoking a mutator during rendering would // trigger another rendering, ultimately resulting in an infinite loop. rendererStore.drawLayer(renderingContext, layer); } } } function useWheelHandler(handler) { function normalize(val, deltaMode) { return val * (deltaMode === 0 ? 1 : DEFAULT_CELL_HEIGHT); } const onMouseWheel = (ev) => { const deltaX = normalize(ev.shiftKey && !isMacOS() ? ev.deltaY : ev.deltaX, ev.deltaMode); const deltaY = normalize(ev.shiftKey && !isMacOS() ? ev.deltaX : ev.deltaY, ev.deltaMode); handler(deltaX, deltaY); }; return onMouseWheel; } css /* scss */ ` .o-border { position: absolute; &:hover { cursor: grab; } } .o-moving { cursor: grabbing; } `; class Border extends Component { static template = "o-spreadsheet-Border"; static props = { zone: Object, orientation: String, isMoving: Boolean, onMoveHighlight: Function, }; get style() { const isTop = ["n", "w", "e"].includes(this.props.orientation); const isLeft = ["n", "w", "s"].includes(this.props.orientation); const isHorizontal = ["n", "s"].includes(this.props.orientation); const isVertical = ["w", "e"].includes(this.props.orientation); const z = this.props.zone; const margin = 2; const rect = this.env.model.getters.getVisibleRect(z); const left = rect.x; const right = rect.x + rect.width - 2 * margin; const top = rect.y; const bottom = rect.y + rect.height - 2 * margin; const lineWidth = 4; const leftValue = isLeft ? left : right; const topValue = isTop ? top : bottom; const widthValue = isHorizontal ? right - left : lineWidth; const heightValue = isVertical ? bottom - top : lineWidth; return cssPropertiesToCss({ left: `${leftValue}px`, top: `${topValue}px`, width: `${widthValue}px`, height: `${heightValue}px`, }); } onMouseDown(ev) { this.props.onMoveHighlight(ev.clientX, ev.clientY); } } css /* scss */ ` .o-corner { position: absolute; height: 6px; width: 6px; border: 1px solid white; } .o-corner-nw, .o-corner-se { &:hover { cursor: nwse-resize; } } .o-corner-ne, .o-corner-sw { &:hover { cursor: nesw-resize; } } .o-resizing { cursor: grabbing; } `; class Corner extends Component { static template = "o-spreadsheet-Corner"; static props = { zone: Object, color: String, orientation: String, isResizing: Boolean, onResizeHighlight: Function, }; isTop = this.props.orientation[0] === "n"; isLeft = this.props.orientation[1] === "w"; get style() { const z = this.props.zone; const col = this.isLeft ? z.left : z.right; const row = this.isTop ? z.top : z.bottom; const rect = this.env.model.getters.getVisibleRect({ left: col, right: col, top: row, bottom: row, }); // Don't show if not visible in the viewport if (rect.width * rect.height === 0) { return `display:none`; } const leftValue = this.isLeft ? rect.x : rect.x + rect.width; const topValue = this.isTop ? rect.y : rect.y + rect.height; return cssPropertiesToCss({ left: `${leftValue - AUTOFILL_EDGE_LENGTH / 2}px`, top: `${topValue - AUTOFILL_EDGE_LENGTH / 2}px`, "background-color": this.props.color, }); } onMouseDown(ev) { this.props.onResizeHighlight(this.isLeft, this.isTop); } } css /*SCSS*/ ` .o-highlight { z-index: ${ComponentsImportance.Highlight}; } `; class Highlight extends Component { static template = "o-spreadsheet-Highlight"; static props = { zone: Object, color: String, }; static components = { Corner, Border, }; highlightState = useState({ shiftingMode: "none", }); onResizeHighlight(isLeft, isTop) { const activeSheetId = this.env.model.getters.getActiveSheetId(); this.highlightState.shiftingMode = "isResizing"; const z = this.props.zone; const pivotCol = isLeft ? z.right : z.left; const pivotRow = isTop ? z.bottom : z.top; let lastCol = isLeft ? z.left : z.right; let lastRow = isTop ? z.top : z.bottom; let currentZone = z; this.env.model.dispatch("START_CHANGE_HIGHLIGHT", { zone: currentZone }); const mouseMove = (col, row) => { if (lastCol !== col || lastRow !== row) { lastCol = clip(col === -1 ? lastCol : col, 0, this.env.model.getters.getNumberCols(activeSheetId) - 1); lastRow = clip(row === -1 ? lastRow : row, 0, this.env.model.getters.getNumberRows(activeSheetId) - 1); let newZone = { left: Math.min(pivotCol, lastCol), top: Math.min(pivotRow, lastRow), right: Math.max(pivotCol, lastCol), bottom: Math.max(pivotRow, lastRow), }; if (!isEqual(newZone, currentZone)) { this.env.model.selection.selectZone({ cell: { col: newZone.left, row: newZone.top }, zone: newZone, }, { unbounded: true }); currentZone = newZone; } } }; const mouseUp = () => { this.highlightState.shiftingMode = "none"; }; dragAndDropBeyondTheViewport(this.env, mouseMove, mouseUp); } onMoveHighlight(clientX, clientY) { this.highlightState.shiftingMode = "isMoving"; const z = this.props.zone; const position = gridOverlayPosition(); const activeSheetId = this.env.model.getters.getActiveSheetId(); const initCol = this.env.model.getters.getColIndex(clientX - position.left); const initRow = this.env.model.getters.getRowIndex(clientY - position.top); const deltaColMin = -z.left; const deltaColMax = this.env.model.getters.getNumberCols(activeSheetId) - z.right - 1; const deltaRowMin = -z.top; const deltaRowMax = this.env.model.getters.getNumberRows(activeSheetId) - z.bottom - 1; let currentZone = z; this.env.model.dispatch("START_CHANGE_HIGHLIGHT", { zone: currentZone }); let lastCol = initCol; let lastRow = initRow; const mouseMove = (col, row) => { if (lastCol !== col || lastRow !== row) { lastCol = col === -1 ? lastCol : col; lastRow = row === -1 ? lastRow : row; const deltaCol = clip(lastCol - initCol, deltaColMin, deltaColMax); const deltaRow = clip(lastRow - initRow, deltaRowMin, deltaRowMax); let newZone = { left: z.left + deltaCol, top: z.top + deltaRow, right: z.right + deltaCol, bottom: z.bottom + deltaRow, }; if (!isEqual(newZone, currentZone)) { this.env.model.selection.selectZone({ cell: { col: newZone.left, row: newZone.top }, zone: newZone, }, { unbounded: true }); currentZone = newZone; } } }; const mouseUp = () => { this.highlightState.shiftingMode = "none"; }; dragAndDropBeyondTheViewport(this.env, mouseMove, mouseUp); } } let ScrollBar$1 = class ScrollBar { direction; el; constructor(el, direction) { this.el = el; this.direction = direction; } get scroll() { return this.direction === "horizontal" ? this.el.scrollLeft : this.el.scrollTop; } set scroll(value) { if (this.direction === "horizontal") { this.el.scrollLeft = value; } else { this.el.scrollTop = value; } } }; css /* scss */ ` .o-scrollbar { position: absolute; overflow: auto; z-index: ${ComponentsImportance.ScrollBar}; background-color: ${BACKGROUND_GRAY_COLOR}; &.corner { right: 0px; bottom: 0px; height: ${SCROLLBAR_WIDTH}px; width: ${SCROLLBAR_WIDTH}px; border-top: 1px solid #e2e3e3; border-left: 1px solid #e2e3e3; } } `; class ScrollBar extends Component { static props = { width: { type: Number, optional: true }, height: { type: Number, optional: true }, direction: String, position: Object, offset: Number, onScroll: Function, }; static template = xml /*xml*/ `
`; static defaultProps = { width: 1, height: 1, }; scrollbarRef; scrollbar; setup() { this.scrollbarRef = useRef("scrollbar"); this.scrollbar = new ScrollBar$1(this.scrollbarRef.el, this.props.direction); onMounted(() => { this.scrollbar.el = this.scrollbarRef.el; }); // TODO improve useEffect dependencies typing in owl useEffect(() => { if (this.scrollbar.scroll !== this.props.offset) { this.scrollbar.scroll = this.props.offset; } }, () => [this.scrollbar.scroll, this.props.offset]); } get sizeCss() { return cssPropertiesToCss({ width: `${this.props.width}px`, height: `${this.props.height}px`, }); } get positionCss() { return cssPropertiesToCss(this.props.position); } onScroll(ev) { if (this.props.offset !== this.scrollbar.scroll) { this.props.onScroll(this.scrollbar.scroll); } } } class HorizontalScrollBar extends Component { static props = { leftOffset: { type: Number, optional: true }, }; static components = { ScrollBar }; static template = xml /*xml*/ ` `; static defaultProps = { leftOffset: 0, }; get offset() { return this.env.model.getters.getActiveSheetDOMScrollInfo().scrollX; } get width() { return this.env.model.getters.getMainViewportRect().width; } get isDisplayed() { const { xRatio } = this.env.model.getters.getFrozenSheetViewRatio(this.env.model.getters.getActiveSheetId()); return xRatio < 1; } get position() { const { x } = this.env.model.getters.getMainViewportRect(); return { left: `${this.props.leftOffset + x}px`, bottom: "0px", height: `${SCROLLBAR_WIDTH}px`, right: `0px`, }; } onScroll(offset) { const { scrollY } = this.env.model.getters.getActiveSheetDOMScrollInfo(); this.env.model.dispatch("SET_VIEWPORT_OFFSET", { offsetX: offset, offsetY: scrollY, // offsetY is the same }); } } class VerticalScrollBar extends Component { static props = { topOffset: { type: Number, optional: true }, }; static components = { ScrollBar }; static template = xml /*xml*/ ` `; static defaultProps = { topOffset: 0, }; get offset() { return this.env.model.getters.getActiveSheetDOMScrollInfo().scrollY; } get height() { return this.env.model.getters.getMainViewportRect().height; } get isDisplayed() { const { yRatio } = this.env.model.getters.getFrozenSheetViewRatio(this.env.model.getters.getActiveSheetId()); return yRatio < 1; } get position() { const { y } = this.env.model.getters.getMainViewportRect(); return { top: `${this.props.topOffset + y}px`, right: "0px", width: `${SCROLLBAR_WIDTH}px`, bottom: `0px`, }; } onScroll(offset) { const { scrollX } = this.env.model.getters.getActiveSheetDOMScrollInfo(); this.env.model.dispatch("SET_VIEWPORT_OFFSET", { offsetX: scrollX, // offsetX is the same offsetY: offset, }); } } const DEFAULT_SIDE_PANEL_SIZE = 350; const MIN_SHEET_VIEW_WIDTH = 150; class SidePanelStore extends SpreadsheetStore { mutators = ["open", "toggle", "close", "changePanelSize", "resetPanelSize"]; initialPanelProps = {}; componentTag = ""; panelSize = DEFAULT_SIDE_PANEL_SIZE; get isOpen() { if (!this.componentTag) { return false; } return this.computeState(this.componentTag, this.initialPanelProps).isOpen; } get panelProps() { const state = this.computeState(this.componentTag, this.initialPanelProps); if (state.isOpen) { return state.props ?? {}; } return {}; } get panelKey() { const state = this.computeState(this.componentTag, this.initialPanelProps); if (state.isOpen) { return state.key; } return undefined; } open(componentTag, panelProps = {}) { const state = this.computeState(componentTag, panelProps); if (state.isOpen === false) { return; } if (this.isOpen && componentTag !== this.componentTag) { this.initialPanelProps?.onCloseSidePanel?.(); } this.componentTag = componentTag; this.initialPanelProps = state.props ?? {}; } toggle(componentTag, panelProps) { if (this.isOpen && componentTag === this.componentTag) { this.close(); } else { this.open(componentTag, panelProps); } } close() { this.initialPanelProps.onCloseSidePanel?.(); this.initialPanelProps = {}; this.componentTag = ""; } changePanelSize(size, spreadsheetElWidth) { if (size < DEFAULT_SIDE_PANEL_SIZE) { this.panelSize = DEFAULT_SIDE_PANEL_SIZE; } else if (size > spreadsheetElWidth - MIN_SHEET_VIEW_WIDTH) { this.panelSize = Math.max(spreadsheetElWidth - MIN_SHEET_VIEW_WIDTH, DEFAULT_SIDE_PANEL_SIZE); } else { this.panelSize = size; } } resetPanelSize() { this.panelSize = DEFAULT_SIDE_PANEL_SIZE; } computeState(componentTag, panelProps) { const customComputeState = sidePanelRegistry.get(componentTag).computeState; if (!customComputeState) { return { isOpen: true, props: panelProps, }; } else { return customComputeState(this.getters, panelProps); } } } const SIZE = 3; const COLOR = "#777"; css /* scss */ ` .o-table-resizer { width: ${SIZE}px; height: ${SIZE}px; border-bottom: ${SIZE}px solid ${COLOR}; border-right: ${SIZE}px solid ${COLOR}; cursor: nwse-resize; } `; class TableResizer extends Component { static template = "o-spreadsheet-TableResizer"; static props = { table: Object }; state = useState({ highlightZone: undefined }); setup() { useHighlights(this); } get containerStyle() { const tableZone = this.props.table.range.zone; const bottomRight = { ...tableZone, left: tableZone.right, top: tableZone.bottom }; const rect = this.env.model.getters.getVisibleRect(bottomRight); if (rect.height === 0 || rect.width === 0) { return cssPropertiesToCss({ display: "none" }); } return cssPropertiesToCss({ top: `${rect.y + rect.height - SIZE * 2}px`, left: `${rect.x + rect.width - SIZE * 2}px`, }); } onMouseDown(ev) { const tableZone = this.props.table.range.zone; const topLeft = { col: tableZone.left, row: tableZone.top }; document.body.style.cursor = "nwse-resize"; const onMouseUp = () => { document.body.style.cursor = ""; const newTableZone = this.state.highlightZone; if (!newTableZone) return; const sheetId = this.props.table.range.sheetId; this.env.model.dispatch("RESIZE_TABLE", { sheetId, zone: this.props.table.range.zone, newTableRange: this.env.model.getters.getRangeDataFromZone(sheetId, newTableZone), }); this.state.highlightZone = undefined; }; const onMouseMove = (col, row, ev) => { this.state.highlightZone = { left: topLeft.col, top: topLeft.row, right: Math.max(col, topLeft.col), bottom: Math.max(row, topLeft.row), }; }; dragAndDropBeyondTheViewport(this.env, onMouseMove, onMouseUp); } get highlights() { if (!this.state.highlightZone) return []; return [ { zone: this.state.highlightZone, sheetId: this.props.table.range.sheetId, color: COLOR, noFill: true, }, ]; } } const registries$1 = { ROW: rowMenuRegistry, COL: colMenuRegistry, CELL: cellMenuRegistry, GROUP_HEADERS: groupHeadersMenuRegistry, UNGROUP_HEADERS: unGroupHeadersMenuRegistry, }; // ----------------------------------------------------------------------------- // JS // ----------------------------------------------------------------------------- class Grid extends Component { static template = "o-spreadsheet-Grid"; static props = { exposeFocus: Function, }; static components = { GridComposer, GridOverlay, GridPopover, HeadersOverlay, Menu, Autofill, ClientTag, Highlight, Popover, VerticalScrollBar, HorizontalScrollBar, TableResizer, }; HEADER_HEIGHT = HEADER_HEIGHT; HEADER_WIDTH = HEADER_WIDTH; menuState; gridRef; highlightStore; cellPopovers; composerFocusStore; DOMFocusableElementStore; paintFormatStore; onMouseWheel; canvasPosition; hoveredCell; sidePanel; setup() { this.highlightStore = useStore(HighlightStore); this.menuState = useState({ isOpen: false, position: null, menuItems: [], }); this.gridRef = useRef("grid"); this.canvasPosition = useAbsoluteBoundingRect(this.gridRef); this.hoveredCell = useStore(HoveredCellStore); this.composerFocusStore = useStore(ComposerFocusStore); this.DOMFocusableElementStore = useStore(DOMFocusableElementStore); this.sidePanel = useStore(SidePanelStore); this.paintFormatStore = useStore(PaintFormatStore); useStore(ArrayFormulaHighlight); useChildSubEnv({ getPopoverContainerRect: () => this.getGridRect() }); useExternalListener(document.body, "cut", this.copy.bind(this, true)); useExternalListener(document.body, "copy", this.copy.bind(this, false)); useExternalListener(document.body, "paste", this.paste); onMounted(() => this.focusDefaultElement()); this.props.exposeFocus(() => this.focusDefaultElement()); useGridDrawing("canvas", this.env.model, () => this.env.model.getters.getSheetViewDimensionWithHeaders()); this.onMouseWheel = useWheelHandler((deltaX, deltaY) => { this.moveCanvas(deltaX, deltaY); this.hoveredCell.clear(); }); this.cellPopovers = useStore(CellPopoverStore); useEffect(() => { if (!this.sidePanel.isOpen) { this.DOMFocusableElementStore.focus(); } }, () => [this.sidePanel.isOpen]); } onCellHovered({ col, row }) { this.hoveredCell.hover({ col, row }); } get highlights() { return this.highlightStore.highlights; } get gridOverlayDimensions() { return cssPropertiesToCss({ top: `${HEADER_HEIGHT}px`, left: `${HEADER_WIDTH}px`, height: `calc(100% - ${HEADER_HEIGHT + SCROLLBAR_WIDTH}px)`, width: `calc(100% - ${HEADER_WIDTH + SCROLLBAR_WIDTH}px)`, }); } onClosePopover() { if (this.cellPopovers.isOpen) { this.cellPopovers.close(); } this.focusDefaultElement(); } // this map will handle most of the actions that should happen on key down. The arrow keys are managed in the key // down itself keyDownMapping = { Enter: () => { const cell = this.env.model.getters.getActiveCell(); cell.type === CellValueType.empty ? this.onComposerCellFocused() : this.onComposerContentFocused(); }, Tab: () => this.env.model.selection.moveAnchorCell("right", 1), "Shift+Tab": () => this.env.model.selection.moveAnchorCell("left", 1), F2: () => { const cell = this.env.model.getters.getActiveCell(); cell.type === CellValueType.empty ? this.onComposerCellFocused() : this.onComposerContentFocused(); }, Delete: () => { this.env.model.dispatch("DELETE_CONTENT", { sheetId: this.env.model.getters.getActiveSheetId(), target: this.env.model.getters.getSelectedZones(), }); }, Backspace: () => { this.env.model.dispatch("DELETE_CONTENT", { sheetId: this.env.model.getters.getActiveSheetId(), target: this.env.model.getters.getSelectedZones(), }); }, Escape: () => { /** TODO: Clean once we introduce proper focus on sub components. Grid should not have to handle all this logic */ if (this.cellPopovers.isOpen) { this.cellPopovers.close(); } else if (this.menuState.isOpen) { this.closeMenu(); } else if (this.paintFormatStore.isActive) { this.paintFormatStore.cancel(); } else { this.env.model.dispatch("CLEAN_CLIPBOARD_HIGHLIGHT"); } }, "Ctrl+A": () => this.env.model.selection.loopSelection(), "Ctrl+Z": () => this.env.model.dispatch("REQUEST_UNDO"), "Ctrl+Y": () => this.env.model.dispatch("REQUEST_REDO"), F4: () => this.env.model.dispatch("REQUEST_REDO"), "Ctrl+B": () => this.env.model.dispatch("SET_FORMATTING", { sheetId: this.env.model.getters.getActiveSheetId(), target: this.env.model.getters.getSelectedZones(), style: { bold: !this.env.model.getters.getCurrentStyle().bold }, }), "Ctrl+I": () => this.env.model.dispatch("SET_FORMATTING", { sheetId: this.env.model.getters.getActiveSheetId(), target: this.env.model.getters.getSelectedZones(), style: { italic: !this.env.model.getters.getCurrentStyle().italic }, }), "Ctrl+U": () => this.env.model.dispatch("SET_FORMATTING", { sheetId: this.env.model.getters.getActiveSheetId(), target: this.env.model.getters.getSelectedZones(), style: { underline: !this.env.model.getters.getCurrentStyle().underline }, }), "Ctrl+O": () => CREATE_IMAGE(this.env), "Alt+=": () => { const sheetId = this.env.model.getters.getActiveSheetId(); const mainSelectedZone = this.env.model.getters.getSelectedZone(); const { anchor } = this.env.model.getters.getSelection(); const sums = this.env.model.getters.getAutomaticSums(sheetId, mainSelectedZone, anchor.cell); if (this.env.model.getters.isSingleCellOrMerge(sheetId, mainSelectedZone) || (this.env.model.getters.isEmpty(sheetId, mainSelectedZone) && sums.length <= 1)) { const zone = sums[0]?.zone; const zoneXc = zone ? this.env.model.getters.zoneToXC(sheetId, sums[0].zone) : ""; const formula = `=SUM(${zoneXc})`; this.onComposerCellFocused(formula, { start: 5, end: 5 + zoneXc.length }); } else { this.env.model.dispatch("SUM_SELECTION"); } }, "Alt+Enter": () => { const cell = this.env.model.getters.getActiveCell(); if (cell.link) { openLink(cell.link, this.env); } }, "Ctrl+Home": () => { const sheetId = this.env.model.getters.getActiveSheetId(); const { col, row } = this.env.model.getters.getNextVisibleCellPosition({ sheetId, col: 0, row: 0, }); this.env.model.selection.selectCell(col, row); }, "Ctrl+End": () => { const sheetId = this.env.model.getters.getActiveSheetId(); const col = this.env.model.getters.findVisibleHeader(sheetId, "COL", this.env.model.getters.getNumberCols(sheetId) - 1, 0); const row = this.env.model.getters.findVisibleHeader(sheetId, "ROW", this.env.model.getters.getNumberRows(sheetId) - 1, 0); this.env.model.selection.selectCell(col, row); }, "Shift+ ": () => { const sheetId = this.env.model.getters.getActiveSheetId(); const newZone = { ...this.env.model.getters.getSelectedZone(), left: 0, right: this.env.model.getters.getNumberCols(sheetId) - 1, }; const position = this.env.model.getters.getActivePosition(); this.env.model.selection.selectZone({ cell: position, zone: newZone }); }, "Ctrl+ ": () => { const sheetId = this.env.model.getters.getActiveSheetId(); const newZone = { ...this.env.model.getters.getSelectedZone(), top: 0, bottom: this.env.model.getters.getNumberRows(sheetId) - 1, }; const position = this.env.model.getters.getActivePosition(); this.env.model.selection.selectZone({ cell: position, zone: newZone }); }, "Ctrl+D": async () => this.env.model.dispatch("COPY_PASTE_CELLS_ABOVE"), "Ctrl+R": async () => this.env.model.dispatch("COPY_PASTE_CELLS_ON_LEFT"), "Ctrl+Shift+E": () => this.setHorizontalAlign("center"), "Ctrl+Shift+L": () => this.setHorizontalAlign("left"), "Ctrl+Shift+R": () => this.setHorizontalAlign("right"), "Ctrl+Shift+V": () => PASTE_AS_VALUE_ACTION(this.env), "Ctrl+Shift+<": () => this.clearFormatting(), // for qwerty "Ctrl+<": () => this.clearFormatting(), // for azerty "Ctrl+Shift+ ": () => { this.env.model.selection.selectAll(); }, "Ctrl+Alt+=": () => { const activeCols = this.env.model.getters.getActiveCols(); const activeRows = this.env.model.getters.getActiveRows(); const isSingleSelection = this.env.model.getters.getSelectedZones().length === 1; const areFullCols = activeCols.size > 0 && isSingleSelection; const areFullRows = activeRows.size > 0 && isSingleSelection; if (areFullCols && !areFullRows) { INSERT_COLUMNS_BEFORE_ACTION(this.env); } else if (areFullRows && !areFullCols) { INSERT_ROWS_BEFORE_ACTION(this.env); } }, "Ctrl+Alt+-": () => { const columns = [...this.env.model.getters.getActiveCols()]; const rows = [...this.env.model.getters.getActiveRows()]; if (columns.length > 0 && rows.length === 0) { this.env.model.dispatch("REMOVE_COLUMNS_ROWS", { sheetId: this.env.model.getters.getActiveSheetId(), dimension: "COL", elements: columns, }); } else if (rows.length > 0 && columns.length === 0) { this.env.model.dispatch("REMOVE_COLUMNS_ROWS", { sheetId: this.env.model.getters.getActiveSheetId(), dimension: "ROW", elements: rows, }); } }, "Shift+PageDown": () => { this.env.model.dispatch("ACTIVATE_NEXT_SHEET"); }, "Shift+PageUp": () => { this.env.model.dispatch("ACTIVATE_PREVIOUS_SHEET"); }, PageDown: () => this.env.model.dispatch("SHIFT_VIEWPORT_DOWN"), PageUp: () => this.env.model.dispatch("SHIFT_VIEWPORT_UP"), "Ctrl+K": () => INSERT_LINK(this.env), "Alt+Shift+ArrowRight": () => this.processHeaderGroupingKey("right"), "Alt+Shift+ArrowLeft": () => this.processHeaderGroupingKey("left"), "Alt+Shift+ArrowUp": () => this.processHeaderGroupingKey("up"), "Alt+Shift+ArrowDown": () => this.processHeaderGroupingKey("down"), }; focusDefaultElement() { if (!this.env.model.getters.getSelectedFigureId() && this.composerFocusStore.activeComposer.editionMode === "inactive") { this.DOMFocusableElementStore.focus(); } } get gridEl() { if (!this.gridRef.el) { throw new Error("Grid el is not defined."); } return this.gridRef.el; } getAutofillPosition() { const zone = this.env.model.getters.getSelectedZone(); const rect = this.env.model.getters.getVisibleRect(zone); return { left: rect.x + rect.width - AUTOFILL_EDGE_LENGTH / 2, top: rect.y + rect.height - AUTOFILL_EDGE_LENGTH / 2, }; } get isAutofillVisible() { const zone = this.env.model.getters.getSelectedZone(); const rect = this.env.model.getters.getVisibleRect({ left: zone.right, right: zone.right, top: zone.bottom, bottom: zone.bottom, }); return !(rect.width === 0 || rect.height === 0); } onGridResized({ height, width }) { this.env.model.dispatch("RESIZE_SHEETVIEW", { width: width, height: height, gridOffsetX: HEADER_WIDTH, gridOffsetY: HEADER_HEIGHT, }); } moveCanvas(deltaX, deltaY) { const { scrollX, scrollY } = this.env.model.getters.getActiveSheetDOMScrollInfo(); this.env.model.dispatch("SET_VIEWPORT_OFFSET", { offsetX: scrollX + deltaX, offsetY: scrollY + deltaY, }); } getClientPositionKey(client) { return `${client.id}-${client.position?.sheetId}-${client.position?.col}-${client.position?.row}`; } isCellHovered(col, row) { return this.hoveredCell.col === col && this.hoveredCell.row === row; } getGridRect() { return { ...this.canvasPosition, ...this.env.model.getters.getSheetViewDimensionWithHeaders() }; } // --------------------------------------------------------------------------- // Zone selection with mouse // --------------------------------------------------------------------------- onCellClicked(col, row, modifiers) { if (this.composerFocusStore.activeComposer.editionMode === "editing") { this.composerFocusStore.activeComposer.stopEdition(); } if (modifiers.expandZone) { this.env.model.selection.setAnchorCorner(col, row); } else if (modifiers.addZone) { this.env.model.selection.addCellToSelection(col, row); } else { this.env.model.selection.selectCell(col, row); } let prevCol = col; let prevRow = row; const onMouseMove = (col, row, ev) => { // When selecting cells during the edition, we don't want to avoid the default // browser behaviour that will select the text inside the composer // (see related commit msg for more information) ev.preventDefault(); if ((col !== prevCol && col != -1) || (row !== prevRow && row != -1)) { prevCol = col === -1 ? prevCol : col; prevRow = row === -1 ? prevRow : row; this.env.model.selection.setAnchorCorner(prevCol, prevRow); } }; const onMouseUp = () => { if (this.paintFormatStore.isActive) { this.paintFormatStore.pasteFormat(this.env.model.getters.getSelectedZones()); } }; dragAndDropBeyondTheViewport(this.env, onMouseMove, onMouseUp); } onCellDoubleClicked(col, row) { const sheetId = this.env.model.getters.getActiveSheetId(); ({ col, row } = this.env.model.getters.getMainCellPosition({ sheetId, col, row })); const cell = this.env.model.getters.getEvaluatedCell({ sheetId, col, row }); if (cell.type === CellValueType.empty) { this.onComposerCellFocused(); } else { this.onComposerContentFocused(); } } // --------------------------------------------------------------------------- // Keyboard interactions // --------------------------------------------------------------------------- processArrows(ev) { ev.preventDefault(); ev.stopPropagation(); if (this.cellPopovers.isOpen) { this.cellPopovers.close(); } updateSelectionWithArrowKeys(ev, this.env.model.selection); if (this.paintFormatStore.isActive) { this.paintFormatStore.pasteFormat(this.env.model.getters.getSelectedZones()); } } onKeydown(ev) { const keyDownString = keyboardEventToShortcutString(ev); const handler = this.keyDownMapping[keyDownString]; if (handler) { ev.preventDefault(); ev.stopPropagation(); handler(); return; } if (ev.key.startsWith("Arrow")) { this.processArrows(ev); return; } } // --------------------------------------------------------------------------- // Context Menu // --------------------------------------------------------------------------- onInputContextMenu(ev) { ev.preventDefault(); const lastZone = this.env.model.getters.getSelectedZone(); const { left: col, top: row } = lastZone; let type = "CELL"; this.composerFocusStore.activeComposer.stopEdition(); if (this.env.model.getters.getActiveCols().has(col)) { type = "COL"; } else if (this.env.model.getters.getActiveRows().has(row)) { type = "ROW"; } const { x, y, width } = this.env.model.getters.getVisibleRect(lastZone); const gridRect = this.getGridRect(); this.toggleContextMenu(type, gridRect.x + x + width, gridRect.y + y); } onCellRightClicked(col, row, { x, y }) { const zones = this.env.model.getters.getSelectedZones(); const lastZone = zones[zones.length - 1]; let type = "CELL"; if (!isInside(col, row, lastZone)) { this.env.model.selection.getBackToDefault(); this.env.model.selection.selectCell(col, row); } else { if (this.env.model.getters.getActiveCols().has(col)) { type = "COL"; } else if (this.env.model.getters.getActiveRows().has(row)) { type = "ROW"; } } this.toggleContextMenu(type, x, y); } toggleContextMenu(type, x, y) { if (this.cellPopovers.isOpen) { this.cellPopovers.close(); } this.menuState.isOpen = true; this.menuState.position = { x, y }; this.menuState.menuItems = registries$1[type].getMenuItems(); } async copy(cut, ev) { if (!this.gridEl.contains(document.activeElement)) { return; } /* If we are currently editing a cell, let the default behavior */ if (this.composerFocusStore.activeComposer.editionMode !== "inactive") { return; } if (cut) { interactiveCut(this.env); } else { this.env.model.dispatch("COPY"); } const content = this.env.model.getters.getClipboardContent(); const clipboardData = ev.clipboardData; for (const type in content) { clipboardData?.setData(type, content[type]); } ev.preventDefault(); } async paste(ev) { if (!this.gridEl.contains(document.activeElement)) { return; } ev.preventDefault(); const clipboardData = ev.clipboardData; if (!clipboardData) { return; } const osClipboard = { content: { [ClipboardMIMEType.PlainText]: clipboardData?.getData(ClipboardMIMEType.PlainText), [ClipboardMIMEType.Html]: clipboardData?.getData(ClipboardMIMEType.Html), }, }; const target = this.env.model.getters.getSelectedZones(); const isCutOperation = this.env.model.getters.isCutOperation(); const clipboardContent = parseOSClipboardContent(osClipboard.content); const clipboardId = clipboardContent.data?.clipboardId; if (this.env.model.getters.getClipboardId() === clipboardId) { interactivePaste(this.env, target); } else { interactivePasteFromOS(this.env, target, clipboardContent); } if (isCutOperation) { await this.env.clipboard.write({ [ClipboardMIMEType.PlainText]: "" }); } } clearFormatting() { this.env.model.dispatch("CLEAR_FORMATTING", { sheetId: this.env.model.getters.getActiveSheetId(), target: this.env.model.getters.getSelectedZones(), }); } setHorizontalAlign(align) { this.env.model.dispatch("SET_FORMATTING", { sheetId: this.env.model.getters.getActiveSheetId(), target: this.env.model.getters.getSelectedZones(), style: { align }, }); } closeMenu() { this.menuState.isOpen = false; this.focusDefaultElement(); } processHeaderGroupingKey(direction) { if (this.env.model.getters.getSelectedZones().length !== 1) { return; } const selectingRows = this.env.model.getters.getActiveRows().size > 0; const selectingCols = this.env.model.getters.getActiveCols().size > 0; if (selectingCols && selectingRows) { this.processHeaderGroupingEventOnWholeSheet(direction); } else if (selectingCols) { this.processHeaderGroupingEventOnHeaders(direction, "COL"); } else if (selectingRows) { this.processHeaderGroupingEventOnHeaders(direction, "ROW"); } else { this.processHeaderGroupingEventOnGrid(direction); } } processHeaderGroupingEventOnHeaders(direction, dimension) { const sheetId = this.env.model.getters.getActiveSheetId(); const zone = this.env.model.getters.getSelectedZone(); const start = dimension === "COL" ? zone.left : zone.top; const end = dimension === "COL" ? zone.right : zone.bottom; switch (direction) { case "right": this.env.model.dispatch("GROUP_HEADERS", { sheetId, dimension: dimension, start, end }); break; case "left": this.env.model.dispatch("UNGROUP_HEADERS", { sheetId, dimension: dimension, start, end }); break; case "down": this.env.model.dispatch("UNFOLD_HEADER_GROUPS_IN_ZONE", { sheetId, dimension, zone }); break; case "up": this.env.model.dispatch("FOLD_HEADER_GROUPS_IN_ZONE", { sheetId, dimension, zone }); break; } } processHeaderGroupingEventOnWholeSheet(direction) { const sheetId = this.env.model.getters.getActiveSheetId(); if (direction === "up") { this.env.model.dispatch("FOLD_ALL_HEADER_GROUPS", { sheetId, dimension: "ROW" }); this.env.model.dispatch("FOLD_ALL_HEADER_GROUPS", { sheetId, dimension: "COL" }); } else if (direction === "down") { this.env.model.dispatch("UNFOLD_ALL_HEADER_GROUPS", { sheetId, dimension: "ROW" }); this.env.model.dispatch("UNFOLD_ALL_HEADER_GROUPS", { sheetId, dimension: "COL" }); } } processHeaderGroupingEventOnGrid(direction) { const sheetId = this.env.model.getters.getActiveSheetId(); const zone = this.env.model.getters.getSelectedZone(); switch (direction) { case "down": this.env.model.dispatch("UNFOLD_HEADER_GROUPS_IN_ZONE", { sheetId, dimension: "ROW", zone: zone, }); this.env.model.dispatch("UNFOLD_HEADER_GROUPS_IN_ZONE", { sheetId, dimension: "COL", zone: zone, }); break; case "up": this.env.model.dispatch("FOLD_HEADER_GROUPS_IN_ZONE", { sheetId, dimension: "ROW", zone: zone, }); this.env.model.dispatch("FOLD_HEADER_GROUPS_IN_ZONE", { sheetId, dimension: "COL", zone: zone, }); break; case "right": { const { x, y, width } = this.env.model.getters.getVisibleRect(zone); const gridRect = this.getGridRect(); this.toggleContextMenu("GROUP_HEADERS", x + width + gridRect.x, y + gridRect.y); break; } case "left": { if (!canUngroupHeaders(this.env, "COL") && !canUngroupHeaders(this.env, "ROW")) { return; } const { x, y, width } = this.env.model.getters.getVisibleRect(zone); const gridRect = this.getGridRect(); this.toggleContextMenu("UNGROUP_HEADERS", x + width + gridRect.x, y + gridRect.y); break; } } } onComposerCellFocused(content, selection) { this.composerFocusStore.focusActiveComposer({ content, selection, focusMode: "cellFocus" }); } onComposerContentFocused() { this.composerFocusStore.focusActiveComposer({ focusMode: "contentFocus" }); } get staticTables() { const sheetId = this.env.model.getters.getActiveSheetId(); return this.env.model.getters.getCoreTables(sheetId).filter(isStaticTable); } } /** @odoo-module */ class EditableName extends Component { static template = "o-spreadsheet-EditableName"; static props = { name: String, displayName: String, onChanged: Function, }; state; setup() { this.state = useState({ isEditing: false, name: "", }); } rename() { this.state.isEditing = true; this.state.name = this.props.name; } save() { this.props.onChanged(this.state.name.trim()); this.state.isEditing = false; } } /** * BasePlugin * * Since the spreadsheet internal state is quite complex, it is split into * multiple parts, each managing a specific concern. * * This file introduce the BasePlugin, which is the common class that defines * how each of these model sub parts should interact with each other. * There are two kind of plugins: core plugins handling persistent data * and UI plugins handling transient data. */ class BasePlugin { static getters = []; history; dispatch; canDispatch; constructor(stateObserver, dispatch, canDispatch) { this.history = Object.assign(Object.create(stateObserver), { update: stateObserver.addChange.bind(stateObserver, this), selectCell: () => { }, }); this.dispatch = dispatch; this.canDispatch = canDispatch; } /** * Export for excel should be available for all plugins, even for the UI. * In some case, we need to export evaluated value, which is available from * UI plugin only. */ exportForExcel(data) { } // --------------------------------------------------------------------------- // Command handling // --------------------------------------------------------------------------- /** * Before a command is accepted, the model will ask each plugin if the command * is allowed. If all of then return true, then we can proceed. Otherwise, * the command is cancelled. * * There should not be any side effects in this method. */ allowDispatch(command) { return "Success" /* CommandResult.Success */; } /** * This method is useful when a plugin need to perform some action before a * command is handled in another plugin. This should only be used if it is not * possible to do the work in the handle method. */ beforeHandle(command) { } /** * This is the standard place to handle any command. Most of the plugin * command handling work should take place here. */ handle(command) { } /** * Sometimes, it is useful to perform some work after a command (and all its * subcommands) has been completely handled. For example, when we paste * multiple cells, we only want to reevaluate the cell values once at the end. */ finalize() { } /** * Combine multiple validation functions into a single function * returning the list of result of every validation. */ batchValidations(...validations) { return (toValidate) => validations.map((validation) => validation.call(this, toValidate)).flat(); } /** * Combine multiple validation functions. Every validation is executed one after * the other. As soon as one validation fails, it stops and the cancelled reason * is returned. */ chainValidations(...validations) { return (toValidate) => { for (const validation of validations) { let results = validation.call(this, toValidate); if (!Array.isArray(results)) { results = [results]; } const cancelledReasons = results.filter((result) => result !== "Success" /* CommandResult.Success */); if (cancelledReasons.length) { return cancelledReasons; } } return "Success" /* CommandResult.Success */; }; } checkValidations(command, ...validations) { return this.batchValidations(...validations)(command); } } /** * Core plugins handle spreadsheet data. * They are responsible to import, export and maintain the spreadsheet * persisted state. * They should not be concerned about UI parts or transient state. */ class CorePlugin extends BasePlugin { getters; uuidGenerator; constructor({ getters, stateObserver, range, dispatch, canDispatch, uuidGenerator, }) { super(stateObserver, dispatch, canDispatch); range.addRangeProvider(this.adaptRanges.bind(this)); this.getters = getters; this.uuidGenerator = uuidGenerator; } // --------------------------------------------------------------------------- // Import/Export // --------------------------------------------------------------------------- import(data) { } export(data) { } /** * This method can be implemented in any plugin, to loop over the plugin's data structure and adapt the plugin's ranges. * To adapt them, the implementation of the function must have a perfect knowledge of the data structure, thus * implementing the loops over it makes sense in the plugin itself. * When calling the method applyChange, the range will be adapted if necessary, then a copy will be returned along with * the type of change that occurred. * * @param applyChange a function that, when called, will adapt the range according to the change on the grid * @param sheetId an optional sheetId to adapt either range of that sheet specifically, or ranges pointing to that sheet */ adaptRanges(applyChange, sheetId) { } /** * Implement this method to clean unused external resources, such as images * stored on a server which have been deleted. */ garbageCollectExternalResources() { } } /** * Formatting plugin. * * This plugin manages all things related to a cell look: * - borders */ class BordersPlugin extends CorePlugin { static getters = ["getCellBorder", "getBordersColors"]; borders = {}; // --------------------------------------------------------------------------- // Command Handling // --------------------------------------------------------------------------- allowDispatch(cmd) { switch (cmd.type) { case "SET_BORDER": return this.checkBordersUnchanged(cmd); default: return "Success" /* CommandResult.Success */; } } handle(cmd) { switch (cmd.type) { case "ADD_MERGE": for (const zone of cmd.target) { this.addBordersToMerge(cmd.sheetId, zone); } break; case "DUPLICATE_SHEET": const borders = this.borders[cmd.sheetId]; if (borders) { // borders is a sparse 2D array. // map and slice preserve empty values and do not set `undefined` instead const bordersCopy = borders .slice() .map((col) => col?.slice().map((border) => ({ ...border }))); this.history.update("borders", cmd.sheetIdTo, bordersCopy); } break; case "DELETE_SHEET": const allBorders = { ...this.borders }; delete allBorders[cmd.sheetId]; this.history.update("borders", allBorders); break; case "SET_BORDER": this.setBorder(cmd.sheetId, cmd.col, cmd.row, cmd.border); break; case "SET_ZONE_BORDERS": if (cmd.border) { const target = cmd.target.map((zone) => this.getters.expandZone(cmd.sheetId, zone)); this.setBorders(cmd.sheetId, target, cmd.border.position, cmd.border.color === "" ? undefined : { style: cmd.border.style || DEFAULT_BORDER_DESC.style, color: cmd.border.color || DEFAULT_BORDER_DESC.color, }); } break; case "CLEAR_FORMATTING": this.clearBorders(cmd.sheetId, cmd.target); break; case "REMOVE_COLUMNS_ROWS": const elements = [...cmd.elements].sort((a, b) => b - a); for (const group of groupConsecutive(elements)) { if (cmd.dimension === "COL") { this.shiftBordersHorizontally(cmd.sheetId, group[group.length - 1] + 1, -group.length); } else { this.shiftBordersVertically(cmd.sheetId, group[group.length - 1] + 1, -group.length); } } break; case "ADD_COLUMNS_ROWS": if (cmd.dimension === "COL") { this.handleAddColumns(cmd); } else { this.handleAddRows(cmd); } break; } } /** * Move borders according to the inserted columns. * Ensure borders continuity. */ handleAddColumns(cmd) { // The new columns have already been inserted in the sheet at this point. let colLeftOfInsertion; let colRightOfInsertion; if (cmd.position === "before") { this.shiftBordersHorizontally(cmd.sheetId, cmd.base, cmd.quantity, { moveFirstLeftBorder: true, }); colLeftOfInsertion = cmd.base - 1; colRightOfInsertion = cmd.base + cmd.quantity; } else { this.shiftBordersHorizontally(cmd.sheetId, cmd.base + 1, cmd.quantity, { moveFirstLeftBorder: false, }); colLeftOfInsertion = cmd.base; colRightOfInsertion = cmd.base + cmd.quantity + 1; } this.ensureColumnBorderContinuity(cmd.sheetId, colLeftOfInsertion, colRightOfInsertion); } /** * Move borders according to the inserted rows. * Ensure borders continuity. */ handleAddRows(cmd) { // The new rows have already been inserted at this point. let rowAboveInsertion; let rowBelowInsertion; if (cmd.position === "before") { this.shiftBordersVertically(cmd.sheetId, cmd.base, cmd.quantity, { moveFirstTopBorder: true, }); rowAboveInsertion = cmd.base - 1; rowBelowInsertion = cmd.base + cmd.quantity; } else { this.shiftBordersVertically(cmd.sheetId, cmd.base + 1, cmd.quantity, { moveFirstTopBorder: false, }); rowAboveInsertion = cmd.base; rowBelowInsertion = cmd.base + cmd.quantity + 1; } this.ensureRowBorderContinuity(cmd.sheetId, rowAboveInsertion, rowBelowInsertion); } // --------------------------------------------------------------------------- // Getters // --------------------------------------------------------------------------- getCellBorder({ sheetId, col, row }) { const border = { top: this.borders[sheetId]?.[col]?.[row]?.horizontal, bottom: this.borders[sheetId]?.[col]?.[row + 1]?.horizontal, left: this.borders[sheetId]?.[col]?.[row]?.vertical, right: this.borders[sheetId]?.[col + 1]?.[row]?.vertical, }; if (!border.bottom && !border.left && !border.right && !border.top) { return null; } return border; } getBordersColors(sheetId) { const colors = []; const sheetBorders = this.borders[sheetId]; if (sheetBorders) { for (const borders of sheetBorders.filter(isDefined)) { for (const cellBorder of borders) { if (cellBorder?.horizontal) { colors.push(cellBorder.horizontal.color); } if (cellBorder?.vertical) { colors.push(cellBorder.vertical.color); } } } } return colors; } // --------------------------------------------------------------------------- // Private // --------------------------------------------------------------------------- /** * Ensure border continuity between two columns. * If the two columns have the same borders (at each row respectively), * the same borders are applied to each cell in between. */ ensureColumnBorderContinuity(sheetId, leftColumn, rightColumn) { const targetCols = range(leftColumn + 1, rightColumn); for (let row = 0; row < this.getters.getNumberRows(sheetId); row++) { const leftBorder = this.getCellBorder({ sheetId, col: leftColumn, row }); const rightBorder = this.getCellBorder({ sheetId, col: rightColumn, row }); if (leftBorder && rightBorder) { const commonSides = this.getCommonSides(leftBorder, rightBorder); for (let col of targetCols) { this.addBorder(sheetId, col, row, commonSides); } } } } /** * Ensure border continuity between two rows. * If the two rows have the same borders (at each column respectively), * the same borders are applied to each cell in between. */ ensureRowBorderContinuity(sheetId, topRow, bottomRow) { const targetRows = range(topRow + 1, bottomRow); for (let col = 0; col < this.getters.getNumberCols(sheetId); col++) { const aboveBorder = this.getCellBorder({ sheetId, col, row: topRow }); const belowBorder = this.getCellBorder({ sheetId, col, row: bottomRow }); if (aboveBorder && belowBorder) { const commonSides = this.getCommonSides(aboveBorder, belowBorder); for (let row of targetRows) { this.addBorder(sheetId, col, row, commonSides); } } } } /** * From two borders, return a new border with sides defined in both borders. * i.e. the intersection of two borders. */ getCommonSides(border1, border2) { const commonBorder = {}; for (let side of ["top", "bottom", "left", "right"]) { if (border1[side] && border1[side] === border2[side]) { commonBorder[side] = border1[side]; } } return commonBorder; } /** * Get all the columns which contains at least a border */ getColumnsWithBorders(sheetId) { const sheetBorders = this.borders[sheetId]; if (!sheetBorders) return []; return Object.keys(sheetBorders).map((index) => parseInt(index, 10)); } /** * Get all the rows which contains at least a border */ getRowsWithBorders(sheetId) { const sheetBorders = this.borders[sheetId]?.filter(isDefined); if (!sheetBorders) return []; const rowsWithBorders = new Set(); for (const rowBorders of sheetBorders) { for (const rowBorder in rowBorders) { rowsWithBorders.add(parseInt(rowBorder, 10)); } } return Array.from(rowsWithBorders); } /** * Get the range of all the rows in the sheet */ getRowsRange(sheetId) { const sheetBorders = this.borders[sheetId]; if (!sheetBorders) return []; return range(0, this.getters.getNumberRows(sheetId) + 1); } /** * Move borders of a sheet horizontally. * @param sheetId * @param start starting column (included) * @param delta how much borders will be moved (negative if moved to the left) */ shiftBordersHorizontally(sheetId, start, delta, { moveFirstLeftBorder } = {}) { const borders = this.borders[sheetId]; if (!borders) return; if (delta < 0) { this.moveBordersOfColumn(sheetId, start, delta, "vertical", { destructive: false, }); } this.getColumnsWithBorders(sheetId) .filter((col) => col >= start) .sort((a, b) => (delta < 0 ? a - b : b - a)) // start by the end when moving up .forEach((col) => { if ((col === start && moveFirstLeftBorder) || col !== start) { this.moveBordersOfColumn(sheetId, col, delta, "vertical"); } this.moveBordersOfColumn(sheetId, col, delta, "horizontal"); }); } /** * Move borders of a sheet vertically. * @param sheetId * @param start starting row (included) * @param delta how much borders will be moved (negative if moved to the above) */ shiftBordersVertically(sheetId, start, delta, { moveFirstTopBorder } = {}) { const borders = this.borders[sheetId]; if (!borders) return; if (delta < 0) { this.moveBordersOfRow(sheetId, start, delta, "horizontal", { destructive: false, }); } this.getRowsWithBorders(sheetId) .filter((row) => row >= start) .sort((a, b) => (delta < 0 ? a - b : b - a)) // start by the end when moving up .forEach((row) => { if ((row === start && moveFirstTopBorder) || row !== start) { this.moveBordersOfRow(sheetId, row, delta, "horizontal"); } this.moveBordersOfRow(sheetId, row, delta, "vertical"); }); } /** * Moves the borders (left if `vertical` or top if `horizontal` depending on * `borderDirection`) of all cells in an entire row `delta` rows to the right * (`delta` > 0) or to the left (`delta` < 0). * Note that as the left of a cell is the right of the cell-1, if the left is * moved the right is also moved. However, if `horizontal`, the bottom border * is not moved. * It does it by replacing the target border by the moved border. If the * argument `destructive` is given false, the target border is preserved if * the moved border is empty */ moveBordersOfRow(sheetId, row, delta, borderDirection, { destructive } = { destructive: true }) { const borders = this.borders[sheetId]; if (!borders) return; this.getColumnsWithBorders(sheetId).forEach((col) => { const targetBorder = borders[col]?.[row + delta]?.[borderDirection]; const movedBorder = borders[col]?.[row]?.[borderDirection]; this.history.update("borders", sheetId, col, row + delta, borderDirection, destructive ? movedBorder : movedBorder || targetBorder); this.history.update("borders", sheetId, col, row, borderDirection, undefined); }); } /** * Moves the borders (left if `vertical` or top if `horizontal` depending on * `borderDirection`) of all cells in an entire column `delta` columns below * (`delta` > 0) or above (`delta` < 0). * Note that as the top of a cell is the bottom of the cell-1, if the top is * moved the bottom is also moved. However, if `vertical`, the right border * is not moved. * It does it by replacing the target border by the moved border. If the * argument `destructive` is given false, the target border is preserved if * the moved border is empty */ moveBordersOfColumn(sheetId, col, delta, borderDirection, { destructive } = { destructive: true }) { const borders = this.borders[sheetId]; if (!borders) return; this.getRowsRange(sheetId).forEach((row) => { const targetBorder = borders[col + delta]?.[row]?.[borderDirection]; const movedBorder = borders[col]?.[row]?.[borderDirection]; this.history.update("borders", sheetId, col + delta, row, borderDirection, destructive ? movedBorder : movedBorder || targetBorder); this.history.update("borders", sheetId, col, row, borderDirection, undefined); }); } /** * Set the borders of a cell. * It overrides the current border if override === true. */ setBorder(sheetId, col, row, border, override = true) { if (override || !this.borders?.[sheetId]?.[col]?.[row]?.vertical) { this.history.update("borders", sheetId, col, row, "vertical", border?.left); } if (override || !this.borders?.[sheetId]?.[col]?.[row]?.horizontal) { this.history.update("borders", sheetId, col, row, "horizontal", border?.top); } if (override || !this.borders?.[sheetId]?.[col + 1]?.[row]?.vertical) { this.history.update("borders", sheetId, col + 1, row, "vertical", border?.right); } if (override || !this.borders?.[sheetId]?.[col]?.[row + 1]?.horizontal) { this.history.update("borders", sheetId, col, row + 1, "horizontal", border?.bottom); } } /** * Remove the borders of a zone */ clearBorders(sheetId, zones) { for (let zone of recomputeZones(zones)) { for (let row = zone.top; row <= zone.bottom; row++) { this.history.update("borders", sheetId, zone.right + 1, row, "vertical", undefined); for (let col = zone.left; col <= zone.right; col++) { this.history.update("borders", sheetId, col, row, undefined); } } for (let col = zone.left; col <= zone.right; col++) { this.history.update("borders", sheetId, col, zone.bottom + 1, "horizontal", undefined); } } } /** * Add a border to the existing one to a cell */ addBorder(sheetId, col, row, border) { this.setBorder(sheetId, col, row, { ...this.getCellBorder({ sheetId, col, row }), ...border, }); } /** * Set the borders of a zone by computing the borders to add from the given * command */ setBorders(sheetId, zones, position, border) { if (position === "clear") { return this.clearBorders(sheetId, zones); } for (let zone of recomputeZones(zones)) { if (position === "h" || position === "hv" || position === "all") { for (let row = zone.top + 1; row <= zone.bottom; row++) { for (let col = zone.left; col <= zone.right; col++) { this.addBorder(sheetId, col, row, { top: border }); } } } if (position === "v" || position === "hv" || position === "all") { for (let row = zone.top; row <= zone.bottom; row++) { for (let col = zone.left + 1; col <= zone.right; col++) { this.addBorder(sheetId, col, row, { left: border }); } } } if (position === "left" || position === "all" || position === "external") { for (let row = zone.top; row <= zone.bottom; row++) { this.addBorder(sheetId, zone.left, row, { left: border }); } } if (position === "right" || position === "all" || position === "external") { for (let row = zone.top; row <= zone.bottom; row++) { this.addBorder(sheetId, zone.right + 1, row, { left: border }); } } if (position === "top" || position === "all" || position === "external") { for (let col = zone.left; col <= zone.right; col++) { this.addBorder(sheetId, col, zone.top, { top: border }); } } if (position === "bottom" || position === "all" || position === "external") { for (let col = zone.left; col <= zone.right; col++) { this.addBorder(sheetId, col, zone.bottom + 1, { top: border }); } } } } /** * Compute the borders to add to the given zone merged. */ addBordersToMerge(sheetId, zone) { const { left, right, top, bottom } = zone; const bordersTopLeft = this.getCellBorder({ sheetId, col: left, row: top }); const bordersBottomRight = this.getCellBorder({ sheetId, col: right, row: bottom }); this.clearBorders(sheetId, [zone]); if (bordersTopLeft?.top) { this.setBorders(sheetId, [{ ...zone, bottom: top }], "top", bordersTopLeft.top); } if (bordersTopLeft?.left) { this.setBorders(sheetId, [{ ...zone, right: left }], "left", bordersTopLeft.left); } if (bordersBottomRight?.bottom) { this.setBorders(sheetId, [{ ...zone, top: bottom }], "bottom", bordersBottomRight.bottom); } else if (bordersTopLeft?.bottom) { this.setBorders(sheetId, [{ ...zone, top: bottom }], "bottom", bordersTopLeft.bottom); } if (bordersBottomRight?.right) { this.setBorders(sheetId, [{ ...zone, left: right }], "right", bordersBottomRight.right); } else if (bordersTopLeft?.right) { this.setBorders(sheetId, [{ ...zone, left: right }], "right", bordersTopLeft.right); } } checkBordersUnchanged(cmd) { const currentBorder = this.getCellBorder(cmd); const areAllNewBordersUndefined = !cmd.border?.bottom && !cmd.border?.left && !cmd.border?.right && !cmd.border?.top; if ((!currentBorder && areAllNewBordersUndefined) || deepEquals(currentBorder, cmd.border)) { return "NoChanges" /* CommandResult.NoChanges */; } return "Success" /* CommandResult.Success */; } // --------------------------------------------------------------------------- // Import/Export // --------------------------------------------------------------------------- import(data) { // Borders if (Object.keys(data.borders || {}).length) { for (let sheet of data.sheets) { for (const zoneXc in sheet.borders) { const borderId = sheet.borders[zoneXc]; const border = data.borders[borderId]; const zone = toZone(zoneXc); for (let row = zone.top; row <= zone.bottom; row++) { for (let col = zone.left; col <= zone.right; col++) { this.setBorder(sheet.id, col, row, border, false); } } } } } // Merges for (let sheetData of data.sheets) { if (sheetData.merges) { for (let merge of sheetData.merges) { this.addBordersToMerge(sheetData.id, toZone(merge)); } } } } export(data) { const borders = {}; for (let sheet of data.sheets) { const positionsByBorder = {}; for (let col = 0; col < sheet.colNumber; col++) { for (let row = 0; row < sheet.rowNumber; row++) { const border = this.getCellBorder({ sheetId: sheet.id, col, row }); if (border) { const borderId = getItemId(border, borders); const position = { sheetId: sheet.id, col, row }; positionsByBorder[borderId] ??= []; positionsByBorder[borderId].push(position); } } } sheet.borders = groupItemIdsByZones(positionsByBorder); } data.borders = borders; } exportForExcel(data) { for (const sheet of data.sheets) { for (let col = 0; col < sheet.colNumber; col++) { for (let row = 0; row < sheet.rowNumber; row++) { const border = this.getCellBorder({ sheetId: sheet.id, col, row }); if (border) { const xc = toXC(col, row); sheet.cells[xc] ??= {}; sheet.cells[xc].border = getItemId(border, data.borders); } } } } } } class PositionMap { map = {}; set({ sheetId, col, row }, value) { const map = this.map; if (!map[sheetId]) { map[sheetId] = {}; } if (!map[sheetId][col]) { map[sheetId][col] = {}; } map[sheetId][col][row] = value; } get({ sheetId, col, row }) { return this.map[sheetId]?.[col]?.[row]; } getSheet(sheetId) { return this.map[sheetId]; } has({ sheetId, col, row }) { return this.map[sheetId]?.[col]?.[row] !== undefined; } delete({ sheetId, col, row }) { delete this.map[sheetId]?.[col]?.[row]; } keys() { const map = this.map; const keys = []; for (const sheetId in map) { for (const col in map[sheetId]) { for (const row in map[sheetId][col]) { keys.push({ sheetId, col: parseInt(col), row: parseInt(row) }); } } } return keys; } keysForSheet(sheetId) { const map = this.map[sheetId]; if (!map) { return []; } const keys = []; for (const col in map) { for (const row in map[col]) { keys.push({ sheetId, col: parseInt(col), row: parseInt(row) }); } } return keys; } } /** * Core Plugin * * This is the most fundamental of all plugins. It defines how to interact with * cell and sheet content. */ class CellPlugin extends CorePlugin { static getters = [ "zoneToXC", "getCells", "getTranslatedCellFormula", "getCellStyle", "getCellById", "getFormulaString", "getFormulaMovedInSheet", ]; nextId = 1; cells = {}; adaptRanges(applyChange, sheetId) { for (const sheet of Object.keys(this.cells)) { for (const cell of Object.values(this.cells[sheet] || {})) { if (cell.isFormula) { for (const range of cell.compiledFormula.dependencies) { if (!sheetId || range.sheetId === sheetId) { const change = applyChange(range); if (change.changeType !== "NONE") { this.history.update("cells", sheet, cell.id, "compiledFormula", "dependencies", cell.compiledFormula.dependencies.indexOf(range), change.range); } } } } } } } // --------------------------------------------------------------------------- // Command Handling // --------------------------------------------------------------------------- allowDispatch(cmd) { switch (cmd.type) { case "UPDATE_CELL": return this.checkValidations(cmd, this.checkCellOutOfSheet, this.checkUselessUpdateCell); case "CLEAR_CELL": return this.checkValidations(cmd, this.checkCellOutOfSheet, this.checkUselessClearCell); default: return "Success" /* CommandResult.Success */; } } handle(cmd) { switch (cmd.type) { case "SET_FORMATTING": if ("style" in cmd) { this.setStyle(cmd.sheetId, cmd.target, cmd.style); } if ("format" in cmd && cmd.format !== undefined) { this.setFormatter(cmd.sheetId, cmd.target, cmd.format); } break; case "CLEAR_FORMATTING": this.clearFormatting(cmd.sheetId, cmd.target); break; case "ADD_COLUMNS_ROWS": if (cmd.dimension === "COL") { this.handleAddColumnsRows(cmd, this.copyColumnStyle.bind(this)); } else { this.handleAddColumnsRows(cmd, this.copyRowStyle.bind(this)); } break; case "UPDATE_CELL": this.updateCell(cmd.sheetId, cmd.col, cmd.row, cmd); break; case "CLEAR_CELL": this.dispatch("UPDATE_CELL", { sheetId: cmd.sheetId, col: cmd.col, row: cmd.row, content: "", style: null, format: "", }); break; case "CLEAR_CELLS": this.clearCells(cmd.sheetId, cmd.target); break; case "DELETE_CONTENT": this.clearZones(cmd.sheetId, cmd.target); break; } } clearZones(sheetId, zones) { for (let zone of recomputeZones(zones)) { for (let col = zone.left; col <= zone.right; col++) { for (let row = zone.top; row <= zone.bottom; row++) { const cell = this.getters.getCell({ sheetId, col, row }); if (cell) { this.dispatch("UPDATE_CELL", { sheetId: sheetId, content: "", col, row, }); } } } } } /** * Set a format to all the cells in a zone */ setFormatter(sheetId, zones, format) { for (let zone of recomputeZones(zones)) { for (let row = zone.top; row <= zone.bottom; row++) { for (let col = zone.left; col <= zone.right; col++) { this.dispatch("UPDATE_CELL", { sheetId, col, row, format, }); } } } } /** * Clear the styles and format of zones */ clearFormatting(sheetId, zones) { for (let zone of recomputeZones(zones)) { for (let col = zone.left; col <= zone.right; col++) { for (let row = zone.top; row <= zone.bottom; row++) { // commandHelpers.updateCell(sheetId, col, row, { style: undefined}); this.dispatch("UPDATE_CELL", { sheetId, col, row, style: null, format: "", }); } } } } /** * Clear the styles, the format and the content of zones */ clearCells(sheetId, zones) { for (const zone of zones) { for (let col = zone.left; col <= zone.right; col++) { for (let row = zone.top; row <= zone.bottom; row++) { this.dispatch("UPDATE_CELL", { sheetId: sheetId, col, row, content: "", style: null, format: "", }); } } } } /** * Copy the style of the reference column/row to the new columns/rows. */ handleAddColumnsRows(cmd, fn) { // The new elements have already been inserted in the sheet at this point. let insertedElements; let styleReference; if (cmd.position === "before") { insertedElements = range(cmd.base, cmd.base + cmd.quantity); styleReference = cmd.base + cmd.quantity; } else { insertedElements = range(cmd.base + 1, cmd.base + cmd.quantity + 1); styleReference = cmd.base; } fn(cmd.sheetId, styleReference, insertedElements); } // --------------------------------------------------------------------------- // Import/Export // --------------------------------------------------------------------------- import(data) { for (let sheet of data.sheets) { const sheetId = sheet.id; const cellsData = new PositionMap(); // cells content for (const xc in sheet.cells) { if (sheet.cells[xc]?.content) { const { col, row } = toCartesian(xc); const position = { sheetId: sheet.id, col, row }; cellsData.set(position, { content: sheet.cells[xc]?.content }); } } // cells style and format for (const [cellProperty, valuesByZones] of [ ["style", sheet.styles], ["format", sheet.formats], ]) { for (const zoneXc in valuesByZones) { const itemId = valuesByZones[zoneXc]; const zone = toZone(zoneXc); for (let row = zone.top; row <= zone.bottom; row++) { for (let col = zone.left; col <= zone.right; col++) { const position = { sheetId, col, row }; const cellData = cellsData.get(position); if (cellData) { cellData[cellProperty] = itemId; } else { cellsData.set(position, { [cellProperty]: itemId }); } } } } } for (const position of cellsData.keysForSheet(sheetId)) { const cellData = cellsData.get(position); if (cellData?.content || cellData?.format || cellData?.style) { const cell = this.importCell(sheet.id, cellData?.content, cellData?.style ? data.styles[cellData?.style] : undefined, cellData?.format ? data.formats[cellData?.format] : undefined); this.history.update("cells", sheet.id, cell.id, cell); this.dispatch("UPDATE_CELL_POSITION", { cellId: cell.id, ...position, }); } } } } export(data) { const styles = {}; const formats = {}; for (let _sheet of data.sheets) { const positionsByStyle = []; const positionsByFormat = []; const cells = {}; const positions = Object.keys(this.cells[_sheet.id] || {}) .map((cellId) => this.getters.getCellPosition(cellId)) .sort((a, b) => (a.col === b.col ? a.row - b.row : a.col - b.col)); for (const position of positions) { const cell = this.getters.getCell(position); const xc = toXC(position.col, position.row); const style = this.removeDefaultStyleValues(cell.style); if (Object.keys(style).length) { const styleId = getItemId(style, styles); positionsByStyle[styleId] ??= []; positionsByStyle[styleId].push(position); } if (cell.format) { const formatId = getItemId(cell.format, formats); positionsByFormat[formatId] ??= []; positionsByFormat[formatId].push(position); } if (cell.content) { cells[xc] = { content: cell.content, }; } } _sheet.styles = groupItemIdsByZones(positionsByStyle); _sheet.formats = groupItemIdsByZones(positionsByFormat); _sheet.cells = cells; } data.styles = styles; data.formats = formats; } importCell(sheetId, content, style, format) { const cellId = this.getNextUid(); return this.createCell(cellId, content || "", format, style, sheetId); } exportForExcel(data) { this.export(data); for (const sheet of data.sheets) { for (const cellId in this.getters.getCells(sheet.id)) { const { col, row } = this.getters.getCellPosition(cellId); const xc = toXC(col, row); const cell = this.getCellById(cellId); sheet.cells[xc] ??= {}; if (cell?.format) { sheet.cells[xc].format = getItemId(cell.format, data.formats); } if (cell?.style) { sheet.cells[xc] ??= {}; sheet.cells[xc].style = getItemId(this.removeDefaultStyleValues(cell.style), data.styles); } } } const incompatible = []; for (const formatId in data.formats || []) { if (!isExcelCompatible(data.formats[formatId])) { incompatible.push(formatId); delete data.formats[formatId]; } } if (incompatible.length) { for (const sheet of data.sheets) { for (const xc in sheet.cells) { const cell = sheet.cells[xc]; const format = cell?.format; if (format && incompatible.includes(format.toString())) { delete cell.format; } } } } } removeDefaultStyleValues(style) { const cleanedStyle = { ...style }; for (const property in DEFAULT_STYLE) { if (cleanedStyle[property] === DEFAULT_STYLE[property]) { delete cleanedStyle[property]; } } return cleanedStyle; } // --------------------------------------------------------------------------- // GETTERS // --------------------------------------------------------------------------- getCells(sheetId) { return this.cells[sheetId] || {}; } /** * get a cell by ID. Used in evaluation when evaluating an async cell, we need to be able to find it back after * starting an async evaluation even if it has been moved or re-allocated */ getCellById(cellId) { // this must be as fast as possible const position = this.getters.getCellPosition(cellId); const sheet = this.cells[position.sheetId]; return sheet[cellId]; } /* * Reconstructs the original formula string based on new dependencies */ getFormulaString(sheetId, tokens, dependencies, useFixedReference = false) { if (!dependencies.length) { return concat(tokens.map((token) => token.value)); } let rangeIndex = 0; return concat(tokens.map((token) => { if (token.type === "REFERENCE") { const range = dependencies[rangeIndex++]; return this.getters.getRangeString(range, sheetId, { useFixedReference }); } return token.value; })); } /* * Constructs a formula string based on an initial formula and a translation vector */ getTranslatedCellFormula(sheetId, offsetX, offsetY, tokens) { const adaptedDependencies = this.getters.createAdaptedRanges(compileTokens(tokens).dependencies.map((d) => this.getters.getRangeFromSheetXC(sheetId, d)), offsetX, offsetY, sheetId); return this.getFormulaString(sheetId, tokens, adaptedDependencies); } getFormulaMovedInSheet(originSheetId, targetSheetId, tokens) { const dependencies = compileTokens(tokens).dependencies.map((d) => this.getters.getRangeFromSheetXC(originSheetId, d)); const adaptedDependencies = this.getters.removeRangesSheetPrefix(targetSheetId, dependencies); return this.getFormulaString(targetSheetId, tokens, adaptedDependencies); } getCellStyle(position) { return this.getters.getCell(position)?.style || {}; } /** * Converts a zone to a XC coordinate system * * The conversion also treats merges as one single cell * * Examples: * {top:0,left:0,right:0,bottom:0} ==> A1 * {top:0,left:0,right:1,bottom:1} ==> A1:B2 * * if A1:B2 is a merge: * {top:0,left:0,right:1,bottom:1} ==> A1 * {top:1,left:0,right:1,bottom:2} ==> A1:B3 * * if A1:B2 and A4:B5 are merges: * {top:1,left:0,right:1,bottom:3} ==> A1:A5 */ zoneToXC(sheetId, zone, fixedParts = [{ colFixed: false, rowFixed: false }]) { zone = this.getters.expandZone(sheetId, zone); const topLeft = toXC(zone.left, zone.top, fixedParts[0]); const botRight = toXC(zone.right, zone.bottom, fixedParts.length > 1 ? fixedParts[1] : fixedParts[0]); const cellTopLeft = this.getters.getMainCellPosition({ sheetId, col: zone.left, row: zone.top, }); const cellBotRight = this.getters.getMainCellPosition({ sheetId, col: zone.right, row: zone.bottom, }); const sameCell = cellTopLeft.col === cellBotRight.col && cellTopLeft.row === cellBotRight.row; if (topLeft != botRight && !sameCell) { return topLeft + ":" + botRight; } return topLeft; } setStyle(sheetId, target, style) { for (let zone of recomputeZones(target)) { for (let col = zone.left; col <= zone.right; col++) { for (let row = zone.top; row <= zone.bottom; row++) { const cell = this.getters.getCell({ sheetId, col, row }); this.dispatch("UPDATE_CELL", { sheetId, col, row, style: style ? { ...cell?.style, ...style } : undefined, }); } } } } /** * Copy the style of one column to other columns. */ copyColumnStyle(sheetId, refColumn, targetCols) { for (let row = 0; row < this.getters.getNumberRows(sheetId); row++) { const format = this.getFormat(sheetId, refColumn, row); if (format.style || format.format) { for (let col of targetCols) { this.dispatch("UPDATE_CELL", { sheetId, col, row, ...format }); } } } } /** * Copy the style of one row to other rows. */ copyRowStyle(sheetId, refRow, targetRows) { for (let col = 0; col < this.getters.getNumberCols(sheetId); col++) { const format = this.getFormat(sheetId, col, refRow); if (format.style || format.format) { for (let row of targetRows) { this.dispatch("UPDATE_CELL", { sheetId, col, row, ...format }); } } } } /** * gets the currently used style/border of a cell based on it's coordinates */ getFormat(sheetId, col, row) { const format = {}; const position = this.getters.getMainCellPosition({ sheetId, col, row }); const cell = this.getters.getCell(position); if (cell) { if (cell.style) { format["style"] = cell.style; } if (cell.format) { format["format"] = cell.format; } } return format; } getNextUid() { const id = this.nextId.toString(); this.history.update("nextId", this.nextId + 1); return id; } updateCell(sheetId, col, row, after) { const before = this.getters.getCell({ sheetId, col, row }); const hasContent = "content" in after || "formula" in after; // Compute the new cell properties const afterContent = hasContent ? replaceNewLines(after?.content) : before?.content || ""; let style; if (after.style !== undefined) { style = after.style || undefined; } else { style = before ? before.style : undefined; } const format = "format" in after ? after.format : before && before.format; /* Read the following IF as: * we need to remove the cell if it is completely empty, but we can know if it completely empty if: * - the command says the new content is empty and has no border/format/style * - the command has no content property, in this case * - either there wasn't a cell at this place and the command says border/format/style is empty * - or there was a cell at this place, but it's an empty cell and the command says border/format/style is empty * */ if (((hasContent && !afterContent && !after.formula) || (!hasContent && (!before || before.content === ""))) && !style && !format) { if (before) { this.history.update("cells", sheetId, before.id, undefined); this.dispatch("UPDATE_CELL_POSITION", { cellId: undefined, col, row, sheetId, }); } return; } const cellId = before?.id || this.getNextUid(); const cell = this.createCell(cellId, afterContent, format, style, sheetId); this.history.update("cells", sheetId, cell.id, cell); this.dispatch("UPDATE_CELL_POSITION", { cellId: cell.id, col, row, sheetId }); } createCell(id, content, format, style, sheetId) { if (!content.startsWith("=")) { return this.createLiteralCell(id, content, format, style); } return this.createFormulaCell(id, content, format, style, sheetId); } createLiteralCell(id, content, format, style) { const locale = this.getters.getLocale(); const parsedValue = parseLiteral(content, locale); format = format || (typeof parsedValue === "number" ? detectDateFormat(content, locale) || detectNumberFormat(content) : undefined); if (!isTextFormat(format) && !isEvaluationError(content)) { content = toString(parsedValue); } return { id, content, style, format, isFormula: false, parsedValue, }; } createFormulaCell(id, content, format, style, sheetId) { const compiledFormula = compile(content); if (compiledFormula.dependencies.length) { return this.createFormulaCellWithDependencies(id, compiledFormula, format, style, sheetId); } return { id, content, style, format, isFormula: true, compiledFormula: { ...compiledFormula, dependencies: [], }, }; } /** * Create a new formula cell with the content * being a computed property to rebuild the dependencies XC. */ createFormulaCellWithDependencies(id, compiledFormula, format, style, sheetId) { const dependencies = []; for (const xc of compiledFormula.dependencies) { dependencies.push(this.getters.getRangeFromSheetXC(sheetId, xc)); } return new FormulaCellWithDependencies(id, compiledFormula, format, style, dependencies, sheetId, this.getters.getRangeString); } checkCellOutOfSheet(cmd) { const { sheetId, col, row } = cmd; const sheet = this.getters.tryGetSheet(sheetId); if (!sheet) return "InvalidSheetId" /* CommandResult.InvalidSheetId */; const sheetZone = this.getters.getSheetZone(sheetId); return isInside(col, row, sheetZone) ? "Success" /* CommandResult.Success */ : "TargetOutOfSheet" /* CommandResult.TargetOutOfSheet */; } checkUselessClearCell(cmd) { const cell = this.getters.getCell(cmd); if (!cell) return "NoChanges" /* CommandResult.NoChanges */; if (!cell.content && !cell.style && !cell.format) { return "NoChanges" /* CommandResult.NoChanges */; } return "Success" /* CommandResult.Success */; } checkUselessUpdateCell(cmd) { const cell = this.getters.getCell(cmd); const hasContent = "content" in cmd || "formula" in cmd; const hasStyle = "style" in cmd; const hasFormat = "format" in cmd; if ((!hasContent || cell?.content === cmd.content) && (!hasStyle || deepEquals(cell?.style, cmd.style)) && (!hasFormat || cell?.format === cmd.format)) { return "NoChanges" /* CommandResult.NoChanges */; } return "Success" /* CommandResult.Success */; } } class FormulaCellWithDependencies { id; format; style; sheetId; getRangeString; isFormula = true; compiledFormula; constructor(id, compiledFormula, format, style, dependencies, sheetId, getRangeString) { this.id = id; this.format = format; this.style = style; this.sheetId = sheetId; this.getRangeString = getRangeString; let rangeIndex = 0; const tokens = compiledFormula.tokens.map((token) => { if (token.type === "REFERENCE") { const index = rangeIndex++; return new RangeReferenceToken(dependencies, index, this.sheetId, this.getRangeString); } return token; }); this.compiledFormula = { ...compiledFormula, dependencies, tokens, }; } get content() { return concat(this.compiledFormula.tokens.map((token) => token.value)); } get contentWithFixedReferences() { let rangeIndex = 0; return concat(this.compiledFormula.tokens.map((token) => { if (token.type === "REFERENCE") { const index = rangeIndex++; return this.getRangeString(this.compiledFormula.dependencies[index], this.sheetId, { useFixedReference: true, }); } return token.value; })); } } class RangeReferenceToken { ranges; rangeIndex; sheetId; getRangeString; type = "REFERENCE"; constructor(ranges, rangeIndex, sheetId, getRangeString) { this.ranges = ranges; this.rangeIndex = rangeIndex; this.sheetId = sheetId; this.getRangeString = getRangeString; } get value() { const range = this.ranges[this.rangeIndex]; return this.getRangeString(range, this.sheetId); } } class ChartPlugin extends CorePlugin { static getters = [ "isChartDefined", "getChartDefinition", "getChartType", "getChartIds", "getChart", "getContextCreationChart", ]; charts = {}; createChart = chartFactory(this.getters); validateChartDefinition = (cmd) => validateChartDefinition(this, cmd.definition); adaptRanges(applyChange) { for (const [chartId, chart] of Object.entries(this.charts)) { this.history.update("charts", chartId, chart?.updateRanges(applyChange)); } } // --------------------------------------------------------------------------- // Command Handling // --------------------------------------------------------------------------- allowDispatch(cmd) { switch (cmd.type) { case "CREATE_CHART": return this.checkValidations(cmd, this.chainValidations(this.validateChartDefinition, this.checkChartDuplicate)); case "UPDATE_CHART": return this.checkValidations(cmd, this.chainValidations(this.validateChartDefinition, this.checkChartExists)); default: return "Success" /* CommandResult.Success */; } } handle(cmd) { switch (cmd.type) { case "CREATE_CHART": this.addFigure(cmd.id, cmd.sheetId, cmd.position, cmd.size); this.addChart(cmd.id, cmd.definition); break; case "UPDATE_CHART": { this.addChart(cmd.id, cmd.definition); break; } case "DUPLICATE_SHEET": { const sheetFiguresFrom = this.getters.getFigures(cmd.sheetId); for (const fig of sheetFiguresFrom) { if (fig.tag === "chart") { const figureIdBase = fig.id.split(FIGURE_ID_SPLITTER).pop(); const duplicatedFigureId = `${cmd.sheetIdTo}${FIGURE_ID_SPLITTER}${figureIdBase}`; const chart = this.charts[fig.id]?.copyForSheetId(cmd.sheetIdTo); if (chart) { this.dispatch("CREATE_CHART", { id: duplicatedFigureId, position: { x: fig.x, y: fig.y }, size: { width: fig.width, height: fig.height }, definition: chart.getDefinition(), sheetId: cmd.sheetIdTo, }); } } } break; } case "DELETE_FIGURE": this.history.update("charts", cmd.id, undefined); break; case "DELETE_SHEET": for (let id of this.getChartIds(cmd.sheetId)) { this.history.update("charts", id, undefined); } break; } } // --------------------------------------------------------------------------- // Getters // --------------------------------------------------------------------------- getContextCreationChart(figureId) { return this.charts[figureId]?.getContextCreation(); } getChart(figureId) { return this.charts[figureId]; } getChartType(figureId) { const type = this.charts[figureId]?.type; if (!type) { throw new Error("Chart not defined."); } return type; } isChartDefined(figureId) { return figureId in this.charts && this.charts !== undefined; } getChartIds(sheetId) { return Object.entries(this.charts) .filter(([, chart]) => chart?.sheetId === sheetId) .map(([id]) => id); } getChartDefinition(figureId) { const definition = this.charts[figureId]?.getDefinition(); if (!definition) { throw new Error(`There is no chart with the given figureId: ${figureId}`); } return definition; } // --------------------------------------------------------------------------- // Import/Export // --------------------------------------------------------------------------- import(data) { for (let sheet of data.sheets) { if (sheet.figures) { for (let figure of sheet.figures) { // TODO: // figure data should be external IMO => chart should be in sheet.chart // instead of in figure.data if (figure.tag === "chart") { this.charts[figure.id] = this.createChart(figure.id, figure.data, sheet.id); } } } } } export(data) { if (data.sheets) { for (let sheet of data.sheets) { // TODO This code is false, if two plugins want to insert figures on the sheet, it will crash ! const sheetFigures = this.getters.getFigures(sheet.id); const figures = []; for (let sheetFigure of sheetFigures) { const figure = sheetFigure; if (figure && figure.tag === "chart") { const data = this.charts[figure.id]?.getDefinition(); if (data) { figure.data = data; figures.push(figure); } } else { figures.push(figure); } } sheet.figures = figures; } } } // --------------------------------------------------------------------------- // Private // --------------------------------------------------------------------------- /** * Add a figure with tag chart with the given id at the given position */ addFigure(id, sheetId, position = { x: 0, y: 0 }, size = { width: DEFAULT_FIGURE_WIDTH, height: DEFAULT_FIGURE_HEIGHT, }) { if (this.getters.getFigure(sheetId, id)) { return; } const figure = { id, x: position.x, y: position.y, width: size.width, height: size.height, tag: "chart", }; this.dispatch("CREATE_FIGURE", { sheetId, figure }); } /** * Add a chart in the local state. If a chart already exists, this chart is * replaced */ addChart(id, definition) { const sheetId = this.getters.getFigureSheetId(id); if (sheetId) { this.history.update("charts", id, this.createChart(id, definition, sheetId)); } } checkChartDuplicate(cmd) { return this.getters.getFigureSheetId(cmd.id) ? "DuplicatedChartId" /* CommandResult.DuplicatedChartId */ : "Success" /* CommandResult.Success */; } checkChartExists(cmd) { return this.getters.getFigureSheetId(cmd.id) ? "Success" /* CommandResult.Success */ : "ChartDoesNotExist" /* CommandResult.ChartDoesNotExist */; } } // ----------------------------------------------------------------------------- // Constants // ----------------------------------------------------------------------------- function stringToNumber(value) { return value === "" ? NaN : Number(value); } class ConditionalFormatPlugin extends CorePlugin { static getters = [ "getConditionalFormats", "getRulesSelection", "getRulesByCell", "getAdaptedCfRanges", ]; cfRules = {}; loopThroughRangesOfSheet(sheetId, applyChange) { for (const rule of this.cfRules[sheetId]) { if (rule.rule.type === "DataBarRule" && rule.rule.rangeValues) { const change = applyChange(rule.rule.rangeValues); switch (change.changeType) { case "REMOVE": this.history.update("cfRules", sheetId, this.cfRules[sheetId].indexOf(rule), "rule", //@ts-expect-error "rangeValues", undefined); break; case "RESIZE": case "MOVE": case "CHANGE": this.history.update("cfRules", sheetId, this.cfRules[sheetId].indexOf(rule), "rule", //@ts-expect-error "rangeValues", change.range); break; } } for (const range of rule.ranges) { const change = applyChange(range); switch (change.changeType) { case "REMOVE": let copy = rule.ranges.slice(); copy.splice(rule.ranges.indexOf(range), 1); if (copy.length >= 1) { this.history.update("cfRules", sheetId, this.cfRules[sheetId].indexOf(rule), "ranges", copy); } else { this.removeConditionalFormatting(rule.id, sheetId); } break; case "RESIZE": case "MOVE": case "CHANGE": this.history.update("cfRules", sheetId, this.cfRules[sheetId].indexOf(rule), "ranges", rule.ranges.indexOf(range), change.range); break; } } } } adaptRanges(applyChange, sheetId) { if (sheetId) { this.loopThroughRangesOfSheet(sheetId, applyChange); } else { for (const sheetId of Object.keys(this.cfRules)) { this.loopThroughRangesOfSheet(sheetId, applyChange); } } } // --------------------------------------------------------------------------- // Command Handling // --------------------------------------------------------------------------- allowDispatch(cmd) { switch (cmd.type) { case "ADD_CONDITIONAL_FORMAT": return this.checkValidations(cmd, this.checkCFRule, this.checkEmptyRange); case "CHANGE_CONDITIONAL_FORMAT_PRIORITY": return this.checkValidPriorityChange(cmd.cfId, cmd.delta, cmd.sheetId); } return "Success" /* CommandResult.Success */; } handle(cmd) { switch (cmd.type) { case "CREATE_SHEET": this.cfRules[cmd.sheetId] = []; break; case "DUPLICATE_SHEET": this.history.update("cfRules", cmd.sheetIdTo, []); for (const cf of this.getConditionalFormats(cmd.sheetId)) { this.addConditionalFormatting(cf, cmd.sheetIdTo); } break; case "DELETE_SHEET": const cfRules = Object.assign({}, this.cfRules); delete cfRules[cmd.sheetId]; this.history.update("cfRules", cfRules); break; case "ADD_CONDITIONAL_FORMAT": const cf = { ...cmd.cf, ranges: cmd.ranges.map((rangeData) => this.getters.getRangeString(this.getters.getRangeFromRangeData(rangeData), cmd.sheetId)), }; this.addConditionalFormatting(cf, cmd.sheetId); break; case "REMOVE_CONDITIONAL_FORMAT": this.removeConditionalFormatting(cmd.id, cmd.sheetId); break; case "CHANGE_CONDITIONAL_FORMAT_PRIORITY": this.changeCFPriority(cmd.cfId, cmd.delta, cmd.sheetId); break; } } import(data) { for (let sheet of data.sheets) { this.cfRules[sheet.id] = sheet.conditionalFormats.map((rule) => this.mapToConditionalFormatInternal(sheet.id, rule)); } } export(data) { if (data.sheets) { for (let sheet of data.sheets) { if (this.cfRules[sheet.id]) { sheet.conditionalFormats = this.cfRules[sheet.id].map((rule) => this.mapToConditionalFormat(sheet.id, rule)); } } } } exportForExcel(data) { if (data.sheets) { for (let sheet of data.sheets) { if (this.cfRules[sheet.id]) { sheet.conditionalFormats = this.cfRules[sheet.id].map((rule) => this.mapToConditionalFormat(sheet.id, rule, { useFixedReference: true })); } } } } // --------------------------------------------------------------------------- // Getters // --------------------------------------------------------------------------- /** * Returns all the conditional format rules defined for the current sheet to display the user */ getConditionalFormats(sheetId) { return this.cfRules[sheetId]?.map((cf) => this.mapToConditionalFormat(sheetId, cf)) || []; } getRulesSelection(sheetId, selection) { const ruleIds = new Set(); selection.forEach((zone) => { const zoneRuleId = this.getRulesByZone(sheetId, zone); zoneRuleId.forEach((ruleId) => { ruleIds.add(ruleId); }); }); return Array.from(ruleIds); } getRulesByZone(sheetId, zone) { const ruleIds = new Set(); for (let row = zone.top; row <= zone.bottom; row++) { for (let col = zone.left; col <= zone.right; col++) { const cellRules = this.getRulesByCell(sheetId, col, row); cellRules.forEach((rule) => { ruleIds.add(rule.id); }); } } return ruleIds; } getRulesByCell(sheetId, cellCol, cellRow) { const rules = []; for (let cf of this.cfRules[sheetId]) { for (let range of cf.ranges) { if (isInside(cellCol, cellRow, range.zone)) { rules.push(cf); } } } return new Set(rules.map((rule) => { return this.mapToConditionalFormat(sheetId, rule); })); } /** * Add or remove cells to a given conditional formatting rule and return the adapted CF's XCs. */ getAdaptedCfRanges(sheetId, cf, toAdd, toRemove) { if (toAdd.length === 0 && toRemove.length === 0) { return; } const rules = this.getters.getConditionalFormats(sheetId); const replaceIndex = rules.findIndex((c) => c.id === cf.id); let currentRanges = []; if (replaceIndex > -1) { currentRanges = rules[replaceIndex].ranges.map(toUnboundedZone); } // Remove the zones first in case the same position is in toAdd and toRemove const withRemovedZones = recomputeZones(currentRanges, toRemove); return recomputeZones([...toAdd, ...withRemovedZones], []).map((zone) => this.getters.getRangeDataFromZone(sheetId, zone)); } // --------------------------------------------------------------------------- // Private // --------------------------------------------------------------------------- mapToConditionalFormat(sheetId, cf, { useFixedReference } = { useFixedReference: false }) { const ranges = cf.ranges.map((range) => { return this.getters.getRangeString(range, sheetId, { useFixedReference }); }); if (cf.rule.type !== "DataBarRule") { return { ...cf, rule: { ...cf.rule }, ranges, }; } return { ...cf, rule: { ...cf.rule, rangeValues: cf.rule.rangeValues && this.getters.getRangeString(cf.rule.rangeValues, sheetId, { useFixedReference, }), }, ranges, }; } mapToConditionalFormatInternal(sheet, cf) { const ranges = cf.ranges.map((range) => { return this.getters.getRangeFromSheetXC(sheet, range); }); if (cf.rule.type !== "DataBarRule") { return { ...cf, rule: { ...cf.rule }, ranges, }; } return { ...cf, rule: { ...cf.rule, rangeValues: cf.rule.rangeValues ? this.getters.getRangeFromSheetXC(sheet, cf.rule.rangeValues) : undefined, }, ranges, }; } /** * Add or replace a conditional format rule */ addConditionalFormatting(cf, sheet) { const currentCF = this.cfRules[sheet].slice(); const replaceIndex = currentCF.findIndex((c) => c.id === cf.id); const newCF = this.mapToConditionalFormatInternal(sheet, cf); if (replaceIndex > -1) { currentCF.splice(replaceIndex, 1, newCF); } else { currentCF.push(newCF); } this.history.update("cfRules", sheet, currentCF); } checkValidPriorityChange(cfId, delta, sheetId) { if (!this.cfRules[sheetId]) return "InvalidSheetId" /* CommandResult.InvalidSheetId */; const ruleIndex = this.cfRules[sheetId].findIndex((cf) => cf.id === cfId); if (ruleIndex === -1) return "InvalidConditionalFormatId" /* CommandResult.InvalidConditionalFormatId */; const cfIndex2 = ruleIndex - delta; if (cfIndex2 < 0 || cfIndex2 >= this.cfRules[sheetId].length) { return "InvalidConditionalFormatId" /* CommandResult.InvalidConditionalFormatId */; } return "Success" /* CommandResult.Success */; } checkEmptyRange(cmd) { return cmd.ranges.length ? "Success" /* CommandResult.Success */ : "EmptyRange" /* CommandResult.EmptyRange */; } checkCFRule(cmd) { const rule = cmd.cf.rule; switch (rule.type) { case "CellIsRule": return this.checkValidations(rule, this.checkOperatorArgsNumber(2, ["Between", "NotBetween"]), this.checkOperatorArgsNumber(1, [ "BeginsWith", "ContainsText", "EndsWith", "GreaterThan", "GreaterThanOrEqual", "LessThan", "LessThanOrEqual", "NotContains", "Equal", "NotEqual", ]), this.checkOperatorArgsNumber(0, ["IsEmpty", "IsNotEmpty"]), this.checkCFValues); case "ColorScaleRule": { return this.checkValidations(rule, this.chainValidations(this.checkThresholds(this.checkFormulaCompilation)), this.chainValidations(this.checkThresholds(this.checkNaN), this.batchValidations(this.checkMinBiggerThanMax, this.checkMinBiggerThanMid, this.checkMidBiggerThanMax // Those three validations can be factorized further ))); } case "IconSetRule": { return this.checkValidations(rule, this.chainValidations(this.checkInflectionPoints(this.checkNaN), this.checkLowerBiggerThanUpper), this.chainValidations(this.checkInflectionPoints(this.checkFormulaCompilation))); } } return "Success" /* CommandResult.Success */; } checkOperatorArgsNumber(expectedNumber, operators) { if (expectedNumber > 2) { throw new Error("Checking more than 2 arguments is currently not supported. Add the appropriate CommandResult if you want to."); } return (rule) => { if (operators.includes(rule.operator)) { const errors = []; const isEmpty = (value) => value === undefined || value === ""; if (expectedNumber >= 1 && isEmpty(rule.values[0])) { errors.push("FirstArgMissing" /* CommandResult.FirstArgMissing */); } if (expectedNumber >= 2 && isEmpty(rule.values[1])) { errors.push("SecondArgMissing" /* CommandResult.SecondArgMissing */); } return errors.length ? errors : "Success" /* CommandResult.Success */; } return "Success" /* CommandResult.Success */; }; } checkNaN(threshold, thresholdName) { if (["number", "percentage", "percentile"].includes(threshold.type) && (threshold.value === "" || isNaN(threshold.value))) { switch (thresholdName) { case "min": return "MinNaN" /* CommandResult.MinNaN */; case "max": return "MaxNaN" /* CommandResult.MaxNaN */; case "mid": return "MidNaN" /* CommandResult.MidNaN */; case "upperInflectionPoint": return "ValueUpperInflectionNaN" /* CommandResult.ValueUpperInflectionNaN */; case "lowerInflectionPoint": return "ValueLowerInflectionNaN" /* CommandResult.ValueLowerInflectionNaN */; } } return "Success" /* CommandResult.Success */; } checkFormulaCompilation(threshold, thresholdName) { if (threshold.type !== "formula") return "Success" /* CommandResult.Success */; const compiledFormula = compile(threshold.value || ""); if (compiledFormula.isBadExpression) { switch (thresholdName) { case "min": return "MinInvalidFormula" /* CommandResult.MinInvalidFormula */; case "max": return "MaxInvalidFormula" /* CommandResult.MaxInvalidFormula */; case "mid": return "MidInvalidFormula" /* CommandResult.MidInvalidFormula */; case "upperInflectionPoint": return "ValueUpperInvalidFormula" /* CommandResult.ValueUpperInvalidFormula */; case "lowerInflectionPoint": return "ValueLowerInvalidFormula" /* CommandResult.ValueLowerInvalidFormula */; } } return "Success" /* CommandResult.Success */; } checkThresholds(check) { return this.batchValidations((rule) => check(rule.minimum, "min"), (rule) => check(rule.maximum, "max"), (rule) => (rule.midpoint ? check(rule.midpoint, "mid") : "Success" /* CommandResult.Success */)); } checkInflectionPoints(check) { return this.batchValidations((rule) => check(rule.lowerInflectionPoint, "lowerInflectionPoint"), (rule) => check(rule.upperInflectionPoint, "upperInflectionPoint")); } checkLowerBiggerThanUpper(rule) { const minValue = rule.lowerInflectionPoint.value; const maxValue = rule.upperInflectionPoint.value; if (["number", "percentage", "percentile"].includes(rule.lowerInflectionPoint.type) && rule.lowerInflectionPoint.type === rule.upperInflectionPoint.type && Number(minValue) > Number(maxValue)) { return "LowerBiggerThanUpper" /* CommandResult.LowerBiggerThanUpper */; } return "Success" /* CommandResult.Success */; } checkMinBiggerThanMax(rule) { const minValue = rule.minimum.value; const maxValue = rule.maximum.value; if (["number", "percentage", "percentile"].includes(rule.minimum.type) && rule.minimum.type === rule.maximum.type && stringToNumber(minValue) >= stringToNumber(maxValue)) { return "MinBiggerThanMax" /* CommandResult.MinBiggerThanMax */; } return "Success" /* CommandResult.Success */; } checkMidBiggerThanMax(rule) { const midValue = rule.midpoint?.value; const maxValue = rule.maximum.value; if (rule.midpoint && ["number", "percentage", "percentile"].includes(rule.midpoint.type) && rule.midpoint.type === rule.maximum.type && stringToNumber(midValue) >= stringToNumber(maxValue)) { return "MidBiggerThanMax" /* CommandResult.MidBiggerThanMax */; } return "Success" /* CommandResult.Success */; } checkMinBiggerThanMid(rule) { const minValue = rule.minimum.value; const midValue = rule.midpoint?.value; if (rule.midpoint && ["number", "percentage", "percentile"].includes(rule.midpoint.type) && rule.minimum.type === rule.midpoint.type && stringToNumber(minValue) >= stringToNumber(midValue)) { return "MinBiggerThanMid" /* CommandResult.MinBiggerThanMid */; } return "Success" /* CommandResult.Success */; } checkCFValues(rule) { for (const value of rule.values) { if (!value.startsWith("=")) continue; const compiledFormula = compile(value || ""); if (compiledFormula.isBadExpression) { return "ValueCellIsInvalidFormula" /* CommandResult.ValueCellIsInvalidFormula */; } } return "Success" /* CommandResult.Success */; } removeConditionalFormatting(id, sheet) { const cfIndex = this.cfRules[sheet].findIndex((s) => s.id === id); if (cfIndex !== -1) { const currentCF = this.cfRules[sheet].slice(); currentCF.splice(cfIndex, 1); this.history.update("cfRules", sheet, currentCF); } } changeCFPriority(cfId, delta, sheetId) { const currentIndex = this.cfRules[sheetId].findIndex((s) => s.id === cfId); const cf = this.cfRules[sheetId][currentIndex]; const targetIndex = currentIndex - delta; // priority goes up when index goes down const cfRules = [...this.cfRules[sheetId]]; cfRules.splice(currentIndex, 1); cfRules.splice(targetIndex, 0, cf); this.history.update("cfRules", sheetId, cfRules); } } class DataValidationPlugin extends CorePlugin { static getters = [ "cellHasListDataValidationIcon", "getDataValidationRule", "getDataValidationRules", "getValidationRuleForCell", ]; rules = {}; adaptRanges(applyChange, sheetId) { const sheetIds = sheetId ? [sheetId] : Object.keys(this.rules); for (const sheetId of sheetIds) { this.loopThroughRangesOfSheet(sheetId, applyChange); } } loopThroughRangesOfSheet(sheetId, applyChange) { const rules = this.rules[sheetId]; for (let ruleIndex = rules.length - 1; ruleIndex >= 0; ruleIndex--) { const rule = this.rules[sheetId][ruleIndex]; for (let rangeIndex = rule.ranges.length - 1; rangeIndex >= 0; rangeIndex--) { const range = rule.ranges[rangeIndex]; const change = applyChange(range); switch (change.changeType) { case "REMOVE": if (rule.ranges.length === 1) { this.removeDataValidationRule(sheetId, rule.id); } else { const copy = rule.ranges.slice(); copy.splice(rangeIndex, 1); this.history.update("rules", sheetId, ruleIndex, "ranges", copy); } break; case "RESIZE": case "MOVE": case "CHANGE": this.history.update("rules", sheetId, ruleIndex, "ranges", rangeIndex, change.range); break; } } } } allowDispatch(cmd) { switch (cmd.type) { case "ADD_DATA_VALIDATION_RULE": return this.checkValidations(cmd, this.chainValidations(this.checkEmptyRange, this.checkCriterionTypeIsValid, this.checkCriterionHasValidNumberOfValues, this.checkCriterionValuesAreValid)); case "REMOVE_DATA_VALIDATION_RULE": if (!this.rules[cmd.sheetId].find((rule) => rule.id === cmd.id)) { return "UnknownDataValidationRule" /* CommandResult.UnknownDataValidationRule */; } break; } return "Success" /* CommandResult.Success */; } handle(cmd) { switch (cmd.type) { case "CREATE_SHEET": this.history.update("rules", cmd.sheetId, []); break; case "DUPLICATE_SHEET": { const rules = deepCopy(this.rules[cmd.sheetId]).map((rule) => ({ ...rule, ranges: rule.ranges.map((range) => copyRangeWithNewSheetId(cmd.sheetId, cmd.sheetIdTo, range)), })); this.history.update("rules", cmd.sheetIdTo, rules); break; } case "DELETE_SHEET": { const rules = { ...this.rules }; delete rules[cmd.sheetId]; this.history.update("rules", rules); break; } case "REMOVE_DATA_VALIDATION_RULE": { this.removeDataValidationRule(cmd.sheetId, cmd.id); break; } case "ADD_DATA_VALIDATION_RULE": { const ranges = cmd.ranges.map((range) => this.getters.getRangeFromRangeData(range)); this.addDataValidationRule(cmd.sheetId, { ...cmd.rule, ranges }); break; } case "DELETE_CONTENT": { const zones = recomputeZones(cmd.target); const sheetId = cmd.sheetId; for (const zone of zones) { for (let row = zone.top; row <= zone.bottom; row++) { for (let col = zone.left; col <= zone.right; col++) { const dataValidation = this.getValidationRuleForCell({ sheetId, col, row }); if (!dataValidation) { continue; } if (dataValidation.criterion.type === "isBoolean" || (dataValidation.criterion.type === "isValueInList" && !this.getters.getCell({ sheetId, col, row })?.content)) { const rules = this.rules[sheetId]; const ranges = [this.getters.getRangeFromSheetXC(sheetId, toXC(col, row))]; const adaptedRules = this.removeRangesFromRules(sheetId, ranges, rules); this.history.update("rules", sheetId, adaptedRules); } } } } } } } getDataValidationRules(sheetId) { return this.rules[sheetId]; } getDataValidationRule(sheetId, id) { return this.rules[sheetId].find((rule) => rule.id === id); } getValidationRuleForCell({ sheetId, col, row }) { if (!this.rules[sheetId]) { return undefined; } for (const rule of this.rules[sheetId]) { for (const range of rule.ranges) { if (isInside(col, row, range.zone)) { return rule; } } } return undefined; } cellHasListDataValidationIcon(cellPosition) { const rule = this.getValidationRuleForCell(cellPosition); if (!rule) return false; return ((rule.criterion.type === "isValueInList" || rule.criterion.type === "isValueInRange") && rule.criterion.displayStyle === "arrow"); } addDataValidationRule(sheetId, newRule) { const rules = this.rules[sheetId]; if (newRule.criterion.type === "isBoolean") { this.setCenterStyleToBooleanCells(newRule); } else if (newRule.criterion.type === "isValueInList") { newRule.criterion.values = Array.from(new Set(newRule.criterion.values)); } const adaptedRules = this.removeRangesFromRules(sheetId, newRule.ranges, rules); const ruleIndex = adaptedRules.findIndex((rule) => rule.id === newRule.id); if (ruleIndex !== -1) { adaptedRules[ruleIndex] = newRule; this.history.update("rules", sheetId, adaptedRules); } else { this.history.update("rules", sheetId, [...adaptedRules, newRule]); } } removeRangesFromRules(sheetId, ranges, rules) { rules = deepCopy(rules); for (const rule of rules) { rule.ranges = this.getters.recomputeRanges(rule.ranges, ranges); } return rules.filter((rule) => rule.ranges.length > 0); } removeDataValidationRule(sheetId, ruleId) { const rules = this.rules[sheetId]; const newRules = rules.filter((rule) => rule.id !== ruleId); this.history.update("rules", sheetId, newRules); } setCenterStyleToBooleanCells(rule) { for (const position of getCellPositionsInRanges(rule.ranges)) { const cell = this.getters.getCell(position); const style = { ...cell?.style, align: cell?.style?.align ?? "center", verticalAlign: cell?.style?.verticalAlign ?? "middle", }; this.dispatch("UPDATE_CELL", { ...position, style }); } } checkEmptyRange(cmd) { return cmd.ranges.length ? "Success" /* CommandResult.Success */ : "EmptyRange" /* CommandResult.EmptyRange */; } import(data) { for (const sheet of data.sheets) { this.rules[sheet.id] = []; if (!sheet.dataValidationRules) { continue; } for (const rule of sheet.dataValidationRules) { this.rules[sheet.id].push({ ...rule, ranges: rule.ranges.map((range) => this.getters.getRangeFromSheetXC(sheet.id, range)), }); } } } export(data) { if (!data.sheets) { return; } for (const sheet of data.sheets) { sheet.dataValidationRules = []; for (const rule of this.rules[sheet.id]) { sheet.dataValidationRules.push({ ...rule, ranges: rule.ranges.map((range) => this.getters.getRangeString(range, sheet.id)), }); } } } checkCriterionTypeIsValid(cmd) { return dataValidationEvaluatorRegistry.contains(cmd.rule.criterion.type) ? "Success" /* CommandResult.Success */ : "UnknownDataValidationCriterionType" /* CommandResult.UnknownDataValidationCriterionType */; } checkCriterionHasValidNumberOfValues(cmd) { const criterion = cmd.rule.criterion; const evaluator = dataValidationEvaluatorRegistry.get(criterion.type); const expectedNumberOfValues = evaluator.numberOfValues(criterion); if (expectedNumberOfValues !== undefined && criterion.values.length !== expectedNumberOfValues) { return "InvalidNumberOfCriterionValues" /* CommandResult.InvalidNumberOfCriterionValues */; } return "Success" /* CommandResult.Success */; } checkCriterionValuesAreValid(cmd) { const criterion = cmd.rule.criterion; const evaluator = dataValidationEvaluatorRegistry.get(criterion.type); if (criterion.values.some((value) => { if (value.startsWith("=")) { return evaluator.allowedValues === "onlyLiterals"; } else if (evaluator.allowedValues === "onlyFormulas") { return true; } else { return !evaluator.isCriterionValueValid(value); } })) { return "InvalidDataValidationCriterionValue" /* CommandResult.InvalidDataValidationCriterionValue */; } return "Success" /* CommandResult.Success */; } } class FigurePlugin extends CorePlugin { static getters = ["getFigures", "getFigure", "getFigureSheetId"]; figures = {}; // --------------------------------------------------------------------------- // Command Handling // --------------------------------------------------------------------------- allowDispatch(cmd) { switch (cmd.type) { case "CREATE_FIGURE": return this.checkFigureDuplicate(cmd.figure.id); case "UPDATE_FIGURE": case "DELETE_FIGURE": return this.checkFigureExists(cmd.sheetId, cmd.id); default: return "Success" /* CommandResult.Success */; } } beforeHandle(cmd) { switch (cmd.type) { case "DELETE_SHEET": this.getters.getFigures(cmd.sheetId).forEach((figure) => { this.dispatch("DELETE_FIGURE", { id: figure.id, sheetId: cmd.sheetId }); }); break; } } handle(cmd) { switch (cmd.type) { case "CREATE_SHEET": this.figures[cmd.sheetId] = {}; break; case "DELETE_SHEET": this.deleteSheet(cmd.sheetId); break; case "CREATE_FIGURE": this.addFigure(cmd.figure, cmd.sheetId); break; case "UPDATE_FIGURE": const { type, sheetId, ...update } = cmd; const figure = update; this.updateFigure(sheetId, figure); break; case "DELETE_FIGURE": this.removeFigure(cmd.id, cmd.sheetId); break; case "REMOVE_COLUMNS_ROWS": this.onRowColDelete(cmd.sheetId, cmd.dimension); } } onRowColDelete(sheetId, dimension) { dimension === "ROW" ? this.onRowDeletion(sheetId) : this.onColDeletion(sheetId); } onRowDeletion(sheetId) { const numHeader = this.getters.getNumberRows(sheetId); let gridHeight = 0; for (let i = 0; i < numHeader; i++) { // TODO : since the row size is an UI value now, this doesn't work anymore. Using the default cell height is // a temporary solution at best, but is broken. gridHeight += this.getters.getUserRowSize(sheetId, i) || DEFAULT_CELL_HEIGHT; } const figures = this.getters.getFigures(sheetId); for (const figure of figures) { const newY = Math.min(figure.y, gridHeight - figure.height); if (newY !== figure.y) { this.dispatch("UPDATE_FIGURE", { sheetId, id: figure.id, y: newY }); } } } onColDeletion(sheetId) { const numHeader = this.getters.getNumberCols(sheetId); let gridWidth = 0; for (let i = 0; i < numHeader; i++) { gridWidth += this.getters.getColSize(sheetId, i); } const figures = this.getters.getFigures(sheetId); for (const figure of figures) { const newX = Math.min(figure.x, gridWidth - figure.width); if (newX !== figure.x) { this.dispatch("UPDATE_FIGURE", { sheetId, id: figure.id, x: newX }); } } } updateFigure(sheetId, figure) { if (!("id" in figure)) { return; } for (const [key, value] of Object.entries(figure)) { switch (key) { case "x": case "y": if (value !== undefined) { this.history.update("figures", sheetId, figure.id, key, Math.max(value, 0)); } break; case "width": case "height": if (value !== undefined) { this.history.update("figures", sheetId, figure.id, key, value); } break; } } } addFigure(figure, sheetId) { this.history.update("figures", sheetId, figure.id, figure); } deleteSheet(sheetId) { this.history.update("figures", sheetId, undefined); } removeFigure(id, sheetId) { this.history.update("figures", sheetId, id, undefined); } checkFigureExists(sheetId, figureId) { if (this.figures[sheetId]?.[figureId] === undefined) { return "FigureDoesNotExist" /* CommandResult.FigureDoesNotExist */; } return "Success" /* CommandResult.Success */; } checkFigureDuplicate(figureId) { if (Object.values(this.figures).find((sheet) => sheet?.[figureId])) { return "DuplicatedFigureId" /* CommandResult.DuplicatedFigureId */; } return "Success" /* CommandResult.Success */; } // --------------------------------------------------------------------------- // Getters // --------------------------------------------------------------------------- getFigures(sheetId) { return Object.values(this.figures[sheetId] || {}).filter(isDefined); } getFigure(sheetId, figureId) { return this.figures[sheetId]?.[figureId]; } getFigureSheetId(figureId) { return Object.keys(this.figures).find((sheetId) => this.figures[sheetId]?.[figureId] !== undefined); } // --------------------------------------------------------------------------- // Import/Export // --------------------------------------------------------------------------- import(data) { for (let sheet of data.sheets) { const figures = {}; sheet.figures.forEach((figure) => { figures[figure.id] = figure; }); this.figures[sheet.id] = figures; } } export(data) { for (const sheet of data.sheets) { for (const figure of this.getFigures(sheet.id)) { const data = undefined; sheet.figures.push({ ...figure, data }); } } } exportForExcel(data) { this.export(data); } } class HeaderSizePlugin extends CorePlugin { static getters = ["getUserRowSize", "getColSize"]; sizes = {}; handle(cmd) { switch (cmd.type) { case "CREATE_SHEET": { this.history.update("sizes", cmd.sheetId, { COL: Array(this.getters.getNumberCols(cmd.sheetId)).fill(undefined), ROW: Array(this.getters.getNumberRows(cmd.sheetId)).fill(undefined), }); break; } case "DUPLICATE_SHEET": this.history.update("sizes", cmd.sheetIdTo, deepCopy(this.sizes[cmd.sheetId])); break; case "DELETE_SHEET": const sizes = { ...this.sizes }; delete sizes[cmd.sheetId]; this.history.update("sizes", sizes); break; case "REMOVE_COLUMNS_ROWS": { const arr = this.sizes[cmd.sheetId][cmd.dimension]; const sizes = removeIndexesFromArray(arr, cmd.elements); this.history.update("sizes", cmd.sheetId, cmd.dimension, sizes); break; } case "ADD_COLUMNS_ROWS": { let sizes = [...this.sizes[cmd.sheetId][cmd.dimension]]; const addIndex = getAddHeaderStartIndex(cmd.position, cmd.base); const baseSize = sizes[cmd.base]; sizes.splice(addIndex, 0, ...Array(cmd.quantity).fill(baseSize)); this.history.update("sizes", cmd.sheetId, cmd.dimension, sizes); break; } case "RESIZE_COLUMNS_ROWS": if (cmd.dimension === "ROW") { for (const el of cmd.elements) { this.history.update("sizes", cmd.sheetId, cmd.dimension, el, cmd.size || undefined); } } else { for (const el of cmd.elements) { this.history.update("sizes", cmd.sheetId, cmd.dimension, el, cmd.size || undefined); } } break; } return; } getColSize(sheetId, index) { return Math.round(this.sizes[sheetId]?.["COL"][index] || DEFAULT_CELL_WIDTH); } getUserRowSize(sheetId, index) { const rowSize = this.sizes[sheetId]?.["ROW"][index]; return rowSize ? Math.round(rowSize) : undefined; } import(data) { for (let sheet of data.sheets) { const sizes = { COL: Array(sheet.colNumber).fill(undefined), ROW: Array(sheet.rowNumber).fill(undefined), }; for (let [rowIndex, row] of Object.entries(sheet.rows)) { if (row.size) { sizes["ROW"][rowIndex] = row.size; } } for (let [colIndex, col] of Object.entries(sheet.cols)) { if (col.size) { sizes["COL"][colIndex] = col.size; } } this.sizes[sheet.id] = sizes; } return; } exportForExcel(data) { this.exportData(data, true); } export(data) { this.exportData(data); } /** * Export the header sizes * * @param exportDefaults : if true, export column/row sizes even if they have the default size */ exportData(data, exportDefaults = false) { for (let sheet of data.sheets) { // Export row sizes if (sheet.rows === undefined) { sheet.rows = {}; } for (const row of range(0, this.getters.getNumberRows(sheet.id))) { if (exportDefaults || this.sizes[sheet.id]["ROW"][row]) { sheet.rows[row] = { ...sheet.rows[row], size: this.getUserRowSize(sheet.id, row) ?? DEFAULT_CELL_HEIGHT, }; } } // Export col sizes if (sheet.cols === undefined) { sheet.cols = {}; } for (let col of range(0, this.getters.getNumberCols(sheet.id))) { if (exportDefaults || this.sizes[sheet.id]["COL"][col]) { sheet.cols[col] = { ...sheet.cols[col], size: this.getColSize(sheet.id, col) }; } } } } } class HeaderVisibilityPlugin extends CorePlugin { static getters = [ "checkElementsIncludeAllVisibleHeaders", "getHiddenColsGroups", "getHiddenRowsGroups", "isHeaderHiddenByUser", "isRowHiddenByUser", "isColHiddenByUser", ]; hiddenHeaders = {}; allowDispatch(cmd) { switch (cmd.type) { case "HIDE_COLUMNS_ROWS": { if (!this.getters.tryGetSheet(cmd.sheetId)) { return "InvalidSheetId" /* CommandResult.InvalidSheetId */; } const hiddenGroup = cmd.dimension === "COL" ? this.getHiddenColsGroups(cmd.sheetId) : this.getHiddenRowsGroups(cmd.sheetId); const elements = cmd.dimension === "COL" ? this.getters.getNumberCols(cmd.sheetId) : this.getters.getNumberRows(cmd.sheetId); const hiddenElements = new Set((hiddenGroup || []).flat().concat(cmd.elements)); if (hiddenElements.size >= elements) { return "TooManyHiddenElements" /* CommandResult.TooManyHiddenElements */; } else if (largeMin(cmd.elements) < 0 || largeMax(cmd.elements) > elements) { return "InvalidHeaderIndex" /* CommandResult.InvalidHeaderIndex */; } else { return "Success" /* CommandResult.Success */; } } case "REMOVE_COLUMNS_ROWS": if (!this.getters.tryGetSheet(cmd.sheetId)) { return "InvalidSheetId" /* CommandResult.InvalidSheetId */; } if (this.checkElementsIncludeAllVisibleHeaders(cmd.sheetId, cmd.dimension, cmd.elements)) { return "NotEnoughElements" /* CommandResult.NotEnoughElements */; } return "Success" /* CommandResult.Success */; } return "Success" /* CommandResult.Success */; } handle(cmd) { switch (cmd.type) { case "CREATE_SHEET": const hiddenHeaders = { COL: Array(this.getters.getNumberCols(cmd.sheetId)).fill(false), ROW: Array(this.getters.getNumberRows(cmd.sheetId)).fill(false), }; this.history.update("hiddenHeaders", cmd.sheetId, hiddenHeaders); break; case "DUPLICATE_SHEET": this.history.update("hiddenHeaders", cmd.sheetIdTo, deepCopy(this.hiddenHeaders[cmd.sheetId])); break; case "DELETE_SHEET": this.history.update("hiddenHeaders", cmd.sheetId, undefined); break; case "REMOVE_COLUMNS_ROWS": { const hiddenHeaders = [...this.hiddenHeaders[cmd.sheetId][cmd.dimension]]; for (let el of [...cmd.elements].sort((a, b) => b - a)) { hiddenHeaders.splice(el, 1); } this.history.update("hiddenHeaders", cmd.sheetId, cmd.dimension, hiddenHeaders); break; } case "ADD_COLUMNS_ROWS": { const hiddenHeaders = [...this.hiddenHeaders[cmd.sheetId][cmd.dimension]]; const addIndex = getAddHeaderStartIndex(cmd.position, cmd.base); hiddenHeaders.splice(addIndex, 0, ...Array(cmd.quantity).fill(false)); this.history.update("hiddenHeaders", cmd.sheetId, cmd.dimension, hiddenHeaders); break; } case "HIDE_COLUMNS_ROWS": for (let el of cmd.elements) { this.history.update("hiddenHeaders", cmd.sheetId, cmd.dimension, el, true); } break; case "UNHIDE_COLUMNS_ROWS": for (let el of cmd.elements) { this.history.update("hiddenHeaders", cmd.sheetId, cmd.dimension, el, false); } break; } return; } checkElementsIncludeAllVisibleHeaders(sheetId, dimension, elements) { const visibleHeaders = this.getAllVisibleHeaders(sheetId, dimension); return includesAll(elements, visibleHeaders); } isHeaderHiddenByUser(sheetId, dimension, index) { return dimension === "COL" ? this.isColHiddenByUser(sheetId, index) : this.isRowHiddenByUser(sheetId, index); } isRowHiddenByUser(sheetId, index) { return this.hiddenHeaders[sheetId].ROW[index] || this.getters.isRowFolded(sheetId, index); } isColHiddenByUser(sheetId, index) { return this.hiddenHeaders[sheetId].COL[index] || this.getters.isColFolded(sheetId, index); } getHiddenColsGroups(sheetId) { const consecutiveIndexes = [[]]; const hiddenCols = this.hiddenHeaders[sheetId].COL; for (let col = 0; col < hiddenCols.length; col++) { const isColHidden = hiddenCols[col]; if (isColHidden) { consecutiveIndexes[consecutiveIndexes.length - 1].push(col); } else { if (consecutiveIndexes[consecutiveIndexes.length - 1].length !== 0) { consecutiveIndexes.push([]); } } } if (consecutiveIndexes[consecutiveIndexes.length - 1].length === 0) { consecutiveIndexes.pop(); } return consecutiveIndexes; } getHiddenRowsGroups(sheetId) { const consecutiveIndexes = [[]]; const hiddenCols = this.hiddenHeaders[sheetId].ROW; for (let row = 0; row < hiddenCols.length; row++) { const isRowHidden = hiddenCols[row]; if (isRowHidden) { consecutiveIndexes[consecutiveIndexes.length - 1].push(row); } else { if (consecutiveIndexes[consecutiveIndexes.length - 1].length !== 0) { consecutiveIndexes.push([]); } } } if (consecutiveIndexes[consecutiveIndexes.length - 1].length === 0) { consecutiveIndexes.pop(); } return consecutiveIndexes; } getAllVisibleHeaders(sheetId, dimension) { const headers = range(0, this.getters.getNumberHeaders(sheetId, dimension)); const foldedHeaders = []; this.getters.getHeaderGroups(sheetId, dimension).forEach((group) => { if (group.isFolded) { foldedHeaders.push(...range(group.start, group.end + 1)); } }); return headers.filter((i) => { return !this.hiddenHeaders[sheetId][dimension][i] && !foldedHeaders.includes(i); }); } import(data) { for (let sheet of data.sheets) { this.hiddenHeaders[sheet.id] = { COL: [], ROW: [] }; for (let row = 0; row < sheet.rowNumber; row++) { this.hiddenHeaders[sheet.id].ROW[row] = Boolean(sheet.rows[row]?.isHidden); } for (let col = 0; col < sheet.colNumber; col++) { this.hiddenHeaders[sheet.id].COL[col] = Boolean(sheet.cols[col]?.isHidden); } } return; } exportForExcel(data) { this.exportData(data, true); } export(data) { this.exportData(data); } exportData(data, exportDefaults = false) { for (let sheet of data.sheets) { if (sheet.rows === undefined) { sheet.rows = {}; } for (let row = 0; row < this.getters.getNumberRows(sheet.id); row++) { if (exportDefaults || this.hiddenHeaders[sheet.id]["ROW"][row]) { if (sheet.rows[row] === undefined) { sheet.rows[row] = {}; } sheet.rows[row].isHidden ||= this.hiddenHeaders[sheet.id]["ROW"][row]; } } if (sheet.cols === undefined) { sheet.cols = {}; } for (let col = 0; col < this.getters.getNumberCols(sheet.id); col++) { if (exportDefaults || this.hiddenHeaders[sheet.id]["COL"][col]) { if (sheet.cols[col] === undefined) { sheet.cols[col] = {}; } sheet.cols[col].isHidden ||= this.hiddenHeaders[sheet.id]["COL"][col]; } } } } } class ImagePlugin extends CorePlugin { static getters = ["getImage", "getImagePath", "getImageSize"]; fileStore; images = {}; /** * paths of images synced with the file store server. */ syncedImages = new Set(); constructor(config) { super(config); this.fileStore = config.external.fileStore; } // --------------------------------------------------------------------------- // Command Handling // --------------------------------------------------------------------------- allowDispatch(cmd) { switch (cmd.type) { case "CREATE_IMAGE": if (this.getters.getFigure(cmd.sheetId, cmd.figureId)) { return "InvalidFigureId" /* CommandResult.InvalidFigureId */; } return "Success" /* CommandResult.Success */; default: return "Success" /* CommandResult.Success */; } } handle(cmd) { switch (cmd.type) { case "CREATE_IMAGE": this.addImage(cmd.figureId, cmd.sheetId, cmd.position, cmd.size); this.history.update("images", cmd.sheetId, cmd.figureId, cmd.definition); this.syncedImages.add(cmd.definition.path); break; case "DUPLICATE_SHEET": { const sheetFiguresFrom = this.getters.getFigures(cmd.sheetId); for (const fig of sheetFiguresFrom) { if (fig.tag === "image") { const figureIdBase = fig.id.split(FIGURE_ID_SPLITTER).pop(); const duplicatedFigureId = `${cmd.sheetIdTo}${FIGURE_ID_SPLITTER}${figureIdBase}`; const image = this.getImage(fig.id); if (image) { const size = { width: fig.width, height: fig.height }; this.dispatch("CREATE_IMAGE", { sheetId: cmd.sheetIdTo, figureId: duplicatedFigureId, position: { x: fig.x, y: fig.y }, size, definition: deepCopy(image), }); } } } break; } case "DELETE_FIGURE": this.history.update("images", cmd.sheetId, cmd.id, undefined); break; case "DELETE_SHEET": this.history.update("images", cmd.sheetId, undefined); break; } } /** * Delete unused images from the file store */ garbageCollectExternalResources() { const images = new Set(this.getAllImages().map((image) => image.path)); for (const path of this.syncedImages) { if (!images.has(path)) { this.fileStore?.delete(path); } } } // --------------------------------------------------------------------------- // Getters // --------------------------------------------------------------------------- getImage(figureId) { for (const sheet of Object.values(this.images)) { if (sheet && sheet[figureId]) { return sheet[figureId]; } } throw new Error(`There is no image with the given figureId: ${figureId}`); } getImagePath(figureId) { return this.getImage(figureId).path; } getImageSize(figureId) { return this.getImage(figureId).size; } // --------------------------------------------------------------------------- // Private // --------------------------------------------------------------------------- addImage(id, sheetId, position, size) { const figure = { id, x: position.x, y: position.y, width: size.width, height: size.height, tag: "image", }; this.dispatch("CREATE_FIGURE", { sheetId, figure }); } import(data) { for (const sheet of data.sheets) { const images = (sheet.figures || []).filter((figure) => figure.tag === "image"); for (const image of images) { this.history.update("images", sheet.id, image.id, image.data); this.syncedImages.add(image.data.path); } } } export(data) { for (const sheet of data.sheets) { const images = sheet.figures.filter((figure) => figure.tag === "image"); for (const image of images) { image.data = this.images[sheet.id]?.[image.id]; } } } exportForExcel(data) { for (const sheet of data.sheets) { if (!sheet.images) { sheet.images = []; } const figures = this.getters.getFigures(sheet.id); const images = []; for (const figure of figures) { if (figure?.tag === "image") { const image = this.getImage(figure.id); if (image) { images.push({ ...figure, data: deepCopy(image), }); } } } sheet.images = [...sheet.images, ...images]; } } getAllImages() { const images = []; for (const sheetId in this.images) { images.push(...Object.values(this.images[sheetId] || {}).filter(isDefined)); } return images; } } class MergePlugin extends CorePlugin { static getters = [ "isInMerge", "isInSameMerge", "isMergeHidden", "getMainCellPosition", "expandZone", "doesIntersectMerge", "doesColumnsHaveCommonMerges", "doesRowsHaveCommonMerges", "getMerges", "getMerge", "getMergesInZone", "isSingleCellOrMerge", "getSelectionRangeString", "isMainCellPosition", ]; nextId = 1; merges = {}; mergeCellMap = {}; // --------------------------------------------------------------------------- // Command Handling // --------------------------------------------------------------------------- allowDispatch(cmd) { const force = "force" in cmd ? !!cmd.force : false; switch (cmd.type) { case "ADD_MERGE": if (force) { return this.checkValidations(cmd, this.checkFrozenPanes); } return this.checkValidations(cmd, this.checkDestructiveMerge, this.checkOverlap, this.checkFrozenPanes); case "UPDATE_CELL": return this.checkMergedContentUpdate(cmd); case "REMOVE_MERGE": return this.checkMergeExists(cmd); default: return "Success" /* CommandResult.Success */; } } handle(cmd) { switch (cmd.type) { case "CREATE_SHEET": this.history.update("merges", cmd.sheetId, {}); this.history.update("mergeCellMap", cmd.sheetId, {}); break; case "DELETE_SHEET": this.history.update("merges", cmd.sheetId, {}); this.history.update("mergeCellMap", cmd.sheetId, {}); break; case "DUPLICATE_SHEET": const merges = this.merges[cmd.sheetId]; if (!merges) break; for (const range of Object.values(merges).filter(isDefined)) { this.addMerge(cmd.sheetIdTo, range.zone); } break; case "ADD_MERGE": for (const zone of cmd.target) { this.addMerge(cmd.sheetId, zone); } break; case "REMOVE_MERGE": for (const zone of cmd.target) { this.removeMerge(cmd.sheetId, zone); } break; } } adaptRanges(applyChange, sheetId) { const sheetIds = sheetId ? [sheetId] : Object.keys(this.merges); for (const sheetId of sheetIds) { this.applyRangeChangeOnSheet(sheetId, applyChange); } } // --------------------------------------------------------------------------- // Getters // --------------------------------------------------------------------------- getMerges(sheetId) { return Object.keys(this.merges[sheetId] || {}) .map((mergeId) => this.getMergeById(sheetId, parseInt(mergeId, 10))) .filter(isDefined); } getMerge({ sheetId, col, row }) { const sheetMap = this.mergeCellMap[sheetId]; const mergeId = sheetMap ? col in sheetMap && sheetMap[col]?.[row] : undefined; return mergeId ? this.getMergeById(sheetId, mergeId) : undefined; } getMergesInZone(sheetId, zone) { const sheetMap = this.mergeCellMap[sheetId]; if (!sheetMap) return []; const mergeIds = new Set(); for (let col = zone.left; col <= zone.right; col++) { for (let row = zone.top; row <= zone.bottom; row++) { const mergeId = sheetMap[col]?.[row]; if (mergeId) { mergeIds.add(mergeId); } } } return Array.from(mergeIds) .map((mergeId) => this.getMergeById(sheetId, mergeId)) .filter(isDefined); } /** * Same as `getRangeString` but add all necessary merge to the range to make it a valid selection */ getSelectionRangeString(range, forSheetId) { const rangeImpl = RangeImpl.fromRange(range, this.getters); const expandedZone = this.getters.expandZone(rangeImpl.sheetId, rangeImpl.zone); const expandedRange = rangeImpl.clone({ zone: { ...expandedZone, bottom: rangeImpl.isFullCol ? undefined : expandedZone.bottom, right: rangeImpl.isFullRow ? undefined : expandedZone.right, }, }); const rangeString = this.getters.getRangeString(expandedRange, forSheetId); if (this.isSingleCellOrMerge(rangeImpl.sheetId, rangeImpl.zone)) { const { sheetName, xc } = splitReference(rangeString); return getFullReference(sheetName, xc.split(":")[0]); } return rangeString; } /** * Return true if the zone intersects an existing merge: * if they have at least a common cell */ doesIntersectMerge(sheetId, zone) { for (const merge of this.getMerges(sheetId)) { if (overlap(zone, merge)) { return true; } } return false; } /** * Returns true if two columns have at least one merge in common */ doesColumnsHaveCommonMerges(sheetId, colA, colB) { const sheet = this.getters.getSheet(sheetId); for (let row = 0; row < this.getters.getNumberRows(sheetId); row++) { if (this.isInSameMerge(sheet.id, colA, row, colB, row)) { return true; } } return false; } /** * Returns true if two rows have at least one merge in common */ doesRowsHaveCommonMerges(sheetId, rowA, rowB) { const sheet = this.getters.getSheet(sheetId); for (let col = 0; col <= this.getters.getNumberCols(sheetId); col++) { if (this.isInSameMerge(sheet.id, col, rowA, col, rowB)) { return true; } } return false; } /** * Add all necessary merge to the current selection to make it valid */ expandZone(sheetId, zone) { let { left, right, top, bottom } = zone; let result = { left, right, top, bottom }; for (let id in this.merges[sheetId]) { const merge = this.getMergeById(sheetId, parseInt(id)); if (merge && overlap(merge, result)) { result = union(merge, result); } } return isEqual(result, zone) ? result : this.expandZone(sheetId, result); } isInSameMerge(sheetId, colA, rowA, colB, rowB) { const mergeA = this.getMerge({ sheetId, col: colA, row: rowA }); const mergeB = this.getMerge({ sheetId, col: colB, row: rowB }); if (!mergeA || !mergeB) { return false; } return isEqual(mergeA, mergeB); } isInMerge({ sheetId, col, row }) { const sheetMap = this.mergeCellMap[sheetId]; return sheetMap ? col in sheetMap && Boolean(sheetMap[col]?.[row]) : false; } getMainCellPosition(position) { const mergeZone = this.getMerge(position); return mergeZone ? { sheetId: position.sheetId, col: mergeZone.left, row: mergeZone.top, } : position; } isMergeHidden(sheetId, merge) { const hiddenColsGroups = this.getters.getHiddenColsGroups(sheetId); const hiddenRowsGroups = this.getters.getHiddenRowsGroups(sheetId); for (let group of hiddenColsGroups) { if (merge.left >= group[0] && merge.right <= group[group.length - 1]) { return true; } } for (let group of hiddenRowsGroups) { if (merge.top >= group[0] && merge.bottom <= group[group.length - 1]) { return true; } } return false; } /** * Check if the zone represents a single cell or a single merge. */ isSingleCellOrMerge(sheetId, zone) { const merge = this.getMerge({ sheetId, col: zone.left, row: zone.top }); if (merge) { return isEqual(zone, merge); } const { numberOfCols, numberOfRows } = zoneToDimension(zone); return numberOfCols === 1 && numberOfRows === 1; } isMainCellPosition(position) { return deepEquals(this.getMainCellPosition(position), position); } // --------------------------------------------------------------------------- // Merges // --------------------------------------------------------------------------- /** * Return true if the current selection requires losing state if it is merged. * This happens when there is some textual content in other cells than the * top left. */ isMergeDestructive(sheetId, zone) { let { left, right, top, bottom } = zone; right = clip(right, 0, this.getters.getNumberCols(sheetId) - 1); bottom = clip(bottom, 0, this.getters.getNumberRows(sheetId) - 1); for (let row = top; row <= bottom; row++) { for (let col = left; col <= right; col++) { if (col !== left || row !== top) { const cell = this.getters.getCell({ sheetId, col, row }); if (cell && cell.content !== "") { return true; } } } } return false; } getMergeById(sheetId, mergeId) { const range = this.merges[sheetId]?.[mergeId]; return range !== undefined ? rangeToMerge(mergeId, range) : undefined; } checkDestructiveMerge({ sheetId, target }) { const sheet = this.getters.tryGetSheet(sheetId); if (!sheet) return "Success" /* CommandResult.Success */; const isDestructive = target.some((zone) => this.isMergeDestructive(sheetId, zone)); return isDestructive ? "MergeIsDestructive" /* CommandResult.MergeIsDestructive */ : "Success" /* CommandResult.Success */; } checkOverlap({ target }) { for (const zone of target) { for (const zone2 of target) { if (zone !== zone2 && overlap(zone, zone2)) { return "MergeOverlap" /* CommandResult.MergeOverlap */; } } } return "Success" /* CommandResult.Success */; } checkFrozenPanes({ sheetId, target }) { const sheet = this.getters.tryGetSheet(sheetId); if (!sheet) return "Success" /* CommandResult.Success */; const { xSplit, ySplit } = this.getters.getPaneDivisions(sheetId); for (const zone of target) { if ((zone.left < xSplit && zone.right >= xSplit) || (zone.top < ySplit && zone.bottom >= ySplit)) { return "FrozenPaneOverlap" /* CommandResult.FrozenPaneOverlap */; } } return "Success" /* CommandResult.Success */; } /** * The content of a merged cell should always be empty. * Except for the top-left cell. */ checkMergedContentUpdate(cmd) { const { col, row, content } = cmd; if (content === undefined) { return "Success" /* CommandResult.Success */; } const { col: mainCol, row: mainRow } = this.getMainCellPosition(cmd); if (mainCol === col && mainRow === row) { return "Success" /* CommandResult.Success */; } return "CellIsMerged" /* CommandResult.CellIsMerged */; } checkMergeExists(cmd) { const { sheetId, target } = cmd; for (const zone of target) { const { left, top } = zone; const merge = this.getMerge({ sheetId, col: left, row: top }); if (merge === undefined || !isEqual(zone, merge)) { return "InvalidTarget" /* CommandResult.InvalidTarget */; } } return "Success" /* CommandResult.Success */; } /** * Merge the current selection. Note that: * - it assumes that we have a valid selection (no intersection with other * merges) * - it does nothing if the merge is trivial: A1:A1 */ addMerge(sheetId, zone) { let { left, right, top, bottom } = zone; right = clip(right, 0, this.getters.getNumberCols(sheetId) - 1); bottom = clip(bottom, 0, this.getters.getNumberRows(sheetId) - 1); const tl = toXC(left, top); const br = toXC(right, bottom); if (tl === br) { return; } const topLeft = this.getters.getCell({ sheetId, col: left, row: top }); let id = this.nextId++; this.history.update("merges", sheetId, id, this.getters.getRangeFromSheetXC(sheetId, zoneToXc({ left, top, right, bottom }))); let previousMerges = new Set(); for (let row = top; row <= bottom; row++) { for (let col = left; col <= right; col++) { if (col !== left || row !== top) { this.dispatch("UPDATE_CELL", { sheetId, col, row, style: topLeft ? topLeft.style : null, content: "", }); } const merge = this.getMerge({ sheetId, col, row }); if (merge) { previousMerges.add(merge.id); } this.history.update("mergeCellMap", sheetId, col, row, id); } } for (let mergeId of previousMerges) { const { top, bottom, left, right } = this.getMergeById(sheetId, mergeId); for (let row = top; row <= bottom; row++) { for (let col = left; col <= right; col++) { const position = { sheetId, col, row }; const merge = this.getMerge(position); if (!merge || merge.id !== id) { this.history.update("mergeCellMap", sheetId, col, row, undefined); this.dispatch("CLEAR_CELL", position); } } } this.history.update("merges", sheetId, mergeId, undefined); } } removeMerge(sheetId, zone) { const { left, top, bottom, right } = zone; const merge = this.getMerge({ sheetId, col: left, row: top }); if (merge === undefined || !isEqual(zone, merge)) { return; } this.history.update("merges", sheetId, merge.id, undefined); for (let r = top; r <= bottom; r++) { for (let c = left; c <= right; c++) { this.history.update("mergeCellMap", sheetId, c, r, undefined); } } } /** * Apply a range change on merges of a particular sheet. */ applyRangeChangeOnSheet(sheetId, applyChange) { const merges = Object.entries(this.merges[sheetId] || {}); for (const [mergeId, range] of merges) { if (range) { const currentZone = range.zone; const result = applyChange(range); switch (result.changeType) { case "NONE": break; case "REMOVE": this.removeMerge(sheetId, currentZone); break; default: const { numberOfCols, numberOfRows } = zoneToDimension(result.range.zone); if (numberOfCols === 1 && numberOfRows === 1) { this.removeMerge(sheetId, currentZone); } else { this.history.update("merges", sheetId, parseInt(mergeId, 10), result.range); } break; } } } this.history.update("mergeCellMap", sheetId, {}); for (const merge of this.getMerges(sheetId)) { for (const { col, row } of positions(merge)) { this.history.update("mergeCellMap", sheetId, col, row, merge.id); } } } // --------------------------------------------------------------------------- // Import/Export // --------------------------------------------------------------------------- import(data) { const sheets = data.sheets || []; for (let sheetData of sheets) { this.history.update("merges", sheetData.id, {}); this.history.update("mergeCellMap", sheetData.id, {}); if (sheetData.merges) { this.importMerges(sheetData.id, sheetData.merges); } } } importMerges(sheetId, merges) { for (let merge of merges) { this.addMerge(sheetId, toZone(merge)); } } export(data) { for (let sheetData of data.sheets) { const merges = this.merges[sheetData.id]; if (merges) { sheetData.merges.push(...exportMerges(merges)); } } } exportForExcel(data) { this.export(data); } } function exportMerges(merges) { return Object.entries(merges) .map(([mergeId, range]) => (range ? rangeToMerge(parseInt(mergeId, 10), range) : undefined)) .filter(isDefined) .map((merge) => toXC(merge.left, merge.top) + ":" + toXC(merge.right, merge.bottom)); } function rangeToMerge(mergeId, range) { return { ...range.zone, id: mergeId, }; } class RangeAdapter { getters; providers = []; constructor(getters) { this.getters = getters; } static getters = [ "extendRange", "getRangeString", "getRangeFromSheetXC", "createAdaptedRanges", "getRangeDataFromXc", "getRangeDataFromZone", "getRangeFromRangeData", "getRangeFromZone", "getRangesUnion", "recomputeRanges", "isRangeValid", "removeRangesSheetPrefix", ]; // --------------------------------------------------------------------------- // Command Handling // --------------------------------------------------------------------------- allowDispatch(cmd) { if (cmd.type === "MOVE_RANGES") { return cmd.target.length === 1 ? "Success" /* CommandResult.Success */ : "InvalidZones" /* CommandResult.InvalidZones */; } return "Success" /* CommandResult.Success */; } beforeHandle(command) { } handle(cmd) { switch (cmd.type) { case "REMOVE_COLUMNS_ROWS": { let start = cmd.dimension === "COL" ? "left" : "top"; let end = cmd.dimension === "COL" ? "right" : "bottom"; let dimension = cmd.dimension === "COL" ? "columns" : "rows"; const elements = [...cmd.elements]; elements.sort((a, b) => b - a); const groups = groupConsecutive(elements); this.executeOnAllRanges((range) => { if (range.sheetId !== cmd.sheetId) { return { changeType: "NONE" }; } let newRange = range; let changeType = "NONE"; for (let group of groups) { const min = largeMin(group); const max = largeMax(group); if (range.zone[start] <= min && min <= range.zone[end]) { const toRemove = Math.min(range.zone[end], max) - min + 1; changeType = "RESIZE"; newRange = this.createAdaptedRange(newRange, dimension, changeType, -toRemove); } else if (range.zone[start] >= min && range.zone[end] <= max) { changeType = "REMOVE"; newRange = range.clone({ ...this.getInvalidRange() }); } else if (range.zone[start] <= max && range.zone[end] >= max) { const toRemove = max - range.zone[start] + 1; changeType = "RESIZE"; newRange = this.createAdaptedRange(newRange, dimension, changeType, -toRemove); newRange = this.createAdaptedRange(newRange, dimension, "MOVE", -(range.zone[start] - min)); } else if (min < range.zone[start]) { changeType = "MOVE"; newRange = this.createAdaptedRange(newRange, dimension, changeType, -(max - min + 1)); } } if (changeType !== "NONE") { return { changeType, range: newRange }; } return { changeType: "NONE" }; }, cmd.sheetId); break; } case "ADD_COLUMNS_ROWS": { let start = cmd.dimension === "COL" ? "left" : "top"; let end = cmd.dimension === "COL" ? "right" : "bottom"; let dimension = cmd.dimension === "COL" ? "columns" : "rows"; this.executeOnAllRanges((range) => { if (range.sheetId !== cmd.sheetId) { return { changeType: "NONE" }; } if (cmd.position === "after") { if (range.zone[start] <= cmd.base && cmd.base < range.zone[end]) { return { changeType: "RESIZE", range: this.createAdaptedRange(range, dimension, "RESIZE", cmd.quantity), }; } if (cmd.base < range.zone[start]) { return { changeType: "MOVE", range: this.createAdaptedRange(range, dimension, "MOVE", cmd.quantity), }; } } else { if (range.zone[start] < cmd.base && cmd.base <= range.zone[end]) { return { changeType: "RESIZE", range: this.createAdaptedRange(range, dimension, "RESIZE", cmd.quantity), }; } if (cmd.base <= range.zone[start]) { return { changeType: "MOVE", range: this.createAdaptedRange(range, dimension, "MOVE", cmd.quantity), }; } } return { changeType: "NONE" }; }, cmd.sheetId); break; } case "DELETE_SHEET": { this.executeOnAllRanges((range) => { if (range.sheetId !== cmd.sheetId) { return { changeType: "NONE" }; } const invalidSheetName = this.getters.getSheetName(cmd.sheetId); range = range.clone({ ...this.getInvalidRange(), invalidSheetName, }); return { changeType: "REMOVE", range }; }, cmd.sheetId); break; } case "RENAME_SHEET": { this.executeOnAllRanges((range) => { if (range.sheetId === cmd.sheetId) { return { changeType: "CHANGE", range }; } if (cmd.name && range.invalidSheetName === cmd.name) { const invalidSheetName = undefined; const sheetId = cmd.sheetId; const newRange = range.clone({ sheetId, invalidSheetName }); return { changeType: "CHANGE", range: newRange }; } return { changeType: "NONE" }; }); break; } case "MOVE_RANGES": { const originZone = cmd.target[0]; this.executeOnAllRanges((range) => { if (range.sheetId !== cmd.sheetId || !isZoneInside(range.zone, originZone)) { return { changeType: "NONE" }; } const targetSheetId = cmd.targetSheetId; const offsetX = cmd.col - originZone.left; const offsetY = cmd.row - originZone.top; const adaptedRange = this.createAdaptedRange(range, "both", "MOVE", [offsetX, offsetY]); const prefixSheet = cmd.sheetId === targetSheetId ? adaptedRange.prefixSheet : true; return { changeType: "MOVE", range: adaptedRange.clone({ sheetId: targetSheetId, prefixSheet }), }; }); break; } } } finalize() { } /** * Return a modified adapting function that verifies that after adapting a range, the range is still valid. * Any range that gets adapted by the function adaptRange in parameter does so * without caring if the start and end of the range in both row and column * direction can be incorrect. This function ensure that an incorrect range gets removed. */ verifyRangeRemoved(adaptRange) { return (range) => { const result = adaptRange(range); if (result.changeType !== "NONE" && !isZoneValid(result.range.zone)) { return { range: result.range, changeType: "REMOVE" }; } return result; }; } createAdaptedRange(range, dimension, operation, by) { const zone = createAdaptedZone(range.unboundedZone, dimension, operation, by); const adaptedRange = range.clone({ zone }); return adaptedRange; } executeOnAllRanges(adaptRange, sheetId) { const func = this.verifyRangeRemoved(adaptRange); for (const provider of this.providers) { provider(func, sheetId); } } /** * Stores the functions bound to each plugin to be able to iterate over all ranges of the application, * without knowing any details of the internal data structure of the plugins and without storing ranges * in the range adapter. * * @param provider a function bound to a plugin that will loop over its internal data structure to find * all ranges */ addRangeProvider(provider) { this.providers.push(provider); } // --------------------------------------------------------------------------- // Getters // --------------------------------------------------------------------------- createAdaptedRanges(ranges, offsetX, offsetY, sheetId) { const rangesImpl = ranges.map((range) => RangeImpl.fromRange(range, this.getters)); return rangesImpl.map((range) => { if (!isZoneValid(range.zone)) { return range; } const copySheetId = range.prefixSheet ? range.sheetId : sheetId; const unboundZone = { ...range.unboundedZone, // Don't shift left if the range is a full row without header left: range.isFullRow && !range.unboundedZone.hasHeader ? range.unboundedZone.left : range.unboundedZone.left + (range.parts[0].colFixed ? 0 : offsetX), // Don't shift right if the range is a full row right: range.isFullRow ? range.unboundedZone.right : range.unboundedZone.right + ((range.parts[1] || range.parts[0]).colFixed ? 0 : offsetX), // Don't shift up if the range is a column row without header top: range.isFullCol && !range.unboundedZone.hasHeader ? range.unboundedZone.top : range.unboundedZone.top + (range.parts[0].rowFixed ? 0 : offsetY), // Don't shift down if the range is a full column bottom: range.isFullCol ? range.unboundedZone.bottom : range.unboundedZone.bottom + ((range.parts[1] || range.parts[0]).rowFixed ? 0 : offsetY), }; return range.clone({ sheetId: copySheetId, zone: unboundZone }).orderZone(); }); } /** * Remove the sheet name prefix if a range is part of the given sheet. */ removeRangesSheetPrefix(sheetId, ranges) { return ranges.map((range) => { const rangeImpl = RangeImpl.fromRange(range, this.getters); if (rangeImpl.prefixSheet && rangeImpl.sheetId === sheetId) { return rangeImpl.clone({ prefixSheet: false }); } return rangeImpl; }); } extendRange(range, dimension, quantity) { const rangeImpl = RangeImpl.fromRange(range, this.getters); const right = dimension === "COL" ? rangeImpl.zone.right + quantity : rangeImpl.zone.right; const bottom = dimension === "ROW" ? rangeImpl.zone.bottom + quantity : rangeImpl.zone.bottom; const zone = { left: rangeImpl.zone.left, top: rangeImpl.zone.top, right: rangeImpl.isFullRow ? undefined : right, bottom: rangeImpl.isFullCol ? undefined : bottom, }; return new RangeImpl({ ...rangeImpl, zone }, this.getters.getSheetSize).orderZone(); } /** * Creates a range from a XC reference that can contain a sheet reference * @param defaultSheetId the sheet to default to if the sheetXC parameter does not contain a sheet reference (usually the active sheet Id) * @param sheetXC the string description of a range, in the form SheetName!XC:XC */ getRangeFromSheetXC(defaultSheetId, sheetXC) { if (!rangeReference.test(sheetXC) || !this.getters.tryGetSheet(defaultSheetId)) { return new RangeImpl({ sheetId: "", zone: { left: -1, top: -1, right: -1, bottom: -1 }, parts: [], invalidXc: sheetXC, prefixSheet: false, }, this.getters.getSheetSize); } let sheetName; let xc = sheetXC; let prefixSheet = false; if (sheetXC.includes("!")) { ({ xc, sheetName } = splitReference(sheetXC)); if (sheetName) { prefixSheet = true; } } const zone = toUnboundedZone(xc); const parts = RangeImpl.getRangeParts(xc, zone); const invalidSheetName = sheetName && !this.getters.getSheetIdByName(sheetName) ? sheetName : undefined; const sheetId = this.getters.getSheetIdByName(sheetName) || defaultSheetId; const rangeInterface = { prefixSheet, zone, sheetId, invalidSheetName, parts }; return new RangeImpl(rangeInterface, this.getters.getSheetSize).orderZone(); } /** * Gets the string that represents the range as it is at the moment of the call. * The string will be prefixed with the sheet name if the call specified a sheet id in `forSheetId` * different than the sheet on which the range has been created. * * @param range the range (received from getRangeFromXC or getRangeFromZone) * @param forSheetId the id of the sheet where the range string is supposed to be used. * @param options * @param options.useFixedReference if true, the range will be returned with fixed row and column */ getRangeString(range, forSheetId, options = { useFixedReference: false }) { if (!range) { return CellErrorType.InvalidReference; } if (range.invalidXc) { return range.invalidXc; } if (!this.getters.tryGetSheet(range.sheetId)) { return CellErrorType.InvalidReference; } if (range.zone.bottom - range.zone.top < 0 || range.zone.right - range.zone.left < 0) { return CellErrorType.InvalidReference; } if (range.zone.left < 0 || range.zone.top < 0) { return CellErrorType.InvalidReference; } const rangeImpl = RangeImpl.fromRange(range, this.getters); let prefixSheet = rangeImpl.sheetId !== forSheetId || rangeImpl.invalidSheetName || rangeImpl.prefixSheet; let sheetName = ""; if (prefixSheet) { if (rangeImpl.invalidSheetName) { sheetName = rangeImpl.invalidSheetName; } else { sheetName = getCanonicalSymbolName(this.getters.getSheetName(rangeImpl.sheetId)); } } if (prefixSheet && !sheetName) { return CellErrorType.InvalidReference; } let rangeString = this.getRangePartString(rangeImpl, 0, options); if (rangeImpl.parts && rangeImpl.parts.length === 2) { // this if converts A2:A2 into A2 except if any part of the original range had fixed row or column (with $) if (rangeImpl.zone.top !== rangeImpl.zone.bottom || rangeImpl.zone.left !== rangeImpl.zone.right || rangeImpl.parts[0].rowFixed || rangeImpl.parts[0].colFixed || rangeImpl.parts[1].rowFixed || rangeImpl.parts[1].colFixed) { rangeString += ":"; rangeString += this.getRangePartString(rangeImpl, 1, options); } } return `${prefixSheet ? sheetName + "!" : ""}${rangeString}`; } getRangeDataFromXc(sheetId, xc) { return this.getters.getRangeFromSheetXC(sheetId, xc).rangeData; } getRangeDataFromZone(sheetId, zone) { zone = this.getters.getUnboundedZone(sheetId, zone); return { _sheetId: sheetId, _zone: zone }; } getRangeFromZone(sheetId, zone) { return new RangeImpl({ sheetId, zone, parts: [ { colFixed: false, rowFixed: false }, { colFixed: false, rowFixed: false }, ], prefixSheet: false, }, this.getters.getSheetSize); } /** * Allows you to recompute ranges from the same sheet */ recomputeRanges(ranges, rangesToRemove) { const zones = ranges.map((range) => RangeImpl.fromRange(range, this.getters).unboundedZone); const zonesToRemove = rangesToRemove.map((range) => RangeImpl.fromRange(range, this.getters).unboundedZone); return recomputeZones(zones, zonesToRemove).map((zone) => this.getRangeFromZone(ranges[0].sheetId, zone)); } getRangeFromRangeData(data) { const rangeInterface = { prefixSheet: false, zone: data._zone, sheetId: data._sheetId, invalidSheetName: undefined, parts: [ { colFixed: false, rowFixed: false }, { colFixed: false, rowFixed: false }, ], }; return new RangeImpl(rangeInterface, this.getters.getSheetSize); } isRangeValid(rangeStr) { if (!rangeStr) { return false; } const { xc, sheetName } = splitReference(rangeStr); return (xc.match(rangeReference) !== null && (!sheetName || this.getters.getSheetIdByName(sheetName) !== undefined)); } getRangesUnion(ranges) { const zones = ranges.map((range) => RangeImpl.fromRange(range, this.getters).unboundedZone); const unionOfZones = unionUnboundedZones(...zones); return this.getRangeFromZone(ranges[0].sheetId, unionOfZones); } // --------------------------------------------------------------------------- // Private // --------------------------------------------------------------------------- /** * Get a Xc string that represent a part of a range */ getRangePartString(range, part, options = { useFixedReference: false }) { const colFixed = range.parts && range.parts[part]?.colFixed ? "$" : ""; const col = part === 0 ? numberToLetters(range.zone.left) : numberToLetters(range.zone.right); const rowFixed = range.parts && range.parts[part]?.rowFixed ? "$" : ""; const row = part === 0 ? String(range.zone.top + 1) : String(range.zone.bottom + 1); let str = ""; if (range.isFullCol && !options.useFixedReference) { if (part === 0 && range.unboundedZone.hasHeader) { str = colFixed + col + rowFixed + row; } else { str = colFixed + col; } } else if (range.isFullRow && !options.useFixedReference) { if (part === 0 && range.unboundedZone.hasHeader) { str = colFixed + col + rowFixed + row; } else { str = rowFixed + row; } } else { str = colFixed + col + rowFixed + row; } return str; } getInvalidRange() { return { parts: [], prefixSheet: false, zone: { left: -1, top: -1, right: -1, bottom: -1 }, sheetId: "", invalidXc: CellErrorType.InvalidReference, }; } } class SheetPlugin extends CorePlugin { static getters = [ "getSheetName", "tryGetSheetName", "getSheet", "tryGetSheet", "getSheetIdByName", "getSheetIds", "getVisibleSheetIds", "isSheetVisible", "doesHeaderExist", "doesHeadersExist", "getCell", "getCellPosition", "getColsZone", "getRowCells", "getRowsZone", "getNumberCols", "getNumberRows", "getNumberHeaders", "getGridLinesVisibility", "getNextSheetName", "getSheetSize", "getSheetZone", "getPaneDivisions", "checkZonesExistInSheet", "getCommandZones", "getUnboundedZone", "checkElementsIncludeAllNonFrozenHeaders", ]; sheetIdsMapName = {}; orderedSheetIds = []; sheets = {}; cellPosition = {}; // --------------------------------------------------------------------------- // Command Handling // --------------------------------------------------------------------------- allowDispatch(cmd) { const genericChecks = this.chainValidations(this.checkSheetExists, this.checkZonesAreInSheet)(cmd); if (genericChecks !== "Success" /* CommandResult.Success */) { return genericChecks; } switch (cmd.type) { case "HIDE_SHEET": { if (this.getVisibleSheetIds().length === 1) { return "NotEnoughSheets" /* CommandResult.NotEnoughSheets */; } return "Success" /* CommandResult.Success */; } case "CREATE_SHEET": { return this.checkValidations(cmd, this.checkSheetName, this.checkSheetPosition); } case "MOVE_SHEET": try { const currentIndex = this.orderedSheetIds.findIndex((id) => id === cmd.sheetId); this.findIndexOfTargetSheet(currentIndex, cmd.delta); return "Success" /* CommandResult.Success */; } catch (e) { return "WrongSheetMove" /* CommandResult.WrongSheetMove */; } case "RENAME_SHEET": return this.isRenameAllowed(cmd); case "COLOR_SHEET": return !cmd.color || isColorValid(cmd.color) ? "Success" /* CommandResult.Success */ : "InvalidColor" /* CommandResult.InvalidColor */; case "DELETE_SHEET": return this.orderedSheetIds.length > 1 ? "Success" /* CommandResult.Success */ : "NotEnoughSheets" /* CommandResult.NotEnoughSheets */; case "ADD_COLUMNS_ROWS": if (!this.doesHeaderExist(cmd.sheetId, cmd.dimension, cmd.base)) { return "InvalidHeaderIndex" /* CommandResult.InvalidHeaderIndex */; } else if (cmd.quantity <= 0) { return "InvalidQuantity" /* CommandResult.InvalidQuantity */; } return "Success" /* CommandResult.Success */; case "REMOVE_COLUMNS_ROWS": { const min = largeMin(cmd.elements); const max = largeMax(cmd.elements); if (min < 0 || !this.doesHeaderExist(cmd.sheetId, cmd.dimension, max)) { return "InvalidHeaderIndex" /* CommandResult.InvalidHeaderIndex */; } else if (this.checkElementsIncludeAllNonFrozenHeaders(cmd.sheetId, cmd.dimension, cmd.elements)) { return "NotEnoughElements" /* CommandResult.NotEnoughElements */; } else { return "Success" /* CommandResult.Success */; } } case "FREEZE_ROWS": { return this.checkValidations(cmd, this.checkRowFreezeQuantity, this.checkRowFreezeOverlapMerge); } case "FREEZE_COLUMNS": { return this.checkValidations(cmd, this.checkColFreezeQuantity, this.checkColFreezeOverlapMerge); } default: return "Success" /* CommandResult.Success */; } } handle(cmd) { switch (cmd.type) { case "SET_GRID_LINES_VISIBILITY": this.setGridLinesVisibility(cmd.sheetId, cmd.areGridLinesVisible); break; case "CREATE_SHEET": const sheet = this.createSheet(cmd.sheetId, cmd.name || this.getNextSheetName(), cmd.cols || 26, cmd.rows || 100, cmd.position); this.history.update("sheetIdsMapName", sheet.name, sheet.id); break; case "MOVE_SHEET": this.moveSheet(cmd.sheetId, cmd.delta); break; case "RENAME_SHEET": this.renameSheet(this.sheets[cmd.sheetId], cmd.name); break; case "COLOR_SHEET": this.history.update("sheets", cmd.sheetId, "color", cmd.color); break; case "HIDE_SHEET": this.hideSheet(cmd.sheetId); break; case "SHOW_SHEET": this.showSheet(cmd.sheetId); break; case "DUPLICATE_SHEET": this.duplicateSheet(cmd.sheetId, cmd.sheetIdTo); break; case "DELETE_SHEET": this.deleteSheet(this.sheets[cmd.sheetId]); break; case "REMOVE_COLUMNS_ROWS": if (cmd.dimension === "COL") { this.removeColumns(this.sheets[cmd.sheetId], [...cmd.elements]); } else { this.removeRows(this.sheets[cmd.sheetId], [...cmd.elements]); } break; case "ADD_COLUMNS_ROWS": if (cmd.dimension === "COL") { this.addColumns(this.sheets[cmd.sheetId], cmd.base, cmd.position, cmd.quantity); } else { this.addRows(this.sheets[cmd.sheetId], cmd.base, cmd.position, cmd.quantity); } break; case "UPDATE_CELL_POSITION": this.updateCellPosition(cmd); break; case "FREEZE_COLUMNS": this.setPaneDivisions(cmd.sheetId, cmd.quantity, "COL"); break; case "FREEZE_ROWS": this.setPaneDivisions(cmd.sheetId, cmd.quantity, "ROW"); break; case "UNFREEZE_ROWS": this.setPaneDivisions(cmd.sheetId, 0, "ROW"); break; case "UNFREEZE_COLUMNS": this.setPaneDivisions(cmd.sheetId, 0, "COL"); break; case "UNFREEZE_COLUMNS_ROWS": this.setPaneDivisions(cmd.sheetId, 0, "COL"); this.setPaneDivisions(cmd.sheetId, 0, "ROW"); } } // --------------------------------------------------------------------------- // Import/Export // --------------------------------------------------------------------------- import(data) { // we need to fill the sheetIds mapping first, because otherwise formulas // that depends on a sheet not already imported will not be able to be // compiled for (let sheet of data.sheets) { this.sheetIdsMapName[sheet.name] = sheet.id; } for (let sheetData of data.sheets) { const name = sheetData.name || _t("Sheet") + (Object.keys(this.sheets).length + 1); const { colNumber, rowNumber } = this.getImportedSheetSize(sheetData); const sheet = { id: sheetData.id, name: name, numberOfCols: colNumber, rows: createDefaultRows(rowNumber), areGridLinesVisible: sheetData.areGridLinesVisible === undefined ? true : sheetData.areGridLinesVisible, isVisible: sheetData.isVisible, panes: { xSplit: sheetData.panes?.xSplit || 0, ySplit: sheetData.panes?.ySplit || 0, }, color: sheetData.color, }; this.orderedSheetIds.push(sheet.id); this.sheets[sheet.id] = sheet; } } exportSheets(data) { data.sheets = this.orderedSheetIds.filter(isDefined).map((id) => { const sheet = this.sheets[id]; const sheetData = { id: sheet.id, name: sheet.name, colNumber: sheet.numberOfCols, rowNumber: this.getters.getNumberRows(sheet.id), rows: {}, cols: {}, merges: [], cells: {}, styles: {}, formats: {}, borders: {}, conditionalFormats: [], figures: [], tables: [], areGridLinesVisible: sheet.areGridLinesVisible === undefined ? true : sheet.areGridLinesVisible, isVisible: sheet.isVisible, color: sheet.color, }; if (sheet.panes.xSplit || sheet.panes.ySplit) { sheetData.panes = sheet.panes; } return sheetData; }); } export(data) { this.exportSheets(data); } exportForExcel(data) { this.exportSheets(data); } // --------------------------------------------------------------------------- // Getters // --------------------------------------------------------------------------- getGridLinesVisibility(sheetId) { return this.getSheet(sheetId).areGridLinesVisible; } tryGetSheet(sheetId) { return this.sheets[sheetId]; } getSheet(sheetId) { const sheet = this.sheets[sheetId]; if (!sheet) { throw new Error(`Sheet ${sheetId} not found.`); } return sheet; } isSheetVisible(sheetId) { return this.getSheet(sheetId).isVisible; } /** * Return the sheet name. Throw if the sheet is not found. */ getSheetName(sheetId) { return this.getSheet(sheetId).name; } /** * Return the sheet name or undefined if the sheet doesn't exist. */ tryGetSheetName(sheetId) { return this.tryGetSheet(sheetId)?.name; } getSheetIdByName(name) { if (name) { const unquotedName = getUnquotedSheetName(name); for (const key in this.sheetIdsMapName) { if (key.toUpperCase() === unquotedName.toUpperCase()) { return this.sheetIdsMapName[key]; } } } return undefined; } getSheetIds() { return this.orderedSheetIds; } getVisibleSheetIds() { return this.orderedSheetIds.filter(this.isSheetVisible.bind(this)); } doesHeaderExist(sheetId, dimension, index) { return dimension === "COL" ? index >= 0 && index < this.getNumberCols(sheetId) : index >= 0 && index < this.getNumberRows(sheetId); } doesHeadersExist(sheetId, dimension, headerIndexes) { return headerIndexes.every((index) => this.doesHeaderExist(sheetId, dimension, index)); } getCell({ sheetId, col, row }) { const sheet = this.tryGetSheet(sheetId); const cellId = sheet?.rows[row]?.cells[col]; if (cellId === undefined) { return undefined; } return this.getters.getCellById(cellId); } getColsZone(sheetId, start, end) { return { top: 0, bottom: this.getNumberRows(sheetId) - 1, left: start, right: end, }; } getRowCells(sheetId, row) { return Object.values(this.getSheet(sheetId).rows[row]?.cells).filter(isDefined); } getRowsZone(sheetId, start, end) { return { top: start, bottom: end, left: 0, right: this.getSheet(sheetId).numberOfCols - 1, }; } getCellPosition(cellId) { const cell = this.cellPosition[cellId]; if (!cell) { throw new Error(`asking for a cell position that doesn't exist, cell id: ${cellId}`); } return cell; } getNumberCols(sheetId) { return this.getSheet(sheetId).numberOfCols; } getNumberRows(sheetId) { return this.getSheet(sheetId).rows.length; } getNumberHeaders(sheetId, dimension) { return dimension === "COL" ? this.getNumberCols(sheetId) : this.getNumberRows(sheetId); } getNextSheetName(baseName = "Sheet") { let i = 1; const names = this.orderedSheetIds.map(this.getSheetName.bind(this)); let name = `${baseName}${i}`; while (names.includes(name)) { name = `${baseName}${i}`; i++; } return name; } getSheetSize(sheetId) { return { numberOfRows: this.getNumberRows(sheetId), numberOfCols: this.getNumberCols(sheetId), }; } getSheetZone(sheetId) { return { top: 0, left: 0, bottom: this.getNumberRows(sheetId) - 1, right: this.getNumberCols(sheetId) - 1, }; } getUnboundedZone(sheetId, zone) { if (zone.bottom === undefined || zone.right === undefined) { return zone; } const isFullRow = zone.left === 0 && zone.right === this.getNumberCols(sheetId) - 1; const isFullCol = zone.top === 0 && zone.bottom === this.getNumberRows(sheetId) - 1; return { ...zone, bottom: isFullCol ? undefined : zone.bottom, // cannot be unbounded in the 2 dimensions at once right: isFullRow && !isFullCol ? undefined : zone.right, }; } getPaneDivisions(sheetId) { return this.getSheet(sheetId).panes; } setPaneDivisions(sheetId, base, dimension) { const panes = { ...this.getPaneDivisions(sheetId) }; if (dimension === "COL") { panes.xSplit = base; } else if (dimension === "ROW") { panes.ySplit = base; } this.history.update("sheets", sheetId, "panes", panes); } /** * Checks if all non-frozen header indices are present in the provided elements of selected rows/columns. * This validation ensures that all rows or columns cannot be deleted when frozen panes exist. */ checkElementsIncludeAllNonFrozenHeaders(sheetId, dimension, elements) { const paneDivisions = this.getters.getPaneDivisions(sheetId); const startIndex = dimension === "ROW" ? paneDivisions.ySplit : paneDivisions.xSplit; const endIndex = this.getters.getNumberHeaders(sheetId, dimension); if (!startIndex) { return false; } const indicesToCheck = range(startIndex, endIndex); return includesAll(elements, indicesToCheck); } // --------------------------------------------------------------------------- // Row/Col manipulation // --------------------------------------------------------------------------- getCommandZones(cmd) { const zones = []; if ("zone" in cmd) { zones.push(cmd.zone); } if ("target" in cmd) { zones.push(...cmd.target); } if ("ranges" in cmd) { zones.push(...cmd.ranges.map((rangeData) => this.getters.getRangeFromRangeData(rangeData).zone)); } if ("col" in cmd && "row" in cmd) { zones.push({ top: cmd.row, left: cmd.col, bottom: cmd.row, right: cmd.col }); } return zones; } /** * Check if zones in the command are well formed and * not outside the sheet. */ checkZonesExistInSheet(sheetId, zones) { if (!zones.every(isZoneValid)) return "InvalidRange" /* CommandResult.InvalidRange */; if (zones.length) { const sheetZone = this.getSheetZone(sheetId); return zones.every((zone) => isZoneInside(zone, sheetZone)) ? "Success" /* CommandResult.Success */ : "TargetOutOfSheet" /* CommandResult.TargetOutOfSheet */; } return "Success" /* CommandResult.Success */; } updateCellPosition(cmd) { const { sheetId, cellId, col, row } = cmd; if (cellId) { this.setNewPosition(cellId, sheetId, col, row); } else { this.clearPosition(sheetId, col, row); } } /** * Set the cell at a new position and clear its previous position. */ setNewPosition(cellId, sheetId, col, row) { const currentPosition = this.cellPosition[cellId]; if (currentPosition) { this.clearPosition(sheetId, currentPosition.col, currentPosition.row); } this.history.update("cellPosition", cellId, { row: row, col: col, sheetId: sheetId, }); this.history.update("sheets", sheetId, "rows", row, "cells", col, cellId); } /** * Remove the cell at the given position (if there's one) */ clearPosition(sheetId, col, row) { const cellId = this.sheets[sheetId]?.rows[row].cells[col]; if (cellId) { this.history.update("cellPosition", cellId, undefined); this.history.update("sheets", sheetId, "rows", row, "cells", col, undefined); } } setGridLinesVisibility(sheetId, areGridLinesVisible) { this.history.update("sheets", sheetId, "areGridLinesVisible", areGridLinesVisible); } createSheet(id, name, colNumber, rowNumber, position) { const sheet = { id, name, numberOfCols: colNumber, rows: createDefaultRows(rowNumber), areGridLinesVisible: true, isVisible: true, panes: { xSplit: 0, ySplit: 0, }, }; const orderedSheetIds = this.orderedSheetIds.slice(); orderedSheetIds.splice(position, 0, sheet.id); const sheets = this.sheets; this.history.update("orderedSheetIds", orderedSheetIds); this.history.update("sheets", Object.assign({}, sheets, { [sheet.id]: sheet })); return sheet; } moveSheet(sheetId, delta) { const orderedSheetIds = this.orderedSheetIds.slice(); const currentIndex = orderedSheetIds.findIndex((id) => id === sheetId); const sheet = orderedSheetIds.splice(currentIndex, 1); let index = this.findIndexOfTargetSheet(currentIndex, delta); orderedSheetIds.splice(index, 0, sheet[0]); this.history.update("orderedSheetIds", orderedSheetIds); } findIndexOfTargetSheet(currentIndex, deltaIndex) { while (deltaIndex != 0 && 0 <= currentIndex && currentIndex <= this.orderedSheetIds.length) { if (deltaIndex > 0) { currentIndex++; if (this.isSheetVisible(this.orderedSheetIds[currentIndex])) { deltaIndex--; } } else if (deltaIndex < 0) { currentIndex--; if (this.isSheetVisible(this.orderedSheetIds[currentIndex])) { deltaIndex++; } } } if (deltaIndex === 0) { return currentIndex; } throw new Error(_t("There is not enough visible sheets")); } checkSheetName(cmd) { const originalSheetName = this.getters.tryGetSheetName(cmd.sheetId); if (originalSheetName !== undefined && cmd.name === originalSheetName) { return "UnchangedSheetName" /* CommandResult.UnchangedSheetName */; } const { orderedSheetIds, sheets } = this; const name = cmd.name && cmd.name.trim().toLowerCase(); if (orderedSheetIds.find((id) => sheets[id]?.name.toLowerCase() === name && id !== cmd.sheetId)) { return "DuplicatedSheetName" /* CommandResult.DuplicatedSheetName */; } if (FORBIDDEN_SHEETNAME_CHARS_IN_EXCEL_REGEX.test(name)) { return "ForbiddenCharactersInSheetName" /* CommandResult.ForbiddenCharactersInSheetName */; } return "Success" /* CommandResult.Success */; } checkSheetPosition(cmd) { const { orderedSheetIds } = this; if (cmd.position > orderedSheetIds.length || cmd.position < 0) { return "WrongSheetPosition" /* CommandResult.WrongSheetPosition */; } return "Success" /* CommandResult.Success */; } checkRowFreezeQuantity(cmd) { return cmd.quantity >= 1 && cmd.quantity < this.getNumberRows(cmd.sheetId) ? "Success" /* CommandResult.Success */ : "InvalidFreezeQuantity" /* CommandResult.InvalidFreezeQuantity */; } checkColFreezeQuantity(cmd) { return cmd.quantity >= 1 && cmd.quantity < this.getNumberCols(cmd.sheetId) ? "Success" /* CommandResult.Success */ : "InvalidFreezeQuantity" /* CommandResult.InvalidFreezeQuantity */; } checkRowFreezeOverlapMerge(cmd) { const merges = this.getters.getMerges(cmd.sheetId); for (let merge of merges) { if (merge.top < cmd.quantity && cmd.quantity <= merge.bottom) { return "MergeOverlap" /* CommandResult.MergeOverlap */; } } return "Success" /* CommandResult.Success */; } checkColFreezeOverlapMerge(cmd) { const merges = this.getters.getMerges(cmd.sheetId); for (let merge of merges) { if (merge.left < cmd.quantity && cmd.quantity <= merge.right) { return "MergeOverlap" /* CommandResult.MergeOverlap */; } } return "Success" /* CommandResult.Success */; } isRenameAllowed(cmd) { const name = cmd.name && cmd.name.trim().toLowerCase(); if (!name) { return "MissingSheetName" /* CommandResult.MissingSheetName */; } return this.checkSheetName(cmd); } renameSheet(sheet, name) { const oldName = sheet.name; this.history.update("sheets", sheet.id, "name", name.trim()); const sheetIdsMapName = Object.assign({}, this.sheetIdsMapName); delete sheetIdsMapName[oldName]; sheetIdsMapName[name] = sheet.id; this.history.update("sheetIdsMapName", sheetIdsMapName); } hideSheet(sheetId) { this.history.update("sheets", sheetId, "isVisible", false); } showSheet(sheetId) { this.history.update("sheets", sheetId, "isVisible", true); } duplicateSheet(fromId, toId) { const sheet = this.getSheet(fromId); const toName = this.getDuplicateSheetName(sheet.name); const newSheet = deepCopy(sheet); newSheet.id = toId; newSheet.name = toName; for (let col = 0; col <= newSheet.numberOfCols; col++) { for (let row = 0; row <= newSheet.rows.length; row++) { if (newSheet.rows[row]) { newSheet.rows[row].cells[col] = undefined; } } } const orderedSheetIds = this.orderedSheetIds.slice(); const currentIndex = orderedSheetIds.indexOf(fromId); orderedSheetIds.splice(currentIndex + 1, 0, newSheet.id); this.history.update("orderedSheetIds", orderedSheetIds); this.history.update("sheets", Object.assign({}, this.sheets, { [newSheet.id]: newSheet })); for (const cell of Object.values(this.getters.getCells(fromId))) { const { col, row } = this.getCellPosition(cell.id); this.dispatch("UPDATE_CELL", { sheetId: newSheet.id, col, row, content: cell.content, format: cell.format, style: cell.style, }); } const sheetIdsMapName = Object.assign({}, this.sheetIdsMapName); sheetIdsMapName[newSheet.name] = newSheet.id; this.history.update("sheetIdsMapName", sheetIdsMapName); } getDuplicateSheetName(sheetName) { let i = 1; const names = this.orderedSheetIds.map(this.getSheetName.bind(this)); const baseName = _t("Copy of %s", sheetName); let name = baseName.toString(); while (names.includes(name)) { name = `${baseName} (${i})`; i++; } return name; } deleteSheet(sheet) { const name = sheet.name; const sheets = Object.assign({}, this.sheets); delete sheets[sheet.id]; this.history.update("sheets", sheets); const orderedSheetIds = this.orderedSheetIds.slice(); const currentIndex = orderedSheetIds.indexOf(sheet.id); orderedSheetIds.splice(currentIndex, 1); this.history.update("orderedSheetIds", orderedSheetIds); const sheetIdsMapName = Object.assign({}, this.sheetIdsMapName); delete sheetIdsMapName[name]; this.history.update("sheetIdsMapName", sheetIdsMapName); } /** * Delete column. This requires a lot of handling: * - Update all the formulas in all sheets * - Move the cells * - Update the cols/rows (size, number, (cells), ...) * - Reevaluate the cells * * @param sheet ID of the sheet on which deletion should be applied * @param columns Columns to delete */ removeColumns(sheet, columns) { // This is necessary because we have to delete elements in correct order: // begin with the end. columns.sort((a, b) => b - a); for (let column of columns) { // Move the cells. this.moveCellOnColumnsDeletion(sheet, column); } const numberOfCols = this.sheets[sheet.id].numberOfCols; this.history.update("sheets", sheet.id, "numberOfCols", numberOfCols - columns.length); const count = columns.filter((col) => col < sheet.panes.xSplit).length; if (count) { this.setPaneDivisions(sheet.id, sheet.panes.xSplit - count, "COL"); } } /** * Delete row. This requires a lot of handling: * - Update the merges * - Update all the formulas in all sheets * - Move the cells * - Update the cols/rows (size, number, (cells), ...) * - Reevaluate the cells * * @param sheet ID of the sheet on which deletion should be applied * @param rows Rows to delete */ removeRows(sheet, rows) { // This is necessary because we have to delete elements in correct order: // begin with the end. rows.sort((a, b) => b - a); for (let group of groupConsecutive(rows)) { // indexes are sorted in the descending order const from = group[group.length - 1]; const to = group[0]; // Move the cells. this.moveCellOnRowsDeletion(sheet, from, to); // Effectively delete the rows this.updateRowsStructureOnDeletion(sheet, from, to); } const count = rows.filter((row) => row < sheet.panes.ySplit).length; if (count) { this.setPaneDivisions(sheet.id, sheet.panes.ySplit - count, "ROW"); } } addColumns(sheet, column, position, quantity) { const index = position === "before" ? column : column + 1; // Move the cells. this.moveCellsOnAddition(sheet, index, quantity, "columns"); const numberOfCols = this.sheets[sheet.id].numberOfCols; this.history.update("sheets", sheet.id, "numberOfCols", numberOfCols + quantity); if (index < sheet.panes.xSplit) { this.setPaneDivisions(sheet.id, sheet.panes.xSplit + quantity, "COL"); } } addRows(sheet, row, position, quantity) { const index = position === "before" ? row : row + 1; this.addEmptyRows(sheet, quantity); // Move the cells. this.moveCellsOnAddition(sheet, index, quantity, "rows"); if (index < sheet.panes.ySplit) { this.setPaneDivisions(sheet.id, sheet.panes.ySplit + quantity, "ROW"); } } moveCellOnColumnsDeletion(sheet, deletedColumn) { this.dispatch("CLEAR_CELLS", { sheetId: sheet.id, target: [ { left: deletedColumn, top: 0, right: deletedColumn, bottom: sheet.rows.length - 1, }, ], }); for (let rowIndex = 0; rowIndex < sheet.rows.length; rowIndex++) { const row = sheet.rows[rowIndex]; for (let i in row.cells) { const colIndex = Number(i); const cellId = row.cells[i]; if (cellId) { if (colIndex > deletedColumn) { this.setNewPosition(cellId, sheet.id, colIndex - 1, rowIndex); } } } } } /** * Move the cells after a column or rows insertion */ moveCellsOnAddition(sheet, addedElement, quantity, dimension) { const updates = []; 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 = Number(i); const cellId = row.cells[i]; if (cellId) { if (dimension === "rows" || colIndex >= addedElement) { updates.push({ sheetId: sheet.id, cellId: cellId, col: colIndex + (dimension === "columns" ? quantity : 0), row: rowIndex + (dimension === "rows" ? quantity : 0), type: "UPDATE_CELL_POSITION", }); } } } } } for (let update of updates.reverse()) { this.updateCellPosition(update); } } /** * Move all the cells that are from the row under `deleteToRow` up to `deleteFromRow` * * b.e. * move vertically with delete from 3 and delete to 5 will first clear all the cells from lines 3 to 5, * then take all the row starting at index 6 and add them back at index 3 * */ moveCellOnRowsDeletion(sheet, deleteFromRow, deleteToRow) { this.dispatch("CLEAR_CELLS", { sheetId: sheet.id, target: [ { left: 0, top: deleteFromRow, right: this.getters.getNumberCols(sheet.id), bottom: deleteToRow, }, ], }); const numberRows = deleteToRow - deleteFromRow + 1; for (let rowIndex = 0; rowIndex < sheet.rows.length; rowIndex++) { const row = sheet.rows[rowIndex]; if (rowIndex > deleteToRow) { for (let i in row.cells) { const colIndex = Number(i); const cellId = row.cells[i]; if (cellId) { this.setNewPosition(cellId, sheet.id, colIndex, rowIndex - numberRows); } } } } } updateRowsStructureOnDeletion(sheet, deleteFromRow, deleteToRow) { const rows = []; const cellsQueue = sheet.rows.map((row) => row.cells).reverse(); for (let i in sheet.rows) { const row = Number(i); if (row >= deleteFromRow && row <= deleteToRow) { continue; } rows.push({ cells: cellsQueue.pop(), }); } this.history.update("sheets", sheet.id, "rows", rows); } /** * Add empty rows at the end of the rows * * @param sheet Sheet * @param quantity Number of rows to add */ addEmptyRows(sheet, quantity) { const rows = sheet.rows.slice(); for (let i = 0; i < quantity; i++) { rows.push({ cells: {}, }); } this.history.update("sheets", sheet.id, "rows", rows); } getImportedSheetSize(data) { const positions = Object.keys(data.cells).map(toCartesian); let rowNumber = data.rowNumber; let colNumber = data.colNumber; for (let { col, row } of positions) { rowNumber = Math.max(rowNumber, row + 1); colNumber = Math.max(colNumber, col + 1); } return { rowNumber, colNumber }; } /** * Check that any "sheetId" in the command matches an existing * sheet. */ checkSheetExists(cmd) { if (cmd.type !== "CREATE_SHEET" && "sheetId" in cmd && this.sheets[cmd.sheetId] === undefined) { return "InvalidSheetId" /* CommandResult.InvalidSheetId */; } else if (cmd.type === "CREATE_SHEET" && this.sheets[cmd.sheetId] !== undefined) { return "DuplicatedSheetId" /* CommandResult.DuplicatedSheetId */; } return "Success" /* CommandResult.Success */; } /** * Check if zones in the command are well formed and * not outside the sheet. */ checkZonesAreInSheet(cmd) { if (!("sheetId" in cmd)) return "Success" /* CommandResult.Success */; return this.checkZonesExistInSheet(cmd.sheetId, this.getCommandZones(cmd)); } } class TablePlugin extends CorePlugin { static getters = ["getCoreTable", "getCoreTables", "getCoreTableMatchingTopLeft"]; tables = {}; adaptRanges(applyChange, sheetId) { const sheetIds = sheetId ? [sheetId] : this.getters.getSheetIds(); for (const sheetId of sheetIds) { for (const table of this.getCoreTables(sheetId)) { this.applyRangeChangeOnTable(sheetId, table, applyChange); } } } allowDispatch(cmd) { switch (cmd.type) { case "CREATE_TABLE": const zones = cmd.ranges.map((rangeData) => this.getters.getRangeFromRangeData(rangeData).zone); if (!areZonesContinuous(zones)) { return "NonContinuousTargets" /* CommandResult.NonContinuousTargets */; } return this.checkValidations(cmd, (cmd) => this.getTablesOverlappingZones(cmd.sheetId, zones).length ? "TableOverlap" /* CommandResult.TableOverlap */ : "Success" /* CommandResult.Success */, (cmd) => this.checkTableConfigUpdateIsValid(cmd.config)); case "UPDATE_TABLE": const updatedTable = this.getCoreTableMatchingTopLeft(cmd.sheetId, cmd.zone); if (!updatedTable) { return "TableNotFound" /* CommandResult.TableNotFound */; } return this.checkValidations(cmd, this.checkUpdatedTableZoneIsValid, (cmd) => this.checkTableConfigUpdateIsValid(cmd.config)); case "ADD_MERGE": for (const table of this.getCoreTables(cmd.sheetId)) { const tableZone = table.range.zone; for (const merge of cmd.target) { if (overlap(tableZone, merge)) { return "MergeInTable" /* CommandResult.MergeInTable */; } } } break; } return "Success" /* CommandResult.Success */; } handle(cmd) { switch (cmd.type) { case "CREATE_SHEET": this.history.update("tables", cmd.sheetId, {}); break; case "DELETE_SHEET": { const tables = { ...this.tables }; delete tables[cmd.sheetId]; this.history.update("tables", tables); break; } case "DUPLICATE_SHEET": { const newTables = {}; for (const table of this.getCoreTables(cmd.sheetId)) { newTables[table.id] = table.type === "dynamic" ? this.copyDynamicTableForSheet(cmd.sheetIdTo, table) : this.copyStaticTableForSheet(cmd.sheetIdTo, table); } this.history.update("tables", cmd.sheetIdTo, newTables); break; } case "CREATE_TABLE": { const ranges = cmd.ranges.map((rangeData) => this.getters.getRangeFromRangeData(rangeData)); const union = this.getters.getRangesUnion(ranges); const mergesInTarget = this.getters.getMergesInZone(cmd.sheetId, union.zone); this.dispatch("REMOVE_MERGE", { sheetId: cmd.sheetId, target: mergesInTarget }); const id = this.uuidGenerator.uuidv4(); const config = cmd.config || DEFAULT_TABLE_CONFIG; const newTable = cmd.tableType === "dynamic" ? this.createDynamicTable(id, union, config) : this.createStaticTable(id, cmd.tableType, union, config); this.history.update("tables", cmd.sheetId, newTable.id, newTable); break; } case "REMOVE_TABLE": { const tables = {}; for (const table of this.getCoreTables(cmd.sheetId)) { if (cmd.target.every((zone) => !intersection(table.range.zone, zone))) { tables[table.id] = table; } } this.history.update("tables", cmd.sheetId, tables); break; } case "UPDATE_TABLE": { this.updateTable(cmd); break; } case "UPDATE_CELL": { const sheetId = cmd.sheetId; for (const table of this.getCoreTables(sheetId)) { if (table.type === "dynamic") { continue; } const direction = this.canUpdateCellCmdExtendTable(cmd, table); if (direction === "down") { this.extendTableDown(sheetId, table); } else if (direction === "right") { this.extendTableRight(sheetId, table); } } break; } case "DELETE_CONTENT": { const tables = { ...this.tables[cmd.sheetId] }; for (const tableId in tables) { const table = tables[tableId]; if (table && cmd.target.some((zone) => isZoneInside(table.range.zone, zone))) { this.dispatch("REMOVE_TABLE", { sheetId: cmd.sheetId, target: [table.range.zone] }); } } break; } } } getCoreTables(sheetId) { return this.tables[sheetId] ? Object.values(this.tables[sheetId]).filter(isDefined) : []; } getCoreTable({ sheetId, col, row }) { return this.getCoreTables(sheetId).find((table) => isInside(col, row, table.range.zone)); } getTablesOverlappingZones(sheetId, zones) { return this.getCoreTables(sheetId).filter((table) => zones.some((zone) => overlap(table.range.zone, zone))); } /** Extend a table down one row */ extendTableDown(sheetId, table) { const newRange = this.getters.extendRange(table.range, "ROW", 1); this.history.update("tables", sheetId, table.id, this.updateStaticTable(table, newRange)); } /** Extend a table right one col */ extendTableRight(sheetId, table) { const newRange = this.getters.extendRange(table.range, "COL", 1); this.history.update("tables", sheetId, table.id, this.updateStaticTable(table, newRange)); } /** * Check if an UpdateCell command should cause the given table to be extended by one row or col. * * The table should be extended if all of these conditions are true: * 1) The updated cell is right below/right of the table * 2) The command adds a content to the cell * 3) No cell right below/right next to the table had any content before the command * 4) Extending the table down/right would not overlap with another table * 5) Extending the table down/right would not overlap with a merge * */ canUpdateCellCmdExtendTable({ content: newCellContent, sheetId, col, row }, table) { if (!newCellContent) { return "none"; } const zone = table.range.zone; let direction = "none"; if (zone.bottom + 1 === row && col >= zone.left && col <= zone.right) { direction = "down"; } else if (zone.right + 1 === col && row >= zone.top && row <= zone.bottom) { direction = "right"; } if (direction === "none") { return "none"; } const zoneToCheckIfEmpty = direction === "down" ? { ...zone, bottom: zone.bottom + 1, top: zone.bottom + 1 } : { ...zone, right: zone.right + 1, left: zone.right + 1 }; for (const position of positions(zoneToCheckIfEmpty)) { const cellPosition = { sheetId, ...position }; // Since this plugin is loaded before CellPlugin, the getters still give us the old cell content const cellContent = this.getters.getCell(cellPosition)?.content; if (cellContent || this.getters.isInMerge(cellPosition) || this.getTablesOverlappingZones(sheetId, [positionToZone(position)]).length) { return "none"; } } return direction; } getCoreTableMatchingTopLeft(sheetId, zone) { for (const table of this.getCoreTables(sheetId)) { const tableZone = table.range.zone; if (tableZone.left === zone.left && tableZone.top === zone.top) { return table; } } return undefined; } checkUpdatedTableZoneIsValid(cmd) { if (!cmd.newTableRange) { return "Success" /* CommandResult.Success */; } const newTableZone = this.getters.getRangeFromRangeData(cmd.newTableRange).zone; const zoneIsInSheet = this.getters.checkZonesExistInSheet(cmd.sheetId, [newTableZone]); if (zoneIsInSheet !== "Success" /* CommandResult.Success */) { return zoneIsInSheet; } const updatedTable = this.getCoreTableMatchingTopLeft(cmd.sheetId, cmd.zone); if (!updatedTable) { return "TableNotFound" /* CommandResult.TableNotFound */; } const overlappingTables = this.getTablesOverlappingZones(cmd.sheetId, [newTableZone]).filter((table) => table.id !== updatedTable.id); return overlappingTables.length ? "TableOverlap" /* CommandResult.TableOverlap */ : "Success" /* CommandResult.Success */; } checkTableConfigUpdateIsValid(config) { if (!config) { return "Success" /* CommandResult.Success */; } if (config.numberOfHeaders !== undefined && config.numberOfHeaders < 0) { return "InvalidTableConfig" /* CommandResult.InvalidTableConfig */; } if (config.hasFilters && config.numberOfHeaders === 0) { return "InvalidTableConfig" /* CommandResult.InvalidTableConfig */; } return "Success" /* CommandResult.Success */; } createStaticTable(id, type, tableRange, config, filters) { const zone = tableRange.zone; if (!filters) { filters = []; for (const i of range(zone.left, zone.right + 1)) { const filterZone = { ...zone, left: i, right: i }; const uid = this.uuidGenerator.uuidv4(); filters.push(this.createFilterFromZone(uid, tableRange.sheetId, filterZone, config)); } } return { id, range: tableRange, filters, config, type, }; } createDynamicTable(id, tableRange, config) { const zone = zoneToTopLeft(tableRange.zone); return { id, range: this.getters.getRangeFromZone(tableRange.sheetId, zone), config, type: "dynamic", }; } updateTable(cmd) { const table = this.getCoreTableMatchingTopLeft(cmd.sheetId, cmd.zone); if (!table) { return; } const newTableRange = cmd.newTableRange ? this.getters.getRangeFromRangeData(cmd.newTableRange) : undefined; if (newTableRange) { const mergesInTarget = this.getters.getMergesInZone(cmd.sheetId, newTableRange.zone); this.dispatch("REMOVE_MERGE", { sheetId: cmd.sheetId, target: mergesInTarget }); } const range = newTableRange || table.range; const newConfig = this.updateTableConfig(cmd.config, table.config); const newTableType = cmd.tableType ?? table.type; if ((newTableType === "dynamic" && table.type !== "dynamic") || (newTableType !== "dynamic" && table.type === "dynamic")) { const newTable = newTableType === "dynamic" ? this.createDynamicTable(table.id, range, newConfig) : this.createStaticTable(table.id, newTableType, range, newConfig); this.history.update("tables", cmd.sheetId, table.id, newTable); } else { const updatedTable = table.type === "dynamic" ? this.updateDynamicTable(table, range, newConfig) : this.updateStaticTable(table, range, newConfig, newTableType); this.history.update("tables", cmd.sheetId, table.id, updatedTable); } } updateStaticTable(table, newRange, configUpdate, newTableType = table.type) { if (newTableType === "dynamic") { throw new Error("Cannot use updateStaticTable to update a dynamic table"); } const tableRange = newRange ? newRange : table.range; const tableZone = tableRange.zone; const newConfig = this.updateTableConfig(configUpdate, table.config); const config = newConfig ? newConfig : table.config; const filters = []; if (newRange || (newConfig && "numberOfHeaders" in newConfig)) { for (const i of range(tableZone.left, tableZone.right + 1)) { const oldFilter = tableZone.top === table.range.zone.top ? table.filters.find((f) => f.col === i) : undefined; const filterZone = { ...tableZone, left: i, right: i }; const filterId = oldFilter?.id || this.uuidGenerator.uuidv4(); filters.push(this.createFilterFromZone(filterId, tableRange.sheetId, filterZone, config)); } } return { ...table, range: tableRange, config, filters: filters.length ? filters : table.filters, type: newTableType, }; } updateDynamicTable(table, newRange, newConfig) { const range = newRange ? this.getters.getRangeFromZone(newRange.sheetId, zoneToTopLeft(newRange.zone)) : table.range; const config = newConfig ? newConfig : table.config; return { ...table, range, config }; } /** * Update the old config of a table with the new partial config from an UpdateTable command. * * Make sure the new config make sense (e.g. if the table has no header, it should not have * filters and number of headers should be 0) */ updateTableConfig(update, oldConfig) { if (!update) { return oldConfig; } const saneConfig = { ...oldConfig, ...update }; if (update.numberOfHeaders === 0) { saneConfig.hasFilters = false; } else if (update.hasFilters === true) { saneConfig.numberOfHeaders ||= 1; } return saneConfig; } createFilterFromZone(id, sheetId, zone, config) { const range = this.getters.getRangeFromZone(sheetId, zone); return createFilter(id, range, config, this.getters.getRangeFromZone); } copyStaticTableForSheet(sheetId, table) { const newRange = this.getters.getRangeFromZone(sheetId, table.range.zone); const newFilters = table.filters.map((filter) => { const newFilterRange = this.getters.getRangeFromZone(sheetId, filter.rangeWithHeaders.zone); return createFilter(filter.id, newFilterRange, table.config, this.getters.getRangeFromZone); }); return { id: table.id, range: newRange, filters: newFilters, config: deepCopy(table.config), type: table.type, }; } copyDynamicTableForSheet(sheetId, table) { const newRange = this.getters.getRangeFromZone(sheetId, table.range.zone); return { id: table.id, range: newRange, config: deepCopy(table.config), type: "dynamic", }; } applyRangeChangeOnTable(sheetId, table, applyChange) { const tableRangeChange = applyChange(table.range); let newTableRange; switch (tableRangeChange.changeType) { case "REMOVE": this.history.update("tables", sheetId, table.id, undefined); return; case "NONE": return; default: newTableRange = tableRangeChange.range; } if (table.type === "dynamic") { const newTable = this.updateDynamicTable(table, newTableRange); this.history.update("tables", sheetId, table.id, newTable); return; } const filters = []; for (const filter of table.filters) { const filterRangeChange = applyChange(filter.rangeWithHeaders); switch (filterRangeChange.changeType) { case "REMOVE": continue; case "NONE": filters.push(filter); break; default: const newFilterRange = filterRangeChange.range; const newFilter = createFilter(filter.id, newFilterRange, table.config, this.getters.getRangeFromZone); filters.push(newFilter); } } const tableZone = newTableRange.zone; if (filters.length < zoneToDimension(tableZone).numberOfCols) { for (let col = tableZone.left; col <= tableZone.right; col++) { if (!filters.find((filter) => filter.col === col)) { const uid = this.uuidGenerator.uuidv4(); const filterZone = { ...tableZone, left: col, right: col }; filters.push(this.createFilterFromZone(uid, sheetId, filterZone, table.config)); } } filters.sort((f1, f2) => f1.col - f2.col); } const newTable = this.createStaticTable(table.id, table.type, newTableRange, table.config, filters); this.history.update("tables", sheetId, table.id, newTable); } // --------------------------------------------------------------------------- // Import/Export // --------------------------------------------------------------------------- import(data) { for (const sheet of data.sheets) { for (const tableData of sheet.tables || []) { const uuid = this.uuidGenerator.uuidv4(); const tableConfig = tableData.config || DEFAULT_TABLE_CONFIG; const range = this.getters.getRangeFromSheetXC(sheet.id, tableData.range); const tableType = tableData.type || "static"; const table = tableType === "dynamic" ? this.createDynamicTable(uuid, range, tableConfig) : this.createStaticTable(uuid, tableType, range, tableConfig); this.history.update("tables", sheet.id, table.id, table); } } } export(data) { for (const sheet of data.sheets) { for (const table of this.getCoreTables(sheet.id)) { const range = zoneToXc(table.range.zone); const tableData = { range, type: table.type }; if (!deepEquals(table.config, DEFAULT_TABLE_CONFIG)) { tableData.config = table.config; } sheet.tables.push(tableData); } } } exportForExcel(data) { for (const sheet of data.sheets) { for (const table of this.getCoreTables(sheet.id)) { const range = zoneToXc(table.range.zone); sheet.tables.push({ range, filters: [], config: table.config }); } } } } class HeaderGroupingPlugin extends CorePlugin { static getters = [ "getHeaderGroups", "getGroupsLayers", "getVisibleGroupLayers", "getHeaderGroup", "getHeaderGroupsInZone", "isGroupFolded", "isRowFolded", "isColFolded", ]; groups = {}; allowDispatch(cmd) { switch (cmd.type) { case "GROUP_HEADERS": { const { start, end } = cmd; if (!this.getters.doesHeadersExist(cmd.sheetId, cmd.dimension, [start, end])) { return "InvalidHeaderGroupStartEnd" /* CommandResult.InvalidHeaderGroupStartEnd */; } if (start > end) { return "InvalidHeaderGroupStartEnd" /* CommandResult.InvalidHeaderGroupStartEnd */; } if (this.findGroupWithStartEnd(cmd.sheetId, cmd.dimension, start, end)) { return "HeaderGroupAlreadyExists" /* CommandResult.HeaderGroupAlreadyExists */; } break; } case "UNGROUP_HEADERS": { const { start, end } = cmd; if (!this.getters.doesHeadersExist(cmd.sheetId, cmd.dimension, [start, end])) { return "InvalidHeaderGroupStartEnd" /* CommandResult.InvalidHeaderGroupStartEnd */; } if (start > end) { return "InvalidHeaderGroupStartEnd" /* CommandResult.InvalidHeaderGroupStartEnd */; } break; } case "UNFOLD_HEADER_GROUP": case "FOLD_HEADER_GROUP": const group = this.findGroupWithStartEnd(cmd.sheetId, cmd.dimension, cmd.start, cmd.end); if (!group) { return "UnknownHeaderGroup" /* CommandResult.UnknownHeaderGroup */; } const numberOfHeaders = this.getters.getNumberHeaders(cmd.sheetId, cmd.dimension); const willHideAllHeaders = range(0, numberOfHeaders).every((i) => (i >= group.start && i <= group.end) || this.getters.isHeaderHiddenByUser(cmd.sheetId, cmd.dimension, i)); if (willHideAllHeaders) { return "NotEnoughElements" /* CommandResult.NotEnoughElements */; } break; } return "Success" /* CommandResult.Success */; } handle(cmd) { switch (cmd.type) { case "CREATE_SHEET": this.history.update("groups", cmd.sheetId, { ROW: [], COL: [] }); break; case "GROUP_HEADERS": this.groupHeaders(cmd.sheetId, cmd.dimension, cmd.start, cmd.end); break; case "UNGROUP_HEADERS": { this.unGroupHeaders(cmd.sheetId, cmd.dimension, cmd.start, cmd.end); break; } case "DUPLICATE_SHEET": { const groups = deepCopy(this.groups[cmd.sheetId]); this.history.update("groups", cmd.sheetIdTo, groups); break; } case "DELETE_SHEET": { const groups = { ...this.groups }; delete groups[cmd.sheetId]; this.history.update("groups", groups); break; } case "ADD_COLUMNS_ROWS": const addIndex = getAddHeaderStartIndex(cmd.position, cmd.base); this.moveGroupsOnHeaderInsertion(cmd.sheetId, cmd.dimension, addIndex, cmd.quantity); break; case "REMOVE_COLUMNS_ROWS": this.moveGroupsOnHeaderDeletion(cmd.sheetId, cmd.dimension, cmd.elements); break; case "UNFOLD_HEADER_GROUP": { const group = this.findGroupWithStartEnd(cmd.sheetId, cmd.dimension, cmd.start, cmd.end); if (group) { this.unfoldHeaderGroup(cmd.sheetId, cmd.dimension, group); } break; } case "FOLD_HEADER_GROUP": { const group = this.findGroupWithStartEnd(cmd.sheetId, cmd.dimension, cmd.start, cmd.end); if (group) { this.foldHeaderGroup(cmd.sheetId, cmd.dimension, group); } break; } case "UNFOLD_ALL_HEADER_GROUPS": { const groups = this.getters.getHeaderGroups(cmd.sheetId, cmd.dimension); for (const group of groups) { this.unfoldHeaderGroup(cmd.sheetId, cmd.dimension, group); } break; } case "FOLD_ALL_HEADER_GROUPS": { const groups = this.getters.getHeaderGroups(cmd.sheetId, cmd.dimension); for (const group of groups) { this.foldHeaderGroup(cmd.sheetId, cmd.dimension, group); } break; } case "FOLD_HEADER_GROUPS_IN_ZONE": case "UNFOLD_HEADER_GROUPS_IN_ZONE": { const action = cmd.type === "UNFOLD_HEADER_GROUPS_IN_ZONE" ? "unfold" : "fold"; const layers = this.getGroupsLayers(cmd.sheetId, cmd.dimension); if (action === "fold") { layers.reverse(); } const groups = layers.flat(); const start = cmd.dimension === "ROW" ? cmd.zone.top : cmd.zone.left; const end = cmd.dimension === "ROW" ? cmd.zone.bottom : cmd.zone.right; const groupsToToggle = new Set(); for (let header = start; header <= end; header++) { const matchedGroups = groups.filter((g) => g.start - 1 <= header && header <= g.end); // -1 to include the group header for (const group of matchedGroups) { if ((action === "fold" && group.isFolded) || (action === "unfold" && !group.isFolded)) { continue; } groupsToToggle.add(group); break; } } for (const group of groupsToToggle) { if (action === "unfold") { this.unfoldHeaderGroup(cmd.sheetId, cmd.dimension, group); } else { this.foldHeaderGroup(cmd.sheetId, cmd.dimension, group); } } break; } } } getHeaderGroups(sheetId, dim) { return this.groups[sheetId][dim]; } getHeaderGroup(sheetId, dim, start, end) { return this.getHeaderGroups(sheetId, dim).find((group) => group.start === start && group.end === end); } getHeaderGroupsInZone(sheetId, dim, zone) { return this.getHeaderGroups(sheetId, dim).filter((group) => { const start = dim === "ROW" ? zone.top : zone.left; const end = dim === "ROW" ? zone.bottom : zone.right; return this.doGroupOverlap(group, start, end); }); } /** * Get all the groups of a sheet in a dimension, and return an array of layers of those groups. * * The layering rules are: * 1) A group containing another group should be on a layer above the group it contains * 2) The widest/highest groups should be on the left/top layer compared to the groups it contains * 3) The group should be on the left/top-most layer possible, barring intersections with other groups (see rules 1 and 2) */ getGroupsLayers(sheetId, dimension) { const groups = this.getHeaderGroups(sheetId, dimension); return this.bricksFallingAlgorithm(groups, 0, 0); } /** * Get all the groups of a sheet in a dimension, and return an array of layers of those groups, * excluding the groups that are totally hidden. */ getVisibleGroupLayers(sheetId, dimension) { const layers = this.getGroupsLayers(sheetId, dimension); for (const layer of layers) { for (let k = layer.length - 1; k >= 0; k--) { const group = layer[k]; if (group.start === 0) { continue; } const headersInGroup = range(group.start - 1, group.end + 1); if (headersInGroup.every((i) => this.getters.isHeaderHiddenByUser(sheetId, dimension, i))) { layer.splice(k, 1); } } } return layers.filter((layer) => layer.length > 0); } isGroupFolded(sheetId, dimension, start, end) { return this.getHeaderGroup(sheetId, dimension, start, end)?.isFolded || false; } isRowFolded(sheetId, row) { const groups = this.getters.getHeaderGroups(sheetId, "ROW"); return groups.some((group) => group.start <= row && row <= group.end && group.isFolded); } isColFolded(sheetId, col) { const groups = this.getters.getHeaderGroups(sheetId, "COL"); // might become a performance issue if there are a lot of groups (this is called by isColHidden). return groups.some((group) => group.start <= col && col <= group.end && group.isFolded); } getGroupId(group) { return `${group.start}-${group.end}}`; } /** * To get layers of groups, and to add/remove headers from groups, we can see each header of a group as a brick. Each * brick falls down in the pile corresponding to its header, until it hits another brick, or the ground. * * With this abstraction, we can very simply group/ungroup headers from groups, and get the layers of groups. * - grouping headers is done by adding a brick to each header pile * - un-grouping headers is done by removing a brick from each header pile * - getting the layers of groups is done by simply letting the brick fall and checking the result * * Example: * We have 2 groups ([A=>E] and [C=>D]), and we want to group headers [C=>F] * * Headers : A B C D E F G A B C D E F G A B C D E F G * Headers to group: [C=>D]: _ _ [C=>F]: _ _ _ _ * | | ==> | | | | ==> ==> Result: 3 groups * | | ˅ ˅ | | _ _ - [C=>D] * Groups: ˅ ˅ _ _ ˅ | _ _ _ - [C=>E] * Groups: _ _ _ _ _ _ _ _ _ _ ˅ _ _ _ _ _ _ - [A=>F] * @param groups * @param start start of the range where to add/remove headers * @param end end of the range where to add/remove headers * @param delta -1: remove headers, 1: add headers, 0: get layers (don't add/remove anything) */ bricksFallingAlgorithm(groups, start, end, delta = 0) { const isGroupFolded = {}; for (const group of groups) { isGroupFolded[this.getGroupId(group)] = group.isFolded; } /** Number of bricks in each header pile */ const brickPileSize = {}; for (const group of groups) { for (let i = group.start; i <= group.end; i++) { brickPileSize[i] = brickPileSize[i] ? brickPileSize[i] + 1 : 1; } } for (let i = start; i <= end; i++) { brickPileSize[i] = brickPileSize[i] ? brickPileSize[i] + delta : delta; } const numberOfLayers = Math.max(...Object.values(brickPileSize), 0); const groupLayers = Array.from({ length: numberOfLayers }, () => []); const maxHeader = Math.max(end, ...groups.map((group) => group.end)); const minHeader = Math.min(start, ...groups.map((group) => group.start)); for (let header = minHeader; header <= maxHeader; header++) { const pileSize = brickPileSize[header] || 0; for (let layer = 0; layer < pileSize; layer++) { const currentGroup = groupLayers[layer].at(-1); if (currentGroup && isConsecutive([currentGroup.end, header])) { currentGroup.end++; } else { const newGroup = { start: header, end: header }; groupLayers[layer].push(newGroup); } } } for (const layer of groupLayers) { for (const group of layer) { group.isFolded = isGroupFolded[this.getGroupId(group)]; } } return groupLayers; } groupHeaders(sheetId, dimension, start, end) { const groups = this.getHeaderGroups(sheetId, dimension); const newGroups = this.bricksFallingAlgorithm(groups, start, end, +1).flat(); this.history.update("groups", sheetId, dimension, this.removeDuplicateGroups(newGroups)); } /** * Ungroup the given headers. The headers will be taken out of the group they are in. This might split a group into two * if the headers were in the middle of a group. If multiple groups contains a header, it will only be taken out of the * lowest group in the layering of the groups. */ unGroupHeaders(sheetId, dimension, start, end) { const groups = this.getHeaderGroups(sheetId, dimension); const newGroups = this.bricksFallingAlgorithm(groups, start, end, -1).flat(); this.history.update("groups", sheetId, dimension, this.removeDuplicateGroups(newGroups)); } moveGroupsOnHeaderInsertion(sheetId, dim, index, count) { const groups = this.groups[sheetId][dim]; for (let i = 0; i < groups.length; i++) { const group = groups[i]; const [start, end] = moveHeaderIndexesOnHeaderAddition(index, count, [ group.start, group.end, ]); if (start !== group.start || end !== group.end) { this.history.update("groups", sheetId, dim, i, { ...group, start, end }); } } } moveGroupsOnHeaderDeletion(sheetId, dimension, deletedElements) { const groups = this.getHeaderGroups(sheetId, dimension); const newGroups = []; for (const group of groups) { const headersInGroup = range(group.start, group.end + 1); const headersAfterDeletion = moveHeaderIndexesOnHeaderDeletion(deletedElements, headersInGroup); if (headersAfterDeletion.length === 0) { continue; } newGroups.push({ ...group, start: Math.min(...headersAfterDeletion), end: Math.max(...headersAfterDeletion), }); } this.history.update("groups", sheetId, dimension, this.bricksFallingAlgorithm(newGroups, 0, 0).flat()); } doGroupOverlap(group, start, end) { return group.start <= end && group.end >= start; } removeDuplicateGroups(groups) { const newGroups = {}; for (const group of groups) { newGroups[this.getGroupId(group)] = group; } return Object.values(newGroups); } findGroupWithStartEnd(sheetId, dimension, start, end) { return this.getHeaderGroups(sheetId, dimension).find((group) => group.start === start && group.end === end); } /** * Fold the given group, and all the groups starting at the same index that are contained inside the given group. */ foldHeaderGroup(sheetId, dim, groupToFold) { const index = this.getGroupIndex(sheetId, dim, groupToFold.start, groupToFold.end); if (index === undefined) { return; } this.history.update("groups", sheetId, dim, index, "isFolded", true); const groups = this.getters.getHeaderGroups(sheetId, dim); for (let i = 0; i < groups.length; i++) { const group = groups[i]; if (group.start === groupToFold.start && group.end <= groupToFold.end) { this.history.update("groups", sheetId, dim, i, "isFolded", true); } } } /** * Unfold the given group, and all the groups starting at the same index that contain the given group. */ unfoldHeaderGroup(sheetId, dim, groupToUnfold) { const index = this.getGroupIndex(sheetId, dim, groupToUnfold.start, groupToUnfold.end); if (index === undefined) { return; } this.history.update("groups", sheetId, dim, index, "isFolded", false); const groups = this.getters.getHeaderGroups(sheetId, dim); for (let i = 0; i < groups.length; i++) { const group = groups[i]; if (group.start === groupToUnfold.start && group.end >= groupToUnfold.end) { this.history.update("groups", sheetId, dim, i, "isFolded", false); } } } getGroupIndex(sheetId, dimension, start, end) { const index = this.groups[sheetId][dimension].findIndex((group) => group.start === start && group.end === end); return index === -1 ? undefined : index; } import(data) { for (const sheet of data.sheets) { this.groups[sheet.id] = { ROW: [], COL: [] }; if (!sheet.headerGroups) { continue; } for (const dim of ["ROW", "COL"]) { for (const groupData of sheet.headerGroups[dim] || []) { this.groups[sheet.id][dim].push({ ...groupData }); } } } } export(data) { for (const sheet of data.sheets) { sheet.headerGroups = this.groups[sheet.id]; } } exportForExcel(data) { /** * Example of header groups in the XLSX file: * * 0. | * 1. | | * 2. | | * 3. | |_ * 4. |_ * 5. * * The collapsed flag can be on the header before or after the group (or can be missing). Default is after. */ for (const sheet of data.sheets) { for (const dim of ["ROW", "COL"]) { const layers = this.getGroupsLayers(sheet.id, dim); for (let layerIndex = 0; layerIndex < layers.length; layerIndex++) { const layer = layers[layerIndex]; for (const group of layer) { for (let headerIndex = group.start; headerIndex <= group.end; headerIndex++) { const header = getSheetDataHeader(sheet, dim, headerIndex); header.outlineLevel = layerIndex + 1; if (group.isFolded) { header.isHidden = true; } } if (group.isFolded) { const header = getSheetDataHeader(sheet, dim, group.end + 1); header.collapsed = true; } } } } } } } class PivotCorePlugin extends CorePlugin { static getters = [ "getPivotCoreDefinition", "getPivotDisplayName", "getPivotId", "getPivotFormulaId", "getPivotIds", "getMeasureCompiledFormula", "getPivotName", "isExistingPivot", ]; nextFormulaId = 1; pivots = {}; formulaIds = {}; compiledMeasureFormulas = {}; allowDispatch(cmd) { switch (cmd.type) { case "ADD_PIVOT": { return this.checkDuplicatedMeasureIds(cmd.pivot); } case "UPDATE_PIVOT": { if (deepEquals(cmd.pivot, this.pivots[cmd.pivotId]?.definition)) { return "NoChanges" /* CommandResult.NoChanges */; } if (cmd.pivot.name === "") { return "EmptyName" /* CommandResult.EmptyName */; } return this.checkDuplicatedMeasureIds(cmd.pivot); } case "RENAME_PIVOT": if (!(cmd.pivotId in this.pivots)) { return "PivotIdNotFound" /* CommandResult.PivotIdNotFound */; } if (cmd.name === "") { return "EmptyName" /* CommandResult.EmptyName */; } break; case "INSERT_PIVOT": { if (!(cmd.pivotId in this.pivots)) { return "PivotIdNotFound" /* CommandResult.PivotIdNotFound */; } break; } case "DUPLICATE_PIVOT": if (!(cmd.pivotId in this.pivots)) { return "PivotIdNotFound" /* CommandResult.PivotIdNotFound */; } } return "Success" /* CommandResult.Success */; } handle(cmd) { switch (cmd.type) { case "ADD_PIVOT": { const { pivotId, pivot } = cmd; this.addPivot(pivotId, pivot); break; } case "INSERT_PIVOT": { const { sheetId, col, row, pivotId, table } = cmd; const position = { sheetId, col, row }; const { cols, rows, measures, fieldsType } = table; const spTable = new SpreadsheetPivotTable(cols, rows, measures, fieldsType || {}); const formulaId = this.getPivotFormulaId(pivotId); this.insertPivot(position, formulaId, spTable); break; } case "RENAME_PIVOT": { this.history.update("pivots", cmd.pivotId, "definition", "name", cmd.name); break; } case "REMOVE_PIVOT": { const pivots = { ...this.pivots }; delete pivots[cmd.pivotId]; const formulaId = this.getPivotFormulaId(cmd.pivotId); this.history.update("formulaIds", formulaId, undefined); this.history.update("pivots", pivots); break; } case "DUPLICATE_PIVOT": { const { pivotId, newPivotId } = cmd; const pivot = deepCopy(this.getPivotCore(pivotId).definition); pivot.name = cmd.duplicatedPivotName ?? pivot.name + " (copy)"; this.addPivot(newPivotId, pivot); break; } case "UPDATE_PIVOT": { this.history.update("pivots", cmd.pivotId, "definition", cmd.pivot); this.compileCalculatedMeasures(cmd.pivot.measures); break; } } } adaptRanges(applyChange) { for (const sheetId in this.compiledMeasureFormulas) { for (const formulaString in this.compiledMeasureFormulas[sheetId]) { const compiledFormula = this.compiledMeasureFormulas[sheetId][formulaString]; const newDependencies = []; for (const range of compiledFormula.dependencies) { const change = applyChange(range); if (change.changeType === "NONE") { newDependencies.push(range); } else { newDependencies.push(change.range); } } const newFormulaString = this.getters.getFormulaString(sheetId, compiledFormula.tokens, newDependencies); if (newFormulaString !== formulaString) { this.replaceMeasureFormula(sheetId, formulaString, newFormulaString); } } } } // ------------------------------------------------------------------------- // Getters // ------------------------------------------------------------------------- getPivotDisplayName(pivotId) { const formulaId = this.getPivotFormulaId(pivotId); return `(#${formulaId}) ${this.getPivotName(pivotId)}`; } getPivotName(pivotId) { return this.getPivotCore(pivotId).definition.name; } /** * Returns the pivot core definition of the pivot with the given id. * Be careful, this is the core definition, this should be used only in a * context where the pivot is not loaded yet. */ getPivotCoreDefinition(pivotId) { return this.getPivotCore(pivotId).definition; } /** * Get the pivot ID (UID) from the formula ID (the one used in the formula) */ getPivotId(formulaId) { return this.formulaIds[formulaId]; } getPivotFormulaId(pivotId) { return this.getPivotCore(pivotId).formulaId; } getPivotIds() { return Object.keys(this.pivots); } isExistingPivot(pivotId) { return pivotId in this.pivots; } getMeasureCompiledFormula(measure) { if (!measure.computedBy) { throw new Error(`Measure ${measure.fieldName} is not computed by formula`); } const sheetId = measure.computedBy.sheetId; return this.compiledMeasureFormulas[sheetId][measure.computedBy.formula]; } // ------------------------------------------------------------------------- // Private // ------------------------------------------------------------------------- addPivot(pivotId, pivot, formulaId = this.nextFormulaId.toString()) { this.history.update("pivots", pivotId, { definition: pivot, formulaId }); this.compileCalculatedMeasures(pivot.measures); this.history.update("formulaIds", formulaId, pivotId); this.history.update("nextFormulaId", this.nextFormulaId + 1); } compileCalculatedMeasures(measures) { for (const measure of measures) { if (measure.computedBy) { const sheetId = measure.computedBy.sheetId; const compiledFormula = this.compileMeasureFormula(measure.computedBy.sheetId, measure.computedBy.formula); this.history.update("compiledMeasureFormulas", sheetId, measure.computedBy.formula, compiledFormula); } } } insertPivot(position, formulaId, table) { this.resizeSheet(position.sheetId, position, table); const pivotCells = table.getPivotCells(); for (let col = 0; col < pivotCells.length; col++) { for (let row = 0; row < pivotCells[col].length; row++) { const pivotCell = pivotCells[col][row]; this.dispatch("UPDATE_CELL", { sheetId: position.sheetId, col: position.col + col, row: position.row + row, content: createPivotFormula(formulaId, pivotCell), }); } } } resizeSheet(sheetId, { col, row }, table) { const colLimit = table.getNumberOfDataColumns() + 1; // +1 for the Top-Left const numberCols = this.getters.getNumberCols(sheetId); const deltaCol = numberCols - col; if (deltaCol < colLimit) { this.dispatch("ADD_COLUMNS_ROWS", { dimension: "COL", base: numberCols - 1, sheetId: sheetId, quantity: colLimit - deltaCol, position: "after", }); } const rowLimit = table.columns.length + table.rows.length; const numberRows = this.getters.getNumberRows(sheetId); const deltaRow = numberRows - row; if (deltaRow < rowLimit) { this.dispatch("ADD_COLUMNS_ROWS", { dimension: "ROW", base: numberRows - 1, sheetId: sheetId, quantity: rowLimit - deltaRow, position: "after", }); } } getPivotCore(pivotId) { const pivot = this.pivots[pivotId]; if (!pivot) { throw new Error(`Pivot with id ${pivotId} not found`); } return pivot; } compileMeasureFormula(sheetId, formulaString) { const compiledFormula = compile(formulaString); const rangeDependencies = compiledFormula.dependencies.map((xc) => this.getters.getRangeFromSheetXC(sheetId, xc)); return { ...compiledFormula, dependencies: rangeDependencies, }; } replaceMeasureFormula(sheetId, formulaString, newFormulaString) { this.history.update("compiledMeasureFormulas", sheetId, formulaString, undefined); this.history.update("compiledMeasureFormulas", sheetId, newFormulaString, this.compileMeasureFormula(sheetId, newFormulaString)); for (const pivotId in this.pivots) { const pivot = this.pivots[pivotId]; if (!pivot) { continue; } for (const measure of pivot.definition.measures) { if (measure.computedBy?.formula === formulaString) { const measureIndex = pivot.definition.measures.indexOf(measure); this.history.update("pivots", pivotId, "definition", "measures", measureIndex, "computedBy", { formula: newFormulaString, sheetId }); } } } } checkDuplicatedMeasureIds(definition) { const uniqueIds = new Set(definition.measures.map((m) => m.id)); if (definition.measures.length !== uniqueIds.size) { return "InvalidDefinition" /* CommandResult.InvalidDefinition */; } return "Success" /* CommandResult.Success */; } // --------------------------------------------------------------------- // Import/Export // --------------------------------------------------------------------- /** * Import the pivots */ import(data) { if (data.pivots) { for (const [id, pivot] of Object.entries(data.pivots)) { this.addPivot(id, pivot, pivot.formulaId); } } this.history.update("nextFormulaId", data.pivotNextId || getMaxObjectId(this.pivots) + 1); } /** * Export the pivots */ export(data) { data.pivots = {}; for (const pivotId in this.pivots) { data.pivots[pivotId] = { ...this.getPivotCoreDefinition(pivotId), formulaId: this.getPivotFormulaId(pivotId), }; } data.pivotNextId = this.nextFormulaId; } } class SettingsPlugin extends CorePlugin { static getters = ["getLocale"]; locale = DEFAULT_LOCALE; allowDispatch(cmd) { switch (cmd.type) { case "UPDATE_LOCALE": return isValidLocale(cmd.locale) ? "Success" /* CommandResult.Success */ : "InvalidLocale" /* CommandResult.InvalidLocale */; } return "Success" /* CommandResult.Success */; } handle(cmd) { switch (cmd.type) { case "UPDATE_LOCALE": const oldLocale = this.locale; const newLocale = cmd.locale; this.history.update("locale", newLocale); this.changeCellsDateFormatWithLocale(oldLocale, newLocale); break; } } getLocale() { return this.locale; } changeCellsDateFormatWithLocale(oldLocale, newLocale) { for (const sheetId of this.getters.getSheetIds()) { for (const [cellId, cell] of Object.entries(this.getters.getCells(sheetId))) { let formatToApply; if (cell.format === oldLocale.dateFormat) { formatToApply = newLocale.dateFormat; } if (cell.format === oldLocale.timeFormat) { formatToApply = newLocale.timeFormat; } if (cell.format === getDateTimeFormat(oldLocale)) { formatToApply = getDateTimeFormat(newLocale); } if (formatToApply) { const { col, row, sheetId } = this.getters.getCellPosition(cellId); this.dispatch("UPDATE_CELL", { col, row, sheetId, format: formatToApply, }); } } } } import(data) { this.locale = data.settings?.locale ?? DEFAULT_LOCALE; } export(data) { data.settings = { locale: this.locale, }; } } function adaptPivotRange(range, applyChange) { if (!range) { return undefined; } const change = applyChange(range); switch (change.changeType) { case "NONE": return range; case "REMOVE": return undefined; default: return change.range; } } class SpreadsheetPivotCorePlugin extends CorePlugin { adaptRanges(applyChange) { for (const pivotId of this.getters.getPivotIds()) { const definition = this.getters.getPivotCoreDefinition(pivotId); if (definition.type !== "SPREADSHEET") { continue; } if (definition.dataSet) { const { sheetId, zone } = definition.dataSet; const range = this.getters.getRangeFromZone(sheetId, zone); const adaptedRange = adaptPivotRange(range, applyChange); const dataSet = adaptedRange && { sheetId: adaptedRange.sheetId, zone: adaptedRange.zone, }; this.dispatch("UPDATE_PIVOT", { pivotId, pivot: { ...definition, dataSet } }); } } } } class TableStylePlugin extends CorePlugin { static getters = [ "getNewCustomTableStyleName", "getTableStyle", "getTableStyles", "isTableStyleEditable", ]; styles = {}; allowDispatch(cmd) { switch (cmd.type) { case "CREATE_TABLE": case "UPDATE_TABLE": if (cmd.config?.styleId && !this.styles[cmd.config.styleId]) { return "InvalidTableConfig" /* CommandResult.InvalidTableConfig */; } break; case "CREATE_TABLE_STYLE": if (!TABLE_STYLES_TEMPLATES[cmd.templateName]) { return "InvalidTableStyle" /* CommandResult.InvalidTableStyle */; } try { toHex(cmd.primaryColor); } catch (e) { return "InvalidTableStyle" /* CommandResult.InvalidTableStyle */; } break; } return "Success" /* CommandResult.Success */; } handle(cmd) { switch (cmd.type) { case "CREATE_TABLE_STYLE": const style = buildTableStyle(cmd.tableStyleName, cmd.templateName, cmd.primaryColor); this.history.update("styles", cmd.tableStyleId, style); break; case "REMOVE_TABLE_STYLE": const styles = { ...this.styles }; delete styles[cmd.tableStyleId]; this.history.update("styles", styles); for (const sheetId of this.getters.getSheetIds()) { for (const table of this.getters.getCoreTables(sheetId)) { if (table.config.styleId === cmd.tableStyleId) { this.dispatch("UPDATE_TABLE", { sheetId, zone: table.range.zone, config: { styleId: DEFAULT_TABLE_CONFIG.styleId }, }); } } } break; } } getTableStyle(styleId) { if (!this.styles[styleId]) { throw new Error(`Table style ${styleId} does not exist`); } return this.styles[styleId]; } getTableStyles() { return this.styles; } getNewCustomTableStyleName() { let name = _t("Custom Table Style"); const styleNames = new Set(Object.values(this.styles).map((style) => style.displayName)); if (!styleNames.has(name)) { return name; } let i = 2; while (styleNames.has(`${name} ${i}`)) { i++; } return `${name} ${i}`; } isTableStyleEditable(styleId) { return !TABLE_PRESETS[styleId]; } import(data) { for (const presetStyleId in TABLE_PRESETS) { this.styles[presetStyleId] = TABLE_PRESETS[presetStyleId]; } for (const styleId in data.customTableStyles) { const styleData = data.customTableStyles[styleId]; this.styles[styleId] = buildTableStyle(styleData.displayName, styleData.templateName, styleData.primaryColor); } } export(data) { const exportedStyles = {}; for (const styleId in this.styles) { if (!TABLE_PRESETS[styleId]) { exportedStyles[styleId] = { displayName: this.styles[styleId].displayName, templateName: this.styles[styleId].templateName, primaryColor: this.styles[styleId].primaryColor, }; } } data.customTableStyles = exportedStyles; } } /** * UI plugins handle any transient data required to display a spreadsheet. * They can draw on the grid canvas. */ class UIPlugin extends BasePlugin { static layers = []; getters; ui; selection; constructor({ getters, stateObserver, dispatch, canDispatch, uiActions, selection, }) { super(stateObserver, dispatch, canDispatch); this.getters = getters; this.ui = uiActions; this.selection = selection; } // --------------------------------------------------------------------------- // Grid rendering // --------------------------------------------------------------------------- drawLayer(ctx, layer) { } } /** * This registry is used to register functions that should be called after each iteration of the evaluation. * This is use currently to mark the each pivot to be re-evaluated. We have to do this after each iteration * to avoid to reload the data of the pivot at each function call (PIVOT.VALUE and PIVOT.HEADER). After each * evaluation iteration, the pivot has to be re-evaluated during the next iteration. */ const onIterationEndEvaluationRegistry = new Registry(); onIterationEndEvaluationRegistry.add("pivots", (getters) => { for (const pivotId of getters.getPivotIds()) { const pivot = getters.getPivot(pivotId); pivotRegistry.get(pivot.type).onIterationEndEvaluation(pivot); } }); const functionMap = functionRegistry.mapping; /** * Return all functions necessary to properly evaluate a formula: * - a refFn function to read any reference, cell or range of a normalized formula * - a range function to convert any reference to a proper value array * - an evaluation context */ function buildCompilationParameters(context, getters, computeCell) { const builder = new CompilationParametersBuilder(context, getters, computeCell); return builder.getParameters(); } class CompilationParametersBuilder { getters; computeCell; evalContext; rangeCache = {}; constructor(context, getters, computeCell) { this.getters = getters; this.computeCell = computeCell; this.evalContext = Object.assign(Object.create(functionMap), context, { getters: this.getters, locale: this.getters.getLocale(), }); } getParameters() { return { referenceDenormalizer: this.refFn.bind(this), ensureRange: this.range.bind(this), evalContext: this.evalContext, }; } /** * Returns the value of the cell(s) used in reference * * @param range the references used * @param isMeta if a reference is supposed to be used in a `meta` parameter as described in the * function for which this parameter is used, we just return the string of the parameter. * The `compute` of the formula's function must process it completely */ refFn(range, isMeta) { const rangeError = this.getRangeError(range); if (rangeError) { return rangeError; } if (isMeta) { // Use zoneToXc of zone instead of getRangeString to avoid sending unbounded ranges const sheetName = this.getters.getSheetName(range.sheetId); return { value: getFullReference(sheetName, zoneToXc(range.zone)) }; } // the compiler guarantees only single cell ranges reach this part of the code const position = { sheetId: range.sheetId, col: range.zone.left, row: range.zone.top }; return this.computeCell(position); } /** * Return the values of the cell(s) used in reference, but always in the format of a range even * if a single cell is referenced. It is a list of col values. This is useful for the formulas that describe parameters as * range etc. * * Note that each col is possibly sparse: it only contain the values of cells * that are actually present in the grid. */ range(range) { const rangeError = this.getRangeError(range); if (rangeError) { return [[rangeError]]; } const sheetId = range.sheetId; const zone = range.zone; // Performance issue: Avoid fetching data on positions that are out of the spreadsheet // e.g. A1:ZZZ9999 in a sheet with 10 cols and 10 rows should ignore everything past J10 and return a 10x10 array const sheetZone = this.getters.getSheetZone(sheetId); const _zone = intersection(zone, sheetZone); if (!_zone) { return [[]]; } const { top, left, bottom, right } = zone; const cacheKey = `${sheetId}-${top}-${left}-${bottom}-${right}`; if (cacheKey in this.rangeCache) { return this.rangeCache[cacheKey]; } const height = _zone.bottom - _zone.top + 1; const width = _zone.right - _zone.left + 1; const matrix = new Array(width); // Performance issue: nested loop is faster than a map here for (let col = _zone.left; col <= _zone.right; col++) { const colIndex = col - _zone.left; matrix[colIndex] = new Array(height); for (let row = _zone.top; row <= _zone.bottom; row++) { const rowIndex = row - _zone.top; matrix[colIndex][rowIndex] = this.computeCell({ sheetId, col, row }); } } this.rangeCache[cacheKey] = matrix; return matrix; } getRangeError(range) { if (!isZoneValid(range.zone)) { return new InvalidReferenceError(); } if (range.invalidSheetName) { return new EvaluationError(_t("Invalid sheet name: %s", range.invalidSheetName)); } return undefined; } } function quickselect(arr, k, left, right, compare) { quickselectStep(arr, k, left || 0, right || (arr.length - 1), compare || defaultCompare); } function quickselectStep(arr, k, left, right, compare) { while (right > left) { if (right - left > 600) { var n = right - left + 1; var m = k - left + 1; var z = Math.log(n); var s = 0.5 * Math.exp(2 * z / 3); var sd = 0.5 * Math.sqrt(z * s * (n - s) / n) * (m - n / 2 < 0 ? -1 : 1); var newLeft = Math.max(left, Math.floor(k - m * s / n + sd)); var newRight = Math.min(right, Math.floor(k + (n - m) * s / n + sd)); quickselectStep(arr, k, newLeft, newRight, compare); } var t = arr[k]; var i = left; var j = right; swap(arr, left, k); if (compare(arr[right], t) > 0) swap(arr, left, right); while (i < j) { swap(arr, i, j); i++; j--; while (compare(arr[i], t) < 0) i++; while (compare(arr[j], t) > 0) j--; } if (compare(arr[left], t) === 0) swap(arr, left, j); else { j++; swap(arr, j, right); } if (j <= k) left = j + 1; if (k <= j) right = j - 1; } } function swap(arr, i, j) { var tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; } function defaultCompare(a, b) { return a < b ? -1 : a > b ? 1 : 0; } class RBush { constructor(maxEntries = 9) { // max entries in a node is 9 by default; min node fill is 40% for best performance this._maxEntries = Math.max(4, maxEntries); this._minEntries = Math.max(2, Math.ceil(this._maxEntries * 0.4)); this.clear(); } all() { return this._all(this.data, []); } search(bbox) { let node = this.data; const result = []; if (!intersects(bbox, node)) return result; const toBBox = this.toBBox; const nodesToSearch = []; while (node) { for (let i = 0; i < node.children.length; i++) { const child = node.children[i]; const childBBox = node.leaf ? toBBox(child) : child; if (intersects(bbox, childBBox)) { if (node.leaf) result.push(child); else if (contains(bbox, childBBox)) this._all(child, result); else nodesToSearch.push(child); } } node = nodesToSearch.pop(); } return result; } collides(bbox) { let node = this.data; if (!intersects(bbox, node)) return false; const nodesToSearch = []; while (node) { for (let i = 0; i < node.children.length; i++) { const child = node.children[i]; const childBBox = node.leaf ? this.toBBox(child) : child; if (intersects(bbox, childBBox)) { if (node.leaf || contains(bbox, childBBox)) return true; nodesToSearch.push(child); } } node = nodesToSearch.pop(); } return false; } load(data) { if (!(data && data.length)) return this; if (data.length < this._minEntries) { for (let i = 0; i < data.length; i++) { this.insert(data[i]); } return this; } // recursively build the tree with the given data from scratch using OMT algorithm let node = this._build(data.slice(), 0, data.length - 1, 0); if (!this.data.children.length) { // save as is if tree is empty this.data = node; } else if (this.data.height === node.height) { // split root if trees have the same height this._splitRoot(this.data, node); } else { if (this.data.height < node.height) { // swap trees if inserted one is bigger const tmpNode = this.data; this.data = node; node = tmpNode; } // insert the small tree into the large tree at appropriate level this._insert(node, this.data.height - node.height - 1, true); } return this; } insert(item) { if (item) this._insert(item, this.data.height - 1); return this; } clear() { this.data = createNode([]); return this; } remove(item, equalsFn) { if (!item) return this; let node = this.data; const bbox = this.toBBox(item); const path = []; const indexes = []; let i, parent, goingUp; // depth-first iterative tree traversal while (node || path.length) { if (!node) { // go up node = path.pop(); parent = path[path.length - 1]; i = indexes.pop(); goingUp = true; } if (node.leaf) { // check current node const index = findItem(item, node.children, equalsFn); if (index !== -1) { // item found, remove the item and condense tree upwards node.children.splice(index, 1); path.push(node); this._condense(path); return this; } } if (!goingUp && !node.leaf && contains(node, bbox)) { // go down path.push(node); indexes.push(i); i = 0; parent = node; node = node.children[0]; } else if (parent) { // go right i++; node = parent.children[i]; goingUp = false; } else node = null; // nothing found } return this; } toBBox(item) { return item; } compareMinX(a, b) { return a.minX - b.minX; } compareMinY(a, b) { return a.minY - b.minY; } toJSON() { return this.data; } fromJSON(data) { this.data = data; return this; } _all(node, result) { const nodesToSearch = []; while (node) { if (node.leaf) result.push(...node.children); else nodesToSearch.push(...node.children); node = nodesToSearch.pop(); } return result; } _build(items, left, right, height) { const N = right - left + 1; let M = this._maxEntries; let node; if (N <= M) { // reached leaf level; return leaf node = createNode(items.slice(left, right + 1)); calcBBox(node, this.toBBox); return node; } if (!height) { // target height of the bulk-loaded tree height = Math.ceil(Math.log(N) / Math.log(M)); // target number of root entries to maximize storage utilization M = Math.ceil(N / Math.pow(M, height - 1)); } node = createNode([]); node.leaf = false; node.height = height; // split the items into M mostly square tiles const N2 = Math.ceil(N / M); const N1 = N2 * Math.ceil(Math.sqrt(M)); multiSelect(items, left, right, N1, this.compareMinX); for (let i = left; i <= right; i += N1) { const right2 = Math.min(i + N1 - 1, right); multiSelect(items, i, right2, N2, this.compareMinY); for (let j = i; j <= right2; j += N2) { const right3 = Math.min(j + N2 - 1, right2); // pack each entry recursively node.children.push(this._build(items, j, right3, height - 1)); } } calcBBox(node, this.toBBox); return node; } _chooseSubtree(bbox, node, level, path) { while (true) { path.push(node); if (node.leaf || path.length - 1 === level) break; let minArea = Infinity; let minEnlargement = Infinity; let targetNode; for (let i = 0; i < node.children.length; i++) { const child = node.children[i]; const area = bboxArea(child); const enlargement = enlargedArea(bbox, child) - area; // choose entry with the least area enlargement if (enlargement < minEnlargement) { minEnlargement = enlargement; minArea = area < minArea ? area : minArea; targetNode = child; } else if (enlargement === minEnlargement) { // otherwise choose one with the smallest area if (area < minArea) { minArea = area; targetNode = child; } } } node = targetNode || node.children[0]; } return node; } _insert(item, level, isNode) { const bbox = isNode ? item : this.toBBox(item); const insertPath = []; // find the best node for accommodating the item, saving all nodes along the path too const node = this._chooseSubtree(bbox, this.data, level, insertPath); // put the item into the node node.children.push(item); extend(node, bbox); // split on node overflow; propagate upwards if necessary while (level >= 0) { if (insertPath[level].children.length > this._maxEntries) { this._split(insertPath, level); level--; } else break; } // adjust bboxes along the insertion path this._adjustParentBBoxes(bbox, insertPath, level); } // split overflowed node into two _split(insertPath, level) { const node = insertPath[level]; const M = node.children.length; const m = this._minEntries; this._chooseSplitAxis(node, m, M); const splitIndex = this._chooseSplitIndex(node, m, M); const newNode = createNode(node.children.splice(splitIndex, node.children.length - splitIndex)); newNode.height = node.height; newNode.leaf = node.leaf; calcBBox(node, this.toBBox); calcBBox(newNode, this.toBBox); if (level) insertPath[level - 1].children.push(newNode); else this._splitRoot(node, newNode); } _splitRoot(node, newNode) { // split root node this.data = createNode([node, newNode]); this.data.height = node.height + 1; this.data.leaf = false; calcBBox(this.data, this.toBBox); } _chooseSplitIndex(node, m, M) { let index; let minOverlap = Infinity; let minArea = Infinity; for (let i = m; i <= M - m; i++) { const bbox1 = distBBox(node, 0, i, this.toBBox); const bbox2 = distBBox(node, i, M, this.toBBox); const overlap = intersectionArea(bbox1, bbox2); const area = bboxArea(bbox1) + bboxArea(bbox2); // choose distribution with minimum overlap if (overlap < minOverlap) { minOverlap = overlap; index = i; minArea = area < minArea ? area : minArea; } else if (overlap === minOverlap) { // otherwise choose distribution with minimum area if (area < minArea) { minArea = area; index = i; } } } return index || M - m; } // sorts node children by the best axis for split _chooseSplitAxis(node, m, M) { const compareMinX = node.leaf ? this.compareMinX : compareNodeMinX; const compareMinY = node.leaf ? this.compareMinY : compareNodeMinY; const xMargin = this._allDistMargin(node, m, M, compareMinX); const yMargin = this._allDistMargin(node, m, M, compareMinY); // if total distributions margin value is minimal for x, sort by minX, // otherwise it's already sorted by minY if (xMargin < yMargin) node.children.sort(compareMinX); } // total margin of all possible split distributions where each node is at least m full _allDistMargin(node, m, M, compare) { node.children.sort(compare); const toBBox = this.toBBox; const leftBBox = distBBox(node, 0, m, toBBox); const rightBBox = distBBox(node, M - m, M, toBBox); let margin = bboxMargin(leftBBox) + bboxMargin(rightBBox); for (let i = m; i < M - m; i++) { const child = node.children[i]; extend(leftBBox, node.leaf ? toBBox(child) : child); margin += bboxMargin(leftBBox); } for (let i = M - m - 1; i >= m; i--) { const child = node.children[i]; extend(rightBBox, node.leaf ? toBBox(child) : child); margin += bboxMargin(rightBBox); } return margin; } _adjustParentBBoxes(bbox, path, level) { // adjust bboxes along the given tree path for (let i = level; i >= 0; i--) { extend(path[i], bbox); } } _condense(path) { // go through the path, removing empty nodes and updating bboxes for (let i = path.length - 1, siblings; i >= 0; i--) { if (path[i].children.length === 0) { if (i > 0) { siblings = path[i - 1].children; siblings.splice(siblings.indexOf(path[i]), 1); } else this.clear(); } else calcBBox(path[i], this.toBBox); } } } function findItem(item, items, equalsFn) { if (!equalsFn) return items.indexOf(item); for (let i = 0; i < items.length; i++) { if (equalsFn(item, items[i])) return i; } return -1; } // calculate node's bbox from bboxes of its children function calcBBox(node, toBBox) { distBBox(node, 0, node.children.length, toBBox, node); } // min bounding rectangle of node children from k to p-1 function distBBox(node, k, p, toBBox, destNode) { if (!destNode) destNode = createNode(null); destNode.minX = Infinity; destNode.minY = Infinity; destNode.maxX = -Infinity; destNode.maxY = -Infinity; for (let i = k; i < p; i++) { const child = node.children[i]; extend(destNode, node.leaf ? toBBox(child) : child); } return destNode; } function extend(a, b) { a.minX = Math.min(a.minX, b.minX); a.minY = Math.min(a.minY, b.minY); a.maxX = Math.max(a.maxX, b.maxX); a.maxY = Math.max(a.maxY, b.maxY); return a; } function compareNodeMinX(a, b) { return a.minX - b.minX; } function compareNodeMinY(a, b) { return a.minY - b.minY; } function bboxArea(a) { return (a.maxX - a.minX) * (a.maxY - a.minY); } function bboxMargin(a) { return (a.maxX - a.minX) + (a.maxY - a.minY); } function enlargedArea(a, b) { return (Math.max(b.maxX, a.maxX) - Math.min(b.minX, a.minX)) * (Math.max(b.maxY, a.maxY) - Math.min(b.minY, a.minY)); } function intersectionArea(a, b) { const minX = Math.max(a.minX, b.minX); const minY = Math.max(a.minY, b.minY); const maxX = Math.min(a.maxX, b.maxX); const maxY = Math.min(a.maxY, b.maxY); return Math.max(0, maxX - minX) * Math.max(0, maxY - minY); } function contains(a, b) { return a.minX <= b.minX && a.minY <= b.minY && b.maxX <= a.maxX && b.maxY <= a.maxY; } function intersects(a, b) { return b.minX <= a.maxX && b.minY <= a.maxY && b.maxX >= a.minX && b.maxY >= a.minY; } function createNode(children) { return { children, height: 1, leaf: true, minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }; } // sort an array so that items come in groups of n unsorted items, with groups sorted between each other; // combines selection algorithm with binary divide & conquer approach function multiSelect(arr, left, right, n, compare) { const stack = [left, right]; while (stack.length) { right = stack.pop(); left = stack.pop(); if (right - left <= n) continue; const mid = left + Math.ceil((right - left) / n / 2) * n; quickselect(arr, mid, left, right, compare); stack.push(left, mid, mid, right); } } /** * R-Tree Data Structure * * R-Tree is a spatial data structure used for efficient indexing and querying * of multi-dimensional objects, particularly in geometric and spatial applications. * * It organizes objects into a tree hierarchy, grouping nearby objects together * in bounding boxes. Each node in the tree represents a bounding box that * contains its child nodes or leaf objects. This hierarchical structure allows * for faster spatial queries. * * @see https://en.wikipedia.org/wiki/R-tree * * Consider a 2D Space with four zones: A, B, C, D * +--------------------------+ * | | * | +---+ +-------+ | * | | A | | B | | * | +---+ +-------+ | * | | * | | * | +---+ | * | | C | | * | +---+ | * | +-----------+ | * | | D | | * | +-----------+ | * | | * +--------------------------+ * * It groups together zones that are spatially close into a minimum bounding box. * For example, A and B are grouped together in rectangle R1, and C and D are grouped * in R2. * * R0 * +--------------------------+ * | R1 | * | +-----------------+ | * | | A | | B | | * | +-----------------+ | * | | * | R2 | * | +---+---+---+ | * | | | C | | | * | | +---+ | | * | +-----------+ | * | | D | | * | +-----------+ | * | | * +--------------------------+ * * The tree would look like this: * R0 * / \ * / \ * R1 R2 * | | * A,B C,D * Choosing how to group the zones is crucial for the performance of the tree. * Key considerations include avoiding excessive empty space coverage and minimizing overlap * to reduce the number of subtrees processed during searches. * * Various heuristics exist for determining the optimal grouping strategy, such as "least enlargement" * which prioritizes grouping nodes resulting in the smallest increase in bounding box size. In cases where * the choice cannot be made based on this criterion due to the same enlargement for different groupings, * we then evaluate "least area," aiming to minimize the overall area of bounding boxes. * * This implementation is tailored for spreadsheet use, indexing objects associated * with a zone and a sheet. * * It uses the RBush library under the hood. One 2D RBush R-tree per sheet. * @see https://github.com/mourner/rbush */ class SpreadsheetRTree { /** * One 2D R-tree per sheet */ rTrees = {}; /** * Bulk-inserts the given items into the tree. Bulk insertion is usually ~2-3 times * faster than inserting items one by one. After bulk loading (bulk insertion into * an empty tree), subsequent query performance is also ~20-30% better. */ constructor(items = []) { const rangesPerSheet = {}; for (const item of items) { const sheetId = item.boundingBox.sheetId; if (!rangesPerSheet[sheetId]) { rangesPerSheet[sheetId] = []; } rangesPerSheet[sheetId].push(item); } for (const sheetId in rangesPerSheet) { this.rTrees[sheetId] = new ZoneRBush(); this.rTrees[sheetId].load(rangesPerSheet[sheetId]); // bulk-insert } } insert(item) { const sheetId = item.boundingBox.sheetId; if (!this.rTrees[sheetId]) { this.rTrees[sheetId] = new ZoneRBush(); } this.rTrees[sheetId].insert(item); } search({ zone, sheetId }) { if (!this.rTrees[sheetId]) { return []; } return this.rTrees[sheetId].search({ minX: zone.left, minY: zone.top, maxX: zone.right, maxY: zone.bottom, }); } remove(item) { const sheetId = item.boundingBox.sheetId; if (!this.rTrees[sheetId]) { return; } this.rTrees[sheetId].remove(item, this.rtreeItemComparer); } rtreeItemComparer(left, right) { return (left.data == right.data && left.boundingBox.sheetId === right.boundingBox.sheetId && left.boundingBox?.zone.left === right.boundingBox.zone.left && left.boundingBox?.zone.top === right.boundingBox.zone.top && left.boundingBox?.zone.right === right.boundingBox.zone.right && left.boundingBox?.zone.bottom === right.boundingBox.zone.bottom); } } /** * RBush extension to use zones as bounding boxes */ class ZoneRBush extends RBush { toBBox({ boundingBox }) { const zone = boundingBox.zone; return { minX: zone.left, minY: zone.top, maxX: zone.right, maxY: zone.bottom, }; } compareMinX(a, b) { return a.boundingBox.zone.left - b.boundingBox.zone.left; } compareMinY(a, b) { return a.boundingBox.zone.top - b.boundingBox.zone.top; } } /** * Implementation of a dependency Graph. * The graph is used to evaluate the cells in the correct * order, and should be updated each time a cell's content is modified * * It uses an R-Tree data structure to efficiently find dependent cells. */ class FormulaDependencyGraph { createEmptyPositionSet; dependencies = new PositionMap(); rTree; constructor(createEmptyPositionSet, data = []) { this.createEmptyPositionSet = createEmptyPositionSet; this.rTree = new SpreadsheetRTree(data); } removeAllDependencies(formulaPosition) { const ranges = this.dependencies.get(formulaPosition); if (!ranges) { return; } for (const range of ranges) { this.rTree.remove(range); } this.dependencies.delete(formulaPosition); } addDependencies(formulaPosition, dependencies) { const rTreeItems = dependencies.map(({ sheetId, zone }) => ({ data: formulaPosition, boundingBox: { zone, sheetId, }, })); for (const item of rTreeItems) { this.rTree.insert(item); } const existingDependencies = this.dependencies.get(formulaPosition); if (existingDependencies) { existingDependencies.push(...rTreeItems); } else { this.dependencies.set(formulaPosition, rTreeItems); } } /** * Return all the cells that depend on the provided ranges, * in the correct order they should be evaluated. * This is called a topological ordering (excluding cycles) */ getCellsDependingOn(ranges) { const visited = this.createEmptyPositionSet(); const queue = Array.from(ranges).reverse(); while (queue.length > 0) { const range = queue.pop(); const zone = range.zone; const sheetId = range.sheetId; for (let col = zone.left; col <= zone.right; col++) { for (let row = zone.top; row <= zone.bottom; row++) { visited.add({ sheetId, col, row }); } } const impactedPositions = this.rTree.search(range).map((dep) => dep.data); const nextInQueue = {}; for (const position of impactedPositions) { if (!visited.has(position)) { if (!nextInQueue[position.sheetId]) { nextInQueue[position.sheetId] = []; } nextInQueue[position.sheetId].push(positionToZone(position)); } } for (const sheetId in nextInQueue) { const zones = recomputeZones(nextInQueue[sheetId], []); queue.push(...zones.map((zone) => ({ sheetId, zone }))); } } // remove initial ranges for (const range of ranges) { const zone = range.zone; const sheetId = range.sheetId; for (let col = zone.left; col <= zone.right; col++) { for (let row = zone.top; row <= zone.bottom; row++) { visited.delete({ sheetId, col, row }); } } } return visited; } } /** * Implements a fixed-sized grid or 2D matrix of bits. * based on https://github.com/zandaqo/structurae * * The grid is implemented as a 1D array of 32-bit integers, where each bit represents a cell in the grid. * It follows row-major order, with each row stored consecutively in 32-bit blocks. * Pads the number of columns to the next power of 2 to allow quick lookups with bitwise operations. * * Key terminology: * - bucket: Index of an item in the Uint32Array, a 32-bit integer. * - bitPosition: The position of a bit within the bucket 32-bit integer. */ class BinaryGrid extends Uint32Array { columnOffset = 0; cols = 0; rows = 0; /** * Creates a binary grid of specified dimensions. */ static create(rows, columns) { const columnOffset = log2Ceil(columns); const length = (rows << columnOffset) >> 5; const grid = new this(length + 1); grid.columnOffset = columnOffset; grid.cols = columns; grid.rows = rows; return grid; } /** * Returns the bit at given coordinates. */ getValue(position) { const [bucket, bitPosition] = this.getCoordinates(position); return ((this[bucket] >> bitPosition) & 1); } /** * Sets the bit at given coordinates. */ setValue(position, value) { const [bucket, bitPosition] = this.getCoordinates(position); const currentValue = (this[bucket] >> bitPosition) & 1; const hasBeenInserted = currentValue === 0 && value === 1; this[bucket] = (this[bucket] & ~(1 << bitPosition)) | (value << bitPosition); return hasBeenInserted; // Let's breakdown of the above line: // with an example with a 4-bit integer (instead of 32-bit). // // Let's say we want to set the bit at position 2 to 1 and the existing // bit sequence this[bucket] is 1001. The final bit sequence should be 1101. // // First, we clear the bit at position 2 by AND-ing this[bucket] with a // mask having all 1s except a 0 at the bit position (~ (1 << bitPosition)). // 1 << bitPosition is 0100 (shifting 0001 to the left by 2) // Inverting the bits with ~ gives the final mask ~(1 << bitPosition): 1011 // // Then, we shift the value by the bit position (value << bitPosition: 0100) // and OR the result with the previous step's result: // (1001 & 1011) | 0100 = 1101 } isEmpty() { return !this.some((bucket) => bucket !== 0); } fillAllPositions() { const thirtyTwoOnes = -1 >>> 0; // same as 2 ** 32 - 1, a 32-bit number with all bits set to 1 this.fill(thirtyTwoOnes); } clear() { this.fill(0); } getCoordinates(position) { const { row, col } = position; const index = (row << this.columnOffset) + col; const bucket = index >> 5; return [bucket, index - (bucket << 5)]; } } function log2Ceil(value) { // A faster version of Math.ceil(Math.log2(value)). if (value === 0) { return -Infinity; } else if (value < 0) { return NaN; } // --value handles the case where value is a power of 2 return 32 - Math.clz32(--value); } class PositionSet { sheets = {}; /** * List of positions in the order they were inserted. */ insertions = []; maxSize = 0; constructor(sheetSizes) { for (const sheetId in sheetSizes) { const cols = sheetSizes[sheetId].cols; const rows = sheetSizes[sheetId].rows; this.maxSize += cols * rows; this.sheets[sheetId] = BinaryGrid.create(rows, cols); } } add(position) { const hasBeenInserted = this.sheets[position.sheetId].setValue(position, 1); if (hasBeenInserted) { this.insertions.push(position); } } addMany(positions) { for (const position of positions) { this.add(position); } } delete(position) { this.sheets[position.sheetId].setValue(position, 0); } deleteMany(positions) { for (const position of positions) { this.delete(position); } } has(position) { return this.sheets[position.sheetId].getValue(position) === 1; } clear() { const insertions = [...this]; this.insertions = []; for (const sheetId in this.sheets) { this.sheets[sheetId].clear(); } return insertions; } isEmpty() { if (this.insertions.length === 0) { return true; } for (const sheetId in this.sheets) { if (!this.sheets[sheetId].isEmpty()) { return false; } } return true; } fillAllPositions() { this.insertions = new Array(this.maxSize); let index = 0; for (const sheetId in this.sheets) { const grid = this.sheets[sheetId]; grid.fillAllPositions(); for (let i = 0; i < grid.rows; i++) { for (let j = 0; j < grid.cols; j++) { this.insertions[index++] = { sheetId, row: i, col: j }; } } } } /** * Iterate over the positions in the order of insertion. * Note that the same position may be yielded multiple times if the value was added * to the set then removed and then added again. */ *[Symbol.iterator]() { for (const position of this.insertions) { if (this.sheets[position.sheetId].getValue(position) === 1) { yield position; } } } } /** * Contains, for each cell, the array * formulas that could potentially spread on it * This is essentially a two way mapping between array formulas * and their results. * * As we don't allow two array formulas to spread on the same cell, this structure * is used to force the reevaluation of the potential spreaders of a cell when the * content of this cell is modified. This structure should be updated each time * an array formula is evaluated and try to spread on another cell. * */ class SpreadingRelation { /** * Internal structure: * For something like * - A2:'=SPLIT("KAYAK", "A")' * - B1:'=TRANSPOSE(SPLIT("COYOTE", "O"))' * * Resulting in: * ``` * ----------------- * | | A | B | C | * |---+---+---+---| * | 1 | | C | | * | 2 | K | Y | K | * | 3 | | T | | * | 4 | | E | | * ----------------- * ``` * We have `resultsToArrayFormulas` is an R-tree looking like: * - (A2:C2) --> A2 meaning values in A2:C2 are the result of A2 * - (B1:B4) --> B1 meaning values in B1:B4 are the result of B1 * * Note that B2 is part of both zones because it can be the result of * A2 or B1. * Using an R-tree allows for fast insertions while still having * a relatively fast lookup. * * We have `arrayFormulasToResults` looking like: * - (A2) --> A2:C2 meaning A2 spreads on the zone A2:C2 * - (B1) --> B1:B4 meaning B1 spreads on the zone B1:B4 * */ resultsToArrayFormulas = new SpreadsheetRTree(); arrayFormulasToResults = new PositionMap(); searchFormulaPositionsSpreadingOn(sheetId, zone) { return (this.resultsToArrayFormulas.search({ sheetId, zone }).map((node) => node.data) || EMPTY_ARRAY); } getArrayResultZone(formulasPosition) { return this.arrayFormulasToResults.get(formulasPosition); } /** * Remove a node, also remove it from other nodes adjacency list */ removeNode(position) { this.resultsToArrayFormulas.remove({ boundingBox: { sheetId: position.sheetId, zone: positionToZone(position) }, data: position, }); this.arrayFormulasToResults.delete(position); } /** * Create a spreading relation between two cells */ addRelation({ arrayFormulaPosition, resultZone: resultPosition, }) { this.resultsToArrayFormulas.insert({ boundingBox: { sheetId: arrayFormulaPosition.sheetId, zone: resultPosition }, data: arrayFormulaPosition, }); this.arrayFormulasToResults.set(arrayFormulaPosition, resultPosition); } isArrayFormula(position) { return this.arrayFormulasToResults.has(position); } } const EMPTY_ARRAY = []; const MAX_ITERATION = 30; const ERROR_CYCLE_CELL = createEvaluatedCell(new CircularDependencyError()); const EMPTY_CELL = createEvaluatedCell({ value: null }); class Evaluator { context; getters; compilationParams; evaluatedCells = new PositionMap(); formulaDependencies = lazy(new FormulaDependencyGraph(this.createEmptyPositionSet.bind(this))); blockedArrayFormulas = new PositionSet({}); spreadingRelations = new SpreadingRelation(); constructor(context, getters) { this.context = context; this.getters = getters; this.compilationParams = buildCompilationParameters(this.context, this.getters, this.computeAndSave.bind(this)); } getEvaluatedCell(position) { return this.evaluatedCells.get(position) || EMPTY_CELL; } getSpreadZone(position, options = { ignoreSpillError: false }) { const spreadZone = this.spreadingRelations.getArrayResultZone(position); if (!spreadZone) { return undefined; } const evaluatedCell = this.evaluatedCells.get(position); if (evaluatedCell?.type === CellValueType.error && !(options.ignoreSpillError && evaluatedCell?.value === CellErrorType.SpilledBlocked)) { return positionToZone(position); } return union(positionToZone(position), spreadZone); } getEvaluatedPositions() { return this.evaluatedCells.keys(); } getEvaluatedPositionsInSheet(sheetId) { return this.evaluatedCells.keysForSheet(sheetId); } getArrayFormulaSpreadingOn(position) { const hasArrayFormulaResult = this.getEvaluatedCell(position).type !== CellValueType.empty && !this.getters.getCell(position)?.isFormula; if (!hasArrayFormulaResult) { return this.spreadingRelations.isArrayFormula(position) ? position : undefined; } const arrayFormulas = this.spreadingRelations.searchFormulaPositionsSpreadingOn(position.sheetId, positionToZone(position)); return Array.from(arrayFormulas).find((position) => !this.blockedArrayFormulas.has(position)); } updateDependencies(position) { // removing dependencies is slow because it requires // to traverse the entire r-tree. // The data structure is optimized for searches the other way around this.formulaDependencies().removeAllDependencies(position); const dependencies = this.getDirectDependencies(position); this.formulaDependencies().addDependencies(position, dependencies); } addDependencies(position, dependencies) { this.formulaDependencies().addDependencies(position, dependencies); for (const range of dependencies) { const sheetId = range.sheetId; const { left, bottom, right, top } = range.zone; for (let col = left; col <= right; col++) { for (let row = top; row <= bottom; row++) { this.computeAndSave({ sheetId, col, row }); } } } } updateCompilationParameters() { // rebuild the compilation parameters (with a clean cache) this.compilationParams = buildCompilationParameters(this.context, this.getters, this.computeAndSave.bind(this)); this.compilationParams.evalContext.updateDependencies = this.updateDependencies.bind(this); this.compilationParams.evalContext.addDependencies = this.addDependencies.bind(this); } createEmptyPositionSet() { const sheetSizes = {}; for (const sheetId of this.getters.getSheetIds()) { sheetSizes[sheetId] = { rows: this.getters.getNumberRows(sheetId), cols: this.getters.getNumberCols(sheetId), }; } return new PositionSet(sheetSizes); } evaluateCells(positions) { const start = performance.now(); const cellsToCompute = this.createEmptyPositionSet(); cellsToCompute.addMany(positions); const arrayFormulasPositions = this.getArrayFormulasImpactedByChangesOf(positions); cellsToCompute.addMany(this.getCellsDependingOn(positions)); cellsToCompute.addMany(arrayFormulasPositions); cellsToCompute.addMany(this.getCellsDependingOn(arrayFormulasPositions)); this.evaluate(cellsToCompute); console.debug("evaluate Cells", performance.now() - start, "ms"); } getArrayFormulasImpactedByChangesOf(positions) { const impactedPositions = this.createEmptyPositionSet(); for (const position of positions) { const content = this.getters.getCell(position)?.content; const arrayFormulaPosition = this.getArrayFormulaSpreadingOn(position); if (arrayFormulaPosition !== undefined) { // take into account new collisions. impactedPositions.add(arrayFormulaPosition); } if (!content) { // The previous content could have blocked some array formulas impactedPositions.add(position); } } const zonesBySheetIds = aggregatePositionsToZones(impactedPositions); for (const sheetId in zonesBySheetIds) { for (const zone of zonesBySheetIds[sheetId]) { impactedPositions.addMany(this.getArrayFormulasBlockedBy(sheetId, zone)); } } return impactedPositions; } buildDependencyGraph() { this.blockedArrayFormulas = this.createEmptyPositionSet(); this.spreadingRelations = new SpreadingRelation(); this.formulaDependencies = lazy(() => { const dependencies = [...this.getAllCells()].flatMap((position) => this.getDirectDependencies(position) .filter((range) => !range.invalidSheetName && !range.invalidXc) .map((range) => ({ data: position, boundingBox: { zone: range.zone, sheetId: range.sheetId, }, }))); return new FormulaDependencyGraph(this.createEmptyPositionSet.bind(this), dependencies); }); } evaluateAllCells() { const start = performance.now(); this.evaluatedCells = new PositionMap(); this.evaluate(this.getAllCells()); console.debug("evaluate all cells", performance.now() - start, "ms"); } evaluateFormulaResult(sheetId, formulaString) { const compiledFormula = compile(formulaString); const ranges = compiledFormula.dependencies.map((xc) => this.getters.getRangeFromSheetXC(sheetId, xc)); this.updateCompilationParameters(); return this.evaluateCompiledFormula(sheetId, { ...compiledFormula, dependencies: ranges, }); } evaluateCompiledFormula(sheetId, compiledFormula, getContextualSymbolValue) { try { const result = updateEvalContextAndExecute(compiledFormula, this.compilationParams, sheetId, this.buildSafeGetSymbolValue(getContextualSymbolValue), this.compilationParams.evalContext.__originCellPosition); if (isMatrix(result)) { return matrixMap(result, nullValueToZeroValue); } return nullValueToZeroValue(result); } catch (error) { return handleError(error, ""); } } getAllCells() { const positions = this.createEmptyPositionSet(); positions.fillAllPositions(); return positions; } /** * Return the position of formulas blocked by the given positions * as well as all their dependencies. */ getArrayFormulasBlockedBy(sheetId, zone) { const arrayFormulaPositions = this.createEmptyPositionSet(); const arrayFormulas = this.spreadingRelations.searchFormulaPositionsSpreadingOn(sheetId, zone); arrayFormulaPositions.addMany(arrayFormulas); const spilledPositions = [...arrayFormulas].filter((position) => !this.blockedArrayFormulas.has(position)); if (spilledPositions.length) { // ignore the formula spreading on the position. Keep only the blocked ones arrayFormulaPositions.deleteMany(spilledPositions); } arrayFormulaPositions.addMany(this.getCellsDependingOn(arrayFormulaPositions)); return arrayFormulaPositions; } nextPositionsToUpdate = new PositionSet({}); cellsBeingComputed = new Set(); symbolsBeingComputed = new Set(); evaluate(positions) { this.cellsBeingComputed = new Set(); this.nextPositionsToUpdate = positions; let currentIteration = 0; while (!this.nextPositionsToUpdate.isEmpty() && currentIteration++ < MAX_ITERATION) { this.updateCompilationParameters(); const positions = this.nextPositionsToUpdate.clear(); for (let i = 0; i < positions.length; ++i) { this.evaluatedCells.delete(positions[i]); } for (let i = 0; i < positions.length; ++i) { const position = positions[i]; if (this.nextPositionsToUpdate.has(position)) { continue; } const evaluatedCell = this.computeCell(position); if (evaluatedCell !== EMPTY_CELL) { this.evaluatedCells.set(position, evaluatedCell); } } onIterationEndEvaluationRegistry.getAll().forEach((callback) => callback(this.getters)); } if (currentIteration >= MAX_ITERATION) { console.warn("Maximum iteration reached while evaluating cells"); } } computeCell(position) { const evaluation = this.evaluatedCells.get(position); if (evaluation) { return evaluation; // already computed } if (!this.blockedArrayFormulas.has(position)) { this.invalidateSpreading(position); } if (this.spreadingRelations.isArrayFormula(position)) { this.spreadingRelations.removeNode(position); } const cell = this.getters.getCell(position); if (cell === undefined) { return EMPTY_CELL; } const cellId = cell.id; const localeFormat = { format: cell.format, locale: this.getters.getLocale() }; try { if (this.cellsBeingComputed.has(cellId)) { return ERROR_CYCLE_CELL; } this.cellsBeingComputed.add(cellId); return cell.isFormula ? this.computeFormulaCell(position, cell) : evaluateLiteral(cell, localeFormat); } catch (e) { e.value = e?.value || CellErrorType.GenericError; e.message = e?.message || implementationErrorMessage; return createEvaluatedCell(e); } finally { this.cellsBeingComputed.delete(cellId); } } computeAndSave(position) { const evaluatedCell = this.computeCell(position); if (!this.evaluatedCells.has(position)) { this.evaluatedCells.set(position, evaluatedCell); } return evaluatedCell; } computeFormulaCell(formulaPosition, cellData) { const formulaReturn = updateEvalContextAndExecute(cellData.compiledFormula, this.compilationParams, formulaPosition.sheetId, this.buildSafeGetSymbolValue(), formulaPosition); if (!isMatrix(formulaReturn)) { return createEvaluatedCell(nullValueToZeroValue(formulaReturn), this.getters.getLocale(), cellData); } this.assertSheetHasEnoughSpaceToSpreadFormulaResult(formulaPosition, formulaReturn); const nbColumns = formulaReturn.length; const nbRows = formulaReturn[0].length; const resultZone = { top: formulaPosition.row, bottom: formulaPosition.row + nbRows - 1, left: formulaPosition.col, right: formulaPosition.col + nbColumns - 1, }; this.spreadingRelations.addRelation({ resultZone, arrayFormulaPosition: formulaPosition }); this.assertNoMergedCellsInSpreadZone(formulaPosition, formulaReturn); forEachSpreadPositionInMatrix(nbColumns, nbRows, this.checkCollision(formulaPosition)); forEachSpreadPositionInMatrix(nbColumns, nbRows, // thanks to the isMatrix check above, we know that formulaReturn is MatrixFunctionReturn this.spreadValues(formulaPosition, formulaReturn)); this.invalidatePositionsDependingOnSpread(formulaPosition.sheetId, resultZone); return createEvaluatedCell(nullValueToZeroValue(formulaReturn[0][0]), this.getters.getLocale(), cellData); } invalidatePositionsDependingOnSpread(sheetId, resultZone) { // the result matrix is split in 2 zones to exclude the array formula position const invalidatedPositions = this.formulaDependencies().getCellsDependingOn(excludeTopLeft(resultZone).map((zone) => ({ sheetId, zone }))); invalidatedPositions.delete({ sheetId, col: resultZone.left, row: resultZone.top }); this.nextPositionsToUpdate.addMany(invalidatedPositions); } assertSheetHasEnoughSpaceToSpreadFormulaResult({ sheetId, col, row }, matrixResult) { const numberOfCols = this.getters.getNumberCols(sheetId); const numberOfRows = this.getters.getNumberRows(sheetId); const enoughCols = col + matrixResult.length <= numberOfCols; const enoughRows = row + matrixResult[0].length <= numberOfRows; if (enoughCols && enoughRows) { return; } if (enoughCols) { throw new SplillBlockedError(_t("Result couldn't be automatically expanded. Please insert more rows.")); } if (enoughRows) { throw new SplillBlockedError(_t("Result couldn't be automatically expanded. Please insert more columns.")); } throw new SplillBlockedError(_t("Result couldn't be automatically expanded. Please insert more columns and rows.")); } assertNoMergedCellsInSpreadZone({ sheetId, col, row }, matrixResult) { const mergedCells = this.getters.getMergesInZone(sheetId, { top: row, bottom: row + matrixResult[0].length - 1, left: col, right: col + matrixResult.length - 1, }); if (mergedCells.length === 0) { return; } throw new SplillBlockedError(_t("Merged cells found in the spill zone. Please unmerge cells before using array formulas.")); } checkCollision(formulaPosition) { const { sheetId, col, row } = formulaPosition; const checkCollision = (i, j) => { const position = { sheetId: sheetId, col: i + col, row: j + row }; const rawCell = this.getters.getCell(position); if (rawCell?.content || this.getters.getEvaluatedCell(position).type !== CellValueType.empty) { this.blockedArrayFormulas.add(formulaPosition); throw new SplillBlockedError(_t("Array result was not expanded because it would overwrite data in %s.", toXC(position.col, position.row))); } this.blockedArrayFormulas.delete(formulaPosition); }; return checkCollision; } spreadValues({ sheetId, col, row }, matrixResult) { const spreadValues = (i, j) => { const position = { sheetId, col: i + col, row: j + row }; const cell = this.getters.getCell(position); const evaluatedCell = createEvaluatedCell(nullValueToZeroValue(matrixResult[i][j]), this.getters.getLocale(), cell); this.evaluatedCells.set(position, evaluatedCell); }; return spreadValues; } invalidateSpreading(position) { const zone = this.spreadingRelations.getArrayResultZone(position); if (!zone) { return; } for (let col = zone.left; col <= zone.right; col++) { for (let row = zone.top; row <= zone.bottom; row++) { const resultPosition = { sheetId: position.sheetId, col, row }; const content = this.getters.getCell(resultPosition)?.content; if (content) { // there's no point at re-evaluating overlapping array formulas, // there's still a collision continue; } this.evaluatedCells.delete(resultPosition); } } const sheetId = position.sheetId; this.invalidatePositionsDependingOnSpread(sheetId, zone); this.nextPositionsToUpdate.addMany(this.getArrayFormulasBlockedBy(sheetId, zone)); } /** * Wraps a GetSymbolValue function to add cycle detection * and error handling. */ buildSafeGetSymbolValue(getContextualSymbolValue) { const getSymbolValue = (symbolName) => { if (this.symbolsBeingComputed.has(symbolName)) { return ERROR_CYCLE_CELL; } this.symbolsBeingComputed.add(symbolName); try { const symbolValue = getContextualSymbolValue?.(symbolName); if (symbolValue) { return symbolValue; } return new BadExpressionError(_t("Invalid formula")); } finally { this.symbolsBeingComputed.delete(symbolName); } }; return getSymbolValue; } // ---------------------------------------------------------- // COMMON FUNCTIONALITY // ---------------------------------------------------------- getDirectDependencies(position) { const cell = this.getters.getCell(position); if (!cell?.isFormula) { return []; } return cell.compiledFormula.dependencies; } getCellsDependingOn(positions) { const ranges = []; const zonesBySheetIds = aggregatePositionsToZones(positions); for (const sheetId in zonesBySheetIds) { ranges.push(...zonesBySheetIds[sheetId].map((zone) => ({ sheetId, zone }))); } return this.formulaDependencies().getCellsDependingOn(ranges); } } function forEachSpreadPositionInMatrix(nbColumns, nbRows, callback) { for (let i = 0; i < nbColumns; ++i) { for (let j = 0; j < nbRows; ++j) { if (i === 0 && j === 0) { continue; } callback(i, j); } } } /** * This function replaces null payload values with 0. * This aids in the UI by ensuring that a cell with a * formula referencing an empty cell displays a value (0), * rather than appearing empty. This indicates that the * cell is the result of a non-empty content. */ function nullValueToZeroValue(functionResult) { if (functionResult.value === null || functionResult.value === undefined) { // 'functionResult.value === undefined' is supposed to never happen, it's a safety net for javascript use return { ...functionResult, value: 0 }; } return functionResult; } function updateEvalContextAndExecute(compiledFormula, compilationParams, sheetId, getSymbolValue, originCellPosition) { compilationParams.evalContext.__originCellPosition = originCellPosition; compilationParams.evalContext.__originSheetId = sheetId; return compiledFormula.execute(compiledFormula.dependencies, compilationParams.referenceDenormalizer, compilationParams.ensureRange, getSymbolValue, compilationParams.evalContext); } //#region // --------------------------------------------------------------------------- // INTRODUCTION // --------------------------------------------------------------------------- // The evaluation plugin is in charge of computing the values of the cells. // This is a fairly complex task for several reasons: // Reason n°1: Cells can contain formulas that must be interpreted to know // the final value of the cell. And these formulas can depend on other cells. // ex A1:"=SUM(B1:B2)" we have to evaluate B1:B2 first to be able to evaluate A1. // We say here that we have a 'formula dependency' between A1 and B1:B2. // Reason n°2: A cell can assign value to other cells that haven't content. // This concerns cells containing a formula that returns an array of values. // ex A1:"=SPLIT('Odoo','d')" Evaluating A1 must assign the value "O" to A1 and // "oo" to B1. We say here that we have a 'spread relation' between A1 and B1. // B1 have a spread value from A1. // Note that a cell can contain a formula which depends on other cells which // themselves can: // - contain formulas which depends on other cells (and so on). // - contain a spread value from other formulas which depends on other cells // (and so on). // I - How to build the evaluation ? // If we had only formulas dependencies to treat, the evaluation would be // simple: the formulas dependencies are directly deduced from the content // of the formulas. With the dependencies we are able to calculate which // formula must be evaluated before another. // Cycles // ------ // We can also easily detect if the cells are included in reference cycles // and return an error in this case. ex: A1:"=B1" B1:"=C1" C1:"=A1" // The "#CYCLE" error must be returned for // all three cells. // But there's more! There are formulas referring to other cells but never // use them. This is the case for example // with the "IF" formula. ex: // A1:"=IF(D1,A2,B1)" // A2:"=A1" // In this case it is obvious that we have a cyclic dependency. But in practice // this will only exist if D1 is true. // For this reason, we believe that the evaluation should be **partly recursive**: // The function computing a formula cell starts by marking them as 'being evaluated' // and then call itself on the dependencies of the concerned cell. This allows // to evaluate the dependencies before the cell itself and to detect // if the cell that is being evaluated isn't part of a cycle. // II - The spread relation anticipation problem // The biggest difficulty to solve with the evaluation lies in the fact that // we cannot anticipate the spread relations: cells impacted by the result array // of a formula are only determined after the array formula has been // evaluated. In the case where the impacted cells are used in other formulas, // this will require to re-evaluation other formulas (and so on...). ex: // A1:"=B2" // A2:"=SPLIT('Odoo','d')" // in the example above, A2 spreads on B2, but we will know it only after // the evaluation of A2. To be able to evaluate A1 correctly, we must therefore // reevaluate A1 after the evaluation of A2. // We could evaluate which formula spreads first. Except that the array formulas // can themselves depend on the spreads of other formulas. ex: // A1:"=SPLIT(B3,'d')" // A2:="odoo odoo" // A3:"=SPLIT(A2,' ')" // In the example above, A3 must be evaluated before A1 because A1 needs B3 which // can be modified by A3. // Therefore, there would be a spatial evaluation order to be respected between // the array formulas. We could imagine that, when an array formula depends // on a cell, then we evaluate the first formula that spreads located in the upper // left corner of this cell. // Although this possibility has been explored, it remains complicated to spatially // predict which formula should be evaluated before another, especially when // the array formulas are located in different sheets or when the array formulas // depends on the spreads of each other. ex: // // A1:"=ARRAY_FORMULA_ALPHA(B2)" // A2:"=ARRAY_FORMULA_BETA(B1)" // In the example above, ARRAY_FORMULA_ALPHA and ARRAY_FORMULA_BETA are some // formulas that could spread depending on the value of B2 and B1. This could be a // cyclic dependency that we cannot anticipate. // And as with the "IF" formula, array formulas may not use their dependency. // It then becomes very difficult to manage... // Also, if we have a cycle, that doesn't mean it's bad. The cycle can converge to // a stable state at the scale of the sheets. Functionally, we don't want to forbid // convergent cycles. It is an interesting feature but which requires to re-evaluate // the cycle as many times as convergence is not reached. // Thus, in order to respect the relations between the cells (formula dependencies and // spread relations), the evaluation of the cells must: // - respect a precise order (cells values used by another must be evaluated first) : As // we cannot anticipate which dependencies are really used by the formulas, we must // evaluate the cells in a recursive way; // - be done as many times as necessary to ensure that all the cells have been correctly // evaluated in the correct order (in case of, for example, spreading relation cycles). // The chosen solution is to reevaluate the formulas impacted by spreads as many times // as necessary in several iterations, where evaluated cells can trigger the evaluation // of other cells depending on it, at the next iteration. //#endregion class EvaluationPlugin extends UIPlugin { static getters = [ "evaluateFormula", "evaluateFormulaResult", "evaluateCompiledFormula", "getCorrespondingFormulaCell", "getRangeFormattedValues", "getRangeValues", "getRangeFormats", "getEvaluatedCell", "getEvaluatedCells", "getEvaluatedCellsInZone", "getEvaluatedCellsPositions", "getSpreadZone", "getArrayFormulaSpreadingOn", "isEmpty", ]; shouldRebuildDependenciesGraph = true; evaluator; positionsToUpdate = []; constructor(config) { super(config); this.evaluator = new Evaluator(config.custom, this.getters); } // --------------------------------------------------------------------------- // Command Handling // --------------------------------------------------------------------------- beforeHandle(cmd) { if (invalidateEvaluationCommands.has(cmd.type) || invalidateDependenciesCommands.has(cmd.type)) { this.shouldRebuildDependenciesGraph = true; } } handle(cmd) { switch (cmd.type) { case "UPDATE_CELL": if (!("content" in cmd || "format" in cmd) || this.shouldRebuildDependenciesGraph) { return; } const position = { sheetId: cmd.sheetId, row: cmd.row, col: cmd.col }; this.positionsToUpdate.push(position); if ("content" in cmd) { this.evaluator.updateDependencies(position); } break; case "EVALUATE_CELLS": this.evaluator.evaluateAllCells(); break; } } finalize() { if (this.shouldRebuildDependenciesGraph) { this.evaluator.buildDependencyGraph(); this.evaluator.evaluateAllCells(); this.shouldRebuildDependenciesGraph = false; } else if (this.positionsToUpdate.length) { this.evaluator.evaluateCells(this.positionsToUpdate); } this.positionsToUpdate = []; } // --------------------------------------------------------------------------- // Getters // --------------------------------------------------------------------------- evaluateFormula(sheetId, formulaString) { const result = this.evaluateFormulaResult(sheetId, formulaString); if (isMatrix(result)) { return matrixMap(result, (cell) => cell.value); } return result.value; } evaluateFormulaResult(sheetId, formulaString) { return this.evaluator.evaluateFormulaResult(sheetId, formulaString); } evaluateCompiledFormula(sheetId, compiledFormula, getSymbolValue) { return this.evaluator.evaluateCompiledFormula(sheetId, compiledFormula, getSymbolValue); } /** * Return the value of each cell in the range as they are displayed in the grid. */ getRangeFormattedValues(range) { const sheet = this.getters.tryGetSheet(range.sheetId); if (sheet === undefined) return []; return this.mapVisiblePositions(range, (p) => this.getters.getEvaluatedCell(p).formattedValue); } /** * Return the value of each cell in the range. */ getRangeValues(range) { const sheet = this.getters.tryGetSheet(range.sheetId); if (sheet === undefined) return []; return this.mapVisiblePositions(range, (p) => this.getters.getEvaluatedCell(p).value); } /** * Return the format of each cell in the range. */ getRangeFormats(range) { const sheet = this.getters.tryGetSheet(range.sheetId); if (sheet === undefined) return []; return this.getters.getEvaluatedCellsInZone(sheet.id, range.zone).map((cell) => cell.format); } getEvaluatedCell(position) { return this.evaluator.getEvaluatedCell(position); } getEvaluatedCells(sheetId) { return this.evaluator .getEvaluatedPositionsInSheet(sheetId) .map((position) => this.getEvaluatedCell(position)); } getEvaluatedCellsPositions(sheetId) { return this.evaluator.getEvaluatedPositionsInSheet(sheetId); } getEvaluatedCellsInZone(sheetId, zone) { return positions(zone).map(({ col, row }) => this.getters.getEvaluatedCell({ sheetId, col, row })); } /** * Return the spread zone the position is part of, if any */ getSpreadZone(position, options = { ignoreSpillError: false }) { return this.evaluator.getSpreadZone(position, options); } getArrayFormulaSpreadingOn(position) { return this.evaluator.getArrayFormulaSpreadingOn(position); } /** * Check if a zone only contains empty cells */ isEmpty(sheetId, zone) { return positions(zone) .map(({ col, row }) => this.getEvaluatedCell({ sheetId, col, row })) .every((cell) => cell.type === CellValueType.empty); } /** * Maps the visible positions of a range according to a provided callback * @param range - the range we filter out * @param evaluationCallback - the callback applied to the filtered positions * @returns the values filtered (ie we keep only the not hidden values) */ mapVisiblePositions(range, evaluationCallback) { const { sheetId, zone } = range; const xcPositions = positions(zone); return xcPositions.reduce((acc, position) => { const { col, row } = position; if (!this.getters.isColHidden(sheetId, col) && !this.getters.isRowHidden(sheetId, row)) { acc.push(evaluationCallback({ sheetId, ...position })); } return acc; }, []); } // --------------------------------------------------------------------------- // Export // --------------------------------------------------------------------------- exportForExcel(data) { for (const position of this.evaluator.getEvaluatedPositions()) { const evaluatedCell = this.evaluator.getEvaluatedCell(position); const xc = toXC(position.col, position.row); const value = evaluatedCell.value; let isFormula = false; let newContent = undefined; let newFormat = undefined; let isExported = true; const exportedSheetData = data.sheets.find((sheet) => sheet.id === position.sheetId); const formulaCell = this.getCorrespondingFormulaCell(position); if (formulaCell) { isExported = isExportableToExcel(formulaCell.compiledFormula.tokens); isFormula = isExported; if (!isExported) { // If the cell contains a non-exported formula and that is evaluates to // nothing* ,we don't export it. // * non-falsy value are relevant and so are 0 and FALSE, which only leaves // the empty string. if (value !== "") { newContent = (value ?? "").toString(); newFormat = evaluatedCell.format; } } } const exportedCellData = exportedSheetData.cells[xc] || {}; const format = newFormat ? getItemId(newFormat, data.formats) : exportedCellData.format; let content; if (isExported && isFormula && formulaCell instanceof FormulaCellWithDependencies) { content = formulaCell.contentWithFixedReferences; } else { content = !isExported ? newContent : exportedCellData.content; } exportedSheetData.cells[xc] = { ...exportedCellData, value, isFormula, content, format }; } } /** * Returns the corresponding formula cell of a given cell * It could be the formula present in the cell itself or the * formula of the array formula that spreads to the cell */ getCorrespondingFormulaCell(position) { const cell = this.getters.getCell(position); if (cell && cell.isFormula) { return cell.compiledFormula.isBadExpression ? undefined : cell; } else if (cell && cell.content) { return undefined; } const spreadingFormulaPosition = this.getArrayFormulaSpreadingOn(position); if (spreadingFormulaPosition === undefined) { return undefined; } const spreadingFormulaCell = this.getters.getCell(spreadingFormulaPosition); if (spreadingFormulaCell?.isFormula) { return spreadingFormulaCell; } return undefined; } } const chartColorRegex = /"(#[0-9a-fA-F]{6})"/g; /** * https://tomekdev.com/posts/sorting-colors-in-js */ function sortWithClusters(colorsToSort) { const clusters = [ { leadColor: rgba(255, 0, 0), colors: [] }, // red { leadColor: rgba(255, 128, 0), colors: [] }, // orange { leadColor: rgba(128, 128, 0), colors: [] }, // yellow { leadColor: rgba(128, 255, 0), colors: [] }, // chartreuse { leadColor: rgba(0, 255, 0), colors: [] }, // green { leadColor: rgba(0, 255, 128), colors: [] }, // spring green { leadColor: rgba(0, 255, 255), colors: [] }, // cyan { leadColor: rgba(0, 127, 255), colors: [] }, // azure { leadColor: rgba(0, 0, 255), colors: [] }, // blue { leadColor: rgba(127, 0, 255), colors: [] }, // violet { leadColor: rgba(128, 0, 128), colors: [] }, // magenta { leadColor: rgba(255, 0, 128), colors: [] }, // rose ]; for (const color of colorsToSort.map(colorToRGBA)) { let currentDistance = 500; //max distance is 441; let currentIndex = 0; clusters.forEach((cluster, clusterIndex) => { const distance = colorDistance(color, cluster.leadColor); if (currentDistance > distance) { currentDistance = distance; currentIndex = clusterIndex; } }); clusters[currentIndex].colors.push(color); } return clusters .map((cluster) => cluster.colors.sort((a, b) => rgbaToHSLA(a).s - rgbaToHSLA(b).s)) .flat() .map(rgbaToHex); } function colorDistance(color1, color2) { return Math.sqrt(Math.pow(color1.r - color2.r, 2) + Math.pow(color1.g - color2.g, 2) + Math.pow(color1.b - color2.b, 2)); } /** * CustomColors plugin * This plugins aims to compute and keep to custom colors used in the * current spreadsheet */ class CustomColorsPlugin extends UIPlugin { customColors = {}; shouldUpdateColors = true; static getters = ["getCustomColors"]; constructor(config) { super(config); this.tryToAddColors(config.customColors ?? []); } handle(cmd) { switch (cmd.type) { case "START": for (const sheetId of this.getters.getSheetIds()) { for (const chartId of this.getters.getChartIds(sheetId)) { this.tryToAddColors(this.getChartColors(chartId)); } } break; case "UPDATE_CHART": case "CREATE_CHART": this.tryToAddColors(this.getChartColors(cmd.id)); break; case "UPDATE_CELL": case "ADD_CONDITIONAL_FORMAT": case "SET_BORDER": case "SET_ZONE_BORDERS": case "SET_FORMATTING": case "CREATE_TABLE": case "UPDATE_TABLE": this.history.update("shouldUpdateColors", true); break; } } finalize() { if (this.shouldUpdateColors) { this.history.update("shouldUpdateColors", false); this.tryToAddColors(this.computeCustomColors()); } } getCustomColors() { return sortWithClusters(Object.keys(this.customColors)); } computeCustomColors() { let usedColors = []; for (const sheetId of this.getters.getSheetIds()) { usedColors = usedColors.concat(this.getColorsFromCells(sheetId), this.getFormattingColors(sheetId), this.getTableColors(sheetId)); } return [...new Set([...usedColors])]; } getColorsFromCells(sheetId) { const cells = Object.values(this.getters.getCells(sheetId)); const colors = new Set(); for (const cell of cells) { if (cell.style?.textColor) { colors.add(cell.style.textColor); } if (cell.style?.fillColor) { colors.add(cell.style.fillColor); } } for (const color of this.getters.getBordersColors(sheetId)) { colors.add(color); } return [...colors]; } getFormattingColors(sheetId) { const formats = this.getters.getConditionalFormats(sheetId); const formatColors = []; for (const format of formats) { const rule = format.rule; if (rule.type === "CellIsRule") { formatColors.push(rule.style.textColor); formatColors.push(rule.style.fillColor); } else if (rule.type === "ColorScaleRule") { formatColors.push(colorNumberString(rule.minimum.color)); formatColors.push(rule.midpoint ? colorNumberString(rule.midpoint.color) : undefined); formatColors.push(colorNumberString(rule.maximum.color)); } } return formatColors.filter(isDefined); } getChartColors(chartId) { const chart = this.getters.getChart(chartId); if (chart === undefined) { return []; } const stringifiedChart = JSON.stringify(chart.getDefinition()); const colors = stringifiedChart.matchAll(chartColorRegex); return [...colors].map((color) => color[1]); // color[1] is the first capturing group of the regex } getTableColors(sheetId) { const tables = this.getters.getTables(sheetId); return tables.flatMap((table) => { const config = table.config; const style = this.getters.getTableStyle(config.styleId); return [ this.getTableStyleElementColors(style.wholeTable), config.numberOfHeaders > 0 ? this.getTableStyleElementColors(style.headerRow) : [], config.totalRow ? this.getTableStyleElementColors(style.totalRow) : [], config.bandedColumns ? this.getTableStyleElementColors(style.firstColumnStripe) : [], config.bandedColumns ? this.getTableStyleElementColors(style.secondColumnStripe) : [], config.bandedRows ? this.getTableStyleElementColors(style.firstRowStripe) : [], config.bandedRows ? this.getTableStyleElementColors(style.secondRowStripe) : [], config.firstColumn ? this.getTableStyleElementColors(style.firstColumn) : [], config.lastColumn ? this.getTableStyleElementColors(style.lastColumn) : [], ].flat(); }); } getTableStyleElementColors(element) { if (!element) { return []; } return [ element.style?.fillColor, element.style?.textColor, element.border?.bottom?.color, element.border?.top?.color, element.border?.left?.color, element.border?.right?.color, element.border?.horizontal?.color, element.border?.vertical?.color, ].filter(isDefined); } tryToAddColors(colors) { for (const color of colors) { if (!isColorValid(color)) { continue; } const formattedColor = toHex(color); if (color && !COLOR_PICKER_DEFAULTS.includes(formattedColor)) { this.history.update("customColors", formattedColor, true); } } } } class EvaluationChartPlugin extends UIPlugin { static getters = ["getChartRuntime", "getStyleOfSingleCellChart"]; charts = {}; createRuntimeChart = chartRuntimeFactory(this.getters); handle(cmd) { if (invalidateEvaluationCommands.has(cmd.type) || invalidateCFEvaluationCommands.has(cmd.type) || invalidateChartEvaluationCommands.has(cmd.type)) { for (const chartId in this.charts) { this.charts[chartId] = undefined; } } switch (cmd.type) { case "UPDATE_CHART": case "CREATE_CHART": case "DELETE_FIGURE": this.charts[cmd.id] = undefined; break; case "DELETE_SHEET": for (let chartId in this.charts) { if (!this.getters.isChartDefined(chartId)) { this.charts[chartId] = undefined; } } break; } } getChartRuntime(figureId) { if (!this.charts[figureId]) { const chart = this.getters.getChart(figureId); if (!chart) { throw new Error(`No chart for the given id: ${figureId}`); } this.charts[figureId] = this.createRuntimeChart(chart); } return this.charts[figureId]; } /** * Get the background and textColor of a chart based on the color of the first cell of the main range of the chart. */ getStyleOfSingleCellChart(chartBackground, mainRange) { if (chartBackground) return { background: chartBackground, fontColor: chartFontColor(chartBackground) }; if (!mainRange) { return { background: BACKGROUND_CHART_COLOR, fontColor: chartFontColor(BACKGROUND_CHART_COLOR), }; } const col = mainRange.zone.left; const row = mainRange.zone.top; const sheetId = mainRange.sheetId; const style = this.getters.getCellComputedStyle({ sheetId, col, row }); const background = style.fillColor || BACKGROUND_CHART_COLOR; return { background, fontColor: style.textColor || chartFontColor(background), }; } exportForExcel(data) { for (const sheet of data.sheets) { if (!sheet.images) { sheet.images = []; } const sheetFigures = this.getters.getFigures(sheet.id); const figures = []; for (const figure of sheetFigures) { if (!figure || figure.tag !== "chart") { continue; } const figureId = figure.id; const figureData = this.getters.getChart(figureId)?.getDefinitionForExcel(); if (figureData) { figures.push({ ...figure, data: figureData, }); } else { const chart = this.getters.getChart(figureId); if (!chart) { continue; } const type = this.getters.getChartType(figureId); const runtime = this.getters.getChartRuntime(figureId); const img = chartToImage(runtime, figure, type); if (img) { sheet.images.push({ ...figure, tag: "image", data: { mimetype: "image/png", path: img, size: { width: figure.width, height: figure.height }, }, }); } } } sheet.charts = figures; } } } class EvaluationConditionalFormatPlugin extends UIPlugin { static getters = [ "getConditionalIcon", "getCellConditionalFormatStyle", "getConditionalDataBar", ]; isStale = true; // stores the computed styles in the format of computedStyles.sheetName[col][row] = Style computedStyles = {}; computedIcons = {}; computedDataBars = {}; // --------------------------------------------------------------------------- // Command Handling // --------------------------------------------------------------------------- handle(cmd) { if (invalidateEvaluationCommands.has(cmd.type) || invalidateCFEvaluationCommands.has(cmd.type) || (cmd.type === "UPDATE_CELL" && ("content" in cmd || "format" in cmd))) { this.isStale = true; } } finalize() { if (this.isStale) { for (const sheetId of this.getters.getSheetIds()) { this.computedStyles[sheetId] = lazy(() => this.getComputedStyles(sheetId)); this.computedIcons[sheetId] = lazy(() => this.getComputedIcons(sheetId)); this.computedDataBars[sheetId] = lazy(() => this.getComputedDataBars(sheetId)); } this.isStale = false; } } // --------------------------------------------------------------------------- // Getters // --------------------------------------------------------------------------- getCellConditionalFormatStyle(position) { const { sheetId, col, row } = position; const styles = this.computedStyles[sheetId](); return styles && styles[col]?.[row]; } getConditionalIcon({ sheetId, col, row }) { const icons = this.computedIcons[sheetId](); return icons && icons[col]?.[row]; } getConditionalDataBar({ sheetId, col, row }) { const dataBars = this.computedDataBars[sheetId](); return dataBars && dataBars[col]?.[row]; } // --------------------------------------------------------------------------- // Private // --------------------------------------------------------------------------- /** * Compute the styles according to the conditional formatting. * This computation must happen after the cell values are computed if they change * * This result of the computation will be in the state.cell[XC].conditionalStyle and will be the union of all the style * properties of the rules applied (in order). * So if a cell has multiple conditional formatting applied to it, and each affect a different value of the style, * the resulting style will have the combination of all those values. * If multiple conditional formatting use the same style value, they will be applied in order so that the last applied wins */ getComputedStyles(sheetId) { const computedStyle = {}; for (let cf of this.getters.getConditionalFormats(sheetId).reverse()) { switch (cf.rule.type) { case "ColorScaleRule": for (let range of cf.ranges) { this.applyColorScale(sheetId, range, cf.rule, computedStyle); } break; case "CellIsRule": const formulas = cf.rule.values.map((value) => value.startsWith("=") ? compile(value) : undefined); for (let ref of cf.ranges) { const zone = this.getters.getRangeFromSheetXC(sheetId, ref).zone; for (let row = zone.top; row <= zone.bottom; row++) { for (let col = zone.left; col <= zone.right; col++) { const predicate = this.rulePredicate[cf.rule.type]; const target = { sheetId, col, row }; const values = cf.rule.values.map((value, i) => { const compiledFormula = formulas[i]; if (compiledFormula) { return this.getters.getTranslatedCellFormula(sheetId, col - zone.left, row - zone.top, compiledFormula.tokens); } return value; }); if (predicate && predicate(target, { ...cf.rule, values })) { if (!computedStyle[col]) computedStyle[col] = []; // we must combine all the properties of all the CF rules applied to the given cell computedStyle[col][row] = Object.assign(computedStyle[col]?.[row] || {}, cf.rule.style); } } } } break; } } return computedStyle; } getComputedIcons(sheetId) { const computedIcons = {}; for (let cf of this.getters.getConditionalFormats(sheetId).reverse()) { if (cf.rule.type !== "IconSetRule") continue; for (let range of cf.ranges) { this.applyIcon(sheetId, range, cf.rule, computedIcons); } } return computedIcons; } getComputedDataBars(sheetId) { const computedDataBars = {}; for (let cf of this.getters.getConditionalFormats(sheetId).reverse()) { if (cf.rule.type !== "DataBarRule") continue; for (let range of cf.ranges) { this.applyDataBar(sheetId, range, cf.rule, computedDataBars); } } return computedDataBars; } parsePoint(sheetId, range, threshold, functionName) { const rangeValues = this.getters .getEvaluatedCellsInZone(sheetId, this.getters.getRangeFromSheetXC(sheetId, range).zone) .filter((cell) => cell.type === CellValueType.number) .map((cell) => cell.value); switch (threshold.type) { case "value": const result = functionName === "max" ? largeMax(rangeValues) : largeMin(rangeValues); return result; case "number": return Number(threshold.value); case "percentage": const min = largeMin(rangeValues); const max = largeMax(rangeValues); const delta = max - min; return min + (delta * Number(threshold.value)) / 100; case "percentile": return percentile(rangeValues, Number(threshold.value) / 100, true); case "formula": const value = threshold.value && this.getters.evaluateFormula(sheetId, threshold.value); return typeof value === "number" ? value : null; default: return null; } } /** Compute the CF icons for the given range and CF rule, and apply in in the given computedIcons object */ applyIcon(sheetId, range, rule, computedIcons) { const lowerInflectionPoint = this.parsePoint(sheetId, range, rule.lowerInflectionPoint); const upperInflectionPoint = this.parsePoint(sheetId, range, rule.upperInflectionPoint); if (lowerInflectionPoint === null || upperInflectionPoint === null || lowerInflectionPoint > upperInflectionPoint) { return; } const zone = this.getters.getRangeFromSheetXC(sheetId, range).zone; const iconSet = [rule.icons.upper, rule.icons.middle, rule.icons.lower]; for (let row = zone.top; row <= zone.bottom; row++) { for (let col = zone.left; col <= zone.right; col++) { const cell = this.getters.getEvaluatedCell({ sheetId, col, row }); if (cell.type !== CellValueType.number) { continue; } const icon = this.computeIcon(cell.value, upperInflectionPoint, rule.upperInflectionPoint.operator, lowerInflectionPoint, rule.lowerInflectionPoint.operator, iconSet); if (!computedIcons[col]) { computedIcons[col] = []; } computedIcons[col][row] = icon; } } } computeIcon(value, upperInflectionPoint, upperOperator, lowerInflectionPoint, lowerOperator, icons) { if ((upperOperator === "ge" && value >= upperInflectionPoint) || (upperOperator === "gt" && value > upperInflectionPoint)) { return icons[0]; } else if ((lowerOperator === "ge" && value >= lowerInflectionPoint) || (lowerOperator === "gt" && value > lowerInflectionPoint)) { return icons[1]; } return icons[2]; } applyDataBar(sheetId, range, rule, computedDataBars) { const rangeValues = this.getters.getRangeFromSheetXC(sheetId, rule.rangeValues || range); const allValues = this.getters .getEvaluatedCellsInZone(sheetId, rangeValues.zone) .filter((cell) => cell.type === CellValueType.number) .map((cell) => cell.value); const max = largeMax(allValues); if (max <= 0) { // no need to apply the data bar if all values are negative or 0 return; } const color = rule.color; const zone = this.getters.getRangeFromSheetXC(sheetId, range).zone; const zoneOfValues = rangeValues.zone; for (let row = zone.top; row <= zone.bottom; row++) { for (let col = zone.left; col <= zone.right; col++) { const targetCol = col - zone.left + zoneOfValues.left; const targetRow = row - zone.top + zoneOfValues.top; const cell = this.getters.getEvaluatedCell({ sheetId, col: targetCol, row: targetRow }); if (!isInside(targetCol, targetRow, zoneOfValues) || cell.type !== CellValueType.number || cell.value <= 0) { // values negatives or 0 are ignored continue; } if (!computedDataBars[col]) computedDataBars[col] = []; computedDataBars[col][row] = { color: colorNumberString(color), percentage: (cell.value * 100) / max, }; } } } /** Compute the color scale for the given range and CF rule, and apply in in the given computedStyle object */ applyColorScale(sheetId, range, rule, computedStyle) { const minValue = this.parsePoint(sheetId, range, rule.minimum, "min"); const midValue = rule.midpoint ? this.parsePoint(sheetId, range, rule.midpoint) : null; const maxValue = this.parsePoint(sheetId, range, rule.maximum, "max"); if (minValue === null || maxValue === null || minValue >= maxValue || (midValue && (minValue >= midValue || midValue >= maxValue))) { return; } const zone = this.getters.getRangeFromSheetXC(sheetId, range).zone; const colorCellArgs = []; if (rule.midpoint && midValue) { colorCellArgs.push({ minValue, minColor: rule.minimum.color, colorDiffUnit: this.computeColorDiffUnits(minValue, midValue, rule.minimum.color, rule.midpoint.color), }); colorCellArgs.push({ minValue: midValue, minColor: rule.midpoint.color, colorDiffUnit: this.computeColorDiffUnits(midValue, maxValue, rule.midpoint.color, rule.maximum.color), }); } else { colorCellArgs.push({ minValue, minColor: rule.minimum.color, colorDiffUnit: this.computeColorDiffUnits(minValue, maxValue, rule.minimum.color, rule.maximum.color), }); } for (let row = zone.top; row <= zone.bottom; row++) { for (let col = zone.left; col <= zone.right; col++) { const cell = this.getters.getEvaluatedCell({ sheetId, col, row }); if (cell.type === CellValueType.number) { const value = clip(cell.value, minValue, maxValue); let color; if (colorCellArgs.length === 2 && midValue) { color = value <= midValue ? this.colorCell(value, colorCellArgs[0].minValue, colorCellArgs[0].minColor, colorCellArgs[0].colorDiffUnit) : this.colorCell(value, colorCellArgs[1].minValue, colorCellArgs[1].minColor, colorCellArgs[1].colorDiffUnit); } else { color = this.colorCell(value, colorCellArgs[0].minValue, colorCellArgs[0].minColor, colorCellArgs[0].colorDiffUnit); } if (!computedStyle[col]) computedStyle[col] = []; computedStyle[col][row] = computedStyle[col]?.[row] || {}; computedStyle[col][row].fillColor = colorNumberString(color); } } } } computeColorDiffUnits(minValue, maxValue, minColor, maxColor) { const deltaValue = maxValue - minValue; const deltaColorR = ((minColor >> 16) % 256) - ((maxColor >> 16) % 256); const deltaColorG = ((minColor >> 8) % 256) - ((maxColor >> 8) % 256); const deltaColorB = (minColor % 256) - (maxColor % 256); const colorDiffUnitR = deltaColorR / deltaValue; const colorDiffUnitG = deltaColorG / deltaValue; const colorDiffUnitB = deltaColorB / deltaValue; return [colorDiffUnitR, colorDiffUnitG, colorDiffUnitB]; } colorCell(value, minValue, minColor, colorDiffUnit) { const [colorDiffUnitR, colorDiffUnitG, colorDiffUnitB] = colorDiffUnit; const r = Math.round(((minColor >> 16) % 256) - colorDiffUnitR * (value - minValue)); const g = Math.round(((minColor >> 8) % 256) - colorDiffUnitG * (value - minValue)); const b = Math.round((minColor % 256) - colorDiffUnitB * (value - minValue)); return (r << 16) | (g << 8) | b; } /** * Execute the predicate to know if a conditional formatting rule should be applied to a cell */ rulePredicate = { CellIsRule: (target, rule) => { const cell = this.getters.getEvaluatedCell(target); if (cell.type === CellValueType.error) { return false; } let [value0, value1] = rule.values.map((val) => { if (val.startsWith("=")) { return this.getters.evaluateFormula(target.sheetId, val) ?? ""; } return parseLiteral(val, DEFAULT_LOCALE); }); if (isMatrix(value0) || isMatrix(value1)) { return false; } const cellValue = cell.value ?? ""; value0 = value0 ?? ""; value1 = value1 ?? ""; switch (rule.operator) { case "IsEmpty": return cellValue.toString().trim() === ""; case "IsNotEmpty": return cellValue.toString().trim() !== ""; case "BeginsWith": if (value0 === "") { return false; } return cellValue.toString().startsWith(value0.toString()); case "EndsWith": if (value0 === "") { return false; } return cellValue.toString().endsWith(value0.toString()); case "Between": return cellValue >= value0 && cellValue <= value1; case "NotBetween": return !(cellValue >= value0 && cellValue <= value1); case "ContainsText": return cellValue.toString().indexOf(value0.toString()) > -1; case "NotContains": return !cellValue || cellValue.toString().indexOf(value0.toString()) === -1; case "GreaterThan": return cellValue > value0; case "GreaterThanOrEqual": return cellValue >= value0; case "LessThan": return cellValue < value0; case "LessThanOrEqual": return cellValue <= value0; case "NotEqual": if (value0 === "") { return false; } return cellValue !== value0; case "Equal": if (value0 === "") { return true; } return cellValue === value0; default: console.warn(_t("Not implemented operator %s for kind of conditional formatting: %s", rule.operator, rule.type)); } return false; }, }; } const VALID_RESULT = { isValid: true }; class EvaluationDataValidationPlugin extends UIPlugin { static getters = [ "getDataValidationInvalidCriterionValueMessage", "getInvalidDataValidationMessage", "getValidationResultForCellValue", "isCellValidCheckbox", "isDataValidationInvalid", ]; validationResults = {}; handle(cmd) { if (invalidateEvaluationCommands.has(cmd.type) || cmd.type === "EVALUATE_CELLS" || (cmd.type === "UPDATE_CELL" && ("content" in cmd || "format" in cmd))) { this.validationResults = {}; return; } switch (cmd.type) { case "ADD_DATA_VALIDATION_RULE": case "REMOVE_DATA_VALIDATION_RULE": delete this.validationResults[cmd.sheetId]; break; } } isDataValidationInvalid(cellPosition) { return !this.getValidationResultForCell(cellPosition).isValid; } getInvalidDataValidationMessage(cellPosition) { const validationResult = this.getValidationResultForCell(cellPosition); return validationResult.isValid ? undefined : validationResult.error; } /** * Check if the value is valid for the given criterion, and return an error message if not. * * The value must be canonicalized. */ getDataValidationInvalidCriterionValueMessage(criterionType, value) { const evaluator = dataValidationEvaluatorRegistry.get(criterionType); if (value.startsWith("=")) { return evaluator.allowedValues === "onlyLiterals" ? _t("The value must not be a formula") : undefined; } else if (evaluator.allowedValues === "onlyFormulas") { return _t("The value must be a formula"); } return evaluator.isCriterionValueValid(value) ? undefined : evaluator.criterionValueErrorString; } isCellValidCheckbox(cellPosition) { if (!this.getters.isMainCellPosition(cellPosition)) { return false; } const rule = this.getters.getValidationRuleForCell(cellPosition); if (!rule || rule.criterion.type !== "isBoolean") { return false; } return this.getValidationResultForCell(cellPosition).isValid; } /** Get the validation result if the cell on the given position had the given value */ getValidationResultForCellValue(cellValue, cellPosition) { const rule = this.getters.getValidationRuleForCell(cellPosition); if (!rule) { return VALID_RESULT; } const error = this.getRuleErrorForCellValue(cellValue, cellPosition, rule); return error ? { error, rule, isValid: false } : VALID_RESULT; } getValidationResultForCell(cellPosition) { const { col, row, sheetId } = cellPosition; if (!this.validationResults[sheetId]) { this.validationResults[sheetId] = this.computeSheetValidationResults(sheetId); } return this.validationResults[sheetId][col]?.[row]?.() || VALID_RESULT; } computeSheetValidationResults(sheetId) { const validationResults = {}; const ranges = this.getters.getDataValidationRules(sheetId).map((rule) => rule.ranges); for (const cellPosition of getCellPositionsInRanges(ranges.flat())) { const { col, row } = cellPosition; if (!validationResults[col]) { validationResults[col] = []; } validationResults[col][row] = lazy(() => { const evaluatedCell = this.getters.getEvaluatedCell(cellPosition); if (evaluatedCell.type === CellValueType.empty) { return VALID_RESULT; } return this.getValidationResultForCellValue(evaluatedCell.value, cellPosition); }); } return validationResults; } getRuleErrorForCellValue(cellValue, cellPosition, rule) { const { sheetId } = cellPosition; const criterion = rule.criterion; const evaluator = dataValidationEvaluatorRegistry.get(criterion.type); const offset = this.getCellOffsetInRule(cellPosition, rule); const evaluatedCriterionValues = this.getEvaluatedCriterionValues(sheetId, offset, criterion); const evaluatedCriterion = { ...criterion, values: evaluatedCriterionValues }; if (evaluator.isValueValid(cellValue, evaluatedCriterion, this.getters, sheetId)) { return undefined; } return evaluator.getErrorString(evaluatedCriterion, this.getters, sheetId); } /** Get the offset of the cell inside the ranges of the rule. Throws an error if the cell isn't inside the rule. */ getCellOffsetInRule(cellPosition, rule) { const range = rule.ranges.find((range) => isInside(cellPosition.col, cellPosition.row, range.zone)); if (!range) { throw new Error("The cell is not in any range of the rule"); } return { col: cellPosition.col - range.zone.left, row: cellPosition.row - range.zone.top, }; } getEvaluatedCriterionValues(sheetId, offset, criterion) { return criterion.values.map((value) => { if (!value.startsWith("=")) { return value; } const formula = compile(value); const translatedFormula = this.getters.getTranslatedCellFormula(sheetId, offset.col, offset.row, formula.tokens); const evaluated = this.getters.evaluateFormula(sheetId, translatedFormula); return evaluated && !isMatrix(evaluated) ? evaluated.toString() : ""; }); } } class DynamicTablesPlugin extends UIPlugin { static getters = [ "canCreateDynamicTableOnZones", "doesZonesContainFilter", "getFilter", "getFilters", "getTable", "getTables", "getTablesOverlappingZones", "getFilterId", "getFilterHeaders", "isFilterHeader", ]; tables = {}; handle(cmd) { if (invalidateEvaluationCommands.has(cmd.type) || (cmd.type === "UPDATE_CELL" && "content" in cmd) || cmd.type === "EVALUATE_CELLS") { this.tables = {}; return; } switch (cmd.type) { case "CREATE_TABLE": case "REMOVE_TABLE": case "UPDATE_TABLE": case "DELETE_CONTENT": this.tables = {}; break; } } finalize() { for (const sheetId of this.getters.getSheetIds()) { if (!this.tables[sheetId]) { this.tables[sheetId] = this.computeTables(sheetId); } } } computeTables(sheetId) { const tables = []; const coreTables = this.getters.getCoreTables(sheetId); // First we create the static tables, so we can use them to compute collision with dynamic tables for (const table of coreTables) { if (table.type === "dynamic") continue; tables.push(table); } const staticTables = [...tables]; // Then we create the dynamic tables for (const coreTable of coreTables) { if (coreTable.type !== "dynamic") continue; const table = this.coreTableToTable(sheetId, coreTable); let tableZone = table.range.zone; // Reduce the zone to avoid collision with static tables. Per design, dynamic tables can't overlap with other // dynamic tables, because formulas cannot spread on the same area, so we don't need to check for that. for (const staticTable of staticTables) { if (overlap(tableZone, staticTable.range.zone)) { tableZone = { ...tableZone, right: staticTable.range.zone.left - 1 }; } } tables.push({ ...table, range: this.getters.getRangeFromZone(sheetId, tableZone) }); } return tables; } getFilters(sheetId) { return this.getTables(sheetId) .filter((table) => table.config.hasFilters) .map((table) => table.filters) .flat(); } getTables(sheetId) { return this.tables[sheetId] || []; } getFilter(position) { const table = this.getTable(position); if (!table || !table.config.hasFilters) { return undefined; } return table.filters.find((filter) => filter.col === position.col); } getFilterId(position) { return this.getFilter(position)?.id; } getTable({ sheetId, col, row }) { return this.getTables(sheetId).find((table) => isInside(col, row, table.range.zone)); } getTablesOverlappingZones(sheetId, zones) { return this.getTables(sheetId).filter((table) => zones.some((zone) => overlap(table.range.zone, zone))); } doesZonesContainFilter(sheetId, zones) { return this.getTablesOverlappingZones(sheetId, zones).some((table) => table.config.hasFilters); } getFilterHeaders(sheetId) { const headers = []; for (const table of this.getTables(sheetId)) { if (!table.config.hasFilters) { continue; } const zone = table.range.zone; const row = zone.top; for (let col = zone.left; col <= zone.right; col++) { headers.push({ sheetId, col, row }); } } return headers; } isFilterHeader({ sheetId, col, row }) { const headers = this.getFilterHeaders(sheetId); return headers.some((header) => header.col === col && header.row === row); } /** * Check if we can create a dynamic table on the given zones. * - The zones must be continuous * - The union of the zones must be either: * - A single cell that contains an array formula * - All the spread cells of a single array formula */ canCreateDynamicTableOnZones(sheetId, zones) { if (!areZonesContinuous(zones)) { return false; } const unionZone = union(...zones); const topLeft = { col: unionZone.left, row: unionZone.top, sheetId }; const parentSpreadingCell = this.getters.getArrayFormulaSpreadingOn(topLeft); if (!parentSpreadingCell) { return false; } else if (deepEquals(parentSpreadingCell, topLeft) && getZoneArea(unionZone) === 1) { return true; } const zone = this.getters.getSpreadZone(parentSpreadingCell); return deepEquals(unionZone, zone); } coreTableToTable(sheetId, table) { if (table.type !== "dynamic") { return table; } const tableZone = table.range.zone; const tablePosition = { sheetId, col: tableZone.left, row: tableZone.top }; const zone = this.getters.getSpreadZone(tablePosition) ?? table.range.zone; const range = this.getters.getRangeFromZone(sheetId, zone); const filters = this.getDynamicTableFilters(sheetId, table, zone); return { id: table.id, range, filters, config: table.config }; } getDynamicTableFilters(sheetId, table, tableZone) { const filters = []; const { top, bottom, left, right } = tableZone; for (let col = left; col <= right; col++) { const tableColIndex = col - left; const zone = { left: col, right: col, top, bottom }; const filter = createFilter(this.getDynamicTableFilterId(table.id, tableColIndex), this.getters.getRangeFromZone(sheetId, zone), table.config, this.getters.getRangeFromZone); filters.push(filter); } return filters; } getDynamicTableFilterId(tableId, tableCol) { return tableId + "_" + tableCol; } exportForExcel(data) { for (const sheet of data.sheets) { for (const tableData of sheet.tables) { const zone = toZone(tableData.range); const topLeft = { sheetId: sheet.id, col: zone.left, row: zone.top }; const coreTable = this.getters.getCoreTable(topLeft); const table = this.getTable(topLeft); if (coreTable?.type !== "dynamic" || !table) { continue; } tableData.range = zoneToXc(table.range.zone); } } } } class HeaderSizeUIPlugin extends UIPlugin { static getters = ["getRowSize", "getHeaderSize"]; tallestCellInRow = {}; ctx = document.createElement("canvas").getContext("2d"); handle(cmd) { switch (cmd.type) { case "START": for (const sheetId of this.getters.getSheetIds()) { this.initializeSheet(sheetId); } break; case "CREATE_SHEET": { this.initializeSheet(cmd.sheetId); break; } case "DUPLICATE_SHEET": { const tallestCells = deepCopy(this.tallestCellInRow[cmd.sheetId]); this.history.update("tallestCellInRow", cmd.sheetIdTo, tallestCells); break; } case "DELETE_SHEET": const tallestCells = { ...this.tallestCellInRow }; delete tallestCells[cmd.sheetId]; this.history.update("tallestCellInRow", tallestCells); break; case "REMOVE_COLUMNS_ROWS": { if (cmd.dimension === "COL") { return; } const tallestCells = removeIndexesFromArray(this.tallestCellInRow[cmd.sheetId], cmd.elements); this.history.update("tallestCellInRow", cmd.sheetId, tallestCells); break; } case "ADD_COLUMNS_ROWS": { if (cmd.dimension === "COL") { return; } const addIndex = getAddHeaderStartIndex(cmd.position, cmd.base); const newCells = Array(cmd.quantity).fill(undefined); const newTallestCells = insertItemsAtIndex(this.tallestCellInRow[cmd.sheetId], newCells, addIndex); this.history.update("tallestCellInRow", cmd.sheetId, newTallestCells); break; } case "RESIZE_COLUMNS_ROWS": { const sheetId = cmd.sheetId; if (cmd.dimension === "ROW") { for (const row of cmd.elements) { const tallestCell = this.getRowTallestCell(sheetId, row); this.history.update("tallestCellInRow", sheetId, row, tallestCell); } } else { // Recompute row heights on col size change, they might have changed because of wrapped text for (const row of range(0, this.getters.getNumberRows(sheetId))) { for (const col of cmd.elements) { this.updateRowSizeForCellChange(sheetId, row, col); } } } } break; case "UPDATE_CELL": this.updateRowSizeForCellChange(cmd.sheetId, cmd.row, cmd.col); break; case "ADD_MERGE": case "REMOVE_MERGE": for (const target of cmd.target) { for (const position of positions(target)) { this.updateRowSizeForCellChange(cmd.sheetId, position.row, position.col); } } } return; } getRowSize(sheetId, row) { return Math.round(this.getters.getUserRowSize(sheetId, row) ?? this.tallestCellInRow[sheetId][row]?.size ?? DEFAULT_CELL_HEIGHT); } getHeaderSize(sheetId, dimension, index) { if (this.getters.isHeaderHidden(sheetId, dimension, index)) { return 0; } return dimension === "ROW" ? this.getRowSize(sheetId, index) : this.getters.getColSize(sheetId, index); } updateRowSizeForCellChange(sheetId, row, col) { const tallestCellInRow = this.tallestCellInRow[sheetId]?.[row]; if (tallestCellInRow?.cell.col === col) { const newTallestCell = this.getRowTallestCell(sheetId, row); this.history.update("tallestCellInRow", sheetId, row, newTallestCell); } const updatedCellHeight = this.getCellHeight({ sheetId, col, row }); if (updatedCellHeight <= DEFAULT_CELL_HEIGHT) { return; } if ((!tallestCellInRow && updatedCellHeight > DEFAULT_CELL_HEIGHT) || (tallestCellInRow && updatedCellHeight > tallestCellInRow.size)) { const newTallestCell = { cell: { sheetId, col, row }, size: updatedCellHeight }; this.history.update("tallestCellInRow", sheetId, row, newTallestCell); } } initializeSheet(sheetId) { const tallestCells = []; for (let row = 0; row < this.getters.getNumberRows(sheetId); row++) { const tallestCell = this.getRowTallestCell(sheetId, row); tallestCells.push(tallestCell); } this.history.update("tallestCellInRow", sheetId, tallestCells); } /** * Return the height the cell should have in the sheet, which is either DEFAULT_CELL_HEIGHT if the cell is in a multi-row * merge, or the height of the cell computed based on its style/content. */ getCellHeight(position) { if (this.isInMultiRowMerge(position)) { return DEFAULT_CELL_HEIGHT; } const cell = this.getters.getCell(position); const colSize = this.getters.getColSize(position.sheetId, position.col); return getDefaultCellHeight(this.ctx, cell, colSize); } isInMultiRowMerge(position) { const merge = this.getters.getMerge(position); return !!merge && merge.bottom !== merge.top; } /** * Get the tallest cell of a row and its size. */ getRowTallestCell(sheetId, row) { const userRowSize = this.getters.getUserRowSize(sheetId, row); if (userRowSize !== undefined) { return undefined; } const cellIds = this.getters.getRowCells(sheetId, row); let maxHeight = 0; let tallestCell = undefined; for (let i = 0; i < cellIds.length; i++) { const cell = this.getters.getCellById(cellIds[i]); if (!cell) { continue; } const position = this.getters.getCellPosition(cell.id); const cellHeight = this.getCellHeight(position); if (cellHeight > maxHeight && cellHeight > DEFAULT_CELL_HEIGHT) { maxHeight = cellHeight; tallestCell = { cell: position, size: cellHeight }; } } if (tallestCell && tallestCell.size > DEFAULT_CELL_HEIGHT) { return tallestCell; } return undefined; } } const PERCENT_FORMAT = "0.00%"; /** * Dynamically creates a presentation layer wrapper around a given pivot class. * * It allows to implement additional behaviors and features that can be applied * to all pivots, regardless of the specific pivot implementation. * Examples of such features include calculated measures or "Show value as" options. */ function withPivotPresentationLayer (PivotClass) { class PivotPresentationLayer extends PivotClass { getters; cache = {}; rankAsc = {}; rankDesc = {}; runningTotal = {}; runningTotalInPercent = {}; constructor(custom, params) { super(custom, params); this.getters = params.getters; } init(params) { this.cache = {}; this.rankAsc = {}; this.rankDesc = {}; this.runningTotal = {}; this.runningTotalInPercent = {}; super.init(params); } getPivotCellValueAndFormat(measureName, domain) { return this.getMeasureDisplayValue(measureName, domain); } _getPivotCellValueAndFormat(measureName, domain) { const cacheKey = `${measureName}-${domain .map((node) => node.field + "=" + node.value) .join(",")}`; if (this.cache[cacheKey]) { return this.cache[cacheKey]; } const measure = this.getMeasure(measureName); const result = measure.computedBy ? this.computeMeasure(measure, domain) : super.getPivotCellValueAndFormat(measureName, domain); if (measure.format) { this.cache[cacheKey] = { ...result, format: measure.format }; } else { this.cache[cacheKey] = result; } return this.cache[cacheKey]; } computeMeasure(measure, domain) { if (!measure.computedBy) { return { value: 0 }; } const { columns, rows } = super.definition; if (columns.length + rows.length !== domain.length) { const values = this.getValuesToAggregate(measure, domain); const aggregator = AGGREGATORS_FN[measure.aggregator]; if (!aggregator) { return { value: 0 }; } try { return aggregator([values], this.getters.getLocale()); } catch (error) { return handleError(error, measure.aggregator.toUpperCase()); } } const formula = this.getters.getMeasureCompiledFormula(measure); const getSymbolValue = (symbolName) => { const { columns, rows } = this.definition; if (columns.find((col) => col.nameWithGranularity === symbolName)) { const { colDomain } = domainToColRowDomain(this, domain); const symbolIndex = colDomain.findIndex((node) => node.field === symbolName); return this.getPivotHeaderValueAndFormat(colDomain.slice(0, symbolIndex + 1)); } if (rows.find((row) => row.nameWithGranularity === symbolName)) { const { rowDomain } = domainToColRowDomain(this, domain); const symbolIndex = rowDomain.findIndex((row) => row.field === symbolName); return this.getPivotHeaderValueAndFormat(rowDomain.slice(0, symbolIndex + 1)); } return this._getPivotCellValueAndFormat(symbolName, domain); }; const result = this.getters.evaluateCompiledFormula(measure.computedBy.sheetId, formula, getSymbolValue); if (isMatrix(result)) { return result[0][0]; } return result; } getValuesToAggregate(measure, domain) { const { rowDomain, colDomain } = domainToColRowDomain(this, domain); const table = this.getTableStructure(); const values = []; if (colDomain.length === 0 && rowDomain.length < this.definition.rows.length && this.definition.rows.length && this.definition.columns.length) { const colDomains = this.treeToLeafDomains(table.getColTree()); const rowSubTree = this.getSubTreeMatchingDomain(table.getRowTree(), rowDomain); const rowDomains = this.treeToLeafDomains(rowSubTree); for (const colDomain of colDomains) { for (const subRowDomain of rowDomains) { values.push(this._getPivotCellValueAndFormat(measure.id, rowDomain.concat(subRowDomain).concat(colDomain))); } } return values; } else if (rowDomain.length === this.definition.rows.length && colDomain.length === 0) { // aggregate a row in the last column const tree = table.getColTree(); const subTree = this.getSubTreeMatchingDomain(tree, colDomain); const domains = this.treeToLeafDomains(subTree, colDomain); for (const domain of domains) { values.push(this._getPivotCellValueAndFormat(measure.id, rowDomain.concat(domain))); } return values; } else { const tree = table.getRowTree(); const subTree = this.getSubTreeMatchingDomain(tree, rowDomain); const domains = this.treeToLeafDomains(subTree, rowDomain); for (const domain of domains) { values.push(this._getPivotCellValueAndFormat(measure.id, domain.concat(colDomain))); } return values; } } getSubTreeMatchingDomain(tree, domain, domainLevel = 0) { if (domainLevel > domain.length) { return []; } if (domain.length === domainLevel) { return tree; } for (const node of tree) { const dimension = this.definition.getDimension(node.field); const normalizedValue = toNormalizedPivotValue(dimension, domain[domainLevel]?.value); if (node.field === domain[domainLevel]?.field && node.value === normalizedValue) { return this.getSubTreeMatchingDomain(node.children, domain, domainLevel + 1); } } return tree; } treeToLeafDomains(tree, parentDomain = []) { const domains = []; for (const node of tree) { const dimension = this.definition.getDimension(node.field); const nodeDomain = [ ...parentDomain, { field: node.field, value: node.value, type: dimension.type }, ]; if (node.children.length === 0) { domains.push(nodeDomain); } else { domains.push(...this.treeToLeafDomains(node.children, nodeDomain)); } } return domains; } getMeasureDisplayValue(measureId, domain) { const measure = this.getMeasure(measureId); const rawValue = this._getPivotCellValueAndFormat(measureId, domain); if (!measure.display || measure.display.type === "no_calculations" || rawValue.message) { return rawValue; } const fieldName = measure.display.fieldNameWithGranularity; if (fieldName && !this.isFieldInPivot(fieldName)) { return { value: CellErrorType.NotAvailable, message: _t('Field "%s" not found in pivot for measure display calculation', fieldName), }; } try { const display = measure.display; switch (display.type) { case "%_of_grand_total": return this.asPercentOfGrandTotal(rawValue, measure); case "%_of_col_total": return this.asPercentOfColumnTotal(rawValue, measure, domain); case "%_of_row_total": return this.asPercentOfRowTotal(rawValue, measure, domain); case "%_of_parent_row_total": return this.asPercentOfParentRowTotal(rawValue, measure, domain); case "%_of_parent_col_total": return this.asPercentOfParentColumnTotal(rawValue, measure, domain); case "index": return this.asIndex(rawValue, measure, domain); case "%_of_parent_total": return this.asPercentOfParentTotal(rawValue, measure, domain, display); case "running_total": return this.asRunningTotal(rawValue, measure, domain, display, "running_total"); case "%_running_total": return this.asRunningTotal(rawValue, measure, domain, display, "%_running_total"); case "rank_asc": return this.asRank(rawValue, measure, domain, display, "asc"); case "rank_desc": return this.asRank(rawValue, measure, domain, display, "desc"); case "%_of": return this.asPercentOf(rawValue, measure, domain, display); case "difference_from": return this.asDifferenceFrom(rawValue, measure, domain, display); case "%_difference_from": return this.asDifferenceFromInPercent(rawValue, measure, domain, display); } return rawValue; } catch (e) { return handleError(e, "COMPUTE_MEASURE_DISPLAY_VALUE"); } } asPercentOfGrandTotal(rawValue, measure) { const grandTotal = this.getGrandTotal(measure.id); return grandTotal === 0 ? { value: CellErrorType.DivisionByZero } : { value: this.measureValueToNumber(rawValue) / grandTotal, format: PERCENT_FORMAT }; } asPercentOfRowTotal(rawValue, measure, domain) { const rowTotal = this.getRowTotal(measure.id, domain); return rowTotal === 0 ? { value: CellErrorType.DivisionByZero } : { value: this.measureValueToNumber(rawValue) / rowTotal, format: PERCENT_FORMAT }; } asPercentOfColumnTotal(rawValue, measure, domain) { const columnTotal = this.getColumnTotal(measure.id, domain); return columnTotal === 0 ? { value: CellErrorType.DivisionByZero } : { value: this.measureValueToNumber(rawValue) / columnTotal, format: PERCENT_FORMAT }; } asPercentOfParentRowTotal(rawValue, measure, domain) { const parentRowDomain = getDomainOfParentRow(this, domain); const parentRowValue = this.measureValueToNumber(this._getPivotCellValueAndFormat(measure.id, parentRowDomain)); return parentRowValue === 0 ? { value: "" } : { value: this.measureValueToNumber(rawValue) / parentRowValue, format: PERCENT_FORMAT }; } asPercentOfParentColumnTotal(rawValue, measure, domain) { const parentColumnDomain = getDomainOfParentCol(this, domain); const parentColValue = this.measureValueToNumber(this._getPivotCellValueAndFormat(measure.id, parentColumnDomain)); return parentColValue === 0 ? { value: "" } : { value: this.measureValueToNumber(rawValue) / parentColValue, format: PERCENT_FORMAT }; } asPercentOfParentTotal(rawValue, measure, domain, display) { const { fieldNameWithGranularity } = display; if (!fieldNameWithGranularity) { return rawValue; } if (!isFieldInDomain(fieldNameWithGranularity, domain)) { return { value: "" }; } const parentDomain = getFieldParentDomain(this, fieldNameWithGranularity, domain); const parentTotal = this._getPivotCellValueAndFormat(measure.id, parentDomain); const parentTotalValue = this.measureValueToNumber(parentTotal); return parentTotalValue === 0 ? { value: "" } : { value: this.measureValueToNumber(rawValue) / parentTotalValue, format: PERCENT_FORMAT }; } asIndex(rawValue, measure, domain) { const value = this.measureValueToNumber(rawValue); const parentRowTotal = this.getRowTotal(measure.id, domain); const parentColTotal = this.getColumnTotal(measure.id, domain); const grandTotal = this.getGrandTotal(measure.id); return parentRowTotal === 0 || parentColTotal === 0 ? { value: CellErrorType.DivisionByZero } : { value: (value * grandTotal) / (parentColTotal * parentRowTotal), format: undefined }; } asRunningTotal(rawValue, measure, domain, display, mode) { const { fieldNameWithGranularity } = display; if (!fieldNameWithGranularity) { return rawValue; } const totalCache = mode === "running_total" ? this.runningTotal : this.runningTotalInPercent; let runningTotals = totalCache[measure.id]?.[fieldNameWithGranularity]; if (!runningTotals) { runningTotals = this.computeRunningTotal(measure, fieldNameWithGranularity, mode); if (!totalCache[measure.id]) { totalCache[measure.id] = {}; } totalCache[measure.id][fieldNameWithGranularity] = runningTotals; } const { rowDomain, colDomain } = domainToColRowDomain(this, domain); const colDomainKey = domainToString(colDomain); const rowDomainKey = domainToString(rowDomain); const runningTotal = runningTotals[colDomainKey]?.[rowDomainKey]; return { value: runningTotal ?? "", format: mode === "running_total" ? rawValue.format : PERCENT_FORMAT, }; } asPercentOf(rawValue, measure, domain, display) { const { fieldNameWithGranularity, value } = display; if (value === undefined || !fieldNameWithGranularity) { return rawValue; } if (!isFieldInDomain(fieldNameWithGranularity, domain)) { return { value: "" }; } let comparedValue = this.getComparisonValue(measure, domain, fieldNameWithGranularity, value); let numberValue = this.strictMeasureValueToNumber(rawValue); if (comparedValue === 0 || (comparedValue === "sameValue" && numberValue === 0)) { return { value: CellErrorType.DivisionByZero }; } else if (!comparedValue || (comparedValue === "sameValue" && !numberValue)) { return { value: "" }; } else if (comparedValue === "sameValue") { return { value: 1, format: PERCENT_FORMAT }; } else if (numberValue === undefined) { return { value: CellErrorType.NullError }; } return { value: numberValue / comparedValue, format: PERCENT_FORMAT }; } asDifferenceFrom(rawValue, measure, domain, display) { const { fieldNameWithGranularity, value } = display; if (value === undefined || !fieldNameWithGranularity) { return rawValue; } if (!isFieldInDomain(fieldNameWithGranularity, domain)) { return { value: "" }; } const comparedValue = this.getComparisonValue(measure, domain, fieldNameWithGranularity, value) || 0; return comparedValue === "sameValue" ? { value: "" } : { value: this.measureValueToNumber(rawValue) - comparedValue, format: rawValue.format, }; } asDifferenceFromInPercent(rawValue, measure, domain, display) { const { fieldNameWithGranularity, value } = display; if (value === undefined || !fieldNameWithGranularity) { return rawValue; } if (!isFieldInDomain(fieldNameWithGranularity, domain)) { return { value: "" }; } let comparedValue = this.getComparisonValue(measure, domain, fieldNameWithGranularity, value); const numberValue = this.strictMeasureValueToNumber(rawValue); if (comparedValue === 0) { return { value: CellErrorType.DivisionByZero }; } else if (!comparedValue || comparedValue === "sameValue") { return { value: "" }; } else if (numberValue === undefined) { return { value: CellErrorType.NullError }; } return { value: (numberValue - comparedValue) / comparedValue, format: PERCENT_FORMAT }; } asRank(rawValue, measure, domain, display, order) { const { fieldNameWithGranularity } = display; if (!fieldNameWithGranularity) { return rawValue; } if (!isFieldInDomain(fieldNameWithGranularity, domain)) { return { value: "" }; } const rankingCache = order === "asc" ? this.rankAsc : this.rankDesc; let ranking = rankingCache[measure.id]?.[fieldNameWithGranularity]; if (!ranking) { ranking = this.computeRank(measure, fieldNameWithGranularity, order); if (!rankingCache[measure.id]) { rankingCache[measure.id] = {}; } rankingCache[measure.id][fieldNameWithGranularity] = ranking; } const { rowDomain, colDomain } = domainToColRowDomain(this, domain); const colDomainKey = domainToString(colDomain); const rowDomainKey = domainToString(rowDomain); const rank = ranking[colDomainKey]?.[rowDomainKey]; return { value: rank ?? "" }; } computeRank(measure, fieldNameWithGranularity, order) { const ranking = {}; const mainDimension = getFieldDimensionType(this, fieldNameWithGranularity); const secondaryDimension = mainDimension === "row" ? "column" : "row"; let pivotCells = this.getPivotValueCells(measure.id); if (mainDimension === "column") { // Transpose the pivot cells so we can do the same operations on the columns as on the rows // This means that we need to transpose back the ranking at the end pivotCells = transposeMatrix(pivotCells); } for (const col of pivotCells) { const colDomain = getDimensionDomain(this, secondaryDimension, col[0].domain); const colDomainKey = domainToString(colDomain); const cells = col .map((cell) => ({ ...cell, value: this.strictMeasureValueToNumber(this._getPivotCellValueAndFormat(measure.id, cell.domain)), rowDomain: getDimensionDomain(this, mainDimension, cell.domain), })) .filter((cell) => isFieldInDomain(fieldNameWithGranularity, cell.rowDomain)); // Group the cells by ranking domain, and sort them to get the ranking const groupedCell = Object.groupBy(cells, (cell) => getRankingDomainKey(cell.rowDomain, fieldNameWithGranularity)); for (const rankingDomainKey in groupedCell) { groupedCell[rankingDomainKey] = removeDuplicates$1(groupedCell[rankingDomainKey] || [], (cell) => cell.value) .filter((cell) => cell.value !== undefined) .sort((a, b) => (order === "asc" ? a.value - b.value : b.value - a.value)); } ranking[colDomainKey] = {}; for (const cell of cells) { const rowDomain = getDimensionDomain(this, mainDimension, cell.domain); const rowDomainKey = domainToString(rowDomain); const rankingDomainKey = getRankingDomainKey(cell.rowDomain, fieldNameWithGranularity); const group = groupedCell[rankingDomainKey]; if (!group) { continue; } const rank = group.findIndex((c) => c.value === cell.value); if (rank !== -1) { ranking[colDomainKey][rowDomainKey] = rank + 1; // Ranks start at 1 } } } return mainDimension === "row" ? ranking : transpose2dPOJO(ranking); } computeRunningTotal(measure, fieldNameWithGranularity, mode) { const cellsRunningTotals = {}; const mainDimension = getFieldDimensionType(this, fieldNameWithGranularity); const secondaryDimension = mainDimension === "row" ? "column" : "row"; let pivotCells = this.getPivotValueCells(measure.id); if (mainDimension === "column") { // Transpose the pivot cells so we can do the same operations on the columns as on the rows // This means that we need to transpose back the totals at the end pivotCells = transposeMatrix(pivotCells); } for (const col of pivotCells) { const colDomain = getDimensionDomain(this, secondaryDimension, col[0].domain); const colDomainKey = domainToString(colDomain); cellsRunningTotals[colDomainKey] = {}; const runningTotals = {}; const cellsWithValue = col .map((cell) => ({ ...cell, rowDomain: getDimensionDomain(this, mainDimension, cell.domain), value: this.measureValueToNumber(this._getPivotCellValueAndFormat(measure.id, cell.domain)), })) .filter((cell) => isFieldInDomain(fieldNameWithGranularity, cell.rowDomain)); for (const cell of cellsWithValue) { const rowDomainKey = domainToString(cell.rowDomain); const runningTotalKey = getRunningTotalDomainKey(cell.rowDomain, fieldNameWithGranularity); const runningTotal = (runningTotals[runningTotalKey] || 0) + cell.value; runningTotals[runningTotalKey] = runningTotal; cellsRunningTotals[colDomainKey][rowDomainKey] = runningTotal; } if (mode === "%_running_total") { for (const cell of cellsWithValue) { const rowDomain = cell.rowDomain; const rowDomainKey = domainToString(rowDomain); const runningTotalKey = getRunningTotalDomainKey(rowDomain, fieldNameWithGranularity); const cellRunningTotal = cellsRunningTotals[colDomainKey][rowDomainKey] || 0; const finalRunningTotal = runningTotals[runningTotalKey]; cellsRunningTotals[colDomainKey][rowDomainKey] = !finalRunningTotal ? undefined : cellRunningTotal / finalRunningTotal; } } } return mainDimension === "row" ? cellsRunningTotals : transpose2dPOJO(cellsRunningTotals); } getGrandTotal(measureId) { const grandTotal = this._getPivotCellValueAndFormat(measureId, []); return this.measureValueToNumber(grandTotal); } getRowTotal(measureId, domain) { const totalDomain = domainToColRowDomain(this, domain).rowDomain; const rowTotal = this._getPivotCellValueAndFormat(measureId, totalDomain); return this.measureValueToNumber(rowTotal); } getColumnTotal(measureId, domain) { const totalDomain = domainToColRowDomain(this, domain).colDomain; const columnTotal = this._getPivotCellValueAndFormat(measureId, totalDomain); return this.measureValueToNumber(columnTotal); } isFieldInPivot(nameWithGranularity) { return (this.definition.columns.some((c) => c.nameWithGranularity === nameWithGranularity) || this.definition.rows.some((r) => r.nameWithGranularity === nameWithGranularity)); } /** * With the given measure, fetch the value of the cell in the pivot that has the given domain with * the value of the field `fieldNameWithGranularity` replaced by `valueToCompare`. * * @param valueToCompare either a value to replace the field value with, or "(previous)" or "(next)" * @returns the value of the cell in the pivot with the new domain, or "sameValue" if the domain is the same */ getComparisonValue(measure, domain, fieldNameWithGranularity, valueToCompare) { const comparedDomain = valueToCompare === PREVIOUS_VALUE || valueToCompare === NEXT_VALUE ? getPreviousOrNextValueDomain(this, domain, fieldNameWithGranularity, valueToCompare) : replaceFieldValueInDomain(domain, fieldNameWithGranularity, valueToCompare); if (deepEquals(comparedDomain, domain)) { return "sameValue"; } if (!comparedDomain || !isDomainIsInPivot(this, comparedDomain)) { throw new NotAvailableError(); } const comparedValue = this._getPivotCellValueAndFormat(measure.id, comparedDomain); const comparedValueNumber = this.strictMeasureValueToNumber(comparedValue); return comparedValueNumber; } getPivotValueCells(measureId) { return this.getTableStructure() .getPivotCells() .map((col) => col.filter((cell) => cell.type === "VALUE" && cell.measure === measureId)) .filter((col) => col.length > 0); } measureValueToNumber(result) { if (typeof result.value === "number") { return result.value; } if (!result.value) { return 0; } // Should not happen, measures aggregates are always numbers or undefined throw new Error(`Value ${result.value} is not a number`); } strictMeasureValueToNumber(result) { if (typeof result.value === "number") { return result.value; } if (!result.value) { return undefined; } throw new Error(`Value ${result.value} is not a number`); } } return PivotPresentationLayer; } const UNDO_REDO_PIVOT_COMMANDS = ["ADD_PIVOT", "UPDATE_PIVOT"]; function isPivotCommand(cmd) { return UNDO_REDO_PIVOT_COMMANDS.includes(cmd.type); } class PivotUIPlugin extends UIPlugin { static getters = [ "getPivot", "getFirstPivotFunction", "getPivotIdFromPosition", "getPivotCellFromPosition", "generateNewCalculatedMeasureName", "isPivotUnused", "isSpillPivotFormula", ]; pivots = {}; unusedPivots; custom; constructor(config) { super(config); this.custom = config.custom; } beforeHandle(cmd) { switch (cmd.type) { case "START": for (const pivotId of this.getters.getPivotIds()) { this.setupPivot(pivotId); } } } handle(cmd) { if (invalidateEvaluationCommands.has(cmd.type)) { for (const pivotId of this.getters.getPivotIds()) { if (!pivotRegistry.get(this.getters.getPivotCoreDefinition(pivotId).type).externalData) { this.setupPivot(pivotId, { recreate: true }); } } } switch (cmd.type) { case "REFRESH_PIVOT": this.refreshPivot(cmd.id); break; case "ADD_PIVOT": { this.setupPivot(cmd.pivotId); break; } case "DUPLICATE_PIVOT": { this.setupPivot(cmd.newPivotId); break; } case "UPDATE_PIVOT": { this.setupPivot(cmd.pivotId, { recreate: true }); break; } case "DELETE_SHEET": case "UPDATE_CELL": { this.unusedPivots = undefined; break; } case "UNDO": case "REDO": { this.unusedPivots = undefined; const pivotCommands = cmd.commands.filter(isPivotCommand); for (const cmd of pivotCommands) { const pivotId = cmd.pivotId; if (!this.getters.isExistingPivot(pivotId)) { continue; } this.setupPivot(pivotId, { recreate: true }); } break; } case "UPDATE_LOCALE": /** * Reset the cache of the date/datetime pivot values, as it depends on * the locale. (e.g. the first day of the week) */ resetMapValueDimensionDate(); break; } } // --------------------------------------------------------------------- // Getters // --------------------------------------------------------------------- /** * Get the id of the pivot at the given position. Returns undefined if there * is no pivot at this position */ getPivotIdFromPosition(position) { const cell = this.getters.getCorrespondingFormulaCell(position); if (cell && cell.isFormula) { const pivotFunction = this.getFirstPivotFunction(position.sheetId, cell.compiledFormula.tokens); if (pivotFunction) { const pivotId = pivotFunction.args[0]?.toString(); return pivotId && this.getters.getPivotId(pivotId); } } return undefined; } isSpillPivotFormula(position) { const cell = this.getters.getCorrespondingFormulaCell(position); if (cell && cell.isFormula) { const pivotFunction = this.getFirstPivotFunction(position.sheetId, cell.compiledFormula.tokens); return pivotFunction?.functionName === "PIVOT"; } return false; } getFirstPivotFunction(sheetId, tokens) { const pivotFunction = getFirstPivotFunction(tokens); if (!pivotFunction) { return undefined; } const { functionName, args } = pivotFunction; const evaluatedArgs = args.map((argAst) => { if (argAst.type == "EMPTY") { return undefined; } else if (argAst.type === "STRING" || argAst.type === "BOOLEAN" || argAst.type === "NUMBER") { return argAst.value; } const argsString = astToFormula(argAst); return this.getters.evaluateFormula(sheetId, argsString); }); return { functionName, args: evaluatedArgs }; } /** * Returns the domain args of a pivot formula from a position. * For all those formulas: * * =PIVOT.VALUE(1,"expected_revenue","stage_id",2,"city","Brussels") * =PIVOT.HEADER(1,"stage_id",2,"city","Brussels") * =PIVOT.HEADER(1,"stage_id",2,"city","Brussels","measure","expected_revenue") * * the result is the same: ["stage_id", 2, "city", "Brussels"] * * If the cell is the result of PIVOT, the result is the domain of the cell * as if it was the individual pivot formula */ getPivotCellFromPosition(position) { const cell = this.getters.getCorrespondingFormulaCell(position); if (!cell || !cell.isFormula || getNumberOfPivotFunctions(cell.compiledFormula.tokens) === 0) { return EMPTY_PIVOT_CELL; } const mainPosition = this.getters.getCellPosition(cell.id); const result = this.getters.getFirstPivotFunction(position.sheetId, cell.compiledFormula.tokens); if (!result) { return EMPTY_PIVOT_CELL; } const { functionName, args } = result; const formulaId = args[0]; if (!formulaId) { return EMPTY_PIVOT_CELL; } const pivotId = this.getters.getPivotId(formulaId.toString()); if (!pivotId) { return EMPTY_PIVOT_CELL; } const pivot = this.getPivot(pivotId); if (!pivot.isValid()) { return EMPTY_PIVOT_CELL; } if (functionName === "PIVOT" && !cell.content.replaceAll(" ", "").toUpperCase().startsWith("=PIVOT")) { return EMPTY_PIVOT_CELL; } if (functionName === "PIVOT") { const includeTotal = toScalar(args[2]); const shouldIncludeTotal = includeTotal === undefined ? true : toBoolean(includeTotal); const includeColumnHeaders = toScalar(args[3]); const shouldIncludeColumnHeaders = includeColumnHeaders === undefined ? true : toBoolean(includeColumnHeaders); const pivotCells = pivot .getTableStructure() .getPivotCells(shouldIncludeTotal, shouldIncludeColumnHeaders); const pivotCol = position.col - mainPosition.col; const pivotRow = position.row - mainPosition.row; return pivotCells[pivotCol][pivotRow]; } try { if (functionName === "PIVOT.HEADER" && args.at(-2) === "measure") { const domain = pivot.parseArgsToPivotDomain(args.slice(1, -2).map((value) => ({ value }))); return { type: "MEASURE_HEADER", domain, measure: args.at(-1)?.toString() || "", }; } else if (functionName === "PIVOT.HEADER") { const domain = pivot.parseArgsToPivotDomain(args.slice(1).map((value) => ({ value }))); return { type: "HEADER", domain, }; } const [measure, ...domainArgs] = args.slice(1); const domain = pivot.parseArgsToPivotDomain(domainArgs.map((value) => ({ value }))); return { type: "VALUE", domain, measure: measure?.toString() || "", }; } catch (_) { return EMPTY_PIVOT_CELL; } } generateNewCalculatedMeasureName(measures) { const existingMeasures = measures.map((m) => m.fieldName); let i = 1; let name = _t("Calculated measure %s", i); while (existingMeasures.includes(name)) { i++; name = _t("Calculated measure %s", i); } return name; } getPivot(pivotId) { if (!this.getters.isExistingPivot(pivotId)) { throw new Error(`pivot ${pivotId} not found`); } return this.pivots[pivotId]; } isPivotUnused(pivotId) { return this._getUnusedPivots().includes(pivotId); } // --------------------------------------------------------------------- // Private // --------------------------------------------------------------------- /** * Refresh the cache of a pivot */ refreshPivot(pivotId) { const pivot = this.getters.getPivot(pivotId); pivot.init({ reload: true }); } setupPivot(pivotId, { recreate } = { recreate: false }) { const definition = this.getters.getPivotCoreDefinition(pivotId); if (!(pivotId in this.pivots)) { const Pivot = withPivotPresentationLayer(pivotRegistry.get(definition.type).ui); this.pivots[pivotId] = new Pivot(this.custom, { definition, getters: this.getters }); } else if (recreate) { this.pivots[pivotId].onDefinitionChange(definition); } } _getUnusedPivots() { if (this.unusedPivots !== undefined) { return this.unusedPivots; } const unusedPivots = new Set(this.getters.getPivotIds()); for (const sheetId of this.getters.getSheetIds()) { for (const cellId in this.getters.getCells(sheetId)) { const position = this.getters.getCellPosition(cellId); const pivotId = this.getPivotIdFromPosition(position); if (pivotId) { unusedPivots.delete(pivotId); if (!unusedPivots.size) { this.unusedPivots = []; return []; } } } } this.unusedPivots = [...unusedPivots]; return this.unusedPivots; } } /** * This plugin manage the autofill. * * The way it works is the next one: * For each line (row if the direction is left/right, col otherwise), we create * a "AutofillGenerator" object which is used to compute the cells to * autofill. * * When we need to autofill a cell, we compute the origin cell in the source. * EX: from A1:A2, autofill A3->A6. * Target | Origin cell * A3 | A1 * A4 | A2 * A5 | A1 * A6 | A2 * When we have the origin, we take the associated cell in the AutofillGenerator * and we apply the modifier (AutofillModifier) associated to the content of the * cell. */ /** * This class is used to generate the next values to autofill. * It's done from a selection (the source) and describe how the next values * should be computed. */ class AutofillGenerator { cells; getters; index = 0; direction; constructor(cells, getters, direction) { this.cells = cells; this.getters = getters; this.direction = direction; } /** * Get the next value to autofill */ next() { const genCell = this.cells[this.index++ % this.cells.length]; const rule = genCell.rule; const { cellData, tooltip } = autofillModifiersRegistry .get(rule.type) .apply(rule, genCell.data, this.getters, this.direction); return { cellData, tooltip, origin: { col: genCell.data.col, row: genCell.data.row, }, }; } } /** * Autofill Plugin * */ class AutofillPlugin extends UIPlugin { static layers = ["Autofill"]; static getters = ["getAutofillTooltip"]; autofillZone; steps; lastCellSelected = {}; direction; tooltip; // --------------------------------------------------------------------------- // Command Handling // --------------------------------------------------------------------------- allowDispatch(cmd) { switch (cmd.type) { case "AUTOFILL_SELECT": const sheetId = this.getters.getActiveSheetId(); this.lastCellSelected.col = cmd.col === -1 ? this.lastCellSelected.col : clip(cmd.col, 0, this.getters.getNumberCols(sheetId)); this.lastCellSelected.row = cmd.row === -1 ? this.lastCellSelected.row : clip(cmd.row, 0, this.getters.getNumberRows(sheetId)); if (this.lastCellSelected.col !== undefined && this.lastCellSelected.row !== undefined) { return "Success" /* CommandResult.Success */; } return "InvalidAutofillSelection" /* CommandResult.InvalidAutofillSelection */; } return "Success" /* CommandResult.Success */; } handle(cmd) { switch (cmd.type) { case "AUTOFILL": this.autofill(true); break; case "AUTOFILL_SELECT": this.select(cmd.col, cmd.row); break; case "AUTOFILL_AUTO": this.autofillAuto(); break; case "AUTOFILL_CELL": this.autoFillMerge(cmd.originCol, cmd.originRow, cmd.col, cmd.row); const sheetId = this.getters.getActiveSheetId(); this.dispatch("UPDATE_CELL", { sheetId, col: cmd.col, row: cmd.row, style: cmd.style || null, content: cmd.content || "", format: cmd.format || "", }); this.dispatch("SET_BORDER", { sheetId, col: cmd.col, row: cmd.row, border: cmd.border, }); this.autofillCF(cmd.originCol, cmd.originRow, cmd.col, cmd.row); this.autofillDV(cmd.originCol, cmd.originRow, cmd.col, cmd.row); } } // --------------------------------------------------------------------------- // Getters // --------------------------------------------------------------------------- getAutofillTooltip() { return this.tooltip; } // --------------------------------------------------------------------------- // Private methods // --------------------------------------------------------------------------- /** * Autofill the autofillZone from the current selection * @param apply Flag set to true to apply the autofill in the model. It's * useful to set it to false when we need to fill the tooltip */ autofill(apply) { if (!this.autofillZone || !this.steps || this.direction === undefined) { this.tooltip = undefined; return; } const source = this.getters.getSelectedZone(); const target = this.autofillZone; switch (this.direction) { case "down" /* DIRECTION.DOWN */: for (let col = source.left; col <= source.right; col++) { const xcs = []; for (let row = source.top; row <= source.bottom; row++) { xcs.push(toXC(col, row)); } const generator = this.createGenerator(xcs); for (let row = target.top; row <= target.bottom; row++) { this.computeNewCell(generator, col, row, apply); } } break; case "up" /* DIRECTION.UP */: for (let col = source.left; col <= source.right; col++) { const xcs = []; for (let row = source.bottom; row >= source.top; row--) { xcs.push(toXC(col, row)); } const generator = this.createGenerator(xcs); for (let row = target.bottom; row >= target.top; row--) { this.computeNewCell(generator, col, row, apply); } } break; case "left" /* DIRECTION.LEFT */: for (let row = source.top; row <= source.bottom; row++) { const xcs = []; for (let col = source.right; col >= source.left; col--) { xcs.push(toXC(col, row)); } const generator = this.createGenerator(xcs); for (let col = target.right; col >= target.left; col--) { this.computeNewCell(generator, col, row, apply); } } break; case "right" /* DIRECTION.RIGHT */: for (let row = source.top; row <= source.bottom; row++) { const xcs = []; for (let col = source.left; col <= source.right; col++) { xcs.push(toXC(col, row)); } const generator = this.createGenerator(xcs); for (let col = target.left; col <= target.right; col++) { this.computeNewCell(generator, col, row, apply); } } break; } if (apply) { this.autofillZone = undefined; this.selection.resizeAnchorZone(this.direction, this.steps); this.lastCellSelected = {}; this.direction = undefined; this.steps = 0; this.tooltip = undefined; } } /** * Select a cell which becomes the last cell of the autofillZone */ select(col, row) { const source = this.getters.getSelectedZone(); if (isInside(col, row, source)) { this.autofillZone = undefined; return; } this.direction = this.getDirection(col, row); switch (this.direction) { case "up" /* DIRECTION.UP */: this.saveZone(row, source.top - 1, source.left, source.right); this.steps = source.top - row; break; case "down" /* DIRECTION.DOWN */: this.saveZone(source.bottom + 1, row, source.left, source.right); this.steps = row - source.bottom; break; case "left" /* DIRECTION.LEFT */: this.saveZone(source.top, source.bottom, col, source.left - 1); this.steps = source.left - col; break; case "right" /* DIRECTION.RIGHT */: this.saveZone(source.top, source.bottom, source.right + 1, col); this.steps = col - source.right; break; } this.autofill(false); } /** * Computes the autofillZone to autofill when the user double click on the * autofiller */ autofillAuto() { const activePosition = this.getters.getActivePosition(); const table = this.getters.getTable(activePosition); let autofillRow = table ? table.range.zone.bottom : this.getAutofillAutoLastRow(); // Stop autofill at the next non-empty cell const selection = this.getters.getSelectedZone(); for (let row = selection.bottom + 1; row <= autofillRow; row++) { if (this.getters.getEvaluatedCell({ ...activePosition, row }).type !== CellValueType.empty) { autofillRow = row - 1; break; } } if (autofillRow > selection.bottom) { this.select(activePosition.col, autofillRow); this.autofill(true); } } getAutofillAutoLastRow() { const zone = this.getters.getSelectedZone(); const sheetId = this.getters.getActiveSheetId(); let col = zone.left; let row = zone.bottom; if (col > 0) { let leftPosition = { sheetId, col: col - 1, row }; while (this.getters.getEvaluatedCell(leftPosition).type !== CellValueType.empty) { row += 1; leftPosition = { sheetId, col: col - 1, row }; } } if (row === zone.bottom) { col = zone.right; if (col <= this.getters.getNumberCols(sheetId)) { let rightPosition = { sheetId, col: col + 1, row }; while (this.getters.getEvaluatedCell(rightPosition).type !== CellValueType.empty) { row += 1; rightPosition = { sheetId, col: col + 1, row }; } } } return row - 1; } /** * Generate the next cell */ computeNewCell(generator, col, row, apply) { const { cellData, tooltip, origin } = generator.next(); const { content, style, border, format } = cellData; this.tooltip = tooltip; if (apply) { this.dispatch("AUTOFILL_CELL", { originCol: origin.col, originRow: origin.row, col, row, content, style, border, format, }); } } /** * Get the rule associated to the current cell */ getRule(cell, cells) { const rules = autofillRulesRegistry.getAll().sort((a, b) => a.sequence - b.sequence); const rule = rules.find((rule) => rule.condition(cell, cells)); return rule && rule.generateRule(cell, cells); } /** * Create the generator to be able to autofill the next cells. */ createGenerator(source) { const nextCells = []; const cellsData = []; const sheetId = this.getters.getActiveSheetId(); for (let xc of source) { const { col, row } = toCartesian(xc); const cell = this.getters.getCell({ sheetId, col, row }); cellsData.push({ col, row, cell, sheetId, }); } const cells = cellsData.map((cellData) => cellData.cell); for (let cellData of cellsData) { let rule = { type: "COPY_MODIFIER" }; if (cellData && cellData.cell) { const newRule = this.getRule(cellData.cell, cells); rule = newRule || rule; } const border = this.getters.getCellBorder(cellData) || undefined; nextCells.push({ data: { ...cellData, border }, rule, }); } return new AutofillGenerator(nextCells, this.getters, this.direction); } saveZone(top, bottom, left, right) { this.autofillZone = { top, bottom, left, right }; } /** * Compute the direction of the autofill from the last selected zone and * a given cell (col, row) */ getDirection(col, row) { const source = this.getters.getSelectedZone(); const position = { up: { number: source.top - row, value: "up" /* DIRECTION.UP */ }, down: { number: row - source.bottom, value: "down" /* DIRECTION.DOWN */ }, left: { number: source.left - col, value: "left" /* DIRECTION.LEFT */ }, right: { number: col - source.right, value: "right" /* DIRECTION.RIGHT */ }, }; if (Object.values(position) .map((x) => (x.number > 0 ? 1 : 0)) .reduce((acc, value) => acc + value) === 1) { return Object.values(position).find((x) => (x.number > 0 ? 1 : 0)).value; } const first = position.up.number > 0 ? "up" : "down"; const second = position.left.number > 0 ? "left" : "right"; return Math.abs(position[first].number) >= Math.abs(position[second].number) ? position[first].value : position[second].value; } autoFillMerge(originCol, originRow, col, row) { const sheetId = this.getters.getActiveSheetId(); const position = { sheetId, col, row }; const originPosition = { sheetId, col: originCol, row: originRow }; if (this.getters.isInMerge(position) && !this.getters.isInMerge(originPosition)) { const zone = this.getters.getMerge(position); if (zone) { this.dispatch("REMOVE_MERGE", { sheetId, target: [zone], }); } } const originMerge = this.getters.getMerge(originPosition); if (originMerge?.left === originCol && originMerge?.top === originRow) { this.dispatch("ADD_MERGE", { sheetId, target: [ { top: row, bottom: row + originMerge.bottom - originMerge.top, left: col, right: col + originMerge.right - originMerge.left, }, ], }); } } autofillCF(originCol, originRow, col, row) { const sheetId = this.getters.getActiveSheetId(); const cfOrigin = this.getters.getRulesByCell(sheetId, originCol, originRow); for (const cf of cfOrigin) { const newCfRanges = this.getters.getAdaptedCfRanges(sheetId, cf, [positionToZone({ col, row })], []); if (newCfRanges) { this.dispatch("ADD_CONDITIONAL_FORMAT", { cf: deepCopy(cf), ranges: newCfRanges, sheetId, }); } } } autofillDV(originCol, originRow, col, row) { const sheetId = this.getters.getActiveSheetId(); const cellPosition = { sheetId, col: originCol, row: originRow }; const dvOrigin = this.getters.getValidationRuleForCell(cellPosition); if (!dvOrigin) { return; } const dvRangesZones = dvOrigin.ranges.map((range) => range.zone); const newDvRanges = recomputeZones(dvRangesZones.concat(positionToZone({ col, row })), []); this.dispatch("ADD_DATA_VALIDATION_RULE", { rule: dvOrigin, ranges: newDvRanges.map((zone) => this.getters.getRangeDataFromZone(sheetId, zone)), sheetId, }); } // --------------------------------------------------------------------------- // Grid rendering // --------------------------------------------------------------------------- drawLayer(renderingContext) { if (!this.autofillZone) { return; } const { ctx, thinLineWidth } = renderingContext; const { x, y, width, height } = this.getters.getVisibleRect(this.autofillZone); if (width > 0 && height > 0) { ctx.strokeStyle = "black"; ctx.lineWidth = thinLineWidth; ctx.setLineDash([3]); ctx.strokeRect(x, y, width, height); ctx.setLineDash([]); } } } class AutomaticSumPlugin extends UIPlugin { static getters = ["getAutomaticSums"]; handle(cmd) { switch (cmd.type) { case "SUM_SELECTION": const sheetId = this.getters.getActiveSheetId(); const { zones, anchor } = this.getters.getSelection(); for (const zone of zones) { const sums = this.getAutomaticSums(sheetId, zone, anchor.cell); this.dispatchCellUpdates(sheetId, sums); } break; } } getAutomaticSums(sheetId, zone, anchor) { return this.shouldFindData(sheetId, zone) ? this.sumAdjacentData(sheetId, zone, anchor) : this.sumData(sheetId, zone); } // --------------------------------------------------------------------------- // Private methods // --------------------------------------------------------------------------- sumData(sheetId, zone) { const dimensions = this.dimensionsToSum(sheetId, zone); const sums = this.sumDimensions(sheetId, zone, dimensions).filter(({ zone }) => !this.getters.isEmpty(sheetId, zone)); if (dimensions.has("ROW") && dimensions.has("COL")) { sums.push(this.sumTotal(zone)); } return sums; } sumAdjacentData(sheetId, zone, anchor) { const { col, row } = isInside(anchor.col, anchor.row, zone) ? anchor : { col: zone.left, row: zone.top }; const dataZone = this.findAdjacentData(sheetId, col, row); if (!dataZone) { return []; } if (this.getters.isSingleCellOrMerge(sheetId, zone) || isOneDimensional(union(dataZone, zone))) { return [{ position: { col, row }, zone: dataZone }]; } else { return this.sumDimensions(sheetId, union(dataZone, zone), this.transpose(this.dimensionsToSum(sheetId, zone))); } } /** * Find a zone to automatically sum a column or row of numbers. * * We first decide which direction will be summed (column or row). * Here is the strategy: * 1. If the left cell is a number and the top cell is not: choose horizontal * 2. Try to find a valid vertical zone. If it's valid: choose vertical * 3. Try to find a valid horizontal zone. If it's valid: choose horizontal * 4. Otherwise, no zone is returned * * Now, how to find a valid zone? * The zone starts directly above or on the left of the starting point * (depending on the direction). * The zone ends where the first continuous sequence of numbers ends. * Empty or text cells can be part of the zone while no number has been found. * Other kind of cells (boolean, dates, etc.) are not valid in the zone and the * search stops immediately if one is found. * * ------- ------- * | 1 | | 1 | * ------- ------- * | | | | * ------- <= end of the sequence, stop here ------- * | 2 | | 2 | * ------- ------- * | 3 | <= start of the number sequence | 3 | * ------- ------- * | | <= ignored | FALSE | <= invalid, no zone is found * ------- ------- * | A | <= ignored | A | <= ignored * ------- ------- */ findAdjacentData(sheetId, col, row) { const sheet = this.getters.getSheet(sheetId); const mainCellPosition = this.getters.getMainCellPosition({ sheetId, col, row }); const zone = this.findSuitableZoneToSum(sheet, mainCellPosition.col, mainCellPosition.row); if (zone) { return this.getters.expandZone(sheetId, zone); } return undefined; } /** * Return the zone to sum if a valid one is found. * @see getAutomaticSumZone */ findSuitableZoneToSum(sheet, col, row) { const topCell = this.getters.getEvaluatedCell({ sheetId: sheet.id, col, row: row - 1 }); const leftCell = this.getters.getEvaluatedCell({ sheetId: sheet.id, col: col - 1, row }); if (this.isNumber(leftCell) && !this.isNumber(topCell)) { return this.findHorizontalZone(sheet, col, row); } const verticalZone = this.findVerticalZone(sheet, col, row); if (this.isZoneValid(verticalZone)) { return verticalZone; } const horizontalZone = this.findHorizontalZone(sheet, col, row); if (this.isZoneValid(horizontalZone)) { return horizontalZone; } return undefined; } findVerticalZone(sheet, col, row) { const zone = { top: 0, bottom: row - 1, left: col, right: col, }; const top = this.reduceZoneStart(sheet, zone, zone.bottom); return { ...zone, top }; } findHorizontalZone(sheet, col, row) { const zone = { top: row, bottom: row, left: 0, right: col - 1, }; const left = this.reduceZoneStart(sheet, zone, zone.right); return { ...zone, left }; } /** * Reduces a column or row zone to a valid zone for the automatic sum. * @see getAutomaticSumZone * @param sheet * @param zone one dimensional zone (a single row or a single column). The zone is * assumed to start at the beginning of the column (top=0) or the row (left=0) * @param end end index of the zone (`bottom` or `right` depending on the dimension) * @returns the starting position of the valid zone or Infinity if the zone is not valid. */ reduceZoneStart(sheet, zone, end) { const cells = this.getters.getEvaluatedCellsInZone(sheet.id, zone); const cellPositions = range(end, -1, -1); const invalidCells = cellPositions.filter((position) => cells[position] && !cells[position].isAutoSummable); const maxValidPosition = largeMax(invalidCells); const numberSequences = groupConsecutive(cellPositions.filter((position) => this.isNumber(cells[position]))); const firstSequence = numberSequences[0] || []; if (largeMax(firstSequence) < maxValidPosition) { return Infinity; } return largeMin(firstSequence); } shouldFindData(sheetId, zone) { return this.getters.isEmpty(sheetId, zone) || this.getters.isSingleCellOrMerge(sheetId, zone); } isNumber(cell) { return cell.type === CellValueType.number && !(cell.format && isDateTimeFormat(cell.format)); } isZoneValid(zone) { return zone.bottom >= zone.top && zone.right >= zone.left; } lastColIsEmpty(sheetId, zone) { return this.getters.isEmpty(sheetId, { ...zone, left: zone.right }); } lastRowIsEmpty(sheetId, zone) { return this.getters.isEmpty(sheetId, { ...zone, top: zone.bottom }); } /** * Decides which dimensions (columns or rows) should be summed * based on its shape and what's inside the zone. */ dimensionsToSum(sheetId, zone) { const dimensions = new Set(); if (isOneDimensional(zone)) { dimensions.add(zoneToDimension(zone).numberOfCols === 1 ? "COL" : "ROW"); return dimensions; } if (this.lastColIsEmpty(sheetId, zone)) { dimensions.add("ROW"); } if (this.lastRowIsEmpty(sheetId, zone)) { dimensions.add("COL"); } if (dimensions.size === 0) { dimensions.add("COL"); } return dimensions; } /** * Sum each column and/or row in the zone in the appropriate cells, * depending on the available space. */ sumDimensions(sheetId, zone, dimensions) { return [ ...(dimensions.has("COL") ? this.sumColumns(zone, sheetId) : []), ...(dimensions.has("ROW") ? this.sumRows(zone, sheetId) : []), ]; } /** * Sum the total of the zone in the bottom right cell, assuming * the last row contains summed columns. */ sumTotal(zone) { const { bottom, right } = zone; return { position: { col: right, row: bottom }, zone: { ...zone, top: bottom, right: right - 1 }, }; } sumColumns(zone, sheetId) { const target = this.nextEmptyRow(sheetId, { ...zone, bottom: zone.bottom - 1 }); zone = { ...zone, bottom: Math.min(zone.bottom, target.bottom - 1) }; return positions(target).map((position) => ({ position, zone: { ...zone, right: position.col, left: position.col }, })); } sumRows(zone, sheetId) { const target = this.nextEmptyCol(sheetId, { ...zone, right: zone.right - 1 }); zone = { ...zone, right: Math.min(zone.right, target.right - 1) }; return positions(target).map((position) => ({ position, zone: { ...zone, top: position.row, bottom: position.row }, })); } dispatchCellUpdates(sheetId, sums) { for (const sum of sums) { const { col, row } = sum.position; this.dispatch("UPDATE_CELL", { sheetId, col, row, content: `=SUM(${this.getters.zoneToXC(sheetId, sum.zone)})`, }); } } /** * Find the first row where all cells below the zone are empty. */ nextEmptyRow(sheetId, zone) { let start = zone.bottom + 1; const { left, right } = zone; while (!this.getters.isEmpty(sheetId, { bottom: start, top: start, left, right })) { start++; } return { ...zone, top: start, bottom: start, }; } /** * Find the first column where all cells right of the zone are empty. */ nextEmptyCol(sheetId, zone) { let start = zone.right + 1; const { top, bottom } = zone; while (!this.getters.isEmpty(sheetId, { left: start, right: start, top, bottom })) { start++; } return { ...zone, left: start, right: start, }; } /** * Transpose the given dimensions. * COL becomes ROW * ROW becomes COL */ transpose(dimensions) { return new Set([...dimensions.values()].map((dimension) => (dimension === "COL" ? "ROW" : "COL"))); } } /* * This file contains the specifics transformations */ otRegistry.addTransformation("ADD_COLUMNS_ROWS", ["ADD_COLUMNS_ROWS"], addHeadersTransformation); otRegistry.addTransformation("REMOVE_COLUMNS_ROWS", ["ADD_COLUMNS_ROWS"], addHeadersTransformation); otRegistry.addTransformation("ADD_COLUMNS_ROWS", ["CREATE_CHART", "UPDATE_CHART"], updateChartRangesTransformation); otRegistry.addTransformation("REMOVE_COLUMNS_ROWS", ["CREATE_CHART", "UPDATE_CHART"], updateChartRangesTransformation); otRegistry.addTransformation("DELETE_SHEET", ["MOVE_RANGES"], transformTargetSheetId); otRegistry.addTransformation("DELETE_FIGURE", ["UPDATE_FIGURE", "UPDATE_CHART"], updateChartFigure); otRegistry.addTransformation("CREATE_SHEET", ["CREATE_SHEET"], createSheetTransformation); otRegistry.addTransformation("ADD_MERGE", ["ADD_MERGE", "REMOVE_MERGE"], mergeTransformation); otRegistry.addTransformation("ADD_COLUMNS_ROWS", ["FREEZE_COLUMNS", "FREEZE_ROWS"], freezeTransformation); otRegistry.addTransformation("REMOVE_COLUMNS_ROWS", ["FREEZE_COLUMNS", "FREEZE_ROWS"], freezeTransformation); otRegistry.addTransformation("ADD_COLUMNS_ROWS", ["UPDATE_TABLE"], updateTableTransformation); otRegistry.addTransformation("REMOVE_COLUMNS_ROWS", ["UPDATE_TABLE"], updateTableTransformation); otRegistry.addTransformation("REMOVE_TABLE_STYLE", ["CREATE_TABLE", "UPDATE_TABLE"], removeTableStyleTransform); otRegistry.addTransformation("ADD_COLUMNS_ROWS", ["GROUP_HEADERS", "UNGROUP_HEADERS", "FOLD_HEADER_GROUP", "UNFOLD_HEADER_GROUP"], groupHeadersTransformation); otRegistry.addTransformation("REMOVE_COLUMNS_ROWS", ["GROUP_HEADERS", "UNGROUP_HEADERS", "FOLD_HEADER_GROUP", "UNFOLD_HEADER_GROUP"], groupHeadersTransformation); otRegistry.addTransformation("REMOVE_PIVOT", ["RENAME_PIVOT", "DUPLICATE_PIVOT", "INSERT_PIVOT", "UPDATE_PIVOT"], pivotRemovedTransformation); otRegistry.addTransformation("DELETE_SHEET", ["ADD_PIVOT", "UPDATE_PIVOT"], pivotDeletedSheetTransformation); otRegistry.addTransformation("ADD_COLUMNS_ROWS", ["ADD_PIVOT", "UPDATE_PIVOT"], pivotZoneTransformation); otRegistry.addTransformation("REMOVE_COLUMNS_ROWS", ["ADD_PIVOT", "UPDATE_PIVOT"], pivotZoneTransformation); function pivotZoneTransformation(toTransform, executed) { if (toTransform.pivot.type !== "SPREADSHEET") { return toTransform; } if (toTransform.pivot.dataSet?.sheetId !== executed.sheetId) { return toTransform; } const newZone = transformZone(toTransform.pivot.dataSet.zone, executed); const dataSet = newZone ? { ...toTransform.pivot.dataSet, zone: newZone } : undefined; return { ...toTransform, pivot: { ...toTransform.pivot, dataSet } }; } function pivotDeletedSheetTransformation(toTransform, executed) { if (toTransform.pivot.type !== "SPREADSHEET") { return toTransform; } if (toTransform.pivot.dataSet?.sheetId === executed.sheetId) { return { ...toTransform, pivot: { ...toTransform.pivot, dataSet: undefined } }; } return toTransform; } function pivotRemovedTransformation(toTransform, executed) { if (toTransform.pivotId === executed.pivotId) { return undefined; } return toTransform; } function transformTargetSheetId(toTransform, executed) { const deletedSheetId = executed.sheetId; if (toTransform.targetSheetId === deletedSheetId || toTransform.sheetId === deletedSheetId) { return undefined; } return toTransform; } function updateChartFigure(toTransform, executed) { if (toTransform.id === executed.id) { return undefined; } return toTransform; } function updateChartRangesTransformation(toTransform, executed) { return { ...toTransform, definition: transformDefinition(toTransform.definition, executed), }; } function createSheetTransformation(toTransform, executed) { if (toTransform.name === executed.name) { return { ...toTransform, name: toTransform.name?.match(/\d+/) ? toTransform.name.replace(/\d+/, (n) => (parseInt(n) + 1).toString()) : `${toTransform.name}~`, position: toTransform.position + 1, }; } return toTransform; } function mergeTransformation(toTransform, executed) { if (toTransform.sheetId !== executed.sheetId) { return toTransform; } const target = []; for (const zone1 of toTransform.target) { for (const zone2 of executed.target) { if (!overlap(zone1, zone2)) { target.push({ ...zone1 }); } } } if (target.length) { return { ...toTransform, target }; } return undefined; } function freezeTransformation(toTransform, executed) { if (toTransform.sheetId !== executed.sheetId) { return toTransform; } const dimension = toTransform.type === "FREEZE_COLUMNS" ? "COL" : "ROW"; if (dimension !== executed.dimension) { return toTransform; } let quantity = toTransform["quantity"]; if (executed.type === "REMOVE_COLUMNS_ROWS") { const executedElements = [...executed.elements].sort((a, b) => b - a); for (let removedElement of executedElements) { if (quantity > removedElement) { quantity--; } } } if (executed.type === "ADD_COLUMNS_ROWS") { const executedBase = executed.position === "before" ? executed.base - 1 : executed.base; quantity = quantity > executedBase ? quantity + executed.quantity : quantity; } return quantity > 0 ? { ...toTransform, quantity } : undefined; } /** * Update the zones of an UPDATE_TABLE command if some headers were added/removed */ function updateTableTransformation(toTransform, executed) { if (toTransform.sheetId !== executed.sheetId) { return toTransform; } const newCmdZone = transformZone(toTransform.zone, executed); if (!newCmdZone) { return undefined; } const newTableRange = toTransform.newTableRange ? transformRangeData(toTransform.newTableRange, executed) : undefined; return { ...toTransform, newTableRange, zone: newCmdZone }; } function removeTableStyleTransform(toTransform, executed) { if (toTransform.config?.styleId !== executed.tableStyleId) { return toTransform; } return { ...toTransform, config: { ...toTransform.config, styleId: DEFAULT_TABLE_CONFIG.styleId }, }; } /** * Transform ADD_COLUMNS_ROWS command if some headers were added/removed */ function addHeadersTransformation(toTransform, executed) { if (toTransform.sheetId !== executed.sheetId || toTransform.dimension !== executed.dimension) { return toTransform; } let result = undefined; if (executed.type === "REMOVE_COLUMNS_ROWS") { result = moveHeaderIndexesOnHeaderDeletion(executed.elements, [toTransform.base])[0]; } else if (executed.type === "ADD_COLUMNS_ROWS") { const base = getAddHeaderStartIndex(executed.position, executed.base); result = moveHeaderIndexesOnHeaderAddition(base, executed.quantity, [toTransform.base])[0]; } if (result === undefined) { return undefined; } return { ...toTransform, base: result }; } /** * Transform header group command if some headers were added/removed */ function groupHeadersTransformation(toTransform, executed) { if (toTransform.sheetId !== executed.sheetId || toTransform.dimension !== executed.dimension) { return toTransform; } const elementsToTransform = range(toTransform.start, toTransform.end + 1); let results = []; if (executed.type === "REMOVE_COLUMNS_ROWS") { results = moveHeaderIndexesOnHeaderDeletion(executed.elements, elementsToTransform); } else if (executed.type === "ADD_COLUMNS_ROWS") { const base = getAddHeaderStartIndex(executed.position, executed.base); results = moveHeaderIndexesOnHeaderAddition(base, executed.quantity, elementsToTransform); } if (results.length === 0) { return undefined; } return { ...toTransform, start: Math.min(...results), end: Math.max(...results) }; } const transformations = [ { match: isSheetDependent, fn: transformSheetId }, { match: isTargetDependent, fn: transformTarget }, { match: isZoneDependent, fn: transformZoneDependentCommand }, { match: isPositionDependent, fn: transformPosition }, { match: isHeadersDependant, fn: transformHeaders }, { match: isRangeDependant, fn: transformRangesDependentCommand }, ]; /** * Get the result of applying the operation transformations on the given command * to transform based on the executed command. * Let's see a small example: * Given * - command A: set the content of C1 to "Hello" * - command B: add a column after A * * If command B has been executed locally and not transmitted (yet) to * other clients, and command A arrives from an other client to be executed locally. * Command A is no longer valid and no longer reflects the user intention. * It needs to be transformed knowing that command B is already executed. * transform(A, B) => set the content of D1 to "Hello" */ function transform(toTransform, executed) { const specificTransform = otRegistry.getTransformation(toTransform.type, executed.type); return specificTransform ? specificTransform(toTransform, executed) : genericTransform(toTransform, executed); } /** * Get the result of applying the operation transformations on all the given * commands to transform for each executed commands. */ function transformAll(toTransform, executed) { let transformedCommands = [...toTransform]; for (const executedCommand of executed) { transformedCommands = transformedCommands .map((cmd) => transform(cmd, executedCommand)) .filter(isDefined); } return transformedCommands; } /** * Apply all generic transformation based on the characteristic of the given commands. */ function genericTransform(cmd, executed) { for (const { match, fn } of transformations) { if (match(cmd)) { const result = fn(cmd, executed); if (result === "SKIP_TRANSFORMATION") { continue; } if (result === "IGNORE_COMMAND") { return undefined; } cmd = result; } } return cmd; } function transformSheetId(toTransform, executed) { if (!("sheetId" in executed)) { return toTransform; } const deleteSheet = executed.type === "DELETE_SHEET" && executed.sheetId; if (toTransform.sheetId === deleteSheet) { return "IGNORE_COMMAND"; } else if (toTransform.type === "CREATE_SHEET" || executed.type === "CREATE_SHEET" || toTransform.sheetId !== executed.sheetId) { return toTransform; } return "SKIP_TRANSFORMATION"; } function transformTarget(cmd, executed) { const transformSheetResult = transformSheetId(cmd, executed); if (transformSheetResult !== "SKIP_TRANSFORMATION") { return transformSheetResult === "IGNORE_COMMAND" ? "IGNORE_COMMAND" : cmd; } const target = []; for (const zone of cmd.target) { const newZone = transformZone(zone, executed); if (newZone) { target.push(newZone); } } if (!target.length) { return "IGNORE_COMMAND"; } return { ...cmd, target }; } function transformZoneDependentCommand(cmd, executed) { const transformSheetResult = transformSheetId(cmd, executed); if (transformSheetResult !== "SKIP_TRANSFORMATION") { return transformSheetResult === "IGNORE_COMMAND" ? "IGNORE_COMMAND" : cmd; } const newZone = transformZone(cmd.zone, executed); if (newZone) { return { ...cmd, zone: newZone }; } return "IGNORE_COMMAND"; } function transformRangesDependentCommand(toTransform, executed) { if (!("sheetId" in executed)) { return toTransform; } const ranges = toTransform.ranges .map((range) => transformRangeData(range, executed)) .filter(isDefined); if (!ranges.length) { return "IGNORE_COMMAND"; } return { ...toTransform, ranges }; } function transformHeaders(toTransform, executed) { const transformSheetResult = transformSheetId(toTransform, executed); if (transformSheetResult !== "SKIP_TRANSFORMATION") { return transformSheetResult === "IGNORE_COMMAND" ? "IGNORE_COMMAND" : toTransform; } if (executed.type !== "ADD_COLUMNS_ROWS" && executed.type !== "REMOVE_COLUMNS_ROWS") { return "SKIP_TRANSFORMATION"; } if (executed.dimension !== toTransform.dimension) { return toTransform; } let result = []; if (executed.type === "REMOVE_COLUMNS_ROWS") { result = moveHeaderIndexesOnHeaderDeletion(executed.elements, toTransform.elements); } else if (executed.type === "ADD_COLUMNS_ROWS") { const base = getAddHeaderStartIndex(executed.position, executed.base); result = moveHeaderIndexesOnHeaderAddition(base, executed.quantity, toTransform.elements); } if (result.length === 0) { return "IGNORE_COMMAND"; } return { ...toTransform, elements: result }; } /** * Transform a PositionDependentCommand. It could be impacted by a grid command * (Add/remove cols/rows) and a merge */ function transformPosition(toTransform, executed) { const transformSheetResult = transformSheetId(toTransform, executed); if (transformSheetResult !== "SKIP_TRANSFORMATION") { return transformSheetResult === "IGNORE_COMMAND" ? "IGNORE_COMMAND" : toTransform; } if (executed.type === "ADD_COLUMNS_ROWS" || executed.type === "REMOVE_COLUMNS_ROWS") { return transformPositionWithGrid(toTransform, executed); } if (executed.type === "ADD_MERGE") { return transformPositionWithMerge(toTransform, executed); } return "SKIP_TRANSFORMATION"; } /** * Transform a PositionDependentCommand after a grid shape modification. This * transformation consists of updating the position. */ function transformPositionWithGrid(toTransform, executed) { const field = executed.dimension === "COL" ? "col" : "row"; let base = toTransform[field]; if (executed.type === "REMOVE_COLUMNS_ROWS") { const elements = [...executed.elements].sort((a, b) => b - a); if (elements.includes(base)) { return "IGNORE_COMMAND"; } for (let removedElement of elements) { if (base >= removedElement) { base--; } } } if (executed.type === "ADD_COLUMNS_ROWS") { if (base > executed.base || (base === executed.base && executed.position === "before")) { base = base + executed.quantity; } } return { ...toTransform, [field]: base }; } /** * Transform a PositionDependentCommand after a merge. This transformation * consists of checking that the position is not inside the merged zones */ function transformPositionWithMerge(toTransform, executed) { for (const zone of executed.target) { const sameTopLeft = toTransform.col === zone.left && toTransform.row === zone.top; if (!sameTopLeft && isInside(toTransform.col, toTransform.row, zone)) { return "IGNORE_COMMAND"; } } return toTransform; } class Revision { rootCommand; timestamp; id; clientId; _commands = []; _changes = []; /** * A revision represents a whole client action (Create a sheet, merge a Zone, Undo, ...). * A revision contains the following information: * - id: ID of the revision * - commands: CoreCommands that are linked to the action, and should be * dispatched in other clients * - clientId: Client who initiated the action * - changes: List of changes applied on the state. */ constructor(id, clientId, commands, rootCommand, changes, timestamp) { this.rootCommand = rootCommand; this.timestamp = timestamp; this.id = id; this.clientId = clientId; this._commands = [...commands]; this._changes = changes ? [...changes] : []; } setChanges(changes) { this._changes = changes; } get commands() { return this._commands; } get changes() { return this._changes; } } class ClientDisconnectedError extends Error { } class Session extends EventBus { revisions; transportService; serverRevisionId; /** * Positions of the others client. */ clients = {}; clientId = "local"; /** * Id of the server revision */ debouncedMove; pendingMessages = []; waitingAck = false; /** * Flag used to block all commands when an undo or redo is triggered, until * it is accepted on the server */ waitingUndoRedoAck = false; isReplayingInitialRevisions = false; processedRevisions = new Set(); uuidGenerator = new UuidGenerator(); lastLocalOperation; /** * Manages the collaboration between multiple users on the same spreadsheet. * It can forward local state changes to other users to ensure they all eventually * reach the same state. * It also manages the positions of each clients in the spreadsheet to provide * a visual indication of what other users are doing in the spreadsheet. * * @param revisions * @param transportService communication channel used to send and receive messages * between all connected clients * @param client the client connected locally * @param serverRevisionId */ constructor(revisions, transportService, serverRevisionId = DEFAULT_REVISION_ID) { super(); this.revisions = revisions; this.transportService = transportService; this.serverRevisionId = serverRevisionId; this.debouncedMove = debounce(this._move.bind(this), DEBOUNCE_TIME); } canApplyOptimisticUpdate() { return !this.waitingUndoRedoAck; } /** * Add a new revision to the collaborative session. * It will be transmitted to all other connected clients. */ save(rootCommand, commands, changes) { if (!commands.length || !changes.length || !this.canApplyOptimisticUpdate()) return; const revision = new Revision(this.uuidGenerator.uuidv4(), this.clientId, commands, rootCommand, changes, Date.now()); this.revisions.append(revision.id, revision); // REQUEST_REDO just repeats the last operation, the // last operation is still the same and should not change. if (rootCommand.type !== "REQUEST_REDO") { this.lastLocalOperation = revision; } this.trigger("new-local-state-update", { id: revision.id }); this.sendUpdateMessage({ type: "REMOTE_REVISION", version: MESSAGE_VERSION, serverRevisionId: this.serverRevisionId, nextRevisionId: revision.id, clientId: revision.clientId, commands: revision.commands, }); } undo(revisionId) { this.waitingUndoRedoAck = true; this.sendUpdateMessage({ type: "REVISION_UNDONE", version: MESSAGE_VERSION, serverRevisionId: this.serverRevisionId, nextRevisionId: this.uuidGenerator.uuidv4(), undoneRevisionId: revisionId, }); } redo(revisionId) { this.waitingUndoRedoAck = true; this.sendUpdateMessage({ type: "REVISION_REDONE", version: MESSAGE_VERSION, serverRevisionId: this.serverRevisionId, nextRevisionId: this.uuidGenerator.uuidv4(), redoneRevisionId: revisionId, }); } /** * Notify that the position of the client has changed */ move(position) { this.debouncedMove(position); } join(client) { if (client) { this.clients[client.id] = client; this.clientId = client.id; } else { this.clients["local"] = { id: "local", name: "local" }; this.clientId = "local"; } this.transportService.onNewMessage(this.clientId, this.onMessageReceived.bind(this)); } loadInitialMessages(messages) { const start = performance.now(); const numberOfCommands = messages.reduce((acc, message) => acc + (message.type === "REMOTE_REVISION" ? message.commands.length : 1), 0); this.isReplayingInitialRevisions = true; for (const message of messages) { this.onMessageReceived(message); } this.isReplayingInitialRevisions = false; console.debug("Replayed", numberOfCommands, "commands in", performance.now() - start, "ms"); } /** * Notify the server that the user client left the collaborative session */ async leave(data) { if (data && Object.keys(this.clients).length === 1 && this.processedRevisions.size) { await this.snapshot(data()); } delete this.clients[this.clientId]; this.transportService.leave(this.clientId); this.transportService.sendMessage({ type: "CLIENT_LEFT", clientId: this.clientId, version: MESSAGE_VERSION, }); } /** * Send a snapshot of the spreadsheet to the collaboration server */ async snapshot(data) { if (this.pendingMessages.length !== 0) { return; } const snapshotId = this.uuidGenerator.uuidv4(); await this.transportService.sendMessage({ type: "SNAPSHOT", nextRevisionId: snapshotId, serverRevisionId: this.serverRevisionId, data: { ...data, revisionId: snapshotId }, version: MESSAGE_VERSION, }); } getClient() { const client = this.clients[this.clientId]; if (!client) { throw new ClientDisconnectedError("The client left the session"); } return client; } getConnectedClients() { return new Set(Object.values(this.clients).filter(isDefined)); } getRevisionId() { return this.serverRevisionId; } isFullySynchronized() { return this.pendingMessages.length === 0; } /** * Get the last local revision * */ getLastLocalNonEmptyRevision() { return this.lastLocalOperation; } _move(position) { // this method is debounced and might be called after the client // left the session. if (!this.clients[this.clientId]) return; const currentPosition = this.clients[this.clientId]?.position; if (currentPosition?.col === position.col && currentPosition.row === position.row && currentPosition.sheetId === position.sheetId) { return; } const type = currentPosition ? "CLIENT_MOVED" : "CLIENT_JOINED"; const client = this.getClient(); this.clients[this.clientId] = { ...client, position }; this.transportService.sendMessage({ type, version: MESSAGE_VERSION, client: { ...client, position }, }); } /** * Handles messages received from other clients in the collaborative * session. */ onMessageReceived(message) { if (this.isAlreadyProcessed(message)) return; if (this.isWrongServerRevisionId(message)) { this.trigger("unexpected-revision-id"); return; } switch (message.type) { case "CLIENT_MOVED": this.onClientMoved(message); break; case "CLIENT_JOINED": this.onClientJoined(message); break; case "CLIENT_LEFT": this.onClientLeft(message); break; case "REVISION_REDONE": { this.revisions.redo(message.redoneRevisionId, message.nextRevisionId, message.serverRevisionId); this.trigger("revision-redone", { revisionId: message.redoneRevisionId, commands: this.revisions.get(message.redoneRevisionId).commands, }); break; } case "REVISION_UNDONE": this.revisions.undo(message.undoneRevisionId, message.nextRevisionId, message.serverRevisionId); this.trigger("revision-undone", { revisionId: message.undoneRevisionId, commands: this.revisions.get(message.undoneRevisionId).commands, }); break; case "REMOTE_REVISION": const { clientId, commands, timestamp } = message; const revision = new Revision(message.nextRevisionId, clientId, commands, undefined, undefined, timestamp); if (revision.clientId !== this.clientId) { this.revisions.insert(revision.id, revision, message.serverRevisionId); const pendingCommands = this.pendingMessages .filter((msg) => msg.type === "REMOTE_REVISION") .map((msg) => msg.commands) .flat(); this.trigger("remote-revision-received", { commands: transformAll(commands, pendingCommands), }); } break; case "SNAPSHOT_CREATED": { const revision = new Revision(message.nextRevisionId, "server", [], undefined, undefined, Date.now()); this.revisions.insert(revision.id, revision, message.serverRevisionId); this.dropPendingHistoryMessages(); this.trigger("snapshot"); this.lastLocalOperation = undefined; break; } } this.acknowledge(message); this.trigger("collaborative-event-received"); } onClientMoved(message) { if (message.client.id !== this.clientId) { this.clients[message.client.id] = message.client; } } /** * Register the new client and send your * own position back. */ onClientJoined(message) { if (message.client.id !== this.clientId) { this.clients[message.client.id] = message.client; const client = this.clients[this.clientId]; if (client) { const { position } = client; if (position) { this.transportService.sendMessage({ type: "CLIENT_MOVED", version: MESSAGE_VERSION, client: { ...client, position }, }); } } } } onClientLeft(message) { if (message.clientId !== this.clientId) { delete this.clients[message.clientId]; } } sendUpdateMessage(message) { this.pendingMessages.push(message); if (this.waitingAck) { return; } this.waitingAck = true; this.sendPendingMessage(); } /** * Send the next pending message */ sendPendingMessage() { let message = this.pendingMessages[0]; if (!message) return; if (message.type === "REMOTE_REVISION") { const revision = this.revisions.get(message.nextRevisionId); if (revision.commands.length === 0) { /** * The command is empty, we have to drop all the next local revisions * to avoid issues with undo/redo */ this.revisions.drop(revision.id); const revisionIds = this.pendingMessages .filter((message) => message.type === "REMOTE_REVISION") .map((message) => message.nextRevisionId); this.trigger("pending-revisions-dropped", { revisionIds }); this.waitingAck = false; this.waitingUndoRedoAck = false; this.pendingMessages = []; return; } message = { ...message, clientId: revision.clientId, commands: revision.commands, }; } if (this.isReplayingInitialRevisions) { throw new Error(`Trying to send a new revision while replaying initial revision. This can lead to endless dispatches every time the spreadsheet is open. ${JSON.stringify(message)}`); } this.transportService.sendMessage({ ...message, serverRevisionId: this.serverRevisionId, }); } acknowledge(message) { if (message.type === "REVISION_UNDONE" || message.type === "REVISION_REDONE") { this.waitingUndoRedoAck = false; } switch (message.type) { case "REMOTE_REVISION": case "REVISION_REDONE": case "REVISION_UNDONE": case "SNAPSHOT_CREATED": this.waitingAck = false; this.pendingMessages = this.pendingMessages.filter((msg) => msg.nextRevisionId !== message.nextRevisionId); this.serverRevisionId = message.nextRevisionId; this.processedRevisions.add(message.nextRevisionId); this.sendPendingMessage(); break; } } isAlreadyProcessed(message) { if (message.type === "CLIENT_MOVED" && message.client.id === this.clientId) { return true; } switch (message.type) { case "REMOTE_REVISION": case "REVISION_REDONE": case "REVISION_UNDONE": case "SNAPSHOT_CREATED": return this.processedRevisions.has(message.nextRevisionId); default: return false; } } isWrongServerRevisionId(message) { switch (message.type) { case "REMOTE_REVISION": case "REVISION_REDONE": case "REVISION_UNDONE": case "SNAPSHOT_CREATED": return message.serverRevisionId !== this.serverRevisionId; default: return false; } } dropPendingHistoryMessages() { this.waitingUndoRedoAck = false; this.pendingMessages = this.pendingMessages.filter(({ type }) => type !== "REVISION_REDONE" && type !== "REVISION_UNDONE"); } } function randomChoice(arr) { return arr[Math.floor(Math.random() * arr.length)]; } const colors = [ "#ff851b", "#0074d9", "#7fdbff", "#b10dc9", "#39cccc", "#f012be", "#3d9970", "#111111", "#ff4136", "#aaaaaa", "#85144b", "#001f3f", ]; class CollaborativePlugin extends UIPlugin { static getters = [ "getClientsToDisplay", "getClient", "getConnectedClients", "isFullySynchronized", ]; static layers = ["Selection"]; availableColors = new Set(colors); colors = {}; session; constructor(config) { super(config); this.session = config.session; } isPositionValid(position) { return (position.row < this.getters.getNumberRows(position.sheetId) && position.col < this.getters.getNumberCols(position.sheetId)); } chooseNewColor() { if (this.availableColors.size === 0) { this.availableColors = new Set(colors); } const color = randomChoice([...this.availableColors.values()]); this.availableColors.delete(color); return color; } getClient() { return this.session.getClient(); } getConnectedClients() { return this.session.getConnectedClients(); } isFullySynchronized() { return this.session.isFullySynchronized(); } /** * Get the list of others connected clients which are present in the same sheet * and with a valid position */ getClientsToDisplay() { try { this.getters.getClient(); } catch (e) { if (e instanceof ClientDisconnectedError) { return []; } else { throw e; } } const sheetId = this.getters.getActiveSheetId(); const clients = []; for (const client of this.getters.getConnectedClients()) { if (client.id !== this.getters.getClient().id && client.position && client.position.sheetId === sheetId && this.isPositionValid(client.position)) { const position = client.position; if (!this.colors[client.id]) { this.colors[client.id] = this.chooseNewColor(); } const color = this.colors[client.id]; clients.push({ ...client, position, color }); } } return clients; } drawLayer(renderingContext) { if (this.getters.isDashboard()) { return; } const { ctx, thinLineWidth } = renderingContext; const activeSheetId = this.getters.getActiveSheetId(); for (const client of this.getClientsToDisplay()) { const { row, col } = client.position; const zone = this.getters.expandZone(activeSheetId, { top: row, bottom: row, left: col, right: col, }); const { x, y, width, height } = this.getters.getVisibleRect(zone); if (width <= 0 || height <= 0) { continue; } const color = client.color; /* Cell background */ const cellBackgroundColor = `${color}10`; ctx.fillStyle = cellBackgroundColor; ctx.lineWidth = 4 * thinLineWidth; ctx.strokeStyle = color; ctx.globalCompositeOperation = "multiply"; ctx.fillRect(x, y, width, height); /* Cell border */ ctx.globalCompositeOperation = "source-over"; ctx.strokeRect(x, y, width, height); /* client name background */ ctx.font = `bold ${DEFAULT_FONT_SIZE + 1}px ${DEFAULT_FONT}`; } } } class DataCleanupPlugin extends UIPlugin { // --------------------------------------------------------------------------- // Command Handling // --------------------------------------------------------------------------- allowDispatch(cmd) { switch (cmd.type) { case "REMOVE_DUPLICATES": return this.checkValidations(cmd, this.chainValidations(this.checkSingleRangeSelected, this.checkNoMergeInZone, this.checkRangeContainsValues, this.checkColumnsIncludedInZone), this.chainValidations(this.checkNoColumnProvided, this.checkColumnsAreUnique)); } return "Success" /* CommandResult.Success */; } handle(cmd) { switch (cmd.type) { case "REMOVE_DUPLICATES": this.removeDuplicates(cmd.columns, cmd.hasHeader); break; case "TRIM_WHITESPACE": this.trimWhitespace(); break; } } // --------------------------------------------------------------------------- // Private // --------------------------------------------------------------------------- removeDuplicates(columnsToAnalyze, hasHeader) { const sheetId = this.getters.getActiveSheetId(); const zone = this.getters.getSelectedZone(); if (hasHeader) { zone.top += 1; } const uniqueRowsIndexes = this.getUniqueRowsIndexes(sheetId, zone.top, zone.bottom, columnsToAnalyze); const numberOfUniqueRows = uniqueRowsIndexes.length; if (numberOfUniqueRows === zoneToDimension(zone).numberOfRows) { this.notifyRowsRemovedAndRemaining(0, numberOfUniqueRows); return; } const rowsToKeep = uniqueRowsIndexes.map((rowIndex) => ({ left: zone.left, top: rowIndex, right: zone.right, bottom: rowIndex, })); const handler = new CellClipboardHandler(this.getters, this.dispatch); const data = handler.copy(getClipboardDataPositions(sheetId, rowsToKeep)); if (!data) { return; } this.dispatch("CLEAR_CELLS", { target: [zone], sheetId }); const zonePasted = { left: zone.left, top: zone.top, right: zone.left, bottom: zone.top, }; handler.paste({ zones: [zonePasted], sheetId }, data, { isCutOperation: false }); const remainingZone = { left: zone.left, top: zone.top - (hasHeader ? 1 : 0), right: zone.right, bottom: zone.top + numberOfUniqueRows - 1, }; this.selection.selectZone({ cell: { col: remainingZone.left, row: remainingZone.top }, zone: remainingZone, }); const removedRows = zone.bottom - zone.top + 1 - numberOfUniqueRows; this.notifyRowsRemovedAndRemaining(removedRows, numberOfUniqueRows); } getUniqueRowsIndexes(sheetId, top, bottom, columns) { const uniqueRows = new Map(); for (const row of range(top, bottom + 1)) { const cellsValuesInRow = columns.map((col) => { return this.getters.getEvaluatedCell({ sheetId, col, row, }).value; }); const isRowUnique = !Object.values(uniqueRows).some((uniqueRow) => deepEquals(uniqueRow, cellsValuesInRow)); if (isRowUnique) { uniqueRows[row] = cellsValuesInRow; } } // transform key object in number return Object.keys(uniqueRows).map((key) => parseInt(key)); } notifyRowsRemovedAndRemaining(removedRows, remainingRows) { this.ui.notifyUI({ type: "info", text: _t("%s duplicate rows found and removed.\n%s unique rows remain.", removedRows.toString(), remainingRows.toString()), sticky: false, }); } checkSingleRangeSelected() { const zones = this.getters.getSelectedZones(); if (zones.length !== 1) { return "MoreThanOneRangeSelected" /* CommandResult.MoreThanOneRangeSelected */; } return "Success" /* CommandResult.Success */; } checkNoMergeInZone() { const sheetId = this.getters.getActiveSheetId(); const zone = this.getters.getSelectedZone(); const mergesInZone = this.getters.getMergesInZone(sheetId, zone); if (mergesInZone.length > 0) { return "WillRemoveExistingMerge" /* CommandResult.WillRemoveExistingMerge */; } return "Success" /* CommandResult.Success */; } checkRangeContainsValues(cmd) { const sheetId = this.getters.getActiveSheetId(); const zone = this.getters.getSelectedZone(); if (cmd.hasHeader) { zone.top += 1; } const evaluatedCells = this.getters.getEvaluatedCellsInZone(sheetId, zone); if (evaluatedCells.every((evaluatedCel) => evaluatedCel.type === "empty")) { return "EmptyTarget" /* CommandResult.EmptyTarget */; } return "Success" /* CommandResult.Success */; } checkNoColumnProvided(cmd) { if (cmd.columns.length === 0) { return "NoColumnsProvided" /* CommandResult.NoColumnsProvided */; } return "Success" /* CommandResult.Success */; } checkColumnsIncludedInZone(cmd) { const zone = this.getters.getSelectedZone(); const columnsToAnalyze = cmd.columns; if (columnsToAnalyze.some((colIndex) => colIndex < zone.left || colIndex > zone.right)) { return "ColumnsNotIncludedInZone" /* CommandResult.ColumnsNotIncludedInZone */; } return "Success" /* CommandResult.Success */; } checkColumnsAreUnique(cmd) { if (cmd.columns.length !== new Set(cmd.columns).size) { return "DuplicatesColumnsSelected" /* CommandResult.DuplicatesColumnsSelected */; } return "Success" /* CommandResult.Success */; } trimWhitespace() { const zones = recomputeZones(this.getters.getSelectedZones()); const sheetId = this.getters.getActiveSheetId(); let count = 0; for (const { col, row } of zones.map(positions).flat()) { const cell = this.getters.getCell({ col, row, sheetId }); if (!cell) { continue; } const trimmedContent = trimContent(cell.content); if (trimmedContent !== cell.content) { count += 1; this.dispatch("UPDATE_CELL", { sheetId, col, row, content: trimmedContent, }); } } const text = count ? _t("Trimmed whitespace from %s cells.", count) : _t("No selected cells had whitespace trimmed."); this.ui.notifyUI({ type: "info", text: text, sticky: false, }); } } class FormatPlugin extends UIPlugin { // --------------------------------------------------------------------------- // Command Handling // --------------------------------------------------------------------------- handle(cmd) { switch (cmd.type) { case "SET_DECIMAL": this.setDecimal(cmd.sheetId, cmd.target, cmd.step); break; case "SET_FORMATTING_WITH_PIVOT": { this.setContextualFormat(cmd.sheetId, cmd.target, cmd.format); break; } } } setContextualFormat(sheetId, zones, format) { const measurePositions = []; const measuresByPivotId = {}; for (const zone of recomputeZones(zones)) { for (let col = zone.left; col <= zone.right; col++) { for (let row = zone.top; row <= zone.bottom; row++) { const position = { sheetId, col, row }; const pivotCell = this.getters.getPivotCellFromPosition(position); if (this.isSpilledPivotValueFormula(position, pivotCell)) { measurePositions.push(position); const pivotId = this.getters.getPivotIdFromPosition(position) || ""; measuresByPivotId[pivotId] ??= new Set(); measuresByPivotId[pivotId].add(pivotCell.measure); } } } } const measureZones = recomputeZones(measurePositions.map(positionToZone)); for (const pivotId in measuresByPivotId) { const measures = measuresByPivotId[pivotId]; const pivotDefinition = this.getters.getPivotCoreDefinition(pivotId); this.dispatch("UPDATE_PIVOT", { pivotId, pivot: { ...pivotDefinition, measures: pivotDefinition.measures.map((measure) => { if (measures.has(measure.id)) { return { ...measure, format }; } return measure; }), }, }); } this.dispatch("SET_FORMATTING", { sheetId, target: measureZones, format: "", }); this.dispatch("SET_FORMATTING", { sheetId, target: recomputeZones(zones, measureZones), format, }); } isSpilledPivotValueFormula(position, pivotCell) { const cell = this.getters.getCell(position); return pivotCell.type === "VALUE" && !cell?.isFormula; } /** * This function allows to adjust the quantity of decimal places after a decimal * point on cells containing number value. It does this by changing the cells * format. Values aren't modified. * * The change of the decimal quantity is done one by one, the sign of the step * variable indicates whether we are increasing or decreasing. * * If several cells are in the zone, each cell's format will be individually * evaluated and updated with the number type. */ setDecimal(sheetId, zones, step) { const positionsByFormat = {}; // Find the each cell with a number value and get the format for (const zone of recomputeZones(zones)) { for (const position of positions(zone)) { const numberFormat = this.getCellNumberFormat({ sheetId, ...position }); if (numberFormat !== undefined) { // Depending on the step sign, increase or decrease the decimal representation // of the format const newFormat = changeDecimalPlaces(numberFormat, step); positionsByFormat[newFormat] = positionsByFormat[newFormat] || []; positionsByFormat[newFormat].push(position); } } } // consolidate all positions with the same format in bigger zones for (const newFormat in positionsByFormat) { const zones = recomputeZones(positionsByFormat[newFormat].map((position) => positionToZone(position))); this.setContextualFormat(sheetId, zones, newFormat); } } /** * Take a range of cells and return the format of the first cell containing a * number value. Returns a default format if the cell hasn't format. Returns * undefined if no number value in the range. */ getCellNumberFormat(position) { for (const pos of [position]) { const cell = this.getters.getEvaluatedCell(pos); if (cell.type === CellValueType.number && !(cell.format && isDateTimeFormat(cell.format)) // reject dates ) { return cell.format || createDefaultFormat(cell.value); } } return undefined; } } class HeaderVisibilityUIPlugin extends UIPlugin { static getters = [ "getNextVisibleCellPosition", "findVisibleHeader", "findLastVisibleColRowIndex", "findFirstVisibleColRowIndex", "isRowHidden", "isColHidden", "isHeaderHidden", ]; isRowHidden(sheetId, index) { return (this.getters.isRowHiddenByUser(sheetId, index) || this.getters.isRowFiltered(sheetId, index)); } isColHidden(sheetId, index) { return this.getters.isColHiddenByUser(sheetId, index); } isHeaderHidden(sheetId, dimension, index) { return dimension === "COL" ? this.isColHidden(sheetId, index) : this.isRowHidden(sheetId, index); } getNextVisibleCellPosition({ sheetId, col, row }) { return { sheetId, col: this.findVisibleHeader(sheetId, "COL", col, this.getters.getNumberCols(sheetId) - 1), row: this.findVisibleHeader(sheetId, "ROW", row, this.getters.getNumberRows(sheetId) - 1), }; } /** * Find the first visible header in the range [`from` => `to`]. * * Both `from` and `to` are inclusive. */ findVisibleHeader(sheetId, dimension, from, to) { 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(sheetId, dimension, { last, first }) { const lastVisibleIndex = range(last, first, -1).find((index) => !this.isHeaderHidden(sheetId, dimension, index)); return lastVisibleIndex || first; } findFirstVisibleColRowIndex(sheetId, dimension) { const numberOfHeaders = this.getters.getNumberHeaders(sheetId, dimension); for (let i = 0; i < numberOfHeaders; i++) { if (dimension === "COL" && !this.isColHidden(sheetId, i)) { return i; } if (dimension === "ROW" && !this.isRowHidden(sheetId, i)) { return i; } } return undefined; } exportForExcel(data) { for (const sheetData of data.sheets) { for (const [row, rowData] of Object.entries(sheetData.rows)) { const isHidden = this.isRowHidden(sheetData.id, Number(row)); rowData.isHidden = isHidden; } } } } class InsertPivotPlugin extends UIPlugin { static getters = []; handle(cmd) { switch (cmd.type) { case "INSERT_NEW_PIVOT": this.insertNewPivot(cmd.pivotId, cmd.newSheetId); break; case "DUPLICATE_PIVOT_IN_NEW_SHEET": this.duplicatePivotInNewSheet(cmd.pivotId, cmd.newPivotId, cmd.newSheetId); break; case "INSERT_PIVOT_WITH_TABLE": this.insertPivotWithTable(cmd.sheetId, cmd.col, cmd.row, cmd.pivotId, cmd.table, cmd.pivotMode); break; case "SPLIT_PIVOT_FORMULA": this.splitPivotFormula(cmd.sheetId, cmd.col, cmd.row, cmd.pivotId); } } insertNewPivot(pivotId, sheetId) { if (getZoneArea(this.getters.getSelectedZone()) === 1) { this.selection.selectTableAroundSelection(); } const currentSheetId = this.getters.getActiveSheetId(); this.dispatch("ADD_PIVOT", { pivotId, pivot: { dataSet: { zone: this.getters.getSelectedZone(), sheetId: currentSheetId, }, columns: [], rows: [], measures: [], name: _t("New pivot"), type: "SPREADSHEET", }, }); const position = this.getters.getSheetIds().findIndex((sheetId) => sheetId === currentSheetId) + 1; const formulaId = this.getters.getPivotFormulaId(pivotId); this.dispatch("CREATE_SHEET", { sheetId, name: _t("Pivot #%(formulaId)s", { formulaId }), position, }); this.dispatch("ACTIVATE_SHEET", { sheetIdFrom: currentSheetId, sheetIdTo: sheetId, }); const pivot = this.getters.getPivot(pivotId); this.insertPivotWithTable(sheetId, 0, 0, pivotId, pivot.getTableStructure().export(), "dynamic"); } duplicatePivotInNewSheet(pivotId, newPivotId, newSheetId) { this.dispatch("DUPLICATE_PIVOT", { pivotId, newPivotId, duplicatedPivotName: _t("%s (copy)", this.getters.getPivotCoreDefinition(pivotId).name), }); const activeSheetId = this.getters.getActiveSheetId(); const position = this.getters.getSheetIds().indexOf(activeSheetId) + 1; const formulaId = this.getters.getPivotFormulaId(newPivotId); const newPivotName = this.getters.getPivotName(newPivotId); const result = this.dispatch("CREATE_SHEET", { sheetId: newSheetId, name: this.getPivotDuplicateSheetName(_t("%(newPivotName)s (Pivot #%(formulaId)s)", { newPivotName, formulaId, })), position, }); if (result.isSuccessful) { this.dispatch("ACTIVATE_SHEET", { sheetIdFrom: activeSheetId, sheetIdTo: newSheetId }); const pivot = this.getters.getPivot(pivotId); this.insertPivotWithTable(newSheetId, 0, 0, newPivotId, pivot.getTableStructure().export(), "dynamic"); } } getPivotDuplicateSheetName(pivotName) { let i = 1; const names = this.getters.getSheetIds().map((id) => this.getters.getSheetName(id)); const sanitizedName = sanitizeSheetName(pivotName); let name = sanitizedName; while (names.includes(name)) { name = `${sanitizedName} (${i})`; i++; } return name; } insertPivotWithTable(sheetId, col, row, pivotId, table, mode) { const { cols, rows, measures, fieldsType } = table; const pivotTable = new SpreadsheetPivotTable(cols, rows, measures, fieldsType || {}); const numberOfHeaders = pivotTable.columns.length - 1; this.resizeSheet(sheetId, col, row, pivotTable); const pivotFormulaId = this.getters.getPivotFormulaId(pivotId); let zone; if (mode === "dynamic") { this.dispatch("UPDATE_CELL", { sheetId, col, row, content: `=PIVOT(${pivotFormulaId})`, }); zone = { left: col, right: col, top: row, bottom: row, }; } else { this.dispatch("INSERT_PIVOT", { sheetId, col, row, pivotId, table: pivotTable.export(), }); zone = { left: col, right: col + pivotTable.getNumberOfDataColumns(), top: row, bottom: row + numberOfHeaders + pivotTable.rows.length, }; } this.dispatch("CREATE_TABLE", { tableType: mode, sheetId, ranges: [this.getters.getRangeDataFromZone(sheetId, zone)], config: { ...PIVOT_TABLE_CONFIG, numberOfHeaders }, }); } resizeSheet(sheetId, col, row, table) { const colLimit = table.getNumberOfDataColumns() + 1; // +1 for the Top-Left const numberCols = this.getters.getNumberCols(sheetId); const deltaCol = numberCols - col; if (deltaCol < colLimit) { this.dispatch("ADD_COLUMNS_ROWS", { dimension: "COL", base: numberCols - 1, sheetId: sheetId, quantity: colLimit - deltaCol, position: "after", }); } const rowLimit = table.columns.length + table.rows.length; const numberRows = this.getters.getNumberRows(sheetId); const deltaRow = numberRows - row; if (deltaRow < rowLimit) { this.dispatch("ADD_COLUMNS_ROWS", { dimension: "ROW", base: numberRows - 1, sheetId: sheetId, quantity: rowLimit - deltaRow, position: "after", }); } } splitPivotFormula(sheetId, col, row, pivotId) { const spreadZone = this.getters.getSpreadZone({ sheetId, col, row }); if (!spreadZone) { return; } const formulaId = this.getters.getPivotFormulaId(pivotId); const pivotCells = new Map(); for (let col = spreadZone.left; col <= spreadZone.right; col++) { for (let row = spreadZone.top; row <= spreadZone.bottom; row++) { const position = { sheetId, col, row }; pivotCells.set(position, this.getters.getPivotCellFromPosition(position)); } } for (const [position, pivotCell] of pivotCells) { this.dispatch("UPDATE_CELL", { ...position, content: createPivotFormula(formulaId, pivotCell), }); } const table = this.getters.getCoreTable({ sheetId, col, row }); if (table?.type === "dynamic") { const zone = positionToZone({ col, row }); const rangeData = this.getters.getRangeDataFromZone(sheetId, spreadZone); this.dispatch("UPDATE_TABLE", { sheetId, zone, newTableRange: rangeData, tableType: "static", }); } } } class SortPlugin extends UIPlugin { allowDispatch(cmd) { switch (cmd.type) { case "SORT_CELLS": if (!isInside(cmd.col, cmd.row, cmd.zone)) { throw new Error(_t("The anchor must be part of the provided zone")); } return this.checkValidations(cmd, this.checkMerge, this.checkMergeSizes); } return "Success" /* CommandResult.Success */; } handle(cmd) { switch (cmd.type) { case "SORT_CELLS": this.sortZone(cmd.sheetId, cmd, cmd.zone, cmd.sortDirection, cmd.sortOptions || {}); break; } } checkMerge({ sheetId, zone }) { if (!this.getters.doesIntersectMerge(sheetId, zone)) { return "Success" /* CommandResult.Success */; } /*Test the presence of single cells*/ const singleCells = positions(zone).some(({ col, row }) => !this.getters.isInMerge({ sheetId, col, row })); if (singleCells) { return "InvalidSortZone" /* CommandResult.InvalidSortZone */; } return "Success" /* CommandResult.Success */; } checkMergeSizes({ sheetId, zone }) { if (!this.getters.doesIntersectMerge(sheetId, zone)) { return "Success" /* CommandResult.Success */; } const merges = this.getters.getMerges(sheetId).filter((merge) => overlap(merge, zone)); /*Test the presence of merges of different sizes*/ const mergeDimension = zoneToDimension(merges[0]); let [widthFirst, heightFirst] = [mergeDimension.numberOfCols, mergeDimension.numberOfRows]; if (!merges.every((merge) => { let [widthCurrent, heightCurrent] = [ merge.right - merge.left + 1, merge.bottom - merge.top + 1, ]; return widthCurrent === widthFirst && heightCurrent === heightFirst; })) { return "InvalidSortZone" /* CommandResult.InvalidSortZone */; } return "Success" /* CommandResult.Success */; } /** * This function evaluates if the top row of a provided zone can be considered as a `header` * by checking the following criteria: * * If the left-most column top row value (topLeft) is empty, we ignore it while evaluating the criteria. * 1 - Apart from the left-most column, every element of the top row must be non-empty, i.e. a cell should be present in the sheet. * 2 - There should be at least one column in which the type (CellValueType) of the rop row cell differs from the type of the cell below. * For the second criteria, we ignore columns on which the cell below is empty. * */ hasHeader(sheetId, items) { if (items[0].length === 1) return false; let cells = items.map((col) => col.map(({ col, row }) => this.getters.getEvaluatedCell({ sheetId, col, row }).type)); // ignore left-most column when topLeft cell is empty const topLeft = cells[0][0]; if (topLeft === CellValueType.empty) { cells = cells.slice(1); } if (cells.some((item) => item[0] === CellValueType.empty)) { return false; } else if (cells.some((item) => item[1] !== CellValueType.empty && item[0] !== item[1])) { return true; } else { return false; } } sortZone(sheetId, anchor, zone, sortDirection, options) { const [stepX, stepY] = this.mainCellsSteps(sheetId, zone); let sortingCol = this.getters.getMainCellPosition({ sheetId, col: anchor.col, row: anchor.row, }).col; // fetch anchor let sortZone = Object.assign({}, zone); // Update in case of merges in the zone let cellPositions = this.mainCells(sheetId, zone); if (!options.sortHeaders && this.hasHeader(sheetId, cellPositions)) { sortZone.top += stepY; } cellPositions = this.mainCells(sheetId, sortZone); const sortingCells = cellPositions[sortingCol - sortZone.left]; const sortedIndexOfSortTypeCells = sortCells(sortingCells.map((position) => this.getters.getEvaluatedCell(position)), sortDirection, Boolean(options.emptyCellAsZero)); const sortedIndex = sortedIndexOfSortTypeCells.map((x) => x.index); const [width, height] = [cellPositions.length, cellPositions[0].length]; const updateCellCommands = []; for (let c = 0; c < width; c++) { for (let r = 0; r < height; r++) { let { col, row, sheetId } = cellPositions[c][sortedIndex[r]]; const cell = this.getters.getCell({ sheetId, col, row }); let newCol = sortZone.left + c * stepX; let newRow = sortZone.top + r * stepY; let newCellValues = { sheetId: sheetId, col: newCol, row: newRow, content: "", }; if (cell) { let content = cell.content; if (cell.isFormula) { const position = this.getters.getCellPosition(cell.id); // we only have a vertical offset content = this.getters.getTranslatedCellFormula(sheetId, 0, newRow - position.row, cell.compiledFormula.tokens); } newCellValues.style = cell.style; newCellValues.content = content; newCellValues.format = cell.format; } updateCellCommands.push(newCellValues); } } updateCellCommands.forEach((cmdPayload) => this.dispatch("UPDATE_CELL", cmdPayload)); } /** * Return the distances between main merge cells in the zone. * (1 if there are no merges). * Note: it is assumed all merges are the same in the zone. */ mainCellsSteps(sheetId, zone) { const merge = this.getters.getMerge({ sheetId, col: zone.left, row: zone.top }); const stepX = merge ? merge.right - merge.left + 1 : 1; const stepY = merge ? merge.bottom - merge.top + 1 : 1; return [stepX, stepY]; } /** * Return a 2D array of cells in the zone (main merge cells if there are merges) */ mainCells(sheetId, zone) { const [stepX, stepY] = this.mainCellsSteps(sheetId, zone); const cells = []; const cols = range(zone.left, zone.right + 1, stepX); const rows = range(zone.top, zone.bottom + 1, stepY); for (const col of cols) { const colCells = []; cells.push(colCells); for (const row of rows) { colCells.push({ sheetId, col, row }); } } return cells; } } class UIOptionsPlugin extends UIPlugin { static getters = ["shouldShowFormulas"]; showFormulas = false; // --------------------------------------------------------------------------- // Command Handling // --------------------------------------------------------------------------- handle(cmd) { switch (cmd.type) { case "SET_FORMULA_VISIBILITY": this.showFormulas = cmd.show; break; } } // --------------------------------------------------------------------------- // Getters // --------------------------------------------------------------------------- shouldShowFormulas() { return this.showFormulas; } } class SheetUIPlugin extends UIPlugin { static getters = [ "doesCellHaveGridIcon", "getCellWidth", "getCellIconSrc", "getTextWidth", "getCellText", "getCellMultiLineText", "getContiguousZone", ]; ctx = document.createElement("canvas").getContext("2d"); // --------------------------------------------------------------------------- // Command Handling // --------------------------------------------------------------------------- allowDispatch(cmd) { return this.chainValidations(this.checkSheetExists, this.checkZonesAreInSheet)(cmd); } handle(cmd) { switch (cmd.type) { case "AUTORESIZE_COLUMNS": for (let col of cmd.cols) { const size = this.getColMaxWidth(cmd.sheetId, col); if (size !== 0) { this.dispatch("RESIZE_COLUMNS_ROWS", { elements: [col], dimension: "COL", size, sheetId: cmd.sheetId, }); } } break; case "AUTORESIZE_ROWS": for (let row of cmd.rows) { this.dispatch("RESIZE_COLUMNS_ROWS", { elements: [row], dimension: "ROW", size: null, sheetId: cmd.sheetId, }); } break; } } // --------------------------------------------------------------------------- // Getters // --------------------------------------------------------------------------- getCellWidth(position) { const style = this.getters.getCellComputedStyle(position); let contentWidth = 0; const content = this.getters.getEvaluatedCell(position).formattedValue; if (content) { const multiLineText = splitTextToWidth(this.ctx, content, style, undefined); contentWidth += Math.max(...multiLineText.map((line) => computeTextWidth(this.ctx, line, style))); } const icon = this.getters.getCellIconSrc(position); if (icon) { contentWidth += computeIconWidth(style); } if (this.getters.doesCellHaveGridIcon(position)) { contentWidth += ICON_EDGE_LENGTH + GRID_ICON_MARGIN; } if (contentWidth === 0) { return 0; } contentWidth += 2 * PADDING_AUTORESIZE_HORIZONTAL; if (style.wrapping === "wrap") { const colWidth = this.getters.getColSize(this.getters.getActiveSheetId(), position.col); return Math.min(colWidth, contentWidth); } return contentWidth; } getCellIconSrc(position) { const callbacks = iconsOnCellRegistry.getAll(); for (const callback of callbacks) { const imageSrc = callback(this.getters, position); if (imageSrc) { return imageSrc; } } return undefined; } getTextWidth(text, style) { return computeTextWidth(this.ctx, text, style); } getCellText(position, args) { const cell = this.getters.getCell(position); const locale = this.getters.getLocale(); if (args?.showFormula && cell?.isFormula) { return localizeFormula(cell.content, locale); } else if (args?.showFormula && !cell?.content) { return ""; } else { const evaluatedCell = this.getters.getEvaluatedCell(position); const formatWidth = args?.availableWidth ? { availableWidth: args.availableWidth, measureText: (text) => computeTextWidth(this.ctx, text, cell?.style || {}), } : undefined; return formatValue(evaluatedCell.value, { format: evaluatedCell.format, locale, formatWidth, }); } } /** * Return the text of a cell, split in multiple lines if needed. The text will be split in multiple * line if it contains NEWLINE characters, or if it's longer than the given width. */ getCellMultiLineText(position, args) { const style = this.getters.getCellStyle(position); const text = this.getters.getCellText(position, { showFormula: this.getters.shouldShowFormulas(), availableWidth: args.maxWidth, }); return splitTextToWidth(this.ctx, text, style, args.wrapText ? args.maxWidth : undefined); } doesCellHaveGridIcon(position) { const isFilterHeader = this.getters.isFilterHeader(position); const hasListIcon = !this.getters.isReadonly() && this.getters.cellHasListDataValidationIcon(position); return isFilterHeader || hasListIcon; } /** * Expands the given zone until bordered by empty cells or reached the sheet boundaries. */ getContiguousZone(sheetId, zoneToExpand) { /** Try to expand the zone by one col/row in any direction to include a new non-empty cell */ const expandZone = (zone) => { for (const col of range(zone.left, zone.right + 1)) { if (!this.isCellEmpty({ sheetId, col, row: zone.top - 1 })) { return { ...zone, top: zone.top - 1 }; } if (!this.isCellEmpty({ sheetId, col, row: zone.bottom + 1 })) { return { ...zone, bottom: zone.bottom + 1 }; } } for (const row of range(zone.top, zone.bottom + 1)) { if (!this.isCellEmpty({ sheetId, col: zone.left - 1, row })) { return { ...zone, left: zone.left - 1 }; } if (!this.isCellEmpty({ sheetId, col: zone.right + 1, row })) { return { ...zone, right: zone.right + 1 }; } } return zone; }; let hasExpanded = false; let zone = zoneToExpand; do { hasExpanded = false; const newZone = expandZone(zone); if (!isEqual(zone, newZone)) { hasExpanded = true; zone = newZone; continue; } } while (hasExpanded); return zone; } /** * Checks if a cell is empty (i.e. does not have a content or a formula does not spread over it). * If the cell is part of a merge, the check applies to the main cell of the merge. */ isCellEmpty(position) { const mainPosition = this.getters.getMainCellPosition(position); return this.getters.getEvaluatedCell(mainPosition).type === CellValueType.empty; } getColMaxWidth(sheetId, index) { const cellsPositions = positions(this.getters.getColsZone(sheetId, index, index)); const sizes = cellsPositions.map((position) => this.getCellWidth({ sheetId, ...position })); return Math.max(0, largeMax(sizes)); } /** * Check that any "sheetId" in the command matches an existing * sheet. */ checkSheetExists(cmd) { if ("sheetId" in cmd && this.getters.tryGetSheet(cmd.sheetId) === undefined) { return "InvalidSheetId" /* CommandResult.InvalidSheetId */; } return "Success" /* CommandResult.Success */; } /** * Check if zones in the command are well formed and * not outside the sheet. */ checkZonesAreInSheet(cmd) { const sheetId = "sheetId" in cmd ? cmd.sheetId : this.getters.tryGetActiveSheetId(); const zones = this.getters.getCommandZones(cmd); if (!sheetId && zones.length > 0) { return "NoActiveSheet" /* CommandResult.NoActiveSheet */; } if (sheetId && zones.length > 0) { return this.getters.checkZonesExistInSheet(sheetId, zones); } return "Success" /* CommandResult.Success */; } } class TableComputedStylePlugin extends UIPlugin { static getters = ["getCellTableStyle", "getCellTableBorder"]; tableStyles = {}; handle(cmd) { if (invalidateEvaluationCommands.has(cmd.type) || (cmd.type === "UPDATE_CELL" && "content" in cmd) || cmd.type === "EVALUATE_CELLS") { this.tableStyles = {}; return; } if (doesCommandInvalidatesTableStyle(cmd)) { if ("sheetId" in cmd) { delete this.tableStyles[cmd.sheetId]; } else { this.tableStyles = {}; } return; } } finalize() { for (const sheetId of this.getters.getSheetIds()) { if (!this.tableStyles[sheetId]) { this.tableStyles[sheetId] = {}; } for (const table of this.getters.getTables(sheetId)) { if (!this.tableStyles[sheetId][table.id]) { this.tableStyles[sheetId][table.id] = this.computeTableStyle(sheetId, table); } } } } getCellTableStyle(position) { const table = this.getters.getTable(position); if (!table) { return undefined; } return this.tableStyles[position.sheetId][table.id]().styles[position.col]?.[position.row]; } getCellTableBorder(position) { const table = this.getters.getTable(position); if (!table) { return undefined; } return this.tableStyles[position.sheetId][table.id]().borders[position.col]?.[position.row]; } computeTableStyle(sheetId, table) { return lazy(() => { const { config, numberOfCols, numberOfRows } = this.getTableRuntimeConfig(sheetId, table); const style = this.getters.getTableStyle(table.config.styleId); const relativeTableStyle = getComputedTableStyle(config, style, numberOfCols, numberOfRows); // Return the style with sheet coordinates instead of tables coordinates const mapping = this.getTableMapping(sheetId, table); const absoluteTableStyle = { borders: {}, styles: {} }; for (let col = 0; col < numberOfCols; col++) { const colInSheet = mapping.colMapping[col]; absoluteTableStyle.borders[colInSheet] = {}; absoluteTableStyle.styles[colInSheet] = {}; for (let row = 0; row < numberOfRows; row++) { const rowInSheet = mapping.rowMapping[row]; absoluteTableStyle.borders[colInSheet][rowInSheet] = relativeTableStyle.borders[col][row]; absoluteTableStyle.styles[colInSheet][rowInSheet] = relativeTableStyle.styles[col][row]; } } return absoluteTableStyle; }); } /** * Get the actual table config that will be used to compute the table style. It is different from * the config of the table because of hidden rows and columns in the sheet. For example remove the * hidden rows from config.numberOfHeaders. */ getTableRuntimeConfig(sheetId, table) { const tableZone = table.range.zone; const config = { ...table.config }; let numberOfCols = tableZone.right - tableZone.left + 1; let numberOfRows = tableZone.bottom - tableZone.top + 1; for (let row = tableZone.top; row <= tableZone.bottom; row++) { if (!this.getters.isRowHidden(sheetId, row)) { continue; } numberOfRows--; if (row - tableZone.top < table.config.numberOfHeaders) { config.numberOfHeaders--; if (config.numberOfHeaders < 0) { config.numberOfHeaders = 0; } } if (row === tableZone.bottom) { config.totalRow = false; } } for (let col = tableZone.left; col <= tableZone.right; col++) { if (!this.getters.isColHidden(sheetId, col)) { continue; } numberOfCols--; if (col === tableZone.left) { config.firstColumn = false; } if (col === tableZone.right) { config.lastColumn = false; } } return { config, numberOfCols, numberOfRows, }; } /** * Get a mapping: relative col/row position in the table <=> col/row in the sheet */ getTableMapping(sheetId, table) { const colMapping = {}; const rowMapping = {}; let colOffset = 0; let rowOffset = 0; const tableZone = table.range.zone; for (let col = tableZone.left; col <= tableZone.right; col++) { if (this.getters.isColHidden(sheetId, col)) { continue; } colMapping[colOffset] = col; colOffset++; for (let row = tableZone.top; row <= tableZone.bottom; row++) { if (this.getters.isRowHidden(sheetId, row)) { continue; } rowMapping[rowOffset] = row; rowOffset++; } } return { colMapping, rowMapping, }; } } const invalidateTableStyleCommands = [ "HIDE_COLUMNS_ROWS", "UNHIDE_COLUMNS_ROWS", "UNFOLD_HEADER_GROUP", "UNGROUP_HEADERS", "FOLD_HEADER_GROUP", "FOLD_ALL_HEADER_GROUPS", "UNFOLD_ALL_HEADER_GROUPS", "FOLD_HEADER_GROUPS_IN_ZONE", "UNFOLD_HEADER_GROUPS_IN_ZONE", "CREATE_TABLE", "UPDATE_TABLE", "UPDATE_FILTER", "REMOVE_TABLE", "RESIZE_TABLE", "CREATE_TABLE_STYLE", "REMOVE_TABLE_STYLE", ]; const invalidateTableStyleCommandsSet = new Set(invalidateTableStyleCommands); function doesCommandInvalidatesTableStyle(cmd) { return invalidateTableStyleCommandsSet.has(cmd.type); } class CellComputedStylePlugin extends UIPlugin { static getters = ["getCellComputedBorder", "getCellComputedStyle"]; styles = {}; borders = {}; handle(cmd) { if (invalidateEvaluationCommands.has(cmd.type) || cmd.type === "UPDATE_CELL" || cmd.type === "SET_FORMATTING" || cmd.type === "EVALUATE_CELLS") { this.styles = {}; this.borders = {}; return; } if (doesCommandInvalidatesTableStyle(cmd)) { if ("sheetId" in cmd) { delete this.styles[cmd.sheetId]; delete this.borders[cmd.sheetId]; } else { this.styles = {}; this.borders = {}; } return; } if (invalidateCFEvaluationCommands.has(cmd.type)) { this.styles = {}; return; } if (invalidateBordersCommands.has(cmd.type)) { this.borders = {}; return; } } getCellComputedBorder(position) { const { sheetId, row, col } = position; if (this.borders[sheetId]?.[row]?.[col] !== undefined) { return this.borders[sheetId][row][col]; } if (!this.borders[sheetId]) { this.borders[sheetId] = {}; } if (!this.borders[sheetId][row]) { this.borders[sheetId][row] = {}; } if (!this.borders[sheetId][row][col]) { this.borders[sheetId][row][col] = this.computeCellBorder(position); } return this.borders[sheetId][row][col]; } getCellComputedStyle(position) { const { sheetId, row, col } = position; if (this.styles[sheetId]?.[row]?.[col] !== undefined) { return this.styles[sheetId][row][col]; } if (!this.styles[sheetId]) { this.styles[sheetId] = {}; } if (!this.styles[sheetId][row]) { this.styles[sheetId][row] = {}; } if (!this.styles[sheetId][row][col]) { this.styles[sheetId][row][col] = this.computeCellStyle(position); } return this.styles[sheetId][row][col]; } computeCellBorder(position) { const cellBorder = this.getters.getCellBorder(position) || {}; const cellTableBorder = this.getters.getCellTableBorder(position) || {}; // Use removeFalsyAttributes to avoid overwriting borders with undefined values const border = { ...removeFalsyAttributes(cellTableBorder), ...removeFalsyAttributes(cellBorder), }; return isObjectEmptyRecursive(border) ? null : border; } computeCellStyle(position) { const cell = this.getters.getCell(position); const cfStyle = this.getters.getCellConditionalFormatStyle(position); const tableStyle = this.getters.getCellTableStyle(position); const computedStyle = { ...removeFalsyAttributes(tableStyle), ...removeFalsyAttributes(cell?.style), ...removeFalsyAttributes(cfStyle), }; const evaluatedCell = this.getters.getEvaluatedCell(position); if (evaluatedCell.link && !computedStyle.textColor) { computedStyle.textColor = LINK_COLOR; } return computedStyle; } } class DataValidationInsertionPlugin extends UIPlugin { handle(cmd) { switch (cmd.type) { case "ADD_DATA_VALIDATION_RULE": if (cmd.rule.criterion.type === "isBoolean") { const ranges = cmd.ranges.map((range) => this.getters.getRangeFromRangeData(range)); for (const position of getCellPositionsInRanges(ranges)) { const cell = this.getters.getCell(position); const evaluatedCell = this.getters.getEvaluatedCell(position); if (!cell?.content) { this.dispatch("UPDATE_CELL", { ...position, content: "FALSE" }); // In this case, a cell has been updated in the core plugin but // not yet evaluated. This can occur after a paste operation. } else if (cell?.content && evaluatedCell.type === CellValueType.empty) { let value; if (cell.content.startsWith("=")) { const result = this.getters.evaluateFormula(position.sheetId, cell.content); value = (isMatrix(result) ? result[0][0] : result)?.toString(); } else { value = cell.content; } if (!value || !isBoolean(value)) { this.dispatch("UPDATE_CELL", { ...position, content: "FALSE" }); } } else if (evaluatedCell.type !== CellValueType.boolean) { this.dispatch("UPDATE_CELL", { ...position, content: "FALSE" }); } } } } } } const genericRepeatsTransforms = [ repeatSheetDependantCommand, repeatTargetDependantCommand, repeatPositionDependantCommand, repeatRangeDependantCommand, ]; function repeatSheetDependantCommand(getters, command) { if (!("sheetId" in command)) return command; return { ...deepCopy(command), sheetId: getters.getActiveSheetId() }; } function repeatTargetDependantCommand(getters, command) { if (!("target" in command) || !Array.isArray(command.target)) return command; return { ...deepCopy(command), target: getters.getSelectedZones(), }; } function repeatZoneDependantCommand(getters, command) { if (!("zone" in command)) return command; return { ...deepCopy(command), zone: getters.getSelectedZone(), }; } function repeatPositionDependantCommand(getters, command) { if (!("row" in command) || !("col" in command)) return command; const { col, row } = getters.getActivePosition(); return { ...deepCopy(command), col, row }; } function repeatRangeDependantCommand(getters, command) { if (!("ranges" in command)) return command; return { ...deepCopy(command), ranges: getters .getSelectedZones() .map((zone) => getters.getRangeDataFromZone(getters.getActiveSheetId(), zone)), }; } const uuidGenerator = new UuidGenerator(); function repeatCreateChartCommand(getters, cmd) { return { ...repeatSheetDependantCommand(getters, cmd), id: uuidGenerator.uuidv4(), }; } function repeatCreateImageCommand(getters, cmd) { return { ...repeatSheetDependantCommand(getters, cmd), figureId: uuidGenerator.uuidv4(), }; } function repeatCreateFigureCommand(getters, cmd) { const newCmd = repeatSheetDependantCommand(getters, cmd); newCmd.figure.id = uuidGenerator.uuidv4(); return newCmd; } function repeatCreateSheetCommand(getters, cmd) { const newCmd = deepCopy(cmd); newCmd.sheetId = uuidGenerator.uuidv4(); const sheetName = cmd.name || getters.getSheet(getters.getActiveSheetId()).name; // Extract the prefix of the sheet name (everything before the number at the end of the name) const namePrefix = sheetName.match(/(.+?)\d*$/)?.[1] || sheetName; newCmd.name = getters.getNextSheetName(namePrefix); return newCmd; } function repeatAddColumnsRowsCommand(getters, cmd) { const currentPosition = getters.getActivePosition(); return { ...repeatSheetDependantCommand(getters, cmd), base: cmd.dimension === "COL" ? currentPosition.col : currentPosition.row, }; } function repeatHeaderElementCommand(getters, cmd) { const currentSelection = getters.getSelectedZone(); return { ...repeatSheetDependantCommand(getters, cmd), elements: cmd.dimension === "COL" ? range(currentSelection.left, currentSelection.right + 1) : range(currentSelection.top, currentSelection.bottom + 1), }; } function repeatInsertOrDeleteCellCommand(getters, cmd) { const currentSelection = getters.getSelectedZone(); return { ...deepCopy(cmd), zone: currentSelection, }; } function repeatAutoResizeCommand(getters, cmd) { const newCmd = deepCopy(cmd); const currentSelection = getters.getSelectedZone(); const { top, bottom, left, right } = currentSelection; if ("cols" in newCmd) { newCmd.cols = range(left, right + 1); } else if ("rows" in newCmd) { newCmd.rows = range(top, bottom + 1); } return newCmd; } function repeatSortCellsCommand(getters, cmd) { const currentSelection = getters.getSelectedZone(); return { ...repeatSheetDependantCommand(getters, cmd), col: currentSelection.left, row: currentSelection.top, zone: currentSelection, }; } function repeatPasteCommand(getters, cmd) { /** * Note : we have to store the state of the clipboard in the clipboard plugin, and introduce a * new command REPEAT_PASTE to be able to repeat the paste command. * * We cannot re-dispatch a paste, because the content of the clipboard may have changed in between. * * And we cannot adapt the sub-commands of the paste command, because they are dependant on the state of the sheet, * and may change based on the paste location. A simple example is that paste create new col/rows for the clipboard * content to fit the sheet. So there will be ADD_COL_ROW_COMMANDS in the sub-commands in the history, but repeating * paste might not need them. Or they could only needed for the repeated paste, not for the original. */ return { type: "REPEAT_PASTE", pasteOption: deepCopy(cmd.pasteOption), target: getters.getSelectedZones(), }; } function repeatGroupHeadersCommand(getters, cmd) { const currentSelection = getters.getSelectedZone(); return { ...repeatSheetDependantCommand(getters, cmd), start: cmd.dimension === "COL" ? currentSelection.left : currentSelection.top, end: cmd.dimension === "COL" ? currentSelection.right : currentSelection.bottom, }; } /** * Registry containing all the command that can be repeated on redo, and function to transform them * to the current state of the model. * * If the transform function is undefined, the command will be transformed using generic transformations. * (change the sheetId, the row, the col, the target, the ranges, to the current active sheet & selection) * */ const repeatCommandTransformRegistry = new Registry(); repeatCommandTransformRegistry.add("UPDATE_CELL", genericRepeat); repeatCommandTransformRegistry.add("CLEAR_CELL", genericRepeat); repeatCommandTransformRegistry.add("CLEAR_CELLS", genericRepeat); repeatCommandTransformRegistry.add("DELETE_CONTENT", genericRepeat); repeatCommandTransformRegistry.add("ADD_MERGE", genericRepeat); repeatCommandTransformRegistry.add("REMOVE_MERGE", genericRepeat); repeatCommandTransformRegistry.add("SET_FORMATTING", genericRepeat); repeatCommandTransformRegistry.add("CLEAR_FORMATTING", genericRepeat); repeatCommandTransformRegistry.add("SET_BORDER", genericRepeat); repeatCommandTransformRegistry.add("CREATE_TABLE", genericRepeat); repeatCommandTransformRegistry.add("REMOVE_TABLE", genericRepeat); repeatCommandTransformRegistry.add("HIDE_SHEET", genericRepeat); repeatCommandTransformRegistry.add("ADD_COLUMNS_ROWS", repeatAddColumnsRowsCommand); repeatCommandTransformRegistry.add("REMOVE_COLUMNS_ROWS", repeatHeaderElementCommand); repeatCommandTransformRegistry.add("HIDE_COLUMNS_ROWS", repeatHeaderElementCommand); repeatCommandTransformRegistry.add("RESIZE_COLUMNS_ROWS", repeatHeaderElementCommand); repeatCommandTransformRegistry.add("CREATE_SHEET", repeatCreateSheetCommand); repeatCommandTransformRegistry.add("CREATE_FIGURE", repeatCreateFigureCommand); repeatCommandTransformRegistry.add("CREATE_CHART", repeatCreateChartCommand); repeatCommandTransformRegistry.add("CREATE_IMAGE", repeatCreateImageCommand); repeatCommandTransformRegistry.add("GROUP_HEADERS", repeatGroupHeadersCommand); repeatCommandTransformRegistry.add("UNGROUP_HEADERS", repeatGroupHeadersCommand); repeatCommandTransformRegistry.add("UNGROUP_HEADERS", repeatGroupHeadersCommand); repeatCommandTransformRegistry.add("UNFOLD_HEADER_GROUPS_IN_ZONE", repeatZoneDependantCommand); repeatCommandTransformRegistry.add("FOLD_HEADER_GROUPS_IN_ZONE", repeatZoneDependantCommand); const repeatLocalCommandTransformRegistry = new Registry(); repeatLocalCommandTransformRegistry.add("PASTE", repeatPasteCommand); repeatLocalCommandTransformRegistry.add("INSERT_CELL", repeatInsertOrDeleteCellCommand); repeatLocalCommandTransformRegistry.add("DELETE_CELL", repeatInsertOrDeleteCellCommand); repeatLocalCommandTransformRegistry.add("AUTORESIZE_COLUMNS", repeatAutoResizeCommand); repeatLocalCommandTransformRegistry.add("AUTORESIZE_ROWS", repeatAutoResizeCommand); repeatLocalCommandTransformRegistry.add("SORT_CELLS", repeatSortCellsCommand); repeatLocalCommandTransformRegistry.add("SUM_SELECTION", genericRepeat); repeatLocalCommandTransformRegistry.add("SET_DECIMAL", genericRepeat); function genericRepeat(getters, command) { let transformedCommand = deepCopy(command); for (const repeatTransform of genericRepeatsTransforms) { transformedCommand = repeatTransform(getters, transformedCommand); } return transformedCommand; } function repeatCoreCommand(getters, command) { if (!command) { return undefined; } const isRepeatable = repeatCommandTransformRegistry.contains(command.type); if (!isRepeatable) { return undefined; } const transform = repeatCommandTransformRegistry.get(command.type); return transform(getters, command); } function repeatLocalCommand(getters, command, childCommands) { const isRepeatable = repeatLocalCommandTransformRegistry.contains(command.type); if (!isRepeatable) { return undefined; } const repeatTransform = repeatLocalCommandTransformRegistry.get(command.type); return repeatTransform(getters, command, childCommands); } function canRepeatRevision(revision) { if (!revision || !revision.rootCommand || typeof revision.rootCommand !== "object") { return false; } if (isCoreCommand(revision.rootCommand)) { return repeatCommandTransformRegistry.contains(revision.rootCommand.type); } return repeatLocalCommandTransformRegistry.contains(revision.rootCommand.type); } function repeatRevision(revision, getters) { if (!revision.rootCommand || typeof revision.rootCommand !== "object") { return undefined; } if (isCoreCommand(revision.rootCommand)) { return repeatCoreCommand(getters, revision.rootCommand); } return repeatLocalCommand(getters, revision.rootCommand, revision.commands); } /** * Local History * * The local history is responsible of tracking the locally state updates * It maintains the local undo and redo stack to allow to undo/redo only local * changes */ class HistoryPlugin extends UIPlugin { static getters = ["canUndo", "canRedo"]; /** * Ids of the revisions which can be undone */ undoStack = []; /** * Ids of the revisions which can be redone */ redoStack = []; session; constructor(config) { super(config); this.session = config.session; this.session.on("new-local-state-update", this, this.onNewLocalStateUpdate); this.session.on("pending-revisions-dropped", this, ({ revisionIds }) => this.drop(revisionIds)); this.session.on("snapshot", this, () => { this.undoStack = []; this.redoStack = []; }); } allowDispatch(cmd) { switch (cmd.type) { case "REQUEST_UNDO": if (!this.canUndo()) { return "EmptyUndoStack" /* CommandResult.EmptyUndoStack */; } break; case "REQUEST_REDO": if (!this.canRedo()) { return "EmptyRedoStack" /* CommandResult.EmptyRedoStack */; } break; } return "Success" /* CommandResult.Success */; } handle(cmd) { switch (cmd.type) { case "REQUEST_UNDO": case "REQUEST_REDO": // History changes (undo & redo) are *not* applied optimistically on the local state. // We wait a global confirmation from the server. The goal is to avoid handling concurrent // history changes on multiple clients which are very hard to manage correctly. this.requestHistoryChange(cmd.type === "REQUEST_UNDO" ? "UNDO" : "REDO"); } } finalize() { } requestHistoryChange(type) { const id = type === "UNDO" ? this.undoStack.pop() : this.redoStack.pop(); if (!id) { const lastNonRedoRevision = this.getPossibleRevisionToRepeat(); if (!lastNonRedoRevision) { return; } const repeatedCommands = repeatRevision(lastNonRedoRevision, this.getters); if (!repeatedCommands) { return; } if (!Array.isArray(repeatedCommands)) { this.dispatch(repeatedCommands.type, repeatedCommands); return; } for (const command of repeatedCommands) { this.dispatch(command.type, command); } return; } if (type === "UNDO") { this.session.undo(id); this.redoStack.push(id); } else { this.session.redo(id); this.undoStack.push(id); } } canUndo() { return this.undoStack.length > 0; } canRedo() { if (this.redoStack.length > 0) return true; const lastNonRedoRevision = this.getPossibleRevisionToRepeat(); return canRepeatRevision(lastNonRedoRevision); } drop(revisionIds) { this.undoStack = this.undoStack.filter((id) => !revisionIds.includes(id)); this.redoStack = []; } onNewLocalStateUpdate({ id }) { this.undoStack.push(id); this.redoStack = []; if (this.undoStack.length > MAX_HISTORY_STEPS) { this.undoStack.shift(); } } /** * Fetch the last revision which is not empty and not a repeated command * * Ignore repeated commands (REQUEST_REDO command as root command) * Ignore standard undo/redo revisions (that are empty) */ getPossibleRevisionToRepeat() { return this.session.getLastLocalNonEmptyRevision(); } } class SplitToColumnsPlugin extends UIPlugin { static getters = ["getAutomaticSeparator"]; allowDispatch(cmd) { switch (cmd.type) { case "SPLIT_TEXT_INTO_COLUMNS": return this.chainValidations(this.batchValidations(this.checkSingleColSelected, this.checkNonEmptySelector), this.batchValidations(this.checkNotOverwritingContent, this.checkSeparatorInSelection))(cmd); } return "Success" /* CommandResult.Success */; } handle(cmd) { switch (cmd.type) { case "SPLIT_TEXT_INTO_COLUMNS": this.splitIntoColumns(cmd); break; } } getAutomaticSeparator() { const cells = this.getters.getSelectedCells(); for (const cell of cells) { if (cell.value && cell.type === CellValueType.text) { const separator = this.getAutoSeparatorForString(cell.value); if (separator) { return separator; } } } return " "; } getAutoSeparatorForString(str) { const separators = [NEWLINE, ";", ",", " ", "."]; for (const separator of separators) { if (str.includes(separator)) { return separator; } } return; } splitIntoColumns({ separator, addNewColumns }) { const selection = this.getters.getSelectedZone(); const sheetId = this.getters.getActiveSheetId(); const splitted = this.getSplittedCols(selection, separator); if (addNewColumns) { this.addColsToAvoidCollisions(selection, splitted); } this.removeMergesInSplitZone(selection, splitted); this.addColumnsToNotOverflowSheet(selection, splitted); for (let i = 0; i < splitted.length; i++) { const row = selection.top + i; const splittedContent = splitted[i]; const col = selection.left; const mainCell = this.getters.getCell({ sheetId, col, row }); if (splittedContent.length === 1 && splittedContent[0] === mainCell?.content) { continue; } for (const [index, content] of splittedContent.entries()) { this.dispatch("UPDATE_CELL", { sheetId, col: col + index, row, content: canonicalizeNumberContent(content, this.getters.getLocale()), format: "", style: mainCell?.style || null, }); } } } getSplittedCols(selection, separator) { if (!separator) { throw new Error("Separator cannot be empty"); } const sheetId = this.getters.getActiveSheetId(); const splitted = []; for (const row of range(selection.top, selection.bottom + 1)) { const text = this.getters.getEvaluatedCell({ sheetId, col: selection.left, row, }).formattedValue; splitted.push(this.splitAndRemoveTrailingEmpty(text, separator)); } return splitted; } splitAndRemoveTrailingEmpty(string, separator) { const splitted = string.split(separator); while (splitted.length > 1 && splitted[splitted.length - 1] === "") { splitted.pop(); } return splitted; } willSplittedColsOverwriteContent(selection, splittedCols) { const sheetId = this.getters.getActiveSheetId(); for (const row of range(selection.top, selection.bottom + 1)) { const splittedText = splittedCols[row - selection.top]; for (let i = 1; i < splittedText.length; i++) { const cell = this.getters.getCell({ sheetId, col: selection.left + i, row }); if (cell && cell.content) { return true; } } } return false; } removeMergesInSplitZone(selection, splittedCols) { const sheetId = this.getters.getActiveSheetId(); const colsInSplitZone = Math.max(...splittedCols.map((s) => s.length)); const splitZone = { ...selection, right: selection.left + colsInSplitZone - 1 }; const merges = this.getters.getMergesInZone(sheetId, splitZone); this.dispatch("REMOVE_MERGE", { sheetId, target: merges }); } addColsToAvoidCollisions(selection, splittedCols) { const sheetId = this.getters.getActiveSheetId(); let colsToAdd = 0; for (const row of range(selection.top, selection.bottom + 1)) { const cellPosition = { sheetId, col: selection.left, row }; const splittedText = splittedCols[row - selection.top]; const colsToAddInRow = this.getColsToAddToAvoidCollision(cellPosition, splittedText); colsToAdd = Math.max(colsToAdd, colsToAddInRow); } if (colsToAdd) { this.dispatch("ADD_COLUMNS_ROWS", { dimension: "COL", base: selection.left, sheetId, quantity: colsToAdd, position: "after", }); } } getColsToAddToAvoidCollision(cellPosition, splittedText) { const maxColumnsToSpread = splittedText.length; for (let i = 1; i < maxColumnsToSpread; i++) { const col = cellPosition.col + i; const cell = this.getters.getCell({ ...cellPosition, col }); if (cell && cell.content) { return maxColumnsToSpread - i; } } return 0; } addColumnsToNotOverflowSheet(selection, splittedCols) { const sheetId = this.getters.getActiveSheetId(); const maxColumnsToSpread = Math.max(...splittedCols.map((s) => s.length - 1)); const maxColIndex = this.getters.getNumberCols(sheetId) - 1; if (selection.left + maxColumnsToSpread > maxColIndex) { this.dispatch("ADD_COLUMNS_ROWS", { dimension: "COL", base: maxColIndex, sheetId, quantity: selection.left + maxColumnsToSpread - maxColIndex, position: "after", }); } } checkSingleColSelected() { if (!this.getters.isSingleColSelected()) { return "MoreThanOneColumnSelected" /* CommandResult.MoreThanOneColumnSelected */; } return "Success" /* CommandResult.Success */; } checkNonEmptySelector(cmd) { if (cmd.separator === "") { return "EmptySplitSeparator" /* CommandResult.EmptySplitSeparator */; } return "Success" /* CommandResult.Success */; } checkNotOverwritingContent(cmd) { if (cmd.addNewColumns || cmd.force) { return "Success" /* CommandResult.Success */; } const selection = this.getters.getSelectedZones()[0]; const splitted = this.getSplittedCols(selection, cmd.separator); if (this.willSplittedColsOverwriteContent(selection, splitted)) { return "SplitWillOverwriteContent" /* CommandResult.SplitWillOverwriteContent */; } return "Success" /* CommandResult.Success */; } checkSeparatorInSelection({ separator }) { const cells = this.getters.getSelectedCells(); for (const cell of cells) { if (cell.formattedValue.includes(separator)) { return "Success" /* CommandResult.Success */; } } return "NoSplitSeparatorInSelection" /* CommandResult.NoSplitSeparatorInSelection */; } } class TableAutofillPlugin extends UIPlugin { handle(cmd) { switch (cmd.type) { case "AUTOFILL_TABLE_COLUMN": const table = this.getters.getCoreTable(cmd); const cell = this.getters.getCell(cmd); if (!table?.config.automaticAutofill || table.type === "dynamic" || !cell?.isFormula) { return; } const { col, row } = cmd; const tableContentZone = getTableContentZone(table.range.zone, table.config); if (tableContentZone && isInside(col, row, tableContentZone)) { const top = cmd.autofillRowStart ?? tableContentZone.top; const bottom = cmd.autofillRowEnd ?? tableContentZone.bottom; const autofillZone = { ...tableContentZone, top, bottom }; this.autofillTableZone(cmd, autofillZone); } break; } } autofillTableZone(autofillSource, zone) { if (zone.top === zone.bottom) { return; } const { col, row, sheetId } = autofillSource; for (let r = zone.top; r <= zone.bottom; r++) { if (r === row) { continue; } if (this.getters.getEvaluatedCell({ col, row: r, sheetId }).type !== CellValueType.empty) { return; } } // TODO: seems odd that autofill a table column have side effects on the selection. Autofill commands should be // refactored to take the selection as a parameter const oldSelection = { zone: this.getters.getSelectedZone(), cell: this.getters.getActivePosition(), }; this.selection.selectCell(col, row); this.dispatch("AUTOFILL_SELECT", { col, row: zone.bottom }); this.dispatch("AUTOFILL"); this.selection.selectCell(col, row); this.dispatch("AUTOFILL_SELECT", { col, row: zone.top }); this.dispatch("AUTOFILL"); this.selection.selectZone(oldSelection); } } class TableResizeUI extends UIPlugin { allowDispatch(cmd) { switch (cmd.type) { case "RESIZE_TABLE": const table = this.getters.getCoreTableMatchingTopLeft(cmd.sheetId, cmd.zone); if (!table) { return "TableNotFound" /* CommandResult.TableNotFound */; } const oldTableZone = table.range.zone; const newTableZone = this.getters.getRangeFromRangeData(cmd.newTableRange).zone; if (newTableZone.top !== oldTableZone.top || newTableZone.left !== oldTableZone.left) { return "InvalidTableResize" /* CommandResult.InvalidTableResize */; } return this.canDispatch("UPDATE_TABLE", { ...cmd }).reasons; } return "Success" /* CommandResult.Success */; } handle(cmd) { switch (cmd.type) { case "RESIZE_TABLE": { const table = this.getters.getCoreTableMatchingTopLeft(cmd.sheetId, cmd.zone); this.dispatch("UPDATE_TABLE", { ...cmd }); if (!table || !table.config.automaticAutofill) return; const oldTableZone = table.range.zone; const newTableZone = this.getters.getRangeFromRangeData(cmd.newTableRange).zone; if (newTableZone.bottom >= oldTableZone.bottom) { for (let col = newTableZone.left; col <= newTableZone.right; col++) { const autofillSource = { col, row: oldTableZone.bottom, sheetId: cmd.sheetId }; if (this.getters.getCell(autofillSource)?.content.startsWith("=")) { this.dispatch("AUTOFILL_TABLE_COLUMN", { ...autofillSource, autofillRowStart: oldTableZone.bottom, autofillRowEnd: newTableZone.bottom, }); } } break; } } } } } /** * Clipboard Plugin * * This clipboard manages all cut/copy/paste interactions internal to the * application, and with the OS clipboard as well. */ class ClipboardPlugin extends UIPlugin { static layers = ["Clipboard"]; static getters = [ "getClipboardContent", "getClipboardId", "getClipboardTextContent", "isCutOperation", ]; status = "invisible"; originSheetId; copiedData; _isCutOperation = false; clipboardId = new UuidGenerator().uuidv4(); // --------------------------------------------------------------------------- // Command Handling // --------------------------------------------------------------------------- allowDispatch(cmd) { switch (cmd.type) { case "CUT": const zones = this.getters.getSelectedZones(); return this.isCutAllowedOn(zones); case "PASTE_FROM_OS_CLIPBOARD": { const copiedData = this.convertTextToClipboardData(cmd.clipboardContent.text ?? ""); const pasteOption = cmd.pasteOption; return this.isPasteAllowed(cmd.target, copiedData, { pasteOption, isCutOperation: false }); } case "PASTE": { if (!this.copiedData) { return "EmptyClipboard" /* CommandResult.EmptyClipboard */; } const pasteOption = cmd.pasteOption; return this.isPasteAllowed(cmd.target, this.copiedData, { pasteOption: pasteOption, isCutOperation: this._isCutOperation, }); } case "COPY_PASTE_CELLS_ABOVE": { const zones = this.getters.getSelectedZones(); if (zones.length > 1 || (zones[0].top === 0 && zones[0].bottom === 0)) { return "InvalidCopyPasteSelection" /* CommandResult.InvalidCopyPasteSelection */; } break; } case "COPY_PASTE_CELLS_ON_LEFT": { const zones = this.getters.getSelectedZones(); if (zones.length > 1 || (zones[0].left === 0 && zones[0].right === 0)) { return "InvalidCopyPasteSelection" /* CommandResult.InvalidCopyPasteSelection */; } break; } case "INSERT_CELL": { const { cut, paste } = this.getInsertCellsTargets(cmd.zone, cmd.shiftDimension); const copiedData = this.copy(cut); return this.isPasteAllowed(paste, copiedData, { isCutOperation: true }); } case "DELETE_CELL": { const { cut, paste } = this.getDeleteCellsTargets(cmd.zone, cmd.shiftDimension); const copiedData = this.copy(cut); return this.isPasteAllowed(paste, copiedData, { isCutOperation: true }); } } return "Success" /* CommandResult.Success */; } handle(cmd) { switch (cmd.type) { case "COPY": case "CUT": const zones = this.getters.getSelectedZones(); this.status = "visible"; this.originSheetId = this.getters.getActiveSheetId(); this.copiedData = this.copy(zones); this._isCutOperation = cmd.type === "CUT"; break; case "PASTE_FROM_OS_CLIPBOARD": { this._isCutOperation = false; this.copiedData = cmd.clipboardContent.data || this.convertTextToClipboardData(cmd.clipboardContent.text ?? ""); const pasteOption = cmd.pasteOption; this.paste(cmd.target, this.copiedData, { pasteOption, selectTarget: true, isCutOperation: false, }); this.status = "invisible"; break; } case "PASTE": { const pasteOption = cmd.pasteOption; this.paste(cmd.target, this.copiedData, { pasteOption, selectTarget: true, isCutOperation: this._isCutOperation, }); this.status = "invisible"; if (this._isCutOperation) { this.copiedData = undefined; this._isCutOperation = false; } break; } case "COPY_PASTE_CELLS_ABOVE": { const zone = this.getters.getSelectedZone(); const multipleRowsInSelection = zone.top !== zone.bottom; const copyTarget = { ...zone, bottom: multipleRowsInSelection ? zone.top : zone.top - 1, top: multipleRowsInSelection ? zone.top : zone.top - 1, }; this.originSheetId = this.getters.getActiveSheetId(); const copiedData = this.copy([copyTarget]); this.paste([zone], copiedData, { isCutOperation: false, selectTarget: true, }); } break; case "COPY_PASTE_CELLS_ON_LEFT": { const zone = this.getters.getSelectedZone(); const multipleColsInSelection = zone.left !== zone.right; const copyTarget = { ...zone, right: multipleColsInSelection ? zone.left : zone.left - 1, left: multipleColsInSelection ? zone.left : zone.left - 1, }; this.originSheetId = this.getters.getActiveSheetId(); const copiedData = this.copy([copyTarget]); this.paste([zone], copiedData, { isCutOperation: false, selectTarget: true, }); } break; case "CLEAN_CLIPBOARD_HIGHLIGHT": this.status = "invisible"; break; case "DELETE_CELL": { const { cut, paste } = this.getDeleteCellsTargets(cmd.zone, cmd.shiftDimension); if (!isZoneValid(cut[0])) { this.dispatch("CLEAR_CELLS", { target: [cmd.zone], sheetId: this.getters.getActiveSheetId(), }); break; } const copiedData = this.copy(cut); this.paste(paste, copiedData, { isCutOperation: true }); break; } case "INSERT_CELL": { const { cut, paste } = this.getInsertCellsTargets(cmd.zone, cmd.shiftDimension); const copiedData = this.copy(cut); this.paste(paste, copiedData, { isCutOperation: true }); break; } case "ADD_COLUMNS_ROWS": { this.status = "invisible"; // If we add a col/row inside or before the cut area, we invalidate the clipboard if (this._isCutOperation !== true || cmd.sheetId !== this.copiedData?.sheetId) { return; } const isClipboardDirty = this.isColRowDirtyingClipboard(cmd.position === "before" ? cmd.base : cmd.base + 1, cmd.dimension); if (isClipboardDirty) { this.copiedData = undefined; } break; } case "REMOVE_COLUMNS_ROWS": { this.status = "invisible"; // If we remove a col/row inside or before the cut area, we invalidate the clipboard if (this._isCutOperation !== true || cmd.sheetId !== this.copiedData?.sheetId) { return; } for (let el of cmd.elements) { const isClipboardDirty = this.isColRowDirtyingClipboard(el, cmd.dimension); if (isClipboardDirty) { this.copiedData = undefined; break; } } this.status = "invisible"; break; } case "REPEAT_PASTE": { this.paste(cmd.target, this.copiedData, { isCutOperation: false, pasteOption: cmd.pasteOption, selectTarget: true, }); break; } case "DELETE_SHEET": if (this._isCutOperation !== true) { return; } if (this.originSheetId === cmd.sheetId) { this.copiedData = undefined; this.status = "invisible"; } break; default: if (isCoreCommand(cmd)) { this.status = "invisible"; } } } convertTextToClipboardData(clipboardData) { const handlers = this.selectClipboardHandlers({ figureId: true }).concat(this.selectClipboardHandlers({})); let copiedData = {}; for (const { handlerName, handler } of handlers) { const data = handler.convertTextToClipboardData(clipboardData); copiedData[handlerName] = data; const minimalKeys = ["sheetId", "cells", "zones", "figureId"]; for (const key of minimalKeys) { if (data && key in data) { copiedData[key] = data[key]; } } } return copiedData; } selectClipboardHandlers(data) { const handlersRegistry = "figureId" in data ? clipboardHandlersRegistries.figureHandlers : clipboardHandlersRegistries.cellHandlers; return handlersRegistry.getKeys().map((handlerName) => { const Handler = handlersRegistry.get(handlerName); return { handlerName, handler: new Handler(this.getters, this.dispatch) }; }); } isCutAllowedOn(zones) { const clipboardData = this.getClipboardData(zones); for (const { handler } of this.selectClipboardHandlers(clipboardData)) { const result = handler.isCutAllowed(clipboardData); if (result !== "Success" /* CommandResult.Success */) { return result; } } return "Success" /* CommandResult.Success */; } isPasteAllowed(target, copiedData, options) { for (const { handler } of this.selectClipboardHandlers(copiedData)) { const result = handler.isPasteAllowed(this.getters.getActiveSheetId(), target, copiedData, { ...options, }); if (result !== "Success" /* CommandResult.Success */) { return result; } } return "Success" /* CommandResult.Success */; } isColRowDirtyingClipboard(position, dimension) { if (!this.copiedData || !this.copiedData.zones) { return false; } const { zones } = this.copiedData; for (let zone of zones) { if (dimension === "COL" && position <= zone.right) { return true; } if (dimension === "ROW" && position <= zone.bottom) { return true; } } return false; } copy(zones) { let copiedData = {}; const clipboardData = this.getClipboardData(zones); for (const { handlerName, handler } of this.selectClipboardHandlers(clipboardData)) { const data = handler.copy(clipboardData); copiedData[handlerName] = data; const minimalKeys = ["sheetId", "cells", "zones", "figureId"]; for (const key of minimalKeys) { if (data && key in data) { copiedData[key] = data[key]; } } } return copiedData; } paste(zones, copiedData, options) { if (!copiedData) { return; } let zone = undefined; let selectedZones = []; const sheetId = this.getters.getActiveSheetId(); let target = { sheetId, zones, }; const handlers = this.selectClipboardHandlers(copiedData); for (const { handlerName, handler } of handlers) { const handlerData = copiedData[handlerName]; if (!handlerData) { continue; } const currentTarget = handler.getPasteTarget(sheetId, zones, handlerData, options); if (currentTarget.figureId) { target.figureId = currentTarget.figureId; } for (const targetZone of currentTarget.zones) { selectedZones.push(targetZone); if (zone === undefined) { zone = targetZone; continue; } zone = union(zone, targetZone); } } if (zone !== undefined) { this.addMissingDimensions(this.getters.getActiveSheetId(), zone.right - zone.left + 1, zone.bottom - zone.top + 1, zone.left, zone.top); } handlers.forEach(({ handlerName, handler }) => { const handlerData = copiedData[handlerName]; if (handlerData) { handler.paste(target, handlerData, options); } }); if (!options?.selectTarget) { return; } const selection = zones[0]; const col = selection.left; const row = selection.top; this.selection.getBackToDefault(); this.selection.selectZone({ cell: { col, row }, zone: union(...selectedZones) }, { scrollIntoView: false }); } /** * Add columns and/or rows to ensure that col + width and row + height are still * in the sheet */ addMissingDimensions(sheetId, width, height, col, row) { const missingRows = height + row - this.getters.getNumberRows(sheetId); if (missingRows > 0) { this.dispatch("ADD_COLUMNS_ROWS", { dimension: "ROW", base: this.getters.getNumberRows(sheetId) - 1, sheetId, quantity: missingRows, position: "after", }); } const missingCols = width + col - this.getters.getNumberCols(sheetId); if (missingCols > 0) { this.dispatch("ADD_COLUMNS_ROWS", { dimension: "COL", base: this.getters.getNumberCols(sheetId) - 1, sheetId, quantity: missingCols, position: "after", }); } } // --------------------------------------------------------------------------- // Getters // --------------------------------------------------------------------------- /** * Format the current clipboard to a string suitable for being pasted in other * programs. * * - add a tab character between each consecutive cells * - add a newline character between each line * * Note that it returns \t if the clipboard is empty. This is necessary for the * clipboard copy event to add it as data, otherwise an empty string is not * considered as a copy content. */ getClipboardTextContent() { return this.getPlainTextContent(); } getClipboardId() { return this.clipboardId; } getClipboardContent() { return { [ClipboardMIMEType.PlainText]: this.getPlainTextContent(), [ClipboardMIMEType.Html]: this.getHTMLContent(), }; } getSheetData() { const data = { version: CURRENT_VERSION, clipboardId: this.clipboardId, }; if (this.copiedData && "figureId" in this.copiedData) { return data; } return { ...data, ...this.copiedData, }; } getPlainTextContent() { if (!this.copiedData?.cells) { return "\t"; } return (this.copiedData.cells .map((cells) => { return cells .map((c) => this.getters.shouldShowFormulas() && c?.tokens?.length ? c?.content || "" : c.evaluatedCell?.formattedValue || "") .join("\t"); }) .join("\n") || "\t"); } getHTMLContent() { let innerHTML = ""; const cells = this.copiedData?.cells; if (!cells) { innerHTML = "\t"; } else if (cells.length === 1 && cells[0].length === 1) { innerHTML = `${this.getters.getCellText(cells[0][0].position)}`; } else if (!cells[0][0]) { return ""; } else { let htmlTable = ``; for (const row of cells) { htmlTable += ""; for (const cell of row) { if (!cell) { continue; } const cssStyle = cssPropertiesToCss(cellStyleToCss(this.getters.getCellComputedStyle(cell.position))); const cellText = this.getters.getCellText(cell.position); htmlTable += `"; } htmlTable += ""; } htmlTable += "
` + xmlEscape(cellText) + "
"; innerHTML = htmlTable; } const serializedData = JSON.stringify(this.getSheetData()); return `
${innerHTML}
`; } isCutOperation() { return this._isCutOperation ?? false; } // --------------------------------------------------------------------------- // Private methods // --------------------------------------------------------------------------- getDeleteCellsTargets(zone, dimension) { const sheetId = this.getters.getActiveSheetId(); let cut; if (dimension === "COL") { cut = { ...zone, left: zone.right + 1, right: this.getters.getNumberCols(sheetId) - 1, }; } else { cut = { ...zone, top: zone.bottom + 1, bottom: this.getters.getNumberRows(sheetId) - 1, }; } return { cut: [cut], paste: [zone] }; } getInsertCellsTargets(zone, dimension) { const sheetId = this.getters.getActiveSheetId(); let cut; let paste; if (dimension === "COL") { cut = { ...zone, right: this.getters.getNumberCols(sheetId) - 1, }; paste = { ...zone, left: zone.right + 1, right: zone.right + 1, }; } else { cut = { ...zone, bottom: this.getters.getNumberRows(sheetId) - 1, }; paste = { ...zone, top: zone.bottom + 1, bottom: this.getters.getNumberRows(sheetId) - 1 }; } return { cut: [cut], paste: [paste] }; } getClipboardData(zones) { const sheetId = this.getters.getActiveSheetId(); const selectedFigureId = this.getters.getSelectedFigureId(); if (selectedFigureId) { return { figureId: selectedFigureId, sheetId }; } return getClipboardDataPositions(sheetId, zones); } // --------------------------------------------------------------------------- // Grid rendering // --------------------------------------------------------------------------- drawLayer(renderingContext) { if (this.status !== "visible" || !this.copiedData) { return; } const { sheetId, zones } = this.copiedData; if (sheetId !== this.getters.getActiveSheetId() || !zones || !zones.length) { return; } const { ctx, thinLineWidth } = renderingContext; ctx.setLineDash([8, 5]); ctx.strokeStyle = SELECTION_BORDER_COLOR; ctx.lineWidth = 3.3 * thinLineWidth; for (const zone of zones) { const { x, y, width, height } = this.getters.getVisibleRect(zone); if (width > 0 && height > 0) { ctx.strokeRect(x, y, width, height); } } } } class FilterEvaluationPlugin extends UIPlugin { static getters = [ "getFilterHiddenValues", "getFirstTableInSelection", "isRowFiltered", "isFilterActive", ]; filterValues = {}; hiddenRows = {}; isEvaluationDirty = false; allowDispatch(cmd) { switch (cmd.type) { case "UPDATE_FILTER": if (!this.getters.getFilterId(cmd)) { return "FilterNotFound" /* CommandResult.FilterNotFound */; } break; } return "Success" /* CommandResult.Success */; } handle(cmd) { switch (cmd.type) { case "UNDO": case "REDO": case "UPDATE_CELL": case "EVALUATE_CELLS": case "ACTIVATE_SHEET": case "REMOVE_TABLE": case "ADD_COLUMNS_ROWS": case "REMOVE_COLUMNS_ROWS": case "UPDATE_TABLE": this.isEvaluationDirty = true; break; case "START": for (const sheetId of this.getters.getSheetIds()) { this.filterValues[sheetId] = {}; } break; case "CREATE_SHEET": this.filterValues[cmd.sheetId] = {}; break; case "HIDE_COLUMNS_ROWS": case "UNHIDE_COLUMNS_ROWS": case "GROUP_HEADERS": case "UNGROUP_HEADERS": case "FOLD_HEADER_GROUP": case "UNFOLD_HEADER_GROUP": case "FOLD_ALL_HEADER_GROUPS": case "UNFOLD_ALL_HEADER_GROUPS": case "FOLD_HEADER_GROUPS_IN_ZONE": case "UNFOLD_HEADER_GROUPS_IN_ZONE": this.updateHiddenRows(cmd.sheetId); break; case "UPDATE_FILTER": this.updateFilter(cmd); this.updateHiddenRows(cmd.sheetId); break; case "DUPLICATE_SHEET": this.filterValues[cmd.sheetIdTo] = deepCopy(this.filterValues[cmd.sheetId]); break; // If we don't handle DELETE_SHEET, on one hand we will have some residual data, on the other hand we keep the data // on DELETE_SHEET followed by undo } } finalize() { if (this.isEvaluationDirty) { for (const sheetId of this.getters.getSheetIds()) { this.updateHiddenRows(sheetId); } this.isEvaluationDirty = false; } } isRowFiltered(sheetId, row) { return !!this.hiddenRows[sheetId]?.has(row); } getFilterHiddenValues(position) { const id = this.getters.getFilterId(position); const sheetId = position.sheetId; if (!id || !this.filterValues[sheetId]) return []; return this.filterValues[sheetId][id] || []; } isFilterActive(position) { const id = this.getters.getFilterId(position); const sheetId = position.sheetId; return Boolean(id && this.filterValues[sheetId]?.[id]?.length); } getFirstTableInSelection() { const sheetId = this.getters.getActiveSheetId(); const selection = this.getters.getSelectedZones(); return this.getters.getTablesOverlappingZones(sheetId, selection)[0]; } updateFilter({ col, row, hiddenValues, sheetId }) { const id = this.getters.getFilterId({ sheetId, col, row }); if (!id) return; if (!this.filterValues[sheetId]) this.filterValues[sheetId] = {}; this.filterValues[sheetId][id] = hiddenValues; } updateHiddenRows(sheetId) { const filters = this.getters .getFilters(sheetId) .sort((filter1, filter2) => filter1.rangeWithHeaders.zone.top - filter2.rangeWithHeaders.zone.top); const hiddenRows = new Set(); for (let filter of filters) { // Disable filters whose header are hidden if (hiddenRows.has(filter.rangeWithHeaders.zone.top) || this.getters.isRowHiddenByUser(sheetId, filter.rangeWithHeaders.zone.top)) { continue; } const filteredValues = this.filterValues[sheetId]?.[filter.id]?.map(toLowerCase); const filteredZone = filter.filteredRange?.zone; if (!filteredValues || !filteredZone) continue; for (let row = filteredZone.top; row <= filteredZone.bottom; row++) { const value = this.getCellValueAsString(sheetId, filter.col, row); if (filteredValues.includes(value)) { hiddenRows.add(row); } } } this.hiddenRows[sheetId] = hiddenRows; } getCellValueAsString(sheetId, col, row) { const value = this.getters.getEvaluatedCell({ sheetId, col, row }).formattedValue; return value.toLowerCase(); } exportForExcel(data) { for (const sheetData of data.sheets) { const sheetId = sheetData.id; for (const tableData of sheetData.tables) { const tableZone = toZone(tableData.range); const filters = []; const headerNames = []; for (const i of range(0, zoneToDimension(tableZone).numberOfCols)) { const position = { sheetId: sheetData.id, col: tableZone.left + i, row: tableZone.top, }; const filteredValues = this.getFilterHiddenValues(position); const filter = this.getters.getFilter(position); const valuesInFilterZone = filter?.filteredRange ? positions(filter.filteredRange.zone).map((position) => this.getters.getEvaluatedCell({ sheetId, ...position }).formattedValue) : []; if (filteredValues.length) { const xlsxDisplayedValues = valuesInFilterZone .filter((val) => val) .filter((val) => !filteredValues.includes(val)); filters.push({ colId: i, displayedValues: [...new Set(xlsxDisplayedValues)], displayBlanks: !filteredValues.includes("") && valuesInFilterZone.some((val) => !val), }); } // In xlsx, column header should ALWAYS be a string and should be unique in the table const headerString = this.getters.getEvaluatedCell(position).formattedValue; const headerName = this.getUniqueColNameForExcel(i, headerString, headerNames); headerNames.push(headerName); sheetData.cells[toXC(position.col, position.row)] = { ...sheetData.cells[toXC(position.col, position.row)], content: headerName, value: headerName, isFormula: false, }; } tableData.filters = filters; } } } /** * Get an unique column name for the column at colIndex. If the column name is already in the array of used column names, * concatenate a number to the name until we find a new unique name (eg. "ColName" => "ColName1" => "ColName2" ...) */ getUniqueColNameForExcel(colIndex, colName, usedColNames) { if (!colName) { colName = `Column${colIndex}`; } let currentColName = colName; let i = 2; while (usedColNames.includes(currentColName)) { currentColName = colName + String(i); i++; } return currentColName; } } /** * SelectionPlugin */ class GridSelectionPlugin extends UIPlugin { static layers = ["Selection"]; static getters = [ "getActiveSheet", "getActiveSheetId", "getActiveCell", "getActiveCols", "getActiveRows", "getCurrentStyle", "getSelectedZones", "getSelectedZone", "getSelectedCells", "getSelectedFigureId", "getSelection", "getActivePosition", "getSheetPosition", "isSingleColSelected", "getElementsFromSelection", "tryGetActiveSheetId", "isGridSelectionActive", ]; gridSelection = { anchor: { cell: { col: 0, row: 0 }, zone: { top: 0, left: 0, bottom: 0, right: 0 }, }, zones: [{ top: 0, left: 0, bottom: 0, right: 0 }], }; selectedFigureId = null; sheetsData = {}; moveClient; // This flag is used to avoid to historize the ACTIVE_SHEET command when it's // the main command. activeSheet = null; constructor(config) { super(config); this.moveClient = config.moveClient; } // --------------------------------------------------------------------------- // Command Handling // --------------------------------------------------------------------------- allowDispatch(cmd) { switch (cmd.type) { case "ACTIVATE_SHEET": try { const sheet = this.getters.getSheet(cmd.sheetIdTo); if (!sheet.isVisible) { return "SheetIsHidden" /* CommandResult.SheetIsHidden */; } break; } catch (error) { return "InvalidSheetId" /* CommandResult.InvalidSheetId */; } case "MOVE_COLUMNS_ROWS": return this.isMoveElementAllowed(cmd); } return "Success" /* CommandResult.Success */; } handleEvent(event) { const anchor = event.anchor; let zones = []; switch (event.mode) { case "overrideSelection": zones = [anchor.zone]; break; case "updateAnchor": zones = [...this.gridSelection.zones]; const index = zones.findIndex((z) => isEqual(z, event.previousAnchor.zone)); if (index >= 0) { zones[index] = anchor.zone; } break; case "newAnchor": zones = [...this.gridSelection.zones, anchor.zone]; break; } this.setSelectionMixin(event.anchor, zones); /** Any change to the selection has to be reflected in the selection processor. */ this.selection.resetDefaultAnchor(this, deepCopy(this.gridSelection.anchor)); const { col, row } = this.gridSelection.anchor.cell; this.moveClient({ sheetId: this.getters.getActiveSheetId(), col, row, }); this.selectedFigureId = null; } handle(cmd) { switch (cmd.type) { case "ACTIVATE_SHEET": this.selectedFigureId = null; break; case "DELETE_FIGURE": if (this.selectedFigureId === cmd.id) { this.selectedFigureId = null; } break; case "DELETE_SHEET": if (this.selectedFigureId && this.getters.getFigure(cmd.sheetId, this.selectedFigureId)) { this.selectedFigureId = null; } break; } switch (cmd.type) { case "START": const firstSheetId = this.getters.getVisibleSheetIds()[0]; this.activateSheet(firstSheetId, firstSheetId); const { col, row } = this.getters.getNextVisibleCellPosition({ sheetId: firstSheetId, col: 0, row: 0, }); this.selectCell(col, row); this.selection.registerAsDefault(this, this.gridSelection.anchor, { handleEvent: this.handleEvent.bind(this), }); this.moveClient({ sheetId: firstSheetId, col: 0, row: 0 }); break; case "ACTIVATE_SHEET": { this.activateSheet(cmd.sheetIdFrom, cmd.sheetIdTo); break; } case "REMOVE_COLUMNS_ROWS": { const sheetId = this.getters.getActiveSheetId(); if (cmd.sheetId === sheetId) { if (cmd.dimension === "COL") { this.onColumnsRemoved(cmd); } else { this.onRowsRemoved(cmd); } const { col, row } = this.gridSelection.anchor.cell; this.moveClient({ sheetId, col, row }); } break; } case "ADD_COLUMNS_ROWS": { const sheetId = this.getters.getActiveSheetId(); if (cmd.sheetId === sheetId) { this.onAddElements(cmd); const { col, row } = this.gridSelection.anchor.cell; this.moveClient({ sheetId, col, row }); } break; } case "MOVE_COLUMNS_ROWS": if (cmd.sheetId === this.getActiveSheetId()) { this.onMoveElements(cmd); } break; case "SELECT_FIGURE": this.selectedFigureId = cmd.id; break; case "ACTIVATE_NEXT_SHEET": this.activateNextSheet("right"); break; case "ACTIVATE_PREVIOUS_SHEET": this.activateNextSheet("left"); break; case "HIDE_SHEET": if (cmd.sheetId === this.getActiveSheetId()) { this.dispatch("ACTIVATE_SHEET", { sheetIdFrom: cmd.sheetId, sheetIdTo: this.getters.getVisibleSheetIds()[0], }); } break; case "UNDO": case "REDO": case "DELETE_SHEET": const deletedSheetIds = Object.keys(this.sheetsData).filter((sheetId) => !this.getters.tryGetSheet(sheetId)); for (const sheetId of deletedSheetIds) { delete this.sheetsData[sheetId]; } for (const sheetId in this.sheetsData) { const gridSelection = this.clipSelection(sheetId, this.sheetsData[sheetId].gridSelection); this.sheetsData[sheetId] = { gridSelection: deepCopy(gridSelection), }; } if (!this.getters.tryGetSheet(this.getters.getActiveSheetId())) { const currentSheetIds = this.getters.getVisibleSheetIds(); this.activeSheet = this.getters.getSheet(currentSheetIds[0]); if (this.activeSheet.id in this.sheetsData) { const { anchor } = this.clipSelection(this.activeSheet.id, this.sheetsData[this.activeSheet.id].gridSelection); this.selectCell(anchor.cell.col, anchor.cell.row); } else { this.selectCell(0, 0); } const { col, row } = this.gridSelection.anchor.cell; this.moveClient({ sheetId: this.getters.getActiveSheetId(), col, row, }); } const sheetId = this.getters.getActiveSheetId(); this.gridSelection.zones = this.gridSelection.zones.map((z) => this.getters.expandZone(sheetId, z)); this.gridSelection.anchor.zone = this.getters.expandZone(sheetId, this.gridSelection.anchor.zone); this.setSelectionMixin(this.gridSelection.anchor, this.gridSelection.zones); this.selectedFigureId = null; break; } } finalize() { /** Any change to the selection has to be reflected in the selection processor. */ this.selection.resetDefaultAnchor(this, deepCopy(this.gridSelection.anchor)); } // --------------------------------------------------------------------------- // Getters // --------------------------------------------------------------------------- isGridSelectionActive() { return this.selection.isListening(this); } getActiveSheet() { return this.activeSheet; } getActiveSheetId() { return this.activeSheet.id; } tryGetActiveSheetId() { return this.activeSheet?.id; } getActiveCell() { return this.getters.getEvaluatedCell(this.getActivePosition()); } getActiveCols() { const activeCols = new Set(); for (let zone of this.gridSelection.zones) { if (zone.top === 0 && zone.bottom === this.getters.getNumberRows(this.getters.getActiveSheetId()) - 1) { for (let i = zone.left; i <= zone.right; i++) { activeCols.add(i); } } } return activeCols; } getActiveRows() { const activeRows = new Set(); const sheetId = this.getters.getActiveSheetId(); for (let zone of this.gridSelection.zones) { if (zone.left === 0 && zone.right === this.getters.getNumberCols(sheetId) - 1) { for (let i = zone.top; i <= zone.bottom; i++) { activeRows.add(i); } } } return activeRows; } getCurrentStyle() { const zone = this.getters.getSelectedZone(); const sheetId = this.getters.getActiveSheetId(); return this.getters.getCellStyle({ sheetId, col: zone.left, row: zone.top }); } getSelectedZones() { return deepCopy(this.gridSelection.zones); } getSelectedZone() { return deepCopy(this.gridSelection.anchor.zone); } getSelection() { return deepCopy(this.gridSelection); } getSelectedCells() { const sheetId = this.getters.getActiveSheetId(); const cells = []; for (const zone of this.gridSelection.zones) { cells.push(...this.getters.getEvaluatedCellsInZone(sheetId, zone)); } return cells; } getSelectedFigureId() { return this.selectedFigureId; } getActivePosition() { return this.getters.getMainCellPosition({ sheetId: this.getActiveSheetId(), col: this.gridSelection.anchor.cell.col, row: this.gridSelection.anchor.cell.row, }); } getSheetPosition(sheetId) { if (sheetId === this.getters.getActiveSheetId()) { return this.getActivePosition(); } else { const sheetData = this.sheetsData[sheetId]; return sheetData ? { sheetId, col: sheetData.gridSelection.anchor.cell.col, row: sheetData.gridSelection.anchor.cell.row, } : this.getters.getNextVisibleCellPosition({ sheetId, col: 0, row: 0 }); } } isSingleColSelected() { const selection = this.getters.getSelectedZones(); if (selection.length !== 1 || selection[0].left !== selection[0].right) { return false; } return true; } /** * Returns a sorted array of indexes of all columns (respectively rows depending * on the dimension parameter) intersected by the currently selected zones. * * example: * assume selectedZones: [{left:0, right: 2, top :2, bottom: 4}, {left:5, right: 6, top :3, bottom: 5}] * * if dimension === "COL" => [0,1,2,5,6] * if dimension === "ROW" => [2,3,4,5] */ getElementsFromSelection(dimension) { if (dimension === "COL" && this.getters.getActiveCols().size === 0) { return []; } if (dimension === "ROW" && this.getters.getActiveRows().size === 0) { return []; } const zones = this.getters.getSelectedZones(); let elements = []; const start = dimension === "COL" ? "left" : "top"; const end = dimension === "COL" ? "right" : "bottom"; for (const zone of zones) { const zoneRows = Array.from({ length: zone[end] - zone[start] + 1 }, (_, i) => zone[start] + i); elements = elements.concat(zoneRows); } return [...new Set(elements)].sort(); } // --------------------------------------------------------------------------- // Other // --------------------------------------------------------------------------- activateSheet(sheetIdFrom, sheetIdTo) { this.setActiveSheet(sheetIdTo); this.sheetsData[sheetIdFrom] = { gridSelection: deepCopy(this.gridSelection), }; if (sheetIdTo in this.sheetsData) { Object.assign(this, this.sheetsData[sheetIdTo]); this.selection.resetDefaultAnchor(this, deepCopy(this.gridSelection.anchor)); } else { const { col, row } = this.getters.getNextVisibleCellPosition({ sheetId: sheetIdTo, col: 0, row: 0, }); this.selectCell(col, row); } } /** * Ensure selections are not outside sheet boundaries. * They are clipped to fit inside the sheet if needed. */ setSelectionMixin(anchor, zones) { const { anchor: clippedAnchor, zones: clippedZones } = this.clipSelection(this.getters.getActiveSheetId(), { anchor, zones }); this.gridSelection.anchor = clippedAnchor; this.gridSelection.zones = uniqueZones(clippedZones); } /** * Change the anchor of the selection active cell to an absolute col and row index. * * This is a non trivial task. We need to stop the editing process and update * properly the current selection. Also, this method can optionally create a new * range in the selection. */ selectCell(col, row) { const sheetId = this.getters.getActiveSheetId(); const zone = this.getters.expandZone(sheetId, { left: col, right: col, top: row, bottom: row }); this.setSelectionMixin({ zone, cell: { col, row } }, [zone]); } setActiveSheet(id) { const sheet = this.getters.getSheet(id); this.activeSheet = sheet; } activateNextSheet(direction) { const sheetIds = this.getters.getSheetIds(); const oldSheetPosition = sheetIds.findIndex((id) => id === this.activeSheet.id); const delta = direction === "left" ? sheetIds.length - 1 : 1; const newPosition = (oldSheetPosition + delta) % sheetIds.length; this.dispatch("ACTIVATE_SHEET", { sheetIdFrom: this.getActiveSheetId(), sheetIdTo: sheetIds[newPosition], }); } onColumnsRemoved(cmd) { const { cell, zone } = this.gridSelection.anchor; const selectedZone = updateSelectionOnDeletion(zone, "left", [...cmd.elements]); let anchorZone = { left: cell.col, right: cell.col, top: cell.row, bottom: cell.row }; anchorZone = updateSelectionOnDeletion(anchorZone, "left", [...cmd.elements]); const anchor = { cell: { col: anchorZone.left, row: anchorZone.top, }, zone: selectedZone, }; const selections = this.gridSelection.zones.map((zone) => updateSelectionOnDeletion(zone, "left", [...cmd.elements])); this.setSelectionMixin(anchor, selections); } onRowsRemoved(cmd) { const { cell, zone } = this.gridSelection.anchor; const selectedZone = updateSelectionOnDeletion(zone, "top", [...cmd.elements]); let anchorZone = { left: cell.col, right: cell.col, top: cell.row, bottom: cell.row }; anchorZone = updateSelectionOnDeletion(anchorZone, "top", [...cmd.elements]); const anchor = { cell: { col: anchorZone.left, row: anchorZone.top, }, zone: selectedZone, }; const selections = this.gridSelection.zones.map((zone) => updateSelectionOnDeletion(zone, "top", [...cmd.elements])); this.setSelectionMixin(anchor, selections); } onAddElements(cmd) { const start = cmd.dimension === "COL" ? "left" : "top"; const anchorZone = updateSelectionOnInsertion(this.gridSelection.anchor.zone, start, cmd.base, cmd.position, cmd.quantity); const selection = this.gridSelection.zones.map((zone) => updateSelectionOnInsertion(zone, start, cmd.base, cmd.position, cmd.quantity)); const anchor = { cell: { col: anchorZone.left, row: anchorZone.top }, zone: anchorZone, }; this.setSelectionMixin(anchor, selection); } onMoveElements(cmd) { const thickness = cmd.elements.length; this.dispatch("ADD_COLUMNS_ROWS", { dimension: cmd.dimension, sheetId: cmd.sheetId, base: cmd.base, quantity: thickness, position: cmd.position, }); const isCol = cmd.dimension === "COL"; const start = cmd.elements[0]; const end = cmd.elements[thickness - 1]; const isBasedBefore = cmd.base < start; const deltaCol = isBasedBefore && isCol ? thickness : 0; const deltaRow = isBasedBefore && !isCol ? thickness : 0; const target = [ { left: isCol ? start + deltaCol : 0, right: isCol ? end + deltaCol : this.getters.getNumberCols(cmd.sheetId) - 1, top: !isCol ? start + deltaRow : 0, bottom: !isCol ? end + deltaRow : this.getters.getNumberRows(cmd.sheetId) - 1, }, ]; const sheetId = this.getActiveSheetId(); const handler = new CellClipboardHandler(this.getters, this.dispatch); const data = handler.copy(getClipboardDataPositions(sheetId, target)); if (!data) { return; } const base = isBasedBefore ? cmd.base : cmd.base + 1; const pasteTarget = [ { left: isCol ? base : 0, right: isCol ? base + thickness - 1 : this.getters.getNumberCols(cmd.sheetId) - 1, top: !isCol ? base : 0, bottom: !isCol ? base + thickness - 1 : this.getters.getNumberRows(cmd.sheetId) - 1, }, ]; handler.paste({ zones: pasteTarget, sheetId }, data, { isCutOperation: true }); const toRemove = isBasedBefore ? cmd.elements.map((el) => el + thickness) : cmd.elements; let currentIndex = cmd.base; for (const element of toRemove) { const size = this.getters.getHeaderSize(cmd.sheetId, cmd.dimension, element); this.dispatch("RESIZE_COLUMNS_ROWS", { dimension: cmd.dimension, sheetId: cmd.sheetId, size, elements: [currentIndex], }); currentIndex += 1; } this.dispatch("REMOVE_COLUMNS_ROWS", { dimension: cmd.dimension, sheetId: cmd.sheetId, elements: toRemove, }); } isMoveElementAllowed(cmd) { const isCol = cmd.dimension === "COL"; const start = cmd.elements[0]; const end = cmd.elements[cmd.elements.length - 1]; const id = cmd.sheetId; const doesElementsHaveCommonMerges = isCol ? this.getters.doesColumnsHaveCommonMerges : this.getters.doesRowsHaveCommonMerges; if (doesElementsHaveCommonMerges(id, start - 1, start) || doesElementsHaveCommonMerges(id, end, end + 1) || doesElementsHaveCommonMerges(id, cmd.base - 1, cmd.base)) { return "WillRemoveExistingMerge" /* CommandResult.WillRemoveExistingMerge */; } const headers = [cmd.base, ...cmd.elements]; const maxHeaderValue = isCol ? this.getters.getNumberCols(id) : this.getters.getNumberRows(id); if (headers.some((h) => h < 0 || h >= maxHeaderValue)) { return "InvalidHeaderIndex" /* CommandResult.InvalidHeaderIndex */; } return "Success" /* CommandResult.Success */; } //------------------------------------------- // Helpers for extensions // ------------------------------------------ /** * Clip the selection if it spans outside the sheet */ clipSelection(sheetId, selection) { const cols = this.getters.getNumberCols(sheetId) - 1; const rows = this.getters.getNumberRows(sheetId) - 1; const zones = selection.zones.map((z) => { return { left: clip(z.left, 0, cols), right: clip(z.right, 0, cols), top: clip(z.top, 0, rows), bottom: clip(z.bottom, 0, rows), }; }); const anchorCol = clip(selection.anchor.cell.col, 0, cols); const anchorRow = clip(selection.anchor.cell.row, 0, rows); const anchorZone = { left: clip(selection.anchor.zone.left, 0, cols), right: clip(selection.anchor.zone.right, 0, cols), top: clip(selection.anchor.zone.top, 0, rows), bottom: clip(selection.anchor.zone.bottom, 0, rows), }; return { zones, anchor: { cell: { col: anchorCol, row: anchorRow }, zone: anchorZone, }, }; } // --------------------------------------------------------------------------- // Grid rendering // --------------------------------------------------------------------------- drawLayer(renderingContext) { if (this.getters.isDashboard()) { return; } const { ctx, thinLineWidth } = renderingContext; // selection const zones = this.getSelectedZones(); ctx.fillStyle = "#f3f7fe"; const onlyOneCell = zones.length === 1 && zones[0].left === zones[0].right && zones[0].top === zones[0].bottom; ctx.fillStyle = onlyOneCell ? "#f3f7fe" : "#e9f0ff"; ctx.strokeStyle = SELECTION_BORDER_COLOR; ctx.lineWidth = 1.5 * thinLineWidth; for (const zone of zones) { const { x, y, width, height } = this.getters.getVisibleRect(zone); ctx.globalCompositeOperation = "multiply"; ctx.fillRect(x, y, width, height); ctx.globalCompositeOperation = "source-over"; ctx.strokeRect(x, y, width, height); } ctx.globalCompositeOperation = "source-over"; // active zone const position = this.getActivePosition(); ctx.strokeStyle = SELECTION_BORDER_COLOR; ctx.lineWidth = 3 * thinLineWidth; let zone; if (this.getters.isInMerge(position)) { zone = this.getters.getMerge(position); } else { zone = positionToZone(position); } const { x, y, width, height } = this.getters.getVisibleRect(zone); if (width > 0 && height > 0) { ctx.strokeRect(x, y, width, height); } } } class InternalViewport { getters; sheetId; boundaries; top; bottom; left; right; offsetX; offsetY; offsetScrollbarX; offsetScrollbarY; canScrollVertically; canScrollHorizontally; viewportWidth; viewportHeight; offsetCorrectionX; offsetCorrectionY; constructor(getters, sheetId, boundaries, sizeInGrid, options, offsets) { this.getters = getters; this.sheetId = sheetId; this.boundaries = boundaries; this.viewportWidth = sizeInGrid.width; this.viewportHeight = sizeInGrid.height; this.offsetScrollbarX = offsets.x; this.offsetScrollbarY = offsets.y; this.canScrollVertically = options.canScrollVertically; this.canScrollHorizontally = options.canScrollHorizontally; this.offsetCorrectionX = this.getters.getColDimensions(this.sheetId, this.boundaries.left).start; this.offsetCorrectionY = this.getters.getRowDimensions(this.sheetId, this.boundaries.top).start; this.adjustViewportOffsetX(); this.adjustViewportOffsetY(); } // PUBLIC /** Returns the maximum size (in Pixels) of the viewport relative to its allocated client size * When the viewport grid size is smaller than its client width (resp. height), it will return * the client width (resp. height). */ getMaxSize() { const lastCol = this.getters.findLastVisibleColRowIndex(this.sheetId, "COL", { first: this.boundaries.left, last: this.boundaries.right, }); const lastRow = this.getters.findLastVisibleColRowIndex(this.sheetId, "ROW", { first: this.boundaries.top, last: this.boundaries.bottom, }); const { end: lastColEnd, size: lastColSize } = this.getters.getColDimensions(this.sheetId, lastCol); const { end: lastRowEnd, size: lastRowSize } = this.getters.getRowDimensions(this.sheetId, lastRow); const leftColIndex = this.searchHeaderIndex("COL", lastColEnd - this.viewportWidth, 0); const leftColSize = this.getters.getColSize(this.sheetId, leftColIndex); const leftRowIndex = this.searchHeaderIndex("ROW", lastRowEnd - this.viewportHeight, 0); const topRowSize = this.getters.getRowSize(this.sheetId, leftRowIndex); let width = lastColEnd - this.offsetCorrectionX; if (this.canScrollHorizontally) { width += Math.max(DEFAULT_CELL_WIDTH, // leave some minimal space to let the user know they scrolled all the way Math.min(leftColSize, this.viewportWidth - lastColSize) // Add pixels that allows the snapping at maximum horizontal scroll ); width = Math.max(width, this.viewportWidth); // if the viewport grid size is smaller than its client width, return client width } let height = lastRowEnd - this.offsetCorrectionY; if (this.canScrollVertically) { height += Math.max(DEFAULT_CELL_HEIGHT + 5, // leave some space to let the user know they scrolled all the way Math.min(topRowSize, this.viewportHeight - lastRowSize) // Add pixels that allows the snapping at maximum vertical scroll ); height = Math.max(height, this.viewportHeight); // if the viewport grid size is smaller than its client height, return client height } if (lastRowEnd + FOOTER_HEIGHT > height && !this.getters.isReadonly()) { height += FOOTER_HEIGHT; } return { width, height }; } /** * Return the index of a column given an offset x, based on the pane left * visible cell. * It returns -1 if no column is found. */ getColIndex(x) { if (x < this.offsetCorrectionX || x > this.offsetCorrectionX + this.viewportWidth) { return -1; } return this.searchHeaderIndex("COL", x - this.offsetCorrectionX, this.left); } /** * Return the index of a row given an offset y, based on the pane top * visible cell. * It returns -1 if no row is found. */ getRowIndex(y) { if (y < this.offsetCorrectionY || y > this.offsetCorrectionY + this.viewportHeight) { return -1; } return this.searchHeaderIndex("ROW", y - this.offsetCorrectionY, this.top); } /** * This function will make sure that the provided cell position (or current selected position) is part of * the pane that is actually displayed on the client. We therefore adjust the offset of the pane * until it contains the cell completely. */ adjustPosition(position) { const sheetId = this.sheetId; const mainCellPosition = this.getters.getMainCellPosition({ sheetId, ...position }); const { col, row } = this.getters.getNextVisibleCellPosition(mainCellPosition); if (isInside(col, this.boundaries.top, this.boundaries)) { this.adjustPositionX(col); } if (isInside(this.boundaries.left, row, this.boundaries)) { this.adjustPositionY(row); } } adjustPositionX(targetCol) { const sheetId = this.sheetId; const { end } = this.getters.getColDimensions(sheetId, targetCol); if (this.offsetX + this.offsetCorrectionX + this.viewportWidth < end) { const maxCol = this.getters.getNumberCols(sheetId); let finalTarget = targetCol; while (this.getters.isColHidden(sheetId, finalTarget) && finalTarget < maxCol) { finalTarget++; } const finalTargetEnd = this.getters.getColDimensions(sheetId, finalTarget).end; const startIndex = this.searchHeaderIndex("COL", finalTargetEnd - this.viewportWidth - this.offsetCorrectionX, this.boundaries.left); this.offsetScrollbarX = this.getters.getColDimensions(sheetId, startIndex).end - this.offsetCorrectionX; } else if (this.left > targetCol) { let finalTarget = targetCol; while (this.getters.isColHidden(sheetId, finalTarget) && finalTarget > 0) { finalTarget--; } this.offsetScrollbarX = this.getters.getColDimensions(sheetId, finalTarget).start - this.offsetCorrectionX; } this.adjustViewportZoneX(); } adjustPositionY(targetRow) { const sheetId = this.sheetId; const { end } = this.getters.getRowDimensions(sheetId, targetRow); if (this.offsetY + this.viewportHeight + this.offsetCorrectionY < end) { const maxRow = this.getters.getNumberRows(sheetId); let finalTarget = targetRow; while (this.getters.isRowHidden(sheetId, finalTarget) && finalTarget < maxRow) { finalTarget++; } const finalTargetEnd = this.getters.getRowDimensions(sheetId, finalTarget).end; const startIndex = this.searchHeaderIndex("ROW", finalTargetEnd - this.viewportHeight - this.offsetCorrectionY, this.boundaries.top); this.offsetScrollbarY = this.getters.getRowDimensions(sheetId, startIndex).end - this.offsetCorrectionY; } else if (this.top > targetRow) { let finalTarget = targetRow; while (this.getters.isRowHidden(sheetId, finalTarget) && finalTarget > 0) { finalTarget--; } this.offsetScrollbarY = this.getters.getRowDimensions(sheetId, finalTarget).start - this.offsetCorrectionY; } this.adjustViewportZoneY(); } willNewOffsetScrollViewport(offsetX, offsetY) { return ((this.canScrollHorizontally && this.offsetScrollbarX !== offsetX) || (this.canScrollVertically && this.offsetScrollbarY !== offsetY)); } setViewportOffset(offsetX, offsetY) { this.setViewportOffsetX(offsetX); this.setViewportOffsetY(offsetY); } adjustViewportZone() { this.adjustViewportZoneX(); this.adjustViewportZoneY(); } /** * * @param zone * @returns Computes the absolute coordinate of a given zone inside the viewport */ getRect(zone) { const targetZone = intersection(zone, this); if (targetZone) { const x = this.getters.getColRowOffset("COL", this.left, targetZone.left) + this.offsetCorrectionX; const y = this.getters.getColRowOffset("ROW", this.top, targetZone.top) + this.offsetCorrectionY; const width = Math.min(this.getters.getColRowOffset("COL", targetZone.left, targetZone.right + 1), this.viewportWidth); const height = Math.min(this.getters.getColRowOffset("ROW", targetZone.top, targetZone.bottom + 1), this.viewportHeight); return { x, y, width, height, }; } return undefined; } isVisible(col, row) { const isInside = row <= this.bottom && row >= this.top && col >= this.left && col <= this.right; return (isInside && !this.getters.isColHidden(this.sheetId, col) && !this.getters.isRowHidden(this.sheetId, row)); } searchHeaderIndex(dimension, position, startIndex = 0) { const sheetId = this.sheetId; const headers = this.getters.getNumberHeaders(sheetId, dimension); // using a binary search: let start = startIndex; let end = headers; while (start <= end && start !== headers && end !== -1) { const mid = Math.floor((start + end) / 2); const offset = this.getters.getColRowOffset(dimension, startIndex, mid); const size = this.getters.getHeaderSize(sheetId, dimension, mid); if (position >= offset && position < offset + size) { return mid; } else if (position >= offset + size) { start = mid + 1; } else { end = mid - 1; } } return -1; } setViewportOffsetX(offsetX) { if (!this.canScrollHorizontally) { return; } this.offsetScrollbarX = offsetX; this.adjustViewportZoneX(); } setViewportOffsetY(offsetY) { if (!this.canScrollVertically) { return; } this.offsetScrollbarY = offsetY; this.adjustViewportZoneY(); } /** Corrects the viewport's horizontal offset based on the current structure * To make sure that at least on column is visible inside the viewport. */ adjustViewportOffsetX() { if (this.canScrollHorizontally) { const { width: viewportWidth } = this.getMaxSize(); if (this.viewportWidth + this.offsetScrollbarX > viewportWidth) { this.offsetScrollbarX = Math.max(0, viewportWidth - this.viewportWidth); } } this.adjustViewportZoneX(); } /** Corrects the viewport's vertical offset based on the current structure * To make sure that at least on row is visible inside the viewport. */ adjustViewportOffsetY() { if (this.canScrollVertically) { const { height: paneHeight } = this.getMaxSize(); if (this.viewportHeight + this.offsetScrollbarY > paneHeight) { this.offsetScrollbarY = Math.max(0, paneHeight - this.viewportHeight); } } this.adjustViewportZoneY(); } /** Updates the pane zone and snapped offset based on its horizontal * offset (will find Left) and its width (will find Right) */ adjustViewportZoneX() { const sheetId = this.sheetId; this.left = this.searchHeaderIndex("COL", this.offsetScrollbarX, this.boundaries.left); this.right = Math.min(this.boundaries.right, this.searchHeaderIndex("COL", this.viewportWidth, this.left)); if (this.left === -1) { this.left = this.boundaries.left; } if (this.right === -1) { this.right = this.getters.getNumberCols(sheetId) - 1; } this.offsetX = this.getters.getColDimensions(sheetId, this.left).start - this.getters.getColDimensions(sheetId, this.boundaries.left).start; } /** Updates the pane zone and snapped offset based on its vertical * offset (will find Top) and its width (will find Bottom) */ adjustViewportZoneY() { const sheetId = this.sheetId; this.top = this.searchHeaderIndex("ROW", this.offsetScrollbarY, this.boundaries.top); this.bottom = Math.min(this.boundaries.bottom, this.searchHeaderIndex("ROW", this.viewportHeight, this.top)); if (this.top === -1) { this.top = this.boundaries.top; } if (this.bottom === -1) { this.bottom = this.getters.getNumberRows(sheetId) - 1; } this.offsetY = this.getters.getRowDimensions(sheetId, this.top).start - this.getters.getRowDimensions(sheetId, this.boundaries.top).start; } } /** * EdgeScrollCases Schema * * The dots/double dots represent a freeze (= a split of viewports) * In this example, we froze vertically between columns D and E * and horizontally between rows 4 and 5. * * One can see that we scrolled horizontally from column E to G and * vertically from row 5 to 7. * * A B C D G H I J K L M N O P Q R S T * _______________________________________________________ * 1 | : | * 2 | : | * 3 | : B ↑ 6 | * 4 | : | | | | * ····················+···+·················+············| * 7 | : | | | | * 8 | : ↓ 2 | | * 9 | : | | * 10 | A --+--→ | | * 11 | : | | * 12 | : | | * 13 | ←--+-- 1 | | * 14 | : | 3 --+--→ * 15 | : | | * 16 | : | | * 17 | 5 --+-------------------------------------------+--→ * 18 | : | | * 19 | : 4 | | * 20 | : | | | * ______________________________+___________| ____________ * | | * ↓ ↓ */ /** * Viewport plugin. * * This plugin manages all things related to all viewport states. * */ class SheetViewPlugin extends UIPlugin { static getters = [ "getColIndex", "getRowIndex", "getActiveMainViewport", "getSheetViewDimension", "getSheetViewDimensionWithHeaders", "getMainViewportRect", "isVisibleInViewport", "getEdgeScrollCol", "getEdgeScrollRow", "getVisibleFigures", "getVisibleRect", "getVisibleRectWithoutHeaders", "getVisibleCellPositions", "getColRowOffsetInViewport", "getMainViewportCoordinates", "getActiveSheetScrollInfo", "getActiveSheetDOMScrollInfo", "getSheetViewVisibleCols", "getSheetViewVisibleRows", "getFrozenSheetViewRatio", "isPositionVisible", "getColDimensionsInViewport", "getRowDimensionsInViewport", ]; viewports = {}; /** * The viewport dimensions are usually set by one of the components * (i.e. when grid component is mounted) to properly reflect its state in the DOM. * In the absence of a component (standalone model), is it mandatory to set reasonable default values * to ensure the correct operation of this plugin. */ sheetViewWidth = getDefaultSheetViewSize(); sheetViewHeight = getDefaultSheetViewSize(); gridOffsetX = 0; gridOffsetY = 0; sheetsWithDirtyViewports = new Set(); shouldAdjustViewports = false; // --------------------------------------------------------------------------- // Command Handling // --------------------------------------------------------------------------- allowDispatch(cmd) { switch (cmd.type) { case "SET_VIEWPORT_OFFSET": return this.chainValidations(this.checkScrollingDirection, this.checkIfViewportsWillChange)(cmd); case "RESIZE_SHEETVIEW": return this.chainValidations(this.checkValuesAreDifferent, this.checkPositiveDimension)(cmd); default: return "Success" /* CommandResult.Success */; } } handleEvent(event) { const sheetId = this.getters.getActiveSheetId(); if (event.options.scrollIntoView) { let { col, row } = findCellInNewZone(event.previousAnchor.zone, event.anchor.zone); if (event.mode === "updateAnchor") { const oldZone = event.previousAnchor.zone; const newZone = event.anchor.zone; // altering a zone should not move the viewport in a dimension that wasn't changed const { top, bottom, left, right } = this.getMainInternalViewport(sheetId); if (oldZone.left === newZone.left && oldZone.right === newZone.right) { col = left > col || col > right ? left : col; } if (oldZone.top === newZone.top && oldZone.bottom === newZone.bottom) { row = top > row || row > bottom ? top : row; } } col = Math.min(col, this.getters.getNumberCols(sheetId) - 1); row = Math.min(row, this.getters.getNumberRows(sheetId) - 1); if (!this.sheetsWithDirtyViewports.has(sheetId)) { this.refreshViewport(this.getters.getActiveSheetId(), { col, row }); } } } handle(cmd) { // changing the evaluation can hide/show rows because of data filters if (invalidateEvaluationCommands.has(cmd.type)) { for (const sheetId of this.getters.getSheetIds()) { this.sheetsWithDirtyViewports.add(sheetId); } } switch (cmd.type) { case "START": this.selection.observe(this, { handleEvent: this.handleEvent.bind(this), }); this.resetViewports(this.getters.getActiveSheetId()); break; case "UNDO": case "REDO": this.cleanViewports(); for (const sheetId of this.getters.getSheetIds()) { this.sheetsWithDirtyViewports.add(sheetId); } this.shouldAdjustViewports = true; break; case "RESIZE_SHEETVIEW": this.resizeSheetView(cmd.height, cmd.width, cmd.gridOffsetX, cmd.gridOffsetY); break; case "SET_VIEWPORT_OFFSET": this.setSheetViewOffset(cmd.offsetX, cmd.offsetY); break; case "SHIFT_VIEWPORT_DOWN": const sheetId = this.getters.getActiveSheetId(); const { top, viewportHeight, offsetCorrectionY } = this.getMainInternalViewport(sheetId); const topRowDims = this.getters.getRowDimensions(sheetId, top); this.shiftVertically(topRowDims.start + viewportHeight - offsetCorrectionY); break; case "SHIFT_VIEWPORT_UP": { const sheetId = this.getters.getActiveSheetId(); const { top, viewportHeight, offsetCorrectionY } = this.getMainInternalViewport(sheetId); const topRowDims = this.getters.getRowDimensions(sheetId, top); this.shiftVertically(topRowDims.end - offsetCorrectionY - viewportHeight); break; } case "REMOVE_TABLE": case "UPDATE_TABLE": case "UPDATE_FILTER": this.sheetsWithDirtyViewports.add(cmd.sheetId); break; case "REMOVE_COLUMNS_ROWS": case "RESIZE_COLUMNS_ROWS": case "HIDE_COLUMNS_ROWS": case "ADD_COLUMNS_ROWS": case "UNHIDE_COLUMNS_ROWS": case "UNGROUP_HEADERS": case "GROUP_HEADERS": case "FOLD_HEADER_GROUP": case "UNFOLD_HEADER_GROUP": case "FOLD_HEADER_GROUPS_IN_ZONE": case "UNFOLD_HEADER_GROUPS_IN_ZONE": case "UNFOLD_ALL_HEADER_GROUPS": case "FOLD_ALL_HEADER_GROUPS": { const sheetId = "sheetId" in cmd ? cmd.sheetId : this.getters.getActiveSheetId(); this.sheetsWithDirtyViewports.add(sheetId); break; } case "UPDATE_CELL": // update cell content or format can change hidden rows because of data filters if ("content" in cmd || "format" in cmd || cmd.style?.fontSize !== undefined) { for (const sheetId of this.getters.getSheetIds()) { this.sheetsWithDirtyViewports.add(sheetId); } } break; case "DELETE_SHEET": this.cleanViewports(); this.sheetsWithDirtyViewports.delete(cmd.sheetId); break; case "ACTIVATE_SHEET": this.sheetsWithDirtyViewports.add(cmd.sheetIdTo); break; case "UNFREEZE_ROWS": case "UNFREEZE_COLUMNS": case "FREEZE_COLUMNS": case "FREEZE_ROWS": case "UNFREEZE_COLUMNS_ROWS": this.resetViewports(this.getters.getActiveSheetId()); break; case "DELETE_SHEET": this.sheetsWithDirtyViewports.delete(cmd.sheetId); break; case "SCROLL_TO_CELL": this.refreshViewport(this.getters.getActiveSheetId(), { col: cmd.col, row: cmd.row }); break; } } finalize() { for (const sheetId of this.sheetsWithDirtyViewports) { this.resetViewports(sheetId); if (this.shouldAdjustViewports) { const position = this.getters.getSheetPosition(sheetId); this.getSubViewports(sheetId).forEach((viewport) => { viewport.adjustPosition(position); }); } } this.sheetsWithDirtyViewports = new Set(); this.shouldAdjustViewports = false; this.setViewports(); } setViewports() { const sheetIds = this.getters.getSheetIds(); for (const sheetId of sheetIds) { if (!this.viewports[sheetId]?.bottomRight) { this.resetViewports(sheetId); } } } // --------------------------------------------------------------------------- // Getters // --------------------------------------------------------------------------- /** * Return the index of a column given an offset x, based on the viewport left * visible cell. * It returns -1 if no column is found. */ getColIndex(x) { const sheetId = this.getters.getActiveSheetId(); return Math.max(...this.getSubViewports(sheetId).map((viewport) => viewport.getColIndex(x))); } /** * Return the index of a row given an offset y, based on the viewport top * visible cell. * It returns -1 if no row is found. */ getRowIndex(y) { const sheetId = this.getters.getActiveSheetId(); return Math.max(...this.getSubViewports(sheetId).map((viewport) => viewport.getRowIndex(y))); } getSheetViewDimensionWithHeaders() { return { width: this.sheetViewWidth + this.gridOffsetX, height: this.sheetViewHeight + this.gridOffsetY, }; } getSheetViewDimension() { return { width: this.sheetViewWidth, height: this.sheetViewHeight, }; } /** type as pane, not viewport but basically pane extends viewport */ getActiveMainViewport() { const sheetId = this.getters.getActiveSheetId(); return this.getMainViewport(sheetId); } /** * Return the scroll info of the active sheet, ie. the offset between the viewport left/top side and * the grid left/top side, snapped to the columns/rows. */ getActiveSheetScrollInfo() { const sheetId = this.getters.getActiveSheetId(); const viewport = this.getMainInternalViewport(sheetId); return { scrollX: viewport.offsetX, scrollY: viewport.offsetY, }; } /** * Return the DOM scroll info of the active sheet, ie. the offset between the viewport left/top side and * the grid left/top side, corresponding to the scroll of the scrollbars and not snapped to the grid. */ getActiveSheetDOMScrollInfo() { const sheetId = this.getters.getActiveSheetId(); const viewport = this.getMainInternalViewport(sheetId); return { scrollX: viewport.offsetScrollbarX, scrollY: viewport.offsetScrollbarY, }; } getSheetViewVisibleCols() { const sheetId = this.getters.getActiveSheetId(); const viewports = this.getSubViewports(sheetId); //TODO ake another commit to eimprove this return [...new Set(viewports.map((v) => range(v.left, v.right + 1)).flat())].filter((col) => !this.getters.isHeaderHidden(sheetId, "COL", col)); } getSheetViewVisibleRows() { const sheetId = this.getters.getActiveSheetId(); const viewports = this.getSubViewports(sheetId); return [...new Set(viewports.map((v) => range(v.top, v.bottom + 1)).flat())].filter((row) => !this.getters.isHeaderHidden(sheetId, "ROW", row)); } /** * Get the positions of all the cells that are visible in the viewport, taking merges into account. */ getVisibleCellPositions() { const visibleCols = this.getSheetViewVisibleCols(); const visibleRows = this.getSheetViewVisibleRows(); const sheetId = this.getters.getActiveSheetId(); const positions = []; for (const col of visibleCols) { for (const row of visibleRows) { const position = { sheetId, col, row }; const mainPosition = this.getters.getMainCellPosition(position); if (mainPosition.row !== row || mainPosition.col !== col) { continue; } positions.push(position); } } return positions; } /** * Return the main viewport maximum size relative to the client size. */ getMainViewportRect() { const sheetId = this.getters.getActiveSheetId(); const viewport = this.getMainInternalViewport(sheetId); const { xSplit, ySplit } = this.getters.getPaneDivisions(sheetId); let { width, height } = viewport.getMaxSize(); const x = this.getters.getColDimensions(sheetId, xSplit).start; const y = this.getters.getRowDimensions(sheetId, ySplit).start; return { x, y, width, height }; } getMaximumSheetOffset() { const sheetId = this.getters.getActiveSheetId(); const { width, height } = this.getMainViewportRect(); const viewport = this.getMainInternalViewport(sheetId); return { maxOffsetX: Math.max(0, width - viewport.viewportWidth + 1), maxOffsetY: Math.max(0, height - viewport.viewportHeight + 1), }; } getColRowOffsetInViewport(dimension, referenceIndex, index) { const sheetId = this.getters.getActiveSheetId(); const visibleCols = this.getters.getSheetViewVisibleCols(); const visibleRows = this.getters.getSheetViewVisibleRows(); if (index < referenceIndex) { return -this.getColRowOffsetInViewport(dimension, index, referenceIndex); } let offset = 0; const visibleIndexes = dimension === "COL" ? visibleCols : visibleRows; for (let i = referenceIndex; i < index; i++) { if (!visibleIndexes.includes(i)) { continue; } offset += this.getters.getHeaderSize(sheetId, dimension, i); } return offset; } /** * Check if a given position is visible in the viewport. */ isVisibleInViewport({ sheetId, col, row }) { return this.getSubViewports(sheetId).some((pane) => pane.isVisible(col, row)); } // => returns the new offset getEdgeScrollCol(x, previousX, startingX) { let canEdgeScroll = false; let direction = 0; let delay = 0; /** 4 cases : See EdgeScrollCases Schema at the top * 1. previous in XRight > XLeft * 3. previous in XRight > outside * 5. previous in Left > outside * A. previous in Left > right * with X a position taken in the bottomRIght (aka scrollable) viewport */ const { xSplit } = this.getters.getPaneDivisions(this.getters.getActiveSheetId()); const { width } = this.getSheetViewDimension(); const { x: offsetCorrectionX } = this.getMainViewportCoordinates(); const currentOffsetX = this.getActiveSheetScrollInfo().scrollX; if (x > width) { // 3 & 5 canEdgeScroll = true; delay = scrollDelay(x - width); direction = 1; } else if (x < offsetCorrectionX && startingX >= offsetCorrectionX && currentOffsetX > 0) { // 1 canEdgeScroll = true; delay = scrollDelay(offsetCorrectionX - x); direction = -1; } else if (xSplit && previousX < offsetCorrectionX && x > offsetCorrectionX) { // A canEdgeScroll = true; delay = scrollDelay(x); direction = "reset"; } return { canEdgeScroll, direction, delay }; } getEdgeScrollRow(y, previousY, tartingY) { let canEdgeScroll = false; let direction = 0; let delay = 0; /** 4 cases : See EdgeScrollCases Schema at the top * 2. previous in XBottom > XTop * 4. previous in XRight > outside * 6. previous in Left > outside * B. previous in Left > right * with X a position taken in the bottomRIght (aka scrollable) viewport */ const { ySplit } = this.getters.getPaneDivisions(this.getters.getActiveSheetId()); const { height } = this.getSheetViewDimension(); const { y: offsetCorrectionY } = this.getMainViewportCoordinates(); const currentOffsetY = this.getActiveSheetScrollInfo().scrollY; if (y > height) { // 4 & 6 canEdgeScroll = true; delay = scrollDelay(y - height); direction = 1; } else if (y < offsetCorrectionY && tartingY >= offsetCorrectionY && currentOffsetY > 0) { // 2 canEdgeScroll = true; delay = scrollDelay(offsetCorrectionY - y); direction = -1; } else if (ySplit && previousY < offsetCorrectionY && y > offsetCorrectionY) { // B canEdgeScroll = true; delay = scrollDelay(y); direction = "reset"; } return { canEdgeScroll, direction, delay }; } /** * Computes the coordinates and size to draw the zone on the canvas */ getVisibleRect(zone) { const rect = this.getVisibleRectWithoutHeaders(zone); return { ...rect, x: rect.x + this.gridOffsetX, y: rect.y + this.gridOffsetY }; } /** * Computes the coordinates and size to draw the zone without taking the grid offset into account */ getVisibleRectWithoutHeaders(zone) { const sheetId = this.getters.getActiveSheetId(); const viewportRects = this.getSubViewports(sheetId) .map((viewport) => viewport.getRect(zone)) .filter(isDefined); if (viewportRects.length === 0) { return { x: 0, y: 0, width: 0, height: 0 }; } const x = Math.min(...viewportRects.map((rect) => rect.x)); const y = Math.min(...viewportRects.map((rect) => rect.y)); const width = Math.max(...viewportRects.map((rect) => rect.x + rect.width)) - x; const height = Math.max(...viewportRects.map((rect) => rect.y + rect.height)) - y; return { x, y, width, height }; } /** * Returns the position of the MainViewport relatively to the start of the grid (without headers) * It corresponds to the summed dimensions of the visible cols/rows (in x/y respectively) * situated before the pane divisions. */ getMainViewportCoordinates() { const sheetId = this.getters.getActiveSheetId(); const { xSplit, ySplit } = this.getters.getPaneDivisions(sheetId); const x = this.getters.getColDimensions(sheetId, xSplit).start; const y = this.getters.getRowDimensions(sheetId, ySplit).start; return { x, y }; } /** * Returns the size, start and end coordinates of a column relative to the left * column of the current viewport */ getColDimensionsInViewport(sheetId, col) { const left = largeMin(this.getters.getSheetViewVisibleCols()); const start = this.getters.getColRowOffsetInViewport("COL", left, col); const size = this.getters.getColSize(sheetId, col); const isColHidden = this.getters.isColHidden(sheetId, col); return { start, size: size, end: start + (isColHidden ? 0 : size), }; } /** * Returns the size, start and end coordinates of a row relative to the top row * of the current viewport */ getRowDimensionsInViewport(sheetId, row) { const top = largeMin(this.getters.getSheetViewVisibleRows()); const start = this.getters.getColRowOffsetInViewport("ROW", top, row); const size = this.getters.getRowSize(sheetId, row); const isRowHidden = this.getters.isRowHidden(sheetId, row); return { start, size: size, end: start + (isRowHidden ? 0 : size), }; } // --------------------------------------------------------------------------- // Private // --------------------------------------------------------------------------- ensureMainViewportExist(sheetId) { if (!this.viewports[sheetId]) { this.resetViewports(sheetId); } } getSubViewports(sheetId) { this.ensureMainViewportExist(sheetId); return Object.values(this.viewports[sheetId]).filter(isDefined); } checkPositiveDimension(cmd) { if (cmd.width < 0 || cmd.height < 0) { return "InvalidViewportSize" /* CommandResult.InvalidViewportSize */; } return "Success" /* CommandResult.Success */; } checkValuesAreDifferent(cmd) { const { height, width } = this.getSheetViewDimension(); if (cmd.gridOffsetX === this.gridOffsetX && cmd.gridOffsetY === this.gridOffsetY && cmd.width === width && cmd.height === height) { return "ValuesNotChanged" /* CommandResult.ValuesNotChanged */; } return "Success" /* CommandResult.Success */; } checkScrollingDirection({ offsetX, offsetY, }) { const pane = this.getMainInternalViewport(this.getters.getActiveSheetId()); if ((!pane.canScrollHorizontally && offsetX > 0) || (!pane.canScrollVertically && offsetY > 0)) { return "InvalidScrollingDirection" /* CommandResult.InvalidScrollingDirection */; } return "Success" /* CommandResult.Success */; } checkIfViewportsWillChange({ offsetX, offsetY }) { const sheetId = this.getters.getActiveSheetId(); const { maxOffsetX, maxOffsetY } = this.getMaximumSheetOffset(); const willScroll = this.getSubViewports(sheetId).some((viewport) => viewport.willNewOffsetScrollViewport(clip(offsetX, 0, maxOffsetX), clip(offsetY, 0, maxOffsetY))); return willScroll ? "Success" /* CommandResult.Success */ : "ViewportScrollLimitsReached" /* CommandResult.ViewportScrollLimitsReached */; } getMainViewport(sheetId) { const viewport = this.getMainInternalViewport(sheetId); return { top: viewport.top, left: viewport.left, bottom: viewport.bottom, right: viewport.right, }; } getMainInternalViewport(sheetId) { this.ensureMainViewportExist(sheetId); return this.viewports[sheetId].bottomRight; } /** gets rid of deprecated sheetIds */ cleanViewports() { const sheetIds = this.getters.getSheetIds(); for (let sheetId of Object.keys(this.viewports)) { if (!sheetIds.includes(sheetId)) { delete this.viewports[sheetId]; } } } resizeSheetView(height, width, gridOffsetX = 0, gridOffsetY = 0) { this.sheetViewHeight = height; this.sheetViewWidth = width; this.gridOffsetX = gridOffsetX; this.gridOffsetY = gridOffsetY; this.recomputeViewports(); } recomputeViewports() { for (let sheetId of Object.keys(this.viewports)) { this.resetViewports(sheetId); } } setSheetViewOffset(offsetX, offsetY) { const sheetId = this.getters.getActiveSheetId(); const { maxOffsetX, maxOffsetY } = this.getMaximumSheetOffset(); this.getSubViewports(sheetId).forEach((viewport) => viewport.setViewportOffset(clip(offsetX, 0, maxOffsetX), clip(offsetY, 0, maxOffsetY))); } getViewportOffset(sheetId) { return { x: this.viewports[sheetId]?.bottomRight.offsetScrollbarX || 0, y: this.viewports[sheetId]?.bottomRight.offsetScrollbarY || 0, }; } resetViewports(sheetId) { if (!this.getters.tryGetSheet(sheetId)) { return; } const { xSplit, ySplit } = this.getters.getPaneDivisions(sheetId); const nCols = this.getters.getNumberCols(sheetId); const nRows = this.getters.getNumberRows(sheetId); const colOffset = this.getters.getColRowOffset("COL", 0, xSplit, sheetId); const rowOffset = this.getters.getColRowOffset("ROW", 0, ySplit, sheetId); const { xRatio, yRatio } = this.getFrozenSheetViewRatio(sheetId); const canScrollHorizontally = xRatio < 1.0; const canScrollVertically = yRatio < 1.0; const previousOffset = this.getViewportOffset(sheetId); const sheetViewports = { topLeft: (ySplit && xSplit && new InternalViewport(this.getters, sheetId, { left: 0, right: xSplit - 1, top: 0, bottom: ySplit - 1 }, { width: colOffset, height: rowOffset }, { canScrollHorizontally: false, canScrollVertically: false }, { x: 0, y: 0 })) || undefined, topRight: (ySplit && new InternalViewport(this.getters, sheetId, { left: xSplit, right: nCols - 1, top: 0, bottom: ySplit - 1 }, { width: this.sheetViewWidth - colOffset, height: rowOffset }, { canScrollHorizontally, canScrollVertically: false }, { x: canScrollHorizontally ? previousOffset.x : 0, y: 0 })) || undefined, bottomLeft: (xSplit && new InternalViewport(this.getters, sheetId, { left: 0, right: xSplit - 1, top: ySplit, bottom: nRows - 1 }, { width: colOffset, height: this.sheetViewHeight - rowOffset }, { canScrollHorizontally: false, canScrollVertically }, { x: 0, y: canScrollVertically ? previousOffset.y : 0 })) || undefined, bottomRight: new InternalViewport(this.getters, sheetId, { left: xSplit, right: nCols - 1, top: ySplit, bottom: nRows - 1 }, { width: this.sheetViewWidth - colOffset, height: this.sheetViewHeight - rowOffset, }, { canScrollHorizontally, canScrollVertically }, { x: canScrollHorizontally ? previousOffset.x : 0, y: canScrollVertically ? previousOffset.y : 0, }), }; this.viewports[sheetId] = sheetViewports; } /** * Adjust the viewport such that the anchor position is visible */ refreshViewport(sheetId, anchorPosition) { this.getSubViewports(sheetId).forEach((viewport) => { viewport.adjustViewportZone(); viewport.adjustPosition(anchorPosition); }); } /** * Shift the viewport vertically and move the selection anchor * such that it remains at the same place relative to the * viewport top. */ shiftVertically(offset) { const sheetId = this.getters.getActiveSheetId(); const { top } = this.getMainInternalViewport(sheetId); const { scrollX } = this.getActiveSheetScrollInfo(); this.setSheetViewOffset(scrollX, offset); const { anchor } = this.getters.getSelection(); if (anchor.cell.row >= this.getters.getPaneDivisions(sheetId).ySplit) { const deltaRow = this.getMainInternalViewport(sheetId).top - top; this.selection.selectCell(anchor.cell.col, anchor.cell.row + deltaRow); } } getVisibleFigures() { const sheetId = this.getters.getActiveSheetId(); const result = []; const figures = this.getters.getFigures(sheetId); const { scrollX, scrollY } = this.getActiveSheetScrollInfo(); const { x: offsetCorrectionX, y: offsetCorrectionY } = this.getters.getMainViewportCoordinates(); const { width, height } = this.getters.getSheetViewDimensionWithHeaders(); for (const figure of figures) { if (figure.x >= offsetCorrectionX && (figure.x + figure.width <= offsetCorrectionX + scrollX || figure.x >= width + scrollX + offsetCorrectionX)) { continue; } if (figure.y >= offsetCorrectionY && (figure.y + figure.height <= offsetCorrectionY + scrollY || figure.y >= height + scrollY + offsetCorrectionY)) { continue; } result.push(figure); } return result; } isPositionVisible(position) { const { scrollX, scrollY } = this.getters.getActiveSheetScrollInfo(); const { x: mainViewportX, y: mainViewportY } = this.getters.getMainViewportCoordinates(); const { width, height } = this.getters.getSheetViewDimension(); if (position.x >= mainViewportX && (position.x < mainViewportX + scrollX || position.x > width + scrollX + mainViewportX)) { return false; } if (position.y >= mainViewportY && (position.y < mainViewportY + scrollY || position.y > height + scrollY + mainViewportY)) { return false; } return true; } getFrozenSheetViewRatio(sheetId) { const { xSplit, ySplit } = this.getters.getPaneDivisions(sheetId); const offsetCorrectionX = this.getters.getColDimensions(sheetId, xSplit).start; const offsetCorrectionY = this.getters.getRowDimensions(sheetId, ySplit).start; const width = this.sheetViewWidth + this.gridOffsetX; const height = this.sheetViewHeight + this.gridOffsetY; return { xRatio: offsetCorrectionX / width, yRatio: offsetCorrectionY / height }; } } class HeaderPositionsUIPlugin extends UIPlugin { static getters = ["getColDimensions", "getRowDimensions", "getColRowOffset"]; headerPositions = {}; isDirty = true; handle(cmd) { if (invalidateEvaluationCommands.has(cmd.type)) { this.headerPositions = {}; this.isDirty = true; } switch (cmd.type) { case "START": for (const sheetId of this.getters.getSheetIds()) { this.headerPositions[sheetId] = this.computeHeaderPositionsOfSheet(sheetId); } break; case "UPDATE_CELL": this.headerPositions = {}; this.isDirty = true; break; case "UPDATE_FILTER": case "UPDATE_TABLE": case "REMOVE_TABLE": this.headerPositions = {}; this.isDirty = true; break; case "REMOVE_COLUMNS_ROWS": case "RESIZE_COLUMNS_ROWS": case "HIDE_COLUMNS_ROWS": case "ADD_COLUMNS_ROWS": case "UNHIDE_COLUMNS_ROWS": case "FOLD_HEADER_GROUP": case "UNFOLD_HEADER_GROUP": case "FOLD_HEADER_GROUPS_IN_ZONE": case "UNFOLD_HEADER_GROUPS_IN_ZONE": case "UNFOLD_ALL_HEADER_GROUPS": case "FOLD_ALL_HEADER_GROUPS": case "UNGROUP_HEADERS": case "GROUP_HEADERS": case "CREATE_SHEET": this.headerPositions[cmd.sheetId] = this.computeHeaderPositionsOfSheet(cmd.sheetId); break; case "DUPLICATE_SHEET": this.headerPositions[cmd.sheetIdTo] = deepCopy(this.headerPositions[cmd.sheetId]); break; } } finalize() { if (this.isDirty) { for (const sheetId of this.getters.getSheetIds()) { this.headerPositions[sheetId] = this.computeHeaderPositionsOfSheet(sheetId); } this.isDirty = false; } } /** * Returns the size, start and end coordinates of a column on an unfolded sheet */ getColDimensions(sheetId, col) { const start = this.headerPositions[sheetId]["COL"][col]; const size = this.getters.getColSize(sheetId, col); const isColHidden = this.getters.isColHidden(sheetId, col); return { start, size, end: start + (isColHidden ? 0 : size), }; } /** * Returns the size, start and end coordinates of a row an unfolded sheet */ getRowDimensions(sheetId, row) { const start = this.headerPositions[sheetId]["ROW"][row]; const size = this.getters.getRowSize(sheetId, row); const isRowHidden = this.getters.isRowHidden(sheetId, row); return { start, size: size, end: start + (isRowHidden ? 0 : size), }; } /** * Returns the offset of a header (determined by the dimension) at the given index * based on the referenceIndex given. If start === 0, this method will return * the start attribute of the header. * * i.e. The size from A to B is the distance between A.start and B.end */ getColRowOffset(dimension, referenceIndex, index, sheetId = this.getters.getActiveSheetId()) { const referencePosition = this.headerPositions[sheetId][dimension][referenceIndex]; const position = this.headerPositions[sheetId][dimension][index]; return position - referencePosition; } computeHeaderPositionsOfSheet(sheetId) { return { COL: this.computePositions(sheetId, "COL"), ROW: this.computePositions(sheetId, "ROW"), }; } computePositions(sheetId, dimension) { const positions = {}; let offset = 0; // loop on number of headers +1 so the position of (last header + 1) is the end of the sheet for (let i = 0; i < this.getters.getNumberHeaders(sheetId, dimension) + 1; i++) { positions[i] = offset; if (this.getters.isHeaderHidden(sheetId, dimension, i)) { continue; } offset += this.getters.getHeaderSize(sheetId, dimension, i); } return positions; } } const corePluginRegistry = new Registry() .add("settings", SettingsPlugin) .add("sheet", SheetPlugin) .add("header grouping", HeaderGroupingPlugin) .add("header visibility", HeaderVisibilityPlugin) .add("tables", TablePlugin) .add("dataValidation", DataValidationPlugin) .add("cell", CellPlugin) .add("merge", MergePlugin) .add("headerSize", HeaderSizePlugin) .add("borders", BordersPlugin) .add("conditional formatting", ConditionalFormatPlugin) .add("figures", FigurePlugin) .add("chart", ChartPlugin) .add("image", ImagePlugin) .add("pivot_core", PivotCorePlugin) .add("spreadsheet_pivot_core", SpreadsheetPivotCorePlugin) .add("tableStyle", TableStylePlugin); // Plugins which handle a specific feature, without handling any core commands const featurePluginRegistry = new Registry() .add("ui_sheet", SheetUIPlugin) .add("ui_options", UIOptionsPlugin) .add("autofill", AutofillPlugin) .add("sort", SortPlugin) .add("automatic_sum", AutomaticSumPlugin) .add("format", FormatPlugin) .add("insert_pivot", InsertPivotPlugin) .add("split_to_columns", SplitToColumnsPlugin) .add("collaborative", CollaborativePlugin) .add("history", HistoryPlugin) .add("data_cleanup", DataCleanupPlugin) .add("table_autofill", TableAutofillPlugin) .add("table_ui_resize", TableResizeUI) .add("datavalidation_insert", DataValidationInsertionPlugin); // Plugins which have a state, but which should not be shared in collaborative const statefulUIPluginRegistry = new Registry() .add("selection", GridSelectionPlugin) .add("evaluation_filter", FilterEvaluationPlugin) .add("header_visibility_ui", HeaderVisibilityUIPlugin) .add("cell_computed_style", CellComputedStylePlugin) .add("table_computed_style", TableComputedStylePlugin) .add("header_positions", HeaderPositionsUIPlugin) .add("viewport", SheetViewPlugin) .add("clipboard", ClipboardPlugin); // Plugins which have a derived state from core data const coreViewsPluginRegistry = new Registry() .add("evaluation", EvaluationPlugin) .add("evaluation_chart", EvaluationChartPlugin) .add("evaluation_cf", EvaluationConditionalFormatPlugin) .add("row_size", HeaderSizeUIPlugin) .add("data_validation_ui", EvaluationDataValidationPlugin) .add("dynamic_tables", DynamicTablesPlugin) .add("custom_colors", CustomColorsPlugin) .add("pivot_ui", PivotUIPlugin); const clickableCellRegistry = new Registry(); clickableCellRegistry.add("link", { condition: (position, getters) => { return !!getters.getEvaluatedCell(position).link; }, execute: (position, env) => openLink(env.model.getters.getEvaluatedCell(position).link, env), sequence: 5, }); class ImageProvider { fileStore; constructor(fileStore) { this.fileStore = fileStore; } async requestImage() { const file = await this.getImageFromUser(); const path = await this.fileStore.upload(file); const size = await this.getImageOriginalSize(path); return { path, size, mimetype: file.type }; } getImageFromUser() { return new Promise((resolve, reject) => { const input = document.createElement("input"); input.setAttribute("type", "file"); input.setAttribute("accept", "image/*"); input.addEventListener("change", async () => { if (input.files === null || input.files.length != 1) { reject(); } else { resolve(input.files[0]); } }); input.click(); }); } getImageOriginalSize(path) { return new Promise((resolve, reject) => { const image = new Image(); image.src = path; image.addEventListener("load", () => { const size = { width: image.width, height: image.height }; resolve(size); }); image.addEventListener("error", reject); }); } } const RIPPLE_KEY_FRAMES = [ { transform: "scale(0)" }, { transform: "scale(0.8)", offset: 0.33 }, { opacity: "0", transform: "scale(1)", offset: 1 }, ]; css /* scss */ ` .o-ripple { z-index: 1; } `; class RippleEffect extends Component { static template = "o-spreadsheet-RippleEffect"; static props = { x: String, y: String, color: String, opacity: Number, duration: Number, width: Number, height: Number, offsetY: Number, offsetX: Number, allowOverflow: Boolean, onAnimationEnd: Function, style: String, }; rippleRef = useRef("ripple"); setup() { let animation = undefined; onMounted(() => { const rippleEl = this.rippleRef.el; if (!rippleEl || !rippleEl.animate) return; animation = rippleEl.animate(RIPPLE_KEY_FRAMES, { duration: this.props.duration, easing: "ease-out", }); animation.addEventListener("finish", this.props.onAnimationEnd); }); onWillUnmount(() => { animation?.removeEventListener("finish", this.props.onAnimationEnd); }); } get rippleStyle() { const { x, y, width, height } = this.props; const offsetX = this.props.offsetX || 0; const offsetY = this.props.offsetY || 0; return cssPropertiesToCss({ transform: "scale(0)", left: x, top: y, "margin-left": `${-width / 2 + offsetX}px`, "margin-top": `${-height / 2 + offsetY}px`, width: `${width}px`, height: `${height}px`, background: this.props.color, "border-radius": "100%", opacity: `${this.props.opacity}`, }); } } class Ripple extends Component { static template = "o-spreadsheet-Ripple"; static props = { color: { type: String, optional: true }, opacity: { type: Number, optional: true }, duration: { type: Number, optional: true }, ignoreClickPosition: { type: Boolean, optional: true }, width: { type: Number, optional: true }, height: { type: Number, optional: true }, offsetY: { type: Number, optional: true }, offsetX: { type: Number, optional: true }, allowOverflow: { type: Boolean, optional: true }, enabled: { type: Boolean, optional: true }, onAnimationEnd: { type: Function, optional: true }, slots: Object, class: { type: String, optional: true }, }; static components = { RippleEffect }; static defaultProps = { color: "#aaaaaa", opacity: 0.4, duration: 800, enabled: true, onAnimationEnd: () => { }, class: "", }; childContainer = useRef("childContainer"); state = useState({ ripples: [] }); currentId = 1; onClick(ev) { if (!this.props.enabled) return; const containerEl = this.childContainer.el; if (!containerEl) return; const rect = this.getRippleChildRectInfo(); const { x, y, width, height } = rect; const maxDim = Math.max(width, height); const rippleRect = { x: ev.clientX - x, y: ev.clientY - y, width: this.props.width || maxDim * 2.85, height: this.props.height || maxDim * 2.85, }; this.state.ripples.push({ rippleRect, id: this.currentId++ }); } getRippleStyle() { const containerEl = this.childContainer.el; if (!containerEl || containerEl.childElementCount !== 1 || !containerEl.firstElementChild) { return ""; } const rect = this.getRippleChildRectInfo(); return cssPropertiesToCss({ top: rect.marginTop + "px", left: rect.marginLeft + "px", width: rect.width + "px", height: rect.height + "px", }); } getRippleChildRectInfo() { const el = this.childContainer.el; if (!el) throw new Error("No child container element found"); if (el.childElementCount !== 1 || !el.firstElementChild) { const boundingRect = getBoundingRectAsPOJO(el); return { ...boundingRect, marginLeft: 0, marginTop: 0 }; } const childEl = el.firstElementChild; const margins = getElementMargins(childEl); const boundingRect = getBoundingRectAsPOJO(childEl); return { ...boundingRect, marginLeft: margins.left, marginTop: margins.top, }; } removeRipple(id) { const index = this.state.ripples.findIndex((r) => r.id === id); if (index === -1) return; this.state.ripples.splice(index, 1); } getRippleEffectProps(id) { const rect = this.state.ripples.find((r) => r.id === id)?.rippleRect; if (!rect) throw new Error("Cannot find a ripple with the id " + id); return { color: this.props.color, opacity: this.props.opacity, duration: this.props.duration, x: this.props.ignoreClickPosition ? "50%" : rect.x + "px", y: this.props.ignoreClickPosition ? "50%" : rect.y + "px", width: rect.width, height: rect.height, offsetX: this.props.offsetX || 0, offsetY: this.props.offsetY || 0, allowOverflow: this.props.allowOverflow || false, style: this.getRippleStyle(), onAnimationEnd: () => this.removeRipple(id), }; } } function interactiveRenameSheet(env, sheetId, name, errorCallback) { const result = env.model.dispatch("RENAME_SHEET", { sheetId, name }); if (result.reasons.includes("MissingSheetName" /* CommandResult.MissingSheetName */)) { env.raiseError(_t("The sheet name cannot be empty."), errorCallback); } else if (result.reasons.includes("DuplicatedSheetName" /* CommandResult.DuplicatedSheetName */)) { env.raiseError(_t("A sheet with the name %s already exists. Please select another name.", name), errorCallback); } else if (result.reasons.includes("ForbiddenCharactersInSheetName" /* CommandResult.ForbiddenCharactersInSheetName */)) { env.raiseError(_t("Some used characters are not allowed in a sheet name (Forbidden characters are %s).", FORBIDDEN_SHEETNAME_CHARS.join(" ")), errorCallback); } } css /* scss */ ` .o-sheet { padding: 0 15px; padding-right: 10px; height: ${BOTTOMBAR_HEIGHT}px; border-left: 1px solid #c1c1c1; border-right: 1px solid #c1c1c1; margin-left: -1px; cursor: pointer; &:hover { background-color: rgba(0, 0, 0, 0.08); } &.active { color: ${ACTION_COLOR}; background-color: #ffffff; box-shadow: 0 1px 3px 1px rgba(60, 64, 67, 0.15); } .o-sheet-icon { z-index: 1; &:hover { background-color: rgba(0, 0, 0, 0.08); } } .o-sheet-name { outline: none; padding: 2px 4px; &.o-sheet-name-editable { border-radius: 2px; border: 2px solid mediumblue; /* negative margins so nothing moves when the border is added */ margin-left: -2px; margin-right: -2px; } } .o-sheet-color { bottom: 0; left: 0; height: 6px; z-index: 1; width: calc(100% - 1px); } } `; class BottomBarSheet extends Component { static template = "o-spreadsheet-BottomBarSheet"; static props = { sheetId: String, openContextMenu: Function, style: { type: String, optional: true }, onMouseDown: { type: Function, optional: true }, }; static components = { Ripple, ColorPicker }; static defaultProps = { onMouseDown: () => { }, style: "", }; state = useState({ isEditing: false, pickerOpened: false }); sheetDivRef = useRef("sheetDiv"); sheetNameRef = useRef("sheetNameSpan"); editionState = "initializing"; DOMFocusableElementStore; setup() { onMounted(() => { if (this.isSheetActive) { this.scrollToSheet(); } }); onPatched(() => { if (this.sheetNameRef.el && this.state.isEditing && this.editionState === "initializing") { this.editionState = "editing"; this.focusInputAndSelectContent(); } }); this.DOMFocusableElementStore = useStore(DOMFocusableElementStore); useExternalListener(window, "click", () => (this.state.pickerOpened = false)); } focusInputAndSelectContent() { if (!this.state.isEditing || !this.sheetNameRef.el) return; this.sheetNameRef.el.focus(); const selection = window.getSelection(); if (selection && this.sheetNameRef.el.firstChild) { selection.setBaseAndExtent(this.sheetNameRef.el.firstChild, 0, this.sheetNameRef.el.firstChild, this.sheetNameRef.el.textContent?.length || 0); } } scrollToSheet() { this.sheetDivRef.el?.scrollIntoView?.(); } onFocusOut() { if (this.state.isEditing && this.editionState !== "initializing") { this.stopEdition(); } } onMouseDown(ev) { this.activateSheet(); this.props.onMouseDown(ev); } activateSheet() { this.env.model.dispatch("ACTIVATE_SHEET", { sheetIdFrom: this.env.model.getters.getActiveSheetId(), sheetIdTo: this.props.sheetId, }); this.scrollToSheet(); } onDblClick() { if (this.env.model.getters.isReadonly()) { return; } this.startEdition(); } onKeyDown(ev) { if (!this.state.isEditing) return; if (ev.key === "Enter") { ev.preventDefault(); this.stopEdition(); this.DOMFocusableElementStore.focus(); } if (ev.key === "Escape") { this.cancelEdition(); this.DOMFocusableElementStore.focus(); } } onMouseEventSheetName(ev) { if (this.state.isEditing) ev.stopPropagation(); } startEdition() { this.state.isEditing = true; this.editionState = "initializing"; } stopEdition() { const input = this.sheetNameRef.el; if (!this.state.isEditing || !input) return; this.state.isEditing = false; this.editionState = "initializing"; input.blur(); const inputValue = this.getInputContent() || ""; input.innerText = inputValue; interactiveRenameSheet(this.env, this.props.sheetId, inputValue, () => this.startEdition()); } cancelEdition() { this.state.isEditing = false; this.editionState = "initializing"; this.sheetNameRef.el?.blur(); this.setInputContent(this.sheetName); } onIconClick(ev) { if (!this.isSheetActive) { this.activateSheet(); } this.props.openContextMenu(this.contextMenuRegistry, ev); } onContextMenu(ev) { if (!this.isSheetActive) { this.activateSheet(); } this.props.openContextMenu(this.contextMenuRegistry, ev); } getInputContent() { return this.sheetNameRef.el?.textContent; } setInputContent(content) { if (this.sheetNameRef.el) this.sheetNameRef.el.textContent = content; } onColorPicked(color) { this.state.pickerOpened = false; this.env.model.dispatch("COLOR_SHEET", { sheetId: this.props.sheetId, color }); } get colorPickerAnchorRect() { const button = this.sheetDivRef.el; return getBoundingRectAsPOJO(button); } get contextMenuRegistry() { return getSheetMenuRegistry({ renameSheetCallback: () => { this.scrollToSheet(); this.startEdition(); }, openSheetColorPickerCallback: () => { this.state.pickerOpened = true; }, }); } get isSheetActive() { return this.env.model.getters.getActiveSheetId() === this.props.sheetId; } get sheetName() { return this.env.model.getters.getSheetName(this.props.sheetId); } get sheetColorStyle() { const color = this.env.model.getters.getSheet(this.props.sheetId).color || ""; return cssPropertiesToCss({ background: color }); } } const selectionStatisticFunctions = [ { name: _t("Sum"), types: [CellValueType.number], compute: (values, locale) => sum([[values]], locale), }, { name: _t("Avg"), types: [CellValueType.number], compute: (values, locale) => average([[values]], locale), }, { name: _t("Min"), types: [CellValueType.number], compute: (values, locale) => min([[values]], locale).value, }, { name: _t("Max"), types: [CellValueType.number], compute: (values, locale) => max([[values]], locale).value, }, { name: _t("Count"), types: [CellValueType.number, CellValueType.text, CellValueType.boolean, CellValueType.error], compute: (values) => countAny([[values]]), }, { name: _t("Count Numbers"), types: [CellValueType.number, CellValueType.text, CellValueType.boolean, CellValueType.error], compute: (values, locale) => countNumbers([[values]], locale), }, ]; class AggregateStatisticsStore extends SpreadsheetStore { statisticFnResults = this._computeStatisticFnResults(); isDirty = false; constructor(get) { super(get); this.model.selection.observe(this, { handleEvent: this.handleEvent.bind(this), }); } handle(cmd) { if (invalidateEvaluationCommands.has(cmd.type) || (cmd.type === "UPDATE_CELL" && "content" in cmd)) { this.isDirty = true; } switch (cmd.type) { case "HIDE_COLUMNS_ROWS": case "UNHIDE_COLUMNS_ROWS": case "GROUP_HEADERS": case "UNGROUP_HEADERS": case "ACTIVATE_SHEET": case "ACTIVATE_NEXT_SHEET": case "ACTIVATE_PREVIOUS_SHEET": case "EVALUATE_CELLS": case "UNDO": case "REDO": this.isDirty = true; } } finalize() { if (this.isDirty) { this.isDirty = false; this.statisticFnResults = this._computeStatisticFnResults(); } } handleEvent() { if (this.getters.isGridSelectionActive()) { this.statisticFnResults = this._computeStatisticFnResults(); } } _computeStatisticFnResults() { const getters = this.getters; const sheetId = getters.getActiveSheetId(); const cells = []; const recomputedZones = recomputeZones(getters.getSelectedZones(), []); const heightMax = this.getters.getSheetSize(sheetId).numberOfRows - 1; const widthMax = this.getters.getSheetSize(sheetId).numberOfCols - 1; for (const zone of recomputedZones) { for (let col = zone.left; col <= (zone.right ?? widthMax); col++) { for (let row = zone.top; row <= (zone.bottom ?? heightMax); row++) { if (getters.isRowHidden(sheetId, row) || getters.isColHidden(sheetId, col)) { continue; // Skip hidden cells } const evaluatedCell = getters.getEvaluatedCell({ sheetId, col, row }); if (evaluatedCell.type !== CellValueType.empty) { cells.push(evaluatedCell); } } } } const locale = getters.getLocale(); let statisticFnResults = {}; const getCells = memoize((typeStr) => { const types = typeStr.split(","); return cells.filter((c) => types.includes(c.type)); }); for (let fn of selectionStatisticFunctions) { // We don't want to display statistical information when there is no interest: // We set the statistical result to undefined if the data handled by the selection // does not match the data handled by the function. // Ex: if there are only texts in the selection, we prefer that the SUM result // be displayed as undefined rather than 0. let fnResult = undefined; const evaluatedCells = getCells(fn.types.sort().join(",")); if (evaluatedCells.length) { fnResult = lazy(() => fn.compute(evaluatedCells, locale)); } statisticFnResults[fn.name] = fnResult; } return statisticFnResults; } } // ----------------------------------------------------------------------------- // SpreadSheet // ----------------------------------------------------------------------------- css /* scss */ ` .o-selection-statistic { margin-right: 20px; padding: 4px 4px 4px 8px; color: #333; cursor: pointer; &:hover { background-color: rgba(0, 0, 0, 0.08) !important; } } `; class BottomBarStatistic extends Component { static template = "o-spreadsheet-BottomBarStatistic"; static props = { openContextMenu: Function, closeContextMenu: Function, }; static components = { Ripple }; selectedStatisticFn = ""; store; setup() { this.store = useStore(AggregateStatisticsStore); onWillUpdateProps(() => { if (Object.values(this.store.statisticFnResults).every((result) => result === undefined)) { this.props.closeContextMenu(); } }); } getSelectedStatistic() { // don't display button if no function has a result if (Object.values(this.store.statisticFnResults).every((result) => result === undefined)) { return undefined; } if (this.selectedStatisticFn === "") { this.selectedStatisticFn = Object.keys(this.store.statisticFnResults)[0]; } return this.getComposedFnName(this.selectedStatisticFn); } listSelectionStatistics(ev) { const registry = new MenuItemRegistry(); let i = 0; for (let [fnName] of Object.entries(this.store.statisticFnResults)) { registry.add(fnName, { name: () => this.getComposedFnName(fnName), sequence: i, isReadonlyAllowed: true, execute: () => { this.selectedStatisticFn = fnName; }, }); i++; } const target = ev.currentTarget; const { top, left, width } = target.getBoundingClientRect(); this.props.openContextMenu(left + width, top, registry); } getComposedFnName(fnName) { const locale = this.env.model.getters.getLocale(); const fnValue = this.store.statisticFnResults[fnName]; return fnName + ": " + (fnValue !== undefined ? formatValue(fnValue(), { locale }) : "__"); } } // ----------------------------------------------------------------------------- // SpreadSheet // ----------------------------------------------------------------------------- const MENU_MAX_HEIGHT = 250; css /* scss */ ` .o-spreadsheet-bottom-bar { background-color: ${BACKGROUND_GRAY_COLOR}; padding-left: ${HEADER_WIDTH}px; font-size: 15px; border-top: 1px solid lightgrey; .o-sheet-item { cursor: pointer; &:hover { background-color: rgba(0, 0, 0, 0.08); } } .o-all-sheets { max-width: 70%; .o-bottom-bar-fade-out { background-image: linear-gradient(-90deg, #cfcfcf, transparent 1%); } .o-bottom-bar-fade-in { background-image: linear-gradient(90deg, #cfcfcf, transparent 1%); } .o-sheet-list { overflow-y: hidden; overflow-x: auto; &::-webkit-scrollbar { display: none; /* Chrome */ } -ms-overflow-style: none; /* IE and Edge */ scrollbar-width: none; /* Firefox */ } } .o-bottom-bar-arrows { .o-bottom-bar-arrow { cursor: pointer; &:hover:not([class*="o-disabled"]) { .o-icon { opacity: 0.9; } } .o-icon { height: 18px; width: 18px; font-size: 18px; } } } } `; class BottomBar extends Component { static template = "o-spreadsheet-BottomBar"; static props = { onClick: Function, }; static components = { Menu, Ripple, BottomBarSheet, BottomBarStatistic }; bottomBarRef = useRef("bottomBar"); sheetListRef = useRef("sheetList"); dragAndDrop = useDragAndDropListItems(); targetScroll = undefined; state = useState({ isSheetListScrollableLeft: false, isSheetListScrollableRight: false, }); menuMaxHeight = MENU_MAX_HEIGHT; menuState = useState({ isOpen: false, menuId: undefined, position: null, menuItems: [], }); sheetList = this.getVisibleSheets(); setup() { onWillUpdateProps(() => { this.updateScrollState(); const visibleSheets = this.getVisibleSheets(); // Cancel sheet dragging when there is a change in the sheets if (!deepEquals(this.sheetList, visibleSheets)) { this.dragAndDrop.cancel(); } this.sheetList = visibleSheets; }); } clickAddSheet(ev) { const activeSheetId = this.env.model.getters.getActiveSheetId(); const position = this.env.model.getters.getSheetIds().findIndex((sheetId) => sheetId === activeSheetId) + 1; const sheetId = this.env.model.uuidGenerator.uuidv4(); const name = this.env.model.getters.getNextSheetName(_t("Sheet")); this.env.model.dispatch("CREATE_SHEET", { sheetId, position, name }); this.env.model.dispatch("ACTIVATE_SHEET", { sheetIdFrom: activeSheetId, sheetIdTo: sheetId }); } getVisibleSheets() { return this.env.model.getters.getVisibleSheetIds().map((sheetId) => { const sheet = this.env.model.getters.getSheet(sheetId); return { id: sheet.id, name: sheet.name }; }); } clickListSheets(ev) { const registry = new MenuItemRegistry(); const from = this.env.model.getters.getActiveSheetId(); let i = 0; for (const sheetId of this.env.model.getters.getSheetIds()) { const sheet = this.env.model.getters.getSheet(sheetId); registry.add(sheetId, { name: sheet.name, sequence: i, isReadonlyAllowed: true, textColor: sheet.isVisible ? undefined : "#808080", execute: (env) => { if (!this.env.model.getters.isSheetVisible(sheetId)) { this.env.model.dispatch("SHOW_SHEET", { sheetId }); } env.model.dispatch("ACTIVATE_SHEET", { sheetIdFrom: from, sheetIdTo: sheetId }); }, isEnabled: (env) => (env.model.getters.isReadonly() ? sheet.isVisible : true), icon: sheet.color ? "o-spreadsheet-Icon.SMALL_DOT_RIGHT_ALIGN" : undefined, iconColor: sheet.color, }); i++; } const target = ev.currentTarget; const { left } = target.getBoundingClientRect(); const top = this.bottomBarRef.el.getBoundingClientRect().top; this.openContextMenu(left, top, "listSheets", registry); } openContextMenu(x, y, menuId, registry) { this.menuState.isOpen = true; this.menuState.menuId = menuId; this.menuState.menuItems = registry.getMenuItems(); this.menuState.position = { x, y }; } onSheetContextMenu(sheetId, registry, ev) { const target = ev.currentTarget; const { top, left } = target.getBoundingClientRect(); if (ev.closedMenuId === sheetId) { this.closeMenu(); return; } this.openContextMenu(left, top, sheetId, registry); } closeMenu() { this.menuState.isOpen = false; this.menuState.menuId = undefined; this.menuState.menuItems = []; this.menuState.position = null; } closeContextMenuWithId(menuId) { if (this.menuState.menuId === menuId) { this.closeMenu(); } } onWheel(ev) { this.targetScroll = undefined; const target = ev.currentTarget; target.scrollLeft += ev.deltaY * 0.5; } onScroll() { this.updateScrollState(); if (this.targetScroll === this.sheetListCurrentScroll) { this.targetScroll = undefined; } } onArrowLeft(ev) { if (!this.state.isSheetListScrollableLeft) return; if (!this.targetScroll) this.targetScroll = this.sheetListCurrentScroll; const newScroll = this.targetScroll - this.sheetListWidth; this.scrollSheetListTo(Math.max(0, newScroll)); } onArrowRight(ev) { if (!this.state.isSheetListScrollableRight) return; if (!this.targetScroll) this.targetScroll = this.sheetListCurrentScroll; const newScroll = this.targetScroll + this.sheetListWidth; this.scrollSheetListTo(Math.min(this.sheetListMaxScroll, newScroll)); } updateScrollState() { this.state.isSheetListScrollableLeft = this.sheetListCurrentScroll > 0; this.state.isSheetListScrollableRight = this.sheetListCurrentScroll < this.sheetListMaxScroll; } scrollSheetListTo(scroll) { if (!this.sheetListRef.el) return; this.targetScroll = scroll; this.sheetListRef.el.scrollTo({ top: 0, left: scroll, behavior: "smooth" }); } onSheetMouseDown(sheetId, event) { if (event.button !== 0 || this.env.model.getters.isReadonly()) return; this.closeMenu(); const visibleSheets = this.getVisibleSheets(); const sheetRects = this.getSheetItemRects(); const sheets = visibleSheets.map((sheet, index) => ({ id: sheet.id, size: sheetRects[index].width, position: sheetRects[index].x, })); this.dragAndDrop.start("horizontal", { draggedItemId: sheetId, initialMousePosition: event.clientX, items: sheets, containerEl: this.sheetListRef.el, onDragEnd: (sheetId, finalIndex) => this.onDragEnd(sheetId, finalIndex), }); } onDragEnd(sheetId, finalIndex) { const originalIndex = this.getVisibleSheets().findIndex((sheet) => sheet.id === sheetId); const delta = finalIndex - originalIndex; if (sheetId && delta !== 0) { this.env.model.dispatch("MOVE_SHEET", { sheetId: sheetId, delta: delta, }); } } getSheetStyle(sheetId) { return this.dragAndDrop.itemsStyle[sheetId] || ""; } getSheetItemRects() { return Array.from(this.bottomBarRef.el.querySelectorAll(`.o-sheet`)) .map((sheetEl) => sheetEl.getBoundingClientRect()) .map((rect) => ({ x: rect.x, width: rect.width - 1, // -1 to compensate negative margin y: rect.y, height: rect.height, })); } get sheetListCurrentScroll() { if (!this.sheetListRef.el) return 0; return this.sheetListRef.el.scrollLeft; } get sheetListWidth() { if (!this.sheetListRef.el) return 0; return this.sheetListRef.el.clientWidth; } get sheetListMaxScroll() { if (!this.sheetListRef.el) return 0; return this.sheetListRef.el.scrollWidth - this.sheetListRef.el.clientWidth; } } class ClickableCellsStore extends SpreadsheetStore { _clickableCells = markRaw({}); _registryItems = markRaw(clickableCellRegistry.getAll().sort((a, b) => a.sequence - b.sequence)); handle(cmd) { if (invalidateEvaluationCommands.has(cmd.type) || cmd.type === "EVALUATE_CELLS" || (cmd.type === "UPDATE_CELL" && ("content" in cmd || "format" in cmd))) { this._clickableCells = markRaw({}); this._registryItems = markRaw(clickableCellRegistry.getAll().sort((a, b) => a.sequence - b.sequence)); } } getClickableAction(position) { const { sheetId, col, row } = position; const clickableCells = this._clickableCells; const xc = toXC(col, row); if (!clickableCells[sheetId]) { clickableCells[sheetId] = {}; } if (!(xc in clickableCells[sheetId])) { clickableCells[sheetId][xc] = this.findClickableAction(position); } return clickableCells[sheetId][xc]; } findClickableAction(position) { const getters = this.getters; for (const item of this._registryItems) { if (item.condition(position, getters)) { return item.execute; } } return false; } get clickableCells() { const cells = []; const getters = this.getters; const sheetId = getters.getActiveSheetId(); for (const col of getters.getSheetViewVisibleCols()) { for (const row of getters.getSheetViewVisibleRows()) { const position = { sheetId, col, row }; if (!getters.isMainCellPosition(position)) { continue; } const action = this.getClickableAction(position); if (!action) { continue; } const zone = getters.expandZone(sheetId, positionToZone(position)); cells.push({ coordinates: getters.getVisibleRect(zone), position, action, }); } } return cells; } } css /* scss */ ` .o-dashboard-clickable-cell { position: absolute; cursor: pointer; } `; class SpreadsheetDashboard extends Component { static template = "o-spreadsheet-SpreadsheetDashboard"; static props = {}; static components = { GridOverlay, GridPopover, Popover, VerticalScrollBar, HorizontalScrollBar, }; cellPopovers; onMouseWheel; canvasPosition; hoveredCell; clickableCellsStore; setup() { const gridRef = useRef("grid"); this.canvasPosition = useAbsoluteBoundingRect(gridRef); this.hoveredCell = useStore(HoveredCellStore); this.clickableCellsStore = useStore(ClickableCellsStore); useChildSubEnv({ getPopoverContainerRect: () => this.getGridRect() }); useGridDrawing("canvas", this.env.model, () => this.env.model.getters.getSheetViewDimension()); this.onMouseWheel = useWheelHandler((deltaX, deltaY) => { this.moveCanvas(deltaX, deltaY); this.hoveredCell.clear(); }); this.cellPopovers = useStore(CellPopoverStore); } onCellHovered({ col, row }) { this.hoveredCell.hover({ col, row }); } get gridContainer() { const sheetId = this.env.model.getters.getActiveSheetId(); const { right } = this.env.model.getters.getSheetZone(sheetId); const { end } = this.env.model.getters.getColDimensions(sheetId, right); return cssPropertiesToCss({ "max-width": `${end}px` }); } get gridOverlayDimensions() { return cssPropertiesToCss({ height: "100%", width: "100%", }); } getCellClickableStyle(coordinates) { return cssPropertiesToCss({ top: `${coordinates.y}px`, left: `${coordinates.x}px`, width: `${coordinates.width}px`, height: `${coordinates.height}px`, }); } /** * Get all the boxes for the cell in the sheet view that are clickable. * This function is used to render an overlay over each clickable cell in * order to display a pointer cursor. * */ getClickableCells() { return toRaw(this.clickableCellsStore.clickableCells); } selectClickableCell(clickableCell) { const { position, action } = clickableCell; action(position, this.env); } onClosePopover() { this.cellPopovers.close(); } onGridResized({ height, width }) { this.env.model.dispatch("RESIZE_SHEETVIEW", { width: width, height: height, gridOffsetX: 0, gridOffsetY: 0, }); } moveCanvas(deltaX, deltaY) { const { scrollX, scrollY } = this.env.model.getters.getActiveSheetDOMScrollInfo(); this.env.model.dispatch("SET_VIEWPORT_OFFSET", { offsetX: scrollX + deltaX, offsetY: scrollY + deltaY, }); } getGridRect() { return { ...this.canvasPosition, ...this.env.model.getters.getSheetViewDimensionWithHeaders() }; } } css /* scss */ ` .o-header-group { .o-header-group-header { z-index: ${ComponentsImportance.HeaderGroupingButton}; .o-group-fold-button { cursor: pointer; width: 13px; height: 13px; border: 1px solid ${HEADER_GROUPING_BORDER_COLOR}; .o-icon { width: 7px; height: 7px; } &:hover { border-color: #777; } } } .o-group-border { box-sizing: border-box; } } `; class AbstractHeaderGroup extends Component { static template = "o-spreadsheet-HeaderGroup"; static props = { group: Object, layerOffset: Number, openContextMenu: Function, }; toggleGroup() { const sheetId = this.env.model.getters.getActiveSheetId(); const { start, end } = this.props.group; interactiveToggleGroup(this.env, sheetId, this.dimension, start, end); } get groupBoxStyle() { const groupBox = this.groupBox; return cssPropertiesToCss({ top: `${groupBox.groupRect.y}px`, left: `${groupBox.groupRect.x}px`, width: `${groupBox.groupRect.width}px`, height: `${groupBox.groupRect.height}px`, }); } get groupButtonStyle() { return cssPropertiesToCss({ "background-color": this.isGroupFolded ? "#333" : "#fff", color: this.isGroupFolded ? "#fff" : "#333", }); } get groupButtonIcon() { return this.isGroupFolded ? "o-spreadsheet-Icon.PLUS" : "o-spreadsheet-Icon.MINUS"; } get isGroupFolded() { const sheetId = this.env.model.getters.getActiveSheetId(); const group = this.props.group; return this.env.model.getters.isGroupFolded(sheetId, this.dimension, group.start, group.end); } onContextMenu(ev) { const sheetId = this.env.model.getters.getActiveSheetId(); const position = { x: ev.clientX, y: ev.clientY }; const group = this.props.group; const menuItems = getHeaderGroupContextMenu(sheetId, this.dimension, group.start, group.end); this.props.openContextMenu(position, menuItems); } } class RowGroup extends AbstractHeaderGroup { dimension = "ROW"; get groupBorderStyle() { const groupBox = this.groupBox; if (this.groupBox.groupRect.height === 0) { return ""; } return cssPropertiesToCss({ top: `${groupBox.headerRect.height / 2}px`, left: `calc(50% - 1px)`, // -1px: we want the border to be on the center width: `30%`, height: `calc(100% - ${groupBox.headerRect.height / 2}px)`, "border-left": `1px solid ${HEADER_GROUPING_BORDER_COLOR}`, "border-bottom": groupBox.isEndHidden ? "" : `1px solid ${HEADER_GROUPING_BORDER_COLOR}`, }); } get groupHeaderStyle() { return cssPropertiesToCss({ width: `100%`, height: `${this.groupBox.headerRect.height}px`, }); } get groupBox() { const sheetId = this.env.model.getters.getActiveSheetId(); const { start: startRow, end: endRow } = this.props.group; const startCoordinates = this.env.model.getters.getRowDimensions(sheetId, startRow).start; const endCoordinates = this.env.model.getters.getRowDimensions(sheetId, endRow).end; let groupHeaderY = 0; let groupHeaderHeight = HEADER_HEIGHT; if (startRow !== 0) { const headerRowDims = this.env.model.getters.getRowDimensions(sheetId, startRow - 1); groupHeaderY = HEADER_HEIGHT + headerRowDims.start; groupHeaderHeight = headerRowDims.end - headerRowDims.start; } const headerRect = { x: this.props.layerOffset, y: groupHeaderY, width: GROUP_LAYER_WIDTH, height: groupHeaderHeight, }; const groupRect = { x: this.props.layerOffset, y: headerRect.y, width: GROUP_LAYER_WIDTH, height: headerRect.height + (endCoordinates - startCoordinates), }; return { headerRect, groupRect, isEndHidden: this.env.model.getters.isRowHidden(sheetId, endRow), }; } } class ColGroup extends AbstractHeaderGroup { dimension = "COL"; get groupBorderStyle() { const groupBox = this.groupBox; if (groupBox.groupRect.width === 0) { return ""; } return cssPropertiesToCss({ top: `calc(50% - 1px)`, // -1px: we want the border to be on the center left: `${groupBox.headerRect.width / 2}px`, width: `calc(100% - ${groupBox.headerRect.width / 2}px)`, height: `30%`, "border-top": `1px solid ${HEADER_GROUPING_BORDER_COLOR}`, "border-right": groupBox.isEndHidden ? "" : `1px solid ${HEADER_GROUPING_BORDER_COLOR}`, }); } get groupHeaderStyle() { return cssPropertiesToCss({ width: `${this.groupBox.headerRect.width}px`, height: `100%`, }); } get groupBox() { const sheetId = this.env.model.getters.getActiveSheetId(); const { start: startCol, end: endCol } = this.props.group; const startCoordinates = this.env.model.getters.getColDimensions(sheetId, startCol).start; const endCoordinates = this.env.model.getters.getColDimensions(sheetId, endCol).end; let groupHeaderX = 0; let groupHeaderWidth = HEADER_WIDTH; if (startCol !== 0) { const headerRowDims = this.env.model.getters.getColDimensions(sheetId, startCol - 1); groupHeaderX = HEADER_WIDTH + headerRowDims.start; groupHeaderWidth = headerRowDims.end - headerRowDims.start; } const headerRect = { x: groupHeaderX, y: this.props.layerOffset, width: groupHeaderWidth, height: GROUP_LAYER_WIDTH, }; const groupRect = { x: headerRect.x, y: this.props.layerOffset, width: headerRect.width + (endCoordinates - startCoordinates), height: GROUP_LAYER_WIDTH, }; return { headerRect, groupRect, isEndHidden: this.env.model.getters.isColHidden(sheetId, endCol), }; } } css /* scss */ ` .o-header-group-frozen-pane-border { &.o-group-rows { margin-top: -1px; border-bottom: 3px solid ${FROZEN_PANE_HEADER_BORDER_COLOR}; } &.o-group-columns { margin-left: -1px; border-right: 3px solid ${FROZEN_PANE_HEADER_BORDER_COLOR}; } } .o-header-group-main-pane { &.o-group-rows { margin-top: -2px; // Counteract o-header-group-frozen-pane-border offset } &.o-group-columns { margin-left: -2px; } } `; class HeaderGroupContainer extends Component { static template = "o-spreadsheet-HeaderGroupContainer"; static props = { dimension: String, layers: Array, }; static components = { RowGroup, ColGroup, Menu }; menu = useState({ isOpen: false, position: null, menuItems: [] }); getLayerOffset(layerIndex) { return layerIndex * GROUP_LAYER_WIDTH; } onContextMenu(event) { const sheetId = this.env.model.getters.getActiveSheetId(); const position = { x: event.clientX, y: event.clientY }; const menuItems = createHeaderGroupContainerContextMenu(sheetId, this.props.dimension); this.openContextMenu(position, menuItems); } openContextMenu(position, menuItems) { this.menu.isOpen = true; this.menu.position = position; this.menu.menuItems = menuItems; } closeMenu() { this.menu.isOpen = false; this.menu.position = null; this.menu.menuItems = []; } get groupComponent() { return this.props.dimension === "ROW" ? RowGroup : ColGroup; } get hasFrozenPane() { const viewportCoordinates = this.env.model.getters.getMainViewportCoordinates(); return this.props.dimension === "COL" ? viewportCoordinates.x > 0 : viewportCoordinates.y > 0; } get scrollContainerStyle() { const { scrollX, scrollY } = this.env.model.getters.getActiveSheetScrollInfo(); const cssProperties = {}; if (this.props.dimension === "COL") { cssProperties.left = `${-scrollX - this.frozenPaneContainerSize}px`; } else { cssProperties.top = `${-scrollY - this.frozenPaneContainerSize}px`; } return cssPropertiesToCss(cssProperties); } get frozenPaneContainerStyle() { const cssProperties = {}; if (this.props.dimension === "COL") { cssProperties.width = `${this.frozenPaneContainerSize}px`; } else { cssProperties.height = `${this.frozenPaneContainerSize}px`; } return cssPropertiesToCss(cssProperties); } get frozenPaneContainerSize() { if (!this.hasFrozenPane) { return 0; } const viewportCoordinates = this.env.model.getters.getMainViewportCoordinates(); if (this.props.dimension === "COL") { return HEADER_WIDTH + viewportCoordinates.x; } else { return HEADER_HEIGHT + viewportCoordinates.y; } } } css /* scss */ ` .o-sidePanel { display: flex; flex-direction: column; overflow-x: hidden; background-color: white; border: solid ${GRAY_300}; border-width: 1px 0 0 1px; user-select: none; color: ${TEXT_BODY}; .o-sidePanelTitle { line-height: 20px; font-size: 16px; } .o-sidePanelHeader { padding: 8px 16px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid ${GRAY_300}; .o-sidePanelClose { padding: 5px 10px; cursor: pointer; &:hover { background-color: WhiteSmoke; } } } .o-sidePanelBody-container { /* This overwrites the min-height: auto; of flex. Without this, a flex div cannot be smaller than its children */ min-height: 0; } .o-sidePanelBody { overflow: auto; width: 100%; height: 100%; .o-section { padding: 16px; .o-section-title { font-weight: 500; margin-bottom: 5px; } .o-section-subtitle { font-weight: 500; font-size: 13px; line-height: 14px; margin: 8px 0 4px 0; } .o-subsection-left { display: inline-block; width: 47%; margin-right: 3%; } .o-subsection-right { display: inline-block; width: 47%; margin-left: 3%; } } .o-sidePanel-composer { color: ${TEXT_BODY}; } } .o-sidePanelButtons { display: flex; gap: 8px; } .o-invalid { border-width: 2px; border-color: red; } .o-sidePanel-handle-container { width: 8px; position: fixed; top: 50%; z-index: 1; } .o-sidePanel-handle { cursor: col-resize; color: #a9a9a9; .o-icon { height: 25px; margin-left: -5px; } } } .o-fw-bold { font-weight: 500; } `; class SidePanel extends Component { static template = "o-spreadsheet-SidePanel"; static props = {}; sidePanelStore; spreadsheetRect = useSpreadsheetRect(); setup() { this.sidePanelStore = useStore(SidePanelStore); useEffect((isOpen) => { if (!isOpen) { this.sidePanelStore.close(); } }, () => [this.sidePanelStore.isOpen]); } get panel() { return sidePanelRegistry.get(this.sidePanelStore.componentTag); } close() { this.sidePanelStore.close(); } getTitle() { const panel = this.panel; return typeof panel.title === "function" ? panel.title(this.env, this.sidePanelStore.panelProps) : panel.title; } startHandleDrag(ev) { const startingCursor = document.body.style.cursor; const startSize = this.sidePanelStore.panelSize; const startPosition = ev.clientX; const onMouseMove = (ev) => { document.body.style.cursor = "col-resize"; const newSize = startSize + startPosition - ev.clientX; this.sidePanelStore.changePanelSize(newSize, this.spreadsheetRect.width); }; const cleanUp = () => { document.body.style.cursor = startingCursor; }; startDnd(onMouseMove, cleanUp); } } css /* scss */ ` .o-menu-item-button { display: flex; justify-content: center; align-items: center; margin: 2px 1px; padding: 0px 1px; border-radius: 2px; min-width: 20px; } .o-disabled { opacity: 0.6; cursor: default; } `; class ActionButton extends Component { static template = "o-spreadsheet-ActionButton"; static props = { action: Object, hasTriangleDownIcon: { type: Boolean, optional: true }, selectedColor: { type: String, optional: true }, class: { type: String, optional: true }, onClick: { type: Function, optional: true }, }; actionButton = createAction(this.props.action); setup() { onWillUpdateProps((nextProps) => { if (nextProps.action !== this.props.action) { this.actionButton = createAction(nextProps.action); } }); } get isVisible() { return this.actionButton.isVisible(this.env); } get isEnabled() { return this.actionButton.isEnabled(this.env); } get isActive() { return this.actionButton.isActive?.(this.env); } get title() { const name = this.actionButton.name(this.env); const description = this.actionButton.description(this.env); return name + (description ? ` (${description})` : ""); } get iconTitle() { return this.actionButton.icon(this.env); } onClick(ev) { if (this.isEnabled) { this.props.onClick?.(ev); this.actionButton.execute?.(this.env); } } get buttonStyle() { if (this.props.selectedColor) { return cssPropertiesToCss({ "border-bottom": `4px solid ${this.props.selectedColor}`, height: "16px", "margin-top": "2px", }); } return ""; } } /** * List the available borders positions and the corresponding icons. * The structure of this array is defined to match the order/lines we want * to display in the topbar's border tool. */ const BORDER_POSITIONS = [ [ ["all", "o-spreadsheet-Icon.BORDERS"], ["hv", "o-spreadsheet-Icon.BORDER_HV"], ["h", "o-spreadsheet-Icon.BORDER_H"], ["v", "o-spreadsheet-Icon.BORDER_V"], ["external", "o-spreadsheet-Icon.BORDER_EXTERNAL"], ], [ ["left", "o-spreadsheet-Icon.BORDER_LEFT"], ["top", "o-spreadsheet-Icon.BORDER_TOP"], ["right", "o-spreadsheet-Icon.BORDER_RIGHT"], ["bottom", "o-spreadsheet-Icon.BORDER_BOTTOM"], ["clear", "o-spreadsheet-Icon.BORDER_CLEAR"], ], ]; // ----------------------------------------------------------------------------- // Border Editor // ----------------------------------------------------------------------------- css /* scss */ ` .o-border-selector { padding: 4px; background-color: white; .o-divider { border-right: 1px solid ${GRAY_300}; margin: 0 6px; } .o-border-selector-section { .o-dropdown-line { height: 30px; margin: 1px; .o-line-item { padding: 4px; width: 18px; height: 18px; &.active { background-color: ${BUTTON_ACTIVE_BG}; } } } .o-border-style-tool { padding: 0px 3px; margin: 2px; height: 25px; } } } .o-border-style-dropdown { background: #ffffff; padding: 4px; .o-dropdown-line { } .o-style-preview { margin: 7px 5px 7px 5px; width: 60px; height: 5px; } .o-style-thin { border-bottom: 1px solid #000000; } .o-style-medium { border-bottom: 2px solid #000000; } .o-style-thick { border-bottom: 3px solid #000000; } .o-style-dashed { border-bottom: 1px dashed #000000; } .o-style-dotted { border-bottom: 1px dotted #000000; } .o-dropdown-border-type { cursor: pointer; &:not(.o-disabled):not(.active):hover { background-color: ${BUTTON_HOVER_BG}; } } .o-dropdown-border-check { width: 20px; font-size: 12px; } .o-border-picker-button { padding: 0px !important; margin: 5px 0px 0px 0px !important; height: 25px !important; } } `; class BorderEditor extends Component { static template = "o-spreadsheet-BorderEditor"; static props = { class: { type: String, optional: true }, currentBorderColor: { type: String, optional: false }, currentBorderStyle: { type: String, optional: false }, currentBorderPosition: { type: String, optional: true }, onBorderColorPicked: Function, onBorderStylePicked: Function, onBorderPositionPicked: Function, maxHeight: { type: Number, optional: true }, anchorRect: Object, }; static components = { ColorPickerWidget, Popover }; BORDER_POSITIONS = BORDER_POSITIONS; lineStyleButtonRef = useRef("lineStyleButton"); borderStyles = borderStyles; state = useState({ activeTool: undefined, }); toggleDropdownTool(tool) { const isOpen = this.state.activeTool === tool; this.state.activeTool = isOpen ? undefined : tool; } closeDropdown() { this.state.activeTool = undefined; } setBorderPosition(position) { this.props.onBorderPositionPicked(position); this.closeDropdown(); } setBorderColor(color) { this.props.onBorderColorPicked(color); this.closeDropdown(); } setBorderStyle(style) { this.props.onBorderStylePicked(style); this.closeDropdown(); } get lineStylePickerPopoverProps() { return { anchorRect: this.lineStylePickerAnchorRect, positioning: "BottomLeft", verticalOffset: 0, }; } get popoverProps() { return { anchorRect: this.props.anchorRect, maxHeight: this.props.maxHeight, positioning: "BottomLeft", verticalOffset: 0, }; } get lineStylePickerAnchorRect() { const button = this.lineStyleButtonRef.el; if (button === null) { return { x: 0, y: 0, width: 0, height: 0 }; } const buttonRect = button.getBoundingClientRect(); return { x: buttonRect.x, y: buttonRect.y, width: buttonRect.width, height: buttonRect.height, }; } } class BorderEditorWidget extends Component { static template = "o-spreadsheet-BorderEditorWidget"; static props = { toggleBorderEditor: Function, showBorderEditor: Boolean, disabled: { type: Boolean, optional: true }, dropdownMaxHeight: { type: Number, optional: true }, class: { type: String, optional: true }, }; static components = { BorderEditor }; borderEditorButtonRef = useRef("borderEditorButton"); state = useState({ currentColor: DEFAULT_BORDER_DESC.color, currentStyle: DEFAULT_BORDER_DESC.style, currentPosition: undefined, }); get borderEditorAnchorRect() { const button = this.borderEditorButtonRef.el; const buttonRect = button.getBoundingClientRect(); return { x: buttonRect.x, y: buttonRect.y, width: buttonRect.width, height: buttonRect.height, }; } onBorderPositionPicked(position) { this.state.currentPosition = position; this.updateBorder(); } onBorderColorPicked(color) { this.state.currentColor = color; this.updateBorder(); } onBorderStylePicked(style) { this.state.currentStyle = style; this.updateBorder(); } updateBorder() { if (this.state.currentPosition === undefined) { return; } this.env.model.dispatch("SET_ZONE_BORDERS", { sheetId: this.env.model.getters.getActiveSheetId(), target: this.env.model.getters.getSelectedZones(), border: { position: this.state.currentPosition, color: this.state.currentColor, style: this.state.currentStyle, }, }); } } const COMPOSER_MAX_HEIGHT = 100; /* svg free of use from https://uxwing.com/formula-fx-icon/ */ const FX_SVG = /*xml*/ ` `; css /* scss */ ` .o-topbar-composer { height: fit-content; margin-top: -1px; border: 1px solid; font-family: ${DEFAULT_FONT}; .o-composer:empty:not(:focus):not(.active)::before { content: url("data:image/svg+xml,${encodeURIComponent(FX_SVG)}"); position: relative; top: 20%; } } .user-select-text { user-select: text; } `; class TopBarComposer extends Component { static template = "o-spreadsheet-TopBarComposer"; static props = {}; static components = { Composer }; composerFocusStore; composerStore; composerInterface; setup() { this.composerFocusStore = useStore(ComposerFocusStore); const composerStore = useStore(CellComposerStore); this.composerStore = composerStore; this.composerInterface = { id: "topbarComposer", get editionMode() { return composerStore.editionMode; }, startEdition: this.composerStore.startEdition, setCurrentContent: this.composerStore.setCurrentContent, stopEdition: this.composerStore.stopEdition, }; } get focus() { return this.composerFocusStore.activeComposer === this.composerInterface ? this.composerFocusStore.focusMode : "inactive"; } get composerStyle() { const style = { padding: "5px 0px 5px 8px", "max-height": `${COMPOSER_MAX_HEIGHT}px`, "line-height": "24px", }; style.height = this.focus === "inactive" ? `${TOPBAR_TOOLBAR_HEIGHT}px` : "fit-content"; return cssPropertiesToCss(style); } get containerStyle() { if (this.focus === "inactive") { return cssPropertiesToCss({ "border-color": SEPARATOR_COLOR, "border-right": "none", }); } return cssPropertiesToCss({ "border-color": SELECTION_BORDER_COLOR, "z-index": String(ComponentsImportance.TopBarComposer), }); } onFocus(selection) { this.composerFocusStore.focusComposer(this.composerInterface, { selection }); } } css /* scss */ ` .o-font-size-editor { height: calc(100% - 4px); input.o-font-size { outline-color: ${SELECTION_BORDER_COLOR}; height: 20px; width: 23px; } } .o-text-options > div { cursor: pointer; line-height: 26px; padding: 3px 12px; &:hover { background-color: rgba(0, 0, 0, 0.08); } } `; class FontSizeEditor extends Component { static template = "o-spreadsheet-FontSizeEditor"; static props = { onToggle: Function, dropdownStyle: String, class: String, }; static components = {}; fontSizes = FONT_SIZES; dropdown = useState({ isOpen: false }); inputRef = useRef("inputFontSize"); rootEditorRef = useRef("FontSizeEditor"); setup() { useExternalListener(window, "click", this.onExternalClick, { capture: true }); } onExternalClick(ev) { if (!isChildEvent(this.rootEditorRef.el, ev)) { this.closeFontList(); } } get currentFontSize() { return this.env.model.getters.getCurrentStyle().fontSize || DEFAULT_FONT_SIZE; } toggleFontList() { const isOpen = this.dropdown.isOpen; if (!isOpen) { this.props.onToggle(); this.inputRef.el.focus(); } else { this.closeFontList(); } } closeFontList() { this.dropdown.isOpen = false; } setSize(fontSizeStr) { const fontSize = clip(Math.floor(parseFloat(fontSizeStr)), 1, 400); setStyle(this.env, { fontSize }); this.closeFontList(); } setSizeFromInput(ev) { this.setSize(ev.target.value); } setSizeFromList(fontSizeStr) { this.setSize(fontSizeStr); } onInputFocused(ev) { this.dropdown.isOpen = true; ev.target.select(); } onInputKeydown(ev) { if (ev.key === "Enter" || ev.key === "Escape") { this.closeFontList(); const target = ev.target; // In the case of a ESCAPE key, we get the previous font size back if (ev.key === "Escape") { target.value = `${this.currentFontSize}`; } this.props.onToggle(); } } } class TableDropdownButton extends Component { static template = "o-spreadsheet-TableDropdownButton"; static components = { TableStylesPopover, ActionButton }; static props = {}; state = useState({ popoverProps: undefined }); onStylePicked(styleId) { const sheetId = this.env.model.getters.getActiveSheetId(); const tableConfig = { ...this.tableConfig, styleId }; const result = interactiveCreateTable(this.env, sheetId, tableConfig); if (result.isSuccessful) { this.env.openSidePanel("TableSidePanel", {}); } this.closePopover(); } onClick(ev) { if (ev.hasClosedTableStylesPopover || this.state.popoverProps) { this.closePopover(); return; } if (this.env.model.getters.getFirstTableInSelection()) { this.env.toggleSidePanel("TableSidePanel", {}); return; } // Open the popover const target = ev.currentTarget; const { bottom, left } = target.getBoundingClientRect(); this.state.popoverProps = { anchorRect: { x: left, y: bottom, width: 0, height: 0 }, positioning: "BottomLeft", verticalOffset: 0, }; } closePopover() { this.state.popoverProps = undefined; } get action() { return { name: (env) => this.env.model.getters.getFirstTableInSelection() ? _t("Edit table") : _t("Insert table"), icon: (env) => this.env.model.getters.getFirstTableInSelection() ? "o-spreadsheet-Icon.EDIT_TABLE" : "o-spreadsheet-Icon.PAINT_TABLE", }; } get tableConfig() { return { ...DEFAULT_TABLE_CONFIG, numberOfHeaders: 1, bandedRows: true }; } } class PaintFormatButton extends Component { static template = "o-spreadsheet-PaintFormatButton"; static props = { class: { type: String, optional: true }, }; paintFormatStore; setup() { this.paintFormatStore = useStore(PaintFormatStore); } get isActive() { return this.paintFormatStore.isActive; } onDblClick() { this.paintFormatStore.activate({ persistent: true }); } togglePaintFormat() { if (this.isActive) { this.paintFormatStore.cancel(); } else { this.paintFormatStore.activate({ persistent: false }); } } } // ----------------------------------------------------------------------------- // TopBar // ----------------------------------------------------------------------------- css /* scss */ ` .o-spreadsheet-topbar { line-height: 1.2; font-size: 13px; font-weight: 500; background-color: #fff; .o-topbar-top { border-bottom: 1px solid ${SEPARATOR_COLOR}; padding: 2px 10px; /* Menus */ .o-topbar-topleft { .o-topbar-menu { padding: 4px 6px; margin: 0 2px; &.active { background-color: ${BUTTON_ACTIVE_BG}; color: ${BUTTON_ACTIVE_TEXT_COLOR}; } } } } .o-topbar-composer { flex-grow: 1; } /* Toolbar */ .o-topbar-toolbar { height: ${TOPBAR_TOOLBAR_HEIGHT}px; .o-readonly-toolbar { background-color: ${BACKGROUND_HEADER_COLOR}; padding-left: 18px; padding-right: 18px; } /* Toolbar */ .o-toolbar-tools { display: flex; flex-shrink: 0; margin: 0px 6px 0px 16px; cursor: default; .o-divider { display: inline-block; border-right: 1px solid ${SEPARATOR_COLOR}; width: 0; margin: 0 6px; } .o-dropdown { position: relative; display: flex; align-items: center; > span { height: 30px; } .o-dropdown-content { position: absolute; top: 100%; left: 0; overflow-y: auto; overflow-x: hidden; padding: 2px; z-index: ${ComponentsImportance.Dropdown}; box-shadow: 1px 2px 5px 2px rgba(51, 51, 51, 0.15); background-color: white; .o-dropdown-line { display: flex; > span { padding: 4px; } } } } } } } `; class TopBar extends Component { static template = "o-spreadsheet-TopBar"; static props = { onClick: Function, dropdownMaxHeight: Number, }; get dropdownStyle() { return `max-height:${this.props.dropdownMaxHeight}px`; } static components = { ColorPickerWidget, ColorPicker, Menu, TopBarComposer, FontSizeEditor, ActionButton, PaintFormatButton, BorderEditorWidget, TableDropdownButton, }; state = useState({ menuState: { isOpen: false, position: null, menuItems: [] }, activeTool: "", fillColor: "#ffffff", textColor: "#000000", }); isSelectingMenu = false; openedEl = null; menus = []; EDIT = ACTION_EDIT; FORMAT = ACTION_FORMAT; DATA = ACTION_DATA; formatNumberMenuItemSpec = formatNumberMenuItemSpec; isntToolbarMenu = false; composerFocusStore; setup() { this.composerFocusStore = useStore(ComposerFocusStore); useExternalListener(window, "click", this.onExternalClick); onWillStart(() => this.updateCellState()); onWillUpdateProps(() => this.updateCellState()); } get topbarComponents() { return topbarComponentRegistry .getAllOrdered() .filter((item) => !item.isVisible || item.isVisible(this.env)); } onExternalClick(ev) { // TODO : manage click events better. We need this piece of code // otherwise the event opening the menu would close it on the same frame. // And we cannot stop the event propagation because it's used in an // external listener of the Menu component to close the context menu when // clicking on the top bar if (this.openedEl === ev.target) { return; } this.closeMenus(); } onClick() { this.props.onClick(); this.closeMenus(); } onMenuMouseOver(menu, ev) { if (this.isSelectingMenu && this.isntToolbarMenu) { this.openMenu(menu, ev); } } toggleDropdownTool(tool, ev) { const isOpen = this.state.activeTool === tool; this.closeMenus(); this.state.activeTool = isOpen ? "" : tool; this.openedEl = isOpen ? null : ev.target; } toggleContextMenu(menu, ev) { if (this.state.menuState.isOpen && this.isntToolbarMenu) { this.closeMenus(); } else { this.openMenu(menu, ev); this.isntToolbarMenu = true; } } toggleToolbarContextMenu(menuSpec, ev) { if (this.state.menuState.isOpen && !this.isntToolbarMenu) { this.closeMenus(); } else { const menu = createAction(menuSpec); this.openMenu(menu, ev); this.isntToolbarMenu = false; } } openMenu(menu, ev) { const { left, top, height } = ev.currentTarget.getBoundingClientRect(); this.state.activeTool = ""; this.state.menuState.isOpen = true; this.state.menuState.position = { x: left, y: top + height }; this.state.menuState.menuItems = menu .children(this.env) .sort((a, b) => a.sequence - b.sequence); this.state.menuState.parentMenu = menu; this.isSelectingMenu = true; this.openedEl = ev.target; this.composerFocusStore.activeComposer.stopEdition(); } closeMenus() { this.state.activeTool = ""; this.state.menuState.isOpen = false; this.state.menuState.parentMenu = undefined; this.isSelectingMenu = false; this.openedEl = null; } updateCellState() { const style = this.env.model.getters.getCurrentStyle(); this.state.fillColor = style.fillColor || "#ffffff"; this.state.textColor = style.textColor || "#000000"; this.menus = topbarMenuRegistry.getMenuItems(); } getMenuName(menu) { return menu.name(this.env); } setColor(target, color) { setStyle(this.env, { [target]: color }); this.onClick(); } } function instantiateClipboard() { return new WebClipboardWrapper(navigator.clipboard); } class WebClipboardWrapper { clipboard; // Can be undefined because navigator.clipboard doesn't exist in old browsers constructor(clipboard) { this.clipboard = clipboard; } async write(clipboardContent) { if (this.clipboard?.write) { try { await this.clipboard?.write(this.getClipboardItems(clipboardContent)); } catch (e) { /** * Some browsers do not support writing custom mimetypes in the clipboard. * Therefore, we try to catch any errors and fallback on writing only standard * mimetypes to prevent the whole copy action from crashing. */ try { await this.clipboard?.write([ new ClipboardItem({ [ClipboardMIMEType.PlainText]: this.getBlob(clipboardContent, ClipboardMIMEType.PlainText), [ClipboardMIMEType.Html]: this.getBlob(clipboardContent, ClipboardMIMEType.Html), }), ]); } catch (e) { } } } else { await this.writeText(clipboardContent[ClipboardMIMEType.PlainText] ?? ""); } } async writeText(text) { try { this.clipboard?.writeText(text); } catch (e) { } } async read() { let permissionResult = undefined; try { //@ts-ignore - clipboard-read is not implemented in all browsers permissionResult = await navigator.permissions.query({ name: "clipboard-read" }); } catch (e) { } if (this.clipboard?.read) { try { const clipboardItems = await this.clipboard.read(); const clipboardContent = {}; for (const item of clipboardItems) { for (const type of item.types) { const blob = await item.getType(type); const text = await blob.text(); clipboardContent[type] = text; } } return { status: "ok", content: clipboardContent }; } catch (e) { const status = permissionResult?.state === "denied" ? "permissionDenied" : "notImplemented"; return { status }; } } else { return { status: "ok", content: { [ClipboardMIMEType.PlainText]: await this.clipboard?.readText(), }, }; } } getClipboardItems(content) { const clipboardItemData = { [ClipboardMIMEType.PlainText]: this.getBlob(content, ClipboardMIMEType.PlainText), [ClipboardMIMEType.Html]: this.getBlob(content, ClipboardMIMEType.Html), }; return [new ClipboardItem(clipboardItemData)]; } getBlob(clipboardContent, type) { return new Blob([clipboardContent[type] || ""], { type, }); } } // ----------------------------------------------------------------------------- // SpreadSheet // ----------------------------------------------------------------------------- const CARET_DOWN_SVG = /*xml*/ ` `; css /* scss */ ` .o-spreadsheet { position: relative; display: grid; color: ${TEXT_BODY}; font-size: 14px; input { background-color: white; } .text-muted { color: ${TEXT_BODY_MUTED} !important; } .o-disabled { opacity: 0.4; cursor: default; pointer-events: none; } &, *, *:before, *:after { box-sizing: content-box; /** rtl not supported ATM */ direction: ltr; } .o-separator { border-bottom: ${MENU_SEPARATOR_BORDER_WIDTH}px solid ${SEPARATOR_COLOR}; margin-top: ${MENU_SEPARATOR_PADDING}px; margin-bottom: ${MENU_SEPARATOR_PADDING}px; } .o-hoverable-button { border-radius: 2px; cursor: pointer; .o-icon { color: ${TEXT_BODY}; } &:not(.o-disabled):not(.active):hover { background-color: ${BUTTON_HOVER_BG}; color: ${BUTTON_HOVER_TEXT_COLOR}; .o-icon { color: ${BUTTON_HOVER_TEXT_COLOR}; } } &.active { background-color: ${BUTTON_ACTIVE_BG}; color: ${BUTTON_ACTIVE_TEXT_COLOR}; .o-icon { color: ${BUTTON_ACTIVE_TEXT_COLOR}; } } } .o-grid-container { display: grid; background-color: ${HEADER_GROUPING_BACKGROUND_COLOR}; .o-top-left { border: 1px solid ${GRID_BORDER_COLOR}; margin-bottom: -1px; margin-right: -1px; } .o-column-groups { grid-column-start: 2; border-top: 1px solid ${GRID_BORDER_COLOR}; } .o-row-groups { grid-row-start: 2; } .o-group-grid { border-top: 1px solid ${GRID_BORDER_COLOR}; border-left: 1px solid ${GRID_BORDER_COLOR}; } } .o-input { min-width: 0px; padding: 1px 0; box-sizing: border-box; width: 100%; outline: none; border-color: ${GRAY_300}; color: ${GRAY_900}; &::placeholder { opacity: 0.5; } &:focus { border-color: ${ACTION_COLOR}; } } select.o-input { cursor: pointer; border-width: 0 0 1px 0; padding: 1px 6px 1px 0px; appearance: none; -webkit-appearance: none; -moz-appearance: none; background: transparent url("data:image/svg+xml,${encodeURIComponent(CARET_DOWN_SVG)}") no-repeat right center; text-overflow: ellipsis; &:disabled { color: ${DISABLED_TEXT_COLOR}; opacity: 0.4; cursor: default; } } .o-input[type="text"] { border-width: 0 0 1px 0; } .o-input[type="number"], .o-number-input { border-width: 0 0 1px 0; /* Remove number input arrows */ appearance: textfield; &::-webkit-outer-spin-button, &::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } } } .o-two-columns { grid-column: 1 / 3; } .o-text-icon { vertical-align: middle; } `; // ----------------------------------------------------------------------------- // GRID STYLE // ----------------------------------------------------------------------------- css /* scss */ ` .o-grid { position: relative; overflow: hidden; background-color: ${BACKGROUND_GRAY_COLOR}; &:focus { outline: none; } > canvas { border-bottom: 1px solid #e2e3e3; } .o-scrollbar { &.corner { right: 0px; bottom: 0px; height: ${SCROLLBAR_WIDTH}px; width: ${SCROLLBAR_WIDTH}px; border-top: 1px solid #e2e3e3; border-left: 1px solid #e2e3e3; } } .o-grid-overlay { position: absolute; outline: none; } } .o-button { border: 1px solid; border-radius: 4px; font-weight: 500; font-size: 14px; height: 30px; line-height: 16px; flex-grow: 1; background-color: ${BUTTON_BG}; border: 1px solid ${GRAY_200}; color: ${TEXT_BODY}; &:disabled { color: ${DISABLED_TEXT_COLOR}; } &.primary { background-color: ${PRIMARY_BUTTON_BG}; border-color: ${PRIMARY_BUTTON_BG}; color: #fff; &:hover:enabled { color: #fff; background-color: ${PRIMARY_BUTTON_HOVER_BG}; } &:active:enabled { background-color: ${PRIMARY_BUTTON_ACTIVE_BG}; color: ${PRIMARY_BUTTON_BG}; } &.o-disabled, &:disabled { opacity: 0.5; } } &:hover:enabled { color: ${BUTTON_HOVER_TEXT_COLOR}; background-color: ${BUTTON_HOVER_BG}; } &:active:enabled { color: ${BUTTON_ACTIVE_TEXT_COLOR}; background-color: ${BUTTON_ACTIVE_BG}; } &.o-disabled, &:disabled { opacity: 0.8; } &.o-button-danger:hover { color: #ffffff; background: ${ALERT_DANGER_BORDER}; } } .o-button-link { cursor: pointer; text-decoration: none; color: ${ACTION_COLOR}; font-weight: 500; &:hover, &:active { color: ${ACTION_COLOR_HOVER}; } } .o-button-icon { cursor: pointer; color: ${TEXT_BODY_MUTED}; font-weight: 500; &:hover, &:active { color: ${TEXT_BODY}; } } `; class Spreadsheet extends Component { static template = "o-spreadsheet-Spreadsheet"; static props = { model: Object, notifyUser: { type: Function, optional: true }, raiseError: { type: Function, optional: true }, askConfirmation: { type: Function, optional: true }, }; static components = { TopBar, Grid, BottomBar, SidePanel, SpreadsheetDashboard, HeaderGroupContainer, }; sidePanel; spreadsheetRef = useRef("spreadsheet"); spreadsheetRect = useSpreadsheetRect(); _focusGrid; keyDownMapping; isViewportTooSmall = false; notificationStore; composerFocusStore; get model() { return this.props.model; } getStyle() { const properties = {}; if (this.env.isDashboard()) { properties["grid-template-rows"] = `auto`; } else { properties["grid-template-rows"] = `${TOPBAR_HEIGHT}px auto ${BOTTOMBAR_HEIGHT + 1}px`; } properties["grid-template-columns"] = `auto ${this.sidePanel.panelSize}px`; return cssPropertiesToCss(properties); } setup() { const stores = useStoreProvider(); stores.inject(ModelStore, this.model); this.notificationStore = useStore(NotificationStore); this.composerFocusStore = useStore(ComposerFocusStore); this.sidePanel = useStore(SidePanelStore); this.keyDownMapping = { "CTRL+H": () => this.sidePanel.toggle("FindAndReplace", {}), "CTRL+F": () => this.sidePanel.toggle("FindAndReplace", {}), }; const fileStore = this.model.config.external.fileStore; useSubEnv({ model: this.model, imageProvider: fileStore ? new ImageProvider(fileStore) : undefined, loadCurrencies: this.model.config.external.loadCurrencies, loadLocales: this.model.config.external.loadLocales, isDashboard: () => this.model.getters.isDashboard(), openSidePanel: this.sidePanel.open.bind(this.sidePanel), toggleSidePanel: this.sidePanel.toggle.bind(this.sidePanel), clipboard: this.env.clipboard || instantiateClipboard(), startCellEdition: (content) => this.composerFocusStore.focusActiveComposer({ content }), notifyUser: (notification) => this.notificationStore.notifyUser(notification), askConfirmation: (text, confirm, cancel) => this.notificationStore.askConfirmation(text, confirm, cancel), raiseError: (text, cb) => this.notificationStore.raiseError(text, cb), }); this.notificationStore.updateNotificationCallbacks({ ...this.props }); useEffect(() => { /** * Only refocus the grid if the active element is not a child of the spreadsheet * (i.e. activeElement is outside of the spreadsheetRef component) * and spreadsheet is a child of that element. Anything else means that the focus * is on an element that needs to keep it. */ if (!this.spreadsheetRef.el.contains(document.activeElement) && document.activeElement?.contains(this.spreadsheetRef.el)) { this.focusGrid(); } }, () => [this.env.model.getters.getActiveSheetId()]); useExternalListener(window, "resize", () => this.render(true)); // For some reason, the wheel event is not properly registered inside templates // in Chromium-based browsers based on chromium 125 // This hack ensures the event declared in the template is properly registered/working useExternalListener(document.body, "wheel", () => { }); this.bindModelEvents(); onWillUpdateProps((nextProps) => { if (nextProps.model !== this.props.model) { throw new Error("Changing the props model is not supported at the moment."); } if (nextProps.notifyUser !== this.props.notifyUser || nextProps.askConfirmation !== this.props.askConfirmation || nextProps.raiseError !== this.props.raiseError) { this.notificationStore.updateNotificationCallbacks({ ...nextProps }); } }); const render = batched(this.render.bind(this, true)); onMounted(() => { this.checkViewportSize(); stores.on("store-updated", this, render); resizeObserver.observe(this.spreadsheetRef.el); }); onWillUnmount(() => { this.unbindModelEvents(); stores.off("store-updated", this); resizeObserver.disconnect(); }); onPatched(() => { this.checkViewportSize(); }); const resizeObserver = new ResizeObserver(() => { this.sidePanel.changePanelSize(this.sidePanel.panelSize, this.spreadsheetRect.width); }); } bindModelEvents() { this.model.on("update", this, () => this.render(true)); this.model.on("notify-ui", this, (notification) => this.notificationStore.notifyUser(notification)); this.model.on("raise-error-ui", this, ({ text }) => this.notificationStore.raiseError(text)); } unbindModelEvents() { this.model.off("update", this); this.model.off("notify-ui", this); this.model.off("raise-error-ui", this); } checkViewportSize() { const { xRatio, yRatio } = this.env.model.getters.getFrozenSheetViewRatio(this.env.model.getters.getActiveSheetId()); if (!isFinite(xRatio) || !isFinite(yRatio)) { // before mounting, the ratios can be NaN or Infinity if the viewport size is 0 return; } if (yRatio > MAXIMAL_FREEZABLE_RATIO || xRatio > MAXIMAL_FREEZABLE_RATIO) { if (this.isViewportTooSmall) { return; } this.notificationStore.notifyUser({ text: _t("The current window is too small to display this sheet properly. Consider resizing your browser window or adjusting frozen rows and columns."), type: "warning", sticky: false, }); this.isViewportTooSmall = true; } else { this.isViewportTooSmall = false; } } focusGrid() { if (!this._focusGrid) { return; } this._focusGrid(); } onKeydown(ev) { let keyDownString = ""; if (isCtrlKey(ev)) { keyDownString += "CTRL+"; } keyDownString += ev.key.toUpperCase(); let handler = this.keyDownMapping[keyDownString]; if (handler) { ev.preventDefault(); ev.stopPropagation(); handler(); return; } } get gridHeight() { const { height } = this.env.model.getters.getSheetViewDimension(); return height; } get gridContainerStyle() { const gridColSize = GROUP_LAYER_WIDTH * this.rowLayers.length; const gridRowSize = GROUP_LAYER_WIDTH * this.colLayers.length; return cssPropertiesToCss({ "grid-template-columns": `${gridColSize ? gridColSize + 2 : 0}px auto`, // +2: margins "grid-template-rows": `${gridRowSize ? gridRowSize + 2 : 0}px auto`, }); } get rowLayers() { const sheetId = this.env.model.getters.getActiveSheetId(); return this.env.model.getters.getVisibleGroupLayers(sheetId, "ROW"); } get colLayers() { const sheetId = this.env.model.getters.getActiveSheetId(); return this.env.model.getters.getVisibleGroupLayers(sheetId, "COL"); } } class LocalTransportService { listeners = []; async sendMessage(message) { for (const { callback } of this.listeners) { callback(message); } } onNewMessage(id, callback) { this.listeners.push({ id, callback }); } leave(id) { this.listeners = this.listeners.filter((listener) => listener.id !== id); } } function inverseCommand(cmd) { return inverseCommandRegistry.get(cmd.type)(cmd); } /** * A branch holds a sequence of operations. * It can be represented as "A - B - C - D" if A, B, C and D are executed one * after the other. * * @param buildTransformation Factory to build transformations * @param operations initial operations */ class Branch { buildTransformation; operations; constructor(buildTransformation, operations = []) { this.buildTransformation = buildTransformation; this.operations = operations; } getOperations() { return this.operations; } getOperation(operationId) { const operation = this.operations.find((op) => op.id === operationId); if (!operation) { throw new Error(`Operation ${operationId} not found`); } return operation; } getLastOperationId() { return this.operations[this.operations.length - 1]?.id; } /** * Get the id of the operation appears first in the list of operations */ getFirstOperationAmong(op1, op2) { for (const operation of this.operations) { if (operation.id === op1) return op1; if (operation.id === op2) return op2; } throw new Error(`Operation ${op1} and ${op2} not found`); } contains(operationId) { return !!this.operations.find((operation) => operation.id === operationId); } /** * Add the given operation as the first operation */ prepend(operation) { const transformation = this.buildTransformation.with(operation.data); this.operations = [ operation, ...this.operations.map((operation) => operation.transformed(transformation)), ]; } /** * add the given operation after the given predecessorOpId */ insert(newOperation, predecessorOpId) { const transformation = this.buildTransformation.with(newOperation.data); const { before, operation, after } = this.locateOperation(predecessorOpId); this.operations = [ ...before, operation, newOperation, ...after.map((operation) => operation.transformed(transformation)), ]; } /** * Add the given operation as the last operation */ append(operation) { this.operations.push(operation); } /** * Append operations in the given branch to this branch. */ appendBranch(branch) { this.operations = this.operations.concat(branch.operations); } /** * Create and return a copy of this branch, starting after the given operationId */ fork(operationId) { const { after } = this.locateOperation(operationId); return new Branch(this.buildTransformation, after); } /** * Transform all the operations in this branch with the given transformation */ transform(transformation) { this.operations = this.operations.map((operation) => operation.transformed(transformation)); } /** * Cut the branch before the operation, meaning the operation * and all following operations are dropped. */ cutBefore(operationId) { this.operations = this.locateOperation(operationId).before; } /** * Cut the branch after the operation, meaning all following operations are dropped. */ cutAfter(operationId) { const { before, operation } = this.locateOperation(operationId); this.operations = before.concat([operation]); } /** * Find an operation in this branch based on its id. * This returns the operation itself, operations which comes before it * and operation which comes after it. */ locateOperation(operationId) { const operationIndex = this.operations.findIndex((step) => step.id === operationId); if (operationIndex === -1) { throw new Error(`Operation ${operationId} not found`); } return { before: this.operations.slice(0, operationIndex), operation: this.operations[operationIndex], after: this.operations.slice(operationIndex + 1), }; } } /** * An Operation can be executed to change a data structure from state A * to state B. * It should hold the necessary data used to perform this transition. * It should be possible to revert the changes made by this operation. * * In the context of o-spreadsheet, the data from an operation would * be a revision (the commands are used to execute it, the `changes` are used * to revert it). */ class Operation { id; data; constructor(id, data) { this.id = id; this.data = data; } transformed(transformation) { return new LazyOperation(this.id, lazy(() => transformation(this.data))); } } class LazyOperation { id; lazyData; constructor(id, lazyData) { this.id = id; this.lazyData = lazyData; } get data() { return this.lazyData(); } transformed(transformation) { return new LazyOperation(this.id, this.lazyData.map(transformation)); } } /** * An execution object is a sequence of executionSteps (each execution step is an operation in a branch). * * You can iterate over the steps of an execution * ```js * for (const operation of execution) { * // ... do something * } * ``` */ class OperationSequence { operations; constructor(operations) { this.operations = operations; } [Symbol.iterator]() { return this.operations[Symbol.iterator](); } /** * Stop the operation sequence at a given operation * @param operationId included */ stopWith(operationId) { function* filter(execution, operationId) { for (const step of execution) { yield step; if (step.operation.id === operationId) { return; } } } return new OperationSequence(filter(this.operations, operationId)); } /** * Stop the operation sequence before a given operation * @param operationId excluded */ stopBefore(operationId) { function* filter(execution, operationId) { for (const step of execution) { if (step.operation.id === operationId) { return; } yield step; } } return new OperationSequence(filter(this.operations, operationId)); } /** * Start the operation sequence at a given operation * @param operationId excluded */ startAfter(operationId) { function* filter(execution, operationId) { let skip = true; for (const step of execution) { if (!skip) { yield step; } if (step.operation.id === operationId) { skip = false; } } } return new OperationSequence(filter(this.operations, operationId)); } } /** * The tree is a data structure used to maintain the different branches of the * SelectiveHistory. * * Branches can be "stacked" on each other and an execution path can be derived * from any stack of branches. The rules to derive this path is explained below. * * An operation can be cancelled/undone by inserting a new branch below * this operation. * e.g * Given the branch A B C * To undo B, a new branching branch is inserted at operation B. * ```txt * A B C D * > C' D' * ``` * A new execution path can now be derived. At each operation: * - if there is a lower branch, don't execute it and go to the operation below * - if not, execute it and go to the operation on the right. * The execution path is A C' D' * Operation C and D have been adapted (transformed) in the lower branch * since operation B is not executed in this branch. * */ class Tree { buildTransformation; branches; branchingOperationIds = new Map(); constructor(buildTransformation, initialBranch) { this.buildTransformation = buildTransformation; this.branches = [initialBranch]; } /** * Return the last branch of the entire stack of branches. */ getLastBranch() { return this.branches[this.branches.length - 1]; } /** * Return the sequence of operations from this branch * until the very last branch. */ execution(branch) { return new OperationSequence(linkNext(this._execution(branch), this._execution(branch))); } /** * Return the sequence of operations from this branch * to the very first branch. */ revertedExecution(branch) { return new OperationSequence(linkNext(this._revertedExecution(branch), this._revertedExecution(branch))); } /** * Append an operation to the end of the tree. * Also insert the (transformed) operation in all previous branches. * * Adding operation `D` to the last branch * ```txt * A1 B1 C1 * > B2 C2 * ``` * will give * ```txt * A1 B1 C1 D' with D' = D transformed with A1 * > B2 C2 D * ``` */ insertOperationLast(branch, operation) { const insertAfter = branch.getLastOperationId() || this.previousBranch(branch)?.getLastOperationId(); branch.append(operation); if (insertAfter) { this.insertPrevious(branch, operation, insertAfter); } } /** * Insert a new operation after an other operation. * The operation will be inserted in this branch, in next branches (transformed) * and in previous branches (also transformed). * * Given * ```txt * 1: A1 B1 C1 * 2: > B2 C2 * 3: > C3 * ``` * Inserting D to branch 2 gives * ```txt * 1: A1 B1 C1 D1 D1 = D transformed with A1 * 2: > B2 C2 D with D = D * 3: > C3 D2 D2 = D transformed without B2 (B2⁻¹) * ``` */ insertOperationAfter(branch, operation, predecessorOpId) { branch.insert(operation, predecessorOpId); this.updateNextWith(branch, operation, predecessorOpId); this.insertPrevious(branch, operation, predecessorOpId); } /** * Create a new branching branch at the given operation. * This cancels the operation from the execution path. */ undo(branch, operation) { const transformation = this.buildTransformation.without(operation.data); const branchingId = this.branchingOperationIds.get(branch); this.branchingOperationIds.set(branch, operation.id); const nextBranch = branch.fork(operation.id); if (branchingId) { this.branchingOperationIds.set(nextBranch, branchingId); } this.insertBranchAfter(branch, nextBranch); this.transform(nextBranch, transformation); } /** * Remove the branch just after this one. This un-cancels (redo) the branching * operation. Lower branches will be transformed accordingly. * * Given * ```txt * 1: A1 B1 C1 * 2: > B2 C2 * 3: > C3 * ``` * removing the next branch of 1 gives * * ```txt * 1: A1 B1 C1 * 2: > C3' with C3' = C1 transformed without B1 (B1⁻¹) * ``` */ redo(branch) { const removedBranch = this.nextBranch(branch); if (!removedBranch) return; const nextBranch = this.nextBranch(removedBranch); this.removeBranchFromTree(removedBranch); const undoBranchingId = this.branchingOperationIds.get(removedBranch); if (undoBranchingId) { this.branchingOperationIds.set(branch, undoBranchingId); } else { this.branchingOperationIds.delete(branch); } if (nextBranch) { this.rebaseUp(nextBranch); } } /** * Drop the operation and all following operations in every * branch */ drop(operationId) { for (const branch of this.branches) { if (branch.contains(operationId)) { branch.cutBefore(operationId); } } } /** * Find the operation in the execution path. */ findOperation(branch, operationId) { for (const operation of this.revertedExecution(branch)) { if (operation.operation.id === operationId) { return operation; } } throw new Error(`Operation ${operationId} not found`); } /** * Rebuild transformed operations of this branch based on the upper branch. * * Given the following structure: * ```txt * 1: A1 B1 C1 * 2: > B2 C2 * 3: > C3 * ``` * Rebasing branch "2" gives * ```txt * 1: A1 B1 C1 * 2: > B2' C2' With B2' = B1 transformed without A1 and C2' = C1 transformed without A1 * 3: > C3' C3' = C2' transformed without B2' * ``` */ rebaseUp(branch) { const { previousBranch, branchingOperation } = this.findPreviousBranchingOperation(branch); if (!previousBranch || !branchingOperation) return; const rebaseTransformation = this.buildTransformation.without(branchingOperation.data); const newBranch = previousBranch.fork(branchingOperation.id); this.branchingOperationIds.set(newBranch, this.branchingOperationIds.get(branch)); this.removeBranchFromTree(branch); this.insertBranchAfter(previousBranch, newBranch); newBranch.transform(rebaseTransformation); const nextBranch = this.nextBranch(newBranch); if (nextBranch) { this.rebaseUp(nextBranch); } } removeBranchFromTree(branch) { const index = this.branches.findIndex((l) => l === branch); this.branches.splice(index, 1); } insertBranchAfter(branch, toInsert) { const index = this.branches.findIndex((l) => l === branch); this.branches.splice(index + 1, 0, toInsert); } /** * Update the branching branch of this branch, either by (1) inserting the new * operation in it or (2) by transforming it. * (1) If the operation is positioned before the branching branch, the branching * branch should be transformed with this operation. * (2) If it's positioned after, the operation should be inserted in the * branching branch. */ updateNextWith(branch, operation, predecessorOpId) { const branchingId = this.branchingOperationIds.get(branch); const nextBranch = this.nextBranch(branch); if (!branchingId || !nextBranch) { return; } if (branch.getFirstOperationAmong(predecessorOpId, branchingId) === branchingId) { const transformedOperation = this.addToNextBranch(branch, nextBranch, branchingId, operation, predecessorOpId); this.updateNextWith(nextBranch, transformedOperation, predecessorOpId); } else { const transformation = this.buildTransformation.with(operation.data); this.transform(nextBranch, transformation); } } addToNextBranch(branch, nextBranch, branchingId, operation, predecessorOpId) { // If the operation is inserted after the branching operation, it should // be positioned first. let transformedOperation = operation; if (predecessorOpId === branchingId) { transformedOperation = this.getTransformedOperation(branch, branchingId, operation); nextBranch.prepend(transformedOperation); } else if (nextBranch.contains(predecessorOpId)) { transformedOperation = this.getTransformedOperation(branch, branchingId, operation); nextBranch.insert(transformedOperation, predecessorOpId); } else { nextBranch.append(operation); } return transformedOperation; } getTransformedOperation(branch, branchingId, operation) { const branchingOperation = branch.getOperation(branchingId); const branchingTransformation = this.buildTransformation.without(branchingOperation.data); return operation.transformed(branchingTransformation); } /** * Check if this branch should execute the given operation. * i.e. If the operation is not cancelled by a branching branch. */ shouldExecute(branch, operation) { return operation.id !== this.branchingOperationIds.get(branch); } transform(branch, transformation) { branch.transform(transformation); const nextBranch = this.nextBranch(branch); if (nextBranch) { this.transform(nextBranch, transformation); } } /** * Insert a new operation in previous branches. The operations which are * positioned after the inserted operations are transformed with the newly * inserted operations. This one is also transformed, with the branching * operation. */ insertPrevious(branch, newOperation, insertAfter) { const { previousBranch, branchingOperation } = this.findPreviousBranchingOperation(branch); if (!previousBranch || !branchingOperation) return; const transformation = this.buildTransformation.with(branchingOperation.data); const branchTail = branch.fork(insertAfter); branchTail.transform(transformation); previousBranch.cutAfter(insertAfter); previousBranch.appendBranch(branchTail); const operationToInsert = newOperation.transformed(transformation); this.insertPrevious(previousBranch, operationToInsert, insertAfter); } findPreviousBranchingOperation(branch) { const previousBranch = this.previousBranch(branch); if (!previousBranch) return { previousBranch: undefined, branchingOperation: undefined }; const previousBranchingId = this.branchingOperationIds.get(previousBranch); if (!previousBranchingId) return { previousBranch: undefined, branchingOperation: undefined }; return { previousBranch, branchingOperation: previousBranch.getOperation(previousBranchingId), }; } /** * Retrieve the next branch of the given branch */ nextBranch(branch) { const index = this.branches.findIndex((l) => l === branch); if (index === -1) { return undefined; } return this.branches[index + 1]; } /** * Retrieve the previous branch of the given branch */ previousBranch(branch) { const index = this.branches.findIndex((l) => l === branch); if (index === -1) { return undefined; } return this.branches[index - 1]; } /** * Yields the sequence of operations to execute, in reverse order. */ *_revertedExecution(branch) { const branchingOperationId = this.branchingOperationIds.get(branch); let afterBranchingPoint = !!branchingOperationId; const operations = branch.getOperations(); for (let i = operations.length - 1; i >= 0; i--) { const operation = operations[i]; if (operation.id === branchingOperationId) { afterBranchingPoint = false; } if (!afterBranchingPoint) { yield { operation: operation, branch: branch, isCancelled: !this.shouldExecute(branch, operation), }; } } const previous = this.previousBranch(branch); yield* previous ? this._revertedExecution(previous) : []; } /** * Yields the sequence of operations to execute */ *_execution(branch) { for (const operation of branch.getOperations()) { yield { operation: operation, branch: branch, isCancelled: !this.shouldExecute(branch, operation), }; if (operation.id === this.branchingOperationIds.get(branch)) { const next = this.nextBranch(branch); yield* next ? this._execution(next) : []; return; } } if (!this.branchingOperationIds.get(branch)) { const next = this.nextBranch(branch); yield* next ? this._execution(next) : []; } } } class SelectiveHistory { HEAD_BRANCH; HEAD_OPERATION; tree; applyOperation; revertOperation; buildEmpty; buildTransformation; /** * The selective history is a data structure used to register changes/updates of a state. * Each change/update is called an "operation". * The data structure allows to easily cancel (and redo) any operation individually. * An operation can be represented by any data structure. It can be a "command", a "diff", etc. * However it must have the following properties: * - it can be applied to modify the state * - it can be reverted on the state such that it was never executed. * - it can be transformed given other operation (Operational Transformation) * * Since this data structure doesn't know anything about the state nor the structure of * operations, the actual work must be performed by external functions given as parameters. * @param initialOperationId * @param applyOperation a function which can apply an operation to the state * @param revertOperation a function which can revert an operation from the state * @param buildEmpty a function returning an "empty" operation. * i.e an operation that leaves the state unmodified once applied or reverted * (used for internal implementation) * @param buildTransformation Factory used to build transformations */ constructor(args) { this.applyOperation = args.applyOperation; this.revertOperation = args.revertOperation; this.buildEmpty = args.buildEmpty; this.buildTransformation = args.buildTransformation; this.HEAD_BRANCH = new Branch(this.buildTransformation); this.tree = new Tree(this.buildTransformation, this.HEAD_BRANCH); const initialOperationId = args.initialOperationId; const initial = new Operation(initialOperationId, this.buildEmpty(initialOperationId)); this.tree.insertOperationLast(this.HEAD_BRANCH, initial); this.HEAD_OPERATION = initial; } /** * Return the operation identified by its id. */ get(operationId) { return this.tree.findOperation(this.HEAD_BRANCH, operationId).operation.data; } /** * Append a new operation as the last one */ append(operationId, data) { const operation = new Operation(operationId, data); const branch = this.tree.getLastBranch(); this.tree.insertOperationLast(branch, operation); this.HEAD_BRANCH = branch; this.HEAD_OPERATION = operation; } /** * Insert a new operation after a specific operation (may not be the last operation). * Following operations will be transformed according * to the new operation. */ insert(operationId, data, insertAfter) { const operation = new Operation(operationId, data); this.revertTo(insertAfter); this.tree.insertOperationAfter(this.HEAD_BRANCH, operation, insertAfter); this.fastForward(); } /** * @param operationId operation to undo * @param undoId the id of the "undo operation" * @param insertAfter the id of the operation after which to insert the undo */ undo(operationId, undoId, insertAfter) { const { branch, operation } = this.tree.findOperation(this.HEAD_BRANCH, operationId); this.revertBefore(operationId); this.tree.undo(branch, operation); this.fastForward(); this.insert(undoId, this.buildEmpty(undoId), insertAfter); } /** * @param operationId operation to redo * @param redoId the if of the "redo operation" * @param insertAfter the id of the operation after which to insert the redo */ redo(operationId, redoId, insertAfter) { const { branch } = this.tree.findOperation(this.HEAD_BRANCH, operationId); this.revertBefore(operationId); this.tree.redo(branch); this.fastForward(); this.insert(redoId, this.buildEmpty(redoId), insertAfter); } drop(operationId) { this.revertBefore(operationId); this.tree.drop(operationId); } /** * Revert the state as it was *before* the given operation was executed. */ revertBefore(operationId) { const execution = this.tree.revertedExecution(this.HEAD_BRANCH).stopWith(operationId); this.revert(execution); } /** * Revert the state as it was *after* the given operation was executed. */ revertTo(operationId) { const execution = operationId ? this.tree.revertedExecution(this.HEAD_BRANCH).stopBefore(operationId) : this.tree.revertedExecution(this.HEAD_BRANCH); this.revert(execution); } /** * Revert an execution */ revert(execution) { for (const { next, operation, isCancelled } of execution) { if (!isCancelled) { this.revertOperation(operation.data); } if (next) { this.HEAD_BRANCH = next.branch; this.HEAD_OPERATION = next.operation; } } } /** * Replay the operations between the current HEAD_BRANCH and the end of the tree */ fastForward() { const operations = this.HEAD_OPERATION ? this.tree.execution(this.HEAD_BRANCH).startAfter(this.HEAD_OPERATION.id) : this.tree.execution(this.HEAD_BRANCH); for (const { operation: operation, branch, isCancelled } of operations) { if (!isCancelled) { this.applyOperation(operation.data); } this.HEAD_OPERATION = operation; this.HEAD_BRANCH = branch; } } } function buildRevisionLog(args) { return new SelectiveHistory({ initialOperationId: args.initialRevisionId, applyOperation: (revision) => { const commands = revision.commands.slice(); const { changes } = args.recordChanges(() => { for (const command of commands) { args.dispatch(command); } }); revision.setChanges(changes); }, revertOperation: (revision) => revertChanges([revision]), buildEmpty: (id) => new Revision(id, "empty", []), buildTransformation: { with: (revision) => (toTransform) => { return new Revision(toTransform.id, toTransform.clientId, transformAll(toTransform.commands, revision.commands), toTransform.rootCommand, undefined, toTransform.timestamp); }, without: (revision) => (toTransform) => { return new Revision(toTransform.id, toTransform.clientId, transformAll(toTransform.commands, revision.commands.map(inverseCommand).flat()), toTransform.rootCommand, undefined, toTransform.timestamp); }, }, }); } /** * Revert changes from the given revisions */ function revertChanges(revisions) { for (const revision of revisions.slice().reverse()) { for (let i = revision.changes.length - 1; i >= 0; i--) { const change = revision.changes[i]; applyChange(change); } } } /** * Apply the changes of the given HistoryChange to the state */ function applyChange(change) { const target = change.target; const key = change.key; const before = change.before; if (before === undefined) { delete target[key]; } else { target[key] = before; } } /** * Stateless sequence of events that can be processed by consumers. * * There are three kind of consumers: * - the main consumer * - the default consumer * - observer consumers * * Main consumer * ------------- * Anyone can capture the event stream and become the main consumer. * If there is already a main consumer, it is kicked off and it will no longer * receive events. * The main consumer can release the stream at any moment to stop listening * events. * * Default consumer * ---------------- * When the main consumer releases the stream and until the stream is captured * again, all events are transmitted to the default consumer. * * Observer consumers * ------------------ * Observers permanently receive events. * */ class EventStream { observers = new Map(); /** * the one we default to when someone releases the stream by themeselves */ defaultSubscription; mainSubscription; registerAsDefault(owner, callbacks) { this.defaultSubscription = { owner, callbacks }; if (!this.mainSubscription) { this.mainSubscription = this.defaultSubscription; } } /** * Register callbacks to observe the stream */ observe(owner, callbacks) { this.observers.set(owner, { owner, callbacks }); } /** * Capture the stream for yourself */ capture(owner, callbacks) { if (this.observers.get(owner)) { throw new Error("You are already subscribed forever"); } if (this.mainSubscription?.owner && this.mainSubscription.owner !== owner) { this.mainSubscription.callbacks.release?.(); } this.mainSubscription = { owner, callbacks }; } release(owner) { if (this.mainSubscription?.owner !== owner || this.observers.get(owner)) { return; } this.mainSubscription = this.defaultSubscription; } /** * Release whichever subscription in charge and get back to the default subscription */ getBackToDefault() { if (this.mainSubscription === this.defaultSubscription) { return; } this.mainSubscription?.callbacks.release?.(); this.mainSubscription = this.defaultSubscription; } /** * Check if you are currently the main stream consumer */ isListening(owner) { return this.mainSubscription?.owner === owner; } /** * Push an event to the stream and broadcast it to consumers */ send(event) { this.mainSubscription?.callbacks.handleEvent(event); this.observers.forEach((sub) => sub.callbacks.handleEvent(event)); } } /** * Processes all selection updates (usually from user inputs) and emits an event * with the new selected anchor */ class SelectionStreamProcessorImpl { getters; stream; /** * "Active" anchor used as a reference to compute new anchors * An new initial value is given each time the stream is * captured. The value is updated with each new anchor. */ anchor; defaultAnchor; constructor(getters) { this.getters = getters; this.stream = new EventStream(); this.anchor = { cell: { col: 0, row: 0 }, zone: positionToZone({ col: 0, row: 0 }) }; this.defaultAnchor = this.anchor; } capture(owner, anchor, callbacks) { this.stream.capture(owner, callbacks); this.anchor = anchor; } /** * Register as default subscriber and capture the event stream. */ registerAsDefault(owner, anchor, callbacks) { this.checkAnchorZoneOrThrow(anchor); this.stream.registerAsDefault(owner, callbacks); this.defaultAnchor = anchor; this.capture(owner, anchor, callbacks); } resetDefaultAnchor(owner, anchor) { this.checkAnchorZoneOrThrow(anchor); if (this.stream.isListening(owner)) { this.anchor = anchor; } this.defaultAnchor = anchor; } resetAnchor(owner, anchor) { this.checkAnchorZoneOrThrow(anchor); if (this.stream.isListening(owner)) { this.anchor = anchor; } } observe(owner, callbacks) { this.stream.observe(owner, callbacks); } release(owner) { if (this.stream.isListening(owner)) { this.stream.release(owner); this.anchor = this.defaultAnchor; } } getBackToDefault() { this.stream.getBackToDefault(); } modifyAnchor(anchor, mode, options) { const sheetId = this.getters.getActiveSheetId(); anchor = { ...anchor, zone: this.getters.expandZone(sheetId, anchor.zone), }; return this.processEvent({ options, anchor, mode, }); } /** * Select a new anchor */ selectZone(anchor, options = { scrollIntoView: true }) { return this.modifyAnchor(anchor, "overrideSelection", options); } /** * Select a single cell as the new anchor. */ selectCell(col, row) { const zone = positionToZone({ col, row }); return this.selectZone({ zone, cell: { col, row } }, { scrollIntoView: true }); } /** * Set the selection to one of the cells adjacent to the current anchor cell. */ moveAnchorCell(direction, step = 1) { if (step !== "end" && step <= 0) { return new DispatchResult("InvalidSelectionStep" /* CommandResult.InvalidSelectionStep */); } const { col, row } = this.getNextAvailablePosition(direction, step); return this.selectCell(col, row); } /** * Update the current anchor such that it includes the given * cell position. */ setAnchorCorner(col, row) { const sheetId = this.getters.getActiveSheetId(); const { col: anchorCol, row: anchorRow } = this.anchor.cell; const zone = { left: Math.min(anchorCol, col), top: Math.min(anchorRow, row), right: Math.max(anchorCol, col), bottom: Math.max(anchorRow, row), }; const expandedZone = this.getters.expandZone(sheetId, zone); const anchor = { zone: expandedZone, cell: { col: anchorCol, row: anchorRow } }; return this.processEvent({ mode: "updateAnchor", anchor: anchor, options: { scrollIntoView: false }, }); } /** * Add a new cell to the current selection */ addCellToSelection(col, row) { const sheetId = this.getters.getActiveSheetId(); ({ col, row } = this.getters.getMainCellPosition({ sheetId, col, row })); const zone = this.getters.expandZone(sheetId, positionToZone({ col, row })); return this.processEvent({ options: { scrollIntoView: true }, anchor: { zone, cell: { col, row } }, mode: "newAnchor", }); } /** * Increase or decrease the size of the current anchor zone. * The anchor cell remains where it is. It's the opposite side * of the anchor zone which moves. */ resizeAnchorZone(direction, step = 1) { if (step !== "end" && step <= 0) { return new DispatchResult("InvalidSelectionStep" /* CommandResult.InvalidSelectionStep */); } const sheetId = this.getters.getActiveSheetId(); const anchor = this.anchor; const { col: anchorCol, row: anchorRow } = anchor.cell; const { left, right, top, bottom } = anchor.zone; const starting = this.getStartingPosition(direction); let [deltaCol, deltaRow] = this.deltaToTarget(starting, direction, step); if (deltaCol === 0 && deltaRow === 0) { return DispatchResult.Success; } let result = anchor.zone; const expand = (z) => { z = reorderZone(z); const { left, right, top, bottom } = this.getters.expandZone(sheetId, z); return { left: Math.max(0, left), right: Math.min(this.getters.getNumberCols(sheetId) - 1, right), top: Math.max(0, top), bottom: Math.min(this.getters.getNumberRows(sheetId) - 1, bottom), }; }; const { col: refCol, row: refRow } = this.getReferencePosition(); // check if we can shrink selection let n = 0; while (result !== null) { n++; if (deltaCol < 0) { const newRight = this.getNextAvailableCol(deltaCol, right - (n - 1), refRow); result = refCol <= right - n ? expand({ top, left, bottom, right: newRight }) : null; } if (deltaCol > 0) { const newLeft = this.getNextAvailableCol(deltaCol, left + (n - 1), refRow); result = left + n <= refCol ? expand({ top, left: newLeft, bottom, right }) : null; } if (deltaRow < 0) { const newBottom = this.getNextAvailableRow(deltaRow, refCol, bottom - (n - 1)); result = refRow <= bottom - n ? expand({ top, left, bottom: newBottom, right }) : null; } if (deltaRow > 0) { const newTop = this.getNextAvailableRow(deltaRow, refCol, top + (n - 1)); result = top + n <= refRow ? expand({ top: newTop, left, bottom, right }) : null; } result = result ? reorderZone(result) : result; if (result && !isEqual(result, anchor.zone)) { return this.processEvent({ options: { scrollIntoView: true }, mode: "updateAnchor", anchor: { zone: result, cell: { col: anchorCol, row: anchorRow } }, }); } } const currentZone = { top: anchorRow, bottom: anchorRow, left: anchorCol, right: anchorCol, }; const zoneWithDelta = reorderZone({ top: this.getNextAvailableRow(deltaRow, refCol, top), left: this.getNextAvailableCol(deltaCol, left, refRow), bottom: this.getNextAvailableRow(deltaRow, refCol, bottom), right: this.getNextAvailableCol(deltaCol, right, refRow), }); result = expand(union(currentZone, zoneWithDelta)); const newAnchor = { zone: result, cell: { col: anchorCol, row: anchorRow } }; return this.processEvent({ anchor: newAnchor, mode: "updateAnchor", options: { scrollIntoView: true }, }); } selectColumn(index, mode) { const sheetId = this.getters.getActiveSheetId(); const bottom = this.getters.getNumberRows(sheetId) - 1; let zone = { left: index, right: index, top: 0, bottom }; const top = this.getters.findFirstVisibleColRowIndex(sheetId, "ROW"); let col, row; switch (mode) { case "overrideSelection": case "newAnchor": col = index; row = top; break; case "updateAnchor": ({ col, row } = this.anchor.cell); zone = union(zone, { left: col, right: col, top, bottom }); break; } return this.processEvent({ options: { scrollIntoView: false, unbounded: true, }, anchor: { zone, cell: { col, row } }, mode, }); } selectRow(index, mode) { const sheetId = this.getters.getActiveSheetId(); const right = this.getters.getNumberCols(sheetId) - 1; let zone = { top: index, bottom: index, left: 0, right }; const left = this.getters.findFirstVisibleColRowIndex(sheetId, "COL"); let col, row; switch (mode) { case "overrideSelection": case "newAnchor": col = left; row = index; break; case "updateAnchor": ({ col, row } = this.anchor.cell); zone = union(zone, { left, right, top: row, bottom: row }); break; } return this.processEvent({ options: { scrollIntoView: false, unbounded: true, }, anchor: { zone, cell: { col, row } }, mode, }); } /** * Loop the current selection while keeping the same anchor. The selection will loop through: * 1) the smallest zone that contain the anchor and that have only empty cells bordering it * 2) the whole sheet * 3) the anchor cell */ loopSelection() { const sheetId = this.getters.getActiveSheetId(); const anchor = this.anchor; // The whole sheet is selected, select the anchor cell if (isEqual(this.anchor.zone, this.getters.getSheetZone(sheetId))) { return this.modifyAnchor({ ...anchor, zone: positionToZone(anchor.cell) }, "updateAnchor", { scrollIntoView: false, }); } const tableZone = this.getters.getContiguousZone(sheetId, anchor.zone); return !deepEquals(tableZone, anchor.zone) ? this.modifyAnchor({ ...anchor, zone: tableZone }, "updateAnchor", { scrollIntoView: false, }) : this.selectAll(); } /** * Select a "table" around the current selection. * We define a table by the smallest zone that contain the anchor and that have only empty * cells bordering it */ selectTableAroundSelection() { const sheetId = this.getters.getActiveSheetId(); const tableZone = this.getters.getContiguousZone(sheetId, this.anchor.zone); return this.modifyAnchor({ ...this.anchor, zone: tableZone }, "updateAnchor", { scrollIntoView: false, }); } /** * Select the entire sheet */ selectAll() { const sheetId = this.getters.getActiveSheetId(); const bottom = this.getters.getNumberRows(sheetId) - 1; const right = this.getters.getNumberCols(sheetId) - 1; const zone = { left: 0, top: 0, bottom, right }; return this.processEvent({ mode: "overrideSelection", anchor: { zone, cell: this.anchor.cell }, options: { scrollIntoView: false, }, }); } isListening(owner) { return this.stream.isListening(owner); } /** * Process a new anchor selection event. If the new anchor is inside * the sheet boundaries, the event is pushed to the event stream to * be processed. */ processEvent(newAnchorEvent) { const event = { ...newAnchorEvent, previousAnchor: deepCopy(this.anchor) }; const commandResult = this.checkEventAnchorZone(event); if (commandResult !== "Success" /* CommandResult.Success */) { return new DispatchResult(commandResult); } this.anchor = event.anchor; this.stream.send(event); return DispatchResult.Success; } checkEventAnchorZone(event) { return this.checkAnchorZone(event.anchor); } checkAnchorZone(anchor) { const { cell, zone } = anchor; if (!isInside(cell.col, cell.row, zone)) { return "InvalidAnchorZone" /* CommandResult.InvalidAnchorZone */; } const { left, right, top, bottom } = zone; const sheetId = this.getters.getActiveSheetId(); const refCol = this.getters.findVisibleHeader(sheetId, "COL", left, right); const refRow = this.getters.findVisibleHeader(sheetId, "ROW", top, bottom); if (refRow === undefined || refCol === undefined) { return "SelectionOutOfBound" /* CommandResult.SelectionOutOfBound */; } return "Success" /* CommandResult.Success */; } checkAnchorZoneOrThrow(anchor) { const result = this.checkAnchorZone(anchor); if (result === "InvalidAnchorZone" /* CommandResult.InvalidAnchorZone */) { throw new Error(_t("The provided anchor is invalid. The cell must be part of the zone.")); } } /** * ---- PRIVATE ---- */ /** Computes the next cell position in the direction of deltaX and deltaY * by crossing through merges and skipping hidden cells. * Note that the resulting position might be out of the sheet, it needs to be validated. */ getNextAvailablePosition(direction, step = 1) { const { col, row } = this.anchor.cell; const delta = this.deltaToTarget({ col, row }, direction, step); return { col: this.getNextAvailableCol(delta[0], col, row), row: this.getNextAvailableRow(delta[1], col, row), }; } getNextAvailableCol(delta, colIndex, rowIndex) { const sheetId = this.getters.getActiveSheetId(); const position = { col: colIndex, row: rowIndex }; const isInPositionMerge = (nextCol) => this.getters.isInSameMerge(sheetId, colIndex, rowIndex, nextCol, rowIndex); return this.getNextAvailableHeader(delta, "COL", colIndex, position, isInPositionMerge); } getNextAvailableRow(delta, colIndex, rowIndex) { const sheetId = this.getters.getActiveSheetId(); const position = { col: colIndex, row: rowIndex }; const isInPositionMerge = (nextRow) => this.getters.isInSameMerge(sheetId, colIndex, rowIndex, colIndex, nextRow); return this.getNextAvailableHeader(delta, "ROW", rowIndex, position, isInPositionMerge); } getNextAvailableHeader(delta, dimension, startingHeaderIndex, position, isInPositionMerge) { const sheetId = this.getters.getActiveSheetId(); if (delta === 0) { return startingHeaderIndex; } const step = Math.sign(delta); let header = startingHeaderIndex + delta; while (isInPositionMerge(header)) { header += step; } while (this.getters.isHeaderHidden(sheetId, dimension, header)) { header += step; } const outOfBound = header < 0 || header > this.getters.getNumberHeaders(sheetId, dimension) - 1; if (outOfBound) { if (this.getters.isHeaderHidden(sheetId, dimension, startingHeaderIndex)) { return this.getNextAvailableHeader(-step, dimension, startingHeaderIndex, position, isInPositionMerge); } else { return startingHeaderIndex; } } return header; } /** * Finds a visible cell in the currently selected zone starting with the anchor. * If the anchor is hidden, browses from left to right and top to bottom to * find a visible cell. */ getReferencePosition() { const sheetId = this.getters.getActiveSheetId(); const anchor = this.anchor; const { left, right, top, bottom } = anchor.zone; const { col: anchorCol, row: anchorRow } = anchor.cell; return { col: this.getters.isColHidden(sheetId, anchorCol) ? this.getters.findVisibleHeader(sheetId, "COL", left, right) || anchorCol : anchorCol, row: this.getters.isRowHidden(sheetId, anchorRow) ? this.getters.findVisibleHeader(sheetId, "ROW", top, bottom) || anchorRow : anchorRow, }; } deltaToTarget(position, direction, step) { switch (direction) { case "up": return step !== "end" ? [0, -step] : [0, this.getEndOfCluster(position, "rows", -1) - position.row]; case "down": return step !== "end" ? [0, step] : [0, this.getEndOfCluster(position, "rows", 1) - position.row]; case "left": return step !== "end" ? [-step, 0] : [this.getEndOfCluster(position, "cols", -1) - position.col, 0]; case "right": return step !== "end" ? [step, 0] : [this.getEndOfCluster(position, "cols", 1) - position.col, 0]; } } // TODO rename this getStartingPosition(direction) { let { col, row } = this.getPosition(); const zone = this.anchor.zone; switch (direction) { case "down": case "up": row = row === zone.top ? zone.bottom : zone.top; break; case "left": case "right": col = col === zone.right ? zone.left : zone.right; break; } return { col, row }; } /** * Given a starting position, compute the end of the cluster containing the position in the given * direction or the start of the next cluster. We define cluster here as side-by-side cells that * all have a content. * * We will return the end of the cluster if the given cell is inside a cluster, and the start of the * next cluster if the given cell is outside a cluster or at the border of a cluster in the given direction. */ getEndOfCluster(startPosition, dim, dir) { const sheetId = this.getters.getActiveSheetId(); let currentPosition = startPosition; // If both the current cell and the next cell are not empty, we want to go to the end of the cluster const nextCellPosition = this.getNextCellPosition(startPosition, dim, dir); let mode = !this.isCellSkippableInCluster({ ...currentPosition, sheetId }) && !this.isCellSkippableInCluster({ ...nextCellPosition, sheetId }) ? "endOfCluster" : "nextCluster"; while (true) { const nextCellPosition = this.getNextCellPosition(currentPosition, dim, dir); // Break if nextPosition === currentPosition, which happens if there's no next valid position if (currentPosition.col === nextCellPosition.col && currentPosition.row === nextCellPosition.row) { break; } const isNextCellEmpty = this.isCellSkippableInCluster({ ...nextCellPosition, sheetId }); if (mode === "endOfCluster" && isNextCellEmpty) { break; } else if (mode === "nextCluster" && !isNextCellEmpty) { // We want to return the start of the next cluster, not the end of the empty zone currentPosition = nextCellPosition; break; } currentPosition = nextCellPosition; } return dim === "cols" ? currentPosition.col : currentPosition.row; } /** Computes the next cell position in the given direction by crossing through merges and skipping hidden cells. * * This has the same behaviour as getNextAvailablePosition() for certain arguments, but use this method instead * inside directionToDelta(), which is called in getNextAvailablePosition(), to avoid possible infinite * recursion. */ getNextCellPosition(currentPosition, dimension, direction) { const dimOfInterest = dimension === "cols" ? "col" : "row"; const startingPosition = { ...currentPosition }; const nextCoord = dimension === "cols" ? this.getNextAvailableCol(direction, startingPosition.col, startingPosition.row) : this.getNextAvailableRow(direction, startingPosition.col, startingPosition.row); startingPosition[dimOfInterest] = nextCoord; return { col: startingPosition.col, row: startingPosition.row }; } getPosition() { return { ...this.anchor.cell }; } isCellSkippableInCluster(position) { const mainPosition = this.getters.getMainCellPosition(position); const cell = this.getters.getEvaluatedCell(mainPosition); return (cell.type === CellValueType.empty || (cell.type === CellValueType.text && cell.value === "")); } } /** * Create an empty structure according to the type of the node key: * string: object * number: array */ function createEmptyStructure(node) { if (typeof node === "string") { return {}; } else if (typeof node === "number") { return []; } throw new Error(`Cannot create new node`); } class StateObserver { changes; commands = []; /** * Record the changes which could happen in the given callback, save them in a * new revision with the given id and userId. */ recordChanges(callback) { this.changes = []; this.commands = []; callback(); return { changes: this.changes, commands: this.commands }; } addCommand(command) { this.commands.push(command); } addChange(...args) { const val = args.pop(); const root = args[0]; let value = root; let key = args.at(-1); const pathLength = args.length - 2; for (let pathIndex = 1; pathIndex <= pathLength; pathIndex++) { const p = args[pathIndex]; if (value[p] === undefined) { const nextPath = args[pathIndex + 1]; value[p] = createEmptyStructure(nextPath); } value = value[p]; } if (value[key] === val) { return; } this.changes?.push({ key, target: value, before: value[key], }); if (val === undefined) { delete value[key]; } else { value[key] = val; } } } /** * Each axis present inside a graph needs to be identified by an unsigned integer * The value does not matter, it can be hardcoded. */ const catAxId = 17781237; const secondaryCatAxId = 17781238; const valAxId = 88853993; const secondaryValAxId = 88853994; function createChart(chart, chartSheetIndex, data) { const namespaces = [ ["xmlns:r", RELATIONSHIP_NSR], ["xmlns:a", DRAWING_NS_A], ["xmlns:c", DRAWING_NS_C], ]; const chartShapeProperty = shapeProperty({ backgroundColor: chart.data.backgroundColor, line: { color: "000000" }, }); // to manually position the chart in the figure container let title = escapeXml ``; if (chart.data.title?.text) { const color = chart.data.title.color ? toXlsxHexColor(chart.data.title.color) : chart.data.fontColor; title = escapeXml /*xml*/ ` ${insertText(chart.data.title.text, color, DEFAULT_CHART_FONT_SIZE, chart.data.title)} `; } // switch on chart type let plot = escapeXml ``; switch (chart.data.type) { case "bar": plot = addBarChart(chart.data); break; case "combo": plot = addComboChart(chart.data); break; case "line": plot = addLineChart(chart.data); break; case "scatter": plot = addScatterChart(chart.data); break; case "pie": plot = addDoughnutChart(chart.data, chartSheetIndex, data, { holeSize: 0 }); break; } let position = "t"; switch (chart.data.legendPosition) { case "bottom": position = "b"; break; case "left": position = "l"; break; case "right": position = "r"; break; case "top": position = "t"; break; } const fontColor = chart.data.fontColor; const xml = escapeXml /*xml*/ ` ${chartShapeProperty} ${title} ${plot} ${shapeProperty({ backgroundColor: chart.data.backgroundColor })} ${addLegend(position, fontColor)} `; return parseXML(xml); } function shapeProperty(params) { return escapeXml /*xml*/ ` ${params.backgroundColor ? solidFill(params.backgroundColor) : ""} ${params.line ? lineAttributes(params.line) : ""} `; } function solidFill(color) { return escapeXml /*xml*/ ` `; } function lineAttributes(params) { const attrs = [["cmpd", "sng"]]; if (params.width) { attrs.push(["w", convertDotValueToEMU(params.width)]); } const lineStyle = params.style ? escapeXml /*xml*/ `` : ""; return escapeXml /*xml*/ ` ${solidFill(params.color)} ${lineStyle} `; } function insertText(text, fontColor = "000000", fontsize = DEFAULT_CHART_FONT_SIZE, style = {}) { return escapeXml /*xml*/ ` ${solidFill(fontColor)} ${text} `; } function insertTextProperties(fontsize = 12, fontColor = "000000", bold = false, italic = false) { const defPropertiesAttributes = [ ["b", bold ? "1" : "0"], ["i", italic ? "1" : "0"], ["sz", fontsize * 100], ]; return escapeXml /*xml*/ ` ${solidFill(fontColor)} `; } function extractDataSetLabel(label) { if (!label) { return escapeXml /*xml*/ ``; } if ("text" in label && label.text) { return escapeXml /*xml*/ ` ${label.text} `; } if ("reference" in label && label.reference) { return escapeXml /*xml*/ ` ${stringRef(label.reference)} `; } return escapeXml /*xml*/ ``; } function addBarChart(chart) { // gapWitdh and overlap that define the space between clusters (in %) and the overlap between datasets (from -100: completely scattered to 100, completely overlapped) // see gapWidth : https://c-rex.net/projects/samples/ooxml/e1/Part4/OOXML_P4_DOCX_gapWidth_topic_ID0EFVEQB.html#topic_ID0EFVEQB // see overlap : https://c-rex.net/projects/samples/ooxml/e1/Part4/OOXML_P4_DOCX_overlap_topic_ID0ELYQQB.html#topic_ID0ELYQQB // // overlap and gapWitdh seems to be by default at -20 and 20 in chart.js. // See https://www.chartjs.org/docs/latest/charts/bar.html and https://www.chartjs.org/docs/latest/charts/bar.html#barpercentage-vs-categorypercentage const dataSetsColors = chart.dataSets.map((ds) => ds.backgroundColor ?? ""); const colors = new ColorGenerator(chart.dataSets.length, dataSetsColors); const leftDataSetsNodes = []; const rightDataSetsNodes = []; for (const [dsIndex, dataset] of Object.entries(chart.dataSets)) { const color = toXlsxHexColor(colors.next()); const dataShapeProperty = shapeProperty({ backgroundColor: color, line: { color }, }); const dataSetNode = escapeXml /*xml*/ ` ${extractDataSetLabel(dataset.label)} ${dataShapeProperty} ${chart.labelRange ? escapeXml /*xml*/ `${stringRef(chart.labelRange)}` : ""} ${numberRef(dataset.range)} `; if (dataset.rightYAxis) { rightDataSetsNodes.push(dataSetNode); } else { leftDataSetsNodes.push(dataSetNode); } } const grouping = chart.stacked ? "stacked" : "clustered"; const overlap = chart.stacked ? 100 : -20; return escapeXml /*xml*/ ` ${leftDataSetsNodes.length ? escapeXml /*xml*/ ` ${joinXmlNodes(leftDataSetsNodes)} ${addAx("b", "c:catAx", catAxId, valAxId, chart.axesDesign?.x?.title, chart.fontColor)} ${addAx("l", "c:valAx", valAxId, catAxId, chart.axesDesign?.y?.title, chart.fontColor)} ` : ""} ${rightDataSetsNodes.length ? escapeXml /*xml*/ ` ${joinXmlNodes(rightDataSetsNodes)} ${addAx("b", "c:catAx", catAxId + 1, valAxId + 1, chart.axesDesign?.x?.title, chart.fontColor, leftDataSetsNodes.length ? 1 : 0)} ${addAx("r", "c:valAx", valAxId + 1, catAxId + 1, chart.axesDesign?.y1?.title, chart.fontColor)} ` : ""}`; } function addComboChart(chart) { // gapWitdh and overlap that define the space between clusters (in %) and the overlap between datasets (from -100: completely scattered to 100, completely overlapped) // see gapWidth : https://c-rex.net/projects/samples/ooxml/e1/Part4/OOXML_P4_DOCX_gapWidth_topic_ID0EFVEQB.html#topic_ID0EFVEQB // see overlap : https://c-rex.net/projects/samples/ooxml/e1/Part4/OOXML_P4_DOCX_overlap_topic_ID0ELYQQB.html#topic_ID0ELYQQB // // overlap and gapWitdh seems to be by default at -20 and 20 in chart.js. // See https://www.chartjs.org/docs/latest/charts/bar.html and https://www.chartjs.org/docs/latest/charts/bar.html#barpercentage-vs-categorypercentage const dataSets = chart.dataSets; const dataSetsColors = dataSets.map((ds) => ds.backgroundColor ?? ""); const colors = new ColorGenerator(dataSets.length, dataSetsColors); let dataSet = dataSets[0]; const firstColor = toXlsxHexColor(colors.next()); const useRightAxisForBarSerie = dataSet.rightYAxis ?? false; const barDataSetNode = escapeXml /*xml*/ ` ${extractDataSetLabel(dataSet.label)} ${shapeProperty({ backgroundColor: firstColor, line: { color: firstColor }, })} ${chart.labelRange ? escapeXml /*xml*/ `${stringRef(chart.labelRange)}` : ""} ${numberRef(dataSet.range)} `; const leftDataSetsNodes = []; const rightDataSetsNodes = []; for (let dsIndex = 1; dsIndex < dataSets.length; dsIndex++) { dataSet = dataSets[dsIndex]; const color = toXlsxHexColor(colors.next()); const dataShapeProperty = shapeProperty({ backgroundColor: color, line: { color }, }); const dataSetNode = escapeXml /*xml*/ ` ${dataShapeProperty} ${extractDataSetLabel(dataSet.label)} ${dataShapeProperty} ${chart.labelRange ? escapeXml `${stringRef(chart.labelRange)}` : ""} ${numberRef(dataSet.range)} `; if (dataSet.rightYAxis) { rightDataSetsNodes.push(dataSetNode); } else { leftDataSetsNodes.push(dataSetNode); } } const overlap = chart.stacked ? 100 : -20; return escapeXml /*xml*/ ` ${barDataSetNode} ${leftDataSetsNodes.length ? escapeXml /*xml*/ ` ${joinXmlNodes(leftDataSetsNodes)} ` : ""} ${rightDataSetsNodes.length ? escapeXml /*xml*/ ` ${joinXmlNodes(rightDataSetsNodes)} ` : ""} ${!useRightAxisForBarSerie || leftDataSetsNodes.length ? escapeXml /*xml*/ ` ${addAx("b", "c:catAx", catAxId, valAxId, chart.axesDesign?.x?.title, chart.fontColor, leftDataSetsNodes.length ? 1 : 0)} ${addAx("l", "c:valAx", valAxId, catAxId, chart.axesDesign?.y?.title, chart.fontColor)} ` : ""} ${useRightAxisForBarSerie || rightDataSetsNodes.length ? escapeXml /*xml*/ ` ${addAx("b", "c:catAx", secondaryCatAxId, secondaryValAxId, chart.axesDesign?.x?.title, chart.fontColor, leftDataSetsNodes.length || !useRightAxisForBarSerie ? 1 : 0)} ${addAx("r", "c:valAx", secondaryValAxId, secondaryCatAxId, chart.axesDesign?.y1?.title, chart.fontColor)} ` : ""} `; } function addLineChart(chart) { const dataSetsColors = chart.dataSets.map((ds) => ds.backgroundColor ?? ""); const colors = new ColorGenerator(chart.dataSets.length, dataSetsColors); const leftDataSetsNodes = []; const rightDataSetsNodes = []; for (const [dsIndex, dataset] of Object.entries(chart.dataSets)) { const color = toXlsxHexColor(colors.next()); const dataShapeProperty = shapeProperty({ line: { width: 2.5, style: "solid", color, }, }); const dataSetNode = escapeXml /*xml*/ ` ${shapeProperty({ backgroundColor: color, line: { color } })} ${extractDataSetLabel(dataset.label)} ${dataShapeProperty} ${chart.labelRange ? escapeXml `${stringRef(chart.labelRange)}` : ""} ${numberRef(dataset.range)} `; if (dataset.rightYAxis) { rightDataSetsNodes.push(dataSetNode); } else { leftDataSetsNodes.push(dataSetNode); } } const grouping = chart.stacked ? "stacked" : "standard"; return escapeXml /*xml*/ ` ${leftDataSetsNodes.length ? escapeXml /*xml*/ ` ${joinXmlNodes(leftDataSetsNodes)} ${addAx("b", "c:catAx", catAxId, valAxId, chart.axesDesign?.x?.title, chart.fontColor)} ${addAx("l", "c:valAx", valAxId, catAxId, chart.axesDesign?.y?.title, chart.fontColor)} ` : ""} ${rightDataSetsNodes.length ? escapeXml /*xml*/ ` ${joinXmlNodes(rightDataSetsNodes)} ${addAx("b", "c:catAx", catAxId + 1, valAxId + 1, chart.axesDesign?.x?.title, chart.fontColor, leftDataSetsNodes.length ? 1 : 0)} ${addAx("r", "c:valAx", valAxId + 1, catAxId + 1, chart.axesDesign?.y1?.title, chart.fontColor)} ` : ""} `; } function addScatterChart(chart) { const dataSetsColors = chart.dataSets.map((ds) => ds.backgroundColor ?? ""); const colors = new ColorGenerator(chart.dataSets.length, dataSetsColors); const leftDataSetsNodes = []; const rightDataSetsNodes = []; for (const [dsIndex, dataset] of Object.entries(chart.dataSets)) { const color = toXlsxHexColor(colors.next()); const dataSetNode = escapeXml /*xml*/ ` ${shapeProperty({ backgroundColor: color, line: { color } })} ${extractDataSetLabel(dataset.label)} ${chart.labelRange ? escapeXml /*xml*/ ` ${numberRef(chart.labelRange)} ` : ""} ${numberRef(dataset.range)} `; if (dataset.rightYAxis) { rightDataSetsNodes.push(dataSetNode); } else { leftDataSetsNodes.push(dataSetNode); } } return escapeXml /*xml*/ ` ${leftDataSetsNodes.length ? escapeXml /*xml*/ ` ${joinXmlNodes(leftDataSetsNodes)} ${addAx("b", "c:valAx", catAxId, valAxId, chart.axesDesign?.x?.title, chart.fontColor)} ${addAx("l", "c:valAx", valAxId, catAxId, chart.axesDesign?.y?.title, chart.fontColor)} ` : ""} ${rightDataSetsNodes.length ? escapeXml /*xml*/ ` ${joinXmlNodes(rightDataSetsNodes)} ${addAx("b", "c:valAx", catAxId + 1, valAxId + 1, chart.axesDesign?.x?.title, chart.fontColor, leftDataSetsNodes.length ? 1 : 0)} ${addAx("r", "c:valAx", valAxId + 1, catAxId + 1, chart.axesDesign?.y1?.title, chart.fontColor)} ` : ""}`; } function addDoughnutChart(chart, chartSheetIndex, data, { holeSize } = { holeSize: 50 }) { const maxLength = largeMax(chart.dataSets.map((ds) => getRangeSize(ds.range, chartSheetIndex, data))); const colors = new ColorGenerator(maxLength); const doughnutColors = range(0, maxLength).map(() => toXlsxHexColor(colors.next())); const dataSetsNodes = []; for (const [dsIndex, dataset] of Object.entries(chart.dataSets).reverse()) { //dataset slice labels const dsSize = getRangeSize(dataset.range, chartSheetIndex, data); const dataPoints = []; for (const index of range(0, dsSize)) { const pointShapeProperty = shapeProperty({ backgroundColor: doughnutColors[index], line: { color: "FFFFFF", width: 1.5 }, }); dataPoints.push(escapeXml /*xml*/ ` ${pointShapeProperty} `); } dataSetsNodes.push(escapeXml /*xml*/ ` ${extractDataSetLabel(dataset.label)} ${joinXmlNodes(dataPoints)} ${insertDataLabels({ showLeaderLines: true })} ${chart.labelRange ? escapeXml `${stringRef(chart.labelRange)}` : ""} ${numberRef(dataset.range)} `); } return escapeXml /*xml*/ ` ${insertDataLabels()} ${joinXmlNodes(dataSetsNodes)} `; } function insertDataLabels({ showLeaderLines } = { showLeaderLines: false }) { return escapeXml /*xml*/ ` `; } function addAx(position, axisName, axId, crossAxId, title, defaultFontColor, deleteAxis = 0) { // Each Axis present inside a graph needs to be identified by an unsigned integer in order to be referenced by its crossAxis. // I.e. x-axis, will reference y-axis and vice-versa. const color = title?.color ? toXlsxHexColor(title.color) : defaultFontColor; return escapeXml /*xml*/ ` <${axisName}> ${insertMajorGridLines()} ${insertText(title?.text ?? "", color, 10, title)} ${insertTextProperties(10, defaultFontColor)} `; } function addLegend(position, fontColor) { return escapeXml /*xml*/ ` ${insertTextProperties(10, fontColor)} `; } function insertMajorGridLines(color = "B7B7B7") { return escapeXml /*xml*/ ` ${shapeProperty({ line: { color } })} `; } function stringRef(reference) { return escapeXml /*xml*/ ` ${reference} `; } function numberRef(reference) { return escapeXml /*xml*/ ` ${reference} `; } function addFormula(cell) { const formula = cell.content; if (!formula) { return { attrs: [], node: escapeXml `` }; } const type = getCellType(cell.value); if (type === undefined) { return { attrs: [], node: escapeXml `` }; } const attrs = [["t", type]]; const XlsxFormula = adaptFormulaToExcel(formula); const exportedValue = adaptFormulaValueToExcel(cell.value); const node = escapeXml /*xml*/ `${XlsxFormula}${exportedValue}`; return { attrs, node }; } function addContent(content, sharedStrings, forceString = false) { let value = content; const attrs = []; const clearValue = value.trim().toUpperCase(); if (!forceString && ["TRUE", "FALSE"].includes(clearValue)) { value = clearValue === "TRUE" ? "1" : "0"; attrs.push(["t", "b"]); } else if (forceString || !isNumber(value, DEFAULT_LOCALE)) { value = pushElement(content, sharedStrings); attrs.push(["t", "s"]); } return { attrs, node: escapeXml /*xml*/ `${value}` }; } function adaptFormulaToExcel(formulaText) { if (formulaText[0] === "=") { formulaText = formulaText.slice(1); } let ast; try { ast = parse(formulaText); } catch (error) { return formulaText; } ast = convertAstNodes(ast, "STRING", convertDateFormat); ast = convertAstNodes(ast, "FUNCALL", (ast) => { ast = { ...ast, value: ast.value.toUpperCase() }; ast = prependNonRetrocompatibleFunction(ast); ast = addMissingRequiredArgs(ast); return ast; }); ast = convertAstNodes(ast, "REFERENCE", (ast) => { return ast.value === CellErrorType.InvalidReference ? { ...ast, value: "#REF!" } : ast; }); return ast ? astToFormula(ast) : formulaText; } function adaptFormulaValueToExcel(formulaValue) { return formulaValue === CellErrorType.InvalidReference ? "#REF!" : formulaValue; } /** * Some Excel function need required args that might not be mandatory in o-spreadsheet. * This adds those missing args. */ function addMissingRequiredArgs(ast) { const formulaName = ast.value.toUpperCase(); const args = ast.args; const exportDefaultArgs = FORCE_DEFAULT_ARGS_FUNCTIONS[formulaName]; if (exportDefaultArgs) { const requiredArgs = functionRegistry.content[formulaName].args.filter((el) => !el.optional); const diffArgs = requiredArgs.length - ast.args.length; if (diffArgs) { // We know that we have at least 1 default Value missing for (let i = ast.args.length; i < requiredArgs.length; i++) { const currentDefaultArg = exportDefaultArgs[i - diffArgs]; args.push({ type: currentDefaultArg.type, value: currentDefaultArg.value }); } } } return { ...ast, args }; } /** * Prepend function names that are not compatible with Old Excel versions */ function prependNonRetrocompatibleFunction(ast) { const formulaName = ast.value.toUpperCase(); return { ...ast, value: NON_RETROCOMPATIBLE_FUNCTIONS.includes(formulaName) ? `_xlfn.${formulaName}` : formulaName, }; } /** * Convert strings that correspond to a date to the format YYYY-DD-MM */ function convertDateFormat(ast) { const value = ast.value.replace(new RegExp('"', "g"), ""); const internalDate = parseDateTime(value, DEFAULT_LOCALE); if (internalDate) { let format = []; if (mdyDateRegexp.test(value) || ymdDateRegexp.test(value)) { format.push("yyyy-mm-dd"); } if (timeRegexp.test(value)) { format.push("hh:mm:ss"); } return { ...ast, value: formatValue(internalDate.value, { format: format.join(" "), locale: DEFAULT_LOCALE }), }; } else { return { ...ast, value: ast.value.replace(/\\"/g, `""`) }; } } function addConditionalFormatting(dxfs, conditionalFormats) { // Conditional Formats const cfNodes = []; for (const cf of conditionalFormats) { // Special case for each type of rule: might be better to extract that logic in dedicated functions switch (cf.rule.type) { case "CellIsRule": cfNodes.push(addCellIsRule(cf, cf.rule, dxfs)); break; case "ColorScaleRule": cfNodes.push(addColorScaleRule(cf, cf.rule)); break; case "IconSetRule": cfNodes.push(addIconSetRule(cf, cf.rule)); break; default: // @ts-ignore Typescript knows it will never happen at compile time console.warn(`Conditional formatting ${cf.rule.type} not implemented`); break; } } return cfNodes; } // ---------------------- // RULES // ---------------------- function addCellIsRule(cf, rule, dxfs) { const ruleAttributes = commonCfAttributes(cf); const operator = convertOperator(rule.operator); ruleAttributes.push(...cellRuleTypeAttributes(rule), ["operator", operator]); const formulas = cellRuleFormula(cf.ranges, rule).map((formula) => escapeXml /*xml*/ `${formula}`); const dxf = { font: { color: { rgb: rule.style.textColor }, bold: rule.style.bold, italic: rule.style.italic, strike: rule.style.strikethrough, underline: rule.style.underline, }, }; if (rule.style.fillColor) { dxf.fill = { fgColor: { rgb: rule.style.fillColor } }; } ruleAttributes.push(["dxfId", pushElement(dxf, dxfs)]); return escapeXml /*xml*/ ` ${joinXmlNodes(formulas)} `; } function cellRuleFormula(ranges, rule) { const firstCell = ranges[0].split(":")[0]; const values = rule.values; switch (rule.operator) { case "ContainsText": return [`NOT(ISERROR(SEARCH("${values[0]}",${firstCell})))`]; case "NotContains": return [`ISERROR(SEARCH("${values[0]}",${firstCell}))`]; case "BeginsWith": return [`LEFT(${firstCell},LEN("${values[0]}"))="${values[0]}"`]; case "EndsWith": return [`RIGHT(${firstCell},LEN("${values[0]}"))="${values[0]}"`]; case "IsEmpty": return [`LEN(TRIM(${firstCell}))=0`]; case "IsNotEmpty": return [`LEN(TRIM(${firstCell}))>0`]; case "Equal": case "NotEqual": case "GreaterThan": case "GreaterThanOrEqual": case "LessThan": case "LessThanOrEqual": return [values[0]]; case "Between": case "NotBetween": return [values[0], values[1]]; } } function cellRuleTypeAttributes(rule) { const operator = convertOperator(rule.operator); switch (rule.operator) { case "ContainsText": case "NotContains": case "BeginsWith": case "EndsWith": return [ ["type", operator], ["text", rule.values[0]], ]; case "IsEmpty": case "IsNotEmpty": return [["type", operator]]; case "Equal": case "NotEqual": case "GreaterThan": case "GreaterThanOrEqual": case "LessThan": case "LessThanOrEqual": case "Between": case "NotBetween": return [["type", "cellIs"]]; } } function addColorScaleRule(cf, rule) { const ruleAttributes = commonCfAttributes(cf); ruleAttributes.push(["type", "colorScale"]); /** mimic our flow: * for a given ColorScale CF, each range of the "ranges set" has its own behaviour. */ const conditionalFormats = []; for (const range of cf.ranges) { const cfValueObject = []; const colors = []; let canExport = true; for (let position of ["minimum", "midpoint", "maximum"]) { const threshold = rule[position]; if (!threshold) { // pass midpoint if not defined continue; } if (threshold.type === "formula") { canExport = false; continue; } cfValueObject.push(thresholdAttributes(threshold, position)); colors.push([["rgb", toXlsxHexColor(colorNumberString(threshold.color))]]); } if (!canExport) { console.warn("Conditional formats with formula rules are not supported at the moment. The rule is therefore skipped."); continue; } const cfValueObjectNodes = cfValueObject.map((attrs) => escapeXml /*xml*/ ``); const cfColorNodes = colors.map((attrs) => escapeXml /*xml*/ ``); conditionalFormats.push(escapeXml /*xml*/ ` ${joinXmlNodes(cfValueObjectNodes)} ${joinXmlNodes(cfColorNodes)} `); } return joinXmlNodes(conditionalFormats); } function addIconSetRule(cf, rule) { const ruleAttributes = commonCfAttributes(cf); ruleAttributes.push(["type", "iconSet"]); /** mimic our flow: * for a given IconSet CF, each range of the "ranges set" has its own behaviour. */ const conditionalFormats = []; for (const range of cf.ranges) { const cfValueObject = [ // It looks like they always want 3 cfvo and they add a dummy entry [ ["type", "percent"], ["val", 0], ], ]; let canExport = true; for (let position of ["lowerInflectionPoint", "upperInflectionPoint"]) { if (rule[position].type === "formula") { canExport = false; continue; } const threshold = rule[position]; cfValueObject.push([ ...thresholdAttributes(threshold, position), ["gte", threshold.operator === "ge" ? "1" : "0"], ]); } if (!canExport) { console.warn("Conditional formats with formula rules are not supported at the moment. The rule is therefore skipped."); continue; } const cfValueObjectNodes = cfValueObject.map((attrs) => escapeXml /*xml*/ ``); conditionalFormats.push(escapeXml /*xml*/ ` ${joinXmlNodes(cfValueObjectNodes)} `); } return joinXmlNodes(conditionalFormats); } // ---------------------- // MISC // ---------------------- function commonCfAttributes(cf) { return [ ["priority", 1], ["stopIfTrue", cf.stopIfTrue ? 1 : 0], ]; } function getIconSet(iconSet) { return XLSX_ICONSET_MAP[Object.keys(XLSX_ICONSET_MAP).find((key) => iconSet.upper.toLowerCase().startsWith(key)) || "dots"]; } function thresholdAttributes(threshold, position) { const type = getExcelThresholdType(threshold.type, position); const attrs = [["type", type]]; if (type !== "min" && type !== "max") { // what if the formula is not correct // references cannot be relative :/ let val = threshold.value; if (type === "formula") { try { // Relative references are not supported in formula val = adaptFormulaToExcel(threshold.value); } catch (error) { val = threshold.value; } } attrs.push(["val", val]); // value is undefined only for type="value") } return attrs; } /** * This function adapts our Threshold types to their Excel equivalents. * * if type === "value" ,then we must replace it by min or max according to the position * if type === "number", then it becomes num * if type === "percentage", it becomes "percent" * rest of the time, the type is unchanged */ function getExcelThresholdType(type, position) { switch (type) { case "value": return position === "minimum" ? "min" : "max"; case "number": return "num"; case "percentage": return "percent"; default: return type; } } function createDrawing(drawingRelIds, sheet, figures) { const namespaces = [ ["xmlns:xdr", NAMESPACE.drawing], ["xmlns:r", RELATIONSHIP_NSR], ["xmlns:a", DRAWING_NS_A], ["xmlns:c", DRAWING_NS_C], ]; const figuresNodes = []; for (const [figureIndex, figure] of Object.entries(figures)) { switch (figure?.tag) { case "chart": figuresNodes.push(createChartDrawing(figure, sheet, drawingRelIds[figureIndex])); break; case "image": figuresNodes.push(createImageDrawing(figure, sheet, drawingRelIds[figureIndex])); break; } } const xml = escapeXml /*xml*/ ` ${joinXmlNodes(figuresNodes)} `; return parseXML(xml); } /** * Returns the coordinates of topLeft (from) and BottomRight (to) of the chart in English Metric Units (EMU) */ function convertFigureData(figure, sheet) { const { x, y, height, width } = figure; const cols = Object.values(sheet.cols); const rows = Object.values(sheet.rows); const { index: colFrom, offset: offsetColFrom } = figureCoordinates(cols, x); const { index: colTo, offset: offsetColTo } = figureCoordinates(cols, x + width); const { index: rowFrom, offset: offsetRowFrom } = figureCoordinates(rows, y); const { index: rowTo, offset: offsetRowTo } = figureCoordinates(rows, y + height); return { from: { col: colFrom, colOff: offsetColFrom, row: rowFrom, rowOff: offsetRowFrom, }, to: { col: colTo, colOff: offsetColTo, row: rowTo, rowOff: offsetRowTo, }, }; } /** Returns figure coordinates in EMU for a specific header dimension * See https://docs.microsoft.com/en-us/windows/win32/vml/msdn-online-vml-units#other-units-of-measurement */ function figureCoordinates(headers, position) { let currentPosition = 0; for (const [headerIndex, header] of headers.entries()) { if (currentPosition <= position && position < currentPosition + header.size) { return { index: headerIndex, offset: convertDotValueToEMU(position - currentPosition + FIGURE_BORDER_WIDTH), }; } else if (headerIndex < headers.length - 1) { currentPosition += header.size; } } return { index: headers.length - 1, offset: convertDotValueToEMU(position - currentPosition + FIGURE_BORDER_WIDTH), }; } function createChartDrawing(figure, sheet, chartRelId) { // position const { from, to } = convertFigureData(figure, sheet); const chartId = convertChartId(figure.id); const cNvPrAttrs = [ ["id", chartId], ["name", `Chart ${chartId}`], ["title", "Chart"], ]; return escapeXml /*xml*/ ` ${from.col} ${from.colOff} ${from.row} ${from.rowOff} ${to.col} ${to.colOff} ${to.row} ${to.rowOff} `; } function createImageDrawing(figure, sheet, imageRelId) { // position const { from, to } = convertFigureData(figure, sheet); const imageId = convertImageId(figure.id); const cNvPrAttrs = [ ["id", imageId], ["name", `Image ${imageId}`], ["title", "Image"], ]; const cx = convertDotValueToEMU(figure.width); const cy = convertDotValueToEMU(figure.height); return escapeXml /*xml*/ ` ${from.col} ${from.colOff} ${from.row} ${from.rowOff} ${to.col} ${to.colOff} ${to.row} ${to.rowOff} `; } function addNumberFormats(numFmts) { const numFmtNodes = []; for (let [index, numFmt] of Object.entries(numFmts)) { const numFmtAttrs = [ ["numFmtId", parseInt(index) + FIRST_NUMFMT_ID], ["formatCode", numFmt.format], ]; numFmtNodes.push(escapeXml /*xml*/ ` `); } return escapeXml /*xml*/ ` ${joinXmlNodes(numFmtNodes)} `; } function addFont(font) { if (isObjectEmptyRecursive(font)) { return escapeXml /*xml*/ ``; } return escapeXml /*xml*/ ` ${font.bold ? escapeXml /*xml*/ `` : ""} ${font.italic ? escapeXml /*xml*/ `` : ""} ${font.underline ? escapeXml /*xml*/ `` : ""} ${font.strike ? escapeXml /*xml*/ `` : ""} ${font.size ? escapeXml /*xml*/ `` : ""} ${font.color && font.color.rgb ? escapeXml /*xml*/ `` : ""} ${font.name ? escapeXml /*xml*/ `` : ""} `; } function addFonts(fonts) { return escapeXml /*xml*/ ` ${joinXmlNodes(Object.values(fonts).map(addFont))} `; } function addFills(fills) { const fillNodes = []; for (let fill of Object.values(fills)) { if (fill.reservedAttribute !== undefined) { fillNodes.push(escapeXml /*xml*/ ` `); } else { fillNodes.push(escapeXml /*xml*/ ` `); } } return escapeXml /*xml*/ ` ${joinXmlNodes(fillNodes)} `; } function addBorders(borders) { const borderNodes = []; for (let border of Object.values(borders)) { borderNodes.push(escapeXml /*xml*/ ` ${addBorderColor(border["left"])} ${addBorderColor(border["right"])} ${addBorderColor(border["top"])} ${addBorderColor(border["bottom"])} ${addBorderColor(border["diagonal"])} `); } return escapeXml /*xml*/ ` ${joinXmlNodes(borderNodes)} `; } function formatBorderAttribute(description) { if (!description) { return escapeXml ``; } return formatAttributes([["style", description.style]]); } function addBorderColor(description) { if (!description) { return escapeXml ``; } return escapeXml /*xml*/ ` `; } function addStyles(styles) { const styleNodes = []; for (let style of styles) { const attributes = [ ["numFmtId", style.numFmtId], ["fillId", style.fillId], ["fontId", style.fontId], ["borderId", style.borderId], ]; // Note: the apply${substyleName} does not seem to be required const alignAttrs = []; if (style.alignment && style.alignment.vertical) { alignAttrs.push(["vertical", style.alignment.vertical]); } if (style.alignment && style.alignment.horizontal) { alignAttrs.push(["horizontal", style.alignment.horizontal]); } if (style.alignment && style.alignment.wrapText) { alignAttrs.push(["wrapText", "1"]); } if (alignAttrs.length > 0) { attributes.push(["applyAlignment", "1"]); // for Libre Office styleNodes.push(escapeXml /*xml*/ `${escapeXml /*xml*/ ``} `); } else { styleNodes.push(escapeXml /*xml*/ ``); } } return escapeXml /*xml*/ ` ${joinXmlNodes(styleNodes)} `; } /** * DXFS : Differential Formatting Records - Conditional formats */ function addCellWiseConditionalFormatting(dxfs // cell-wise CF ) { const dxfNodes = []; for (const dxf of dxfs) { let fontNode = escapeXml ``; if (dxf.font) { fontNode = addFont(dxf.font); } let fillNode = escapeXml ``; if (dxf.fill) { fillNode = escapeXml /*xml*/ ` `; } dxfNodes.push(escapeXml /*xml*/ ` ${fontNode} ${fillNode} `); } return escapeXml /*xml*/ ` ${joinXmlNodes(dxfNodes)} `; } function createTable(table, tableId, sheetData) { const tableAttributes = [ ["id", tableId], ["name", `Table${tableId}`], ["displayName", `Table${tableId}`], ["ref", table.range], ["headerRowCount", table.config.numberOfHeaders], ["totalsRowCount", table.config.totalRow ? 1 : 0], ["xmlns", NAMESPACE.table], ["xmlns:xr", NAMESPACE.revision], ["xmlns:xr3", NAMESPACE.revision3], ["xmlns:mc", NAMESPACE.markupCompatibility], ]; const xml = escapeXml /*xml*/ ` ${table.config.hasFilters ? addAutoFilter(table) : ""} ${addTableColumns(table, sheetData)} ${addTableStyle(table)}
`; return parseXML(xml); } function addAutoFilter(table) { const autoFilterAttributes = [["ref", table.range]]; return escapeXml /*xml*/ ` ${joinXmlNodes(addFilterColumns(table))} `; } function addFilterColumns(table) { const columns = []; for (const filter of table.filters) { const colXml = escapeXml /*xml*/ ` ${addFilter(filter)} `; columns.push(colXml); } return columns; } function addFilter(filter) { const filterValues = filter.displayedValues.map((val) => escapeXml /*xml*/ ``); const filterAttributes = filter.displayBlanks ? [["blank", 1]] : []; return escapeXml /*xml*/ ` ${joinXmlNodes(filterValues)} `; } function addTableColumns(table, sheetData) { const tableZone = toZone(table.range); const columns = []; for (const i of range(0, zoneToDimension(tableZone).numberOfCols)) { const colHeaderXc = toXC(tableZone.left + i, tableZone.top); const colName = sheetData.cells[colHeaderXc]?.content || `col${i}`; const colAttributes = [ ["id", i + 1], // id cannot be 0 ["name", colName], ]; if (table.config.totalRow) { // Note: To be 100% complete, we could also add a `totalsRowLabel` attribute for total strings, and a tag // `` for the formula of the total. But those doesn't seem to be mandatory for Excel. const colTotalXc = toXC(tableZone.left + i, tableZone.bottom); const colTotalContent = sheetData.cells[colTotalXc]?.content; if (colTotalContent?.startsWith("=")) { colAttributes.push(["totalsRowFunction", "custom"]); } } columns.push(escapeXml /*xml*/ ``); } return escapeXml /*xml*/ ` ${joinXmlNodes(columns)} `; } function addTableStyle(table) { const tableStyleAttrs = [ ["name", table.config.styleId], ["showFirstColumn", table.config.firstColumn ? 1 : 0], ["showLastColumn", table.config.lastColumn ? 1 : 0], ["showRowStripes", table.config.bandedRows ? 1 : 0], ["showColumnStripes", table.config.bandedColumns ? 1 : 0], ]; return escapeXml /*xml*/ ``; } function addColumns(cols) { if (!Object.values(cols).length) { return escapeXml ``; } const colNodes = []; for (let [id, col] of Object.entries(cols)) { // Always force our own col width const attributes = [ ["min", parseInt(id) + 1], ["max", parseInt(id) + 1], ["width", convertWidthToExcel(col.size || DEFAULT_CELL_WIDTH)], ["customWidth", 1], ["hidden", col.isHidden ? 1 : 0], ]; if (col.outlineLevel) { attributes.push(["outlineLevel", col.outlineLevel]); } if (col.collapsed) { attributes.push(["collapsed", 1]); } colNodes.push(escapeXml /*xml*/ ` `); } return escapeXml /*xml*/ ` ${joinXmlNodes(colNodes)} `; } function addRows(construct, data, sheet) { const rowNodes = []; for (let r = 0; r < sheet.rowNumber; r++) { const rowAttrs = [["r", r + 1]]; const row = sheet.rows[r] || {}; if (row.size && row.size !== DEFAULT_CELL_HEIGHT) { rowAttrs.push(["ht", convertHeightToExcel(row.size)], ["customHeight", 1]); } if (row.isHidden) { rowAttrs.push(["hidden", 1]); } if (row.outlineLevel) { rowAttrs.push(["outlineLevel", row.outlineLevel]); } if (row.collapsed) { rowAttrs.push(["collapsed", 1]); } const cellNodes = []; for (let c = 0; c < sheet.colNumber; c++) { const xc = toXC(c, r); const cell = sheet.cells[xc]; if (cell) { const attributes = [["r", xc]]; // style const id = normalizeStyle(construct, extractStyle(cell, data)); // don't add style if default if (id) { attributes.push(["s", id]); } let additionalAttrs = []; let cellNode = escapeXml ``; // Either formula or static value inside the cell if (cell.isFormula) { const res = addFormula(cell); if (!res) { continue; } ({ attrs: additionalAttrs, node: cellNode } = res); } else if (cell.content && isMarkdownLink(cell.content)) { const { label } = parseMarkdownLink(cell.content); ({ attrs: additionalAttrs, node: cellNode } = addContent(label, construct.sharedStrings)); } else if (cell.content && cell.content !== "") { const isTableHeader = isCellTableHeader(c, r, sheet); const isTableTotal = isCellTableTotal(c, r, sheet); const isPlainText = !!(cell.format && isTextFormat(data.formats[cell.format])); ({ attrs: additionalAttrs, node: cellNode } = addContent(cell.content, construct.sharedStrings, isTableHeader || isTableTotal || isPlainText)); } attributes.push(...additionalAttrs); // prettier-ignore cellNodes.push(escapeXml /*xml*/ ` ${cellNode} `); } } if (cellNodes.length || row.size !== DEFAULT_CELL_HEIGHT || row.isHidden || row.outlineLevel || row.collapsed) { rowNodes.push(escapeXml /*xml*/ ` ${joinXmlNodes(cellNodes)} `); } } return escapeXml /*xml*/ ` ${joinXmlNodes(rowNodes)} `; } function isCellTableHeader(col, row, sheet) { return sheet.tables.some((table) => { const zone = toZone(table.range); const headerZone = { ...zone, bottom: zone.top }; return isInside(col, row, headerZone); }); } function isCellTableTotal(col, row, sheet) { return sheet.tables.some((table) => { if (!table.config.totalRow) { return false; } const zone = toZone(table.range); const totalZone = { ...zone, top: zone.bottom }; return isInside(col, row, totalZone); }); } function addHyperlinks(construct, data, sheetIndex) { const sheet = data.sheets[sheetIndex]; const cells = sheet.cells; const linkNodes = []; for (const xc in cells) { const content = cells[xc]?.content; if (content && isMarkdownLink(content)) { const { label, url } = parseMarkdownLink(content); if (isSheetUrl(url)) { const sheetId = parseSheetUrl(url); const sheet = data.sheets.find((sheet) => sheet.id === sheetId); const position = sheet ? `${sheet.name}!A1` : CellErrorType.InvalidReference; const hyperlinkAttributes = [ ["display", label], ["location", position], ["ref", xc], ]; linkNodes.push(escapeXml /*xml*/ ` `); } else { const linkRelId = addRelsToFile(construct.relsFiles, `xl/worksheets/_rels/sheet${sheetIndex}.xml.rels`, { target: withHttps(url), type: XLSX_RELATION_TYPE.hyperlink, targetMode: "External", }); const hyperlinkAttributes = [ ["r:id", linkRelId], ["ref", xc], ]; linkNodes.push(escapeXml /*xml*/ ` `); } } } if (!linkNodes.length) { return escapeXml ``; } return escapeXml /*xml*/ ` ${joinXmlNodes(linkNodes)} `; } function addMerges(merges) { if (merges.length) { const mergeNodes = merges.map((merge) => escapeXml /*xml*/ ``); return escapeXml /*xml*/ ` ${joinXmlNodes(mergeNodes)} `; } else return escapeXml ``; } function addSheetViews(sheet) { const panes = sheet.panes; let splitPanes = escapeXml /*xml*/ ``; if (panes && (panes.xSplit || panes.ySplit)) { const xc = toXC(panes.xSplit, panes.ySplit); //workbookViewId should be defined in the workbook file but it seems like Excel has a default behaviour. const xSplit = panes.xSplit ? escapeXml `xSplit="${panes.xSplit}"` : ""; const ySplit = panes.ySplit ? escapeXml `ySplit="${panes.ySplit}"` : ""; const topRight = panes.xSplit ? escapeXml `` : ""; const bottomLeft = panes.ySplit ? escapeXml `` : ""; const bottomRight = panes.xSplit && panes.ySplit ? escapeXml `` : ""; splitPanes = escapeXml /*xml*/ ` ${topRight} ${bottomLeft} ${bottomRight} `; } const sheetViewAttrs = [ ["showGridLines", sheet.areGridLinesVisible ? 1 : 0], ["workbookViewId", 0], ]; let sheetView = escapeXml /*xml*/ ` ${splitPanes} `; return sheetView; } function addSheetProperties(sheet) { if (!sheet.color) { return ""; } let sheetView = escapeXml /*xml*/ ` `; return sheetView; } /** * Return the spreadsheet data in the Office Open XML file format. * See ECMA-376 standard. * https://www.ecma-international.org/publications-and-standards/standards/ecma-376/ */ function getXLSX(data) { data = fixLengthySheetNames(data); data = purgeSingleRowTables(data); const files = []; const construct = getDefaultXLSXStructure(data); files.push(createWorkbook(data, construct)); files.push(...createWorksheets(data, construct)); files.push(createStylesSheet(construct)); files.push(createSharedStrings(construct.sharedStrings)); files.push(...createRelsFiles(construct.relsFiles)); files.push(createContentTypes(files)); files.push(createRelRoot()); return { name: `my_spreadsheet.xlsx`, files, }; } function createWorkbook(data, construct) { const namespaces = [ ["xmlns", NAMESPACE["workbook"]], ["xmlns:r", RELATIONSHIP_NSR], ]; const sheetNodes = []; for (const [index, sheet] of Object.entries(data.sheets)) { const attributes = [ ["state", sheet.isVisible ? "visible" : "hidden"], ["name", sheet.name], ["sheetId", parseInt(index) + 1], ["r:id", `rId${parseInt(index) + 1}`], ]; sheetNodes.push(escapeXml /*xml*/ ` `); addRelsToFile(construct.relsFiles, "xl/_rels/workbook.xml.rels", { type: XLSX_RELATION_TYPE.sheet, target: `worksheets/sheet${index}.xml`, }); } const xml = escapeXml /*xml*/ ` ${joinXmlNodes(sheetNodes)} `; return createXMLFile(parseXML(xml), "xl/workbook.xml", "workbook"); } function createWorksheets(data, construct) { const files = []; let currentTableIndex = 1; for (const [sheetIndex, sheet] of Object.entries(data.sheets)) { const namespaces = [ ["xmlns", NAMESPACE["worksheet"]], ["xmlns:r", RELATIONSHIP_NSR], ]; const sheetFormatAttributes = [ ["defaultRowHeight", convertHeightToExcel(DEFAULT_CELL_HEIGHT)], ["defaultColWidth", convertWidthToExcel(DEFAULT_CELL_WIDTH)], ]; const tablesNode = createTablesForSheet(sheet, sheetIndex, currentTableIndex, construct, files); currentTableIndex += sheet.tables.length; // Figures and Charts let drawingNode = escapeXml ``; const drawingRelIds = []; for (const chart of sheet.charts) { const xlsxChartId = convertChartId(chart.id); const chartRelId = addRelsToFile(construct.relsFiles, `xl/drawings/_rels/drawing${sheetIndex}.xml.rels`, { target: `../charts/chart${xlsxChartId}.xml`, type: XLSX_RELATION_TYPE.chart, }); drawingRelIds.push(chartRelId); files.push(createXMLFile(createChart(chart, sheetIndex, data), `xl/charts/chart${xlsxChartId}.xml`, "chart")); } for (const image of sheet.images) { const mimeType = image.data.mimetype; if (mimeType === undefined) continue; const extension = IMAGE_MIMETYPE_TO_EXTENSION_MAPPING[mimeType]; // only support exporting images with mimetypes specified in the mapping if (extension === undefined) continue; const xlsxImageId = convertImageId(image.id); let imageFileName = `image${xlsxImageId}.${extension}`; const imageRelId = addRelsToFile(construct.relsFiles, `xl/drawings/_rels/drawing${sheetIndex}.xml.rels`, { target: `../media/${imageFileName}`, type: XLSX_RELATION_TYPE.image, }); drawingRelIds.push(imageRelId); files.push({ path: `xl/media/${imageFileName}`, imageSrc: image.data.path, }); } const drawings = [...sheet.charts, ...sheet.images]; if (drawings.length) { const drawingRelId = addRelsToFile(construct.relsFiles, `xl/worksheets/_rels/sheet${sheetIndex}.xml.rels`, { target: `../drawings/drawing${sheetIndex}.xml`, type: XLSX_RELATION_TYPE.drawing, }); files.push(createXMLFile(createDrawing(drawingRelIds, sheet, drawings), `xl/drawings/drawing${sheetIndex}.xml`, "drawing")); drawingNode = escapeXml /*xml*/ ``; } const sheetXml = escapeXml /*xml*/ ` ${addSheetProperties(sheet)} ${addSheetViews(sheet)} ${addColumns(sheet.cols)} ${addRows(construct, data, sheet)} ${addMerges(sheet.merges)} ${joinXmlNodes(addConditionalFormatting(construct.dxfs, sheet.conditionalFormats))} ${addHyperlinks(construct, data, sheetIndex)} ${drawingNode} ${tablesNode} `; files.push(createXMLFile(parseXML(sheetXml), `xl/worksheets/sheet${sheetIndex}.xml`, "sheet")); } addRelsToFile(construct.relsFiles, "xl/_rels/workbook.xml.rels", { type: XLSX_RELATION_TYPE.sharedStrings, target: "sharedStrings.xml", }); addRelsToFile(construct.relsFiles, "xl/_rels/workbook.xml.rels", { type: XLSX_RELATION_TYPE.styles, target: "styles.xml", }); return files; } /** * Create xlsx files for each tables contained in the given sheet, and add them to the XLSXStructure ans XLSXExportFiles. * * Return an XML string that should be added in the sheet to link these table to the sheet. */ function createTablesForSheet(sheetData, sheetId, startingTableId, construct, files) { let currentTableId = startingTableId; if (!sheetData.tables.length) return new XMLString(""); const sheetRelFile = `xl/worksheets/_rels/sheet${sheetId}.xml.rels`; const tableParts = []; for (const table of sheetData.tables) { const tableRelId = addRelsToFile(construct.relsFiles, sheetRelFile, { target: `../tables/table${currentTableId}.xml`, type: XLSX_RELATION_TYPE.table, }); files.push(createXMLFile(createTable(table, currentTableId, sheetData), `xl/tables/table${currentTableId}.xml`, "table")); tableParts.push(escapeXml /*xml*/ ``); currentTableId++; } return escapeXml /*xml*/ ` ${joinXmlNodes(tableParts)} `; } function createStylesSheet(construct) { const namespaces = [ ["xmlns", NAMESPACE["styleSheet"]], ["xmlns:r", RELATIONSHIP_NSR], ]; const styleXml = escapeXml /*xml*/ ` ${addNumberFormats(construct.numFmts)} ${addFonts(construct.fonts)} ${addFills(construct.fills)} ${addBorders(construct.borders)} ${addStyles(construct.styles)} ${addCellWiseConditionalFormatting(construct.dxfs)} `; return createXMLFile(parseXML(styleXml), "xl/styles.xml", "styles"); } function createSharedStrings(strings) { const namespaces = [ ["xmlns", NAMESPACE["sst"]], ["count", strings.length], ["uniqueCount", strings.length], ]; const stringNodes = strings.map((string) => escapeXml /*xml*/ `${string}`); const xml = escapeXml /*xml*/ ` ${joinXmlNodes(stringNodes)} `; return createXMLFile(parseXML(xml), "xl/sharedStrings.xml", "sharedStrings"); } function createRelsFiles(relsFiles) { const XMLRelsFiles = []; for (const relFile of relsFiles) { const relationNodes = []; for (const rel of relFile.rels) { const attributes = [ ["Id", rel.id], ["Target", rel.target], ["Type", rel.type], ]; if (rel.targetMode) { attributes.push(["TargetMode", rel.targetMode]); } relationNodes.push(escapeXml /*xml*/ ` `); } const xml = escapeXml /*xml*/ ` ${joinXmlNodes(relationNodes)} `; XMLRelsFiles.push(createXMLFile(parseXML(xml), relFile.path)); } return XMLRelsFiles; } function createContentTypes(files) { const overrideNodes = []; // hard-code supported image mimetypes const imageDefaultNodes = Object.entries(IMAGE_MIMETYPE_TO_EXTENSION_MAPPING).map(([mimetype, extension]) => createDefaultXMLElement(extension, mimetype)); for (const file of files) { if ("contentType" in file && file.contentType) { overrideNodes.push(createOverride("/" + file.path, CONTENT_TYPES[file.contentType])); } } const relsAttributes = [ ["Extension", "rels"], ["ContentType", "application/vnd.openxmlformats-package.relationships+xml"], ]; const xmlAttributes = [ ["Extension", "xml"], ["ContentType", "application/xml"], ]; const xml = escapeXml /*xml*/ ` ${joinXmlNodes(Object.values(imageDefaultNodes))} ${joinXmlNodes(overrideNodes)} `; return createXMLFile(parseXML(xml), "[Content_Types].xml"); } function createRelRoot() { const attributes = [ ["Id", "rId1"], ["Type", XLSX_RELATION_TYPE.document], ["Target", "xl/workbook.xml"], ]; const xml = escapeXml /*xml*/ ` `; return createXMLFile(parseXML(xml), "_rels/.rels"); } /** * Excel sheet names are maximum 31 characters while o-spreadsheet do not have this limit. * This method converts the sheet names to be within the 31 characters limit. * The cells/charts referencing this sheet will be updated accordingly. */ function fixLengthySheetNames(data) { const nameMapping = {}; const newNames = new Set(); for (const sheet of data.sheets) { let newName = sheet.name.slice(0, 31); let i = 1; while (newNames.has(newName)) { newName = newName.slice(0, 31 - String(i).length) + i++; } newNames.add(newName); if (newName !== sheet.name) { nameMapping[sheet.name] = newName; sheet.name = newName; } } if (!Object.keys(nameMapping).length) { return data; } const sheetWithNewNames = Object.keys(nameMapping).sort((a, b) => b.length - a.length); let stringifiedData = JSON.stringify(data); for (const sheetName of sheetWithNewNames) { const regex = new RegExp(`'?${escapeRegExp(sheetName)}'?!`, "g"); stringifiedData = stringifiedData.replaceAll(regex, (match) => { const newName = nameMapping[sheetName]; return match.replace(sheetName, newName); }); } return JSON.parse(stringifiedData); } /** Excel files do not support tables with a single row the defined range * Since those tables are not really useful (no filtering/limited styling) * This function filters out all tables with a single row. */ function purgeSingleRowTables(data) { for (const sheet of data.sheets) { sheet.tables = sheet.tables.filter((table) => zoneToDimension(toZone(table.range)).numberOfRows > 1); } return data; } var Status; (function (Status) { Status[Status["Ready"] = 0] = "Ready"; Status[Status["Running"] = 1] = "Running"; Status[Status["RunningCore"] = 2] = "RunningCore"; Status[Status["Finalizing"] = 3] = "Finalizing"; })(Status || (Status = {})); class Model extends EventBus { corePlugins = []; featurePlugins = []; statefulUIPlugins = []; coreViewsPlugins = []; range; session; /** * In a collaborative context, some commands can be replayed, we have to ensure * that these commands are not replayed on the UI plugins. */ isReplayingCommand = false; /** * A plugin can draw some contents on the canvas. But even better: it can do * so multiple times. The order of the render calls will determine a list of * "layers" (i.e., earlier calls will be obviously drawn below later calls). * This list simply keeps the renderers+layer information so the drawing code * can just iterate on it */ renderers = {}; /** * Internal status of the model. Important for command handling coordination */ status = 0 /* Status.Ready */; /** * The config object contains some configuration flag and callbacks */ config; corePluginConfig; uiPluginConfig; state; selection; /** * Getters are the main way the rest of the UI read data from the model. Also, * it is shared between all plugins, so they can also communicate with each * other. */ getters; /** * Getters that are accessible from the core plugins. It's a subset of `getters`, * without the UI getters */ coreGetters; uuidGenerator; handlers = []; uiHandlers = []; coreHandlers = []; constructor(data = {}, config = {}, stateUpdateMessages = [], uuidGenerator = new UuidGenerator(), verboseImport = false) { const start = performance.now(); console.debug("##### Model creation #####"); super(); setDefaultTranslationMethod(); stateUpdateMessages = repairInitialMessages(data, stateUpdateMessages); const workbookData = load(data, verboseImport); this.state = new StateObserver(); this.uuidGenerator = uuidGenerator; this.config = this.setupConfig(config); this.session = this.setupSession(workbookData.revisionId); this.coreGetters = {}; this.range = new RangeAdapter(this.coreGetters); this.coreGetters.getRangeString = this.range.getRangeString.bind(this.range); this.coreGetters.getRangeFromSheetXC = this.range.getRangeFromSheetXC.bind(this.range); this.coreGetters.createAdaptedRanges = this.range.createAdaptedRanges.bind(this.range); this.coreGetters.getRangeDataFromXc = this.range.getRangeDataFromXc.bind(this.range); this.coreGetters.getRangeDataFromZone = this.range.getRangeDataFromZone.bind(this.range); this.coreGetters.getRangeFromRangeData = this.range.getRangeFromRangeData.bind(this.range); this.coreGetters.getRangeFromZone = this.range.getRangeFromZone.bind(this.range); this.coreGetters.recomputeRanges = this.range.recomputeRanges.bind(this.range); this.coreGetters.isRangeValid = this.range.isRangeValid.bind(this.range); this.coreGetters.extendRange = this.range.extendRange.bind(this.range); this.coreGetters.getRangesUnion = this.range.getRangesUnion.bind(this.range); this.coreGetters.removeRangesSheetPrefix = this.range.removeRangesSheetPrefix.bind(this.range); this.getters = { isReadonly: () => this.config.mode === "readonly" || this.config.mode === "dashboard", isDashboard: () => this.config.mode === "dashboard", }; // Initiate stream processor this.selection = new SelectionStreamProcessorImpl(this.getters); this.coreHandlers.push(this.range); this.handlers.push(this.range); this.corePluginConfig = this.setupCorePluginConfig(); this.uiPluginConfig = this.setupUiPluginConfig(); // registering plugins for (let Plugin of corePluginRegistry.getAll()) { this.setupCorePlugin(Plugin, workbookData); } Object.assign(this.getters, this.coreGetters); this.session.loadInitialMessages(stateUpdateMessages); for (let Plugin of coreViewsPluginRegistry.getAll()) { const plugin = this.setupUiPlugin(Plugin); this.coreViewsPlugins.push(plugin); this.handlers.push(plugin); this.uiHandlers.push(plugin); this.coreHandlers.push(plugin); } for (let Plugin of statefulUIPluginRegistry.getAll()) { const plugin = this.setupUiPlugin(Plugin); this.statefulUIPlugins.push(plugin); this.handlers.push(plugin); this.uiHandlers.push(plugin); } for (let Plugin of featurePluginRegistry.getAll()) { const plugin = this.setupUiPlugin(Plugin); this.featurePlugins.push(plugin); this.handlers.push(plugin); this.uiHandlers.push(plugin); } this.uuidGenerator.setIsFastStrategy(false); // starting plugins this.dispatch("START"); // Model should be the last permanent subscriber in the list since he should render // after all changes have been applied to the other subscribers (plugins) this.selection.observe(this, { handleEvent: () => this.trigger("update"), }); // This should be done after construction of LocalHistory due to order of // events this.setupSessionEvents(); this.joinSession(); if (config.snapshotRequested) { const startSnapshot = performance.now(); console.debug("Snapshot requested"); this.session.snapshot(this.exportData()); this.garbageCollectExternalResources(); console.debug("Snapshot taken in", performance.now() - startSnapshot, "ms"); } // mark all models as "raw", so they will not be turned into reactive objects // by owl, since we do not rely on reactivity markRaw(this); console.debug("Model created in", performance.now() - start, "ms"); console.debug("######"); } joinSession() { this.session.join(this.config.client); } async leaveSession() { const snapshot = this.getters.isReadonly() ? undefined : lazy(() => this.exportData()); await this.session.leave(snapshot); } setupUiPlugin(Plugin) { const plugin = new Plugin(this.uiPluginConfig); for (let name of Plugin.getters) { if (!(name in plugin)) { throw new Error(`Invalid getter name: ${name} for plugin ${plugin.constructor}`); } if (name in this.getters) { throw new Error(`Getter "${name}" is already defined.`); } this.getters[name] = plugin[name].bind(plugin); } for (const layer of Plugin.layers) { if (!this.renderers[layer]) { this.renderers[layer] = []; } this.renderers[layer].push(plugin); } return plugin; } /** * Initialize and properly configure a plugin. * * This method is private for now, but if the need arise, there is no deep * reason why the model could not add dynamically a plugin while it is running. */ setupCorePlugin(Plugin, data) { const plugin = new Plugin(this.corePluginConfig); for (let name of Plugin.getters) { if (!(name in plugin)) { throw new Error(`Invalid getter name: ${name} for plugin ${plugin.constructor}`); } if (name in this.coreGetters) { throw new Error(`Getter "${name}" is already defined.`); } this.coreGetters[name] = plugin[name].bind(plugin); } plugin.import(data); this.corePlugins.push(plugin); this.coreHandlers.push(plugin); this.handlers.push(plugin); } onRemoteRevisionReceived({ commands }) { for (let command of commands) { const previousStatus = this.status; this.status = 2 /* Status.RunningCore */; this.dispatchToHandlers(this.statefulUIPlugins, command); this.status = previousStatus; } this.finalize(); } setupSession(revisionId) { const session = new Session(buildRevisionLog({ initialRevisionId: revisionId, recordChanges: this.state.recordChanges.bind(this.state), dispatch: (command) => { const result = this.checkDispatchAllowed(command); if (!result.isSuccessful) { return; } this.isReplayingCommand = true; this.dispatchToHandlers(this.coreHandlers, command); this.isReplayingCommand = false; }, }), this.config.transportService, revisionId); return session; } setupSessionEvents() { this.session.on("remote-revision-received", this, this.onRemoteRevisionReceived); this.session.on("revision-undone", this, ({ commands }) => { this.dispatchFromCorePlugin("UNDO", { commands }); this.finalize(); }); this.session.on("revision-redone", this, ({ commands }) => { this.dispatchFromCorePlugin("REDO", { commands }); this.finalize(); }); // How could we improve communication between the session and UI? // It feels weird to have the model piping specific session events to its own bus. this.session.on("unexpected-revision-id", this, () => this.trigger("unexpected-revision-id")); this.session.on("collaborative-event-received", this, () => { this.trigger("update"); }); } setupConfig(config) { const client = config.client || { id: this.uuidGenerator.uuidv4(), name: _t("Anonymous").toString(), }; const transportService = config.transportService || new LocalTransportService(); return { ...config, mode: config.mode || "normal", custom: config.custom || {}, external: this.setupExternalConfig(config.external || {}), transportService, client, moveClient: () => { }, snapshotRequested: false, notifyUI: (payload) => this.trigger("notify-ui", payload), raiseBlockingErrorUI: (text) => this.trigger("raise-error-ui", { text }), customColors: config.customColors || [], }; } setupExternalConfig(external) { const loadLocales = external.loadLocales || (() => Promise.resolve(DEFAULT_LOCALES)); return { ...external, loadLocales, }; } setupCorePluginConfig() { return { getters: this.coreGetters, stateObserver: this.state, range: this.range, dispatch: this.dispatchFromCorePlugin, canDispatch: this.canDispatch, uuidGenerator: this.uuidGenerator, custom: this.config.custom, external: this.config.external, }; } setupUiPluginConfig() { return { getters: this.getters, stateObserver: this.state, dispatch: this.dispatch, canDispatch: this.canDispatch, selection: this.selection, moveClient: this.session.move.bind(this.session), custom: this.config.custom, uiActions: this.config, session: this.session, defaultCurrency: this.config.defaultCurrency, customColors: this.config.customColors || [], }; } // --------------------------------------------------------------------------- // Command Handling // --------------------------------------------------------------------------- /** * Check if the given command is allowed by all the plugins and the history. */ checkDispatchAllowed(command) { const results = isCoreCommand(command) ? this.checkDispatchAllowedCoreCommand(command) : this.checkDispatchAllowedLocalCommand(command); if (results.some((r) => r !== "Success" /* CommandResult.Success */)) { return new DispatchResult(results.flat()); } return DispatchResult.Success; } checkDispatchAllowedCoreCommand(command) { const results = this.corePlugins.map((handler) => handler.allowDispatch(command)); results.push(this.range.allowDispatch(command)); return results; } checkDispatchAllowedLocalCommand(command) { const results = this.uiHandlers.map((handler) => handler.allowDispatch(command)); return results; } finalize() { this.status = 3 /* Status.Finalizing */; for (const h of this.handlers) { h.finalize(); } this.status = 0 /* Status.Ready */; this.trigger("command-finalized"); } /** * Check if a command can be dispatched, and returns a DispatchResult object with the possible * reasons the dispatch failed. */ canDispatch = (type, payload) => { return this.checkDispatchAllowed(createCommand(type, payload)); }; /** * The dispatch method is the only entry point to manipulate data in the model. * This is through this method that commands are dispatched most of the time * recursively until no plugin want to react anymore. * * CoreCommands dispatched from this function are saved in the history. * * Small technical detail: it is defined as an arrow function. There are two * reasons for this: * 1. this means that the dispatch method can be "detached" from the model, * which is done when it is put in the environment (see the Spreadsheet * component) * 2. This allows us to define its type by using the interface CommandDispatcher */ dispatch = (type, payload) => { const command = createCommand(type, payload); let status = this.status; if (this.getters.isReadonly() && !canExecuteInReadonly(command)) { return new DispatchResult("Readonly" /* CommandResult.Readonly */); } if (!this.session.canApplyOptimisticUpdate()) { return new DispatchResult("WaitingSessionConfirmation" /* CommandResult.WaitingSessionConfirmation */); } switch (status) { case 0 /* Status.Ready */: const result = this.checkDispatchAllowed(command); if (!result.isSuccessful) { this.trigger("update"); return result; } this.status = 1 /* Status.Running */; const { changes, commands } = this.state.recordChanges(() => { const start = performance.now(); if (isCoreCommand(command)) { this.state.addCommand(command); } this.dispatchToHandlers(this.handlers, command); this.finalize(); const time = performance.now() - start; if (time > 5) { console.debug(type, time, "ms"); } }); this.session.save(command, commands, changes); this.status = 0 /* Status.Ready */; this.trigger("update"); break; case 1 /* Status.Running */: if (isCoreCommand(command)) { const dispatchResult = this.checkDispatchAllowed(command); if (!dispatchResult.isSuccessful) { return dispatchResult; } this.state.addCommand(command); } this.dispatchToHandlers(this.handlers, command); break; case 3 /* Status.Finalizing */: throw new Error("Cannot dispatch commands in the finalize state"); case 2 /* Status.RunningCore */: if (isCoreCommand(command)) { throw new Error(`A UI plugin cannot dispatch ${type} while handling a core command`); } this.dispatchToHandlers(this.handlers, command); } return DispatchResult.Success; }; /** * Dispatch a command from a Core Plugin (or the History). * A command dispatched from this function is not added to the history. */ dispatchFromCorePlugin = (type, payload) => { const command = createCommand(type, payload); const previousStatus = this.status; this.status = 2 /* Status.RunningCore */; const handlers = this.isReplayingCommand ? this.coreHandlers : this.handlers; this.dispatchToHandlers(handlers, command); this.status = previousStatus; return DispatchResult.Success; }; /** * Dispatch the given command to the given handlers. * It will call `beforeHandle` and `handle` */ dispatchToHandlers(handlers, command) { const isCommandCore = isCoreCommand(command); for (const handler of handlers) { if (!isCommandCore && handler instanceof CorePlugin) { continue; } handler.beforeHandle(command); } for (const handler of handlers) { if (!isCommandCore && handler instanceof CorePlugin) { continue; } handler.handle(command); } this.trigger("command-dispatched", command); } // --------------------------------------------------------------------------- // Grid Rendering // --------------------------------------------------------------------------- /** * When the Grid component is ready (= mounted), it has a reference to its * canvas and need to draw the grid on it. This is then done by calling this * method, which will dispatch the call to all registered plugins. * * Note that nothing prevent multiple grid components from calling this method * each, or one grid component calling it multiple times with a different * context. This is probably the way we should do if we want to be able to * freeze a part of the grid (so, we would need to render different zones) */ drawLayer(context, layer) { const renderers = this.renderers[layer]; if (!renderers) { return; } for (const renderer of renderers) { context.ctx.save(); renderer.drawLayer(context, layer); context.ctx.restore(); } } // --------------------------------------------------------------------------- // Data Export // --------------------------------------------------------------------------- /** * As the name of this method strongly implies, it is useful when we need to * export date out of the model. */ exportData() { let data = createEmptyWorkbookData(); for (let handler of this.handlers) { if (handler instanceof CorePlugin) { handler.export(data); } } data.revisionId = this.session.getRevisionId() || DEFAULT_REVISION_ID; data = deepCopy(data); return data; } updateMode(mode) { // @ts-ignore For testing purposes only this.config.mode = mode; this.trigger("update"); } /** * Exports the current model data into a list of serialized XML files * to be zipped together as an *.xlsx file. * * We need to trigger a cell revaluation on every sheet and ensure that even * async functions are evaluated. * This prove to be necessary if the client did not trigger that evaluation in the first place * (e.g. open a document with several sheet and click on download before visiting each sheet) */ exportXLSX() { this.dispatch("EVALUATE_CELLS"); let data = createEmptyExcelWorkbookData(); for (let handler of this.handlers) { if (handler instanceof BasePlugin) { handler.exportForExcel(data); } } data = deepCopy(data); return getXLSX(data); } garbageCollectExternalResources() { for (const plugin of this.corePlugins) { plugin.garbageCollectExternalResources(); } } } function createCommand(type, payload = {}) { const command = deepCopy(payload); command.type = type; return command; } /** * We export here all entities that needs to be accessed publicly by Odoo. * * Note that the __info__ key is actually completed by the build process (see * the rollup.config.js file) */ const __info__ = {}; const SPREADSHEET_DIMENSIONS = { MIN_ROW_HEIGHT, MIN_COL_WIDTH, HEADER_HEIGHT, HEADER_WIDTH, TOPBAR_HEIGHT, BOTTOMBAR_HEIGHT, DEFAULT_CELL_WIDTH, DEFAULT_CELL_HEIGHT, SCROLLBAR_WIDTH, }; const registries = { autoCompleteProviders, autofillModifiersRegistry, autofillRulesRegistry, cellMenuRegistry, colMenuRegistry, errorTypes, linkMenuRegistry, functionRegistry, featurePluginRegistry, iconsOnCellRegistry, statefulUIPluginRegistry, coreViewsPluginRegistry, corePluginRegistry, rowMenuRegistry, sidePanelRegistry, figureRegistry, chartSidePanelComponentRegistry, chartComponentRegistry, chartRegistry, chartSubtypeRegistry, topbarMenuRegistry, topbarComponentRegistry, clickableCellRegistry, otRegistry, inverseCommandRegistry, urlRegistry, cellPopoverRegistry, numberFormatMenuRegistry, repeatLocalCommandTransformRegistry, repeatCommandTransformRegistry, clipboardHandlersRegistries, pivotRegistry, pivotTimeAdapterRegistry, pivotSidePanelRegistry, pivotNormalizationValueRegistry, supportedPivotPositionalFormulaRegistry, pivotToFunctionValueRegistry, migrationStepRegistry, }; const helpers = { arg, isEvaluationError, toBoolean, toJsDate, toNumber, toString, toNormalizedPivotValue, toXC, toZone, toUnboundedZone, toCartesian, numberToLetters, lettersToNumber, UuidGenerator, formatValue, createCurrencyFormat, ColorGenerator, computeTextWidth, createEmptyWorkbookData, createEmptySheet, createEmptyExcelSheet, getDefaultChartJsRuntime, chartFontColor, getChartAxisTitleRuntime, getChartAxisType, getTrendDatasetForBarChart, getTrendDatasetForLineChart, getFillingMode, rgbaToHex, colorToRGBA, positionToZone, isDefined, isMatrix, lazy, genericRepeat, createAction, createActions, transformRangeData, deepEquals, overlap, union, isInside, deepCopy, expandZoneOnInsertion, reduceZoneOnDeletion, unquote, getMaxObjectId, getFunctionsFromTokens, getFirstPivotFunction, getNumberOfPivotFunctions, parseDimension, isDateOrDatetimeField, makeFieldProposal, insertTokenAfterArgSeparator, insertTokenAfterLeftParenthesis, mergeContiguousZones, getPivotHighlights, pivotTimeAdapter, UNDO_REDO_PIVOT_COMMANDS, createPivotFormula, areDomainArgsFieldsValid, splitReference, formatTickValue, sanitizeSheetName, }; const links = { isMarkdownLink, parseMarkdownLink, markdownLink, openLink, urlRepresentation, }; const components = { Checkbox, Section, RoundColorPicker, ChartDataSeries, ChartErrorSection, ChartLabelRange, ChartTitle, ChartPanel, ChartFigure, ChartJsComponent, Grid, GridOverlay, ScorecardChart, LineConfigPanel, BarConfigPanel, PieChartDesignPanel, GenericChartConfigPanel, ChartWithAxisDesignPanel, GaugeChartConfigPanel, GaugeChartDesignPanel, ScorecardChartConfigPanel, ScorecardChartDesignPanel, ChartTypePicker, FigureComponent, Menu, Popover, SelectionInput, ValidationMessages, AddDimensionButton, PivotDimensionGranularity, PivotDimensionOrder, PivotDimension, PivotLayoutConfigurator, EditableName, PivotDeferUpdate, PivotTitleSection, CogWheelMenu, TextInput, SidePanelCollapsible, }; const hooks = { useDragAndDropListItems, useHighlights, useHighlightsOnHover, }; const stores = { useStoreProvider, DependencyContainer, CellPopoverStore, ComposerFocusStore, CellComposerStore, FindAndReplaceStore, HighlightStore, HoveredCellStore, ModelStore, NotificationStore, RendererStore, SelectionInputStore, SpreadsheetStore, useStore, useLocalStore, SidePanelStore, PivotSidePanelStore, PivotMeasureDisplayPanelStore, }; function addFunction(functionName, functionDescription) { functionRegistry.add(functionName, functionDescription); return { addFunction: (fName, fDescription) => addFunction(fName, fDescription), }; } const constants = { DEFAULT_LOCALE, HIGHLIGHT_COLOR, PIVOT_TABLE_CONFIG, TREND_LINE_XAXIS_ID, CHART_AXIS_CHOICES, ChartTerms, }; export { AbstractCellClipboardHandler, AbstractChart, AbstractFigureClipboardHandler, CellErrorType, CommandResult, CorePlugin, DispatchResult, EvaluationError, Model, PivotRuntimeDefinition, Registry, Revision, SPREADSHEET_DIMENSIONS, Spreadsheet, SpreadsheetPivotTable, UIPlugin, __info__, addFunction, addRenderingLayer, astToFormula, compile, compileTokens, components, constants, convertAstNodes, coreTypes, findCellInNewZone, functionCache, helpers, hooks, invalidateCFEvaluationCommands, invalidateDependenciesCommands, invalidateEvaluationCommands, iterateAstNodes, links, load, parse, parseTokens, readonlyAllowedCommands, registries, setDefaultSheetViewSize, setTranslationMethod, stores, tokenColors, tokenize }; __info__.version = "18.0.8"; __info__.date = "2024-12-19T07:55:19.099Z"; __info__.hash = "7cf34a618"; //# sourceMappingURL=o_spreadsheet.js.map