Kaynağa Gözat

Fix issues related to history (#701)

* Separate UI from Canvas

* Explicitly define history recording

* ActionManager: Set syncActionState during construction instead of in every call

* Add commit to history flag to necessary actions

* Disable undoing during multiElement

* Write custom equality function for UI component to render it only when specific props and elements change

* Remove stale comments about history skipping

* Stop undo/redoing when in resizing element mode

* wip

* correctly reset resizingElement & add undo check

* Separate selection element from the rest of the array and stop redrawing the UI when dragging the selection

* Remove selectionElement from local storage

* Remove unnecessary readonly type casting in actionFinalize

* Fix undo / redo for multi points

* Fix an issue that did not update history when elements were locked

* Disable committing to history for noops

- deleteSelected without deleting anything
- Basic selection

* Use generateEntry only inside history and pass elements and appstate to history

* Update component after every history resume

* Remove last item from the history only if in multi mode

* Resume recording when element type is not selection

* ensure we prevent hotkeys only on writable elements

* Remove selection clearing from history

* Remove one point arrows as they are invisibly small

* Remove shape of elements from local storage

* Fix removing invisible element from the array

* add missing history resuming cases & simplify slice

* fix lint

* don't regenerate elements if no elements deselected

* regenerate elements array on selection

* reset state.selectionElement unconditionally

* Use getter instead of passing appState and scene data through functions to actions

* fix import

Co-authored-by: David Luzar <luzar.david@gmail.com>
Gasim Gasimzada 5 yıl önce
ebeveyn
işleme
33016bf6bf

+ 3 - 1
src/actions/actionCanvas.tsx

@@ -8,7 +8,7 @@ import { t } from "../i18n";
 
 export const actionChangeViewBackgroundColor: Action = {
   name: "changeViewBackgroundColor",
-  perform: (elements, appState, value) => {
+  perform: (_, appState, value) => {
     return { appState: { ...appState, viewBackgroundColor: value } };
   },
   PanelComponent: ({ appState, updateData }) => {
@@ -23,10 +23,12 @@ export const actionChangeViewBackgroundColor: Action = {
       </div>
     );
   },
+  commitToHistory: () => true,
 };
 
 export const actionClearCanvas: Action = {
   name: "clearCanvas",
+  commitToHistory: () => true,
   perform: () => {
     return {
       elements: [],

+ 1 - 0
src/actions/actionDeleteSelected.tsx

@@ -12,5 +12,6 @@ export const actionDeleteSelected: Action = {
   },
   contextItemLabel: "labels.delete",
   contextMenuOrder: 3,
+  commitToHistory: (_, elements) => elements.some(el => el.isSelected),
   keyTest: event => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
 };

+ 6 - 1
src/actions/actionFinalize.tsx

@@ -1,10 +1,12 @@
 import { Action } from "./types";
 import { KEYS } from "../keys";
 import { clearSelection } from "../scene";
+import { isInvisiblySmallElement } from "../element";
 
 export const actionFinalize: Action = {
   name: "finalize",
   perform: (elements, appState) => {
+    let newElements = clearSelection(elements);
     if (window.document.activeElement instanceof HTMLElement) {
       window.document.activeElement.blur();
     }
@@ -13,10 +15,13 @@ export const actionFinalize: Action = {
         0,
         appState.multiElement.points.length - 1,
       );
+      if (isInvisiblySmallElement(appState.multiElement)) {
+        newElements = newElements.slice(0, -1);
+      }
       appState.multiElement.shape = null;
     }
     return {
-      elements: clearSelection(elements),
+      elements: newElements,
       appState: {
         ...appState,
         elementType: "selection",

+ 8 - 0
src/actions/actionProperties.tsx

@@ -47,6 +47,7 @@ export const actionChangeStrokeColor: Action = {
       appState: { ...appState, currentItemStrokeColor: value },
     };
   },
+  commitToHistory: () => true,
   PanelComponent: ({ elements, appState, updateData }) => (
     <>
       <h3 aria-hidden="true">{t("labels.stroke")}</h3>
@@ -77,6 +78,7 @@ export const actionChangeBackgroundColor: Action = {
       appState: { ...appState, currentItemBackgroundColor: value },
     };
   },
+  commitToHistory: () => true,
   PanelComponent: ({ elements, appState, updateData }) => (
     <>
       <h3 aria-hidden="true">{t("labels.background")}</h3>
@@ -107,6 +109,7 @@ export const actionChangeFillStyle: Action = {
       appState: { ...appState, currentItemFillStyle: value },
     };
   },
+  commitToHistory: () => true,
   PanelComponent: ({ elements, appState, updateData }) => (
     <fieldset>
       <legend>{t("labels.fill")}</legend>
@@ -143,6 +146,7 @@ export const actionChangeStrokeWidth: Action = {
       appState: { ...appState, currentItemStrokeWidth: value },
     };
   },
+  commitToHistory: () => true,
   PanelComponent: ({ elements, appState, updateData }) => (
     <fieldset>
       <legend>{t("labels.strokeWidth")}</legend>
@@ -177,6 +181,7 @@ export const actionChangeSloppiness: Action = {
       appState: { ...appState, currentItemRoughness: value },
     };
   },
+  commitToHistory: () => true,
   PanelComponent: ({ elements, appState, updateData }) => (
     <fieldset>
       <legend>{t("labels.sloppiness")}</legend>
@@ -211,6 +216,7 @@ export const actionChangeOpacity: Action = {
       appState: { ...appState, currentItemOpacity: value },
     };
   },
+  commitToHistory: () => true,
   PanelComponent: ({ elements, appState, updateData }) => (
     <label className="control-label">
       {t("labels.opacity")}
@@ -272,6 +278,7 @@ export const actionChangeFontSize: Action = {
       },
     };
   },
+  commitToHistory: () => true,
   PanelComponent: ({ elements, appState, updateData }) => (
     <fieldset>
       <legend>{t("labels.fontSize")}</legend>
@@ -320,6 +327,7 @@ export const actionChangeFontFamily: Action = {
       },
     };
   },
+  commitToHistory: () => true,
   PanelComponent: ({ elements, appState, updateData }) => (
     <fieldset>
       <legend>{t("labels.fontFamily")}</legend>

+ 1 - 0
src/actions/actionStyles.ts

@@ -45,6 +45,7 @@ export const actionPasteStyles: Action = {
       }),
     };
   },
+  commitToHistory: () => true,
   contextItemLabel: "labels.pasteStyles",
   keyTest: event => event[KEYS.META] && event.shiftKey && event.key === "V",
   contextMenuOrder: 1,

+ 4 - 0
src/actions/actionZindex.tsx

@@ -18,6 +18,7 @@ export const actionSendBackward: Action = {
   },
   contextItemLabel: "labels.sendBackward",
   keyPriority: 40,
+  commitToHistory: () => true,
   keyTest: event => event[KEYS.META] && event.altKey && event.key === "B",
 };
 
@@ -31,6 +32,7 @@ export const actionBringForward: Action = {
   },
   contextItemLabel: "labels.bringForward",
   keyPriority: 40,
+  commitToHistory: () => true,
   keyTest: event => event[KEYS.META] && event.altKey && event.key === "F",
 };
 
@@ -43,6 +45,7 @@ export const actionSendToBack: Action = {
     };
   },
   contextItemLabel: "labels.sendToBack",
+  commitToHistory: () => true,
   keyTest: event => event[KEYS.META] && event.shiftKey && event.key === "B",
 };
 
@@ -54,6 +57,7 @@ export const actionBringToFront: Action = {
       appState,
     };
   },
+  commitToHistory: () => true,
   contextItemLabel: "labels.bringToFront",
   keyTest: event => event[KEYS.META] && event.shiftKey && event.key === "F",
 };

+ 49 - 28
src/actions/manager.tsx

@@ -12,29 +12,37 @@ import { t } from "../i18n";
 export class ActionManager implements ActionsManagerInterface {
   actions: { [keyProp: string]: Action } = {};
 
-  updater:
-    | ((elements: ExcalidrawElement[], appState: AppState) => void)
-    | null = null;
+  updater: UpdaterFn;
 
-  setUpdater(
-    updater: (elements: ExcalidrawElement[], appState: AppState) => void,
+  resumeHistoryRecording: () => void;
+
+  getAppState: () => AppState;
+
+  getElements: () => readonly ExcalidrawElement[];
+
+  constructor(
+    updater: UpdaterFn,
+    resumeHistoryRecording: () => void,
+    getAppState: () => AppState,
+    getElements: () => readonly ExcalidrawElement[],
   ) {
     this.updater = updater;
+    this.resumeHistoryRecording = resumeHistoryRecording;
+    this.getAppState = getAppState;
+    this.getElements = getElements;
   }
 
   registerAction(action: Action) {
     this.actions[action.name] = action;
   }
 
-  handleKeyDown(
-    event: KeyboardEvent,
-    elements: readonly ExcalidrawElement[],
-    appState: AppState,
-  ) {
+  handleKeyDown(event: KeyboardEvent) {
     const data = Object.values(this.actions)
       .sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0))
       .filter(
-        action => action.keyTest && action.keyTest(event, appState, elements),
+        action =>
+          action.keyTest &&
+          action.keyTest(event, this.getAppState(), this.getElements()),
       );
 
     if (data.length === 0) {
@@ -42,15 +50,16 @@ export class ActionManager implements ActionsManagerInterface {
     }
 
     event.preventDefault();
-    return data[0].perform(elements, appState, null);
+    if (
+      data[0].commitToHistory &&
+      data[0].commitToHistory(this.getAppState(), this.getElements())
+    ) {
+      this.resumeHistoryRecording();
+    }
+    return data[0].perform(this.getElements(), this.getAppState(), null);
   }
 
-  getContextMenuItems(
-    elements: readonly ExcalidrawElement[],
-    appState: AppState,
-    updater: UpdaterFn,
-    actionFilter: ActionFilterFn = action => action,
-  ) {
+  getContextMenuItems(actionFilter: ActionFilterFn = action => action) {
     return Object.values(this.actions)
       .filter(actionFilter)
       .filter(action => "contextItemLabel" in action)
@@ -62,28 +71,40 @@ export class ActionManager implements ActionsManagerInterface {
       .map(action => ({
         label: action.contextItemLabel ? t(action.contextItemLabel) : "",
         action: () => {
-          updater(action.perform(elements, appState, null));
+          if (
+            action.commitToHistory &&
+            action.commitToHistory(this.getAppState(), this.getElements())
+          ) {
+            this.resumeHistoryRecording();
+          }
+          this.updater(
+            action.perform(this.getElements(), this.getAppState(), null),
+          );
         },
       }));
   }
 
-  renderAction(
-    name: string,
-    elements: readonly ExcalidrawElement[],
-    appState: AppState,
-    updater: UpdaterFn,
-  ) {
+  renderAction(name: string) {
     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));
+        if (
+          action.commitToHistory &&
+          action.commitToHistory(this.getAppState(), this.getElements()) ===
+            true
+        ) {
+          this.resumeHistoryRecording();
+        }
+        this.updater(
+          action.perform(this.getElements(), this.getAppState(), formState),
+        );
       };
 
       return (
         <PanelComponent
-          elements={elements}
-          appState={appState}
+          elements={this.getElements()}
+          appState={this.getAppState()}
           updateData={updateData}
         />
       );

+ 7 - 15
src/actions/types.ts

@@ -3,7 +3,7 @@ import { ExcalidrawElement } from "../element/types";
 import { AppState } from "../types";
 
 export type ActionResult = {
-  elements?: ExcalidrawElement[];
+  elements?: readonly ExcalidrawElement[];
   appState?: AppState;
 };
 
@@ -32,6 +32,10 @@ export interface Action {
   ) => boolean;
   contextItemLabel?: string;
   contextMenuOrder?: number;
+  commitToHistory?: (
+    appState: AppState,
+    elements: readonly ExcalidrawElement[],
+  ) => boolean;
 }
 
 export interface ActionsManagerInterface {
@@ -39,21 +43,9 @@ export interface ActionsManagerInterface {
     [keyProp: string]: Action;
   };
   registerAction: (action: Action) => void;
-  handleKeyDown: (
-    event: KeyboardEvent,
-    elements: readonly ExcalidrawElement[],
-    appState: AppState,
-  ) => ActionResult | null;
+  handleKeyDown: (event: KeyboardEvent) => ActionResult | null;
   getContextMenuItems: (
-    elements: readonly ExcalidrawElement[],
-    appState: AppState,
-    updater: UpdaterFn,
     actionFilter: ActionFilterFn,
   ) => { label: string; action: () => void }[];
-  renderAction: (
-    name: string,
-    elements: readonly ExcalidrawElement[],
-    appState: AppState,
-    updater: UpdaterFn,
-  ) => React.ReactElement | null;
+  renderAction: (name: string) => React.ReactElement | null;
 }

+ 19 - 0
src/appState.ts

@@ -27,6 +27,7 @@ export function getDefaultAppState(): AppState {
     scrolledOutside: false,
     name: DEFAULT_PROJECT_NAME,
     isResizing: false,
+    selectionElement: null,
   };
 }
 
@@ -36,12 +37,30 @@ export function clearAppStateForLocalStorage(appState: AppState) {
     resizingElement,
     multiElement,
     editingElement,
+    selectionElement,
     isResizing,
     ...exportedState
   } = appState;
   return exportedState;
 }
 
+export function clearAppStatePropertiesForHistory(
+  appState: AppState,
+): Partial<AppState> {
+  return {
+    exportBackground: appState.exportBackground,
+    currentItemStrokeColor: appState.currentItemStrokeColor,
+    currentItemBackgroundColor: appState.currentItemBackgroundColor,
+    currentItemFillStyle: appState.currentItemFillStyle,
+    currentItemStrokeWidth: appState.currentItemStrokeWidth,
+    currentItemRoughness: appState.currentItemRoughness,
+    currentItemOpacity: appState.currentItemOpacity,
+    currentItemFont: appState.currentItemFont,
+    viewBackgroundColor: appState.viewBackgroundColor,
+    name: appState.name,
+  };
+}
+
 export function cleanAppStateForExport(appState: AppState) {
   return {
     viewBackgroundColor: appState.viewBackgroundColor,

+ 3 - 18
src/components/ExportDialog.tsx

@@ -9,7 +9,7 @@ import { Island } from "./Island";
 import { ExcalidrawElement } from "../element/types";
 import { AppState } from "../types";
 import { exportToCanvas } from "../scene/export";
-import { ActionsManagerInterface, UpdaterFn } from "../actions/types";
+import { ActionsManagerInterface } from "../actions/types";
 import Stack from "./Stack";
 import { t } from "../i18n";
 
@@ -30,7 +30,6 @@ function ExportModal({
   appState,
   exportPadding = 10,
   actionManager,
-  syncActionResult,
   onExportToPng,
   onExportToSvg,
   onExportToClipboard,
@@ -41,7 +40,6 @@ function ExportModal({
   elements: readonly ExcalidrawElement[];
   exportPadding?: number;
   actionManager: ActionsManagerInterface;
-  syncActionResult: UpdaterFn;
   onExportToPng: ExportCB;
   onExportToSvg: ExportCB;
   onExportToClipboard: ExportCB;
@@ -160,12 +158,7 @@ function ExportModal({
             </Stack.Row>
           </Stack.Col>
 
-          {actionManager.renderAction(
-            "changeProjectName",
-            elements,
-            appState,
-            syncActionResult,
-          )}
+          {actionManager.renderAction("changeProjectName")}
           <Stack.Col gap={1}>
             <div className="ExportDialog__scales">
               <Stack.Row gap={2} align="baseline">
@@ -184,12 +177,7 @@ function ExportModal({
                 ))}
               </Stack.Row>
             </div>
-            {actionManager.renderAction(
-              "changeExportBackground",
-              elements,
-              appState,
-              syncActionResult,
-            )}
+            {actionManager.renderAction("changeExportBackground")}
             {someElementIsSelected && (
               <div>
                 <label>
@@ -215,7 +203,6 @@ export function ExportDialog({
   appState,
   exportPadding = 10,
   actionManager,
-  syncActionResult,
   onExportToPng,
   onExportToSvg,
   onExportToClipboard,
@@ -225,7 +212,6 @@ export function ExportDialog({
   elements: readonly ExcalidrawElement[];
   exportPadding?: number;
   actionManager: ActionsManagerInterface;
-  syncActionResult: UpdaterFn;
   onExportToPng: ExportCB;
   onExportToSvg: ExportCB;
   onExportToClipboard: ExportCB;
@@ -260,7 +246,6 @@ export function ExportDialog({
             appState={appState}
             exportPadding={exportPadding}
             actionManager={actionManager}
-            syncActionResult={syncActionResult}
             onExportToPng={onExportToPng}
             onExportToSvg={onExportToSvg}
             onExportToClipboard={onExportToClipboard}

+ 1 - 1
src/element/sizeHelpers.ts

@@ -2,7 +2,7 @@ import { ExcalidrawElement } from "./types";
 
 export function isInvisiblySmallElement(element: ExcalidrawElement): boolean {
   if (element.type === "arrow" || element.type === "line") {
-    return element.points.length === 0;
+    return element.points.length < 2;
   }
   return element.width === 0 && element.height === 0;
 }

+ 12 - 5
src/history.ts

@@ -1,25 +1,31 @@
 import { AppState } from "./types";
 import { ExcalidrawElement } from "./element/types";
+import { clearAppStatePropertiesForHistory } from "./appState";
 
 class SceneHistory {
   private recording: boolean = true;
   private stateHistory: string[] = [];
   private redoStack: string[] = [];
 
-  generateCurrentEntry(
-    appState: Partial<AppState>,
+  private generateEntry(
+    appState: AppState,
     elements: readonly ExcalidrawElement[],
   ) {
     return JSON.stringify({
-      appState,
+      appState: clearAppStatePropertiesForHistory(appState),
       elements: elements.map(({ shape, ...element }) => ({
         ...element,
-        isSelected: false,
+        shape: null,
+        points:
+          appState.multiElement && appState.multiElement.id === element.id
+            ? element.points.slice(0, -1)
+            : element.points,
       })),
     });
   }
 
-  pushEntry(newEntry: string) {
+  pushEntry(appState: AppState, elements: readonly ExcalidrawElement[]) {
+    const newEntry = this.generateEntry(appState, elements);
     if (
       this.stateHistory.length > 0 &&
       this.stateHistory[this.stateHistory.length - 1] === newEntry
@@ -67,6 +73,7 @@ class SceneHistory {
     }
 
     const currentEntry = this.stateHistory.pop();
+
     const entryToRestore = this.stateHistory[this.stateHistory.length - 1];
 
     if (currentEntry !== undefined) {

+ 308 - 338
src/index.tsx

@@ -44,8 +44,8 @@ import { AppState } from "./types";
 import { ExcalidrawElement } from "./element/types";
 
 import {
-  isInputLike,
   isWritableElement,
+  isInputLike,
   debounce,
   capitalizeString,
   distance,
@@ -154,37 +154,267 @@ export function viewportCoordsToSceneCoords(
   return { x, y };
 }
 
-function pickAppStatePropertiesForHistory(
-  appState: AppState,
-): Partial<AppState> {
-  return {
-    exportBackground: appState.exportBackground,
-    currentItemStrokeColor: appState.currentItemStrokeColor,
-    currentItemBackgroundColor: appState.currentItemBackgroundColor,
-    currentItemFillStyle: appState.currentItemFillStyle,
-    currentItemStrokeWidth: appState.currentItemStrokeWidth,
-    currentItemRoughness: appState.currentItemRoughness,
-    currentItemOpacity: appState.currentItemOpacity,
-    currentItemFont: appState.currentItemFont,
-    viewBackgroundColor: appState.viewBackgroundColor,
-    name: appState.name,
-  };
-}
-
 let cursorX = 0;
 let cursorY = 0;
 let isHoldingSpace: boolean = false;
 let isPanning: boolean = false;
 let isHoldingMouseButton: boolean = false;
 
+interface LayerUIProps {
+  actionManager: ActionManager;
+  appState: AppState;
+  canvas: HTMLCanvasElement | null;
+  setAppState: any;
+  elements: readonly ExcalidrawElement[];
+  setElements: (elements: readonly ExcalidrawElement[]) => void;
+}
+
+const LayerUI = React.memo(
+  ({
+    actionManager,
+    appState,
+    setAppState,
+    canvas,
+    elements,
+    setElements,
+  }: LayerUIProps) => {
+    function renderCanvasActions() {
+      return (
+        <Stack.Col gap={4}>
+          <Stack.Row justifyContent={"space-between"}>
+            {actionManager.renderAction("loadScene")}
+            {actionManager.renderAction("saveScene")}
+            <ExportDialog
+              elements={elements}
+              appState={appState}
+              actionManager={actionManager}
+              onExportToPng={(exportedElements, scale) => {
+                if (canvas) {
+                  exportCanvas("png", exportedElements, canvas, {
+                    exportBackground: appState.exportBackground,
+                    name: appState.name,
+                    viewBackgroundColor: appState.viewBackgroundColor,
+                    scale,
+                  });
+                }
+              }}
+              onExportToSvg={(exportedElements, scale) => {
+                if (canvas) {
+                  exportCanvas("svg", exportedElements, canvas, {
+                    exportBackground: appState.exportBackground,
+                    name: appState.name,
+                    viewBackgroundColor: appState.viewBackgroundColor,
+                    scale,
+                  });
+                }
+              }}
+              onExportToClipboard={(exportedElements, scale) => {
+                if (canvas) {
+                  exportCanvas("clipboard", exportedElements, canvas, {
+                    exportBackground: appState.exportBackground,
+                    name: appState.name,
+                    viewBackgroundColor: appState.viewBackgroundColor,
+                    scale,
+                  });
+                }
+              }}
+              onExportToBackend={exportedElements => {
+                if (canvas) {
+                  exportCanvas(
+                    "backend",
+                    exportedElements.map(element => ({
+                      ...element,
+                      isSelected: false,
+                    })),
+                    canvas,
+                    appState,
+                  );
+                }
+              }}
+            />
+            {actionManager.renderAction("clearCanvas")}
+          </Stack.Row>
+          {actionManager.renderAction("changeViewBackgroundColor")}
+        </Stack.Col>
+      );
+    }
+
+    function renderSelectedShapeActions(
+      elements: readonly ExcalidrawElement[],
+    ) {
+      const { elementType, editingElement } = appState;
+      const targetElements = editingElement
+        ? [editingElement]
+        : elements.filter(el => el.isSelected);
+      if (!targetElements.length && elementType === "selection") {
+        return null;
+      }
+
+      return (
+        <Island padding={4}>
+          <div className="panelColumn">
+            {actionManager.renderAction("changeStrokeColor")}
+            {(hasBackground(elementType) ||
+              targetElements.some(element => hasBackground(element.type))) && (
+              <>
+                {actionManager.renderAction("changeBackgroundColor")}
+
+                {actionManager.renderAction("changeFillStyle")}
+              </>
+            )}
+
+            {(hasStroke(elementType) ||
+              targetElements.some(element => hasStroke(element.type))) && (
+              <>
+                {actionManager.renderAction("changeStrokeWidth")}
+
+                {actionManager.renderAction("changeSloppiness")}
+              </>
+            )}
+
+            {(hasText(elementType) ||
+              targetElements.some(element => hasText(element.type))) && (
+              <>
+                {actionManager.renderAction("changeFontSize")}
+
+                {actionManager.renderAction("changeFontFamily")}
+              </>
+            )}
+
+            {actionManager.renderAction("changeOpacity")}
+
+            {actionManager.renderAction("deleteSelectedElements")}
+          </div>
+        </Island>
+      );
+    }
+
+    function renderShapesSwitcher() {
+      return (
+        <>
+          {SHAPES.map(({ value, icon }, index) => {
+            const label = t(`toolBar.${value}`);
+            return (
+              <ToolButton
+                key={value}
+                type="radio"
+                icon={icon}
+                checked={appState.elementType === value}
+                name="editor-current-shape"
+                title={`${capitalizeString(label)} — ${
+                  capitalizeString(value)[0]
+                }, ${index + 1}`}
+                keyBindingLabel={`${index + 1}`}
+                aria-label={capitalizeString(label)}
+                aria-keyshortcuts={`${label[0]} ${index + 1}`}
+                onChange={() => {
+                  setAppState({ elementType: value, multiElement: null });
+                  setElements(clearSelection(elements));
+                  document.documentElement.style.cursor =
+                    value === "text" ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR;
+                  setAppState({});
+                }}
+              ></ToolButton>
+            );
+          })}
+        </>
+      );
+    }
+
+    return (
+      <FixedSideContainer side="top">
+        <div className="App-menu App-menu_top">
+          <Stack.Col gap={4} align="end">
+            <section
+              className="App-right-menu"
+              aria-labelledby="canvas-actions-title"
+            >
+              <h2 className="visually-hidden" id="canvas-actions-title">
+                {t("headings.canvasActions")}
+              </h2>
+              <Island padding={4}>{renderCanvasActions()}</Island>
+            </section>
+            <section
+              className="App-right-menu"
+              aria-labelledby="selected-shape-title"
+            >
+              <h2 className="visually-hidden" id="selected-shape-title">
+                {t("headings.selectedShapeActions")}
+              </h2>
+              {renderSelectedShapeActions(elements)}
+            </section>
+          </Stack.Col>
+          <section aria-labelledby="shapes-title">
+            <Stack.Col gap={4} align="start">
+              <Stack.Row gap={1}>
+                <Island padding={1}>
+                  <h2 className="visually-hidden" id="shapes-title">
+                    {t("headings.shapes")}
+                  </h2>
+                  <Stack.Row gap={1}>{renderShapesSwitcher()}</Stack.Row>
+                </Island>
+                <LockIcon
+                  checked={appState.elementLocked}
+                  onChange={() => {
+                    setAppState({
+                      elementLocked: !appState.elementLocked,
+                      elementType: appState.elementLocked
+                        ? "selection"
+                        : appState.elementType,
+                    });
+                  }}
+                  title={t("toolBar.lock")}
+                />
+              </Stack.Row>
+            </Stack.Col>
+          </section>
+          <div />
+        </div>
+      </FixedSideContainer>
+    );
+  },
+  (prev, next) => {
+    const getNecessaryObj = (appState: AppState): Partial<AppState> => {
+      const {
+        draggingElement,
+        resizingElement,
+        multiElement,
+        editingElement,
+        isResizing,
+        cursorX,
+        cursorY,
+        ...ret
+      } = appState;
+      return ret;
+    };
+    const prevAppState = getNecessaryObj(prev.appState);
+    const nextAppState = getNecessaryObj(next.appState);
+
+    const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
+
+    return (
+      prev.elements === next.elements &&
+      keys.every(k => prevAppState[k] === nextAppState[k])
+    );
+  },
+);
+
 export class App extends React.Component<any, AppState> {
   canvas: HTMLCanvasElement | null = null;
   rc: RoughCanvas | null = null;
 
-  actionManager: ActionManager = new ActionManager();
+  actionManager: ActionManager;
   canvasOnlyActions: Array<Action>;
   constructor(props: any) {
     super(props);
+    this.actionManager = new ActionManager(
+      this.syncActionResult,
+      () => {
+        history.resumeRecording();
+      },
+      () => this.state,
+      () => elements,
+    );
     this.actionManager.registerAction(actionFinalize);
     this.actionManager.registerAction(actionDeleteSelected);
     this.actionManager.registerAction(actionSendToBack);
@@ -233,6 +463,7 @@ export class App extends React.Component<any, AppState> {
     }
     copyToAppClipboard(elements);
     elements = deleteSelectedElements(elements);
+    history.resumeRecording();
     this.setState({});
     e.preventDefault();
   };
@@ -279,6 +510,7 @@ export class App extends React.Component<any, AppState> {
         element.isSelected = true;
 
         elements = [...clearSelection(elements), element];
+        history.resumeRecording();
         this.setState({});
       }
       e.preventDefault();
@@ -291,17 +523,6 @@ export class App extends React.Component<any, AppState> {
     this.saveDebounced.flush();
   };
 
-  public shouldComponentUpdate(props: any, nextState: AppState) {
-    if (!history.isRecording()) {
-      // temporary hack to fix #592
-      // eslint-disable-next-line react/no-direct-mutation-state
-      this.state = nextState;
-      this.componentDidUpdate();
-      return false;
-    }
-    return true;
-  }
-
   private async loadScene(id: string | null, k: string | undefined) {
     let data;
     let selectedId;
@@ -321,6 +542,7 @@ export class App extends React.Component<any, AppState> {
     }
 
     if (data.appState) {
+      history.resumeRecording();
       this.setState({ ...data.appState, selectedId });
     } else {
       this.setState({});
@@ -395,11 +617,7 @@ export class App extends React.Component<any, AppState> {
       return;
     }
 
-    const actionResult = this.actionManager.handleKeyDown(
-      event,
-      elements,
-      this.state,
-    );
+    const actionResult = this.actionManager.handleKeyDown(event);
 
     if (actionResult) {
       this.syncActionResult(actionResult);
@@ -452,9 +670,10 @@ export class App extends React.Component<any, AppState> {
       event.preventDefault();
 
       if (
-        this.state.resizingElement ||
         this.state.multiElement ||
-        this.state.editingElement
+        this.state.resizingElement ||
+        this.state.editingElement ||
+        this.state.draggingElement
       ) {
         return;
       }
@@ -509,212 +728,14 @@ export class App extends React.Component<any, AppState> {
     }
   };
 
-  private renderSelectedShapeActions(elements: readonly ExcalidrawElement[]) {
-    const { elementType, editingElement } = this.state;
-    const targetElements = editingElement
-      ? [editingElement]
-      : elements.filter(el => el.isSelected);
-    if (!targetElements.length && elementType === "selection") {
-      return null;
-    }
-
-    return (
-      <Island padding={4}>
-        <div className="panelColumn">
-          {this.actionManager.renderAction(
-            "changeStrokeColor",
-            elements,
-            this.state,
-            this.syncActionResult,
-          )}
-          {(hasBackground(elementType) ||
-            targetElements.some(element => hasBackground(element.type))) && (
-            <>
-              {this.actionManager.renderAction(
-                "changeBackgroundColor",
-                elements,
-                this.state,
-                this.syncActionResult,
-              )}
-
-              {this.actionManager.renderAction(
-                "changeFillStyle",
-                elements,
-                this.state,
-                this.syncActionResult,
-              )}
-            </>
-          )}
-
-          {(hasStroke(elementType) ||
-            targetElements.some(element => hasStroke(element.type))) && (
-            <>
-              {this.actionManager.renderAction(
-                "changeStrokeWidth",
-                elements,
-                this.state,
-                this.syncActionResult,
-              )}
-
-              {this.actionManager.renderAction(
-                "changeSloppiness",
-                elements,
-                this.state,
-                this.syncActionResult,
-              )}
-            </>
-          )}
-
-          {(hasText(elementType) ||
-            targetElements.some(element => hasText(element.type))) && (
-            <>
-              {this.actionManager.renderAction(
-                "changeFontSize",
-                elements,
-                this.state,
-                this.syncActionResult,
-              )}
-
-              {this.actionManager.renderAction(
-                "changeFontFamily",
-                elements,
-                this.state,
-                this.syncActionResult,
-              )}
-            </>
-          )}
-
-          {this.actionManager.renderAction(
-            "changeOpacity",
-            elements,
-            this.state,
-            this.syncActionResult,
-          )}
-
-          {this.actionManager.renderAction(
-            "deleteSelectedElements",
-            elements,
-            this.state,
-            this.syncActionResult,
-          )}
-        </div>
-      </Island>
-    );
-  }
-
-  private renderShapesSwitcher() {
-    return (
-      <>
-        {SHAPES.map(({ value, icon }, index) => {
-          const label = t(`toolBar.${value}`);
-          return (
-            <ToolButton
-              key={value}
-              type="radio"
-              icon={icon}
-              checked={this.state.elementType === value}
-              name="editor-current-shape"
-              title={`${capitalizeString(label)} — ${
-                capitalizeString(value)[0]
-              }, ${index + 1}`}
-              keyBindingLabel={`${index + 1}`}
-              aria-label={capitalizeString(label)}
-              aria-keyshortcuts={`${label[0]} ${index + 1}`}
-              onChange={() => {
-                this.setState({ elementType: value, multiElement: null });
-                elements = clearSelection(elements);
-                document.documentElement.style.cursor =
-                  value === "text" ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR;
-                this.setState({});
-              }}
-            ></ToolButton>
-          );
-        })}
-      </>
-    );
-  }
+  setAppState = (obj: any) => {
+    this.setState(obj);
+  };
 
-  private renderCanvasActions() {
-    return (
-      <Stack.Col gap={4}>
-        <Stack.Row justifyContent={"space-between"}>
-          {this.actionManager.renderAction(
-            "loadScene",
-            elements,
-            this.state,
-            this.syncActionResult,
-          )}
-          {this.actionManager.renderAction(
-            "saveScene",
-            elements,
-            this.state,
-            this.syncActionResult,
-          )}
-          <ExportDialog
-            elements={elements}
-            appState={this.state}
-            actionManager={this.actionManager}
-            syncActionResult={this.syncActionResult}
-            onExportToPng={(exportedElements, scale) => {
-              if (this.canvas) {
-                exportCanvas("png", exportedElements, this.canvas, {
-                  exportBackground: this.state.exportBackground,
-                  name: this.state.name,
-                  viewBackgroundColor: this.state.viewBackgroundColor,
-                  scale,
-                });
-              }
-            }}
-            onExportToSvg={(exportedElements, scale) => {
-              if (this.canvas) {
-                exportCanvas("svg", exportedElements, this.canvas, {
-                  exportBackground: this.state.exportBackground,
-                  name: this.state.name,
-                  viewBackgroundColor: this.state.viewBackgroundColor,
-                  scale,
-                });
-              }
-            }}
-            onExportToClipboard={(exportedElements, scale) => {
-              if (this.canvas) {
-                exportCanvas("clipboard", exportedElements, this.canvas, {
-                  exportBackground: this.state.exportBackground,
-                  name: this.state.name,
-                  viewBackgroundColor: this.state.viewBackgroundColor,
-                  scale,
-                });
-              }
-            }}
-            onExportToBackend={exportedElements => {
-              if (this.canvas) {
-                exportCanvas(
-                  "backend",
-                  exportedElements.map(element => ({
-                    ...element,
-                    isSelected: false,
-                  })),
-                  this.canvas,
-                  this.state,
-                );
-              }
-            }}
-          />
-          {this.actionManager.renderAction(
-            "clearCanvas",
-            elements,
-            this.state,
-            this.syncActionResult,
-          )}
-        </Stack.Row>
-        {this.actionManager.renderAction(
-          "changeViewBackgroundColor",
-          elements,
-          this.state,
-          this.syncActionResult,
-        )}
-      </Stack.Col>
-    );
-  }
+  setElements = (elements_: readonly ExcalidrawElement[]) => {
+    elements = elements_;
+    this.setState({});
+  };
 
   public render() {
     const canvasWidth = window.innerWidth - CANVAS_WINDOW_OFFSET_LEFT;
@@ -722,55 +743,14 @@ export class App extends React.Component<any, AppState> {
 
     return (
       <div className="container">
-        <FixedSideContainer side="top">
-          <div className="App-menu App-menu_top">
-            <Stack.Col gap={4} align="end">
-              <section
-                className="App-right-menu"
-                aria-labelledby="canvas-actions-title"
-              >
-                <h2 className="visually-hidden" id="canvas-actions-title">
-                  {t("headings.canvasActions")}
-                </h2>
-                <Island padding={4}>{this.renderCanvasActions()}</Island>
-              </section>
-              <section
-                className="App-right-menu"
-                aria-labelledby="selected-shape-title"
-              >
-                <h2 className="visually-hidden" id="selected-shape-title">
-                  {t("headings.selectedShapeActions")}
-                </h2>
-                {this.renderSelectedShapeActions(elements)}
-              </section>
-            </Stack.Col>
-            <section aria-labelledby="shapes-title">
-              <Stack.Col gap={4} align="start">
-                <Stack.Row gap={1}>
-                  <Island padding={1}>
-                    <h2 className="visually-hidden" id="shapes-title">
-                      {t("headings.shapes")}
-                    </h2>
-                    <Stack.Row gap={1}>{this.renderShapesSwitcher()}</Stack.Row>
-                  </Island>
-                  <LockIcon
-                    checked={this.state.elementLocked}
-                    onChange={() => {
-                      this.setState({
-                        elementLocked: !this.state.elementLocked,
-                        elementType: this.state.elementLocked
-                          ? "selection"
-                          : this.state.elementType,
-                      });
-                    }}
-                    title={t("toolBar.lock")}
-                  />
-                </Stack.Row>
-              </Stack.Col>
-            </section>
-            <div />
-          </div>
-        </FixedSideContainer>
+        <LayerUI
+          canvas={this.canvas}
+          appState={this.state}
+          setAppState={this.setAppState}
+          actionManager={this.actionManager}
+          elements={elements}
+          setElements={this.setElements}
+        />
         <main>
           <canvas
             id="canvas"
@@ -822,11 +802,8 @@ export class App extends React.Component<any, AppState> {
                       label: t("labels.paste"),
                       action: () => this.pasteFromClipboard(),
                     },
-                    ...this.actionManager.getContextMenuItems(
-                      elements,
-                      this.state,
-                      this.syncActionResult,
-                      action => this.canvasOnlyActions.includes(action),
+                    ...this.actionManager.getContextMenuItems(action =>
+                      this.canvasOnlyActions.includes(action),
                     ),
                   ],
                   top: e.clientY,
@@ -852,9 +829,6 @@ export class App extends React.Component<any, AppState> {
                     action: () => this.pasteFromClipboard(),
                   },
                   ...this.actionManager.getContextMenuItems(
-                    elements,
-                    this.state,
-                    this.syncActionResult,
                     action => !this.canvasOnlyActions.includes(action),
                   ),
                 ],
@@ -889,8 +863,7 @@ export class App extends React.Component<any, AppState> {
                   const deltaY = lastY - e.clientY;
                   lastX = e.clientX;
                   lastY = e.clientY;
-                  // We don't want to save history when panning around
-                  history.skipRecording();
+
                   this.setState({
                     scrollX: this.state.scrollX - deltaX,
                     scrollY: this.state.scrollY - deltaY,
@@ -1004,6 +977,7 @@ export class App extends React.Component<any, AppState> {
                     // state of the box
                     if (!hitElement.isSelected) {
                       hitElement.isSelected = true;
+                      elements = elements.slice();
                       elementIsAddedToSelection = true;
                     }
 
@@ -1074,6 +1048,7 @@ export class App extends React.Component<any, AppState> {
                         },
                       ];
                     }
+                    history.resumeRecording();
                     resetSelection();
                   },
                   onCancel: () => {
@@ -1104,6 +1079,11 @@ export class App extends React.Component<any, AppState> {
                     draggingElement: element,
                   });
                 }
+              } else if (element.type === "selection") {
+                this.setState({
+                  selectionElement: element,
+                  draggingElement: element,
+                });
               } else {
                 elements = [...elements, element];
                 this.setState({ multiElement: null, draggingElement: element });
@@ -1138,7 +1118,6 @@ export class App extends React.Component<any, AppState> {
                 mouseY: number,
                 perfect: boolean,
               ) => {
-                // TODO: Implement perfect sizing for origin
                 if (perfect) {
                   const absPx = p1[0] + element.x;
                   const absPy = p1[1] + element.y;
@@ -1195,8 +1174,6 @@ export class App extends React.Component<any, AppState> {
                 if (isOverHorizontalScrollBar) {
                   const x = e.clientX - CANVAS_WINDOW_OFFSET_LEFT;
                   const dx = x - lastX;
-                  // We don't want to save history when scrolling
-                  history.skipRecording();
                   this.setState({ scrollX: this.state.scrollX - dx });
                   lastX = x;
                   return;
@@ -1205,8 +1182,6 @@ export class App extends React.Component<any, AppState> {
                 if (isOverVerticalScrollBar) {
                   const y = e.clientY - CANVAS_WINDOW_OFFSET_TOP;
                   const dy = y - lastY;
-                  // We don't want to save history when scrolling
-                  history.skipRecording();
                   this.setState({ scrollY: this.state.scrollY - dy });
                   lastY = y;
                   return;
@@ -1444,8 +1419,6 @@ export class App extends React.Component<any, AppState> {
 
                     lastX = x;
                     lastY = y;
-                    // We don't want to save history when resizing an element
-                    history.skipRecording();
                     this.setState({});
                     return;
                   }
@@ -1465,8 +1438,6 @@ export class App extends React.Component<any, AppState> {
                     });
                     lastX = x;
                     lastY = y;
-                    // We don't want to save history when dragging an element to initially size it
-                    history.skipRecording();
                     this.setState({});
                     return;
                   }
@@ -1532,7 +1503,7 @@ export class App extends React.Component<any, AppState> {
                 draggingElement.shape = null;
 
                 if (this.state.elementType === "selection") {
-                  if (!e.shiftKey) {
+                  if (!e.shiftKey && elements.some(el => el.isSelected)) {
                     elements = clearSelection(elements);
                   }
                   const elementsWithinSelection = getElementsWithinSelection(
@@ -1543,13 +1514,10 @@ export class App extends React.Component<any, AppState> {
                     element.isSelected = true;
                   });
                 }
-                // We don't want to save history when moving an element
-                history.skipRecording();
                 this.setState({});
               };
 
               const onMouseUp = (e: MouseEvent) => {
-                this.setState({ isResizing: false });
                 const {
                   draggingElement,
                   resizingElement,
@@ -1558,6 +1526,12 @@ export class App extends React.Component<any, AppState> {
                   elementLocked,
                 } = this.state;
 
+                this.setState({
+                  isResizing: false,
+                  resizingElement: null,
+                  selectionElement: null,
+                });
+
                 resizeArrowFn = null;
                 lastMouseUp = null;
                 isHoldingMouseButton = false;
@@ -1567,6 +1541,7 @@ export class App extends React.Component<any, AppState> {
                 if (elementType === "arrow" || elementType === "line") {
                   if (draggingElement!.points.length > 1) {
                     history.resumeRecording();
+                    this.setState({});
                   }
                   if (!draggingOccurred && draggingElement && !multiElement) {
                     const { x, y } = viewportCoordsToSceneCoords(e, this.state);
@@ -1603,6 +1578,11 @@ export class App extends React.Component<any, AppState> {
                   this.setState({});
                 }
 
+                if (resizingElement) {
+                  history.resumeRecording();
+                  this.setState({});
+                }
+
                 if (
                   resizingElement &&
                   isInvisiblySmallElement(resizingElement)
@@ -1640,15 +1620,19 @@ export class App extends React.Component<any, AppState> {
                   return;
                 }
 
-                if (elementType === "selection") {
-                  elements = elements.slice(0, -1);
-                } else if (!elementLocked) {
+                if (!elementLocked) {
                   draggingElement.isSelected = true;
                 }
 
+                if (
+                  elementType !== "selection" ||
+                  elements.some(el => el.isSelected)
+                ) {
+                  history.resumeRecording();
+                }
+
                 if (!elementLocked) {
                   resetCursor();
-
                   this.setState({
                     draggingElement: null,
                     elementType: "selection",
@@ -1664,16 +1648,6 @@ export class App extends React.Component<any, AppState> {
 
               window.addEventListener("mousemove", onMouseMove);
               window.addEventListener("mouseup", onMouseUp);
-
-              if (
-                !this.state.multiElement ||
-                (this.state.multiElement &&
-                  this.state.multiElement.points.length < 2)
-              ) {
-                // We don't want to save history on mouseDown, only on mouseUp when it's fully configured
-                history.skipRecording();
-                this.setState({});
-              }
             }}
             onDoubleClick={e => {
               const { x, y } = viewportCoordsToSceneCoords(e, this.state);
@@ -1765,6 +1739,7 @@ export class App extends React.Component<any, AppState> {
                       },
                     ];
                   }
+                  history.resumeRecording();
                   resetSelection();
                 },
                 onCancel: () => {
@@ -1883,8 +1858,7 @@ export class App extends React.Component<any, AppState> {
   private handleWheel = (e: WheelEvent) => {
     e.preventDefault();
     const { deltaX, deltaY } = e;
-    // We don't want to save history when panning around
-    history.skipRecording();
+
     this.setState({
       scrollX: this.state.scrollX - deltaX,
       scrollY: this.state.scrollY - deltaY,
@@ -1918,6 +1892,7 @@ export class App extends React.Component<any, AppState> {
         return duplicate;
       }),
     ];
+    history.resumeRecording();
     this.setState({});
   };
 
@@ -1960,6 +1935,7 @@ export class App extends React.Component<any, AppState> {
   componentDidUpdate() {
     const atLeastOneVisibleElement = renderScene(
       elements,
+      this.state.selectionElement,
       this.rc!,
       this.canvas!,
       {
@@ -1974,14 +1950,8 @@ export class App extends React.Component<any, AppState> {
     }
     this.saveDebounced();
     if (history.isRecording()) {
-      history.pushEntry(
-        history.generateCurrentEntry(
-          pickAppStatePropertiesForHistory(this.state),
-          elements,
-        ),
-      );
-    } else {
-      history.resumeRecording();
+      history.pushEntry(this.state, elements);
+      history.skipRecording();
     }
   }
 }

+ 13 - 0
src/renderer/renderScene.ts

@@ -16,6 +16,7 @@ import { renderElement, renderElementToSvg } from "./renderElement";
 
 export function renderScene(
   elements: readonly ExcalidrawElement[],
+  selectionElement: ExcalidrawElement | null,
   rc: RoughCanvas,
   canvas: HTMLCanvasElement,
   sceneState: SceneState,
@@ -86,6 +87,18 @@ export function renderScene(
     );
   });
 
+  if (selectionElement) {
+    context.translate(
+      selectionElement.x + sceneState.scrollX,
+      selectionElement.y + sceneState.scrollY,
+    );
+    renderElement(selectionElement, rc, context);
+    context.translate(
+      -selectionElement.x - sceneState.scrollX,
+      -selectionElement.y - sceneState.scrollY,
+    );
+  }
+
   if (renderSelection) {
     const selectedElements = elements.filter(el => el.isSelected);
 

+ 6 - 1
src/scene/data.ts

@@ -437,7 +437,12 @@ export function saveToLocalStorage(
   elements: readonly ExcalidrawElement[],
   appState: AppState,
 ) {
-  localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(elements));
+  localStorage.setItem(
+    LOCAL_STORAGE_KEY,
+    JSON.stringify(
+      elements.map(({ shape, ...element }: ExcalidrawElement) => element),
+    ),
+  );
   localStorage.setItem(
     LOCAL_STORAGE_KEY_STATE,
     JSON.stringify(clearAppStateForLocalStorage(appState)),

+ 1 - 0
src/scene/export.ts

@@ -37,6 +37,7 @@ export function exportToCanvas(
 
   renderScene(
     elements,
+    null,
     rough.canvas(tempCanvas),
     tempCanvas,
     {

+ 7 - 5
src/scene/selection.ts

@@ -30,13 +30,15 @@ export function getElementsWithinSelection(
 }
 
 export function clearSelection(elements: readonly ExcalidrawElement[]) {
-  const newElements = [...elements];
-
-  newElements.forEach(element => {
-    element.isSelected = false;
+  let someWasSelected = false;
+  elements.forEach(element => {
+    if (element.isSelected) {
+      someWasSelected = true;
+      element.isSelected = false;
+    }
   });
 
-  return newElements;
+  return someWasSelected ? elements.slice() : elements;
 }
 
 export function deleteSelectedElements(elements: readonly ExcalidrawElement[]) {

+ 1 - 0
src/types.ts

@@ -4,6 +4,7 @@ export type AppState = {
   draggingElement: ExcalidrawElement | null;
   resizingElement: ExcalidrawElement | null;
   multiElement: ExcalidrawElement | null;
+  selectionElement: ExcalidrawElement | null;
   // element being edited, but not necessarily added to elements array yet
   //  (e.g. text element when typing into the input)
   editingElement: ExcalidrawElement | null;