diff --git a/packages/common/src/colors.ts b/packages/common/src/colors.ts index 763510646b..567093c7d9 100644 --- a/packages/common/src/colors.ts +++ b/packages/common/src/colors.ts @@ -240,22 +240,21 @@ export const DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE = { // ----------------------------------------------------------------------------- // !!!MUST BE WITHOUT GRAY, TRANSPARENT AND BLACK!!! -export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) => - [ - // 2nd row - COLOR_PALETTE.cyan[index], - COLOR_PALETTE.blue[index], - COLOR_PALETTE.violet[index], - COLOR_PALETTE.grape[index], - COLOR_PALETTE.pink[index], +export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) => [ + // 2nd row + COLOR_PALETTE.cyan[index], + COLOR_PALETTE.blue[index], + COLOR_PALETTE.violet[index], + COLOR_PALETTE.grape[index], + COLOR_PALETTE.pink[index], - // 3rd row - COLOR_PALETTE.green[index], - COLOR_PALETTE.teal[index], - COLOR_PALETTE.yellow[index], - COLOR_PALETTE.orange[index], - COLOR_PALETTE.red[index], - ] as const; + // 3rd row + COLOR_PALETTE.green[index], + COLOR_PALETTE.teal[index], + COLOR_PALETTE.yellow[index], + COLOR_PALETTE.orange[index], + COLOR_PALETTE.red[index], +]; // ----------------------------------------------------------------------------- // other helpers diff --git a/packages/element/src/types.ts b/packages/element/src/types.ts index 8067342a20..58e4469706 100644 --- a/packages/element/src/types.ts +++ b/packages/element/src/types.ts @@ -15,7 +15,7 @@ import type { ValueOf, } from "@excalidraw/common/utility-types"; -export type ChartType = "bar" | "line"; +export type ChartType = "bar" | "line" | "radar"; export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag"; export type FontFamilyKeys = keyof typeof FONT_FAMILY; export type FontFamilyValues = typeof FONT_FAMILY[FontFamilyKeys]; diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index f9c57a2851..d6986a17af 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -118,7 +118,6 @@ export const actionClearCanvas = register({ gridStep: appState.gridStep, gridModeEnabled: appState.gridModeEnabled, stats: appState.stats, - pasteDialog: appState.pasteDialog, activeTool: appState.activeTool.type === "image" ? { diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 087b1b795e..18e303db29 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -27,7 +27,6 @@ export const getDefaultAppState = (): Omit< showWelcomeScreen: false, theme: THEME.LIGHT, collaborators: new Map(), - currentChartType: "bar", currentItemBackgroundColor: DEFAULT_ELEMENT_PROPS.backgroundColor, currentItemEndArrowhead: "arrow", currentItemFillStyle: DEFAULT_ELEMENT_PROPS.fillStyle, @@ -83,7 +82,6 @@ export const getDefaultAppState = (): Omit< openPopup: null, openSidebar: null, openDialog: null, - pasteDialog: { shown: false, data: null }, previousSelectedElementIds: {}, resizingElement: null, scrolledOutside: false, @@ -150,7 +148,6 @@ const APP_STATE_STORAGE_CONF = (< showWelcomeScreen: { browser: true, export: false, server: false }, theme: { browser: true, export: false, server: false }, collaborators: { browser: false, export: false, server: false }, - currentChartType: { browser: true, export: false, server: false }, currentItemBackgroundColor: { browser: true, export: false, server: false }, currentItemEndArrowhead: { browser: true, export: false, server: false }, currentItemFillStyle: { browser: true, export: false, server: false }, @@ -212,7 +209,6 @@ const APP_STATE_STORAGE_CONF = (< openPopup: { browser: false, export: false, server: false }, openSidebar: { browser: true, export: false, server: false }, openDialog: { browser: false, export: false, server: false }, - pasteDialog: { browser: false, export: false, server: false }, previousSelectedElementIds: { browser: true, export: false, server: false }, resizingElement: { browser: false, export: false, server: false }, scrolledOutside: { browser: true, export: false, server: false }, diff --git a/packages/excalidraw/charts.test.ts b/packages/excalidraw/charts.test.ts index 94fa92fa0c..16e161ca40 100644 --- a/packages/excalidraw/charts.test.ts +++ b/packages/excalidraw/charts.test.ts @@ -1,8 +1,40 @@ -import { tryParseCells, tryParseNumber, VALID_SPREADSHEET } from "./charts"; +import { FONT_FAMILY } from "@excalidraw/common"; +import { + DEFAULT_CHART_COLOR_INDEX, + getAllColorsSpecificShade, +} from "@excalidraw/common"; + +import type { + ExcalidrawLineElement, + ExcalidrawTextElement, +} from "@excalidraw/element/types"; + +import { + isSpreadsheetValidForChartType, + renderSpreadsheet, + tryParseCells, + tryParseNumber, +} from "./charts"; import type { Spreadsheet } from "./charts"; describe("charts", () => { + const getRotatedBounds = (element: ExcalidrawTextElement) => { + const cos = Math.abs(Math.cos(element.angle)); + const sin = Math.abs(Math.sin(element.angle)); + const rotatedWidth = element.width * cos + element.height * sin; + const rotatedHeight = element.width * sin + element.height * cos; + const centerX = element.x + element.width / 2; + const centerY = element.y + element.height / 2; + return { + left: centerX - rotatedWidth / 2, + right: centerX + rotatedWidth / 2, + top: centerY - rotatedHeight / 2, + bottom: centerY + rotatedHeight / 2, + centerX, + }; + }; + describe("tryParseNumber", () => { it.each<[string, number]>([ ["1", 1], @@ -42,11 +74,11 @@ describe("charts", () => { const result = tryParseCells(spreadsheet); - expect(result.type).toBe(VALID_SPREADSHEET); + expect(result.ok).toBe(true); - const { title, labels, values } = ( - result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet } - ).spreadsheet; + const { title, labels, series } = ( + result as { ok: true; data: Spreadsheet } + ).data; expect(title).toEqual("value"); expect(labels).toEqual([ @@ -57,7 +89,9 @@ describe("charts", () => { "05:00", "06:00", ]); - expect(values).toEqual([61, -60, 85, -67, 54, 95]); + expect(series).toEqual([ + { title: "value", values: [61, -60, 85, -67, 54, 95] }, + ]); }); it("Uses the second column as the label if it is not a number", () => { @@ -73,11 +107,11 @@ describe("charts", () => { const result = tryParseCells(spreadsheet); - expect(result.type).toBe(VALID_SPREADSHEET); + expect(result.ok).toBe(true); - const { title, labels, values } = ( - result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet } - ).spreadsheet; + const { title, labels, series } = ( + result as { ok: true; data: Spreadsheet } + ).data; expect(title).toEqual("value"); expect(labels).toEqual([ @@ -88,7 +122,9 @@ describe("charts", () => { "05:00", "06:00", ]); - expect(values).toEqual([61, -60, 85, -67, 54, 95]); + expect(series).toEqual([ + { title: "value", values: [61, -60, 85, -67, 54, 95] }, + ]); }); it("treats the first column as labels if both columns are numbers", () => { @@ -104,15 +140,1026 @@ describe("charts", () => { const result = tryParseCells(spreadsheet); - expect(result.type).toBe(VALID_SPREADSHEET); + expect(result.ok).toBe(true); - const { title, labels, values } = ( - result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet } - ).spreadsheet; + const { title, labels, series } = ( + result as { ok: true; data: Spreadsheet } + ).data; expect(title).toEqual("value"); expect(labels).toEqual(["01", "02", "03", "04", "05", "06"]); - expect(values).toEqual([61, -60, 85, -67, 54, 95]); + expect(series).toEqual([ + { title: "value", values: [61, -60, 85, -67, 54, 95] }, + ]); + }); + + it("parses multi-series cells for radar charts", () => { + const spreadsheet = [ + ["Metric", "Player A", "Player B", "Player C"], + ["Speed", "80", "60", "75"], + ["Strength", "65", "85", "70"], + ["Agility", "90", "70", "88"], + ["Intelligence", "70", "88", "92"], + ["Stamina", "85", "75", "80"], + ]; + + const result = tryParseCells(spreadsheet); + + expect(result.ok).toBe(true); + + const parsed = (result as { ok: true; data: Spreadsheet }).data; + + expect(parsed.title).toEqual("Metric"); + expect(parsed.labels).toEqual([ + "Speed", + "Strength", + "Agility", + "Intelligence", + "Stamina", + ]); + expect(parsed.series).toEqual([ + { title: "Player A", values: [80, 65, 90, 70, 85] }, + { title: "Player B", values: [60, 85, 70, 88, 75] }, + { title: "Player C", values: [75, 70, 88, 92, 80] }, + ]); + }); + + it("treats first row as title+series headers only when all cells are non-numeric", () => { + const spreadsheet = [ + ["Trait", "10", "20"], + ["Physical Strength", "4", "8"], + ["Strategy", "6", "9"], + ["Charisma", "7", "5"], + ]; + + const result = tryParseCells(spreadsheet); + expect(result.ok).toBe(true); + + const parsed = (result as { ok: true; data: Spreadsheet }).data; + + expect(parsed.title).toBeNull(); + expect(parsed.labels?.[0]).toEqual("Trait"); + expect(parsed.series[0].title).toEqual("Series 1"); + expect(parsed.series[1].title).toEqual("Series 2"); + }); + + it("supports header row with series labels but no chart title", () => { + const spreadsheet = [ + ["", "Dunk", "Egg"], + ["Physical Strength", "10", "2"], + ["Swordsmanship", "8", "1"], + ["Political Instinct", "3", "9"], + ]; + + const result = tryParseCells(spreadsheet); + expect(result.ok).toBe(true); + + const parsed = (result as { ok: true; data: Spreadsheet }).data; + + expect(parsed.title).toBeNull(); + expect(parsed.labels).toEqual([ + "Physical Strength", + "Swordsmanship", + "Political Instinct", + ]); + expect(parsed.series).toEqual([ + { title: "Dunk", values: [10, 8, 3] }, + { title: "Egg", values: [2, 1, 9] }, + ]); + }); + + it("parses 2-row multi-series data with header row", () => { + const spreadsheet = [ + ["trait", "Dunk", "Egg"], + ["Physical Strength", "10", "2"], + ["Swordsmanship skill", "8", "1"], + ]; + + const result = tryParseCells(spreadsheet); + expect(result.ok).toBe(true); + + const parsed = (result as { ok: true; data: Spreadsheet }).data; + + expect(parsed.title).toEqual("trait"); + expect(parsed.labels).toEqual([ + "Physical Strength", + "Swordsmanship skill", + ]); + expect(parsed.series).toEqual([ + { title: "Dunk", values: [10, 8] }, + { title: "Egg", values: [2, 1] }, + ]); + }); + + it("parses 2-row multi-series data without header and keeps first column as labels", () => { + const spreadsheet = [ + ["Physical Strength", "10", "2"], + ["Swordsmanship skill", "8", "1"], + ]; + + const result = tryParseCells(spreadsheet); + expect(result.ok).toBe(true); + + const parsed = (result as { ok: true; data: Spreadsheet }).data; + + expect(parsed.title).toBeNull(); + expect(parsed.labels).toEqual([ + "Physical Strength", + "Swordsmanship skill", + ]); + expect(parsed.series).toEqual([ + { title: "Series 1", values: [10, 8] }, + { title: "Series 2", values: [2, 1] }, + ]); + }); + + it("always interprets 2-column data as label in first column and numeric value in second", () => { + const spreadsheet = [ + ["10", "2"], + ["8", "Swordsmanship skill"], + ["6", "3"], + ]; + + const result = tryParseCells(spreadsheet); + expect(result).toEqual({ + ok: false, + reason: "Value is not numeric", + }); + }); + }); + + describe("isSpreadsheetValidForChartType", () => { + it("rejects radar charts with only 2 dimensions", () => { + const spreadsheet: Spreadsheet = { + title: "trait", + labels: ["Physical Strength", "Swordsmanship skill"], + series: [ + { title: "Dunk", values: [10, 8] }, + { title: "Egg", values: [2, 1] }, + ], + }; + + expect(isSpreadsheetValidForChartType(spreadsheet, "radar")).toBe(false); + expect(isSpreadsheetValidForChartType(spreadsheet, "bar")).toBe(true); + expect(isSpreadsheetValidForChartType(spreadsheet, "line")).toBe(true); + }); + + it("accepts radar charts with 3 or more dimensions", () => { + const spreadsheet: Spreadsheet = { + title: "trait", + labels: [ + "Physical Strength", + "Swordsmanship skill", + "Political Instinct", + ], + series: [ + { title: "Dunk", values: [10, 8, 3] }, + { title: "Egg", values: [2, 1, 9] }, + ], + }; + + expect(isSpreadsheetValidForChartType(spreadsheet, "radar")).toBe(true); + }); + }); + + describe("renderSpreadsheet", () => { + it("renders grouped bars and legend for multi-series bar charts", () => { + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D", "E"], + series: [ + { title: "Dunk", values: [10, 8, 3, 2.5, 5] }, + { title: "Egg", values: [2, 1, 9, 8, 9] }, + { title: "Aerion", values: [7, 8, 7, 4, 5] }, + ], + }; + + const elements = renderSpreadsheet("bar", spreadsheet, 0, 0); + const bars = elements!.filter( + (element) => + element.type === "rectangle" && + element.strokeWidth === 1 && + element.opacity === 100 && + !element.roundness, + ); + const textElements = elements!.filter( + (element) => element.type === "text", + ); + const axisLabels = textElements.filter((element) => + spreadsheet.labels?.includes(element.originalText || ""), + ); + const legendLabels = textElements.filter((element) => + spreadsheet.series.some( + (series) => series.title === element.originalText, + ), + ); + + const axisBottomY = Math.max( + ...axisLabels.map((axisLabel) => axisLabel.y + axisLabel.height), + ); + const legendTopY = Math.min( + ...legendLabels.map((legendLabel) => legendLabel.y), + ); + + expect(bars).toHaveLength( + spreadsheet.series.length * spreadsheet.series[0].values.length, + ); + expect(legendLabels).toHaveLength(spreadsheet.series.length); + expect(legendTopY).toBeGreaterThan(axisBottomY + 2); + }); + + it("spreads grouped bar series colors across palette", () => { + const palette = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX); + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D", "E"], + series: [ + { title: "S1", values: [1, 2, 3, 4, 5] }, + { title: "S2", values: [2, 3, 4, 5, 1] }, + { title: "S3", values: [3, 4, 5, 1, 2] }, + { title: "S4", values: [4, 5, 1, 2, 3] }, + ], + }; + + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); + const elements = renderSpreadsheet("bar", spreadsheet, 0, 0); + randomSpy.mockRestore(); + + const bars = elements!.filter( + (element) => + element.type === "rectangle" && + element.strokeWidth === 1 && + element.opacity === 100 && + !element.roundness, + ); + const uniqueColors = Array.from( + new Set(bars.map((bar) => bar.backgroundColor)), + ); + const colorIndices = uniqueColors.map((color) => + palette.findIndex((paletteColor) => paletteColor === color), + ); + + expect(uniqueColors).toHaveLength(spreadsheet.series.length); + expect(colorIndices.every((index) => index >= 0)).toBe(true); + + const circularDistance = (first: number, second: number) => { + const absoluteDistance = Math.abs(first - second); + return Math.min(absoluteDistance, palette.length - absoluteDistance); + }; + const minDistance = Math.min( + ...colorIndices.flatMap((index, i) => + colorIndices + .slice(i + 1) + .map((other) => circularDistance(index, other)), + ), + ); + expect(minDistance).toBeGreaterThan(1); + }); + + it("renders grouped bars for parsed multi-series cells without header row", () => { + const cells = [ + ["Physical Strength", "10", "2", "7"], + ["Swordsmanship", "8", "1", "8"], + ["Political Instinct", "3", "9", "7"], + ["Book Knowledge", "2.5", "8", "4"], + ]; + const parsedResult = tryParseCells(cells); + expect(parsedResult.ok).toBe(true); + const parsedSpreadsheet = ( + parsedResult as { + ok: true; + data: Spreadsheet; + } + ).data; + + const elements = renderSpreadsheet("bar", parsedSpreadsheet, 0, 0); + const bars = elements!.filter( + (element) => + element.type === "rectangle" && + element.strokeWidth === 1 && + element.opacity === 100 && + !element.roundness, + ); + const textElements = elements!.filter( + (element) => element.type === "text", + ); + const legendLabels = textElements + .map((element) => element.originalText) + .filter((text): text is string => typeof text === "string"); + + expect(bars).toHaveLength( + parsedSpreadsheet.series[0].values.length * + parsedSpreadsheet.series.length, + ); + expect(legendLabels).toContain("Series 1"); + expect(legendLabels).toContain("Series 2"); + expect(legendLabels).toContain("Series 3"); + }); + + it("makes multi-series bar charts wider than single-series bar charts", () => { + const singleSeries: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D"], + series: [{ title: "Trait", values: [10, 8, 3, 2.5] }], + }; + const multiSeries: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D"], + series: [ + { title: "Dunk", values: [10, 8, 3, 2.5] }, + { title: "Egg", values: [2, 1, 9, 8] }, + { title: "Aerion", values: [7, 8, 7, 4] }, + ], + }; + + const singleElements = renderSpreadsheet("bar", singleSeries, 0, 0); + const multiElements = renderSpreadsheet("bar", multiSeries, 0, 0); + const getXAxisWidth = (elements: ReturnType) => + elements!.find( + (element): element is ExcalidrawLineElement => + element.type === "line" && + element.strokeStyle === "solid" && + element.points[0][1] === 0 && + element.points[1][1] === 0 && + element.points[1][0] > 0, + )?.width || 0; + + expect(getXAxisWidth(multiElements)).toBeGreaterThan( + getXAxisWidth(singleElements), + ); + }); + + it("makes multi-series line charts wider than single-series line charts", () => { + const singleSeries: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D"], + series: [{ title: "Trait", values: [10, 8, 3, 2.5] }], + }; + const multiSeries: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D"], + series: [ + { title: "Dunk", values: [10, 8, 3, 2.5] }, + { title: "Egg", values: [2, 1, 9, 8] }, + { title: "Aerion", values: [7, 8, 7, 4] }, + ], + }; + + const singleElements = renderSpreadsheet("line", singleSeries, 0, 0); + const multiElements = renderSpreadsheet("line", multiSeries, 0, 0); + const getXAxisWidth = (elements: ReturnType) => + elements!.find( + (element): element is ExcalidrawLineElement => + element.type === "line" && + element.strokeStyle === "solid" && + element.points[0][1] === 0 && + element.points[1][1] === 0 && + element.points[1][0] > 0, + )?.width || 0; + + expect(getXAxisWidth(multiElements)).toBeGreaterThan( + getXAxisWidth(singleElements), + ); + }); + + it("wraps grouped bar labels with spaces and still ellipsifies long single words", () => { + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: [ + "Supercalifragilisticexpialidocious", + "Data Flow", + "Logic Layer", + ], + series: [ + { title: "Dunk", values: [8, 3, 2.5] }, + { title: "Egg", values: [1, 9, 8] }, + { title: "Aerion", values: [8, 7, 4] }, + ], + }; + + const elements = renderSpreadsheet("bar", spreadsheet, 0, 0); + const longWordLabel = elements!.find( + (element): element is ExcalidrawTextElement => + element.type === "text" && + Math.abs(element.angle) > 0 && + element.text.includes("..."), + ); + const spacedLabels = elements!.filter( + (element): element is ExcalidrawTextElement => + element.type === "text" && + (element.originalText === "Data Flow" || + element.originalText === "Logic Layer"), + ); + + expect(longWordLabel).toBeDefined(); + expect(longWordLabel?.text).toContain("..."); + expect(longWordLabel?.originalText).toBe(longWordLabel?.text); + expect( + (longWordLabel?.text || "").replace("...", "").length, + ).toBeGreaterThan(0); + expect(spacedLabels.some((label) => label.text.includes("\n"))).toBe( + true, + ); + expect( + spacedLabels.every( + (label) => !!label.originalText && !label.originalText.includes("\n"), + ), + ).toBe(true); + }); + + it("keeps single-series bar x-axis labels below axis and avoids neighbor overlap", () => { + const spreadsheet: Spreadsheet = { + title: "Dunk", + labels: [ + "Physical Strength", + "Swordsmanship", + "Political Instinct", + "Book Knowledge", + "Strategic Thinking", + "charisma", + "courage", + "Stubbornness", + "Empathy", + "Practical Survival Skills", + ], + series: [{ title: "Dunk", values: [10, 8, 3, 2.5, 5, 7, 9, 8, 8, 9] }], + }; + + const elements = renderSpreadsheet("bar", spreadsheet, 0, 0); + const axisLabels = elements!.filter( + (element): element is ExcalidrawTextElement => + element.type === "text" && Math.abs(element.angle) > 0, + ); + + expect(axisLabels).toHaveLength(spreadsheet.labels!.length); + + const bounds = axisLabels.map(getRotatedBounds); + for (const bound of bounds) { + expect(bound.top).toBeGreaterThan(0); + } + + const sortedBounds = bounds.sort( + (left, right) => left.centerX - right.centerX, + ); + for (let index = 1; index < sortedBounds.length; index++) { + expect(sortedBounds[index - 1].right).toBeLessThanOrEqual( + sortedBounds[index].left + 2, + ); + } + }); + + it("renders one line per series and one dot per data point for multi-series line charts", () => { + const spreadsheet: Spreadsheet = { + title: "Scores", + labels: ["alpha", "beta", "gamma", "delta", "epsilon"], + series: [ + { title: "Team A", values: [42150, 8300, 95400, 7820, 310500] }, + { title: "Team B", values: [63400, 3150, 51200, 4670, 125800] }, + ], + }; + + const elements = renderSpreadsheet("line", spreadsheet, 0, 0); + const seriesLines = elements!.filter( + (element): element is ExcalidrawLineElement => + element.type === "line" && element.strokeWidth === 2, + ); + const dots = elements!.filter( + (element) => element.type === "ellipse" && element.strokeWidth === 2, + ); + + expect(seriesLines).toHaveLength(spreadsheet.series.length); + expect(dots).toHaveLength( + spreadsheet.series.length * spreadsheet.series[0].values.length, + ); + }); + + it("spreads line series colors across palette to avoid similar adjacent colors", () => { + const palette = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX); + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D", "E"], + series: [ + { title: "S1", values: [1, 2, 3, 4, 5] }, + { title: "S2", values: [2, 3, 4, 5, 1] }, + { title: "S3", values: [3, 4, 5, 1, 2] }, + { title: "S4", values: [4, 5, 1, 2, 3] }, + ], + }; + + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); + const elements = renderSpreadsheet("line", spreadsheet, 0, 0); + randomSpy.mockRestore(); + + const seriesLines = elements!.filter( + (element) => element.type === "line" && element.strokeWidth === 2, + ); + const colorIndices = seriesLines.map((line) => + palette.findIndex((color) => color === line.strokeColor), + ); + + expect(colorIndices.every((index) => index >= 0)).toBe(true); + + const circularDistance = (first: number, second: number) => { + const absoluteDistance = Math.abs(first - second); + return Math.min(absoluteDistance, palette.length - absoluteDistance); + }; + const minDistance = Math.min( + ...colorIndices.flatMap((index, i) => + colorIndices + .slice(i + 1) + .map((other) => circularDistance(index, other)), + ), + ); + + expect(minDistance).toBeGreaterThan(1); + }); + + it("uses colorSeed to deterministically pick chart colors", () => { + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D"], + series: [ + { title: "S1", values: [1, 2, 3, 4] }, + { title: "S2", values: [4, 3, 2, 1] }, + { title: "S3", values: [2, 3, 4, 1] }, + ], + }; + + const getSeriesLineColors = (seed: number) => { + const elements = renderSpreadsheet("line", spreadsheet, 0, 0, seed); + return elements! + .filter( + (element): element is ExcalidrawLineElement => + element.type === "line" && element.strokeWidth === 2, + ) + .map((line) => line.strokeColor); + }; + + expect(getSeriesLineColors(0.125)).toEqual(getSeriesLineColors(0.125)); + expect(getSeriesLineColors(0.125)).not.toEqual( + getSeriesLineColors(0.875), + ); + }); + + it("renders multi-series line legend below axis labels with clearance", () => { + const spreadsheet: Spreadsheet = { + title: "Scores", + labels: ["alpha", "beta", "gamma", "delta", "epsilon"], + series: [ + { title: "Team A", values: [42150, 8300, 95400, 12600, 310500] }, + { title: "Team B", values: [63400, 3150, 51200, 9200, 125800] }, + ], + }; + + const elements = renderSpreadsheet("line", spreadsheet, 0, 0); + const textElements = elements!.filter( + (element) => element.type === "text", + ); + const axisLabels = textElements.filter((element) => + spreadsheet.labels?.includes(element.originalText || ""), + ); + const legendLabels = textElements.filter((element) => + spreadsheet.series.some( + (series) => series.title === element.originalText, + ), + ); + + const axisBottomY = Math.max( + ...axisLabels.map((axisLabel) => axisLabel.y + axisLabel.height), + ); + const legendTopY = Math.min( + ...legendLabels.map((legendLabel) => legendLabel.y), + ); + + expect(axisLabels.length).toBeGreaterThan(0); + expect(legendLabels.length).toBe(2); + expect(legendTopY).toBeGreaterThan(axisBottomY + 2); + }); + + it("keeps multi-series line x-axis labels below axis and avoids neighbor overlap", () => { + const spreadsheet: Spreadsheet = { + title: "trait", + labels: [ + "Physical Strength", + "Swordsmanship", + "Political Instinct", + "Book Knowledge", + "Strategic Thinking", + "charisma", + "courage", + "Stubbornness", + "Empathy", + "Practical Survival Skills", + ], + series: [ + { title: "Dunk", values: [10, 8, 3, 2.5, 5, 7, 9, 8, 8, 9] }, + { title: "Egg", values: [2, 1, 9, 8, 9, 8, 7, 9, 8, 4] }, + ], + }; + + const elements = renderSpreadsheet("line", spreadsheet, 0, 0); + const axisLabels = elements!.filter( + (element): element is ExcalidrawTextElement => + element.type === "text" && Math.abs(element.angle) > 0, + ); + + expect(axisLabels).toHaveLength(spreadsheet.labels!.length); + + const bounds = axisLabels.map(getRotatedBounds); + for (const bound of bounds) { + expect(bound.top).toBeGreaterThan(0); + } + + const sortedBounds = bounds.sort( + (left, right) => left.centerX - right.centerX, + ); + for (let index = 1; index < sortedBounds.length; index++) { + expect(sortedBounds[index - 1].right).toBeLessThanOrEqual( + sortedBounds[index].left + 2, + ); + } + }); + + it("renders one closed polygon line per radar series", () => { + const spreadsheet: Spreadsheet = { + title: "Metric", + labels: ["Speed", "Strength", "Agility", "Intelligence", "Stamina"], + series: [ + { title: "Player A", values: [80, 65, 90, 70, 85] }, + { title: "Player B", values: [60, 85, 70, 88, 75] }, + { title: "Player C", values: [75, 70, 88, 92, 80] }, + ], + }; + + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + const seriesPolygons = elements!.filter( + (element): element is ExcalidrawLineElement => + element.type === "line" && + "polygon" in element && + element.polygon === true && + element.strokeWidth === 2, + ); + + expect(seriesPolygons).toHaveLength(3); + for (const polygon of seriesPolygons) { + expect(polygon.points[0]).toEqual( + polygon.points[polygon.points.length - 1], + ); + } + }); + + it("normalizes multi-series radar values with global scale", () => { + const spreadsheet: Spreadsheet = { + title: "Scores", + labels: ["alpha", "beta", "gamma", "delta", "epsilon"], + series: [ + { title: "Series 1", values: [40000, 8300, 95400, 7820, 5000000] }, + { title: "Series 2", values: [76000, 3150, 51200, 4670, 60000] }, + ], + }; + + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + const seriesPolygons = elements!.filter( + (element): element is ExcalidrawLineElement => + element.type === "line" && + "polygon" in element && + element.polygon === true && + element.strokeWidth === 2, + ); + + const series1 = seriesPolygons[0]; + const series2 = seriesPolygons[1]; + const getRadius = (point: readonly [number, number]) => + Math.hypot(point[0], point[1]); + + // On alpha axis, second series is about ~1.9x first series. + const alphaRatio = + getRadius(series2.points[0]!) / getRadius(series1.points[0]!); + expect(alphaRatio).toBeCloseTo(76000 / 40000, 1); + + // On epsilon axis, first series should dominate strongly. + const epsilonRatio = + getRadius(series1.points[4]!) / getRadius(series2.points[4]!); + expect(epsilonRatio).toBeGreaterThan(50); + }); + + // it("always renders radar step rings regardless of axis scale ratio", () => { + // const spreadsheet: Spreadsheet = { + // title: "Scores", + // labels: ["alpha", "beta", "gamma", "delta", "epsilon"], + // series: [ + // { title: "Series 1", values: [40000, 8300, 95400, 7820, 5000000] }, + // { title: "Series 2", values: [76000, 3150, 51200, 4670, 60000] }, + // ], + // }; + + // const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + // const stepRings = elements!.filter( + // (element) => + // element.type === "line" && + // "polygon" in element && + // element.polygon && + // element.strokeStyle === "solid" && + // element.strokeWidth === 1, + // ); + + // expect(stepRings).toHaveLength(4); + // }); + + it("uses log normalization for highly skewed single-series radar data", () => { + const spreadsheet: Spreadsheet = { + title: "Scores", + labels: ["alpha", "beta", "gamma", "delta", "epsilon"], + series: [ + { + title: "Scores", + values: [40000, 8300, 95400, 7820, 5000000], + }, + ], + }; + + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + const seriesPolygons = elements!.filter( + (element): element is ExcalidrawLineElement => + element.type === "line" && + "polygon" in element && + element.polygon === true && + element.strokeWidth === 2, + ); + + const polygon = seriesPolygons[0]; + const getRadius = (point: readonly [number, number]) => + Math.hypot(point[0], point[1]); + + const alphaRadius = getRadius(polygon.points[0]!); + const epsilonRadius = getRadius(polygon.points[4]!); + + // With linear scaling this would collapse near 0; log keeps it visible. + expect(alphaRadius).toBeGreaterThan(40); + expect(epsilonRadius).toBeGreaterThan(alphaRadius); + }); + + it("does not render 0/max value labels for radar charts", () => { + const spreadsheet: Spreadsheet = { + title: "Scores", + labels: ["alpha", "beta", "gamma", "delta", "epsilon"], + series: [ + { + title: "Scores", + values: [40000, 8300, 95400, 7820, 5000000], + }, + ], + }; + + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + const textElements = elements!.filter( + (element) => element.type === "text", + ); + + expect(textElements.some((element) => element.text === "0")).toBe(false); + expect( + textElements.some( + (element) => + element.text === + Math.max(...spreadsheet.series[0].values).toLocaleString(), + ), + ).toBe(false); + }); + + it("wraps long radar axis labels instead of ellipsifying", () => { + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: [ + "Physical Strength", + "Swordsmanship", + "Political Instinct", + "Book Knowledge", + "Strategic Thinking", + "Charisma", + "Courage", + "Stubbornness", + "Empathy", + "Practical Survival Skills", + ], + series: [ + { title: "Dunk", values: [10, 8, 3, 2.5, 5, 7, 9, 8, 8, 9] }, + { title: "Egg", values: [2, 1, 9, 8, 9, 8, 7, 9, 8, 4] }, + ], + }; + + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + const textElements = elements!.filter( + (element) => element.type === "text", + ); + const wrappedAxisLabels = textElements.filter( + (element) => + element.text.includes("\n") && + element.text !== "Trait" && + element.text !== "Dunk" && + element.text !== "Egg", + ); + + expect(wrappedAxisLabels.length).toBeGreaterThan(0); + expect( + wrappedAxisLabels.every( + (element) => + typeof element.originalText === "string" && + !element.originalText.includes("\n"), + ), + ).toBe(true); + expect( + textElements.some( + (element) => element.text.includes("...") && element.text !== "Dunk", + ), + ).toBe(false); + expect( + textElements.some( + (element) => + element.originalText === "Stubbornness" && + !element.text.includes("\n") && + element.text === "Stubbornness", + ), + ).toBe(true); + expect( + textElements.some( + (element) => + element.originalText === "Physical Strength" && + element.text.includes("Physical\nStrength"), + ), + ).toBe(true); + + const topLabel = textElements.find( + (element) => element.originalText === "Physical Strength", + ); + const topSpokeY = Math.min( + ...elements! + .filter( + (element): element is ExcalidrawLineElement => + element.type === "line" && + "polygon" in element && + !element.polygon && + element.strokeStyle === "solid" && + element.strokeWidth === 1, + ) + .map((element) => element.y + element.points[1][1]), + ); + expect(topLabel).toBeDefined(); + expect(topLabel!.y + topLabel!.height).toBeLessThan(topSpokeY - 2); + }); + + it("renders radar title and series legend labels in Lilita One", () => { + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: ["Physical Strength", "Swordsmanship", "Strategy", "Charisma"], + series: [ + { title: "Dunk", values: [10, 8, 5, 7] }, + { title: "Egg", values: [2, 1, 9, 8] }, + ], + }; + + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + const textElements = elements!.filter( + (element) => element.type === "text", + ); + const title = textElements.find((element) => + element.text.includes("Trait"), + ); + const dunkLabel = textElements.find((element) => element.text === "Dunk"); + const eggLabel = textElements.find((element) => element.text === "Egg"); + + expect(title?.fontFamily).toBe(FONT_FAMILY["Lilita One"]); + expect(title?.originalText).toBe("Trait"); + expect(dunkLabel?.fontFamily).toBe(FONT_FAMILY["Lilita One"]); + expect(eggLabel?.fontFamily).toBe(FONT_FAMILY["Lilita One"]); + }); + + it("positions radar title with vertical clearance above axis labels", () => { + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: [ + "Physical Strength", + "Swordsmanship", + "Political Instinct", + "Book Knowledge", + "Strategic Thinking", + "Charisma", + "Courage", + "Stubbornness", + "Empathy", + "Practical Survival Skills", + ], + series: [ + { title: "Dunk", values: [10, 8, 3, 2.5, 5, 7, 9, 8, 8, 9] }, + { title: "Egg", values: [2, 1, 9, 8, 9, 8, 7, 9, 8, 4] }, + ], + }; + + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + const textElements = elements!.filter( + (element) => element.type === "text", + ); + const title = textElements.find( + (element) => element.fontFamily === FONT_FAMILY["Lilita One"], + ); + const axisLabels = textElements.filter( + (element) => + element.fontFamily === FONT_FAMILY.Excalifont && + element.text !== "Dunk" && + element.text !== "Egg", + ); + const topAxisLabelY = Math.min(...axisLabels.map((element) => element.y)); + + expect(title).toBeDefined(); + expect(title!.y + title!.height).toBeLessThan(topAxisLabelY - 4); + }); + + it("spreads radar series colors across palette to avoid similar adjacent colors", () => { + const palette = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX); + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D", "E"], + series: [ + { title: "S1", values: [1, 2, 3, 4, 5] }, + { title: "S2", values: [2, 3, 4, 5, 1] }, + { title: "S3", values: [3, 4, 5, 1, 2] }, + { title: "S4", values: [4, 5, 1, 2, 3] }, + ], + }; + + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + randomSpy.mockRestore(); + + const seriesPolygons = elements!.filter( + (element) => + element.type === "line" && + "polygon" in element && + element.polygon === true && + element.strokeWidth === 2, + ); + const colorIndices = seriesPolygons.map((polygon) => + palette.findIndex((color) => color === polygon.strokeColor), + ); + + expect(colorIndices.every((index) => index >= 0)).toBe(true); + + const circularDistance = (first: number, second: number) => { + const absoluteDistance = Math.abs(first - second); + return Math.min(absoluteDistance, palette.length - absoluteDistance); + }; + const minDistance = Math.min( + ...colorIndices.flatMap((index, i) => + colorIndices + .slice(i + 1) + .map((other) => circularDistance(index, other)), + ), + ); + + expect(minDistance).toBeGreaterThan(1); + }); + + it("positions series legend below the lowest axis label with clearance", () => { + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: [ + "Psychological Warfare", + "Divine Favor", + "Confidence", + "Morale", + "Armor Protection long wrapped label from above", + "Accuracy", + "Agility", + "Weapon Reach", + ], + series: [ + { title: "David", values: [6, 7, 8, 9, 7, 8, 6, 9] }, + { title: "Goliath", values: [9, 3, 2, 6, 10, 2, 8, 1] }, + ], + }; + + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + const textElements = elements!.filter( + (element) => element.type === "text", + ); + const axisLabels = textElements.filter((element) => + spreadsheet.labels?.includes(element.originalText), + ); + const legendLabels = textElements.filter((element) => + spreadsheet.series.some( + (series) => series.title === element.originalText, + ), + ); + + const axisBottomY = Math.max( + ...axisLabels.map((axisLabel) => axisLabel.y + axisLabel.height), + ); + const legendTopY = Math.min( + ...legendLabels.map((legendLabel) => legendLabel.y), + ); + + expect(axisLabels.length).toBeGreaterThan(0); + expect(legendLabels.length).toBeGreaterThan(0); + expect(legendTopY).toBeGreaterThan(axisBottomY + 2); }); }); }); diff --git a/packages/excalidraw/charts.ts b/packages/excalidraw/charts.ts deleted file mode 100644 index 26f8802936..0000000000 --- a/packages/excalidraw/charts.ts +++ /dev/null @@ -1,481 +0,0 @@ -import { pointFrom } from "@excalidraw/math"; - -import { - COLOR_PALETTE, - DEFAULT_CHART_COLOR_INDEX, - getAllColorsSpecificShade, - DEFAULT_FONT_FAMILY, - DEFAULT_FONT_SIZE, - VERTICAL_ALIGN, - randomId, - isDevEnv, - FONT_SIZES, -} from "@excalidraw/common"; - -import { - newTextElement, - newLinearElement, - newElement, -} from "@excalidraw/element"; - -import type { Radians } from "@excalidraw/math"; - -import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; - -export type ChartElements = readonly NonDeletedExcalidrawElement[]; - -const BAR_WIDTH = 32; -const BAR_GAP = 12; -const BAR_HEIGHT = 256; -const GRID_OPACITY = 50; - -export interface Spreadsheet { - title: string | null; - labels: string[] | null; - values: number[]; -} - -export const NOT_SPREADSHEET = "NOT_SPREADSHEET"; -export const VALID_SPREADSHEET = "VALID_SPREADSHEET"; - -type ParseSpreadsheetResult = - | { type: typeof NOT_SPREADSHEET; reason: string } - | { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }; - -/** - * @private exported for testing - */ -export const tryParseNumber = (s: string): number | null => { - const match = /^([-+]?)[$€£¥₩]?([-+]?)([\d.,]+)[%]?$/.exec(s); - if (!match) { - return null; - } - return parseFloat(`${(match[1] || match[2]) + match[3]}`.replace(/,/g, "")); -}; - -const isNumericColumn = (lines: string[][], columnIndex: number) => - lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null); - -/** - * @private exported for testing - */ -export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => { - const numCols = cells[0].length; - - if (numCols > 2) { - return { type: NOT_SPREADSHEET, reason: "More than 2 columns" }; - } - - if (numCols === 1) { - if (!isNumericColumn(cells, 0)) { - return { type: NOT_SPREADSHEET, reason: "Value is not numeric" }; - } - - const hasHeader = tryParseNumber(cells[0][0]) === null; - const values = (hasHeader ? cells.slice(1) : cells).map((line) => - tryParseNumber(line[0]), - ); - - if (values.length < 2) { - return { type: NOT_SPREADSHEET, reason: "Less than two rows" }; - } - - return { - type: VALID_SPREADSHEET, - spreadsheet: { - title: hasHeader ? cells[0][0] : null, - labels: null, - values: values as number[], - }, - }; - } - - const labelColumnNumeric = isNumericColumn(cells, 0); - const valueColumnNumeric = isNumericColumn(cells, 1); - - if (!labelColumnNumeric && !valueColumnNumeric) { - return { type: NOT_SPREADSHEET, reason: "Value is not numeric" }; - } - - const [labelColumnIndex, valueColumnIndex] = valueColumnNumeric - ? [0, 1] - : [1, 0]; - const hasHeader = tryParseNumber(cells[0][valueColumnIndex]) === null; - const rows = hasHeader ? cells.slice(1) : cells; - - if (rows.length < 2) { - return { type: NOT_SPREADSHEET, reason: "Less than 2 rows" }; - } - - return { - type: VALID_SPREADSHEET, - spreadsheet: { - title: hasHeader ? cells[0][valueColumnIndex] : null, - labels: rows.map((row) => row[labelColumnIndex]), - values: rows.map((row) => tryParseNumber(row[valueColumnIndex])!), - }, - }; -}; - -const transposeCells = (cells: string[][]) => { - const nextCells: string[][] = []; - for (let col = 0; col < cells[0].length; col++) { - const nextCellRow: string[] = []; - for (let row = 0; row < cells.length; row++) { - nextCellRow.push(cells[row][col]); - } - nextCells.push(nextCellRow); - } - return nextCells; -}; - -export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => { - // Copy/paste from excel, spreadsheets, tsv, csv. - // For now we only accept 2 columns with an optional header - - // Check for tab separated values - let lines = text - .trim() - .split("\n") - .map((line) => line.trim().split("\t")); - - // Check for comma separated files - if (lines.length && lines[0].length !== 2) { - lines = text - .trim() - .split("\n") - .map((line) => line.trim().split(",")); - } - - if (lines.length === 0) { - return { type: NOT_SPREADSHEET, reason: "No values" }; - } - - const numColsFirstLine = lines[0].length; - const isSpreadsheet = lines.every((line) => line.length === numColsFirstLine); - - if (!isSpreadsheet) { - return { - type: NOT_SPREADSHEET, - reason: "All rows don't have same number of columns", - }; - } - - const result = tryParseCells(lines); - if (result.type !== VALID_SPREADSHEET) { - const transposedResults = tryParseCells(transposeCells(lines)); - if (transposedResults.type === VALID_SPREADSHEET) { - return transposedResults; - } - } - return result; -}; - -const bgColors = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX); - -// Put all the common properties here so when the whole chart is selected -// the properties dialog shows the correct selected values -const commonProps = { - fillStyle: "hachure", - fontFamily: DEFAULT_FONT_FAMILY, - fontSize: DEFAULT_FONT_SIZE, - opacity: 100, - roughness: 1, - strokeColor: COLOR_PALETTE.black, - roundness: null, - strokeStyle: "solid", - strokeWidth: 1, - verticalAlign: VERTICAL_ALIGN.MIDDLE, - locked: false, -} as const; - -const getChartDimensions = (spreadsheet: Spreadsheet) => { - const chartWidth = - (BAR_WIDTH + BAR_GAP) * spreadsheet.values.length + BAR_GAP; - const chartHeight = BAR_HEIGHT + BAR_GAP * 2; - return { chartWidth, chartHeight }; -}; - -const chartXLabels = ( - spreadsheet: Spreadsheet, - x: number, - y: number, - groupId: string, - backgroundColor: string, -): ChartElements => { - return ( - spreadsheet.labels?.map((label, index) => { - return newTextElement({ - groupIds: [groupId], - backgroundColor, - ...commonProps, - text: label.length > 8 ? `${label.slice(0, 5)}...` : label, - x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2, - y: y + BAR_GAP / 2, - width: BAR_WIDTH, - angle: 5.87 as Radians, - fontSize: FONT_SIZES.sm, - textAlign: "center", - verticalAlign: "top", - }); - }) || [] - ); -}; - -const chartYLabels = ( - spreadsheet: Spreadsheet, - x: number, - y: number, - groupId: string, - backgroundColor: string, -): ChartElements => { - const minYLabel = newTextElement({ - groupIds: [groupId], - backgroundColor, - ...commonProps, - x: x - BAR_GAP, - y: y - BAR_GAP, - text: "0", - textAlign: "right", - }); - - const maxYLabel = newTextElement({ - groupIds: [groupId], - backgroundColor, - ...commonProps, - x: x - BAR_GAP, - y: y - BAR_HEIGHT - minYLabel.height / 2, - text: Math.max(...spreadsheet.values).toLocaleString(), - textAlign: "right", - }); - - return [minYLabel, maxYLabel]; -}; - -const chartLines = ( - spreadsheet: Spreadsheet, - x: number, - y: number, - groupId: string, - backgroundColor: string, -): ChartElements => { - const { chartWidth, chartHeight } = getChartDimensions(spreadsheet); - const xLine = newLinearElement({ - backgroundColor, - groupIds: [groupId], - ...commonProps, - type: "line", - x, - y, - width: chartWidth, - points: [pointFrom(0, 0), pointFrom(chartWidth, 0)], - }); - - const yLine = newLinearElement({ - backgroundColor, - groupIds: [groupId], - ...commonProps, - type: "line", - x, - y, - height: chartHeight, - points: [pointFrom(0, 0), pointFrom(0, -chartHeight)], - }); - - const maxLine = newLinearElement({ - backgroundColor, - groupIds: [groupId], - ...commonProps, - type: "line", - x, - y: y - BAR_HEIGHT - BAR_GAP, - strokeStyle: "dotted", - width: chartWidth, - opacity: GRID_OPACITY, - points: [pointFrom(0, 0), pointFrom(chartWidth, 0)], - }); - - return [xLine, yLine, maxLine]; -}; - -// For the maths behind it https://excalidraw.com/#json=6320864370884608,O_5xfD-Agh32tytHpRJx1g -const chartBaseElements = ( - spreadsheet: Spreadsheet, - x: number, - y: number, - groupId: string, - backgroundColor: string, - debug?: boolean, -): ChartElements => { - const { chartWidth, chartHeight } = getChartDimensions(spreadsheet); - - const title = spreadsheet.title - ? newTextElement({ - backgroundColor, - groupIds: [groupId], - ...commonProps, - text: spreadsheet.title, - x: x + chartWidth / 2, - y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE, - roundness: null, - textAlign: "center", - }) - : null; - - const debugRect = debug - ? newElement({ - backgroundColor, - groupIds: [groupId], - ...commonProps, - type: "rectangle", - x, - y: y - chartHeight, - width: chartWidth, - height: chartHeight, - strokeColor: COLOR_PALETTE.black, - fillStyle: "solid", - opacity: 6, - }) - : null; - - return [ - ...(debugRect ? [debugRect] : []), - ...(title ? [title] : []), - ...chartXLabels(spreadsheet, x, y, groupId, backgroundColor), - ...chartYLabels(spreadsheet, x, y, groupId, backgroundColor), - ...chartLines(spreadsheet, x, y, groupId, backgroundColor), - ]; -}; - -const chartTypeBar = ( - spreadsheet: Spreadsheet, - x: number, - y: number, -): ChartElements => { - const max = Math.max(...spreadsheet.values); - const groupId = randomId(); - const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)]; - - const bars = spreadsheet.values.map((value, index) => { - const barHeight = (value / max) * BAR_HEIGHT; - return newElement({ - backgroundColor, - groupIds: [groupId], - ...commonProps, - type: "rectangle", - x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP, - y: y - barHeight - BAR_GAP, - width: BAR_WIDTH, - height: barHeight, - }); - }); - - return [ - ...bars, - ...chartBaseElements( - spreadsheet, - x, - y, - groupId, - backgroundColor, - isDevEnv(), - ), - ]; -}; - -const chartTypeLine = ( - spreadsheet: Spreadsheet, - x: number, - y: number, -): ChartElements => { - const max = Math.max(...spreadsheet.values); - const groupId = randomId(); - const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)]; - - let index = 0; - const points = []; - for (const value of spreadsheet.values) { - const cx = index * (BAR_WIDTH + BAR_GAP); - const cy = -(value / max) * BAR_HEIGHT; - points.push([cx, cy]); - index++; - } - - const maxX = Math.max(...points.map((element) => element[0])); - const maxY = Math.max(...points.map((element) => element[1])); - const minX = Math.min(...points.map((element) => element[0])); - const minY = Math.min(...points.map((element) => element[1])); - - const line = newLinearElement({ - backgroundColor, - groupIds: [groupId], - ...commonProps, - type: "line", - x: x + BAR_GAP + BAR_WIDTH / 2, - y: y - BAR_GAP, - height: maxY - minY, - width: maxX - minX, - strokeWidth: 2, - points: points as any, - }); - - const dots = spreadsheet.values.map((value, index) => { - const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2; - const cy = -(value / max) * BAR_HEIGHT + BAR_GAP / 2; - return newElement({ - backgroundColor, - groupIds: [groupId], - ...commonProps, - fillStyle: "solid", - strokeWidth: 2, - type: "ellipse", - x: x + cx + BAR_WIDTH / 2, - y: y + cy - BAR_GAP * 2, - width: BAR_GAP, - height: BAR_GAP, - }); - }); - - const lines = spreadsheet.values.map((value, index) => { - const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2; - const cy = (value / max) * BAR_HEIGHT + BAR_GAP / 2 + BAR_GAP; - return newLinearElement({ - backgroundColor, - groupIds: [groupId], - ...commonProps, - type: "line", - x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2, - y: y - cy, - height: cy, - strokeStyle: "dotted", - opacity: GRID_OPACITY, - points: [pointFrom(0, 0), pointFrom(0, cy)], - }); - }); - - return [ - ...chartBaseElements( - spreadsheet, - x, - y, - groupId, - backgroundColor, - isDevEnv(), - ), - line, - ...lines, - ...dots, - ]; -}; - -export const renderSpreadsheet = ( - chartType: string, - spreadsheet: Spreadsheet, - x: number, - y: number, -): ChartElements => { - if (chartType === "line") { - return chartTypeLine(spreadsheet, x, y); - } - return chartTypeBar(spreadsheet, x, y); -}; diff --git a/packages/excalidraw/charts/charts.bar.ts b/packages/excalidraw/charts/charts.bar.ts new file mode 100644 index 0000000000..b1a7759606 --- /dev/null +++ b/packages/excalidraw/charts/charts.bar.ts @@ -0,0 +1,103 @@ +import { isDevEnv } from "@excalidraw/common"; + +import { newElement } from "@excalidraw/element"; + +import { commonProps } from "./charts.constants"; +import { + chartBaseElements, + chartXLabels, + createSeriesLegend, + getBackgroundColor, + getCartesianChartLayout, + getChartDimensions, + getColorOffset, + getRotatedTextElementBottom, + getSeriesColors, +} from "./charts.helpers"; + +import type { ChartElements, Spreadsheet } from "./charts.types"; + +export const renderBarChart = ( + spreadsheet: Spreadsheet, + x: number, + y: number, + colorSeed?: number, +): ChartElements => { + const series = spreadsheet.series; + const layout = getCartesianChartLayout("bar", series.length); + const max = Math.max( + 1, + ...series.flatMap((seriesData) => + seriesData.values.map((value) => Math.max(0, value)), + ), + ); + const colorOffset = getColorOffset(colorSeed); + const backgroundColor = getBackgroundColor(colorOffset); + const seriesColors = getSeriesColors(series.length, colorOffset); + const interBarGap = + series.length > 1 + ? Math.max(1, Math.floor(layout.gap / (series.length + 1))) + : 0; + const barWidth = + series.length > 1 + ? Math.max( + 2, + (layout.slotWidth - interBarGap * (series.length - 1)) / + series.length, + ) + : layout.slotWidth; + const clusterWidth = + series.length * barWidth + interBarGap * (series.length - 1); + const clusterOffset = (layout.slotWidth - clusterWidth) / 2; + + const bars = series[0].values.flatMap((_, categoryIndex) => + series.map((seriesData, seriesIndex) => { + const value = Math.max(0, seriesData.values[categoryIndex] ?? 0); + const barHeight = (value / max) * layout.chartHeight; + const barColor = + series.length > 1 ? seriesColors[seriesIndex] : backgroundColor; + return newElement({ + backgroundColor: barColor, + ...commonProps, + type: "rectangle", + fillStyle: series.length > 1 ? "solid" : commonProps.fillStyle, + strokeColor: series.length > 1 ? barColor : commonProps.strokeColor, + x: + x + + categoryIndex * (layout.slotWidth + layout.gap) + + layout.gap + + clusterOffset + + seriesIndex * (barWidth + interBarGap), + y: y - barHeight - layout.gap, + width: barWidth, + height: barHeight, + }); + }), + ); + + const baseElements = chartBaseElements( + spreadsheet, + x, + y, + backgroundColor, + layout, + max, + isDevEnv(), + ); + const xLabels = chartXLabels(spreadsheet, x, y, backgroundColor, layout); + const xLabelsBottomY = Math.max( + y + layout.gap / 2, + ...xLabels.map((label) => getRotatedTextElementBottom(label)), + ); + const { chartWidth } = getChartDimensions(spreadsheet, layout); + const seriesLegend = createSeriesLegend( + series, + seriesColors, + x + chartWidth / 2, + xLabelsBottomY, + y + layout.gap * 5, + backgroundColor, + ); + + return [...baseElements, ...bars, ...seriesLegend]; +}; diff --git a/packages/excalidraw/charts/charts.constants.ts b/packages/excalidraw/charts/charts.constants.ts new file mode 100644 index 0000000000..4cb23da11b --- /dev/null +++ b/packages/excalidraw/charts/charts.constants.ts @@ -0,0 +1,63 @@ +import { + COLOR_PALETTE, + DEFAULT_FONT_FAMILY, + DEFAULT_FONT_SIZE, + VERTICAL_ALIGN, +} from "@excalidraw/common"; + +import type { Radians } from "@excalidraw/math"; + +export const CARTESIAN_BASE_SLOT_WIDTH = 44; +export const CARTESIAN_BAR_SLOT_EXTRA_PER_SERIES = 22; +export const CARTESIAN_BAR_SLOT_EXTRA_MAX = 66; +export const CARTESIAN_LINE_SLOT_WIDTH = 48; +export const CARTESIAN_GAP = 14; +export const CARTESIAN_BAR_HEIGHT = 304; +export const CARTESIAN_LINE_HEIGHT = 320; +export const CARTESIAN_LABEL_ROTATION = 5.87 as Radians; +export const CARTESIAN_LABEL_MIN_WIDTH = 28; +export const CARTESIAN_LABEL_SLOT_PADDING = 4; +export const CARTESIAN_LABEL_AXIS_CLEARANCE = 2; +export const CARTESIAN_LABEL_MAX_WIDTH_BUFFER = 10; +export const CARTESIAN_LABEL_ROTATED_WIDTH_BUFFER = 10; +export const CARTESIAN_LABEL_OVERFLOW_PREFERENCE_BUFFER = 8; + +export const BAR_GAP = 12; +export const BAR_HEIGHT = 256; +export const GRID_OPACITY = 10; + +export const RADAR_GRID_LEVELS = 4; +export const RADAR_LABEL_OFFSET = BAR_GAP * 2; +export const RADAR_PADDING = BAR_GAP * 2; +export const RADAR_SINGLE_SERIES_LOG_SCALE_THRESHOLD = 100; +export const RADAR_AXIS_LABEL_MAX_WIDTH = 140; +export const RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD = 0.35; +export const RADAR_AXIS_LABEL_CLEARANCE = BAR_GAP / 2; +export const RADAR_LEGEND_SWATCH_SIZE = 20; +export const RADAR_LEGEND_ITEM_GAP = BAR_GAP * 2; +export const RADAR_LEGEND_TEXT_GAP = BAR_GAP; + +// Put all common chart element properties here so properties dialog +// shows stable values when selecting chart groups. +export const commonProps = { + fillStyle: "hachure", + fontFamily: DEFAULT_FONT_FAMILY, + fontSize: DEFAULT_FONT_SIZE, + opacity: 100, + roughness: 1, + strokeColor: COLOR_PALETTE.black, + roundness: null, + strokeStyle: "solid", + strokeWidth: 1, + verticalAlign: VERTICAL_ALIGN.MIDDLE, + locked: false, +} as const; + +export type CartesianChartType = "bar" | "line"; + +export type CartesianChartLayout = { + slotWidth: number; + gap: number; + chartHeight: number; + xLabelMaxWidth: number; +}; diff --git a/packages/excalidraw/charts/charts.helpers.ts b/packages/excalidraw/charts/charts.helpers.ts new file mode 100644 index 0000000000..18097b1df9 --- /dev/null +++ b/packages/excalidraw/charts/charts.helpers.ts @@ -0,0 +1,865 @@ +import { pointFrom } from "@excalidraw/math"; + +import { + COLOR_PALETTE, + DEFAULT_CHART_COLOR_INDEX, + FONT_FAMILY, + FONT_SIZES, + ROUNDNESS, + DEFAULT_FONT_SIZE, + getAllColorsSpecificShade, + getFontString, + getLineHeight, + ROUGHNESS, +} from "@excalidraw/common"; + +import { + getApproxMinLineWidth, + measureText, + newElement, + newLinearElement, + newTextElement, + wrapText, +} from "@excalidraw/element"; + +import type { + ChartType, + ExcalidrawTextElement, +} from "@excalidraw/element/types"; +import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; + +import { + BAR_GAP, + CARTESIAN_BAR_HEIGHT, + CARTESIAN_BASE_SLOT_WIDTH, + CARTESIAN_BAR_SLOT_EXTRA_MAX, + CARTESIAN_BAR_SLOT_EXTRA_PER_SERIES, + CARTESIAN_GAP, + CARTESIAN_LABEL_AXIS_CLEARANCE, + CARTESIAN_LABEL_MAX_WIDTH_BUFFER, + CARTESIAN_LABEL_MIN_WIDTH, + CARTESIAN_LABEL_OVERFLOW_PREFERENCE_BUFFER, + CARTESIAN_LABEL_ROTATED_WIDTH_BUFFER, + CARTESIAN_LABEL_ROTATION, + CARTESIAN_LABEL_SLOT_PADDING, + CARTESIAN_LINE_HEIGHT, + CARTESIAN_LINE_SLOT_WIDTH, + GRID_OPACITY, + RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD, + RADAR_AXIS_LABEL_CLEARANCE, + RADAR_AXIS_LABEL_MAX_WIDTH, + RADAR_LABEL_OFFSET, + RADAR_LEGEND_ITEM_GAP, + RADAR_LEGEND_SWATCH_SIZE, + RADAR_LEGEND_TEXT_GAP, + RADAR_PADDING, + RADAR_SINGLE_SERIES_LOG_SCALE_THRESHOLD, + BAR_HEIGHT, + commonProps, + type CartesianChartLayout, + type CartesianChartType, +} from "./charts.constants"; + +import type { + ChartElements, + Spreadsheet, + SpreadsheetSeries, +} from "./charts.types"; + +const bgColors = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX); + +const getSpreadsheetDimensionCount = (spreadsheet: Spreadsheet) => + spreadsheet.labels?.length ?? spreadsheet.series[0]?.values.length ?? 0; + +export const isSpreadsheetValidForChartType = ( + spreadsheet: Spreadsheet | null, + chartType: ChartType, +) => { + if (!spreadsheet) { + return false; + } + + const dimensionCount = getSpreadsheetDimensionCount(spreadsheet); + if (dimensionCount < 2) { + return false; + } + + if (chartType === "radar") { + return dimensionCount >= 3; + } + + return true; +}; + +const getSeriesAwareSlotWidth = ( + baseSlotWidth: number, + seriesCount: number, +) => { + const extraSlotWidth = + seriesCount <= 1 + ? 0 + : Math.min( + CARTESIAN_BAR_SLOT_EXTRA_MAX, + (seriesCount - 1) * CARTESIAN_BAR_SLOT_EXTRA_PER_SERIES, + ); + return baseSlotWidth + extraSlotWidth; +}; + +export const getCartesianChartLayout = ( + chartType: CartesianChartType, + seriesCount: number, +): CartesianChartLayout => { + if (chartType === "line") { + const slotWidth = getSeriesAwareSlotWidth( + CARTESIAN_LINE_SLOT_WIDTH, + seriesCount, + ); + return { + slotWidth, + gap: CARTESIAN_GAP, + chartHeight: CARTESIAN_LINE_HEIGHT, + xLabelMaxWidth: + slotWidth + CARTESIAN_GAP * 3 + CARTESIAN_LABEL_MAX_WIDTH_BUFFER, + }; + } + + const slotWidth = getSeriesAwareSlotWidth( + CARTESIAN_BASE_SLOT_WIDTH, + seriesCount, + ); + return { + slotWidth, + gap: CARTESIAN_GAP, + chartHeight: CARTESIAN_BAR_HEIGHT, + xLabelMaxWidth: + slotWidth + CARTESIAN_GAP * 3 + CARTESIAN_LABEL_MAX_WIDTH_BUFFER, + }; +}; + +export const getChartDimensions = ( + spreadsheet: Spreadsheet, + layout: CartesianChartLayout, +) => { + const chartWidth = + (layout.slotWidth + layout.gap) * spreadsheet.series[0].values.length + + layout.gap; + const chartHeight = layout.chartHeight + layout.gap * 2; + return { chartWidth, chartHeight }; +}; + +export const getRadarDimensions = () => { + const chartWidth = BAR_HEIGHT + RADAR_PADDING * 2; + const chartHeight = BAR_HEIGHT + RADAR_PADDING * 2; + return { chartWidth, chartHeight }; +}; + +const getCircularDistance = ( + firstIndex: number, + secondIndex: number, + paletteSize: number, +) => { + const absoluteDistance = Math.abs(firstIndex - secondIndex); + return Math.min(absoluteDistance, paletteSize - absoluteDistance); +}; + +export const getSeriesColors = ( + seriesCount: number, + colorOffset: number, +): readonly string[] => { + if (seriesCount <= 0 || bgColors.length === 0) { + return []; + } + + const paletteSize = bgColors.length; + const startIndex = ((colorOffset % paletteSize) + paletteSize) % paletteSize; + const selectedIndices = [startIndex]; + const maxUniqueColors = Math.min(seriesCount, paletteSize); + const availableIndices = new Set( + Array.from({ length: paletteSize }, (_, index) => index).filter( + (index) => index !== startIndex, + ), + ); + + while (selectedIndices.length < maxUniqueColors) { + let bestIndex = -1; + let bestMinDistance = -1; + let bestAverageDistance = -1; + + for (const candidateIndex of availableIndices) { + const distances = selectedIndices.map((selectedIndex) => + getCircularDistance(candidateIndex, selectedIndex, paletteSize), + ); + const minDistance = Math.min(...distances); + const averageDistance = + distances.reduce((total, distance) => total + distance, 0) / + distances.length; + + if ( + minDistance > bestMinDistance || + (minDistance === bestMinDistance && + averageDistance > bestAverageDistance) + ) { + bestIndex = candidateIndex; + bestMinDistance = minDistance; + bestAverageDistance = averageDistance; + } + } + + selectedIndices.push(bestIndex); + availableIndices.delete(bestIndex); + } + + return Array.from( + { length: seriesCount }, + (_, index) => bgColors[selectedIndices[index % selectedIndices.length]], + ); +}; + +export const getColorOffset = (colorSeed?: number) => { + if (bgColors.length === 0) { + return 0; + } + + if (typeof colorSeed !== "number" || !Number.isFinite(colorSeed)) { + return Math.floor(Math.random() * bgColors.length); + } + + const seedText = colorSeed.toString(); + let hash = 0; + for (let index = 0; index < seedText.length; index++) { + hash = (hash * 31 + seedText.charCodeAt(index)) | 0; + } + return Math.abs(hash) % bgColors.length; +}; + +export const getBackgroundColor = (colorOffset: number) => + bgColors[colorOffset]; + +export const getRadarValueScale = ( + series: SpreadsheetSeries[], + _labelsLength: number, +) => { + const allValues = series.flatMap((s) => + s.values.map((value) => Math.max(0, value)), + ); + const positiveValues = allValues.filter((value) => value > 0); + const max = Math.max(1, ...allValues); + const minPositive = + positiveValues.length > 0 ? Math.min(...positiveValues) : 1; + const useLogScale = + series.length === 1 && + minPositive > 0 && + max / minPositive >= RADAR_SINGLE_SERIES_LOG_SCALE_THRESHOLD; + + return { + renderSteps: false, + normalize: (value: number, _axisIndex: number) => { + const safeValue = Math.max(0, value); + return useLogScale + ? Math.log10(safeValue + 1) / Math.log10(max + 1) + : safeValue / max; + }, + }; +}; + +const shouldWrapRadarText = (text: string) => /\s/.test(text.trim()); + +export const getRadarDisplayText = ( + text: string, + fontString: ReturnType, + maxWidth: number, +) => { + return shouldWrapRadarText(text) + ? wrapText(text, fontString, maxWidth) + : text; +}; + +export const createRadarAxisLabels = ( + labels: readonly string[], + angles: readonly number[], + centerX: number, + centerY: number, + radius: number, + backgroundColor: string, +): { + axisLabels: ChartElements; + axisLabelTopY: number; + axisLabelBottomY: number; +} => { + const fontFamily = FONT_FAMILY.Excalifont; + const fontSize = FONT_SIZES.sm; + const lineHeight = getLineHeight(fontFamily); + const fontString = getFontString({ fontFamily, fontSize }); + const baseLabelWidth = Math.min( + RADAR_AXIS_LABEL_MAX_WIDTH, + radius * (labels.length > 8 ? 0.56 : 0.72), + ); + const minLabelWidth = getApproxMinLineWidth(fontString, lineHeight); + + const axisLabels = labels.map((label, index) => { + const angle = angles[index]; + const longestWordWidth = Math.max( + 0, + ...label + .trim() + .split(/\s+/) + .filter(Boolean) + .map((word) => measureText(word, fontString, lineHeight).width), + ); + const maxLabelWidth = Math.max( + minLabelWidth, + baseLabelWidth, + longestWordWidth, + ); + const displayLabel = getRadarDisplayText(label, fontString, maxLabelWidth); + const metrics = measureText(displayLabel, fontString, lineHeight); + const cos = Math.cos(angle); + const sin = Math.sin(angle); + + const textAlign: "left" | "center" | "right" = + cos > RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD + ? "left" + : cos < -RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD + ? "right" + : "center"; + + // Keep labels outside the radar ring by projecting text extents + // onto the axis direction. + const centerAlignedXExtent = textAlign === "center" ? metrics.width / 2 : 0; + const projectedExtent = + Math.abs(cos) * centerAlignedXExtent + + Math.abs(sin) * (metrics.height / 2); + const radialOffset = + RADAR_LABEL_OFFSET + projectedExtent + RADAR_AXIS_LABEL_CLEARANCE; + const anchorX = centerX + cos * (radius + radialOffset); + const anchorY = centerY + sin * (radius + radialOffset); + + const yNudge = + sin > RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD + ? BAR_GAP / 3 + : sin < -RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD + ? -BAR_GAP / 3 + : 0; + + return newTextElement({ + backgroundColor, + ...commonProps, + text: displayLabel, + originalText: label, + x: anchorX, + y: anchorY + yNudge, + fontFamily, + fontSize, + lineHeight, + textAlign, + verticalAlign: "middle", + }); + }); + + const axisLabelTopY = Math.min(...axisLabels.map((axisLabel) => axisLabel.y)); + const axisLabelBottomY = Math.max( + ...axisLabels.map((axisLabel) => axisLabel.y + axisLabel.height), + ); + return { axisLabels, axisLabelTopY, axisLabelBottomY }; +}; + +export const createSeriesLegend = ( + series: SpreadsheetSeries[], + seriesColors: readonly string[], + centerX: number, + minLegendTopY: number, + fallbackLegendY: number, + backgroundColor: string, +): ChartElements => { + if (series.length <= 1) { + return []; + } + + const fontFamily = FONT_FAMILY["Lilita One"]; + const fontSize = FONT_SIZES.lg; + const lineHeight = getLineHeight(fontFamily); + const fontString = getFontString({ fontFamily, fontSize }); + const legendItems = series.map((seriesItem, index) => { + const label = seriesItem.title?.trim() || `Series ${index + 1}`; + const displayLabel = getRadarDisplayText(label, fontString, BAR_HEIGHT); + const metrics = measureText(displayLabel, fontString, lineHeight); + const itemWidth = + RADAR_LEGEND_SWATCH_SIZE + RADAR_LEGEND_TEXT_GAP + metrics.width; + return { + label, + displayLabel, + color: seriesColors[index], + width: itemWidth, + height: metrics.height, + }; + }); + const maxLegendHalfHeight = Math.max( + RADAR_LEGEND_SWATCH_SIZE / 2, + ...legendItems.map((item) => item.height / 2), + ); + const legendY = Math.max( + fallbackLegendY, + minLegendTopY + maxLegendHalfHeight + RADAR_LABEL_OFFSET, + ); + + const pillPaddingX = RADAR_LEGEND_ITEM_GAP; + const pillPaddingY = RADAR_LEGEND_SWATCH_SIZE * 0.6; + const totalLegendWidth = + legendItems.reduce((total, item) => total + item.width, 0) + + RADAR_LEGEND_ITEM_GAP * Math.max(0, legendItems.length - 1); + const pillWidth = totalLegendWidth + pillPaddingX * 2; + const pillHeight = maxLegendHalfHeight * 2 + pillPaddingY * 2; + + const legendElements: NonDeletedExcalidrawElement[] = []; + + // rounded pill background + legendElements.push( + newElement({ + ...commonProps, + backgroundColor: "transparent", + type: "rectangle", + fillStyle: "solid", + strokeColor: COLOR_PALETTE.black, + x: centerX - pillWidth / 2, + y: legendY - pillHeight / 2, + width: pillWidth, + height: pillHeight, + roughness: ROUGHNESS.architect, + roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS }, + }), + ); + + let cursorX = centerX - totalLegendWidth / 2; + + legendItems.forEach((item) => { + // solid filled swatch + legendElements.push( + newElement({ + ...commonProps, + backgroundColor: item.color, + type: "rectangle", + x: cursorX, + y: legendY - RADAR_LEGEND_SWATCH_SIZE / 2, + width: RADAR_LEGEND_SWATCH_SIZE, + height: RADAR_LEGEND_SWATCH_SIZE, + fillStyle: "solid", + strokeColor: item.color, + roughness: ROUGHNESS.architect, + roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS }, + }), + ); + + // label in default (black) color + legendElements.push( + newTextElement({ + ...commonProps, + text: item.displayLabel, + originalText: item.label, + autoResize: false, + x: cursorX + RADAR_LEGEND_SWATCH_SIZE + RADAR_LEGEND_TEXT_GAP, + y: legendY, + fontFamily, + fontSize, + lineHeight, + textAlign: "left", + verticalAlign: "middle", + }), + ); + + cursorX += item.width + RADAR_LEGEND_ITEM_GAP; + }); + + return legendElements; +}; + +const ellipsifyTextToWidth = ( + text: string, + maxWidth: number, + fontString: ReturnType, + lineHeight: ExcalidrawTextElement["lineHeight"], +) => { + if (measureText(text, fontString, lineHeight).width <= maxWidth) { + return text; + } + + let end = text.length; + while (end > 1) { + const candidate = `${text.slice(0, end)}...`; + if (measureText(candidate, fontString, lineHeight).width <= maxWidth) { + return candidate; + } + end--; + } + + return text[0] ? `${text[0]}...` : text; +}; + +const wrapOrEllipsifyTextToWidth = ( + text: string, + maxWidth: number, + fontString: ReturnType, + lineHeight: ExcalidrawTextElement["lineHeight"], +) => { + if (measureText(text, fontString, lineHeight).width <= maxWidth) { + return { wrapped: false, text }; + } + + const words = text.trim().split(/\s+/).filter(Boolean); + if (words.length > 1) { + const hasLongWord = words.some((word) => { + return measureText(word, fontString, lineHeight).width > maxWidth; + }); + if ( + !hasLongWord && + maxWidth >= getApproxMinLineWidth(fontString, lineHeight) + ) { + return { wrapped: true, text: wrapText(text, fontString, maxWidth) }; + } + } + + return { + wrapped: false, + text: ellipsifyTextToWidth(text, maxWidth, fontString, lineHeight), + }; +}; + +const getRotatedBoundingBox = ( + width: number, + height: number, + angle: number, +) => { + const cos = Math.abs(Math.cos(angle)); + const sin = Math.abs(Math.sin(angle)); + return { + width: width * cos + height * sin, + height: width * sin + height * cos, + }; +}; + +type CartesianAxisLabelSpec = { + originalText: string; + text: string; + wrapped: boolean; + metrics: ReturnType; + rotatedWidth: number; + rotatedHeight: number; +}; + +const isEllipsifiedLabel = (text: string) => text.includes("..."); + +const getCartesianAxisLabelSpec = ( + label: string, + maxLabelWidth: number, + maxRotatedWidth: number, + fontString: ReturnType, + lineHeight: ExcalidrawTextElement["lineHeight"], +): CartesianAxisLabelSpec => { + const minWidth = Math.max( + CARTESIAN_LABEL_MIN_WIDTH, + Math.ceil(getApproxMinLineWidth(fontString, lineHeight)), + ); + const maxWidth = Math.max(minWidth, Math.floor(maxLabelWidth)); + const candidateWidths: number[] = []; + for (let width = maxWidth; width >= minWidth; width -= 4) { + candidateWidths.push(width); + } + if (candidateWidths[candidateWidths.length - 1] !== minWidth) { + candidateWidths.push(minWidth); + } + + const getRank = (spec: CartesianAxisLabelSpec) => { + const ellipsified = isEllipsifiedLabel(spec.text); + const visibleChars = spec.text + .replace(/\.\.\./g, "") + .replace(/\n/g, "").length; + const lineCount = spec.text.split("\n").length; + return { + ellipsified, + visibleChars, + lineCount, + }; + }; + + const shouldPrefer = ( + candidate: CartesianAxisLabelSpec, + current: CartesianAxisLabelSpec, + ) => { + const candidateRank = getRank(candidate); + const currentRank = getRank(current); + if (candidateRank.ellipsified !== currentRank.ellipsified) { + return !candidateRank.ellipsified; + } + if (candidateRank.visibleChars !== currentRank.visibleChars) { + return candidateRank.visibleChars > currentRank.visibleChars; + } + if (candidateRank.lineCount !== currentRank.lineCount) { + return candidateRank.lineCount < currentRank.lineCount; + } + return candidate.rotatedHeight < current.rotatedHeight; + }; + + let bestFit: CartesianAxisLabelSpec | null = null; + let bestOverflowAny: { + overflow: number; + spec: CartesianAxisLabelSpec; + } | null = null; + let bestOverflowNonEllipsified: { + overflow: number; + spec: CartesianAxisLabelSpec; + } | null = null; + + for (const width of candidateWidths) { + const { wrapped, text } = wrapOrEllipsifyTextToWidth( + label, + width, + fontString, + lineHeight, + ); + const metrics = measureText(text, fontString, lineHeight); + const rotated = getRotatedBoundingBox( + metrics.width, + metrics.height, + CARTESIAN_LABEL_ROTATION, + ); + const spec = { + originalText: label, + text, + metrics, + rotatedWidth: rotated.width, + rotatedHeight: rotated.height, + wrapped, + }; + const overflow = rotated.width - maxRotatedWidth; + if (overflow <= 0) { + if (!bestFit || shouldPrefer(spec, bestFit)) { + bestFit = spec; + } + continue; + } + if ( + !bestOverflowAny || + overflow < bestOverflowAny.overflow || + (overflow === bestOverflowAny.overflow && + shouldPrefer(spec, bestOverflowAny.spec)) + ) { + bestOverflowAny = { overflow, spec }; + } + if ( + !isEllipsifiedLabel(spec.text) && + (!bestOverflowNonEllipsified || + overflow < bestOverflowNonEllipsified.overflow || + (overflow === bestOverflowNonEllipsified.overflow && + shouldPrefer(spec, bestOverflowNonEllipsified.spec))) + ) { + bestOverflowNonEllipsified = { overflow, spec }; + } + } + + if (bestFit) { + return bestFit; + } + + if ( + bestOverflowNonEllipsified && + bestOverflowAny && + bestOverflowNonEllipsified.overflow <= + bestOverflowAny.overflow + CARTESIAN_LABEL_OVERFLOW_PREFERENCE_BUFFER + ) { + return bestOverflowNonEllipsified.spec; + } + + return bestOverflowAny!.spec; +}; + +export const getRotatedTextElementBottom = ( + element: NonDeletedExcalidrawElement, +) => { + if (element.type !== "text") { + return element.y + element.height; + } + const rotated = getRotatedBoundingBox( + element.width, + element.height, + element.angle, + ); + return element.y + element.height / 2 + rotated.height / 2; +}; + +export const chartXLabels = ( + spreadsheet: Spreadsheet, + x: number, + y: number, + backgroundColor: string, + layout: CartesianChartLayout, +): ChartElements => { + const fontFamily = commonProps.fontFamily; + const fontSize = FONT_SIZES.sm; + const lineHeight = getLineHeight(fontFamily); + const fontString = getFontString({ fontFamily, fontSize }); + const maxRotatedWidth = Math.max( + 1, + layout.slotWidth + + layout.gap - + CARTESIAN_LABEL_SLOT_PADDING * 2 + + CARTESIAN_LABEL_ROTATED_WIDTH_BUFFER, + ); + const axisY = y; + + return ( + spreadsheet.labels?.map((label, index) => { + const labelSpec = getCartesianAxisLabelSpec( + label, + layout.xLabelMaxWidth, + maxRotatedWidth, + fontString, + lineHeight, + ); + const centerX = + x + + index * (layout.slotWidth + layout.gap) + + layout.gap + + layout.slotWidth / 2; + const labelY = + axisY + + CARTESIAN_LABEL_AXIS_CLEARANCE + + (labelSpec.rotatedHeight - labelSpec.metrics.height) / 2; + + return newTextElement({ + backgroundColor, + ...commonProps, + text: labelSpec.text, + originalText: labelSpec.wrapped ? label : labelSpec.text, + autoResize: !labelSpec.wrapped, + x: centerX, + y: labelY, + angle: CARTESIAN_LABEL_ROTATION, + fontSize, + lineHeight, + textAlign: "center", + verticalAlign: "top", + }); + }) || [] + ); +}; + +const chartYLabels = ( + spreadsheet: Spreadsheet, + x: number, + y: number, + backgroundColor: string, + layout: CartesianChartLayout, + maxValue = Math.max(...spreadsheet.series[0].values), +): ChartElements => { + const minYLabel = newTextElement({ + backgroundColor, + ...commonProps, + x: x - layout.gap, + y: y - layout.gap, + text: "0", + textAlign: "right", + }); + + const maxYLabel = newTextElement({ + backgroundColor, + ...commonProps, + x: x - layout.gap, + y: y - layout.chartHeight - minYLabel.height / 2, + text: maxValue.toLocaleString(), + textAlign: "right", + }); + + return [minYLabel, maxYLabel]; +}; + +const chartLines = ( + spreadsheet: Spreadsheet, + x: number, + y: number, + backgroundColor: string, + layout: CartesianChartLayout, +): ChartElements => { + const { chartWidth, chartHeight } = getChartDimensions(spreadsheet, layout); + const xLine = newLinearElement({ + backgroundColor, + ...commonProps, + type: "line", + x, + y, + width: chartWidth, + points: [pointFrom(0, 0), pointFrom(chartWidth, 0)], + }); + + const yLine = newLinearElement({ + backgroundColor, + ...commonProps, + type: "line", + x, + y, + height: chartHeight, + points: [pointFrom(0, 0), pointFrom(0, -chartHeight)], + }); + + const maxLine = newLinearElement({ + backgroundColor, + ...commonProps, + type: "line", + x, + y: y - layout.chartHeight - layout.gap, + strokeStyle: "dotted", + width: chartWidth, + opacity: GRID_OPACITY, + points: [pointFrom(0, 0), pointFrom(chartWidth, 0)], + }); + + return [xLine, yLine, maxLine]; +}; + +// For the maths behind it https://excalidraw.com/#json=6320864370884608,O_5xfD-Agh32tytHpRJx1g +export const chartBaseElements = ( + spreadsheet: Spreadsheet, + x: number, + y: number, + backgroundColor: string, + layout: CartesianChartLayout, + maxValue = Math.max(...spreadsheet.series[0].values), + debug?: boolean, +): ChartElements => { + const { chartWidth, chartHeight } = getChartDimensions(spreadsheet, layout); + + const title = spreadsheet.title + ? newTextElement({ + backgroundColor, + ...commonProps, + text: spreadsheet.title, + x: x + chartWidth / 2, + y: y - layout.chartHeight - layout.gap * 2 - DEFAULT_FONT_SIZE, + roundness: null, + textAlign: "center", + fontSize: FONT_SIZES.xl, + fontFamily: FONT_FAMILY["Lilita One"], + }) + : null; + + const debugRect = debug + ? newElement({ + backgroundColor, + ...commonProps, + type: "rectangle", + x, + y: y - chartHeight, + width: chartWidth, + height: chartHeight, + strokeColor: COLOR_PALETTE.black, + fillStyle: "solid", + opacity: 6, + }) + : null; + + return [ + ...(debugRect ? [debugRect] : []), + ...(title ? [title] : []), + ...chartXLabels(spreadsheet, x, y, backgroundColor, layout), + ...chartYLabels(spreadsheet, x, y, backgroundColor, layout, maxValue), + ...chartLines(spreadsheet, x, y, backgroundColor, layout), + ]; +}; diff --git a/packages/excalidraw/charts/charts.line.ts b/packages/excalidraw/charts/charts.line.ts new file mode 100644 index 0000000000..b08774d8b3 --- /dev/null +++ b/packages/excalidraw/charts/charts.line.ts @@ -0,0 +1,130 @@ +import { pointFrom } from "@excalidraw/math"; + +import { isDevEnv } from "@excalidraw/common"; + +import { newElement, newLinearElement } from "@excalidraw/element"; + +import type { LocalPoint } from "@excalidraw/math"; + +import { GRID_OPACITY, commonProps } from "./charts.constants"; +import { + chartBaseElements, + chartXLabels, + createSeriesLegend, + getBackgroundColor, + getCartesianChartLayout, + getChartDimensions, + getColorOffset, + getRotatedTextElementBottom, + getSeriesColors, +} from "./charts.helpers"; + +import type { ChartElements, Spreadsheet } from "./charts.types"; + +export const renderLineChart = ( + spreadsheet: Spreadsheet, + x: number, + y: number, + colorSeed?: number, +): ChartElements => { + const series = spreadsheet.series; + const layout = getCartesianChartLayout("line", series.length); + const max = Math.max(1, ...series.flatMap((seriesData) => seriesData.values)); + const colorOffset = getColorOffset(colorSeed); + const backgroundColor = getBackgroundColor(colorOffset); + const seriesColors = getSeriesColors(series.length, colorOffset); + + const lines = series.map((seriesData, seriesIndex) => { + const points = seriesData.values.map((value, valueIndex) => + pointFrom( + valueIndex * (layout.slotWidth + layout.gap), + -(value / max) * layout.chartHeight, + ), + ); + + const maxX = Math.max(...points.map((point) => point[0])); + const maxY = Math.max(...points.map((point) => point[1])); + const minX = Math.min(...points.map((point) => point[0])); + const minY = Math.min(...points.map((point) => point[1])); + + return newLinearElement({ + backgroundColor: "transparent", + ...commonProps, + type: "line", + x: x + layout.gap + layout.slotWidth / 2, + y: y - layout.gap, + height: maxY - minY, + width: maxX - minX, + strokeColor: seriesColors[seriesIndex], + strokeWidth: 2, + points, + }); + }); + + const dots = series.flatMap((seriesData, seriesIndex) => + seriesData.values.map((value, valueIndex) => { + const cx = valueIndex * (layout.slotWidth + layout.gap) + layout.gap / 2; + const cy = -(value / max) * layout.chartHeight + layout.gap / 2; + return newElement({ + backgroundColor: seriesColors[seriesIndex], + ...commonProps, + fillStyle: "solid", + strokeColor: seriesColors[seriesIndex], + strokeWidth: 2, + type: "ellipse", + x: x + cx + layout.slotWidth / 2, + y: y + cy - layout.gap * 2, + width: layout.gap, + height: layout.gap, + }); + }), + ); + + const guideValues = series[0].values.map((_, valueIndex) => + Math.max( + 0, + ...series.map((seriesData) => seriesData.values[valueIndex] ?? 0), + ), + ); + const guides = guideValues.map((value, valueIndex) => { + const cx = valueIndex * (layout.slotWidth + layout.gap) + layout.gap / 2; + const cy = (value / max) * layout.chartHeight + layout.gap / 2 + layout.gap; + return newLinearElement({ + backgroundColor, + ...commonProps, + type: "line", + x: x + cx + layout.slotWidth / 2 + layout.gap / 2, + y: y - cy, + height: cy, + strokeStyle: "dotted", + opacity: GRID_OPACITY, + points: [pointFrom(0, 0), pointFrom(0, cy)], + }); + }); + + const baseElements = chartBaseElements( + spreadsheet, + x, + y, + backgroundColor, + layout, + max, + isDevEnv(), + ); + const xLabels = chartXLabels(spreadsheet, x, y, backgroundColor, layout); + const xLabelsBottomY = Math.max( + y + layout.gap / 2, + ...xLabels.map((label) => getRotatedTextElementBottom(label)), + ); + const { chartWidth } = getChartDimensions(spreadsheet, layout); + const seriesLegend = createSeriesLegend( + series, + seriesColors, + x + chartWidth / 2, + xLabelsBottomY, + y + layout.gap * 5, + backgroundColor, + ); + + return [...baseElements, ...lines, ...guides, ...dots, ...seriesLegend]; +}; diff --git a/packages/excalidraw/charts/charts.parse.ts b/packages/excalidraw/charts/charts.parse.ts new file mode 100644 index 0000000000..f6d71fdf69 --- /dev/null +++ b/packages/excalidraw/charts/charts.parse.ts @@ -0,0 +1,174 @@ +import { type ParseSpreadsheetResult } from "./charts.types"; + +/** + * @private exported for testing + */ +export const tryParseNumber = (s: string): number | null => { + const match = + /^([-+]?)[$\u20AC\u00A3\u00A5\u20A9]?([-+]?)([\d.,]+)[%]?$/.exec(s); + if (!match) { + return null; + } + return parseFloat(`${(match[1] || match[2]) + match[3]}`.replace(/,/g, "")); +}; + +const isNumericColumn = (lines: string[][], columnIndex: number) => + lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null); + +/** + * @private exported for testing + */ +export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => { + const numCols = cells[0].length; + + if (numCols > 2) { + const hasHeader = cells[0].every((cell) => tryParseNumber(cell) === null); + const rows = hasHeader ? cells.slice(1) : cells; + + if (rows.length < 1) { + return { ok: false, reason: "No data rows" }; + } + + const invalidNumericColumn = rows.some((row) => + row.slice(1).some((value) => tryParseNumber(value) === null), + ); + if (invalidNumericColumn) { + return { ok: false, reason: "Value is not numeric" }; + } + + // When there are more value columns than data rows, the data is in + // "wide" format — transpose so columns become labels (dimensions) + // and rows become series. This enables e.g. radar charts for wide data. + const numValueCols = numCols - 1; + if (numValueCols > rows.length) { + const labels = hasHeader ? cells[0].slice(1).map((h) => h.trim()) : null; + const series = rows.map((row) => ({ + title: row[0]?.trim() || null, + values: row.slice(1).map((v) => tryParseNumber(v)!), + })); + const title = + series.length === 1 + ? series[0].title + : hasHeader + ? cells[0][0].trim() || null + : null; + return { + ok: true, + data: { title, labels, series }, + }; + } + + const series = cells[0].slice(1).map((seriesTitle, index) => { + const valueColumnIndex = index + 1; + const fallbackTitle = `Series ${valueColumnIndex}`; + return { + title: hasHeader ? seriesTitle.trim() || fallbackTitle : fallbackTitle, + values: rows.map((row) => tryParseNumber(row[valueColumnIndex])!), + }; + }); + + return { + ok: true, + data: { + title: hasHeader ? cells[0][0].trim() || null : null, + labels: rows.map((row) => row[0]), + series, + }, + }; + } + + if (numCols === 1) { + if (!isNumericColumn(cells, 0)) { + return { ok: false, reason: "Value is not numeric" }; + } + + const hasHeader = tryParseNumber(cells[0][0]) === null; + const title = hasHeader ? cells[0][0] : null; + const values = (hasHeader ? cells.slice(1) : cells).map((line) => + tryParseNumber(line[0]), + ); + + if (values.length < 2) { + return { ok: false, reason: "Less than two rows" }; + } + + return { + ok: true, + data: { + title, + labels: null, + series: [{ title, values: values as number[] }], + }, + }; + } + + const hasHeader = tryParseNumber(cells[0][1]) === null; + const rows = hasHeader ? cells.slice(1) : cells; + + if (rows.length < 2) { + return { ok: false, reason: "Less than 2 rows" }; + } + + const invalidNumericColumn = rows.some( + (row) => tryParseNumber(row[1]) === null, + ); + if (invalidNumericColumn) { + return { ok: false, reason: "Value is not numeric" }; + } + + const title = hasHeader ? cells[0][1] : null; + + return { + ok: true, + data: { + title, + labels: rows.map((row) => row[0]), + series: [{ title, values: rows.map((row) => tryParseNumber(row[1])!) }], + }, + }; +}; + +export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => { + // Copy/paste from excel, spreadsheets, TSV, CSV, semicolon-separated. + const parseDelimitedLines = (delimiter: "\t" | "," | ";") => + text + .replace(/\r\n?/g, "\n") + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line) => line.split(delimiter).map((cell) => cell.trim())); + + // Score each delimiter: prefer consistent column counts with the most columns. + // A delimiter that produces all single-column rows likely isn't the right one. + const candidates = (["\t", ",", ";"] as const).map((delimiter) => { + const parsed = parseDelimitedLines(delimiter); + const numCols = parsed[0]?.length ?? 0; + const isConsistent = + parsed.length > 0 && parsed.every((line) => line.length === numCols); + return { delimiter, parsed, numCols, isConsistent }; + }); + + // Prefer: consistent + most columns. Among ties, tab > comma > semicolon + // (the array order already encodes this priority). + const best = + candidates.find((c) => c.isConsistent && c.numCols > 1) ?? + candidates.find((c) => c.isConsistent) ?? + candidates[0]; + + const lines = best.parsed; + + if (lines.length === 0) { + return { ok: false, reason: "No values" }; + } + + const numColsFirstLine = lines[0].length; + const isSpreadsheet = lines.every((line) => line.length === numColsFirstLine); + + if (!isSpreadsheet) { + return { + ok: false, + reason: "All rows don't have same number of columns", + }; + } + + return tryParseCells(lines); +}; diff --git a/packages/excalidraw/charts/charts.radar.ts b/packages/excalidraw/charts/charts.radar.ts new file mode 100644 index 0000000000..6606a4af60 --- /dev/null +++ b/packages/excalidraw/charts/charts.radar.ts @@ -0,0 +1,199 @@ +import { pointFrom } from "@excalidraw/math"; + +import { + FONT_FAMILY, + FONT_SIZES, + getFontString, + getLineHeight, + ROUGHNESS, +} from "@excalidraw/common"; + +import { + measureText, + newLinearElement, + newTextElement, +} from "@excalidraw/element"; + +import type { LocalPoint } from "@excalidraw/math"; + +import { + BAR_GAP, + BAR_HEIGHT, + GRID_OPACITY, + RADAR_GRID_LEVELS, + RADAR_LABEL_OFFSET, + commonProps, +} from "./charts.constants"; +import { + createRadarAxisLabels, + createSeriesLegend, + getBackgroundColor, + getColorOffset, + getRadarDimensions, + getRadarDisplayText, + getRadarValueScale, + getSeriesColors, + isSpreadsheetValidForChartType, +} from "./charts.helpers"; + +import type { ChartElements, Spreadsheet } from "./charts.types"; + +export const renderRadarChart = ( + spreadsheet: Spreadsheet, + x: number, + y: number, + colorSeed?: number, +): ChartElements | null => { + if (!isSpreadsheetValidForChartType(spreadsheet, "radar")) { + return null; + } + + const labels = + spreadsheet.labels ?? + spreadsheet.series[0].values.map((_, index) => `Value ${index + 1}`); + + const series = spreadsheet.series; + const { normalize, renderSteps } = getRadarValueScale(series, labels.length); + const colorOffset = getColorOffset(colorSeed); + const backgroundColor = getBackgroundColor(colorOffset); + const seriesColors = getSeriesColors(series.length, colorOffset); + const { chartWidth, chartHeight } = getRadarDimensions(); + const centerX = x + chartWidth / 2; + const centerY = y - chartHeight / 2; + const radius = BAR_HEIGHT / 2; + const angles = labels.map( + (_, index) => -Math.PI / 2 + (Math.PI * 2 * index) / labels.length, + ); + + const { axisLabels, axisLabelTopY, axisLabelBottomY } = createRadarAxisLabels( + labels, + angles, + centerX, + centerY, + radius, + backgroundColor, + ); + + const titleFontFamily = FONT_FAMILY["Lilita One"]; + const titleFontSize = FONT_SIZES.xl; + const titleLineHeight = getLineHeight(titleFontFamily); + const titleFontString = getFontString({ + fontFamily: titleFontFamily, + fontSize: titleFontSize, + }); + const titleText = spreadsheet.title + ? getRadarDisplayText( + spreadsheet.title, + titleFontString, + chartWidth + RADAR_LABEL_OFFSET * 2, + ) + : null; + const titleTextMetrics = titleText + ? measureText(titleText, titleFontString, titleLineHeight) + : null; + const title = titleText + ? newTextElement({ + backgroundColor, + ...commonProps, + text: titleText, + originalText: spreadsheet.title ?? titleText, + x: x + chartWidth / 2, + y: axisLabelTopY - RADAR_LABEL_OFFSET - titleTextMetrics!.height / 2, + fontFamily: titleFontFamily, + fontSize: titleFontSize, + lineHeight: titleLineHeight, + textAlign: "center", + }) + : null; + + const radarGridLines = renderSteps + ? Array.from({ length: RADAR_GRID_LEVELS }, (_, levelIndex) => { + const levelRatio = (levelIndex + 1) / RADAR_GRID_LEVELS; + const levelRadius = radius * levelRatio; + const points = angles.map((angle) => + pointFrom( + Math.cos(angle) * levelRadius, + Math.sin(angle) * levelRadius, + ), + ); + points.push(pointFrom(points[0][0], points[0][1])); + + return newLinearElement({ + backgroundColor: "transparent", + ...commonProps, + type: "line", + x: centerX, + y: centerY, + width: levelRadius * 2, + height: levelRadius * 2, + strokeStyle: "solid", + roughness: ROUGHNESS.architect, + opacity: GRID_OPACITY, + polygon: true, + points, + }); + }) + : []; + + const spokes = angles.map((angle) => { + const px = Math.cos(angle) * radius; + const py = Math.sin(angle) * radius; + return newLinearElement({ + backgroundColor: "transparent", + ...commonProps, + type: "line", + x: centerX, + y: centerY, + width: Math.abs(px), + height: Math.abs(py), + strokeStyle: "solid", + roughness: ROUGHNESS.architect, + opacity: GRID_OPACITY, + points: [pointFrom(0, 0), pointFrom(px, py)], + }); + }); + + const seriesPolygons = series.map((seriesData, index) => { + const points = angles.map((angle, axisIndex) => { + const value = seriesData.values[axisIndex] ?? 0; + const pointRadius = normalize(value, axisIndex) * radius; + return pointFrom( + Math.cos(angle) * pointRadius, + Math.sin(angle) * pointRadius, + ); + }); + points.push(pointFrom(points[0][0], points[0][1])); + + return newLinearElement({ + backgroundColor: "transparent", + ...commonProps, + type: "line", + x: centerX, + y: centerY, + width: radius * 2, + height: radius * 2, + strokeColor: seriesColors[index], + strokeWidth: 2, + polygon: true, + points, + }); + }); + + const seriesLegend = createSeriesLegend( + series, + seriesColors, + centerX, + axisLabelBottomY, + y + BAR_GAP * 5, + backgroundColor, + ); + + return [ + ...(title ? [title] : []), + ...axisLabels, + ...radarGridLines, + ...spokes, + ...seriesPolygons, + ...seriesLegend, + ]; +}; diff --git a/packages/excalidraw/charts/charts.types.ts b/packages/excalidraw/charts/charts.types.ts new file mode 100644 index 0000000000..29f3971a58 --- /dev/null +++ b/packages/excalidraw/charts/charts.types.ts @@ -0,0 +1,18 @@ +import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; + +export type ChartElements = readonly NonDeletedExcalidrawElement[]; + +export interface Spreadsheet { + title: string | null; + labels: string[] | null; + series: SpreadsheetSeries[]; +} + +export interface SpreadsheetSeries { + title: string | null; + values: number[]; +} + +export type ParseSpreadsheetResult = + | { ok: false; reason: string } + | { ok: true; data: Spreadsheet }; diff --git a/packages/excalidraw/charts/index.ts b/packages/excalidraw/charts/index.ts new file mode 100644 index 0000000000..d806546a49 --- /dev/null +++ b/packages/excalidraw/charts/index.ts @@ -0,0 +1,38 @@ +import type { ChartType } from "@excalidraw/element/types"; + +import { renderBarChart } from "./charts.bar"; +import { renderLineChart } from "./charts.line"; +import { + tryParseCells, + tryParseNumber, + tryParseSpreadsheet, +} from "./charts.parse"; +import { renderRadarChart } from "./charts.radar"; + +import type { ChartElements, Spreadsheet } from "./charts.types"; + +export { + type ParseSpreadsheetResult, + type Spreadsheet, + type SpreadsheetSeries, + type ChartElements, +} from "./charts.types"; + +export { isSpreadsheetValidForChartType } from "./charts.helpers"; +export { tryParseCells, tryParseNumber, tryParseSpreadsheet }; + +export const renderSpreadsheet = ( + chartType: ChartType, + spreadsheet: Spreadsheet, + x: number, + y: number, + colorSeed?: number, +): ChartElements | null => { + if (chartType === "line") { + return renderLineChart(spreadsheet, x, y, colorSeed); + } + if (chartType === "radar") { + return renderRadarChart(spreadsheet, x, y, colorSeed); + } + return renderBarChart(spreadsheet, x, y, colorSeed); +}; diff --git a/packages/excalidraw/clipboard.test.ts b/packages/excalidraw/clipboard.test.ts index 2115c3eff2..6f2b6fc374 100644 --- a/packages/excalidraw/clipboard.test.ts +++ b/packages/excalidraw/clipboard.test.ts @@ -155,67 +155,4 @@ describe("parseClipboard()", () => { }, ]); }); - - it("should parse spreadsheet from either text/plain and text/html", async () => { - let clipboardData; - // ------------------------------------------------------------------------- - clipboardData = await parseClipboard( - await parseDataTransferEvent( - createPasteEvent({ - types: { - "text/plain": `a b - 1 2 - 4 5 - 7 10`, - }, - }), - ), - ); - expect(clipboardData.spreadsheet).toEqual({ - title: "b", - labels: ["1", "4", "7"], - values: [2, 5, 10], - }); - // ------------------------------------------------------------------------- - clipboardData = await parseClipboard( - await parseDataTransferEvent( - createPasteEvent({ - types: { - "text/html": `a b - 1 2 - 4 5 - 7 10`, - }, - }), - ), - ); - expect(clipboardData.spreadsheet).toEqual({ - title: "b", - labels: ["1", "4", "7"], - values: [2, 5, 10], - }); - // ------------------------------------------------------------------------- - clipboardData = await parseClipboard( - await parseDataTransferEvent( - createPasteEvent({ - types: { - "text/html": ` - -
ab
12
45
710
- - `, - "text/plain": `a b - 1 2 - 4 5 - 7 10`, - }, - }), - ), - ); - expect(clipboardData.spreadsheet).toEqual({ - title: "b", - labels: ["1", "4", "7"], - values: [2, 5, 10], - }); - }); }); diff --git a/packages/excalidraw/clipboard.ts b/packages/excalidraw/clipboard.ts index 6033b857af..165534741f 100644 --- a/packages/excalidraw/clipboard.ts +++ b/packages/excalidraw/clipboard.ts @@ -33,12 +33,8 @@ import { normalizeFile, } from "./data/blob"; -import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts"; - import type { FileSystemHandle } from "./data/filesystem"; -import type { Spreadsheet } from "./charts"; - import type { BinaryFiles } from "./types"; type ElementsClipboard = { @@ -50,7 +46,6 @@ type ElementsClipboard = { export type PastedMixedContent = { type: "text" | "imageUrl"; value: string }[]; export interface ClipboardData { - spreadsheet?: Spreadsheet; elements?: readonly ExcalidrawElement[]; files?: BinaryFiles; text?: string; @@ -215,16 +210,6 @@ export const copyToClipboard = async ( ); }; -const parsePotentialSpreadsheet = ( - text: string, -): { spreadsheet: Spreadsheet } | { errorMessage: string } | null => { - const result = tryParseSpreadsheet(text); - if (result.type === VALID_SPREADSHEET) { - return { spreadsheet: result.spreadsheet }; - } - return null; -}; - /** internal, specific to parsing paste events. Do not reuse. */ function parseHTMLTree(el: ChildNode) { let result: PastedMixedContent = []; @@ -551,19 +536,6 @@ export const parseClipboard = async ( }; } - try { - // if system clipboard contains spreadsheet, use it even though it's - // technically possible it's staler than in-app clipboard - const spreadsheetResult = - !isPlainPaste && parsePotentialSpreadsheet(parsedEventData.value); - - if (spreadsheetResult) { - return spreadsheetResult; - } - } catch (error: any) { - console.error(error); - } - try { const systemClipboardData = JSON.parse(parsedEventData.value); const programmaticAPI = diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 285421e357..adbcce44e5 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -425,6 +425,8 @@ import { EraserTrail } from "../eraser"; import { getShortcutKey } from "../shortcut"; +import { tryParseSpreadsheet } from "../charts"; + import ConvertElementTypePopup, { getConversionTypeFromElements, convertElementTypePopupAtom, @@ -3542,14 +3544,19 @@ class App extends React.Component { } // ------------------- Spreadsheet ------------------- - if (data.spreadsheet && !isPlainPaste) { - this.setState({ - pasteDialog: { - data: data.spreadsheet, - shown: true, - }, - }); - return; + + if (!isPlainPaste && data.text) { + const result = tryParseSpreadsheet(data.text); + if (result.ok) { + this.setState({ + openDialog: { + name: "charts", + data: result.data, + rawText: data.text, + }, + }); + return; + } } // ------------------- Images or SVG code ------------------- diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index 11447bbd31..85d2701b11 100644 --- a/packages/excalidraw/components/LayerUI.tsx +++ b/packages/excalidraw/components/LayerUI.tsx @@ -556,13 +556,13 @@ const LayerUI = ({ {renderImageExportDialog()} {renderJSONExportDialog()} - {appState.pasteDialog.shown && ( + {appState.openDialog?.name === "charts" && ( setAppState({ - pasteDialog: { shown: false, data: null }, + openDialog: null, }) } /> diff --git a/packages/excalidraw/components/PasteChartDialog.scss b/packages/excalidraw/components/PasteChartDialog.scss index 3bd21b02be..7e73768e39 100644 --- a/packages/excalidraw/components/PasteChartDialog.scss +++ b/packages/excalidraw/components/PasteChartDialog.scss @@ -2,6 +2,40 @@ .excalidraw { .PasteChartDialog { + .PasteChartDialog__title { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .PasteChartDialog__titleText { + min-width: 0; + } + + .PasteChartDialog__reshuffleBtn { + margin-left: auto; + flex: 0 0 auto; + width: 1rem; + height: 1rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 4px; + cursor: pointer; + color: var(--text-primary-color); + transition: transform 120ms ease, background-color 120ms ease, + color 120ms ease; + user-select: none; + + &:hover { + color: $color-blue-6; + } + + &:active { + transform: scale(0.94); + } + } + @include isMobile { .Island { display: flex; @@ -11,35 +45,61 @@ .container { display: flex; align-items: center; - justify-content: space-around; + justify-content: center; flex-wrap: wrap; + gap: 1rem; @include isMobile { flex-direction: column; justify-content: center; + align-items: stretch; } } .ChartPreview { - margin: 8px; - text-align: center; - width: 192px; - height: 128px; - border-radius: 2px; - padding: 1px; + width: 260px; + min-height: 190px; + border-radius: 8px; + padding: 10px; border: 1px solid $color-gray-4; display: flex; - align-items: center; - justify-content: center; + flex-direction: column; + align-items: stretch; + justify-content: flex-start; + gap: 10px; background: transparent; - div { - display: inline-block; + .ChartPreview__canvas { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + overflow: hidden; + } + .ChartPreview__label { + font-size: 0.875rem; + font-weight: 600; + line-height: 1; + text-align: center; + color: var(--text-primary-color); } svg { - max-height: 120px; - max-width: 186px; + max-height: 144px; + max-width: 100%; } &:hover { - padding: 0; - border: 2px solid $color-blue-5; + border-color: $color-blue-5; + } + &:active { + border-color: $color-blue-5; + box-shadow: 0 0 0 1px $color-blue-5; + transform: scale(0.98); + } + &:focus-visible { + border-color: $color-blue-5; + box-shadow: 0 0 0 1px $color-blue-5; + } + + @include isMobile { + width: 100%; + min-height: 200px; } } } diff --git a/packages/excalidraw/components/PasteChartDialog.tsx b/packages/excalidraw/components/PasteChartDialog.tsx index 2db632a63b..19c3271766 100644 --- a/packages/excalidraw/components/PasteChartDialog.tsx +++ b/packages/excalidraw/components/PasteChartDialog.tsx @@ -1,35 +1,57 @@ import React, { useLayoutEffect, useRef, useState } from "react"; +import { newTextElement } from "@excalidraw/element"; + import type { ChartType } from "@excalidraw/element/types"; import { trackEvent } from "../analytics"; -import { renderSpreadsheet } from "../charts"; +import { isSpreadsheetValidForChartType, renderSpreadsheet } from "../charts"; import { t } from "../i18n"; import { exportToSvg } from "../scene/export"; +import { useUIAppState } from "../context/ui-appState"; + import { useApp } from "./App"; import { Dialog } from "./Dialog"; import "./PasteChartDialog.scss"; +import { bucketFillIcon } from "./icons"; + import type { ChartElements, Spreadsheet } from "../charts"; -import type { UIAppState } from "../types"; + +type OnPlainTextPaste = (rawText: string) => void; type OnInsertChart = (chartType: ChartType, elements: ChartElements) => void; +const getChartTypeLabel = (chartType: ChartType) => { + switch (chartType) { + case "bar": + return t("labels.chartType_bar"); + case "line": + return t("labels.chartType_line"); + case "radar": + return t("labels.chartType_radar"); + default: + return chartType; + } +}; + const ChartPreviewBtn = (props: { spreadsheet: Spreadsheet | null; chartType: ChartType; - selected: boolean; + colorSeed: number; onClick: OnInsertChart; }) => { const previewRef = useRef(null); const [chartElements, setChartElements] = useState( null, ); + const { theme } = useUIAppState(); useLayoutEffect(() => { if (!props.spreadsheet) { + setChartElements(null); return; } @@ -38,7 +60,13 @@ const ChartPreviewBtn = (props: { props.spreadsheet, 0, 0, + props.colorSeed, ); + if (!elements) { + setChartElements(null); + previewRef.current?.replaceChildren(); + return; + } setChartElements(elements); let svg: SVGSVGElement; const previewNode = previewRef.current!; @@ -49,6 +77,7 @@ const ChartPreviewBtn = (props: { { exportBackground: false, viewBackgroundColor: "#fff", + exportWithDarkMode: theme === "dark", }, null, // files { @@ -58,42 +87,108 @@ const ChartPreviewBtn = (props: { svg.querySelector(".style-fonts")?.remove(); previewNode.replaceChildren(); previewNode.appendChild(svg); - - if (props.selected) { - (previewNode.parentNode as HTMLDivElement).focus(); - } })(); return () => { previewNode.replaceChildren(); }; - }, [props.spreadsheet, props.chartType, props.selected]); + }, [props.spreadsheet, props.chartType, props.colorSeed, theme]); + + const chartTypeLabel = getChartTypeLabel(props.chartType); return ( + ); +}; + +const PlainTextPreviewBtn = (props: { + rawText: string; + onClick: OnPlainTextPaste; +}) => { + const previewRef = useRef(null); + const { theme } = useUIAppState(); + + useLayoutEffect(() => { + if (!props.rawText) { + return; + } + + const textElement = newTextElement({ + text: props.rawText, + x: 0, + y: 0, + }); + + const previewNode = previewRef.current!; + + (async () => { + const svg = await exportToSvg( + [textElement], + { + exportBackground: false, + viewBackgroundColor: "#fff", + exportWithDarkMode: theme === "dark", + }, + null, + { + skipInliningFonts: true, + }, + ); + svg.querySelector(".style-fonts")?.remove(); + previewNode.replaceChildren(); + previewNode.appendChild(svg); + })(); + + return () => { + previewNode.replaceChildren(); + }; + }, [props.rawText, theme]); + + return ( + ); }; export const PasteChartDialog = ({ - setAppState, - appState, + data, + rawText, onClose, }: { - appState: UIAppState; + data: Spreadsheet; + rawText: string; onClose: () => void; - setAppState: React.Component["setState"]; }) => { - const { onInsertElements } = useApp(); + const { onInsertElements, focusContainer } = useApp(); + const [colorSeed, setColorSeed] = useState(Math.random()); + + const handleReshuffleColors = React.useCallback(() => { + setColorSeed(Math.random()); + }, []); + const handleClose = React.useCallback(() => { if (onClose) { onClose(); @@ -103,36 +198,72 @@ export const PasteChartDialog = ({ const handleChartClick = (chartType: ChartType, elements: ChartElements) => { onInsertElements(elements); trackEvent("paste", "chart", chartType); - setAppState({ - currentChartType: chartType, - pasteDialog: { - shown: false, - data: null, - }, + onClose(); + focusContainer(); + }; + + const handlePlainTextClick = (rawText: string) => { + const textElement = newTextElement({ + text: rawText, + x: 0, + y: 0, }); + onInsertElements([textElement]); + trackEvent("paste", "chart", "plaintext"); + onClose(); + focusContainer(); }; return ( +
+ {t("labels.pasteCharts")} +
+
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleReshuffleColors(); + } + }} + > + {bucketFillIcon} +
+ + } className={"PasteChartDialog"} autofocus={false} >
- - + {(["bar", "line", "radar"] as const).map((chartType) => { + if (!isSpreadsheetValidForChartType(data, chartType)) { + return null; + } + + return ( + + ); + })} + {rawText && ( + + )}
); diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index f8004b4c2f..ab0054ee35 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -319,3 +319,9 @@ export { isElementLink } from "@excalidraw/element"; export { setCustomTextMetricsProvider } from "@excalidraw/element"; export { CommandPalette } from "./components/CommandPalette/CommandPalette"; + +export { + renderSpreadsheet, + tryParseSpreadsheet, + isSpreadsheetValidForChartType, +} from "./charts"; diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index 182e59bb61..18e8c88908 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -3,6 +3,10 @@ "paste": "Paste", "pasteAsPlaintext": "Paste as plaintext", "pasteCharts": "Paste charts", + "chartType_bar": "Bar chart", + "chartType_line": "Line chart", + "chartType_radar": "Radar chart", + "chartType_plaintext": "Plain text", "selectAll": "Select all", "multiSelect": "Add element to selection", "moveCanvas": "Move canvas", diff --git a/packages/excalidraw/tests/__snapshots__/charts.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/charts.test.tsx.snap index 868e27e842..5fa9fb7e35 100644 --- a/packages/excalidraw/tests/__snapshots__/charts.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/charts.test.tsx.snap @@ -2,19 +2,24 @@ exports[`tryParseSpreadsheet > works for numbers with comma in them 1`] = ` { - "spreadsheet": { + "data": { "labels": [ "Week 1", "Week 2", "Week 3", ], - "title": "Users", - "values": [ - 814, - 10301, - 4264, + "series": [ + { + "title": "Users", + "values": [ + 814, + 10301, + 4264, + ], + }, ], + "title": "Users", }, - "type": "VALID_SPREADSHEET", + "ok": true, } `; diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index b8467c20ab..38f748b817 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -889,7 +889,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro "top": 40, }, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -951,10 +950,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -1091,7 +1086,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -1150,10 +1144,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -1308,7 +1298,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -1367,10 +1356,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -1642,7 +1627,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -1701,10 +1685,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -1976,7 +1956,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -2035,10 +2014,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -2193,7 +2168,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -2252,10 +2226,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -2437,7 +2407,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -2496,10 +2465,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -2738,7 +2703,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -2797,10 +2761,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -3113,7 +3073,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "#a5d8ff", @@ -3172,10 +3131,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -3609,7 +3564,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -3668,10 +3622,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -3935,7 +3885,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -3994,10 +3943,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -4261,7 +4206,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -4320,10 +4264,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -5549,7 +5489,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "top": -7, }, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -5608,10 +5547,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -6769,7 +6704,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "top": -7, }, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -6828,10 +6762,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -7707,7 +7637,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app "top": -9, }, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -7769,10 +7698,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -8710,7 +8635,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "top": -7, }, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -8769,10 +8693,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -9704,7 +9624,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "top": 90, }, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -9766,10 +9685,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index c8c427e6ae..97204839fc 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -15,7 +15,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -73,10 +72,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -649,7 +644,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -707,10 +701,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -1213,7 +1203,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -1274,10 +1263,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -1576,7 +1561,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -1637,10 +1621,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -1941,7 +1921,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -2002,10 +1981,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -2207,7 +2182,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -2265,10 +2239,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -2663,7 +2633,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -2724,10 +2693,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -2969,7 +2934,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -3030,10 +2994,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -3291,7 +3251,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -3352,10 +3311,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -3588,7 +3543,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -3649,10 +3603,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -3877,7 +3827,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -3938,10 +3887,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -4115,7 +4060,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -4176,10 +4120,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -4375,7 +4315,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -4436,10 +4375,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -4649,7 +4584,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -4710,10 +4644,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -4881,7 +4811,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -4942,10 +4871,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -5113,7 +5038,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -5174,10 +5098,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -5363,7 +5283,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -5424,10 +5343,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -5622,7 +5537,6 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -5683,10 +5597,6 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -5883,7 +5793,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -5941,10 +5850,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -6215,7 +6120,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "#ffc9c9", @@ -6273,10 +6177,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -6645,7 +6545,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -6703,10 +6602,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -7022,7 +6917,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -7083,10 +6977,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -7337,7 +7227,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -7395,10 +7284,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -7632,7 +7517,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "#ffc9c9", @@ -7690,10 +7574,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "openPopup": "elementBackground", "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -7865,7 +7745,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -7923,10 +7802,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -8220,7 +8095,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -8278,10 +8152,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -8575,7 +8445,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -8633,10 +8502,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -8984,7 +8849,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -9042,10 +8906,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -9266,7 +9126,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -9324,10 +9183,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -9533,7 +9388,6 @@ exports[`history > multiplayer undo/redo > should not override remote changes on "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "#ffc9c9", @@ -9591,10 +9445,6 @@ exports[`history > multiplayer undo/redo > should not override remote changes on "openPopup": "elementBackground", "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -9801,7 +9651,6 @@ exports[`history > multiplayer undo/redo > should not override remote changes on "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "#ffc9c9", @@ -9859,10 +9708,6 @@ exports[`history > multiplayer undo/redo > should not override remote changes on "openPopup": "elementBackground", "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -10036,7 +9881,6 @@ exports[`history > multiplayer undo/redo > should override remotely added groups "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -10097,10 +9941,6 @@ exports[`history > multiplayer undo/redo > should override remotely added groups "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -10336,7 +10176,6 @@ exports[`history > multiplayer undo/redo > should override remotely added points "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -10394,10 +10233,6 @@ exports[`history > multiplayer undo/redo > should override remotely added points "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -10656,7 +10491,6 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -10714,10 +10548,6 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -10895,7 +10725,6 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -10956,10 +10785,6 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -11340,7 +11165,6 @@ exports[`history > multiplayer undo/redo > should update history entries after r "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "#a5d8ff", @@ -11398,10 +11222,6 @@ exports[`history > multiplayer undo/redo > should update history entries after r "openPopup": "elementBackground", "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -11603,7 +11423,6 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -11661,10 +11480,6 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -11841,7 +11656,6 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -11899,10 +11713,6 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -12081,7 +11891,6 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -12139,10 +11948,6 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -12475,7 +12280,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on e "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -12536,10 +12340,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on e "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -12688,7 +12488,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on e "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -12746,10 +12545,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on e "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -12898,7 +12693,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -12959,10 +12753,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -13202,7 +12992,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -13260,10 +13049,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -13503,7 +13288,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on s "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -13564,10 +13348,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on s "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -13751,7 +13531,6 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -13809,10 +13588,6 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -13991,7 +13766,6 @@ exports[`history > singleplayer undo/redo > should end up with no history entry "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -14049,10 +13823,6 @@ exports[`history > singleplayer undo/redo > should end up with no history entry "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -14231,7 +14001,6 @@ exports[`history > singleplayer undo/redo > should iterate through the history w "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -14289,10 +14058,6 @@ exports[`history > singleplayer undo/redo > should iterate through the history w "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -14481,7 +14246,6 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -14539,10 +14303,6 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -14815,7 +14575,6 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -14876,10 +14635,6 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -14988,7 +14743,6 @@ exports[`history > singleplayer undo/redo > should not end up with history entry "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -15046,10 +14800,6 @@ exports[`history > singleplayer undo/redo > should not end up with history entry "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -15275,7 +15025,6 @@ exports[`history > singleplayer undo/redo > should not end up with history entry "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -15336,10 +15085,6 @@ exports[`history > singleplayer undo/redo > should not end up with history entry "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -15541,7 +15286,6 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -15602,10 +15346,6 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -15697,7 +15437,6 @@ exports[`history > singleplayer undo/redo > should not override appstate changes "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "#a5d8ff", @@ -15755,10 +15494,6 @@ exports[`history > singleplayer undo/redo > should not override appstate changes "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -15982,7 +15717,6 @@ exports[`history > singleplayer undo/redo > should support appstate name or view "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -16043,10 +15777,6 @@ exports[`history > singleplayer undo/redo > should support appstate name or view "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -16147,7 +15877,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -16205,10 +15934,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -16898,7 +16623,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -16956,10 +16680,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -17547,7 +17267,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -17605,10 +17324,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -18196,7 +17911,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -18254,10 +17968,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -18948,7 +18658,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -19006,10 +18715,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -19719,7 +19424,6 @@ exports[`history > singleplayer undo/redo > should support changes in elements' "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -19777,10 +19481,6 @@ exports[`history > singleplayer undo/redo > should support changes in elements' "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -20202,7 +19902,6 @@ exports[`history > singleplayer undo/redo > should support duplication of groups "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -20260,10 +19959,6 @@ exports[`history > singleplayer undo/redo > should support duplication of groups "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -20716,7 +20411,6 @@ exports[`history > singleplayer undo/redo > should support element creation, del "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -20774,10 +20468,6 @@ exports[`history > singleplayer undo/redo > should support element creation, del "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -21178,7 +20868,6 @@ exports[`history > singleplayer undo/redo > should support linear element creati "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -21236,10 +20925,6 @@ exports[`history > singleplayer undo/redo > should support linear element creati "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { diff --git a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap index d9f469988c..c7dd74b91f 100644 --- a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap @@ -15,7 +15,6 @@ exports[`given element A and group of elements B and given both are selected whe "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -74,10 +73,6 @@ exports[`given element A and group of elements B and given both are selected whe "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -444,7 +439,6 @@ exports[`given element A and group of elements B and given both are selected whe "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -503,10 +497,6 @@ exports[`given element A and group of elements B and given both are selected whe "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -863,7 +853,6 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -922,10 +911,6 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -1432,7 +1417,6 @@ exports[`regression tests > Drags selected element when hitting only bounding bo "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -1491,10 +1475,6 @@ exports[`regression tests > Drags selected element when hitting only bounding bo "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -1642,7 +1622,6 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -1701,10 +1680,6 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -2029,7 +2004,6 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -2088,10 +2062,6 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -2277,7 +2247,6 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = ` "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -2336,10 +2305,6 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = ` "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -2460,7 +2425,6 @@ exports[`regression tests > can drag element that covers another element, while "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -2519,10 +2483,6 @@ exports[`regression tests > can drag element that covers another element, while "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -2788,7 +2748,6 @@ exports[`regression tests > change the properties of a shape > [end of test] app "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "#ffc9c9", @@ -2847,10 +2806,6 @@ exports[`regression tests > change the properties of a shape > [end of test] app "openPopup": "elementStroke", "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -3046,7 +3001,6 @@ exports[`regression tests > click on an element and drag it > [dragged] appState "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -3105,10 +3059,6 @@ exports[`regression tests > click on an element and drag it > [dragged] appState "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -3290,7 +3240,6 @@ exports[`regression tests > click on an element and drag it > [end of test] appS "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -3349,10 +3298,6 @@ exports[`regression tests > click on an element and drag it > [end of test] appS "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -3529,7 +3474,6 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`] "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -3588,10 +3532,6 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`] "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -3790,7 +3730,6 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -3849,10 +3788,6 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -4107,7 +4042,6 @@ exports[`regression tests > deleting last but one element in editing group shoul "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -4166,10 +4100,6 @@ exports[`regression tests > deleting last but one element in editing group shoul "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -4546,7 +4476,6 @@ exports[`regression tests > deselects group of selected elements on pointer down "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -4605,10 +4534,6 @@ exports[`regression tests > deselects group of selected elements on pointer down "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -4832,7 +4757,6 @@ exports[`regression tests > deselects group of selected elements on pointer up w "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -4891,10 +4815,6 @@ exports[`regression tests > deselects group of selected elements on pointer up w "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -5111,7 +5031,6 @@ exports[`regression tests > deselects selected element on pointer down when poin "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -5170,10 +5089,6 @@ exports[`regression tests > deselects selected element on pointer down when poin "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -5322,7 +5237,6 @@ exports[`regression tests > deselects selected element, on pointer up, when clic "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -5381,10 +5295,6 @@ exports[`regression tests > deselects selected element, on pointer up, when clic "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -5525,7 +5435,6 @@ exports[`regression tests > double click to edit a group > [end of test] appStat "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -5584,10 +5493,6 @@ exports[`regression tests > double click to edit a group > [end of test] appStat "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -5921,7 +5826,6 @@ exports[`regression tests > drags selected elements from point inside common bou "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -5980,10 +5884,6 @@ exports[`regression tests > drags selected elements from point inside common bou "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -6221,7 +6121,6 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1` "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -6280,10 +6179,6 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1` "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -7012,7 +6907,6 @@ exports[`regression tests > given a group of selected elements with an element t "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -7071,10 +6965,6 @@ exports[`regression tests > given a group of selected elements with an element t "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -7349,7 +7239,6 @@ exports[`regression tests > given a selected element A and a not selected elemen "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "#ffc9c9", @@ -7408,10 +7297,6 @@ exports[`regression tests > given a selected element A and a not selected elemen "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -7631,7 +7516,6 @@ exports[`regression tests > given selected element A with lower z-index than uns "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -7690,10 +7574,6 @@ exports[`regression tests > given selected element A with lower z-index than uns "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -7869,7 +7749,6 @@ exports[`regression tests > given selected element A with lower z-index than uns "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -7928,10 +7807,6 @@ exports[`regression tests > given selected element A with lower z-index than uns "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -8112,7 +7987,6 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -8171,10 +8045,6 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -8295,7 +8165,6 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -8354,10 +8223,6 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -8478,7 +8343,6 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -8537,10 +8401,6 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -8661,7 +8521,6 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -8720,10 +8579,6 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -8896,7 +8751,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -8955,10 +8809,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -9129,7 +8979,6 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -9188,10 +9037,6 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -9324,7 +9169,6 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -9383,10 +9227,6 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -9559,7 +9399,6 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -9618,10 +9457,6 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -9742,7 +9577,6 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -9801,10 +9635,6 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -9975,7 +9805,6 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -10034,10 +9863,6 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -10158,7 +9983,6 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -10217,10 +10041,6 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -10353,7 +10173,6 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -10412,10 +10231,6 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -10536,7 +10351,6 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -10595,10 +10409,6 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -11070,7 +10880,6 @@ exports[`regression tests > noop interaction after undo shouldn't create history "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -11129,10 +10938,6 @@ exports[`regression tests > noop interaction after undo shouldn't create history "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -11353,7 +11158,6 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = ` "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -11412,10 +11216,6 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = ` "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -11479,7 +11279,6 @@ exports[`regression tests > shift click on selected element should deselect it o "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -11538,10 +11337,6 @@ exports[`regression tests > shift click on selected element should deselect it o "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -11682,7 +11477,6 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -11741,10 +11535,6 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -12004,7 +11794,6 @@ exports[`regression tests > should group elements and ungroup them > [end of tes "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -12063,10 +11852,6 @@ exports[`regression tests > should group elements and ungroup them > [end of tes "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -12436,7 +12221,6 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -12495,10 +12279,6 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -13079,7 +12859,6 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -13141,10 +12920,6 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -13208,7 +12983,6 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`] "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -13267,10 +13041,6 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`] "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -13842,7 +13612,6 @@ exports[`regression tests > switches from group of selected elements to another "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -13901,10 +13670,6 @@ exports[`regression tests > switches from group of selected elements to another "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -14184,7 +13949,6 @@ exports[`regression tests > switches selected element on pointer down > [end of "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -14243,10 +14007,6 @@ exports[`regression tests > switches selected element on pointer down > [end of "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -14451,7 +14211,6 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`] "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -14510,10 +14269,6 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`] "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -14577,7 +14332,6 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -14636,10 +14390,6 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -14944,7 +14694,6 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -15003,10 +14752,6 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -15070,7 +14815,6 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = ` "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -15132,10 +14876,6 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = ` "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { diff --git a/packages/excalidraw/tests/charts.test.tsx b/packages/excalidraw/tests/charts.test.tsx index a4bce153bd..8f2274c824 100644 --- a/packages/excalidraw/tests/charts.test.tsx +++ b/packages/excalidraw/tests/charts.test.tsx @@ -10,4 +10,155 @@ Week 3${"\t"}4,264`, ); expect(result).toMatchSnapshot(); }); + + it("parses multi-series CSV for radar charts", () => { + const result = tryParseSpreadsheet( + `Metric,Player A,Player B,Player C +Speed,80,60,75 +Strength,65,85,70 +Agility,90,70,88 +Intelligence,70,88,92 +Stamina,85,75,80`, + ); + + expect(result).toEqual({ + ok: true, + data: { + title: "Metric", + labels: ["Speed", "Strength", "Agility", "Intelligence", "Stamina"], + series: [ + { title: "Player A", values: [80, 65, 90, 70, 85] }, + { title: "Player B", values: [60, 85, 70, 88, 75] }, + { title: "Player C", values: [75, 70, 88, 92, 80] }, + ], + }, + }); + }); + + it("parses TSV with empty chart-name header cell", () => { + const result = tryParseSpreadsheet( + `\tDunk\tEgg +Physical Strength\t10\t2 +Swordsmanship\t8\t1 +Political Instinct\t3\t9`, + ); + + expect(result).toEqual({ + ok: true, + data: { + title: null, + labels: ["Physical Strength", "Swordsmanship", "Political Instinct"], + series: [ + { title: "Dunk", values: [10, 8, 3] }, + { title: "Egg", values: [2, 1, 9] }, + ], + }, + }); + }); + + it("parses 2-row multi-series TSV without transposing", () => { + const result = tryParseSpreadsheet( + `Physical Strength\t10\t2 +Swordsmanship skill\t8\t1`, + ); + + expect(result).toEqual({ + ok: true, + data: { + title: null, + labels: ["Physical Strength", "Swordsmanship skill"], + series: [ + { title: "Series 1", values: [10, 8] }, + { title: "Series 2", values: [2, 1] }, + ], + }, + }); + }); + + it("parses semicolon-separated values", () => { + const result = tryParseSpreadsheet( + `Metric;Player A;Player B +Speed;80;60 +Strength;65;85 +Agility;90;70`, + ); + + expect(result).toEqual({ + ok: true, + data: { + title: "Metric", + labels: ["Speed", "Strength", "Agility"], + series: [ + { title: "Player A", values: [80, 65, 90] }, + { title: "Player B", values: [60, 85, 70] }, + ], + }, + }); + }); + + it("transposes wide data (more value cols than rows) into series-per-row", () => { + const result = tryParseSpreadsheet( + `trait,Dunk,Egg,Daeron +Physical,10,2,7 +Mental,10,2,7`, + ); + + expect(result).toEqual({ + ok: true, + data: { + title: "trait", + labels: ["Dunk", "Egg", "Daeron"], + series: [ + { title: "Physical", values: [10, 2, 7] }, + { title: "Mental", values: [10, 2, 7] }, + ], + }, + }); + }); + + it("transposes single data row with header into single series", () => { + const result = tryParseSpreadsheet( + `trait,Dunk,Egg,Daeron +Physical,10,2,7`, + ); + + expect(result).toEqual({ + ok: true, + data: { + title: "Physical", + labels: ["Dunk", "Egg", "Daeron"], + series: [{ title: "Physical", values: [10, 2, 7] }], + }, + }); + }); + + it("transposes single data row without header into single series", () => { + const result = tryParseSpreadsheet(`Physical,10,2,7`); + + expect(result).toEqual({ + ok: true, + data: { + title: "Physical", + labels: null, + series: [{ title: "Physical", values: [10, 2, 7] }], + }, + }); + }); + + it("prefers tab over comma/semicolon when tabs produce multiple columns", () => { + const result = tryParseSpreadsheet( + `Label\tValue +A\t10 +B\t20`, + ); + + expect(result).toEqual({ + ok: true, + data: { + title: "Value", + labels: ["A", "B"], + series: [{ title: "Value", values: [10, 20] }], + }, + }); + }); }); diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 02f70f62b4..07701e17bb 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -20,7 +20,6 @@ import type { GroupId, ExcalidrawBindableElement, Arrowhead, - ChartType, FontFamilyValues, FileId, Theme, @@ -383,7 +382,8 @@ export interface AppState { | { name: "ttd"; tab: "text-to-diagram" | "mermaid" } | { name: "commandPalette" } | { name: "settings" } - | { name: "elementLinkSelector"; sourceElementId: ExcalidrawElement["id"] }; + | { name: "elementLinkSelector"; sourceElementId: ExcalidrawElement["id"] } + | { name: "charts"; data: Spreadsheet; rawText: string }; /** * Reflects user preference for whether the default sidebar should be docked. * @@ -425,16 +425,6 @@ export interface AppState { /** bitmap. Use `STATS_PANELS` bit values */ panels: number; }; - currentChartType: ChartType; - pasteDialog: - | { - shown: false; - data: null; - } - | { - shown: true; - data: Spreadsheet; - }; showHyperlinkPopup: false | "info" | "editor"; selectedLinearElement: LinearElementEditor | null; snapLines: readonly SnapLine[]; diff --git a/packages/utils/tests/__snapshots__/export.test.ts.snap b/packages/utils/tests/__snapshots__/export.test.ts.snap index f914a2bf2b..8b239c7d04 100644 --- a/packages/utils/tests/__snapshots__/export.test.ts.snap +++ b/packages/utils/tests/__snapshots__/export.test.ts.snap @@ -15,7 +15,6 @@ exports[`exportToSvg > with default arguments 1`] = ` "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -75,10 +74,6 @@ exports[`exportToSvg > with default arguments 1`] = ` "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": {