diff --git a/addons/spreadsheet/models/spreadsheet_mixin.py b/addons/spreadsheet/models/spreadsheet_mixin.py index 8bf630dc68a..8b5f0db56d8 100644 --- a/addons/spreadsheet/models/spreadsheet_mixin.py +++ b/addons/spreadsheet/models/spreadsheet_mixin.py @@ -90,6 +90,8 @@ class SpreadsheetMixin(models.AbstractModel): return stream.getvalue() def _get_file_content(self, file_path): + if file_path.startswith('data:image/png;base64,'): + return base64.b64decode(file_path.split(',')[1]) _, args = self.env['ir.http']._match(file_path) file_record = self.env['ir.binary']._find_record( xmlid=args.get('xmlid'), diff --git a/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js b/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js index aacbe22cdb6..be4200fb077 100644 --- a/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js +++ b/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js @@ -2,9 +2,9 @@ /** * This file is generated by o-spreadsheet build tools. Do not edit it. * @see https://github.com/odoo/o-spreadsheet - * @version 16.5.0-alpha.10 - * @date 2023-10-13T11:29:20.366Z - * @hash 30c3f691 + * @version 16.5.0-alpha.12 + * @date 2023-10-20T15:39:44.984Z + * @hash 430f0907 */ (function (exports, owl) { @@ -2059,6 +2059,7 @@ CommandResult["InvalidDataValidationCriterionValue"] = "InvalidDataValidationCriterionValue"; CommandResult["InvalidNumberOfCriterionValues"] = "InvalidNumberOfCriterionValues"; CommandResult["BlockingValidationRule"] = "BlockingValidationRule"; + CommandResult["InvalidCopyPasteSelection"] = "InvalidCopyPasteSelection"; })(exports.CommandResult || (exports.CommandResult = {})); const DEFAULT_LOCALES = [ @@ -4500,6 +4501,69 @@ 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]); + } + 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 - textHeight; + break; + case "middle": + underlineY += boxHeight / 2 - strokeWidth; + break; + case "alphabetic": + underlineY += 2 * strokeWidth; + strikeY -= textHeight / 2 - strokeWidth / 2; + 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 @@ -4563,6 +4627,29 @@ }; } + 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; + } + class ChartJsComponent extends owl.Component { static template = "o-spreadsheet-ChartJsComponent"; canvas = owl.useRef("graphContainer"); @@ -4585,15 +4672,7 @@ const runtime = this.chartRuntime; this.createChart(runtime.chartJsConfig); }); - let previousRuntime = this.chartRuntime; - owl.onPatched(() => { - const chartRuntime = this.chartRuntime; - if (deepEquals(previousRuntime, chartRuntime)) { - return; - } - this.updateChartJs(chartRuntime); - previousRuntime = chartRuntime; - }); + owl.useEffect(() => this.updateChartJs(this.chartRuntime), () => [this.chartRuntime]); } createChart(chartData) { const canvas = this.canvas.el; @@ -4630,145 +4709,606 @@ }; /** - * 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. + * AbstractChart is the class from which every Chart should inherit. + * The role of this class is to maintain the state of each chart. */ - 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; + 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 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 || ""); + + /** + * 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, + }; } - if (stackIndex < selectorStack.length - 1) { - part = generateSelector(stackIndex + 1, part); + } + const dataRange = adaptChartRange(ds.dataRange, applyChange); + if (dataRange === undefined || + getters.getRangeString(dataRange, dataRange.sheetId) === INCORRECT_RANGE_STRING) { + 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$1); + 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, dataSetsString, sheetId, dataSetsHaveTitle) { + const dataSets = []; + for (const sheetXC of dataSetsString) { + const dataRange = getters.getRangeFromSheetXC(sheetId, sheetXC); + 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)); } - 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 (zone.left === zone.right && zone.top === zone.bottom) { + // A single cell. If it's only the title, the dataset is not added. + if (!dataSetsHaveTitle) { + dataSets.push(createDataSet(getters, dataSetSheetId, zone, undefined)); + } } else { - if (tokens[0] === "{") { - generateRules(); - selectorStack.push(token.split(/\s*,\s*/)); - tokens.shift(); - } - if (tokens[0] === ";") { - rules.push(" " + token + ";"); - } + /* 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)); } } - return parts.join("\n"); + return dataSets; } - 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"; + 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)), + }; } - return `${strikethrough ? "line-through" : ""} ${underline ? "underline" : ""}`; } /** - * Convert the cell style to CSS properties. + * Transform a dataSet to a ExcelDataSet */ - function cellStyleToCss(style) { - const attributes = cellTextStyleToCss(style); - if (!style) - return attributes; - if (style.fillColor) { - attributes["background"] = style.fillColor; + 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 }; + } } - 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) { - const str = Object.entries(attributes) - .filter(([attName, attValue]) => attValue !== undefined) - .map(([attName, attValue]) => `${attName}:${attValue};`) - .join(" "); - return str; - } - 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, + const dataRange = ds.dataRange.clone({ zone: dataZone }); + return { + label: ds.labelCell ? getters.getRangeString(ds.labelCell, "forceSheetReference") : undefined, + range: getters.getRangeString(dataRange, "forceSheetReference"), + }; + } + 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"); + } + /** + * 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(toUnboundedZone) + .map((zone) => transformZone(zone, executed)) + .filter(isDefined$1) + .map(zoneToXc); + return { + ...definition, + labelRange, + dataSets, + }; + } + const GraphColors = [ + // the same colors as those used in odoo reporting + "rgb(31,119,180)", + "rgb(255,127,14)", + "rgb(174,199,232)", + "rgb(255,187,120)", + "rgb(44,160,44)", + "rgb(152,223,138)", + "rgb(214,39,40)", + "rgb(255,152,150)", + "rgb(148,103,189)", + "rgb(197,176,213)", + "rgb(140,86,75)", + "rgb(196,156,148)", + "rgb(227,119,194)", + "rgb(247,182,210)", + "rgb(127,127,127)", + "rgb(199,199,199)", + "rgb(188,189,34)", + "rgb(219,219,141)", + "rgb(23,190,207)", + "rgb(158,218,229)", + ]; + class ChartColors { + graphColorIndex = 0; + next() { + return GraphColors[this.graphColorIndex++ % GraphColors.length]; + } + } + /** + * 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)) !== undefined; + if (invalidRanges) { + return "InvalidDataSet" /* CommandResult.InvalidDataSet */; + } + const zones = definition.dataSets.map(toUnboundedZone); + 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; + } + // --------------------------------------------------------------------------- + // Scorecard + // --------------------------------------------------------------------------- + function getBaselineText(baseline, keyValue, baselineMode, locale) { + if (!baseline) { + return ""; + } + else if (baselineMode === "text" || + keyValue?.type !== CellValueType.number || + baseline.type !== CellValueType.number) { + return baseline.formattedValue; + } + else { + let diff = keyValue.value - baseline.value; + if (baselineMode === "percentage" && diff !== 0) { + diff = (diff / baseline.value) * 100; + } + if (baselineMode !== "percentage" && baseline.format) { + return formatValue(diff, { format: baseline.format, locale }); + } + const baselineStr = Math.abs(parseFloat(diff.toFixed(2))).toLocaleString(); + return baselineMode === "percentage" ? baselineStr + "%" : baselineStr; + } + } + function getBaselineColor(baseline, baselineMode, keyValue, colorUp, colorDown) { + if (baselineMode === "text" || + 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 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 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; + background; + baselineColorUp; + baselineColorDown; + fontColor; + type = "scorecard"; + constructor(definition, sheetId, getters) { + super(definition, sheetId, getters); + this.keyValue = createRange(getters, sheetId, definition.keyValue); + this.baseline = createRange(getters, sheetId, definition.baseline); + this.baselineMode = definition.baselineMode; + this.baselineDescr = definition.baselineDescr; + this.background = definition.background; + this.baselineColorUp = definition.baselineColorUp; + this.baselineColorDown = definition.baselineColorDown; + } + 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] : undefined, + title: context.title || "", + 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 { + background: this.background, + title: this.title, + range: this.keyValue ? [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, + }; + } + 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; + 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; + ctx.fillText(structure.title.text, structure.title.position.x, structure.title.position.y); + } + 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) { + ctx.font = structure.baselineDescr.style.font; + ctx.fillStyle = structure.baselineDescr.style.color; + ctx.fillText(structure.baselineDescr.text, structure.baselineDescr.position.x, structure.baselineDescr.position.y); + } + if (structure.key) { + ctx.font = structure.key.style.font; + ctx.fillStyle = structure.key.style.color; + drawDecoratedText(ctx, structure.key.text, structure.key.position, structure.key.style.underline, structure.key.style.strikethrough); + } + } + function createScorecardChartRuntime(chart, getters) { + let keyValue = ""; + let formattedKeyValue = ""; + let keyValueCell; + if (chart.keyValue) { + const keyValuePosition = { + sheetId: chart.keyValue.sheetId, + col: chart.keyValue.zone.left, + row: chart.keyValue.zone.top, + }; + keyValueCell = getters.getEvaluatedCell(keyValuePosition); + keyValue = String(keyValueCell.value); + formattedKeyValue = keyValueCell.formattedValue; + } + let baselineCell; + const baseline = chart.baseline; + if (baseline) { + const baselinePosition = { + sheetId: chart.baseline.sheetId, + col: chart.baseline.zone.left, + row: chart.baseline.zone.top, + }; + baselineCell = getters.getEvaluatedCell(baselinePosition); + } + const background = getters.getBackgroundOfSingleCellChart(chart.background, chart.keyValue); + const locale = getters.getLocale(); + return { + title: _t(chart.title), + keyValue: formattedKeyValue || keyValue, + baselineDisplay: getBaselineText(baselineCell, keyValueCell, chart.baselineMode, locale), + baselineArrow: getBaselineArrowDirection(baselineCell, keyValueCell, chart.baselineMode), + baselineColor: getBaselineColor(baselineCell, chart.baselineMode, keyValueCell, chart.baselineColorUp, chart.baselineColorDown), + baselineDescr: chart.baselineDescr ? _t(chart.baselineDescr) : "", + fontColor: chartFontColor(background), + background, + baselineStyle: chart.baselineMode !== "percentage" && 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, }; - return margins; } /* Sizes of boxes containing the texts, in percentage of the Chart size */ @@ -4784,64 +5324,96 @@ * Having a line heigh =1em (=font size) don't work, the font will overflow. */ const LINE_HEIGHT = 1.2; - css /* scss */ ` - div.o-scorecard { - font-family: ${DEFAULT_FONT}; - user-select: none; - background-color: white; - display: flex; - flex-direction: column; - box-sizing: border-box; - - .o-scorecard-content { - display: flex; - flex-direction: column; - height: 100%; - justify-content: center; - text-align: center; + function formatBaselineDescr(baselineDescr, baseline) { + const _baselineDescr = baselineDescr || ""; + return baseline && _baselineDescr ? " " + _baselineDescr : _baselineDescr; } - - .o-title-text { - text-align: left; - height: ${LINE_HEIGHT + "em"}; - line-height: ${LINE_HEIGHT + "em"}; - overflow: hidden; - white-space: nowrap; + function getDefaultContextFont(fontSize, bold = false, italic = false) { + const italicStr = italic ? "italic" : ""; + const weight = bold ? "bold" : ""; + return `${italicStr} ${weight} ${fontSize}px ${DEFAULT_FONT}`; } - - .o-key-text { - line-height: ${LINE_HEIGHT + "em"}; - height: ${LINE_HEIGHT + "em"}; - overflow: hidden; - white-space: nowrap; + function getScorecardConfiguration({ width, height }, runtime) { + const designer = new ScorecardChartConfigBuilder({ width, height }, runtime); + return designer.computeDesign(); } - - .o-cf-icon { - display: inline-block; - width: 0.65em; - height: 1em; - line-height: 1em; - padding-bottom: 0.07em; - padding-right: 3px; - } - - .o-baseline-text { - line-height: ${LINE_HEIGHT + "em"}; - height: ${LINE_HEIGHT + "em"}; - overflow: hidden; - white-space: nowrap; - - .o-baseline-text-description { - white-space: pre; - } - } - } -`; - let ScorecardChart$1 = class ScorecardChart extends owl.Component { - static template = "o-spreadsheet-ScorecardChart"; - ctx = document.createElement("canvas").getContext("2d"); - get runtime() { - return this.env.model.getters.getChartRuntime(this.props.figure.id); + 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(); + const { height: titleHeight } = this.getTextDimensions(this.title, style.title.font); + if (this.title) { + structure.title = { + text: this.title, + style: style.title, + position: { + x: this.chartPadding, + y: this.chartPadding + titleHeight, + }, + }; + } + const baselineArrowSize = style.baselineArrow?.size ?? 0; + const { height: baselineHeight, width: baselineWidth } = this.getTextDimensions(this.baseline, style.baselineValue.font); + const { width: baselineDescrWidth } = this.getTextDimensions(this.baselineDescr, style.baselineDescr.font); + structure.baseline = { + text: this.baseline, + style: style.baselineValue, + position: { + x: (this.width - baselineWidth - baselineDescrWidth + baselineArrowSize) / 2, + y: this.keyValue + ? this.height - 2 * this.chartPadding + : this.height - (this.height - titleHeight - baselineHeight) / 2 - this.chartPadding, + }, + }; + if (style.baselineArrow) { + 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) { + structure.baselineDescr = { + text: this.baselineDescr, + style: style.baselineDescr, + position: { + x: structure.baseline.position.x + baselineWidth, + y: structure.baseline.position.y, + }, + }; + } + const { height: keyHeight, width: keyWidth } = this.getTextDimensions(this.keyValue, style.keyValue.font); + if (this.keyValue) { + structure.key = { + text: this.keyValue, + style: style.keyValue, + position: { + x: (this.width - keyWidth) / 2, + y: (this.height - baselineHeight + titleHeight + keyHeight) / 2 - this.chartPadding, + }, + }; + } + return structure; } get title() { return this.runtime.title; @@ -4853,87 +5425,73 @@ return this.runtime.baselineDisplay; } get baselineDescr() { - const baselineDescr = this.runtime.baselineDescr || ""; - return this.baseline && baselineDescr ? " " + baselineDescr : baselineDescr; + return formatBaselineDescr(this.runtime.baselineDescr, this.baseline); } - get baselineArrowDirection() { + get baselineArrow() { return this.runtime.baselineArrow; } get backgroundColor() { return this.runtime.background; } - get primaryFontColor() { - return this.runtime.fontColor; - } get secondaryFontColor() { return relativeLuminance(this.backgroundColor) > 0.3 ? "#525252" : "#C8C8C8"; } - get figure() { - return this.props.figure; - } - get chartStyle() { - return cssPropertiesToCss({ - padding: `${this.chartPadding}px`, - background: this.backgroundColor, - }); - } - get chartContentStyle() { - return cssPropertiesToCss({ height: `${this.getDrawableHeight()}px` }); - } get chartPadding() { - return this.props.figure.width * CHART_PADDING_RATIO; + return this.width * CHART_PADDING_RATIO; } - translate(term) { - return _t(term); + getTextDimensions(text, font) { + this.context.font = font; + const measure = this.context.measureText(text); + return { + width: measure.width, + height: measure.actualBoundingBoxAscent + measure.actualBoundingBoxDescent, + }; } getTextStyles() { // If the widest text overflows horizontally, scale it down, and apply the same scaling factors to all the other fonts. - const maxLineWidth = this.props.figure.width * (1 - 2 * CHART_PADDING_RATIO); + const maxLineWidth = this.width * (1 - 2 * CHART_PADDING_RATIO); const widestElement = this.getWidestElement(); const baseFontSize = widestElement.getElementMaxFontSize(this.getDrawableHeight(), this); - const fontSizeMatchingWidth = getFontSizeMatchingWidth(maxLineWidth, baseFontSize, (fontSize) => widestElement.getElementWidth(fontSize, this.ctx, this)); + const fontSizeMatchingWidth = getFontSizeMatchingWidth(maxLineWidth, baseFontSize, (fontSize) => widestElement.getElementWidth(fontSize, this.context, this)); let scalingFactor = fontSizeMatchingWidth / baseFontSize; // Fonts sizes in px const keyFontSize = new KeyValueElement(this.runtime.keyValueStyle).getElementMaxFontSize(this.getDrawableHeight(), this) * scalingFactor; const baselineFontSize = new BaselineElement(this.runtime.baselineStyle).getElementMaxFontSize(this.getDrawableHeight(), this) * scalingFactor; return { - titleStyle: this.getTextStyle({ - fontSize: TITLE_FONT_SIZE, + title: { + font: getDefaultContextFont(TITLE_FONT_SIZE), color: this.secondaryFontColor, - }), - keyStyle: this.getTextStyle({ - fontSize: keyFontSize, - cellStyle: this.runtime.keyValueStyle, - color: this.primaryFontColor, - }), - baselineStyle: this.getTextStyle({ - fontSize: baselineFontSize, - }), - baselineValueStyle: this.getTextStyle({ - fontSize: baselineFontSize, - cellStyle: this.runtime.baselineStyle, - color: this.runtime.baselineColor || this.secondaryFontColor, - }), - baselineDescrStyle: this.getTextStyle({ - fontSize: baselineFontSize * BASELINE_DESCR_FONT_RATIO, + }, + keyValue: { + color: this.runtime.keyValueStyle?.textColor || this.runtime.fontColor, + font: getDefaultContextFont(keyFontSize, this.runtime.keyValueStyle?.bold, this.runtime.keyValueStyle?.italic), + strikethrough: this.runtime.keyValueStyle?.strikethrough, + underline: this.runtime.keyValueStyle?.underline, + }, + baselineValue: { + font: getDefaultContextFont(baselineFontSize, 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(baselineFontSize * BASELINE_DESCR_FONT_RATIO), color: this.secondaryFontColor, - }), + }, + baselineArrow: this.baselineArrow === "neutral" + ? undefined + : { + size: this.keyValue ? 0.8 * baselineFontSize : 0, + color: this.runtime.baselineColor || this.secondaryFontColor, + }, }; } - /** Return an CSS style string corresponding to the given arguments */ - getTextStyle(args) { - const cssAttributes = cellTextStyleToCss(args.cellStyle); - cssAttributes["font-size"] = `${args.fontSize}px`; - cssAttributes["display"] = "inline-block"; - if (!cssAttributes["color"] && args.color) { - cssAttributes["color"] = args.color; - } - return cssPropertiesToCss(cssAttributes); - } /** Get the height of the chart minus all the vertical paddings */ getDrawableHeight() { const verticalPadding = 2 * this.chartPadding; - let availableHeight = this.props.figure.height - verticalPadding; + let availableHeight = this.height - verticalPadding; availableHeight -= this.title ? TITLE_FONT_SIZE * LINE_HEIGHT : 0; return availableHeight; } @@ -4941,39 +5499,39 @@ getWidestElement() { const baseline = new BaselineElement(this.runtime.baselineStyle); const keyValue = new KeyValueElement(this.runtime.keyValueStyle); - return baseline.getElementWidth(BASELINE_BOX_HEIGHT_RATIO, this.ctx, this) > - keyValue.getElementWidth(KEY_BOX_HEIGHT_RATIO, this.ctx, this) + return baseline.getElementWidth(BASELINE_BOX_HEIGHT_RATIO, this.context, this) > + keyValue.getElementWidth(KEY_BOX_HEIGHT_RATIO, this.context, this) ? baseline : keyValue; } - }; + } class ScorecardScalableElement { style; constructor(style = {}) { this.style = style; } measureTextWidth(ctx, text, fontSize) { - const italic = this.style.italic ? "italic" : ""; - const weight = this.style.bold ? "bold" : ""; - ctx.font = `${italic} ${weight} ${fontSize}px ${DEFAULT_FONT}`; + ctx.font = getDefaultContextFont(fontSize, this.style.bold, this.style.italic); return ctx.measureText(text).width; } } class BaselineElement extends ScorecardScalableElement { getElementWidth(fontSize, ctx, chart) { - if (!chart.runtime) + if (!chart.runtime) { return 0; + } const baselineStr = chart.baseline; // Put mock text to simulate the width of the up/down arrow - const largeText = chart.baselineArrowDirection !== "neutral" ? "A " + baselineStr : baselineStr; + const largeText = chart.baselineArrow !== "neutral" ? "A " + baselineStr : baselineStr; let textWidth = this.measureTextWidth(ctx, largeText, fontSize); // Baseline descr font size should be smaller than baseline font size textWidth += this.measureTextWidth(ctx, chart.baselineDescr, fontSize * BASELINE_DESCR_FONT_RATIO); return textWidth; } getElementMaxFontSize(availableHeight, chart) { - if (!chart.runtime) + if (!chart.runtime) { return 0; + } const haveBaseline = chart.baseline !== "" || chart.baselineDescr; const maxHeight = haveBaseline ? BASELINE_BOX_HEIGHT_RATIO * availableHeight : 0; return maxHeight / LINE_HEIGHT; @@ -4981,20 +5539,42 @@ } class KeyValueElement extends ScorecardScalableElement { getElementWidth(fontSize, ctx, chart) { - if (!chart.runtime) + if (!chart.runtime) { return 0; + } const str = chart.keyValue || ""; return this.measureTextWidth(ctx, str, fontSize); } getElementMaxFontSize(availableHeight, chart) { - if (!chart.runtime) + if (!chart.runtime) { return 0; + } const haveBaseline = chart.baseline !== "" || chart.baselineDescr; const maxHeight = haveBaseline ? KEY_BOX_HEIGHT_RATIO * availableHeight : availableHeight; return maxHeight / LINE_HEIGHT; } } - ScorecardChart$1.props = { + + class ScorecardChart extends owl.Component { + static template = "o-spreadsheet-ScorecardChart"; + canvas = owl.useRef("chartContainer"); + get runtime() { + return this.env.model.getters.getChartRuntime(this.props.figure.id); + } + setup() { + owl.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); + } + } + ScorecardChart.props = { figure: Object, }; @@ -5507,6 +6087,148 @@ sequence: 40, }); + /** + * 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) { + const str = Object.entries(attributes) + .filter(([attName, attValue]) => attValue !== undefined) + .map(([attName, attValue]) => `${attName}:${attValue};`) + .join(" "); + return str; + } + 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 ERROR_TOOLTIP_MAX_HEIGHT = 80; const ERROR_TOOLTIP_WIDTH = 180; css /* scss */ ` @@ -6240,6 +6962,10 @@ cursor: pointer; user-select: none; + .o-menu-item-name { + min-width: 40%; + } + &.o-menu-root { display: flex; justify-content: space-between; @@ -16375,410 +17101,11 @@ return color; } - /** - * 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 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 }; - } - - /** - * 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) === INCORRECT_RANGE_STRING) { - 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$1); - 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, dataSetsString, sheetId, dataSetsHaveTitle) { - const dataSets = []; - for (const sheetXC of dataSetsString) { - const dataRange = getters.getRangeFromSheetXC(sheetId, sheetXC); - 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)); - } - } - else if (zone.left === zone.right && zone.top === zone.bottom) { - // A single cell. If it's only the title, the dataset is not added. - if (!dataSetsHaveTitle) { - dataSets.push(createDataSet(getters, dataSetSheetId, zone, undefined)); - } - } - else { - /* 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)); - } - } - 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 }); - return { - label: ds.labelCell ? getters.getRangeString(ds.labelCell, "forceSheetReference") : undefined, - range: getters.getRangeString(dataRange, "forceSheetReference"), - }; - } - 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"); - } - /** - * 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(toUnboundedZone) - .map((zone) => transformZone(zone, executed)) - .filter(isDefined$1) - .map(zoneToXc); - return { - ...definition, - labelRange, - dataSets, - }; - } - const GraphColors = [ - // the same colors as those used in odoo reporting - "rgb(31,119,180)", - "rgb(255,127,14)", - "rgb(174,199,232)", - "rgb(255,187,120)", - "rgb(44,160,44)", - "rgb(152,223,138)", - "rgb(214,39,40)", - "rgb(255,152,150)", - "rgb(148,103,189)", - "rgb(197,176,213)", - "rgb(140,86,75)", - "rgb(196,156,148)", - "rgb(227,119,194)", - "rgb(247,182,210)", - "rgb(127,127,127)", - "rgb(199,199,199)", - "rgb(188,189,34)", - "rgb(219,219,141)", - "rgb(23,190,207)", - "rgb(158,218,229)", - ]; - class ChartColors { - graphColorIndex = 0; - next() { - return GraphColors[this.graphColorIndex++ % GraphColors.length]; - } - } - /** - * 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)) !== undefined; - if (invalidRanges) { - return "InvalidDataSet" /* CommandResult.InvalidDataSet */; - } - const zones = definition.dataSets.map(toUnboundedZone); - 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; - } - // --------------------------------------------------------------------------- - // Scorecard - // --------------------------------------------------------------------------- - function getBaselineText(baseline, keyValue, baselineMode, locale) { - if (!baseline) { - return ""; - } - else if (baselineMode === "text" || - keyValue?.type !== CellValueType.number || - baseline.type !== CellValueType.number) { - return baseline.formattedValue; - } - else { - let diff = keyValue.value - baseline.value; - if (baselineMode === "percentage" && diff !== 0) { - diff = (diff / baseline.value) * 100; - } - if (baselineMode !== "percentage" && baseline.format) { - return formatValue(diff, { format: baseline.format, locale }); - } - const baselineStr = Math.abs(parseFloat(diff.toFixed(2))).toLocaleString(); - return baselineMode === "percentage" ? baselineStr + "%" : baselineStr; - } - } - function getBaselineColor(baseline, baselineMode, keyValue, colorUp, colorDown) { - if (baselineMode === "text" || - 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 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; - } - 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("Cannot paste merged cells over a frozen pane."), + frozenPaneOverlap: _t("This operation is not allowed due to an overlapping frozen pane."), }; function handlePasteResult(env, result) { if (!result.isSuccessful) { @@ -17121,6 +17448,51 @@ return index - 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) { + runtime.chartJsConfig.plugins = [backgroundColorChartJSPlugin]; + // @ts-ignore + const chart = new window.Chart(canvas, runtime.chartJsConfig); + 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; + } + return ""; + } + /** + * 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; @@ -18191,172 +18563,6 @@ return { chartJsConfig: config, background: chart.background || BACKGROUND_CHART_COLOR }; } - 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 */; - } - class ScorecardChart extends AbstractChart { - keyValue; - baseline; - baselineMode; - baselineDescr; - background; - baselineColorUp; - baselineColorDown; - fontColor; - type = "scorecard"; - constructor(definition, sheetId, getters) { - super(definition, sheetId, getters); - this.keyValue = createRange(getters, sheetId, definition.keyValue); - this.baseline = createRange(getters, sheetId, definition.baseline); - this.baselineMode = definition.baselineMode; - this.baselineDescr = definition.baselineDescr; - this.background = definition.background; - this.baselineColorUp = definition.baselineColorUp; - this.baselineColorDown = definition.baselineColorDown; - } - 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] : undefined, - title: context.title || "", - 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 { - background: this.background, - title: this.title, - range: this.keyValue ? [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, - }; - } - 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 createScorecardChartRuntime(chart, getters) { - let keyValue = ""; - let formattedKeyValue = ""; - let keyValueCell; - if (chart.keyValue) { - const keyValuePosition = { - sheetId: chart.keyValue.sheetId, - col: chart.keyValue.zone.left, - row: chart.keyValue.zone.top, - }; - keyValueCell = getters.getEvaluatedCell(keyValuePosition); - keyValue = String(keyValueCell.value); - formattedKeyValue = keyValueCell.formattedValue; - } - let baselineCell; - const baseline = chart.baseline; - if (baseline) { - const baselinePosition = { - sheetId: chart.baseline.sheetId, - col: chart.baseline.zone.left, - row: chart.baseline.zone.top, - }; - baselineCell = getters.getEvaluatedCell(baselinePosition); - } - const background = getters.getBackgroundOfSingleCellChart(chart.background, chart.keyValue); - const locale = getters.getLocale(); - return { - title: _t(chart.title), - keyValue: formattedKeyValue || keyValue, - baselineDisplay: getBaselineText(baselineCell, keyValueCell, chart.baselineMode, locale), - baselineArrow: getBaselineArrowDirection(baselineCell, keyValueCell, chart.baselineMode), - baselineColor: getBaselineColor(baselineCell, chart.baselineMode, keyValueCell, chart.baselineColorUp, chart.baselineColorDown), - baselineDescr: chart.baselineDescr ? _t(chart.baselineDescr) : "", - fontColor: chartFontColor(background), - background, - baselineStyle: chart.baselineMode !== "percentage" && 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, - }; - } - /** * This registry is intended to map a cell content (raw string) to * an instance of a cell. @@ -18394,11 +18600,11 @@ }); chartRegistry.add("scorecard", { match: (type) => type === "scorecard", - createChart: (definition, sheetId, getters) => new ScorecardChart(definition, sheetId, getters), + createChart: (definition, sheetId, getters) => new ScorecardChart$1(definition, sheetId, getters), getChartRuntime: createScorecardChartRuntime, - validateChartDefinition: (validator, definition) => ScorecardChart.validateChartDefinition(validator, definition), - transformDefinition: (definition, executed) => ScorecardChart.transformDefinition(definition, executed), - getChartDefinitionFromContextCreation: (context) => ScorecardChart.getDefinitionFromContextCreation(context), + validateChartDefinition: (validator, definition) => ScorecardChart$1.validateChartDefinition(validator, definition), + transformDefinition: (definition, executed) => ScorecardChart$1.transformDefinition(definition, executed), + getChartDefinitionFromContextCreation: (context) => ScorecardChart$1.getDefinitionFromContextCreation(context), name: _t("Scorecard"), sequence: 40, }); @@ -18417,7 +18623,7 @@ chartComponentRegistry.add("bar", ChartJsComponent); chartComponentRegistry.add("pie", ChartJsComponent); chartComponentRegistry.add("gauge", ChartJsComponent); - chartComponentRegistry.add("scorecard", ScorecardChart$1); + chartComponentRegistry.add("scorecard", ScorecardChart); /** * Registry intended to support usual currencies. It is mainly used to create @@ -19159,6 +19365,7 @@ // Helpers //------------------------------------------------------------------------------ function setFormatter(env, format) { + env.model.dispatch("CANCEL_EDITION"); env.model.dispatch("SET_FORMATTING", { sheetId: env.model.getters.getActiveSheetId(), target: env.model.getters.getSelectedZones(), @@ -20270,109 +20477,126 @@ icon: "o-spreadsheet-Icon.SPLIT_TEXT", }; + /** + * 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)), + }; + } const formatNumberAutomatic = { name: _t("Automatic"), execute: (env) => setFormatter(env, ""), isActive: (env) => isAutomaticFormatSelected(env), }; - const formatNumberNumber = { + const formatNumberNumber = createFormatActionSpec({ name: _t("Number"), - description: (env) => formatValue(1000.12, { - format: "#,##0.00", - locale: env.model.getters.getLocale(), - }), - execute: (env) => setFormatter(env, "#,##0.00"), - isActive: (env) => isFormatSelected(env, "#,##0.00"), - }; + descriptionValue: 1000.12, + format: "#,##0.00", + }); const formatPercent = { name: _t("Format as percent"), execute: FORMAT_PERCENT_ACTION, icon: "o-spreadsheet-Icon.PERCENT", }; - const formatNumberPercent = { + const formatNumberPercent = createFormatActionSpec({ name: _t("Percent"), - description: (env) => formatValue(0.1012, { - format: "0.00%", - locale: env.model.getters.getLocale(), - }), - execute: FORMAT_PERCENT_ACTION, - isActive: (env) => isFormatSelected(env, "0.00%"), - }; - const formatNumberCurrency = { + descriptionValue: 0.1012, + format: "0.00%", + }); + const formatNumberCurrency = createFormatActionSpec({ name: _t("Currency"), - description: (env) => formatValue(1000.12, { - format: env.model.config.defaultCurrencyFormat, - locale: env.model.getters.getLocale(), - }), - execute: (env) => setFormatter(env, env.model.config.defaultCurrencyFormat), - isActive: (env) => isFormatSelected(env, env.model.config.defaultCurrencyFormat), - }; + descriptionValue: 1000.12, + format: (env) => env.model.config.defaultCurrencyFormat, + }); const formatNumberCurrencyRounded = { - name: _t("Currency rounded"), - description: (env) => formatValue(1000, { - format: roundFormat(env.model.config.defaultCurrencyFormat), - locale: env.model.getters.getLocale(), + ...createFormatActionSpec({ + name: _t("Currency rounded"), + descriptionValue: 1000, + format: (env) => roundFormat(env.model.config.defaultCurrencyFormat), }), - execute: (env) => setFormatter(env, roundFormat(env.model.config.defaultCurrencyFormat)), - isActive: (env) => isFormatSelected(env, roundFormat(env.model.config.defaultCurrencyFormat)), isVisible: (env) => { const currencyFormat = env.model.config.defaultCurrencyFormat; return currencyFormat !== roundFormat(currencyFormat); }, }; + 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 = { + const formatNumberDate = createFormatActionSpec({ name: _t("Date"), - description: (env) => { - const locale = env.model.getters.getLocale(); - return formatValue(parseLiteral("9/26/2023", DEFAULT_LOCALE), { - format: locale.dateFormat, - locale, - }); - }, - execute: (env) => setFormatter(env, env.model.getters.getLocale().dateFormat), - isActive: (env) => isFormatSelected(env, env.model.getters.getLocale().dateFormat), - }; - const formatNumberTime = { + descriptionValue: EXAMPLE_DATE, + format: (env) => env.model.getters.getLocale().dateFormat, + }); + const formatNumberTime = createFormatActionSpec({ name: _t("Time"), - description: (env) => { - const locale = env.model.getters.getLocale(); - return formatValue(parseLiteral("9/26/2023 10:43:00 PM", DEFAULT_LOCALE), { - format: locale.timeFormat, - locale, - }); - }, - execute: (env) => setFormatter(env, env.model.getters.getLocale().timeFormat), - isActive: (env) => isFormatSelected(env, env.model.getters.getLocale().timeFormat), - }; - const formatNumberDateTime = { + descriptionValue: EXAMPLE_DATE, + format: (env) => env.model.getters.getLocale().timeFormat, + }); + const formatNumberDateTime = createFormatActionSpec({ name: _t("Date time"), - description: (env) => { + descriptionValue: EXAMPLE_DATE, + format: (env) => { const locale = env.model.getters.getLocale(); - return formatValue(parseLiteral("9/26/2023 22:43:00", DEFAULT_LOCALE), { - format: locale.dateFormat + " " + locale.timeFormat, - locale, - }); + return locale.dateFormat + " " + locale.timeFormat; }, - execute: (env) => { - const locale = env.model.getters.getLocale(); - setFormatter(env, locale.dateFormat + " " + locale.timeFormat); - }, - isActive: (env) => { - const locale = env.model.getters.getLocale(); - return isFormatSelected(env, locale.dateFormat + " " + locale.timeFormat); - }, - }; - const formatNumberDuration = { + }); + const formatNumberDuration = createFormatActionSpec({ name: _t("Duration"), - description: "27:51:38", - execute: (env) => setFormatter(env, "hhhh:mm:ss"), - isActive: (env) => isFormatSelected(env, "hhhh:mm:ss"), + descriptionValue: "27:51:38", + format: "hhhh:mm:ss", + }); + 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", @@ -20625,9 +20849,16 @@ formatNumberCurrencyRounded: formatNumberCurrencyRounded, formatNumberDate: formatNumberDate, formatNumberDateTime: formatNumberDateTime, + formatNumberDayAndFullMonth: formatNumberDayAndFullMonth, + formatNumberDayAndShortMonth: formatNumberDayAndShortMonth, formatNumberDuration: formatNumberDuration, + formatNumberFullDateTime: formatNumberFullDateTime, + formatNumberFullMonth: formatNumberFullMonth, + formatNumberFullWeekDayAndMonth: formatNumberFullWeekDayAndMonth, formatNumberNumber: formatNumberNumber, formatNumberPercent: formatNumberPercent, + formatNumberShortMonth: formatNumberShortMonth, + formatNumberShortWeekDay: formatNumberShortWeekDay, formatNumberTime: formatNumberTime, formatPercent: formatPercent, formatStrikethrough: formatStrikethrough, @@ -20638,6 +20869,7 @@ formatWrappingOverflow: formatWrappingOverflow, formatWrappingWrap: formatWrappingWrap, incraseDecimalPlaces: incraseDecimalPlaces, + moreFormats: moreFormats, textColor: textColor }); @@ -21095,6 +21327,10 @@ ...formatNumberDuration, sequence: 100, separator: true, + }) + .add("more_formats", { + ...moreFormats, + sequence: 110, }); const formatNumberMenuItemSpec = { name: _t("More formats"), @@ -22996,7 +23232,7 @@ }); } canUpdateChart(figureId, updateDefinition) { - if (figureId !== this.figureId) { + if (figureId !== this.figureId || !this.env.model.getters.isChartDefined(figureId)) { return; } const definition = { @@ -24363,6 +24599,44 @@ onCloseSidePanel: Function, }; + css /* scss */ ` + .o-more-formats-panel { + .format-preview { + height: 48px; + background-color: white; + + &: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, + ]); + class MoreFormatsPanel extends owl.Component { + static template = "o-spreadsheet-MoreFormatsPanel"; + get dateFormatsActions() { + return DATE_FORMAT_ACTIONS; + } + } + MoreFormatsPanel.props = { + onCloseSidePanel: Function, + }; + css /* scss */ ` .o-checkbox-selection { height: 150px; @@ -25820,6 +26094,10 @@ title: _t("Data validation"), Body: DataValidationPanel, }); + sidePanelRegistry.add("MoreFormats", { + title: _t("More date formats"), + Body: MoreFormatsPanel, + }); class TopBarComponentRegistry extends Registry { mapping = {}; @@ -29853,6 +30131,8 @@ 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"), @@ -32287,12 +32567,15 @@ }; } function xmlEscape(str) { - return String(str) + return (String(str) .replace(/\&/g, "&") .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(" ")); @@ -35422,24 +35705,6 @@ } } } - exportForExcel(data) { - for (let sheet of data.sheets) { - const sheetFigures = this.getters.getFigures(sheet.id); - const figures = []; - for (let figure of sheetFigures) { - if (figure && figure.tag === "chart") { - const figureData = this.charts[figure.id]?.getDefinitionForExcel(); - if (figureData) { - figures.push({ - ...figure, - data: figureData, - }); - } - } - } - sheet.charts = figures; - } - } // --------------------------------------------------------------------------- // Private // --------------------------------------------------------------------------- @@ -36961,6 +37226,9 @@ } 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) { @@ -36974,7 +37242,7 @@ } } } - sheet.images = images; + sheet.images = [...sheet.images, ...images]; } } getAllImages() { @@ -40314,6 +40582,47 @@ const style = this.getters.getCellComputedStyle({ sheetId, col, row }); return style.fillColor || BACKGROUND_CHART_COLOR; } + 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); + 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 { @@ -41930,7 +42239,7 @@ { match: isZoneDependent, fn: transformZoneDependentCommand }, { match: isPositionDependent, fn: transformPosition }, { match: isHeadersDependant, fn: transformHeaders }, - { match: isRangeDependant, fn: transformRangeData }, + { match: isRangeDependant, fn: transformRangesDependentCommand }, ]; /** * Get the result of applying the operation transformations on the given command @@ -42026,23 +42335,13 @@ } return "IGNORE_COMMAND"; } - function transformRangeData(toTransform, executed) { + function transformRangesDependentCommand(toTransform, executed) { if (!("sheetId" in executed)) { return toTransform; } - const ranges = []; - const deletedSheet = executed.type === "DELETE_SHEET" && executed.sheetId; - for (const range of toTransform.ranges) { - if (range._sheetId !== executed.sheetId) { - ranges.push({ ...range, _zone: range._zone }); - } - else { - const newZone = transformZone(range._zone, executed); - if (newZone && deletedSheet !== range._sheetId) { - ranges.push({ ...range, _zone: newZone }); - } - } - } + const ranges = toTransform.ranges + .map((range) => transformRangeData(range, executed)) + .filter(isDefined$1); if (!ranges.length) { return "IGNORE_COMMAND"; } @@ -43187,6 +43486,10 @@ if (!newRangesXC) { return; } + if (newRangesXC.length === 0) { + this.dispatch("REMOVE_CONDITIONAL_FORMAT", { id: cf.id, sheetId }); + return; + } this.dispatch("ADD_CONDITIONAL_FORMAT", { cf: { id: cf.id, @@ -44102,7 +44405,7 @@ } } drawTexts(renderingContext) { - const { ctx, thinLineWidth } = renderingContext; + const { ctx } = renderingContext; ctx.textBaseline = "top"; let currentFont; for (let box of this.boxes) { @@ -44147,23 +44450,7 @@ // use the horizontal and the vertical start points to: // fill text / fill strikethrough / fill underline for (let brokenLine of box.content.textLines) { - ctx.fillText(brokenLine, Math.round(x), Math.round(y)); - if (style.strikethrough || style.underline) { - const lineWidth = computeTextWidth(ctx, brokenLine, style); - let _x = x; - if (align === "right") { - _x -= lineWidth; - } - else if (align === "center") { - _x -= lineWidth / 2; - } - if (style.strikethrough) { - ctx.fillRect(_x, y + textLineHeight / 2, lineWidth, 2.6 * thinLineWidth); - } - if (style.underline) { - ctx.fillRect(_x, y + textLineHeight + 1, lineWidth, 1.3 * thinLineWidth); - } - } + drawDecoratedText(ctx, brokenLine, { x: Math.round(x), y: Math.round(y) }, style.underline, style.strikethrough); y += MIN_CELL_TEXT_MARGIN + textLineHeight; } if (box.clipRect) { @@ -46248,6 +46535,20 @@ const state = new ClipboardOsState(cmd.text, this.getters, this.dispatch, this.selection); return state.isPasteAllowed(cmd.target, { pasteOption: cmd.pasteOption }); } + 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 state = this.getClipboardStateForCopyCells(cut, "CUT"); @@ -46287,6 +46588,40 @@ this.status = "invisible"; } 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, + }; + const state = this.getClipboardStateForCopyCells([copyTarget], "COPY"); + state.paste([zone], { + pasteOption: undefined, + shouldPasteCF: true, + 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, + }; + const state = this.getClipboardStateForCopyCells([copyTarget], "COPY"); + state.paste([zone], { + pasteOption: undefined, + shouldPasteCF: true, + selectTarget: true, + }); + } + break; case "CLEAN_CLIPBOARD_HIGHLIGHT": this.status = "invisible"; break; @@ -46966,7 +47301,15 @@ return cell?.content || ""; case CellValueType.number: if (format && isDateTimeFormat(format)) { - return formattedValue; + 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 + : locale.dateFormat + " " + locale.timeFormat; + return formatValue(value, { locale, format: timeFormat }); } return this.numberComposerContent(value, format, locale); } @@ -47818,11 +48161,19 @@ } } getStatisticFnResults() { - // get deduplicated cells in zones - const cells = new Set(this.gridSelection.zones - .map((zone) => this.getters.getEvaluatedCellsInZone(this.getters.getActiveSheetId(), zone)) - .flat() - .filter((cell) => cell.type !== CellValueType.empty)); + const sheetId = this.getters.getActiveSheetId(); + const cells = new Set(); + for (const zone of this.gridSelection.zones) { + for (const { col, row } of positions(zone)) { + if (this.getters.isRowHidden(sheetId, row) || this.getters.isColHidden(sheetId, col)) { + continue; // Skip hidden cells + } + const evaluatedCell = this.getters.getEvaluatedCell({ sheetId, col, row }); + if (evaluatedCell.type !== CellValueType.empty) { + cells.add(evaluatedCell); + } + } + } let cellsTypes = new Set(); let cellsValues = []; for (let cell of cells) { @@ -55076,8 +55427,10 @@ toString, toXC, toZone, + toUnboundedZone, toCartesian, numberToLetters, + lettersToNumber, UuidGenerator, formatValue, createCurrencyFormat, @@ -55099,6 +55452,7 @@ genericRepeat, createAction, createActions, + transformRangeData, }; const links = { isMarkdownLink, @@ -55113,7 +55467,7 @@ ChartJsComponent, Grid, GridOverlay, - ScorecardChart: ScorecardChart$1, + ScorecardChart, LineConfigPanel, LineBarPieDesignPanel, BarConfigPanel, @@ -55124,6 +55478,7 @@ ScorecardChartDesignPanel, FigureComponent, Menu, + SelectionInput, }; const hooks = { useDragAndDropListItems, @@ -55174,9 +55529,9 @@ exports.tokenize = tokenize; - __info__.version = "16.5.0-alpha.10"; - __info__.date = "2023-10-13T11:29:20.366Z"; - __info__.hash = "30c3f691"; + __info__.version = "16.5.0-alpha.12"; + __info__.date = "2023-10-20T15:39:44.984Z"; + __info__.hash = "430f0907"; })(this.o_spreadsheet = this.o_spreadsheet || {}, owl); diff --git a/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.xml b/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.xml index 96207ead698..417f82eaf46 100644 --- a/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.xml +++ b/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.xml @@ -1,9 +1,9 @@ @@ -678,35 +678,7 @@ -
- -
-
-
- -
- - - - - - - - - - - - - - - - - - -
-
-
-
+ @@ -1521,6 +1493,7 @@ @@ -1927,7 +1900,7 @@ - + @@ -1940,7 +1913,7 @@ - + @@ -1948,7 +1921,7 @@ - + @@ -2220,10 +2193,10 @@ - + @@ -2473,7 +2446,7 @@
+ +
+
+ + + + +
+
+
+