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:
parent
c253c0b635
commit
f465121f9b
47
src/actions/actionCanvas.tsx
Normal file
47
src/actions/actionCanvas.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
};
|
19
src/actions/actionDeleteSelected.tsx
Normal file
19
src/actions/actionDeleteSelected.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
};
|
70
src/actions/actionExport.tsx
Normal file
70
src/actions/actionExport.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
};
|
251
src/actions/actionProperties.tsx
Normal file
251
src/actions/actionProperties.tsx
Normal 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)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
};
|
13
src/actions/actionSelectAll.ts
Normal file
13
src/actions/actionSelectAll.ts
Normal 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"
|
||||||
|
};
|
50
src/actions/actionStyles.ts
Normal file
50
src/actions/actionStyles.ts
Normal 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
|
||||||
|
};
|
82
src/actions/actionZindex.tsx
Normal file
82
src/actions/actionZindex.tsx
Normal 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
33
src/actions/index.ts
Normal 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
89
src/actions/manager.tsx
Normal 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
57
src/actions/types.ts
Normal 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;
|
||||||
|
}
|
@ -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) => {
|
|
||||||
changeProperty(element => ({
|
|
||||||
...element,
|
|
||||||
strokeColor: color
|
|
||||||
}));
|
|
||||||
onUpdateAppState("currentItemStrokeColor", color);
|
|
||||||
}}
|
|
||||||
colorValue={getSelectedAttribute(
|
|
||||||
elements,
|
elements,
|
||||||
element => element.strokeColor
|
appState,
|
||||||
|
syncActionResult
|
||||||
)}
|
)}
|
||||||
/>
|
|
||||||
|
|
||||||
{hasBackground(elements) && (
|
{hasBackground(elements) && (
|
||||||
<>
|
<>
|
||||||
<PanelColor
|
{actionManager.renderAction(
|
||||||
title="Background Color"
|
"changeBackgroundColor",
|
||||||
onColorChange={(color: string) => {
|
|
||||||
changeProperty(element => ({
|
|
||||||
...element,
|
|
||||||
backgroundColor: color
|
|
||||||
}));
|
|
||||||
onUpdateAppState("currentItemBackgroundColor", color);
|
|
||||||
}}
|
|
||||||
colorValue={getSelectedAttribute(
|
|
||||||
elements,
|
elements,
|
||||||
element => element.backgroundColor
|
appState,
|
||||||
|
syncActionResult
|
||||||
)}
|
)}
|
||||||
/>
|
|
||||||
|
|
||||||
<h5>Fill</h5>
|
{actionManager.renderAction(
|
||||||
<ButtonSelect
|
"changeFillStyle",
|
||||||
options={[
|
|
||||||
{ value: "solid", text: "Solid" },
|
|
||||||
{ value: "hachure", text: "Hachure" },
|
|
||||||
{ value: "cross-hatch", text: "Cross-hatch" }
|
|
||||||
]}
|
|
||||||
value={getSelectedAttribute(
|
|
||||||
elements,
|
elements,
|
||||||
element => element.fillStyle
|
appState,
|
||||||
|
syncActionResult
|
||||||
)}
|
)}
|
||||||
onChange={value => {
|
|
||||||
changeProperty(element => ({
|
|
||||||
...element,
|
|
||||||
fillStyle: value
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasStroke(elements) && (
|
{hasStroke(elements) && (
|
||||||
<>
|
<>
|
||||||
<h5>Stroke Width</h5>
|
{actionManager.renderAction(
|
||||||
<ButtonSelect
|
"changeStrokeWidth",
|
||||||
options={[
|
|
||||||
{ value: 1, text: "Thin" },
|
|
||||||
{ value: 2, text: "Bold" },
|
|
||||||
{ value: 4, text: "Extra Bold" }
|
|
||||||
]}
|
|
||||||
value={getSelectedAttribute(
|
|
||||||
elements,
|
elements,
|
||||||
element => element.strokeWidth
|
appState,
|
||||||
|
syncActionResult
|
||||||
)}
|
)}
|
||||||
onChange={value => {
|
|
||||||
changeProperty(element => ({
|
|
||||||
...element,
|
|
||||||
strokeWidth: value
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<h5>Sloppiness</h5>
|
{actionManager.renderAction(
|
||||||
<ButtonSelect
|
"changeSloppiness",
|
||||||
options={[
|
|
||||||
{ value: 0, text: "Draftsman" },
|
|
||||||
{ value: 1, text: "Artist" },
|
|
||||||
{ value: 3, text: "Cartoonist" }
|
|
||||||
]}
|
|
||||||
value={getSelectedAttribute(
|
|
||||||
elements,
|
elements,
|
||||||
element => element.roughness
|
appState,
|
||||||
|
syncActionResult
|
||||||
)}
|
)}
|
||||||
onChange={value =>
|
|
||||||
changeProperty(element => ({
|
|
||||||
...element,
|
|
||||||
roughness: value
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasText(elements) && (
|
{hasText(elements) && (
|
||||||
<>
|
<>
|
||||||
<h5>Font size</h5>
|
{actionManager.renderAction(
|
||||||
<ButtonSelect
|
"changeFontSize",
|
||||||
options={[
|
|
||||||
{ value: 16, text: "Small" },
|
|
||||||
{ value: 20, text: "Medium" },
|
|
||||||
{ value: 28, text: "Large" },
|
|
||||||
{ value: 36, text: "Very Large" }
|
|
||||||
]}
|
|
||||||
value={getSelectedAttribute(
|
|
||||||
elements,
|
elements,
|
||||||
element =>
|
appState,
|
||||||
isTextElement(element) && +element.font.split("px ")[0]
|
syncActionResult
|
||||||
)}
|
)}
|
||||||
onChange={value =>
|
|
||||||
changeProperty(element => {
|
|
||||||
if (isTextElement(element)) {
|
|
||||||
element.font = `${value}px ${element.font.split("px ")[1]}`;
|
|
||||||
redrawTextBoundingBox(element);
|
|
||||||
}
|
|
||||||
|
|
||||||
return element;
|
{actionManager.renderAction(
|
||||||
})
|
"changeFontFamily",
|
||||||
}
|
|
||||||
/>
|
|
||||||
<h5>Font familly</h5>
|
|
||||||
<ButtonSelect
|
|
||||||
options={[
|
|
||||||
{ value: "Virgil", text: "Virgil" },
|
|
||||||
{ value: "Helvetica", text: "Helvetica" },
|
|
||||||
{ value: "Courier", text: "Courier" }
|
|
||||||
]}
|
|
||||||
value={getSelectedAttribute(
|
|
||||||
elements,
|
elements,
|
||||||
element =>
|
appState,
|
||||||
isTextElement(element) && element.font.split("px ")[1]
|
syncActionResult
|
||||||
)}
|
)}
|
||||||
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>
|
||||||
);
|
);
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
244
src/index.tsx
244
src/index.tsx
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user