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

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

View File

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

View File

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

View File

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

View File

@ -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"
};

View File

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

View File

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

33
src/actions/index.ts Normal file
View File

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

89
src/actions/manager.tsx Normal file
View File

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

57
src/actions/types.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,19 +4,16 @@ import ReactDOM from "react-dom";
import rough from "roughjs/bin/wrappers/rough"; import rough from "roughjs/bin/wrappers/rough";
import { RoughCanvas } from "roughjs/bin/canvas"; import { RoughCanvas } from "roughjs/bin/canvas";
import { moveOneLeft, moveAllLeft, moveOneRight, moveAllRight } from "./zindex";
import { import {
newElement, newElement,
duplicateElement, duplicateElement,
resizeTest, resizeTest,
isTextElement, isTextElement,
textWysiwyg, textWysiwyg,
getElementAbsoluteCoords, getElementAbsoluteCoords
redrawTextBoundingBox
} from "./element"; } from "./element";
import { import {
clearSelection, clearSelection,
getSelectedIndices,
deleteSelectedElements, deleteSelectedElements,
setSelection, setSelection,
isOverScrollBars, isOverScrollBars,
@ -41,7 +38,33 @@ import ContextMenu from "./components/ContextMenu";
import "./styles.scss"; import "./styles.scss";
import { getElementWithResizeHandler } from "./element/resizeTest"; 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 { SidePanel } from "./components/SidePanel";
import { ActionResult } from "./actions/types";
let { elements } = createScene(); let { elements } = createScene();
const { history } = createHistory(); const { history } = createHistory();
@ -50,8 +73,6 @@ const DEFAULT_PROJECT_NAME = `excalidraw-${getDateTime()}`;
const CANVAS_WINDOW_OFFSET_LEFT = 250; const CANVAS_WINDOW_OFFSET_LEFT = 250;
const CANVAS_WINDOW_OFFSET_TOP = 0; const CANVAS_WINDOW_OFFSET_TOP = 0;
let copiedStyles: string = "{}";
function resetCursor() { function resetCursor() {
document.documentElement.style.cursor = ""; document.documentElement.style.cursor = "";
} }
@ -101,6 +122,48 @@ export class App extends React.Component<{}, AppState> {
canvas: HTMLCanvasElement | null = null; canvas: HTMLCanvasElement | null = null;
rc: RoughCanvas | 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() { public componentDidMount() {
document.addEventListener("keydown", this.onKeyDown, false); document.addEventListener("keydown", this.onKeyDown, false);
document.addEventListener("mousemove", this.getCurrentCursorPosition); document.addEventListener("mousemove", this.getCurrentCursorPosition);
@ -166,10 +229,14 @@ export class App extends React.Component<{}, AppState> {
} }
if (isInputLike(event.target)) return; if (isInputLike(event.target)) return;
if (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) { const data = this.actionManager.handleKeyDown(event, elements, this.state);
this.deleteSelectedElements(); this.syncActionResult(data);
event.preventDefault();
} else if (isArrowKey(event.key)) { if (data.elements !== undefined && data.appState !== undefined) {
return;
}
if (isArrowKey(event.key)) {
const step = event.shiftKey const step = event.shiftKey
? ELEMENT_SHIFT_TRANSLATE_AMOUNT ? ELEMENT_SHIFT_TRANSLATE_AMOUNT
: ELEMENT_TRANSLATE_AMOUNT; : ELEMENT_TRANSLATE_AMOUNT;
@ -186,46 +253,6 @@ export class App extends React.Component<{}, AppState> {
}); });
this.forceUpdate(); this.forceUpdate();
event.preventDefault(); 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())) { } else if (shapesShortcutKeys.includes(event.key.toLowerCase())) {
this.setState({ elementType: findShapeByKey(event.key) }); this.setState({ elementType: findShapeByKey(event.key) });
} else if (event[META_KEY] && event.code === "KeyZ") { } else if (event[META_KEY] && event.code === "KeyZ") {
@ -244,99 +271,11 @@ export class App extends React.Component<{}, AppState> {
} }
this.forceUpdate(); this.forceUpdate();
event.preventDefault(); 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 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 = () => { private copyToClipboard = () => {
if (navigator.clipboard) { if (navigator.clipboard) {
const text = JSON.stringify( const text = JSON.stringify(
@ -384,6 +323,9 @@ export class App extends React.Component<{}, AppState> {
}} }}
> >
<SidePanel <SidePanel
actionManager={this.actionManager}
syncActionResult={this.syncActionResult}
appState={{ ...this.state }}
elements={elements} elements={elements}
onToolChange={value => { onToolChange={value => {
this.setState({ elementType: value }); this.setState({ elementType: value });
@ -392,20 +334,6 @@ export class App extends React.Component<{}, AppState> {
value === "text" ? "text" : "crosshair"; value === "text" ? "text" : "crosshair";
this.forceUpdate(); 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={this.canvas!}
/> />
<canvas <canvas
@ -482,13 +410,11 @@ export class App extends React.Component<{}, AppState> {
label: "Paste", label: "Paste",
action: () => this.pasteFromClipboard() action: () => this.pasteFromClipboard()
}, },
{ label: "Copy Styles", action: this.copyStyles }, ...this.actionManager.getContextMenuItems(
{ label: "Paste Styles", action: this.pasteStyles }, elements,
{ label: "Delete", action: this.deleteSelectedElements }, this.state,
{ label: "Move Forward", action: this.moveOneRight }, this.syncActionResult
{ label: "Send to Front", action: this.moveAllRight }, )
{ label: "Move Backwards", action: this.moveOneLeft },
{ label: "Send to Back", action: this.moveAllLeft }
], ],
top: e.clientY, top: e.clientY,
left: e.clientX left: e.clientX