瀏覽代碼

feat: Add line chart and paste dialog selection (#2670)

Co-authored-by: dwelle <luzar.david@gmail.com>
Co-authored-by: Jed Fox <git@jedfox.com>
Lipis 4 年之前
父節點
當前提交
022f349dc6

+ 13 - 13
src/actions/actionCanvas.tsx

@@ -1,23 +1,22 @@
 import React from "react";
-import { ColorPicker } from "../components/ColorPicker";
+import { EVENT_ACTION, EVENT_CHANGE, trackEvent } from "../analytics";
 import { getDefaultAppState } from "../appState";
-import { trash, zoomIn, zoomOut, resetZoom } from "../components/icons";
+import colors from "../colors";
+import { ColorPicker } from "../components/ColorPicker";
+import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
 import { ToolButton } from "../components/ToolButton";
+import { getCommonBounds, getNonDeletedElements } from "../element";
+import { newElementWith } from "../element/mutateElement";
+import { ExcalidrawElement } from "../element/types";
 import { t } from "../i18n";
-import { getNormalizedZoom, getSelectedElements } from "../scene";
-import { getNonDeletedElements } from "../element";
+import useIsMobile from "../is-mobile";
 import { CODES, KEYS } from "../keys";
+import { getNormalizedZoom, getSelectedElements } from "../scene";
+import { centerScrollOn } from "../scene/scroll";
+import { getNewZoom } from "../scene/zoom";
+import { AppState, NormalizedZoomValue } from "../types";
 import { getShortcutKey } from "../utils";
-import useIsMobile from "../is-mobile";
 import { register } from "./register";
-import { newElementWith } from "../element/mutateElement";
-import { ExcalidrawElement } from "../element/types";
-import { AppState, NormalizedZoomValue } from "../types";
-import { getCommonBounds } from "../element";
-import { getNewZoom } from "../scene/zoom";
-import { centerScrollOn } from "../scene/scroll";
-import { EVENT_ACTION, EVENT_CHANGE, trackEvent } from "../analytics";
-import colors from "../colors";
 
 export const actionChangeViewBackgroundColor = register({
   name: "changeViewBackgroundColor",
@@ -67,6 +66,7 @@ export const actionClearCanvas = register({
         gridSize: appState.gridSize,
         shouldAddWatermark: appState.shouldAddWatermark,
         showStats: appState.showStats,
+        pasteDialog: appState.pasteDialog,
       },
       commitToHistory: true,
     };

+ 58 - 57
src/appState.ts

@@ -1,12 +1,12 @@
 import oc from "open-color";
-import { AppState, FlooredNumber, NormalizedZoomValue } from "./types";
-import { getDateTime } from "./utils";
-import { t } from "./i18n";
 import {
-  DEFAULT_FONT_SIZE,
   DEFAULT_FONT_FAMILY,
+  DEFAULT_FONT_SIZE,
   DEFAULT_TEXT_ALIGN,
 } from "./constants";
+import { t } from "./i18n";
+import { AppState, FlooredNumber, NormalizedZoomValue } from "./types";
+import { getDateTime } from "./utils";
 
 export const getDefaultAppState = (): Omit<
   AppState,
@@ -14,64 +14,63 @@ export const getDefaultAppState = (): Omit<
 > => {
   return {
     appearance: "light",
-    isLoading: false,
-    errorMessage: null,
-    draggingElement: null,
-    resizingElement: null,
-    multiElement: null,
-    editingElement: null,
-    startBoundElement: null,
-    editingLinearElement: null,
-    elementType: "selection",
-    elementLocked: false,
-    exportBackground: true,
-    exportEmbedScene: false,
-    shouldAddWatermark: false,
-    currentItemStrokeColor: oc.black,
+    collaborators: new Map(),
+    currentChartType: "bar",
     currentItemBackgroundColor: "transparent",
+    currentItemEndArrowhead: "arrow",
     currentItemFillStyle: "hachure",
-    currentItemStrokeWidth: 1,
-    currentItemStrokeStyle: "solid",
-    currentItemRoughness: 1,
-    currentItemOpacity: 100,
-    currentItemFontSize: DEFAULT_FONT_SIZE,
     currentItemFontFamily: DEFAULT_FONT_FAMILY,
-    currentItemTextAlign: DEFAULT_TEXT_ALIGN,
-    currentItemStrokeSharpness: "sharp",
+    currentItemFontSize: DEFAULT_FONT_SIZE,
     currentItemLinearStrokeSharpness: "round",
+    currentItemOpacity: 100,
+    currentItemRoughness: 1,
     currentItemStartArrowhead: null,
-    currentItemEndArrowhead: "arrow",
-    viewBackgroundColor: oc.white,
-    scrollX: 0 as FlooredNumber,
-    scrollY: 0 as FlooredNumber,
+    currentItemStrokeColor: oc.black,
+    currentItemStrokeSharpness: "sharp",
+    currentItemStrokeStyle: "solid",
+    currentItemStrokeWidth: 1,
+    currentItemTextAlign: DEFAULT_TEXT_ALIGN,
     cursorButton: "up",
-    scrolledOutside: false,
-    name: `${t("labels.untitled")}-${getDateTime()}`,
+    draggingElement: null,
+    editingElement: null,
+    editingGroupId: null,
+    editingLinearElement: null,
+    elementLocked: false,
+    elementType: "selection",
+    errorMessage: null,
+    exportBackground: true,
+    exportEmbedScene: false,
+    fileHandle: null,
+    gridSize: null,
+    height: window.innerHeight,
     isBindingEnabled: true,
+    isLibraryOpen: false,
+    isLoading: false,
     isResizing: false,
     isRotating: false,
-    selectionElement: null,
-    zoom: {
-      value: 1 as NormalizedZoomValue,
-      translation: { x: 0, y: 0 },
-    },
-    openMenu: null,
     lastPointerDownWith: "mouse",
-    selectedElementIds: {},
+    multiElement: null,
+    name: `${t("labels.untitled")}-${getDateTime()}`,
+    openMenu: null,
+    pasteDialog: { shown: false, data: null },
     previousSelectedElementIds: {},
+    resizingElement: null,
+    scrolledOutside: false,
+    scrollX: 0 as FlooredNumber,
+    scrollY: 0 as FlooredNumber,
+    selectedElementIds: {},
+    selectedGroupIds: {},
+    selectionElement: null,
+    shouldAddWatermark: false,
     shouldCacheIgnoreZoom: false,
     showShortcutsDialog: false,
+    showStats: false,
+    startBoundElement: null,
     suggestedBindings: [],
-    zenModeEnabled: false,
-    gridSize: null,
-    editingGroupId: null,
-    selectedGroupIds: {},
+    viewBackgroundColor: oc.white,
     width: window.innerWidth,
-    height: window.innerHeight,
-    isLibraryOpen: false,
-    fileHandle: null,
-    collaborators: new Map(),
-    showStats: false,
+    zenModeEnabled: false,
+    zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
   };
 };
 
@@ -91,24 +90,25 @@ const APP_STATE_STORAGE_CONF = (<
   config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
 ) => config)({
   appearance: { browser: true, export: false },
+  collaborators: { browser: false, export: false },
+  currentChartType: { browser: true, export: false },
   currentItemBackgroundColor: { browser: true, export: false },
+  currentItemEndArrowhead: { browser: true, export: false },
   currentItemFillStyle: { browser: true, export: false },
   currentItemFontFamily: { browser: true, export: false },
   currentItemFontSize: { browser: true, export: false },
+  currentItemLinearStrokeSharpness: { browser: true, export: false },
   currentItemOpacity: { browser: true, export: false },
   currentItemRoughness: { browser: true, export: false },
+  currentItemStartArrowhead: { browser: true, export: false },
   currentItemStrokeColor: { browser: true, export: false },
+  currentItemStrokeSharpness: { browser: true, export: false },
   currentItemStrokeStyle: { browser: true, export: false },
   currentItemStrokeWidth: { browser: true, export: false },
   currentItemTextAlign: { browser: true, export: false },
-  currentItemStrokeSharpness: { browser: true, export: false },
-  currentItemLinearStrokeSharpness: { browser: true, export: false },
-  currentItemStartArrowhead: { browser: true, export: false },
-  currentItemEndArrowhead: { browser: true, export: false },
   cursorButton: { browser: true, export: false },
   draggingElement: { browser: false, export: false },
   editingElement: { browser: false, export: false },
-  startBoundElement: { browser: false, export: false },
   editingGroupId: { browser: true, export: false },
   editingLinearElement: { browser: false, export: false },
   elementLocked: { browser: true, export: false },
@@ -116,6 +116,7 @@ const APP_STATE_STORAGE_CONF = (<
   errorMessage: { browser: false, export: false },
   exportBackground: { browser: true, export: false },
   exportEmbedScene: { browser: true, export: false },
+  fileHandle: { browser: false, export: false },
   gridSize: { browser: true, export: true },
   height: { browser: false, export: false },
   isBindingEnabled: { browser: false, export: false },
@@ -126,7 +127,10 @@ const APP_STATE_STORAGE_CONF = (<
   lastPointerDownWith: { browser: true, export: false },
   multiElement: { browser: false, export: false },
   name: { browser: true, export: false },
+  offsetLeft: { browser: false, export: false },
+  offsetTop: { browser: false, export: false },
   openMenu: { browser: true, export: false },
+  pasteDialog: { browser: false, export: false },
   previousSelectedElementIds: { browser: true, export: false },
   resizingElement: { browser: false, export: false },
   scrolledOutside: { browser: true, export: false },
@@ -138,16 +142,13 @@ const APP_STATE_STORAGE_CONF = (<
   shouldAddWatermark: { browser: true, export: false },
   shouldCacheIgnoreZoom: { browser: true, export: false },
   showShortcutsDialog: { browser: false, export: false },
+  showStats: { browser: true, export: false },
+  startBoundElement: { browser: false, export: false },
   suggestedBindings: { browser: false, export: false },
   viewBackgroundColor: { browser: true, export: true },
   width: { browser: false, export: false },
   zenModeEnabled: { browser: true, export: false },
   zoom: { browser: true, export: false },
-  offsetTop: { browser: false, export: false },
-  offsetLeft: { browser: false, export: false },
-  fileHandle: { browser: false, export: false },
-  collaborators: { browser: false, export: false },
-  showStats: { browser: true, export: false },
 });
 
 const _clearAppStateForStorage = <ExportType extends "export" | "browser">(

+ 273 - 72
src/charts.ts

@@ -1,13 +1,21 @@
 import { EVENT_MAGIC, trackEvent } from "./analytics";
 import colors from "./colors";
-import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "./constants";
-import { newElement, newTextElement, newLinearElement } from "./element";
-import { ExcalidrawElement } from "./element/types";
+import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, ENV } from "./constants";
+import { newElement, newLinearElement, newTextElement } from "./element";
+import { NonDeletedExcalidrawElement } from "./element/types";
 import { randomId } from "./random";
 
+export type ChartElements = readonly NonDeletedExcalidrawElement[];
+
 const BAR_WIDTH = 32;
 const BAR_GAP = 12;
 const BAR_HEIGHT = 256;
+const GRID_OPACITY = 50;
+
+export const CHART_LABELS = {
+  bar: "labels.chartTypeBar",
+  line: "labels.chartTypeLine",
+};
 
 export interface Spreadsheet {
   title: string | null;
@@ -139,41 +147,72 @@ export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
       return transposedResults;
     }
   }
-
   return result;
 };
 
-// For the maths behind it https://excalidraw.com/#json=6320864370884608,O_5xfD-Agh32tytHpRJx1g
-export const renderSpreadsheet = (
+const bgColors = colors.elementBackground.slice(
+  2,
+  colors.elementBackground.length,
+);
+
+// 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: colors.elementStroke[0],
+  strokeSharpness: "sharp",
+  strokeStyle: "solid",
+  strokeWidth: 1,
+  verticalAlign: "middle",
+} as const;
+
+const getChartDimentions = (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,
-): ExcalidrawElement[] => {
-  const values = spreadsheet.values;
-  const max = Math.max(...values);
-  const chartHeight = BAR_HEIGHT + BAR_GAP * 2;
-  const chartWidth = (BAR_WIDTH + BAR_GAP) * values.length + BAR_GAP;
-  const maxColors = colors.elementBackground.length;
-  const bgColors = colors.elementBackground.slice(2, maxColors);
-
-  // Put all the common properties here so when the whole chart is selected
-  // the properties dialog shows the correct selected values
-  const commonProps = {
-    backgroundColor: bgColors[Math.floor(Math.random() * bgColors.length)],
-    fillStyle: "hachure",
-    fontFamily: DEFAULT_FONT_FAMILY,
-    fontSize: DEFAULT_FONT_SIZE,
-    groupIds: [randomId()],
-    opacity: 100,
-    roughness: 1,
-    strokeColor: colors.elementStroke[0],
-    strokeSharpness: "sharp",
-    strokeStyle: "solid",
-    strokeWidth: 1,
-    verticalAlign: "middle",
-  } as const;
+  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,
+        fontSize: 16,
+        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,
@@ -182,14 +221,30 @@ export const renderSpreadsheet = (
   });
 
   const maxYLabel = newTextElement({
+    groupIds: [groupId],
+    backgroundColor,
     ...commonProps,
     x: x - BAR_GAP,
     y: y - BAR_HEIGHT - minYLabel.height / 2,
-    text: max.toLocaleString(),
+    text: Math.max(...spreadsheet.values).toLocaleString(),
     textAlign: "right",
   });
 
-  const xAxisLine = newLinearElement({
+  return [minYLabel, maxYLabel];
+};
+
+const chartLines = (
+  spreadsheet: Spreadsheet,
+  x: number,
+  y: number,
+  groupId: string,
+  backgroundColor: string,
+): ChartElements => {
+  const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
+  const xLine = newLinearElement({
+    backgroundColor,
+    groupIds: [groupId],
+    ...commonProps,
     type: "line",
     x,
     y,
@@ -200,10 +255,12 @@ export const renderSpreadsheet = (
       [0, 0],
       [chartWidth, 0],
     ],
-    ...commonProps,
   });
 
-  const yAxisLine = newLinearElement({
+  const yLine = newLinearElement({
+    backgroundColor,
+    groupIds: [groupId],
+    ...commonProps,
     type: "line",
     x,
     y,
@@ -214,72 +271,216 @@ export const renderSpreadsheet = (
       [0, 0],
       [0, -chartHeight],
     ],
-    ...commonProps,
   });
 
-  const maxValueLine = newLinearElement({
+  const maxLine = newLinearElement({
+    backgroundColor,
+    groupIds: [groupId],
+    ...commonProps,
     type: "line",
     x,
     y: y - BAR_HEIGHT - BAR_GAP,
     startArrowhead: null,
     endArrowhead: null,
-    ...commonProps,
     strokeStyle: "dotted",
     width: chartWidth,
+    opacity: GRID_OPACITY,
     points: [
       [0, 0],
       [chartWidth, 0],
     ],
   });
 
-  const bars = values.map((value, index) => {
-    const barHeight = (value / max) * BAR_HEIGHT;
-    return newElement({
-      ...commonProps,
-      type: "rectangle",
-      x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP,
-      y: y - barHeight - BAR_GAP,
-      width: BAR_WIDTH,
-      height: barHeight,
-    });
-  });
+  return [xLine, yLine, maxLine];
+};
 
-  const xLabels =
-    spreadsheet.labels?.map((label, index) => {
-      return newTextElement({
-        ...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,
-        fontSize: 16,
-        textAlign: "center",
-        verticalAlign: "top",
-      });
-    }) || [];
+// 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 } = getChartDimentions(spreadsheet);
 
   const title = spreadsheet.title
     ? newTextElement({
+        backgroundColor,
+        groupIds: [groupId],
         ...commonProps,
         text: spreadsheet.title,
         x: x + chartWidth / 2,
-        y: y - BAR_HEIGHT - BAR_GAP * 2 - maxYLabel.height,
+        y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE,
         strokeSharpness: "sharp",
         strokeStyle: "solid",
         textAlign: "center",
       })
     : null;
 
-  trackEvent(EVENT_MAGIC, "chart", "bars", bars.length);
+  const debugRect = debug
+    ? newElement({
+        backgroundColor,
+        groupIds: [groupId],
+        ...commonProps,
+        type: "rectangle",
+        x,
+        y: y - chartHeight,
+        width: chartWidth,
+        height: chartHeight,
+        strokeColor: colors.elementStroke[0],
+        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 [
-    title,
     ...bars,
-    ...xLabels,
-    xAxisLine,
-    yAxisLine,
-    maxValueLine,
-    minYLabel,
-    maxYLabel,
-  ].filter((element) => element !== null) as ExcalidrawElement[];
+    ...chartBaseElements(
+      spreadsheet,
+      x,
+      y,
+      groupId,
+      backgroundColor,
+      process.env.NODE_ENV === ENV.DEVELOPMENT,
+    ),
+  ];
+};
+
+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,
+    startArrowhead: null,
+    endArrowhead: null,
+    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,
+      startArrowhead: null,
+      endArrowhead: null,
+      height: cy,
+      strokeStyle: "dotted",
+      opacity: GRID_OPACITY,
+      points: [
+        [0, 0],
+        [0, cy],
+      ],
+    });
+  });
+
+  return [
+    ...chartBaseElements(
+      spreadsheet,
+      x,
+      y,
+      groupId,
+      backgroundColor,
+      process.env.NODE_ENV === ENV.DEVELOPMENT,
+    ),
+    line,
+    ...lines,
+    ...dots,
+  ];
+};
+
+export const renderSpreadsheet = (
+  chartType: string,
+  spreadsheet: Spreadsheet,
+  x: number,
+  y: number,
+): ChartElements => {
+  trackEvent(EVENT_MAGIC, "chart", chartType, spreadsheet.values.length);
+  if (chartType === "line") {
+    return chartTypeLine(spreadsheet, x, y);
+  }
+  return chartTypeBar(spreadsheet, x, y);
 };

+ 136 - 147
src/components/App.tsx

@@ -1,181 +1,167 @@
+import { Point, simplify } from "points-on-curve";
 import React from "react";
-
-import rough from "roughjs/bin/rough";
 import { RoughCanvas } from "roughjs/bin/canvas";
-import { simplify, Point } from "points-on-curve";
-
-import {
-  newElement,
-  newTextElement,
-  duplicateElement,
-  isInvisiblySmallElement,
-  isTextElement,
-  textWysiwyg,
-  getCommonBounds,
-  getCursorForResizingElement,
-  getPerfectElementSize,
-  getNormalizedDimensions,
-  newLinearElement,
-  transformElements,
-  getElementWithTransformHandleType,
-  getResizeOffsetXY,
-  getResizeArrowDirection,
-  getTransformHandleTypeFromCoords,
-  isNonDeletedElement,
-  updateTextElement,
-  dragSelectedElements,
-  getDragOffsetXY,
-  dragNewElement,
-  hitTest,
-  isHittingElementBoundingBoxWithoutHittingElement,
-  getNonDeletedElements,
-} from "../element";
-import {
-  getElementsWithinSelection,
-  isOverScrollBars,
-  getElementsAtPosition,
-  getElementContainingPosition,
-  getNormalizedZoom,
-  getSelectedElements,
-  isSomeElementSelected,
-  calculateScrollCenter,
-} from "../scene";
-import { loadFromBlob, exportCanvas } from "../data";
-
-import { renderScene } from "../renderer";
-import {
-  AppState,
-  GestureEvent,
-  Gesture,
-  ExcalidrawProps,
-  SceneData,
-} from "../types";
-import {
-  ExcalidrawElement,
-  ExcalidrawTextElement,
-  NonDeleted,
-  ExcalidrawGenericElement,
-  ExcalidrawLinearElement,
-  ExcalidrawBindableElement,
-} from "../element/types";
-
-import { distance2d, isPathALoop, getGridPoint } from "../math";
-
-import {
-  isWritableElement,
-  isInputLike,
-  isToolIcon,
-  debounce,
-  distance,
-  resetCursor,
-  viewportCoordsToSceneCoords,
-  sceneCoordsToViewportCoords,
-  setCursorForShape,
-  tupleToCoors,
-  ResolvablePromise,
-  resolvablePromise,
-  withBatchedUpdates,
-} from "../utils";
-import {
-  KEYS,
-  isArrowKey,
-  getResizeCenterPointKey,
-  getResizeWithSidesSameLengthKey,
-  getRotateWithDiscreteAngleKey,
-  CODES,
-} from "../keys";
-
-import { findShapeByKey } from "../shapes";
-import { createHistory, SceneHistory } from "../history";
-
-import ContextMenu from "./ContextMenu";
-
-import { ActionManager } from "../actions/manager";
+import rough from "roughjs/bin/rough";
 import "../actions";
+import { actionDeleteSelected, actionFinalize } from "../actions";
+import { createRedoAction, createUndoAction } from "../actions/actionHistory";
+import { ActionManager } from "../actions/manager";
 import { actions } from "../actions/register";
-
 import { ActionResult } from "../actions/types";
+import {
+  EVENT_DIALOG,
+  EVENT_LIBRARY,
+  EVENT_SHAPE,
+  trackEvent,
+} from "../analytics";
 import { getDefaultAppState } from "../appState";
-import { t, getLanguage } from "../i18n";
-
 import {
   copyToClipboard,
   parseClipboard,
   probablySupportsClipboardBlob,
   probablySupportsClipboardWriteText,
 } from "../clipboard";
-import { normalizeScroll } from "../scene";
-import { getCenter, getDistance } from "../gesture";
-import { createUndoAction, createRedoAction } from "../actions/actionHistory";
-
 import {
+  APP_NAME,
+  CANVAS_ONLY_ACTIONS,
   CURSOR_TYPE,
+  DEFAULT_VERTICAL_ALIGN,
+  DRAGGING_THRESHOLD,
   ELEMENT_SHIFT_TRANSLATE_AMOUNT,
   ELEMENT_TRANSLATE_AMOUNT,
-  POINTER_BUTTON,
-  DRAGGING_THRESHOLD,
-  TEXT_TO_CENTER_SNAP_THRESHOLD,
-  LINE_CONFIRM_THRESHOLD,
-  EVENT,
   ENV,
-  CANVAS_ONLY_ACTIONS,
-  DEFAULT_VERTICAL_ALIGN,
+  EVENT,
   GRID_SIZE,
+  LINE_CONFIRM_THRESHOLD,
   MIME_TYPES,
+  POINTER_BUTTON,
   TAP_TWICE_TIMEOUT,
+  TEXT_TO_CENTER_SNAP_THRESHOLD,
   TOUCH_CTX_MENU_TIMEOUT,
-  APP_NAME,
 } from "../constants";
-
-import LayerUI from "./LayerUI";
-import { ScrollBars, SceneState } from "../scene/types";
+import { exportCanvas, loadFromBlob } from "../data";
+import { isValidLibrary } from "../data/json";
+import { Library } from "../data/library";
+import { restore } from "../data/restore";
+import {
+  dragNewElement,
+  dragSelectedElements,
+  duplicateElement,
+  getCommonBounds,
+  getCursorForResizingElement,
+  getDragOffsetXY,
+  getElementWithTransformHandleType,
+  getNonDeletedElements,
+  getNormalizedDimensions,
+  getPerfectElementSize,
+  getResizeArrowDirection,
+  getResizeOffsetXY,
+  getTransformHandleTypeFromCoords,
+  hitTest,
+  isHittingElementBoundingBoxWithoutHittingElement,
+  isInvisiblySmallElement,
+  isNonDeletedElement,
+  isTextElement,
+  newElement,
+  newLinearElement,
+  newTextElement,
+  textWysiwyg,
+  transformElements,
+  updateTextElement,
+} from "../element";
+import {
+  bindOrUnbindSelectedElements,
+  fixBindingsAfterDeletion,
+  fixBindingsAfterDuplication,
+  getEligibleElementsForBinding,
+  getHoveredElementForBinding,
+  isBindingEnabled,
+  isLinearElementSimpleAndAlreadyBound,
+  maybeBindLinearElement,
+  shouldEnableBindingForPointerEvent,
+  unbindLinearElements,
+  updateBoundElements,
+} from "../element/binding";
+import { LinearElementEditor } from "../element/linearElementEditor";
 import { mutateElement } from "../element/mutateElement";
-import { invalidateShapeForElement } from "../renderer/renderElement";
+import { deepCopyElement } from "../element/newElement";
+import { MaybeTransformHandleType } from "../element/transformHandles";
 import {
-  isLinearElement,
-  isLinearElementType,
   isBindingElement,
   isBindingElementType,
+  isLinearElement,
+  isLinearElementType,
 } from "../element/typeChecks";
-import { actionFinalize, actionDeleteSelected } from "../actions";
-
-import { LinearElementEditor } from "../element/linearElementEditor";
 import {
+  ExcalidrawBindableElement,
+  ExcalidrawElement,
+  ExcalidrawGenericElement,
+  ExcalidrawLinearElement,
+  ExcalidrawTextElement,
+  NonDeleted,
+} from "../element/types";
+import { getCenter, getDistance } from "../gesture";
+import {
+  editGroupForSelectedElement,
+  getElementsInGroup,
+  getSelectedGroupIdForElement,
   getSelectedGroupIds,
+  isElementInGroup,
   isSelectedViaGroup,
   selectGroupsForSelectedElements,
-  isElementInGroup,
-  getSelectedGroupIdForElement,
-  getElementsInGroup,
-  editGroupForSelectedElement,
 } from "../groups";
-import { Library } from "../data/library";
-import Scene from "../scene/Scene";
+import { createHistory, SceneHistory } from "../history";
+import { getLanguage, t } from "../i18n";
 import {
-  getHoveredElementForBinding,
-  maybeBindLinearElement,
-  getEligibleElementsForBinding,
-  bindOrUnbindSelectedElements,
-  unbindLinearElements,
-  fixBindingsAfterDuplication,
-  fixBindingsAfterDeletion,
-  isLinearElementSimpleAndAlreadyBound,
-  isBindingEnabled,
-  updateBoundElements,
-  shouldEnableBindingForPointerEvent,
-} from "../element/binding";
-import { MaybeTransformHandleType } from "../element/transformHandles";
-import { deepCopyElement } from "../element/newElement";
-import { renderSpreadsheet } from "../charts";
-import { isValidLibrary } from "../data/json";
+  CODES,
+  getResizeCenterPointKey,
+  getResizeWithSidesSameLengthKey,
+  getRotateWithDiscreteAngleKey,
+  isArrowKey,
+  KEYS,
+} from "../keys";
+import { distance2d, getGridPoint, isPathALoop } from "../math";
+import { renderScene } from "../renderer";
+import { invalidateShapeForElement } from "../renderer/renderElement";
+import {
+  calculateScrollCenter,
+  getElementContainingPosition,
+  getElementsAtPosition,
+  getElementsWithinSelection,
+  getNormalizedZoom,
+  getSelectedElements,
+  isOverScrollBars,
+  isSomeElementSelected,
+  normalizeScroll,
+} from "../scene";
+import Scene from "../scene/Scene";
+import { SceneState, ScrollBars } from "../scene/types";
 import { getNewZoom } from "../scene/zoom";
-import { restore } from "../data/restore";
+import { findShapeByKey } from "../shapes";
 import {
-  EVENT_DIALOG,
-  EVENT_LIBRARY,
-  EVENT_SHAPE,
-  trackEvent,
-} from "../analytics";
+  AppState,
+  ExcalidrawProps,
+  Gesture,
+  GestureEvent,
+  SceneData,
+} from "../types";
+import {
+  debounce,
+  distance,
+  isInputLike,
+  isToolIcon,
+  isWritableElement,
+  resetCursor,
+  ResolvablePromise,
+  resolvablePromise,
+  sceneCoordsToViewportCoords,
+  setCursorForShape,
+  tupleToCoors,
+  viewportCoordsToSceneCoords,
+  withBatchedUpdates,
+} from "../utils";
+import ContextMenu from "./ContextMenu";
+import LayerUI from "./LayerUI";
 import { Stats } from "./Stats";
 
 const { history } = createHistory();
@@ -374,7 +360,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
           elements={this.scene.getElements()}
           onCollabButtonClick={onCollabButtonClick}
           onLockToggle={this.toggleLock}
-          onInsertShape={(elements) =>
+          onInsertElements={(elements) =>
             this.addElementsFromPasteOrLibrary(
               elements,
               DEFAULT_PASTE_X,
@@ -1004,9 +990,12 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       if (data.errorMessage) {
         this.setState({ errorMessage: data.errorMessage });
       } else if (data.spreadsheet) {
-        this.addElementsFromPasteOrLibrary(
-          renderSpreadsheet(data.spreadsheet, cursorX, cursorY),
-        );
+        this.setState({
+          pasteDialog: {
+            data: data.spreadsheet,
+            shown: true,
+          },
+        });
       } else if (data.elements) {
         this.addElementsFromPasteOrLibrary(data.elements);
       } else if (data.text) {

+ 7 - 7
src/components/Dialog.tsx

@@ -1,13 +1,12 @@
-import React, { useCallback, useEffect, useState } from "react";
 import clsx from "clsx";
-import { Modal } from "./Modal";
-import { Island } from "./Island";
+import React, { useCallback, useEffect, useState } from "react";
 import { t } from "../i18n";
 import useIsMobile from "../is-mobile";
-import { back, close } from "./icons";
 import { KEYS } from "../keys";
-
 import "./Dialog.scss";
+import { back, close } from "./icons";
+import { Island } from "./Island";
+import { Modal } from "./Modal";
 
 const useRefState = <T,>() => {
   const [refValue, setRefValue] = useState<T | null>(null);
@@ -23,6 +22,7 @@ export const Dialog = (props: {
   maxWidth?: number;
   onCloseRequest(): void;
   title: React.ReactNode;
+  autofocus?: boolean;
 }) => {
   const [islandNode, setIslandNode] = useRefState<HTMLDivElement>();
 
@@ -33,7 +33,7 @@ export const Dialog = (props: {
 
     const focusableElements = queryFocusableElements(islandNode);
 
-    if (focusableElements.length > 0) {
+    if (focusableElements.length > 0 && props.autofocus !== false) {
       // If there's an element other than close, focus it.
       (focusableElements[1] || focusableElements[0]).focus();
     }
@@ -62,7 +62,7 @@ export const Dialog = (props: {
     islandNode.addEventListener("keydown", handleKeyDown);
 
     return () => islandNode.removeEventListener("keydown", handleKeyDown);
-  }, [islandNode]);
+  }, [islandNode, props.autofocus]);
 
   const queryFocusableElements = (node: HTMLElement) => {
     const focusableElements = node.querySelectorAll<HTMLElement>(

+ 40 - 18
src/components/LayerUI.tsx

@@ -51,6 +51,7 @@ import {
   EVENT_LIBRARY,
   trackEvent,
 } from "../analytics";
+import { PasteChartDialog } from "./PasteChartDialog";
 
 interface LayerUIProps {
   actionManager: ActionManager;
@@ -60,7 +61,7 @@ interface LayerUIProps {
   elements: readonly NonDeletedExcalidrawElement[];
   onCollabButtonClick?: () => void;
   onLockToggle: () => void;
-  onInsertShape: (elements: LibraryItem) => void;
+  onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
   zenModeEnabled: boolean;
   toggleZenMode: () => void;
   lng: string;
@@ -318,7 +319,7 @@ const LayerUI = ({
   elements,
   onCollabButtonClick,
   onLockToggle,
-  onInsertShape,
+  onInsertElements,
   zenModeEnabled,
   toggleZenMode,
   isCollaborating,
@@ -456,7 +457,7 @@ const LayerUI = ({
     <LibraryMenu
       pendingElements={getSelectedElements(elements, appState)}
       onClickOutside={closeLibrary}
-      onInsertShape={onInsertShape}
+      onInsertShape={onInsertElements}
       onAddToLibrary={deselectItems}
       setAppState={setAppState}
     />
@@ -592,21 +593,8 @@ const LayerUI = ({
     </footer>
   );
 
-  return isMobile ? (
-    <MobileMenu
-      appState={appState}
-      elements={elements}
-      actionManager={actionManager}
-      libraryMenu={libraryMenu}
-      exportButton={renderExportDialog()}
-      setAppState={setAppState}
-      onCollabButtonClick={onCollabButtonClick}
-      onLockToggle={onLockToggle}
-      canvas={canvas}
-      isCollaborating={isCollaborating}
-    />
-  ) : (
-    <div className="layer-ui__wrapper">
+  const dialogs = (
+    <>
       {appState.isLoading && <LoadingMessage />}
       {appState.errorMessage && (
         <ErrorDialog
@@ -619,6 +607,40 @@ const LayerUI = ({
           onClose={() => setAppState({ showShortcutsDialog: false })}
         />
       )}
+      {appState.pasteDialog.shown && (
+        <PasteChartDialog
+          setAppState={setAppState}
+          appState={appState}
+          onInsertChart={onInsertElements}
+          onClose={() =>
+            setAppState({
+              pasteDialog: { shown: false, data: null },
+            })
+          }
+        />
+      )}
+    </>
+  );
+
+  return isMobile ? (
+    <>
+      {dialogs}
+      <MobileMenu
+        appState={appState}
+        elements={elements}
+        actionManager={actionManager}
+        libraryMenu={libraryMenu}
+        exportButton={renderExportDialog()}
+        setAppState={setAppState}
+        onCollabButtonClick={onCollabButtonClick}
+        onLockToggle={onLockToggle}
+        canvas={canvas}
+        isCollaborating={isCollaborating}
+      />
+    </>
+  ) : (
+    <div className="layer-ui__wrapper">
+      {dialogs}
       {renderFixedSideContainer()}
       {renderBottomAppMenu()}
       {

+ 6 - 6
src/components/LibraryUnit.tsx

@@ -1,13 +1,13 @@
-import React, { useRef, useEffect, useState } from "react";
 import clsx from "clsx";
-import { exportToSvg } from "../scene/export";
+import oc from "open-color";
+import React, { useEffect, useRef, useState } from "react";
 import { close } from "../components/icons";
-
-import "./LibraryUnit.scss";
+import { MIME_TYPES } from "../constants";
 import { t } from "../i18n";
 import useIsMobile from "../is-mobile";
+import { exportToSvg } from "../scene/export";
 import { LibraryItem } from "../types";
-import { MIME_TYPES } from "../constants";
+import "./LibraryUnit.scss";
 
 // fa-plus
 const PLUS_ICON = (
@@ -38,7 +38,7 @@ export const LibraryUnit = ({
     }
     const svg = exportToSvg(elementsToRender, {
       exportBackground: false,
-      viewBackgroundColor: "#fff",
+      viewBackgroundColor: oc.white,
       shouldAddWatermark: false,
     });
     for (const child of ref.current!.children) {

+ 0 - 2
src/components/MobileMenu.tsx

@@ -15,7 +15,6 @@ import { Section } from "./Section";
 import CollabButton from "./CollabButton";
 import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
 import { LockIcon } from "./LockIcon";
-import { LoadingMessage } from "./LoadingMessage";
 import { UserList } from "./UserList";
 import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
 import { EVENT_ACTION, trackEvent } from "../analytics";
@@ -46,7 +45,6 @@ export const MobileMenu = ({
   isCollaborating,
 }: MobileMenuProps) => (
   <>
-    {appState.isLoading && <LoadingMessage />}
     <FixedSideContainer side="top">
       <Section heading="shapes">
         {(heading) => (

+ 46 - 0
src/components/PasteChartDialog.scss

@@ -0,0 +1,46 @@
+@import "../css/_variables";
+
+.excalidraw {
+  .PasteChartDialog {
+    @media #{$media-query} {
+      .Island {
+        display: flex;
+        flex-direction: column;
+      }
+    }
+    .container {
+      display: flex;
+      align-items: center;
+      justify-content: space-around;
+      flex-wrap: wrap;
+      @media #{$media-query} {
+        flex-direction: column;
+        justify-content: center;
+      }
+    }
+    .ChartPreview {
+      margin: 8px;
+      text-align: center;
+      width: 192px;
+      height: 128px;
+      border-radius: 2px;
+      padding: 1px;
+      border: 1px solid $oc-gray-4;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      background: transparent;
+      div {
+        display: inline-block;
+      }
+      svg {
+        max-height: 120px;
+        max-width: 186px;
+      }
+      &:hover {
+        padding: 0;
+        border: 2px solid $oc-blue-5;
+      }
+    }
+  }
+}

+ 121 - 0
src/components/PasteChartDialog.tsx

@@ -0,0 +1,121 @@
+import oc from "open-color";
+import React, { useLayoutEffect, useRef, useState } from "react";
+import { ChartElements, renderSpreadsheet, Spreadsheet } from "../charts";
+import { ChartType } from "../element/types";
+import { exportToSvg } from "../scene/export";
+import { AppState, LibraryItem } from "../types";
+import { Dialog } from "./Dialog";
+import "./PasteChartDialog.scss";
+
+type OnInsertChart = (chartType: ChartType, elements: ChartElements) => void;
+
+const ChartPreviewBtn = (props: {
+  spreadsheet: Spreadsheet | null;
+  chartType: ChartType;
+  selected: boolean;
+  onClick: OnInsertChart;
+}) => {
+  const previewRef = useRef<HTMLDivElement | null>(null);
+  const [chartElements, setChartElements] = useState<ChartElements | null>(
+    null,
+  );
+
+  useLayoutEffect(() => {
+    if (!props.spreadsheet) {
+      return;
+    }
+
+    const elements = renderSpreadsheet(
+      props.chartType,
+      props.spreadsheet,
+      0,
+      0,
+    );
+    setChartElements(elements);
+
+    const svg = exportToSvg(elements, {
+      exportBackground: false,
+      viewBackgroundColor: oc.white,
+      shouldAddWatermark: false,
+    });
+
+    const previewNode = previewRef.current!;
+
+    previewNode.appendChild(svg);
+
+    if (props.selected) {
+      (previewNode.parentNode as HTMLDivElement).focus();
+    }
+
+    return () => {
+      previewNode.removeChild(svg);
+    };
+  }, [props.spreadsheet, props.chartType, props.selected]);
+
+  return (
+    <button
+      className="ChartPreview"
+      onClick={() => {
+        if (chartElements) {
+          props.onClick(props.chartType, chartElements);
+        }
+      }}
+    >
+      <div ref={previewRef} />
+    </button>
+  );
+};
+
+export const PasteChartDialog = ({
+  setAppState,
+  appState,
+  onClose,
+  onInsertChart,
+}: {
+  appState: AppState;
+  onClose: () => void;
+  setAppState: React.Component<any, AppState>["setState"];
+  onInsertChart: (elements: LibraryItem) => void;
+}) => {
+  const handleClose = React.useCallback(() => {
+    if (onClose) {
+      onClose();
+    }
+  }, [onClose]);
+
+  const handleChartClick = (chartType: ChartType, elements: ChartElements) => {
+    onInsertChart(elements);
+    setAppState({
+      currentChartType: chartType,
+      pasteDialog: {
+        shown: false,
+        data: null,
+      },
+    });
+  };
+
+  return (
+    <Dialog
+      maxWidth={500}
+      onCloseRequest={handleClose}
+      title={"Paste chart"}
+      className={"PasteChartDialog"}
+      autofocus={false}
+    >
+      <div className={"container"}>
+        <ChartPreviewBtn
+          chartType="bar"
+          spreadsheet={appState.pasteDialog.data}
+          selected={appState.currentChartType === "bar"}
+          onClick={handleChartClick}
+        />
+        <ChartPreviewBtn
+          chartType="line"
+          spreadsheet={appState.pasteDialog.data}
+          selected={appState.currentChartType === "line"}
+          onClick={handleChartClick}
+        />
+      </div>
+    </Dialog>
+  );
+};

+ 0 - 1
src/components/Stats.tsx

@@ -85,7 +85,6 @@ export const Stats = (props: {
               <td>{t("stats.total")}</td>
               <td>{nFormatter(storageSizes.total, 1)}</td>
             </tr>
-
             {selectedElements.length === 1 && (
               <tr>
                 <th colSpan={2}>{t("stats.element")}</th>

+ 1 - 0
src/element/types.ts

@@ -1,6 +1,7 @@
 import { Point } from "../types";
 import { FONT_FAMILY } from "../constants";
 
+export type ChartType = "bar" | "line";
 export type FillStyle = "hachure" | "cross-hatch" | "solid";
 export type FontFamily = keyof typeof FONT_FAMILY;
 export type FontString = string & { _brand: "fontString" };

+ 5 - 1
src/locales/en.json

@@ -90,7 +90,9 @@
     "centerVertically": "Center vertically",
     "centerHorizontally": "Center horizontally",
     "distributeHorizontally": "Distribute horizontally",
-    "distributeVertically": "Distribute vertically"
+    "distributeVertically": "Distribute vertically",
+    "chartTypeBar": "Bar",
+    "chartTypeLine": "Line"
   },
   "buttons": {
     "clearReset": "Reset the canvas",
@@ -222,6 +224,8 @@
   },
   "stats": {
     "angle": "Angle",
+    "charts": "Charts",
+    "current": "Current",
     "element": "Element",
     "elements": "Elements",
     "height": "Height",

File diff suppressed because it is too large
+ 265 - 0
src/tests/__snapshots__/regressionTests.test.tsx.snap


+ 12 - 0
src/types.ts

@@ -9,6 +9,7 @@ import {
   GroupId,
   ExcalidrawBindableElement,
   Arrowhead,
+  ChartType,
 } from "./element/types";
 import { SHAPES } from "./shapes";
 import { Point as RoughPoint } from "roughjs/bin/geometry";
@@ -17,6 +18,7 @@ import { SuggestedBinding } from "./element/binding";
 import { ImportedDataState } from "./data/types";
 import { ExcalidrawImperativeAPI } from "./components/App";
 import type { ResolvablePromise } from "./utils";
+import { Spreadsheet } from "./charts";
 
 export type FlooredNumber = number & { _brand: "FlooredNumber" };
 export type Point = Readonly<RoughPoint>;
@@ -97,6 +99,16 @@ export type AppState = {
   fileHandle: import("browser-nativefs").FileSystemHandle | null;
   collaborators: Map<string, Collaborator>;
   showStats: boolean;
+  currentChartType: ChartType;
+  pasteDialog:
+    | {
+        shown: false;
+        data: null;
+      }
+    | {
+        shown: true;
+        data: Spreadsheet;
+      };
 };
 
 export type NormalizedZoomValue = number & { _brand: "normalizedZoom" };

Some files were not shown because too many files changed in this diff