Browse Source

Undo/Redo buttons, refactor menu toggles (#793)

* Make Undo & Redo and the menu buttons into actions; add undo/redo buttons

* Create variables for the ToolIcon colors

* Darken the menu buttons when they’re active

* Put the more intensive test in `perform`

* Fix & restyle hint viewer

* Add pinch zoom for macOS Safari

* Chrome/Firefox trackpad pinch zoom

* openedMenu → openMenu

* needsShapeEditor.ts → showSelectedShapeActions.ts

* Call showSelectedShapeActions
Jed Fox 5 năm trước cách đây
mục cha
commit
8e0206cc1e

+ 8 - 13
src/actions/actionFinalize.tsx

@@ -54,18 +54,13 @@ export const actionFinalize: Action = {
     ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
       appState.multiElement !== null),
   PanelComponent: ({ appState, updateData }) => (
-    <div
-      style={{
-        visibility: appState.multiElement != null ? "visible" : "hidden",
-      }}
-    >
-      <ToolButton
-        type="button"
-        icon={done}
-        title={t("buttons.done")}
-        aria-label={t("buttons.done")}
-        onClick={() => updateData(null)}
-      />
-    </div>
+    <ToolButton
+      type="button"
+      icon={done}
+      title={t("buttons.done")}
+      aria-label={t("buttons.done")}
+      onClick={updateData}
+      visible={appState.multiElement != null}
+    />
   ),
 };

+ 73 - 0
src/actions/actionHistory.tsx

@@ -0,0 +1,73 @@
+import { Action } from "./types";
+import React from "react";
+import { undo, redo } from "../components/icons";
+import { ToolButton } from "../components/ToolButton";
+import { t } from "../i18n";
+import { SceneHistory } from "../history";
+import { ExcalidrawElement } from "../element/types";
+import { AppState } from "../types";
+import { KEYS } from "../keys";
+
+const writeData = (
+  appState: AppState,
+  data: { elements: ExcalidrawElement[]; appState: AppState } | null,
+) => {
+  if (data !== null) {
+    return {
+      elements: data.elements,
+      appState: { ...appState, ...data.appState },
+    };
+  }
+  return {};
+};
+
+const testUndo = (shift: boolean) => (
+  event: KeyboardEvent,
+  appState: AppState,
+) => event[KEYS.META] && /z/i.test(event.key) && event.shiftKey === shift;
+
+export const createUndoAction: (h: SceneHistory) => Action = history => ({
+  name: "undo",
+  perform: (_, appState) =>
+    [
+      appState.multiElement,
+      appState.resizingElement,
+      appState.editingElement,
+      appState.draggingElement,
+    ].every(x => x === null)
+      ? writeData(appState, history.undoOnce())
+      : {},
+  keyTest: testUndo(false),
+  PanelComponent: ({ updateData }) => (
+    <ToolButton
+      type="button"
+      icon={undo}
+      aria-label={t("buttons.undo")}
+      onClick={updateData}
+    />
+  ),
+  commitToHistory: () => false,
+});
+
+export const createRedoAction: (h: SceneHistory) => Action = history => ({
+  name: "redo",
+  perform: (_, appState) =>
+    [
+      appState.multiElement,
+      appState.resizingElement,
+      appState.editingElement,
+      appState.draggingElement,
+    ].every(x => x === null)
+      ? writeData(appState, history.redoOnce())
+      : {},
+  keyTest: testUndo(true),
+  PanelComponent: ({ updateData }) => (
+    <ToolButton
+      type="button"
+      icon={redo}
+      aria-label={t("buttons.redo")}
+      onClick={updateData}
+    />
+  ),
+  commitToHistory: () => false,
+});

+ 45 - 0
src/actions/actionMenu.tsx

@@ -0,0 +1,45 @@
+import { Action } from "./types";
+import React from "react";
+import { menu, palette } from "../components/icons";
+import { ToolButton } from "../components/ToolButton";
+import { t } from "../i18n";
+import { showSelectedShapeActions } from "../element";
+
+export const actionToggleCanvasMenu: Action = {
+  name: "toggleCanvasMenu",
+  perform: (_, appState) => ({
+    appState: {
+      ...appState,
+      openMenu: appState.openMenu === "canvas" ? null : "canvas",
+    },
+  }),
+  PanelComponent: ({ appState, updateData }) => (
+    <ToolButton
+      type="button"
+      icon={menu}
+      aria-label={t("buttons.menu")}
+      onClick={updateData}
+      selected={appState.openMenu === "canvas"}
+    />
+  ),
+};
+
+export const actionToggleEditMenu: Action = {
+  name: "toggleEditMenu",
+  perform: (_elements, appState) => ({
+    appState: {
+      ...appState,
+      openMenu: appState.openMenu === "shape" ? null : "shape",
+    },
+  }),
+  PanelComponent: ({ elements, appState, updateData }) => (
+    <ToolButton
+      visible={showSelectedShapeActions(appState, elements)}
+      type="button"
+      icon={palette}
+      aria-label={t("buttons.edit")}
+      onClick={updateData}
+      selected={appState.openMenu === "shape"}
+    />
+  ),
+};

+ 1 - 0
src/actions/index.ts

@@ -36,3 +36,4 @@ export {
 } from "./actionExport";
 
 export { actionCopyStyles, actionPasteStyles } from "./actionStyles";
+export { actionToggleCanvasMenu, actionToggleEditMenu } from "./actionMenu";

+ 1 - 1
src/actions/manager.tsx

@@ -83,7 +83,7 @@ export class ActionManager implements ActionsManagerInterface {
     if (this.actions[name] && "PanelComponent" in this.actions[name]) {
       const action = this.actions[name];
       const PanelComponent = action.PanelComponent!;
-      const updateData = (formState: any) => {
+      const updateData = (formState?: any) => {
         const commitToHistory =
           action.commitToHistory &&
           action.commitToHistory(this.getAppState(), this.getElements());

+ 1 - 1
src/actions/types.ts

@@ -21,7 +21,7 @@ export interface Action {
   PanelComponent?: React.FC<{
     elements: readonly ExcalidrawElement[];
     appState: AppState;
-    updateData: (formData: any) => void;
+    updateData: (formData?: any) => void;
   }>;
   perform: ActionFn;
   keyPriority?: number;

+ 1 - 1
src/appState.ts

@@ -30,7 +30,7 @@ export function getDefaultAppState(): AppState {
     isResizing: false,
     selectionElement: null,
     zoom: 1,
-    openedMenu: null,
+    openMenu: null,
     lastPointerDownWith: "mouse",
   };
 }

+ 7 - 1
src/components/HintViewer.css

@@ -1,5 +1,4 @@
 .HintViewer {
-  background-color: rgba(255, 255, 255, 0.88);
   color: #868e96; /* OC: GRAY 6*/
   font-size: 0.8rem;
   left: 50%;
@@ -9,9 +8,16 @@
   transform: translateX(calc(-50% - 16px)); /* 16px is half of lock icon */
 }
 
+.HintViewer > span {
+  background-color: rgba(255, 255, 255, 0.88);
+  padding: 0.2rem 0.4rem;
+  border-radius: 3px;
+}
+
 @media (max-width: 600px), (max-height: 500px) and (max-width: 1000px) {
   .HintViewer {
     position: static;
+    transform: none;
     margin-top: 0.5rem;
     text-align: center;
   }

+ 5 - 1
src/components/HintViewer.tsx

@@ -52,5 +52,9 @@ export const HintViewer = ({
     return null;
   }
 
-  return <div className="HintViewer">{hint}</div>;
+  return (
+    <div className="HintViewer">
+      <span>{hint}</span>
+    </div>
+  );
 };

+ 4 - 1
src/components/ToolButton.tsx

@@ -16,6 +16,7 @@ type ToolButtonBaseProps = {
   keyBindingLabel?: string;
   showAriaLabel?: boolean;
   visible?: boolean;
+  selected?: boolean;
 };
 
 type ToolButtonProps =
@@ -40,7 +41,9 @@ export const ToolButton = React.forwardRef(function(
   if (props.type === "button") {
     return (
       <button
-        className={`ToolIcon_type_button ToolIcon ${sizeCn}`}
+        className={`ToolIcon_type_button ToolIcon ${sizeCn}${
+          props.selected ? " ToolIcon--selected" : ""
+        }`}
         title={props.title}
         aria-label={props["aria-label"]}
         type="button"

+ 21 - 11
src/components/ToolIcon.scss

@@ -1,10 +1,17 @@
+:root {
+  --button-gray-1: #e9ecef;
+  --button-gray-2: #ced4da;
+  --button-gray-3: #adb5bd;
+  --button-blue: #a5d8ff;
+}
+
 .ToolIcon {
   display: inline-flex;
   align-items: center;
   position: relative;
   font-family: Cascadia;
   cursor: pointer;
-  background-color: #e9ecef;
+  background-color: var(--button-gray-1);
   -webkit-tap-highlight-color: transparent;
 }
 
@@ -41,13 +48,19 @@
   font-size: inherit;
 
   &:hover {
-    background-color: #e9ecef;
+    background-color: var(--button-gray-1);
   }
   &:active {
-    background-color: #ced4da;
+    background-color: var(--button-gray-2);
   }
   &:focus {
-    box-shadow: 0 0 0 2px #a5d8ff;
+    box-shadow: 0 0 0 2px var(--button-blue);
+  }
+  &.ToolIcon--selected {
+    background-color: var(--button-gray-2);
+    &:active {
+      background-color: var(--button-gray-3);
+    }
   }
 }
 
@@ -57,17 +70,14 @@
   opacity: 0;
   pointer-events: none;
 
-  &:hover + .ToolIcon__icon {
-    background-color: #e9ecef;
-  }
   &:checked + .ToolIcon__icon {
-    background-color: #ced4da;
+    background-color: var(--button-gray-2);
   }
   &:focus + .ToolIcon__icon {
-    box-shadow: 0 0 0 2px #a5d8ff;
+    box-shadow: 0 0 0 2px var(--button-blue);
   }
   &:active + .ToolIcon__icon {
-    background-color: #adb5bd;
+    background-color: var(--button-gray-3);
   }
 }
 
@@ -105,7 +115,7 @@
   bottom: 2px;
   right: 3px;
   font-size: 0.5em;
-  color: #adb5bd; // OC GRAY 5
+  color: var(--button-gray-3); // OC GRAY 5
   font-family: var(--ui-font);
   user-select: none;
 }

+ 1 - 0
src/element/index.ts

@@ -23,3 +23,4 @@ export {
   resizePerfectLineForNWHandler,
   normalizeDimensions,
 } from "./sizeHelpers";
+export { showSelectedShapeActions } from "./showSelectedShapeActions";

+ 13 - 0
src/element/showSelectedShapeActions.ts

@@ -0,0 +1,13 @@
+import { AppState } from "../types";
+import { ExcalidrawElement } from "./types";
+import { getSelectedElements } from "../scene";
+
+export const showSelectedShapeActions = (
+  appState: AppState,
+  elements: readonly ExcalidrawElement[],
+) =>
+  Boolean(
+    appState.editingElement ||
+      getSelectedElements(elements).length ||
+      appState.elementType !== "selection",
+  );

+ 8 - 3
src/history.ts

@@ -2,7 +2,12 @@ import { AppState } from "./types";
 import { ExcalidrawElement } from "./element/types";
 import { clearAppStatePropertiesForHistory } from "./appState";
 
-class SceneHistory {
+type Result = {
+  appState: AppState;
+  elements: ExcalidrawElement[];
+};
+
+export class SceneHistory {
   private recording: boolean = true;
   private stateHistory: string[] = [];
   private redoStack: string[] = [];
@@ -53,7 +58,7 @@ class SceneHistory {
     this.redoStack.splice(0, this.redoStack.length);
   }
 
-  redoOnce() {
+  redoOnce(): Result | null {
     if (this.redoStack.length === 0) {
       return null;
     }
@@ -68,7 +73,7 @@ class SceneHistory {
     return null;
   }
 
-  undoOnce() {
+  undoOnce(): Result | null {
     if (this.stateHistory.length === 0) {
       return null;
     }

+ 73 - 92
src/index.tsx

@@ -17,6 +17,7 @@ import {
   getCursorForResizingElement,
   getPerfectElementSize,
   normalizeDimensions,
+  showSelectedShapeActions,
 } from "./element";
 import {
   clearSelection,
@@ -41,7 +42,7 @@ import {
 } from "./scene";
 
 import { renderScene } from "./renderer";
-import { AppState, FlooredNumber, Gesture } from "./types";
+import { AppState, FlooredNumber, Gesture, GestureEvent } from "./types";
 import { ExcalidrawElement } from "./element/types";
 
 import {
@@ -91,6 +92,8 @@ import {
   actionCopyStyles,
   actionPasteStyles,
   actionFinalize,
+  actionToggleCanvasMenu,
+  actionToggleEditMenu,
 } from "./actions";
 import { Action, ActionResult } from "./actions/types";
 import { getDefaultAppState } from "./appState";
@@ -109,7 +112,7 @@ import useIsMobile, { IsMobileProvider } from "./is-mobile";
 import { copyToAppClipboard, getClipboardContent } from "./clipboard";
 import { normalizeScroll } from "./scene/data";
 import { getCenter, getDistance } from "./gesture";
-import { menu, palette } from "./components/icons";
+import { createUndoAction, createRedoAction } from "./actions/actionHistory";
 
 let { elements } = createScene();
 const { history } = createHistory();
@@ -287,12 +290,6 @@ const LayerUI = React.memo(
       );
     }
 
-    const showSelectedShapeActions = Boolean(
-      appState.editingElement ||
-        getSelectedElements(elements).length ||
-        appState.elementType !== "selection",
-    );
-
     function renderSelectedShapeActions() {
       const { elementType, editingElement } = appState;
       const targetElements = editingElement
@@ -392,7 +389,7 @@ const LayerUI = React.memo(
 
     return isMobile ? (
       <>
-        {appState.openedMenu === "canvas" ? (
+        {appState.openMenu === "canvas" ? (
           <section
             className="App-mobile-menu"
             aria-labelledby="canvas-actions-title"
@@ -421,7 +418,8 @@ const LayerUI = React.memo(
               </Stack.Col>
             </div>
           </section>
-        ) : appState.openedMenu === "shape" && showSelectedShapeActions ? (
+        ) : appState.openMenu === "shape" &&
+          showSelectedShapeActions(appState, elements) ? (
           <section
             className="App-mobile-menu"
             aria-labelledby="selected-shape-title"
@@ -456,59 +454,23 @@ const LayerUI = React.memo(
         </FixedSideContainer>
         <footer className="App-toolbar">
           <div className="App-toolbar-content">
-            {appState.multiElement ? (
-              <>
-                {actionManager.renderAction("deleteSelectedElements")}
-                <ToolButton
-                  visible={showSelectedShapeActions}
-                  type="button"
-                  icon={palette}
-                  aria-label={t("buttons.edit")}
-                  onClick={() =>
-                    setAppState(({ openedMenu }: any) => ({
-                      openedMenu: openedMenu === "shape" ? null : "shape",
-                    }))
-                  }
-                />
-                {actionManager.renderAction("finalize")}
-              </>
-            ) : (
-              <>
-                <ToolButton
-                  type="button"
-                  icon={menu}
-                  aria-label={t("buttons.menu")}
-                  onClick={() =>
-                    setAppState(({ openedMenu }: any) => ({
-                      openedMenu: openedMenu === "canvas" ? null : "canvas",
-                    }))
-                  }
-                />
-                <ToolButton
-                  visible={showSelectedShapeActions}
-                  type="button"
-                  icon={palette}
-                  aria-label={t("buttons.edit")}
-                  onClick={() =>
-                    setAppState(({ openedMenu }: any) => ({
-                      openedMenu: openedMenu === "shape" ? null : "shape",
-                    }))
-                  }
-                />
-                {actionManager.renderAction("deleteSelectedElements")}
-                {appState.scrolledOutside && (
-                  <button
-                    className="scroll-back-to-content"
-                    onClick={() => {
-                      setAppState({ ...calculateScrollCenter(elements) });
-                    }}
-                  >
-                    {t("buttons.scrollBackToContent")}
-                  </button>
-                )}
-              </>
-            )}
+            {actionManager.renderAction("toggleCanvasMenu")}
+            {actionManager.renderAction("toggleEditMenu")}
+            {actionManager.renderAction("undo")}
+            {actionManager.renderAction("redo")}
+            {actionManager.renderAction("finalize")}
+            {actionManager.renderAction("deleteSelectedElements")}
           </div>
+          {appState.scrolledOutside && (
+            <button
+              className="scroll-back-to-content"
+              onClick={() => {
+                setAppState({ ...calculateScrollCenter(elements) });
+              }}
+            >
+              {t("buttons.scrollBackToContent")}
+            </button>
+          )}
         </footer>
       </>
     ) : (
@@ -541,7 +503,7 @@ const LayerUI = React.memo(
                   </Stack.Col>
                 </Island>
               </section>
-              {showSelectedShapeActions && (
+              {showSelectedShapeActions(appState, elements) && (
                 <section
                   className="App-right-menu"
                   aria-labelledby="selected-shape-title"
@@ -686,6 +648,12 @@ export class App extends React.Component<any, AppState> {
     this.actionManager.registerAction(actionCopyStyles);
     this.actionManager.registerAction(actionPasteStyles);
 
+    this.actionManager.registerAction(actionToggleCanvasMenu);
+    this.actionManager.registerAction(actionToggleEditMenu);
+
+    this.actionManager.registerAction(createUndoAction(history));
+    this.actionManager.registerAction(createRedoAction(history));
+
     this.canvasOnlyActions = [actionSelectAll];
   }
 
@@ -755,6 +723,19 @@ export class App extends React.Component<any, AppState> {
     window.addEventListener("dragover", this.disableEvent, false);
     window.addEventListener("drop", this.disableEvent, false);
 
+    // Safari-only desktop pinch zoom
+    document.addEventListener(
+      "gesturestart",
+      this.onGestureStart as any,
+      false,
+    );
+    document.addEventListener(
+      "gesturechange",
+      this.onGestureChange as any,
+      false,
+    );
+    document.addEventListener("gestureend", this.onGestureEnd as any, false);
+
     const searchParams = new URLSearchParams(window.location.search);
     const id = searchParams.get("id");
 
@@ -794,6 +775,18 @@ export class App extends React.Component<any, AppState> {
     window.removeEventListener("blur", this.onUnload, false);
     window.removeEventListener("dragover", this.disableEvent, false);
     window.removeEventListener("drop", this.disableEvent, false);
+
+    document.removeEventListener(
+      "gesturestart",
+      this.onGestureStart as any,
+      false,
+    );
+    document.removeEventListener(
+      "gesturechange",
+      this.onGestureChange as any,
+      false,
+    );
+    document.removeEventListener("gestureend", this.onGestureEnd as any, false);
   }
 
   public state: AppState = getDefaultAppState();
@@ -853,34 +846,6 @@ export class App extends React.Component<any, AppState> {
       this.state.draggingElement === null
     ) {
       this.selectShapeTool(shape);
-      // Undo action
-    } else if (event[KEYS.META] && /z/i.test(event.key)) {
-      event.preventDefault();
-
-      if (
-        this.state.multiElement ||
-        this.state.resizingElement ||
-        this.state.editingElement ||
-        this.state.draggingElement
-      ) {
-        return;
-      }
-
-      if (event.shiftKey) {
-        // Redo action
-        const data = history.redoOnce();
-        if (data !== null) {
-          elements = data.elements;
-          this.setState({ ...data.appState });
-        }
-      } else {
-        // undo action
-        const data = history.undoOnce();
-        if (data !== null) {
-          elements = data.elements;
-          this.setState({ ...data.appState });
-        }
-      }
     } else if (event.key === KEYS.SPACE && gesture.pointers.length === 0) {
       isHoldingSpace = true;
       document.documentElement.style.cursor = CURSOR_TYPE.GRABBING;
@@ -967,6 +932,22 @@ export class App extends React.Component<any, AppState> {
     this.setState({ elementType });
   }
 
+  private onGestureStart = (event: GestureEvent) => {
+    event.preventDefault();
+    gesture.initialScale = this.state.zoom;
+  };
+  private onGestureChange = (event: GestureEvent) => {
+    event.preventDefault();
+
+    this.setState({
+      zoom: getNormalizedZoom(gesture.initialScale! * event.scale),
+    });
+  };
+  private onGestureEnd = (event: GestureEvent) => {
+    event.preventDefault();
+    gesture.initialScale = null;
+  };
+
   setAppState = (obj: any) => {
     this.setState(obj);
   };
@@ -2214,7 +2195,7 @@ export class App extends React.Component<any, AppState> {
     event.preventDefault();
     const { deltaX, deltaY } = event;
 
-    if (event[KEYS.META]) {
+    if (event.metaKey || event.ctrlKey) {
       const sign = Math.sign(deltaY);
       const MAX_STEP = 10;
       let delta = Math.abs(deltaY);

+ 3 - 1
src/locales/en.json

@@ -60,7 +60,9 @@
     "resetZoom": "Reset zoom",
     "menu": "Menu",
     "done": "Done",
-    "edit": "Edit"
+    "edit": "Edit",
+    "undo": "Undo",
+    "redo": "Redo"
   },
   "alerts": {
     "clearReset": "This will clear the whole canvas. Are you sure?",

+ 6 - 1
src/types.ts

@@ -31,7 +31,7 @@ export type AppState = {
   selectedId?: string;
   isResizing: boolean;
   zoom: number;
-  openedMenu: "canvas" | "shape" | null;
+  openMenu: "canvas" | "shape" | null;
   lastPointerDownWith: PointerType;
 };
 
@@ -47,3 +47,8 @@ export type Gesture = {
   initialDistance: number | null;
   initialScale: number | null;
 };
+
+export declare class GestureEvent extends UIEvent {
+  readonly rotation: number;
+  readonly scale: number;
+}