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
This commit is contained in:
Gasim Gasimzada 2020-01-12 02:22:03 +04:00 committed by Christopher Chedeau
parent c253c0b635
commit f465121f9b
15 changed files with 967 additions and 430 deletions

View file

@ -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