浏览代码

Feature: Action System (#298)

* Add Action System

- Add keyboard test
- Add context menu label
- Add PanelComponent

* Show context menu items based on actions

* Add render action feature

- Replace bringForward etc buttons with action manager render functions

* Move all property changes and canvas into actions

* Remove unnecessary functions and add forgotten force update when elements array change

* Extract export operations into actions

* Add elements and app state as arguments to `keyTest` function

* Add key priorities

- Sort actions by key priority when handling key presses

* Extract copy/paste styles

* Add Context Menu Item order

- Sort context menu items based on menu item order parameter

* Remove unnecessary functions from App component
Gasim Gasimzada 5 年之前
父节点
当前提交
f465121f9b

+ 47 - 0
src/actions/actionCanvas.tsx

@@ -0,0 +1,47 @@
+import React from "react";
+import { Action } from "./types";
+import { ColorPicker } from "../components/ColorPicker";
+
+export const actionChangeViewBackgroundColor: Action = {
+  name: "changeViewBackgroundColor",
+  perform: (elements, appState, value) => {
+    return { appState: { ...appState, viewBackgroundColor: value } };
+  },
+  PanelComponent: ({ appState, updateData }) => (
+    <>
+      <h5>Canvas Background Color</h5>
+      <ColorPicker
+        color={appState.viewBackgroundColor}
+        onChange={color => updateData(color)}
+      />
+    </>
+  )
+};
+
+export const actionClearCanvas: Action = {
+  name: "clearCanvas",
+  perform: (elements, appState, value) => {
+    return {
+      elements: [],
+      appState: {
+        ...appState,
+        viewBackgroundColor: "#ffffff",
+        scrollX: 0,
+        scrollY: 0
+      }
+    };
+  },
+  PanelComponent: ({ updateData }) => (
+    <button
+      type="button"
+      onClick={() => {
+        if (window.confirm("This will clear the whole canvas. Are you sure?")) {
+          updateData(null);
+        }
+      }}
+      title="Clear the canvas & reset background color"
+    >
+      Clear canvas
+    </button>
+  )
+};

+ 19 - 0
src/actions/actionDeleteSelected.tsx

@@ -0,0 +1,19 @@
+import React from "react";
+import { Action } from "./types";
+import { deleteSelectedElements } from "../scene";
+import { KEYS } from "../keys";
+
+export const actionDeleteSelected: Action = {
+  name: "deleteSelectedElements",
+  perform: elements => {
+    return {
+      elements: deleteSelectedElements(elements)
+    };
+  },
+  contextItemLabel: "Delete",
+  contextMenuOrder: 3,
+  keyTest: event => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
+  PanelComponent: ({ updateData }) => (
+    <button onClick={() => updateData(null)}>Delete selected</button>
+  )
+};

+ 70 - 0
src/actions/actionExport.tsx

@@ -0,0 +1,70 @@
+import React from "react";
+import { Action } from "./types";
+import { EditableText } from "../components/EditableText";
+import { saveAsJSON, loadFromJSON } from "../scene";
+
+export const actionChangeProjectName: Action = {
+  name: "changeProjectName",
+  perform: (elements, appState, value) => {
+    return { appState: { ...appState, name: value } };
+  },
+  PanelComponent: ({ appState, updateData }) => (
+    <>
+      <h5>Name</h5>
+      {appState.name && (
+        <EditableText
+          value={appState.name}
+          onChange={(name: string) => updateData(name)}
+        />
+      )}
+    </>
+  )
+};
+
+export const actionChangeExportBackground: Action = {
+  name: "changeExportBackground",
+  perform: (elements, appState, value) => {
+    return { appState: { ...appState, exportBackground: value } };
+  },
+  PanelComponent: ({ appState, updateData }) => (
+    <label>
+      <input
+        type="checkbox"
+        checked={appState.exportBackground}
+        onChange={e => {
+          updateData(e.target.checked);
+        }}
+      />
+      background
+    </label>
+  )
+};
+
+export const actionSaveScene: Action = {
+  name: "saveScene",
+  perform: (elements, appState, value) => {
+    saveAsJSON(elements, appState.name);
+    return {};
+  },
+  PanelComponent: ({ updateData }) => (
+    <button onClick={() => updateData(null)}>Save as...</button>
+  )
+};
+
+export const actionLoadScene: Action = {
+  name: "loadScene",
+  perform: (elements, appState, loadedElements) => {
+    return { elements: loadedElements };
+  },
+  PanelComponent: ({ updateData }) => (
+    <button
+      onClick={() => {
+        loadFromJSON().then(({ elements }) => {
+          updateData(elements);
+        });
+      }}
+    >
+      Load file...
+    </button>
+  )
+};

+ 251 - 0
src/actions/actionProperties.tsx

@@ -0,0 +1,251 @@
+import React from "react";
+import { Action } from "./types";
+import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
+import { getSelectedAttribute } from "../scene";
+import { ButtonSelect } from "../components/ButtonSelect";
+import { PanelColor } from "../components/panels/PanelColor";
+import { isTextElement, redrawTextBoundingBox } from "../element";
+
+const changeProperty = (
+  elements: readonly ExcalidrawElement[],
+  callback: (element: ExcalidrawElement) => ExcalidrawElement
+) => {
+  return elements.map(element => {
+    if (element.isSelected) {
+      return callback(element);
+    }
+    return element;
+  });
+};
+
+export const actionChangeStrokeColor: Action = {
+  name: "changeStrokeColor",
+  perform: (elements, appState, value) => {
+    return {
+      elements: changeProperty(elements, el => ({
+        ...el,
+        strokeColor: value
+      })),
+      appState: { ...appState, currentItemStrokeColor: value }
+    };
+  },
+  PanelComponent: ({ elements, appState, updateData }) => (
+    <PanelColor
+      title="Stroke Color"
+      onColorChange={(color: string) => {
+        updateData(color);
+      }}
+      colorValue={getSelectedAttribute(
+        elements,
+        element => element.strokeColor
+      )}
+    />
+  )
+};
+
+export const actionChangeBackgroundColor: Action = {
+  name: "changeBackgroundColor",
+  perform: (elements, appState, value) => {
+    return {
+      elements: changeProperty(elements, el => ({
+        ...el,
+        backgroundColor: value
+      })),
+      appState: { ...appState, currentItemBackgroundColor: value }
+    };
+  },
+  PanelComponent: ({ elements, updateData }) => (
+    <PanelColor
+      title="Background Color"
+      onColorChange={(color: string) => {
+        updateData(color);
+      }}
+      colorValue={getSelectedAttribute(
+        elements,
+        element => element.backgroundColor
+      )}
+    />
+  )
+};
+
+export const actionChangeFillStyle: Action = {
+  name: "changeFillStyle",
+  perform: (elements, appState, value) => {
+    return {
+      elements: changeProperty(elements, el => ({
+        ...el,
+        fillStyle: value
+      }))
+    };
+  },
+  PanelComponent: ({ elements, updateData }) => (
+    <>
+      <h5>Fill</h5>
+      <ButtonSelect
+        options={[
+          { value: "solid", text: "Solid" },
+          { value: "hachure", text: "Hachure" },
+          { value: "cross-hatch", text: "Cross-hatch" }
+        ]}
+        value={getSelectedAttribute(elements, element => element.fillStyle)}
+        onChange={value => {
+          updateData(value);
+        }}
+      />
+    </>
+  )
+};
+
+export const actionChangeStrokeWidth: Action = {
+  name: "changeStrokeWidth",
+  perform: (elements, appState, value) => {
+    return {
+      elements: changeProperty(elements, el => ({
+        ...el,
+        strokeWidth: value
+      }))
+    };
+  },
+  PanelComponent: ({ elements, appState, updateData }) => (
+    <>
+      <h5>Stroke Width</h5>
+      <ButtonSelect
+        options={[
+          { value: 1, text: "Thin" },
+          { value: 2, text: "Bold" },
+          { value: 4, text: "Extra Bold" }
+        ]}
+        value={getSelectedAttribute(elements, element => element.strokeWidth)}
+        onChange={value => updateData(value)}
+      />
+    </>
+  )
+};
+
+export const actionChangeSloppiness: Action = {
+  name: "changeSloppiness",
+  perform: (elements, appState, value) => {
+    return {
+      elements: changeProperty(elements, el => ({
+        ...el,
+        roughness: value
+      }))
+    };
+  },
+  PanelComponent: ({ elements, appState, updateData }) => (
+    <>
+      <h5>Sloppiness</h5>
+      <ButtonSelect
+        options={[
+          { value: 0, text: "Draftsman" },
+          { value: 1, text: "Artist" },
+          { value: 3, text: "Cartoonist" }
+        ]}
+        value={getSelectedAttribute(elements, element => element.roughness)}
+        onChange={value => updateData(value)}
+      />
+    </>
+  )
+};
+
+export const actionChangeOpacity: Action = {
+  name: "changeOpacity",
+  perform: (elements, appState, value) => {
+    return {
+      elements: changeProperty(elements, el => ({
+        ...el,
+        opacity: value
+      }))
+    };
+  },
+  PanelComponent: ({ elements, updateData }) => (
+    <>
+      <h5>Opacity</h5>
+      <input
+        type="range"
+        min="0"
+        max="100"
+        onChange={e => updateData(+e.target.value)}
+        value={
+          getSelectedAttribute(elements, element => element.opacity) ||
+          0 /* Put the opacity at 0 if there are two conflicting ones */
+        }
+      />
+    </>
+  )
+};
+
+export const actionChangeFontSize: Action = {
+  name: "changeFontSize",
+  perform: (elements, appState, value) => {
+    return {
+      elements: changeProperty(elements, el => {
+        if (isTextElement(el)) {
+          const element: ExcalidrawTextElement = {
+            ...el,
+            font: `${value}px ${el.font.split("px ")[1]}`
+          };
+          redrawTextBoundingBox(element);
+          return element;
+        }
+
+        return el;
+      })
+    };
+  },
+  PanelComponent: ({ elements, updateData }) => (
+    <>
+      <h5>Font size</h5>
+      <ButtonSelect
+        options={[
+          { value: 16, text: "Small" },
+          { value: 20, text: "Medium" },
+          { value: 28, text: "Large" },
+          { value: 36, text: "Very Large" }
+        ]}
+        value={getSelectedAttribute(
+          elements,
+          element => isTextElement(element) && +element.font.split("px ")[0]
+        )}
+        onChange={value => updateData(value)}
+      />
+    </>
+  )
+};
+
+export const actionChangeFontFamily: Action = {
+  name: "changeFontFamily",
+  perform: (elements, appState, value) => {
+    return {
+      elements: changeProperty(elements, el => {
+        if (isTextElement(el)) {
+          const element: ExcalidrawTextElement = {
+            ...el,
+            font: `${el.font.split("px ")[0]}px ${value}`
+          };
+          redrawTextBoundingBox(element);
+          return element;
+        }
+
+        return el;
+      })
+    };
+  },
+  PanelComponent: ({ elements, updateData }) => (
+    <>
+      <h5>Font family</h5>
+      <ButtonSelect
+        options={[
+          { value: "Virgil", text: "Virgil" },
+          { value: "Helvetica", text: "Helvetica" },
+          { value: "Courier", text: "Courier" }
+        ]}
+        value={getSelectedAttribute(
+          elements,
+          element => isTextElement(element) && element.font.split("px ")[1]
+        )}
+        onChange={value => updateData(value)}
+      />
+    </>
+  )
+};

+ 13 - 0
src/actions/actionSelectAll.ts

@@ -0,0 +1,13 @@
+import { Action } from "./types";
+import { META_KEY } from "../keys";
+
+export const actionSelectAll: Action = {
+  name: "selectAll",
+  perform: elements => {
+    return {
+      elements: elements.map(elem => ({ ...elem, isSelected: true }))
+    };
+  },
+  contextItemLabel: "Select All",
+  keyTest: event => event[META_KEY] && event.code === "KeyA"
+};

+ 50 - 0
src/actions/actionStyles.ts

@@ -0,0 +1,50 @@
+import { Action } from "./types";
+import { isTextElement, redrawTextBoundingBox } from "../element";
+import { META_KEY } from "../keys";
+
+let copiedStyles: string = "{}";
+
+export const actionCopyStyles: Action = {
+  name: "copyStyles",
+  perform: elements => {
+    const element = elements.find(el => el.isSelected);
+    if (element) {
+      copiedStyles = JSON.stringify(element);
+    }
+    return {};
+  },
+  contextItemLabel: "Copy Styles",
+  keyTest: event => event[META_KEY] && event.shiftKey && event.code === "KeyC",
+  contextMenuOrder: 0
+};
+
+export const actionPasteStyles: Action = {
+  name: "pasteStyles",
+  perform: elements => {
+    const pastedElement = JSON.parse(copiedStyles);
+    return {
+      elements: elements.map(element => {
+        if (element.isSelected) {
+          const newElement = {
+            ...element,
+            backgroundColor: pastedElement?.backgroundColor,
+            strokeWidth: pastedElement?.strokeWidth,
+            strokeColor: pastedElement?.strokeColor,
+            fillStyle: pastedElement?.fillStyle,
+            opacity: pastedElement?.opacity,
+            roughness: pastedElement?.roughness
+          };
+          if (isTextElement(newElement)) {
+            newElement.font = pastedElement?.font;
+            redrawTextBoundingBox(newElement);
+          }
+          return newElement;
+        }
+        return element;
+      })
+    };
+  },
+  contextItemLabel: "Paste Styles",
+  keyTest: event => event[META_KEY] && event.shiftKey && event.code === "KeyV",
+  contextMenuOrder: 1
+};

+ 82 - 0
src/actions/actionZindex.tsx

@@ -0,0 +1,82 @@
+import React from "react";
+import { Action } from "./types";
+import {
+  moveOneLeft,
+  moveOneRight,
+  moveAllLeft,
+  moveAllRight
+} from "../zindex";
+import { getSelectedIndices } from "../scene";
+import { META_KEY } from "../keys";
+
+export const actionSendBackward: Action = {
+  name: "sendBackward",
+  perform: (elements, appState) => {
+    return {
+      elements: moveOneLeft([...elements], getSelectedIndices(elements)),
+      appState
+    };
+  },
+  contextItemLabel: "Send Backward",
+  keyPriority: 40,
+  keyTest: event =>
+    event[META_KEY] && event.shiftKey && event.altKey && event.code === "KeyB",
+  PanelComponent: ({ updateData }) => (
+    <button type="button" onClick={e => updateData(null)}>
+      Send backward
+    </button>
+  )
+};
+
+export const actionBringForward: Action = {
+  name: "bringForward",
+  perform: (elements, appState) => {
+    return {
+      elements: moveOneRight([...elements], getSelectedIndices(elements)),
+      appState
+    };
+  },
+  contextItemLabel: "Bring Forward",
+  keyPriority: 40,
+  keyTest: event =>
+    event[META_KEY] && event.shiftKey && event.altKey && event.code === "KeyF",
+  PanelComponent: ({ updateData }) => (
+    <button type="button" onClick={e => updateData(null)}>
+      Bring Forward
+    </button>
+  )
+};
+
+export const actionSendToBack: Action = {
+  name: "sendToBack",
+  perform: (elements, appState) => {
+    return {
+      elements: moveAllLeft([...elements], getSelectedIndices(elements)),
+      appState
+    };
+  },
+  contextItemLabel: "Send to Back",
+  keyTest: event => event[META_KEY] && event.shiftKey && event.code === "KeyB",
+  PanelComponent: ({ updateData }) => (
+    <button type="button" onClick={e => updateData(null)}>
+      Send to Back
+    </button>
+  )
+};
+
+export const actionBringToFront: Action = {
+  name: "bringToFront",
+  perform: (elements, appState) => {
+    return {
+      elements: moveAllRight([...elements], getSelectedIndices(elements)),
+      appState
+    };
+  },
+  contextItemLabel: "Bring to Front",
+  keyTest: event => event[META_KEY] && event.shiftKey && event.code === "KeyF",
+  PanelComponent: ({ updateData }) => (
+    <button type="button" onClick={e => updateData(null)}>
+      Bring to Front
+    </button>
+  )
+};

+ 33 - 0
src/actions/index.ts

@@ -0,0 +1,33 @@
+export { ActionManager } from "./manager";
+export { actionDeleteSelected } from "./actionDeleteSelected";
+export {
+  actionBringForward,
+  actionBringToFront,
+  actionSendBackward,
+  actionSendToBack
+} from "./actionZindex";
+export { actionSelectAll } from "./actionSelectAll";
+export {
+  actionChangeStrokeColor,
+  actionChangeBackgroundColor,
+  actionChangeStrokeWidth,
+  actionChangeFillStyle,
+  actionChangeSloppiness,
+  actionChangeOpacity,
+  actionChangeFontSize,
+  actionChangeFontFamily
+} from "./actionProperties";
+
+export {
+  actionChangeViewBackgroundColor,
+  actionClearCanvas
+} from "./actionCanvas";
+
+export {
+  actionChangeProjectName,
+  actionChangeExportBackground,
+  actionSaveScene,
+  actionLoadScene
+} from "./actionExport";
+
+export { actionCopyStyles, actionPasteStyles } from "./actionStyles";

+ 89 - 0
src/actions/manager.tsx

@@ -0,0 +1,89 @@
+import React from "react";
+import { Action, ActionsManagerInterface, UpdaterFn } from "./types";
+import { ExcalidrawElement } from "../element/types";
+import { AppState } from "../types";
+
+export class ActionManager implements ActionsManagerInterface {
+  actions: { [keyProp: string]: Action } = {};
+
+  updater:
+    | ((elements: ExcalidrawElement[], appState: AppState) => void)
+    | null = null;
+
+  setUpdater(
+    updater: (elements: ExcalidrawElement[], appState: AppState) => void
+  ) {
+    this.updater = updater;
+  }
+
+  registerAction(action: Action) {
+    this.actions[action.name] = action;
+  }
+
+  handleKeyDown(
+    event: KeyboardEvent,
+    elements: readonly ExcalidrawElement[],
+    appState: AppState
+  ) {
+    const data = Object.values(this.actions)
+      .sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0))
+      .filter(
+        action => action.keyTest && action.keyTest(event, elements, appState)
+      );
+
+    if (data.length === 0) return {};
+
+    event.preventDefault();
+    return data[0].perform(elements, appState, null);
+  }
+
+  getContextMenuItems(
+    elements: readonly ExcalidrawElement[],
+    appState: AppState,
+    updater: UpdaterFn
+  ) {
+    console.log(
+      Object.values(this.actions)
+        .filter(action => "contextItemLabel" in action)
+        .map(a => ({ name: a.name, label: a.contextItemLabel }))
+    );
+    return Object.values(this.actions)
+      .filter(action => "contextItemLabel" in action)
+      .sort(
+        (a, b) =>
+          (a.contextMenuOrder !== undefined ? a.contextMenuOrder : 999) -
+          (b.contextMenuOrder !== undefined ? b.contextMenuOrder : 999)
+      )
+      .map(action => ({
+        label: action.contextItemLabel!,
+        action: () => {
+          updater(action.perform(elements, appState, null));
+        }
+      }));
+  }
+
+  renderAction(
+    name: string,
+    elements: readonly ExcalidrawElement[],
+    appState: AppState,
+    updater: UpdaterFn
+  ) {
+    if (this.actions[name] && "PanelComponent" in this.actions[name]) {
+      const action = this.actions[name];
+      const PanelComponent = action.PanelComponent!;
+      const updateData = (formState: any) => {
+        updater(action.perform(elements, appState, formState));
+      };
+
+      return (
+        <PanelComponent
+          elements={elements}
+          appState={appState}
+          updateData={updateData}
+        />
+      );
+    }
+
+    return null;
+  }
+}

+ 57 - 0
src/actions/types.ts

@@ -0,0 +1,57 @@
+import React from "react";
+import { ExcalidrawElement } from "../element/types";
+import { AppState } from "../types";
+
+export type ActionResult = {
+  elements?: ExcalidrawElement[];
+  appState?: AppState;
+};
+
+type ActionFn = (
+  elements: readonly ExcalidrawElement[],
+  appState: AppState,
+  formData: any
+) => ActionResult;
+
+export type UpdaterFn = (res: ActionResult) => void;
+
+export interface Action {
+  name: string;
+  PanelComponent?: React.FC<{
+    elements: readonly ExcalidrawElement[];
+    appState: AppState;
+    updateData: (formData: any) => void;
+  }>;
+  perform: ActionFn;
+  keyPriority?: number;
+  keyTest?: (
+    event: KeyboardEvent,
+    elements?: readonly ExcalidrawElement[],
+    appState?: AppState
+  ) => boolean;
+  contextItemLabel?: string;
+  contextMenuOrder?: number;
+}
+
+export interface ActionsManagerInterface {
+  actions: {
+    [keyProp: string]: Action;
+  };
+  registerAction: (action: Action) => void;
+  handleKeyDown: (
+    event: KeyboardEvent,
+    elements: readonly ExcalidrawElement[],
+    appState: AppState
+  ) => ActionResult | {};
+  getContextMenuItems: (
+    elements: readonly ExcalidrawElement[],
+    appState: AppState,
+    updater: UpdaterFn
+  ) => { label: string; action: () => void }[];
+  renderAction: (
+    name: string,
+    elements: ExcalidrawElement[],
+    appState: AppState,
+    updater: UpdaterFn
+  ) => React.ReactElement | null;
+}

+ 74 - 201
src/components/SidePanel.tsx

@@ -2,55 +2,36 @@ import React from "react";
 import { PanelTools } from "./panels/PanelTools";
 import { Panel } from "./Panel";
 import { PanelSelection } from "./panels/PanelSelection";
-import { PanelColor } from "./panels/PanelColor";
 import {
   hasBackground,
   someElementIsSelected,
-  getSelectedAttribute,
   hasStroke,
   hasText,
-  loadFromJSON,
-  saveAsJSON,
-  exportCanvas,
-  deleteSelectedElements
+  exportCanvas
 } from "../scene";
-import { ButtonSelect } from "./ButtonSelect";
 import { ExcalidrawElement } from "../element/types";
-import { redrawTextBoundingBox, isTextElement } from "../element";
 import { PanelCanvas } from "./panels/PanelCanvas";
 import { PanelExport } from "./panels/PanelExport";
 import { ExportType } from "../scene/types";
 import { AppState } from "../types";
+import { ActionManager } from "../actions";
+import { UpdaterFn } from "../actions/types";
 
 interface SidePanelProps {
+  actionManager: ActionManager;
   elements: readonly ExcalidrawElement[];
-  onToolChange: (elementType: string) => void;
-  changeProperty: (
-    callback: (element: ExcalidrawElement) => ExcalidrawElement
-  ) => void;
-  moveAllLeft: () => void;
-  moveOneLeft: () => void;
-  moveAllRight: () => void;
-  moveOneRight: () => void;
-  onClearCanvas: React.MouseEventHandler;
-  onUpdateAppState: (name: string, value: any) => void;
+  syncActionResult: UpdaterFn;
   appState: AppState;
-  onUpdateElements: (elements: readonly ExcalidrawElement[]) => void;
+  onToolChange: (elementType: string) => void;
   canvas: HTMLCanvasElement;
 }
 
 export const SidePanel: React.FC<SidePanelProps> = ({
+  actionManager,
+  syncActionResult,
   elements,
   onToolChange,
-  changeProperty,
-  moveAllLeft,
-  moveOneLeft,
-  moveAllRight,
-  moveOneRight,
-  onClearCanvas,
-  onUpdateAppState,
   appState,
-  onUpdateElements,
   canvas
 }) => {
   return (
@@ -63,209 +44,101 @@ export const SidePanel: React.FC<SidePanelProps> = ({
       />
       <Panel title="Selection" hide={!someElementIsSelected(elements)}>
         <PanelSelection
-          onBringForward={moveOneRight}
-          onBringToFront={moveAllRight}
-          onSendBackward={moveOneLeft}
-          onSendToBack={moveAllLeft}
+          actionManager={actionManager}
+          syncActionResult={syncActionResult}
+          elements={elements}
+          appState={appState}
         />
 
-        <PanelColor
-          title="Stroke Color"
-          onColorChange={(color: string) => {
-            changeProperty(element => ({
-              ...element,
-              strokeColor: color
-            }));
-            onUpdateAppState("currentItemStrokeColor", color);
-          }}
-          colorValue={getSelectedAttribute(
-            elements,
-            element => element.strokeColor
-          )}
-        />
+        {actionManager.renderAction(
+          "changeStrokeColor",
+          elements,
+          appState,
+          syncActionResult
+        )}
 
         {hasBackground(elements) && (
           <>
-            <PanelColor
-              title="Background Color"
-              onColorChange={(color: string) => {
-                changeProperty(element => ({
-                  ...element,
-                  backgroundColor: color
-                }));
-                onUpdateAppState("currentItemBackgroundColor", color);
-              }}
-              colorValue={getSelectedAttribute(
-                elements,
-                element => element.backgroundColor
-              )}
-            />
+            {actionManager.renderAction(
+              "changeBackgroundColor",
+              elements,
+              appState,
+              syncActionResult
+            )}
 
-            <h5>Fill</h5>
-            <ButtonSelect
-              options={[
-                { value: "solid", text: "Solid" },
-                { value: "hachure", text: "Hachure" },
-                { value: "cross-hatch", text: "Cross-hatch" }
-              ]}
-              value={getSelectedAttribute(
-                elements,
-                element => element.fillStyle
-              )}
-              onChange={value => {
-                changeProperty(element => ({
-                  ...element,
-                  fillStyle: value
-                }));
-              }}
-            />
+            {actionManager.renderAction(
+              "changeFillStyle",
+              elements,
+              appState,
+              syncActionResult
+            )}
           </>
         )}
 
         {hasStroke(elements) && (
           <>
-            <h5>Stroke Width</h5>
-            <ButtonSelect
-              options={[
-                { value: 1, text: "Thin" },
-                { value: 2, text: "Bold" },
-                { value: 4, text: "Extra Bold" }
-              ]}
-              value={getSelectedAttribute(
-                elements,
-                element => element.strokeWidth
-              )}
-              onChange={value => {
-                changeProperty(element => ({
-                  ...element,
-                  strokeWidth: value
-                }));
-              }}
-            />
+            {actionManager.renderAction(
+              "changeStrokeWidth",
+              elements,
+              appState,
+              syncActionResult
+            )}
 
-            <h5>Sloppiness</h5>
-            <ButtonSelect
-              options={[
-                { value: 0, text: "Draftsman" },
-                { value: 1, text: "Artist" },
-                { value: 3, text: "Cartoonist" }
-              ]}
-              value={getSelectedAttribute(
-                elements,
-                element => element.roughness
-              )}
-              onChange={value =>
-                changeProperty(element => ({
-                  ...element,
-                  roughness: value
-                }))
-              }
-            />
+            {actionManager.renderAction(
+              "changeSloppiness",
+              elements,
+              appState,
+              syncActionResult
+            )}
           </>
         )}
 
         {hasText(elements) && (
           <>
-            <h5>Font size</h5>
-            <ButtonSelect
-              options={[
-                { value: 16, text: "Small" },
-                { value: 20, text: "Medium" },
-                { value: 28, text: "Large" },
-                { value: 36, text: "Very Large" }
-              ]}
-              value={getSelectedAttribute(
-                elements,
-                element =>
-                  isTextElement(element) && +element.font.split("px ")[0]
-              )}
-              onChange={value =>
-                changeProperty(element => {
-                  if (isTextElement(element)) {
-                    element.font = `${value}px ${element.font.split("px ")[1]}`;
-                    redrawTextBoundingBox(element);
-                  }
-
-                  return element;
-                })
-              }
-            />
-            <h5>Font familly</h5>
-            <ButtonSelect
-              options={[
-                { value: "Virgil", text: "Virgil" },
-                { value: "Helvetica", text: "Helvetica" },
-                { value: "Courier", text: "Courier" }
-              ]}
-              value={getSelectedAttribute(
-                elements,
-                element =>
-                  isTextElement(element) && element.font.split("px ")[1]
-              )}
-              onChange={value =>
-                changeProperty(element => {
-                  if (isTextElement(element)) {
-                    element.font = `${element.font.split("px ")[0]}px ${value}`;
-                    redrawTextBoundingBox(element);
-                  }
+            {actionManager.renderAction(
+              "changeFontSize",
+              elements,
+              appState,
+              syncActionResult
+            )}
 
-                  return element;
-                })
-              }
-            />
+            {actionManager.renderAction(
+              "changeFontFamily",
+              elements,
+              appState,
+              syncActionResult
+            )}
           </>
         )}
 
-        <h5>Opacity</h5>
-        <input
-          type="range"
-          min="0"
-          max="100"
-          onChange={event => {
-            changeProperty(element => ({
-              ...element,
-              opacity: +event.target.value
-            }));
-          }}
-          value={
-            getSelectedAttribute(elements, element => element.opacity) ||
-            0 /* Put the opacity at 0 if there are two conflicting ones */
-          }
-        />
+        {actionManager.renderAction(
+          "changeOpacity",
+          elements,
+          appState,
+          syncActionResult
+        )}
 
-        <button
-          onClick={() => {
-            onUpdateElements(deleteSelectedElements(elements));
-          }}
-        >
-          Delete selected
-        </button>
+        {actionManager.renderAction(
+          "deleteSelectedElements",
+          elements,
+          appState,
+          syncActionResult
+        )}
       </Panel>
       <PanelCanvas
-        onClearCanvas={onClearCanvas}
-        onViewBackgroundColorChange={value => {
-          onUpdateAppState("viewBackgroundColor", value);
-        }}
-        viewBackgroundColor={appState.viewBackgroundColor}
+        actionManager={actionManager}
+        syncActionResult={syncActionResult}
+        elements={elements}
+        appState={appState}
       />
       <PanelExport
-        projectName={appState.name}
-        onProjectNameChange={name => {
-          onUpdateAppState("name", name);
-        }}
+        actionManager={actionManager}
+        syncActionResult={syncActionResult}
+        elements={elements}
+        appState={appState}
         onExportCanvas={(type: ExportType) =>
           exportCanvas(type, elements, canvas, appState)
         }
-        exportBackground={appState.exportBackground}
-        onExportBackgroundChange={value => {
-          onUpdateAppState("exportBackground", value);
-        }}
-        onSaveScene={() => saveAsJSON(elements, appState.name)}
-        onLoadScene={() =>
-          loadFromJSON().then(({ elements }) => {
-            onUpdateElements(elements);
-          })
-        }
       />
     </div>
   );

+ 25 - 19
src/components/panels/PanelCanvas.tsx

@@ -1,33 +1,39 @@
 import React from "react";
 
-import { ColorPicker } from "../ColorPicker";
 import { Panel } from "../Panel";
+import { ActionManager } from "../../actions";
+import { ExcalidrawElement } from "../../element/types";
+import { AppState } from "../../types";
+import { UpdaterFn } from "../../actions/types";
 
 interface PanelCanvasProps {
-  viewBackgroundColor: string;
-  onViewBackgroundColorChange: (val: string) => void;
-  onClearCanvas: React.MouseEventHandler;
+  actionManager: ActionManager;
+  elements: readonly ExcalidrawElement[];
+  appState: AppState;
+  syncActionResult: UpdaterFn;
 }
 
 export const PanelCanvas: React.FC<PanelCanvasProps> = ({
-  viewBackgroundColor,
-  onViewBackgroundColorChange,
-  onClearCanvas
+  actionManager,
+  elements,
+  appState,
+  syncActionResult
 }) => {
   return (
     <Panel title="Canvas">
-      <h5>Canvas Background Color</h5>
-      <ColorPicker
-        color={viewBackgroundColor}
-        onChange={color => onViewBackgroundColorChange(color)}
-      />
-      <button
-        type="button"
-        onClick={onClearCanvas}
-        title="Clear the canvas & reset background color"
-      >
-        Clear canvas
-      </button>
+      {actionManager.renderAction(
+        "changeViewBackgroundColor",
+        elements,
+        appState,
+        syncActionResult
+      )}
+
+      {actionManager.renderAction(
+        "clearCanvas",
+        elements,
+        appState,
+        syncActionResult
+      )}
     </Panel>
   );
 };

+ 36 - 31
src/components/panels/PanelExport.tsx

@@ -1,18 +1,19 @@
 import React from "react";
-import { EditableText } from "../EditableText";
 import { Panel } from "../Panel";
 import { ExportType } from "../../scene/types";
 
 import "./panelExport.scss";
+import { ActionManager } from "../../actions";
+import { ExcalidrawElement } from "../../element/types";
+import { AppState } from "../../types";
+import { UpdaterFn } from "../../actions/types";
 
 interface PanelExportProps {
-  projectName: string;
-  onProjectNameChange: (name: string) => void;
+  actionManager: ActionManager;
+  elements: readonly ExcalidrawElement[];
+  appState: AppState;
+  syncActionResult: UpdaterFn;
   onExportCanvas: (type: ExportType) => void;
-  exportBackground: boolean;
-  onExportBackgroundChange: (val: boolean) => void;
-  onSaveScene: React.MouseEventHandler;
-  onLoadScene: React.MouseEventHandler;
 }
 
 // fa-clipboard
@@ -32,23 +33,20 @@ const probablySupportsClipboard =
   "ClipboardItem" in window;
 
 export const PanelExport: React.FC<PanelExportProps> = ({
-  projectName,
-  exportBackground,
-  onProjectNameChange,
-  onExportBackgroundChange,
-  onSaveScene,
-  onLoadScene,
+  actionManager,
+  elements,
+  appState,
+  syncActionResult,
   onExportCanvas
 }) => {
   return (
     <Panel title="Export">
       <div className="panelColumn">
-        <h5>Name</h5>
-        {projectName && (
-          <EditableText
-            value={projectName}
-            onChange={(name: string) => onProjectNameChange(name)}
-          />
+        {actionManager.renderAction(
+          "changeProjectName",
+          elements,
+          appState,
+          syncActionResult
         )}
         <h5>Image</h5>
         <div className="panelExport-imageButtons">
@@ -68,19 +66,26 @@ export const PanelExport: React.FC<PanelExportProps> = ({
             </button>
           )}
         </div>
-        <label>
-          <input
-            type="checkbox"
-            checked={exportBackground}
-            onChange={e => {
-              onExportBackgroundChange(e.target.checked);
-            }}
-          />
-          background
-        </label>
+        {actionManager.renderAction(
+          "changeExportBackground",
+          elements,
+          appState,
+          syncActionResult
+        )}
+
         <h5>Scene</h5>
-        <button onClick={onSaveScene}>Save as...</button>
-        <button onClick={onLoadScene}>Load file...</button>
+        {actionManager.renderAction(
+          "saveScene",
+          elements,
+          appState,
+          syncActionResult
+        )}
+        {actionManager.renderAction(
+          "loadScene",
+          elements,
+          appState,
+          syncActionResult
+        )}
       </div>
     </Panel>
   );

+ 36 - 20
src/components/panels/PanelSelection.tsx

@@ -1,33 +1,49 @@
 import React from "react";
+import { ActionManager } from "../../actions";
+import { ExcalidrawElement } from "../../element/types";
+import { AppState } from "../../types";
+import { UpdaterFn } from "../../actions/types";
 
 interface PanelSelectionProps {
-  onBringForward: React.MouseEventHandler;
-  onBringToFront: React.MouseEventHandler;
-  onSendBackward: React.MouseEventHandler;
-  onSendToBack: React.MouseEventHandler;
+  actionManager: ActionManager;
+  elements: readonly ExcalidrawElement[];
+  appState: AppState;
+  syncActionResult: UpdaterFn;
 }
 
 export const PanelSelection: React.FC<PanelSelectionProps> = ({
-  onBringForward,
-  onBringToFront,
-  onSendBackward,
-  onSendToBack
+  actionManager,
+  elements,
+  appState,
+  syncActionResult
 }) => {
   return (
     <div>
       <div className="buttonList">
-        <button type="button" onClick={onBringForward}>
-          Bring forward
-        </button>
-        <button type="button" onClick={onBringToFront}>
-          Bring to front
-        </button>
-        <button type="button" onClick={onSendBackward}>
-          Send backward
-        </button>
-        <button type="button" onClick={onSendToBack}>
-          Send to back
-        </button>
+        {actionManager.renderAction(
+          "bringForward",
+          elements,
+          appState,
+          syncActionResult
+        )}
+        {actionManager.renderAction(
+          "bringToFront",
+          elements,
+          appState,
+          syncActionResult
+        )}
+        {actionManager.renderAction(
+          "sendBackward",
+          elements,
+          appState,
+          syncActionResult
+        )}
+        {actionManager.renderAction(
+          "sendToBack",
+          elements,
+          appState,
+          syncActionResult
+        )}
       </div>
     </div>
   );

+ 85 - 159
src/index.tsx

@@ -4,19 +4,16 @@ import ReactDOM from "react-dom";
 import rough from "roughjs/bin/wrappers/rough";
 import { RoughCanvas } from "roughjs/bin/canvas";
 
-import { moveOneLeft, moveAllLeft, moveOneRight, moveAllRight } from "./zindex";
 import {
   newElement,
   duplicateElement,
   resizeTest,
   isTextElement,
   textWysiwyg,
-  getElementAbsoluteCoords,
-  redrawTextBoundingBox
+  getElementAbsoluteCoords
 } from "./element";
 import {
   clearSelection,
-  getSelectedIndices,
   deleteSelectedElements,
   setSelection,
   isOverScrollBars,
@@ -41,7 +38,33 @@ import ContextMenu from "./components/ContextMenu";
 
 import "./styles.scss";
 import { getElementWithResizeHandler } from "./element/resizeTest";
+import {
+  ActionManager,
+  actionDeleteSelected,
+  actionSendBackward,
+  actionBringForward,
+  actionSendToBack,
+  actionBringToFront,
+  actionSelectAll,
+  actionChangeStrokeColor,
+  actionChangeBackgroundColor,
+  actionChangeOpacity,
+  actionChangeStrokeWidth,
+  actionChangeFillStyle,
+  actionChangeSloppiness,
+  actionChangeFontSize,
+  actionChangeFontFamily,
+  actionChangeViewBackgroundColor,
+  actionClearCanvas,
+  actionChangeProjectName,
+  actionChangeExportBackground,
+  actionLoadScene,
+  actionSaveScene,
+  actionCopyStyles,
+  actionPasteStyles
+} from "./actions";
 import { SidePanel } from "./components/SidePanel";
+import { ActionResult } from "./actions/types";
 
 let { elements } = createScene();
 const { history } = createHistory();
@@ -50,8 +73,6 @@ const DEFAULT_PROJECT_NAME = `excalidraw-${getDateTime()}`;
 const CANVAS_WINDOW_OFFSET_LEFT = 250;
 const CANVAS_WINDOW_OFFSET_TOP = 0;
 
-let copiedStyles: string = "{}";
-
 function resetCursor() {
   document.documentElement.style.cursor = "";
 }
@@ -101,6 +122,48 @@ export class App extends React.Component<{}, AppState> {
   canvas: HTMLCanvasElement | null = null;
   rc: RoughCanvas | null = null;
 
+  actionManager: ActionManager = new ActionManager();
+  constructor(props: any) {
+    super(props);
+    this.actionManager.registerAction(actionDeleteSelected);
+    this.actionManager.registerAction(actionSendToBack);
+    this.actionManager.registerAction(actionBringToFront);
+    this.actionManager.registerAction(actionSendBackward);
+    this.actionManager.registerAction(actionBringForward);
+    this.actionManager.registerAction(actionSelectAll);
+
+    this.actionManager.registerAction(actionChangeStrokeColor);
+    this.actionManager.registerAction(actionChangeBackgroundColor);
+    this.actionManager.registerAction(actionChangeFillStyle);
+    this.actionManager.registerAction(actionChangeStrokeWidth);
+    this.actionManager.registerAction(actionChangeOpacity);
+    this.actionManager.registerAction(actionChangeSloppiness);
+    this.actionManager.registerAction(actionChangeFontSize);
+    this.actionManager.registerAction(actionChangeFontFamily);
+
+    this.actionManager.registerAction(actionChangeViewBackgroundColor);
+    this.actionManager.registerAction(actionClearCanvas);
+
+    this.actionManager.registerAction(actionChangeProjectName);
+    this.actionManager.registerAction(actionChangeExportBackground);
+    this.actionManager.registerAction(actionSaveScene);
+    this.actionManager.registerAction(actionLoadScene);
+
+    this.actionManager.registerAction(actionCopyStyles);
+    this.actionManager.registerAction(actionPasteStyles);
+  }
+
+  private syncActionResult = (res: ActionResult) => {
+    if (res.elements !== undefined) {
+      elements = res.elements;
+      this.forceUpdate();
+    }
+
+    if (res.appState !== undefined) {
+      this.setState({ ...res.appState });
+    }
+  };
+
   public componentDidMount() {
     document.addEventListener("keydown", this.onKeyDown, false);
     document.addEventListener("mousemove", this.getCurrentCursorPosition);
@@ -166,10 +229,14 @@ export class App extends React.Component<{}, AppState> {
     }
     if (isInputLike(event.target)) return;
 
-    if (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) {
-      this.deleteSelectedElements();
-      event.preventDefault();
-    } else if (isArrowKey(event.key)) {
+    const data = this.actionManager.handleKeyDown(event, elements, this.state);
+    this.syncActionResult(data);
+
+    if (data.elements !== undefined && data.appState !== undefined) {
+      return;
+    }
+
+    if (isArrowKey(event.key)) {
       const step = event.shiftKey
         ? ELEMENT_SHIFT_TRANSLATE_AMOUNT
         : ELEMENT_TRANSLATE_AMOUNT;
@@ -186,46 +253,6 @@ export class App extends React.Component<{}, AppState> {
       });
       this.forceUpdate();
       event.preventDefault();
-
-      // Send backward: Cmd-Shift-Alt-B
-    } else if (
-      event[META_KEY] &&
-      event.shiftKey &&
-      event.altKey &&
-      event.code === "KeyB"
-    ) {
-      this.moveOneLeft();
-      event.preventDefault();
-
-      // Send to back: Cmd-Shift-B
-    } else if (event[META_KEY] && event.shiftKey && event.code === "KeyB") {
-      this.moveAllLeft();
-      event.preventDefault();
-
-      // Bring forward: Cmd-Shift-Alt-F
-    } else if (
-      event[META_KEY] &&
-      event.shiftKey &&
-      event.altKey &&
-      event.code === "KeyF"
-    ) {
-      this.moveOneRight();
-      event.preventDefault();
-
-      // Bring to front: Cmd-Shift-F
-    } else if (event[META_KEY] && event.shiftKey && event.code === "KeyF") {
-      this.moveAllRight();
-      event.preventDefault();
-      // Select all: Cmd-A
-    } else if (event[META_KEY] && event.code === "KeyA") {
-      let newElements = [...elements];
-      newElements.forEach(element => {
-        element.isSelected = true;
-      });
-
-      elements = newElements;
-      this.forceUpdate();
-      event.preventDefault();
     } else if (shapesShortcutKeys.includes(event.key.toLowerCase())) {
       this.setState({ elementType: findShapeByKey(event.key) });
     } else if (event[META_KEY] && event.code === "KeyZ") {
@@ -244,99 +271,11 @@ export class App extends React.Component<{}, AppState> {
       }
       this.forceUpdate();
       event.preventDefault();
-      // Copy Styles: Cmd-Shift-C
-    } else if (event.metaKey && event.shiftKey && event.code === "KeyC") {
-      this.copyStyles();
-      // Paste Styles: Cmd-Shift-V
-    } else if (event.metaKey && event.shiftKey && event.code === "KeyV") {
-      this.pasteStyles();
-      event.preventDefault();
     }
   };
 
-  private deleteSelectedElements = () => {
-    elements = deleteSelectedElements(elements);
-    this.forceUpdate();
-  };
-
-  private clearCanvas = () => {
-    if (window.confirm("This will clear the whole canvas. Are you sure?")) {
-      elements = [];
-      this.setState({
-        viewBackgroundColor: "#ffffff",
-        scrollX: 0,
-        scrollY: 0
-      });
-      this.forceUpdate();
-    }
-  };
-
-  private copyStyles = () => {
-    const element = elements.find(el => el.isSelected);
-    if (element) {
-      copiedStyles = JSON.stringify(element);
-    }
-  };
-
-  private pasteStyles = () => {
-    const pastedElement = JSON.parse(copiedStyles);
-    elements = elements.map(element => {
-      if (element.isSelected) {
-        const newElement = {
-          ...element,
-          backgroundColor: pastedElement?.backgroundColor,
-          strokeWidth: pastedElement?.strokeWidth,
-          strokeColor: pastedElement?.strokeColor,
-          fillStyle: pastedElement?.fillStyle,
-          opacity: pastedElement?.opacity,
-          roughness: pastedElement?.roughness
-        };
-        if (isTextElement(newElement)) {
-          newElement.font = pastedElement?.font;
-          redrawTextBoundingBox(newElement);
-        }
-        return newElement;
-      }
-      return element;
-    });
-    this.forceUpdate();
-  };
-
-  private moveAllLeft = () => {
-    elements = moveAllLeft([...elements], getSelectedIndices(elements));
-    this.forceUpdate();
-  };
-
-  private moveOneLeft = () => {
-    elements = moveOneLeft([...elements], getSelectedIndices(elements));
-    this.forceUpdate();
-  };
-
-  private moveAllRight = () => {
-    elements = moveAllRight([...elements], getSelectedIndices(elements));
-    this.forceUpdate();
-  };
-
-  private moveOneRight = () => {
-    elements = moveOneRight([...elements], getSelectedIndices(elements));
-    this.forceUpdate();
-  };
-
   private removeWheelEventListener: (() => void) | undefined;
 
-  private changeProperty = (
-    callback: (element: ExcalidrawElement) => ExcalidrawElement
-  ) => {
-    elements = elements.map(element => {
-      if (element.isSelected) {
-        return callback(element);
-      }
-      return element;
-    });
-
-    this.forceUpdate();
-  };
-
   private copyToClipboard = () => {
     if (navigator.clipboard) {
       const text = JSON.stringify(
@@ -384,6 +323,9 @@ export class App extends React.Component<{}, AppState> {
         }}
       >
         <SidePanel
+          actionManager={this.actionManager}
+          syncActionResult={this.syncActionResult}
+          appState={{ ...this.state }}
           elements={elements}
           onToolChange={value => {
             this.setState({ elementType: value });
@@ -392,20 +334,6 @@ export class App extends React.Component<{}, AppState> {
               value === "text" ? "text" : "crosshair";
             this.forceUpdate();
           }}
-          moveAllLeft={this.moveAllLeft}
-          moveAllRight={this.moveAllRight}
-          moveOneLeft={this.moveOneLeft}
-          moveOneRight={this.moveOneRight}
-          onClearCanvas={this.clearCanvas}
-          changeProperty={this.changeProperty}
-          onUpdateAppState={(name, value) => {
-            this.setState({ [name]: value } as any);
-          }}
-          onUpdateElements={newElements => {
-            elements = newElements;
-            this.forceUpdate();
-          }}
-          appState={{ ...this.state }}
           canvas={this.canvas!}
         />
         <canvas
@@ -482,13 +410,11 @@ export class App extends React.Component<{}, AppState> {
                   label: "Paste",
                   action: () => this.pasteFromClipboard()
                 },
-                { label: "Copy Styles", action: this.copyStyles },
-                { label: "Paste Styles", action: this.pasteStyles },
-                { label: "Delete", action: this.deleteSelectedElements },
-                { label: "Move Forward", action: this.moveOneRight },
-                { label: "Send to Front", action: this.moveAllRight },
-                { label: "Move Backwards", action: this.moveOneLeft },
-                { label: "Send to Back", action: this.moveAllLeft }
+                ...this.actionManager.getContextMenuItems(
+                  elements,
+                  this.state,
+                  this.syncActionResult
+                )
               ],
               top: e.clientY,
               left: e.clientX