From f465121f9b608cdf7577516b234c393f32b38389 Mon Sep 17 00:00:00 2001 From: Gasim Gasimzada Date: Sun, 12 Jan 2020 02:22:03 +0400 Subject: [PATCH] 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 --- src/actions/actionCanvas.tsx | 47 ++++ src/actions/actionDeleteSelected.tsx | 19 ++ src/actions/actionExport.tsx | 70 ++++++ src/actions/actionProperties.tsx | 251 +++++++++++++++++++++ src/actions/actionSelectAll.ts | 13 ++ src/actions/actionStyles.ts | 50 +++++ src/actions/actionZindex.tsx | 82 +++++++ src/actions/index.ts | 33 +++ src/actions/manager.tsx | 89 ++++++++ src/actions/types.ts | 57 +++++ src/components/SidePanel.tsx | 275 ++++++----------------- src/components/panels/PanelCanvas.tsx | 44 ++-- src/components/panels/PanelExport.tsx | 67 +++--- src/components/panels/PanelSelection.tsx | 56 +++-- src/index.tsx | 244 +++++++------------- 15 files changed, 967 insertions(+), 430 deletions(-) create mode 100644 src/actions/actionCanvas.tsx create mode 100644 src/actions/actionDeleteSelected.tsx create mode 100644 src/actions/actionExport.tsx create mode 100644 src/actions/actionProperties.tsx create mode 100644 src/actions/actionSelectAll.ts create mode 100644 src/actions/actionStyles.ts create mode 100644 src/actions/actionZindex.tsx create mode 100644 src/actions/index.ts create mode 100644 src/actions/manager.tsx create mode 100644 src/actions/types.ts diff --git a/src/actions/actionCanvas.tsx b/src/actions/actionCanvas.tsx new file mode 100644 index 00000000..ef9376c0 --- /dev/null +++ b/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 }) => ( + <> +
Canvas Background 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 }) => ( + + ) +}; diff --git a/src/actions/actionDeleteSelected.tsx b/src/actions/actionDeleteSelected.tsx new file mode 100644 index 00000000..1c2aed03 --- /dev/null +++ b/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 }) => ( + + ) +}; diff --git a/src/actions/actionExport.tsx b/src/actions/actionExport.tsx new file mode 100644 index 00000000..bb2fb7e3 --- /dev/null +++ b/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 }) => ( + <> +
Name
+ {appState.name && ( + updateData(name)} + /> + )} + + ) +}; + +export const actionChangeExportBackground: Action = { + name: "changeExportBackground", + perform: (elements, appState, value) => { + return { appState: { ...appState, exportBackground: value } }; + }, + PanelComponent: ({ appState, updateData }) => ( + + ) +}; + +export const actionSaveScene: Action = { + name: "saveScene", + perform: (elements, appState, value) => { + saveAsJSON(elements, appState.name); + return {}; + }, + PanelComponent: ({ updateData }) => ( + + ) +}; + +export const actionLoadScene: Action = { + name: "loadScene", + perform: (elements, appState, loadedElements) => { + return { elements: loadedElements }; + }, + PanelComponent: ({ updateData }) => ( + + ) +}; diff --git a/src/actions/actionProperties.tsx b/src/actions/actionProperties.tsx new file mode 100644 index 00000000..a637b786 --- /dev/null +++ b/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 }) => ( + { + 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 }) => ( + { + 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 }) => ( + <> +
Fill
+ 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 }) => ( + <> +
Stroke Width
+ 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 }) => ( + <> +
Sloppiness
+ 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 }) => ( + <> +
Opacity
+ 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 }) => ( + <> +
Font size
+ 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 }) => ( + <> +
Font family
+ isTextElement(element) && element.font.split("px ")[1] + )} + onChange={value => updateData(value)} + /> + + ) +}; diff --git a/src/actions/actionSelectAll.ts b/src/actions/actionSelectAll.ts new file mode 100644 index 00000000..ddcd5e00 --- /dev/null +++ b/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" +}; diff --git a/src/actions/actionStyles.ts b/src/actions/actionStyles.ts new file mode 100644 index 00000000..8dd0c058 --- /dev/null +++ b/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 +}; diff --git a/src/actions/actionZindex.tsx b/src/actions/actionZindex.tsx new file mode 100644 index 00000000..21530002 --- /dev/null +++ b/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 }) => ( + + ) +}; + +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 }) => ( + + ) +}; + +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 }) => ( + + ) +}; + +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 }) => ( + + ) +}; diff --git a/src/actions/index.ts b/src/actions/index.ts new file mode 100644 index 00000000..23a2c67b --- /dev/null +++ b/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"; diff --git a/src/actions/manager.tsx b/src/actions/manager.tsx new file mode 100644 index 00000000..37681d41 --- /dev/null +++ b/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 ( + + ); + } + + return null; + } +} diff --git a/src/actions/types.ts b/src/actions/types.ts new file mode 100644 index 00000000..ba989c5c --- /dev/null +++ b/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; +} diff --git a/src/components/SidePanel.tsx b/src/components/SidePanel.tsx index c1dc365e..6e119fac 100644 --- a/src/components/SidePanel.tsx +++ b/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 = ({ + actionManager, + syncActionResult, elements, onToolChange, - changeProperty, - moveAllLeft, - moveOneLeft, - moveAllRight, - moveOneRight, - onClearCanvas, - onUpdateAppState, appState, - onUpdateElements, canvas }) => { return ( @@ -63,209 +44,101 @@ export const SidePanel: React.FC = ({ /> - { - changeProperty(element => ({ - ...element, - strokeColor: color - })); - onUpdateAppState("currentItemStrokeColor", color); - }} - colorValue={getSelectedAttribute( - elements, - element => element.strokeColor - )} - /> + {actionManager.renderAction( + "changeStrokeColor", + elements, + appState, + syncActionResult + )} {hasBackground(elements) && ( <> - { - changeProperty(element => ({ - ...element, - backgroundColor: color - })); - onUpdateAppState("currentItemBackgroundColor", color); - }} - colorValue={getSelectedAttribute( - elements, - element => element.backgroundColor - )} - /> + {actionManager.renderAction( + "changeBackgroundColor", + elements, + appState, + syncActionResult + )} -
Fill
- element.fillStyle - )} - onChange={value => { - changeProperty(element => ({ - ...element, - fillStyle: value - })); - }} - /> + {actionManager.renderAction( + "changeFillStyle", + elements, + appState, + syncActionResult + )} )} {hasStroke(elements) && ( <> -
Stroke Width
- element.strokeWidth - )} - onChange={value => { - changeProperty(element => ({ - ...element, - strokeWidth: value - })); - }} - /> + {actionManager.renderAction( + "changeStrokeWidth", + elements, + appState, + syncActionResult + )} -
Sloppiness
- element.roughness - )} - onChange={value => - changeProperty(element => ({ - ...element, - roughness: value - })) - } - /> + {actionManager.renderAction( + "changeSloppiness", + elements, + appState, + syncActionResult + )} )} {hasText(elements) && ( <> -
Font size
- - 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); - } + {actionManager.renderAction( + "changeFontSize", + elements, + appState, + syncActionResult + )} - return element; - }) - } - /> -
Font familly
- - 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); - } - - return element; - }) - } - /> + {actionManager.renderAction( + "changeFontFamily", + elements, + appState, + syncActionResult + )} )} -
Opacity
- { - 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 + )} - + {actionManager.renderAction( + "deleteSelectedElements", + elements, + appState, + syncActionResult + )}
{ - onUpdateAppState("viewBackgroundColor", value); - }} - viewBackgroundColor={appState.viewBackgroundColor} + actionManager={actionManager} + syncActionResult={syncActionResult} + elements={elements} + appState={appState} /> { - 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); - }) - } /> ); diff --git a/src/components/panels/PanelCanvas.tsx b/src/components/panels/PanelCanvas.tsx index 9208b0d9..959dc0f4 100644 --- a/src/components/panels/PanelCanvas.tsx +++ b/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 = ({ - viewBackgroundColor, - onViewBackgroundColorChange, - onClearCanvas + actionManager, + elements, + appState, + syncActionResult }) => { return ( -
Canvas Background Color
- onViewBackgroundColorChange(color)} - /> - + {actionManager.renderAction( + "changeViewBackgroundColor", + elements, + appState, + syncActionResult + )} + + {actionManager.renderAction( + "clearCanvas", + elements, + appState, + syncActionResult + )}
); }; diff --git a/src/components/panels/PanelExport.tsx b/src/components/panels/PanelExport.tsx index 461aa9cf..5cc8a87b 100644 --- a/src/components/panels/PanelExport.tsx +++ b/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 = ({ - projectName, - exportBackground, - onProjectNameChange, - onExportBackgroundChange, - onSaveScene, - onLoadScene, + actionManager, + elements, + appState, + syncActionResult, onExportCanvas }) => { return (
-
Name
- {projectName && ( - onProjectNameChange(name)} - /> + {actionManager.renderAction( + "changeProjectName", + elements, + appState, + syncActionResult )}
Image
@@ -68,19 +66,26 @@ export const PanelExport: React.FC = ({ )}
- + {actionManager.renderAction( + "changeExportBackground", + elements, + appState, + syncActionResult + )} +
Scene
- - + {actionManager.renderAction( + "saveScene", + elements, + appState, + syncActionResult + )} + {actionManager.renderAction( + "loadScene", + elements, + appState, + syncActionResult + )}
); diff --git a/src/components/panels/PanelSelection.tsx b/src/components/panels/PanelSelection.tsx index 6e366c69..b5683a32 100644 --- a/src/components/panels/PanelSelection.tsx +++ b/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 = ({ - onBringForward, - onBringToFront, - onSendBackward, - onSendToBack + actionManager, + elements, + appState, + syncActionResult }) => { return (
- - - - + {actionManager.renderAction( + "bringForward", + elements, + appState, + syncActionResult + )} + {actionManager.renderAction( + "bringToFront", + elements, + appState, + syncActionResult + )} + {actionManager.renderAction( + "sendBackward", + elements, + appState, + syncActionResult + )} + {actionManager.renderAction( + "sendToBack", + elements, + appState, + syncActionResult + )}
); diff --git a/src/index.tsx b/src/index.tsx index 53b48cd4..0a2fbdbf 100644 --- a/src/index.tsx +++ b/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> { }} > { 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!} /> { 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