diff --git a/README.md b/README.md index 1e3d1e930..cb9ebe6a9 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ ## Try it now -Go to https://excalidraw.com to start sketching +Go to https://www.excalidraw.com to start sketching ## Testimonials @@ -15,6 +15,8 @@ Go to https://excalidraw.com to start sketching + + ## Run the code diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index 65dfc78b5..0431b7e77 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -1,5 +1,6 @@ import React from "react"; import { TwitterPicker } from "react-color"; +import { Popover } from "./Popover"; export function ColorPicker({ color, @@ -17,8 +18,7 @@ export function ColorPicker({ onClick={() => setActive(!isActive)} /> {isActive ? ( -
-
setActive(false)} /> + setActive(false)}> -
+ ) : null} onChange(e.clipboardData.getData("text"))} onChange={e => onChange(e.target.value)} />
diff --git a/src/components/ContextMenu.css b/src/components/ContextMenu.css new file mode 100644 index 000000000..78366b175 --- /dev/null +++ b/src/components/ContextMenu.css @@ -0,0 +1,34 @@ +.context-menu { + position: relative; + border-radius: 4px; + box-shadow: 0px 3px 10px rgba(0, 0, 0, 0.2); + padding: 0; + list-style: none; + user-select: none; +} + +.context-menu__option { + width: 150px; +} + +.context-menu-option { + position: relative; + width: 100%; + margin: 0; + text-align: left; + border-radius: 0; +} + +.context-menu-option:focus { + z-index: 1; +} + +.context-menu__option:first-child .context-menu-option { + border-top-left-radius: 4px; + border-top-right-radius: 4px; +} + +.context-menu__option:last-child .context-menu-option { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; +} diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx new file mode 100644 index 000000000..c367d8246 --- /dev/null +++ b/src/components/ContextMenu.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import { Popover } from "./Popover"; +import { render, unmountComponentAtNode } from "react-dom"; + +import "./ContextMenu.css"; + +type ContextMenuOption = { + label: string; + action(): void; +}; + +type Props = { + options: ContextMenuOption[]; + onCloseRequest?(): void; + top: number; + left: number; +}; + +function ContextMenu({ options, onCloseRequest, top, left }: Props) { + return ( + + + + ); +} + +function ContextMenuOption({ label, action }: ContextMenuOption) { + return ( + + ); +} + +let contextMenuNode: HTMLDivElement; +function getContextMenuNode(): HTMLDivElement { + if (contextMenuNode) { + return contextMenuNode; + } + const div = document.createElement("div"); + document.body.appendChild(div); + return (contextMenuNode = div); +} + +type ContextMenuParams = { + options: (ContextMenuOption | false | null | undefined)[]; + top: number; + left: number; +}; + +function handleClose() { + unmountComponentAtNode(getContextMenuNode()); +} + +export default { + push(params: ContextMenuParams) { + const options = Array.of(); + params.options.forEach(option => { + if (option) { + options.push(option); + } + }); + if (options.length) { + render( + , + getContextMenuNode() + ); + } + } +}; diff --git a/src/components/Popover.tsx b/src/components/Popover.tsx new file mode 100644 index 000000000..522f7b351 --- /dev/null +++ b/src/components/Popover.tsx @@ -0,0 +1,24 @@ +import React from "react"; + +type Props = { + top?: number; + left?: number; + children?: React.ReactNode; + onCloseRequest?(): void; +}; + +export function Popover({ children, left, onCloseRequest, top }: Props) { + return ( +
+
{ + e.preventDefault(); + if (onCloseRequest) onCloseRequest(); + }} + /> + {children} +
+ ); +} diff --git a/src/index.tsx b/src/index.tsx index 3dc759076..cf30ed229 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -36,6 +36,7 @@ import { SHAPES, findShapeByKey, shapesShortcutKeys } from "./shapes"; import { createHistory } from "./history"; import "./styles.scss"; +import ContextMenu from "./components/ContextMenu"; const { elements } = createScene(); const { history } = createHistory(); @@ -59,6 +60,8 @@ const META_KEY = /Mac|iPod|iPhone|iPad/.test(window.navigator.platform) ? "metaKey" : "ctrlKey"; +let COPIED_STYLES: string = "{}"; + function isArrowKey(keyCode: string) { return ( keyCode === KEYS.ARROW_LEFT || @@ -149,8 +152,7 @@ class App extends React.Component<{}, AppState> { this.forceUpdate(); event.preventDefault(); } else if (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) { - deleteSelectedElements(elements); - this.forceUpdate(); + this.deleteSelectedElements(); event.preventDefault(); } else if (isArrowKey(event.key)) { const step = event.shiftKey @@ -196,7 +198,6 @@ class App extends React.Component<{}, AppState> { } 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") { elements.forEach(element => { @@ -216,6 +217,29 @@ class App extends React.Component<{}, AppState> { } this.forceUpdate(); event.preventDefault(); + // Copy Styles: Cmd-Shift-C + } else if (event.metaKey && event.shiftKey && event.code === "KeyC") { + const element = elements.find(el => el.isSelected); + if (element) { + COPIED_STYLES = JSON.stringify(element); + } + // Paste Styles: Cmd-Shift-V + } else if (event.metaKey && event.shiftKey && event.code === "KeyV") { + const pastedElement = JSON.parse(COPIED_STYLES); + if (pastedElement.type) { + elements.forEach(element => { + if (element.isSelected) { + element.backgroundColor = pastedElement?.backgroundColor; + element.strokeWidth = pastedElement?.strokeWidth; + element.strokeColor = pastedElement?.strokeColor; + element.fillStyle = pastedElement?.fillStyle; + element.opacity = pastedElement?.opacity; + generateDraw(element); + } + }); + } + this.forceUpdate(); + event.preventDefault(); } }; @@ -287,6 +311,23 @@ class App extends React.Component<{}, AppState> { this.setState({ currentItemBackgroundColor: color }); }; + private copyToClipboard = () => { + if (navigator.clipboard) { + const text = JSON.stringify( + elements.filter(element => element.isSelected) + ); + navigator.clipboard.writeText(text); + } + }; + + private pasteFromClipboard = (x?: number, y?: number) => { + if (navigator.clipboard) { + navigator.clipboard + .readText() + .then(text => this.addElementsFromPaste(text, x, y)); + } + }; + public render() { const canvasWidth = window.innerWidth - CANVAS_WINDOW_OFFSET_LEFT; const canvasHeight = window.innerHeight - CANVAS_WINDOW_OFFSET_TOP; @@ -312,25 +353,7 @@ class App extends React.Component<{}, AppState> { }} onPaste={e => { const paste = e.clipboardData.getData("text"); - let parsedElements; - try { - parsedElements = JSON.parse(paste); - } catch (e) {} - if ( - Array.isArray(parsedElements) && - parsedElements.length > 0 && - parsedElements[0].type // need to implement a better check here... - ) { - clearSelection(elements); - parsedElements.forEach(parsedElement => { - parsedElement.x += 10; - parsedElement.y += 10; - parsedElement.seed = randomSeed(); - generateDraw(parsedElement); - elements.push(parsedElement); - }); - this.forceUpdate(); - } + this.addElementsFromPaste(paste); e.preventDefault(); }} > @@ -557,6 +580,54 @@ class App extends React.Component<{}, AppState> { } } }} + onContextMenu={e => { + e.preventDefault(); + + const x = + e.clientX - CANVAS_WINDOW_OFFSET_LEFT - this.state.scrollX; + const y = e.clientY - CANVAS_WINDOW_OFFSET_TOP - this.state.scrollY; + + const element = getElementAtPosition(elements, x, y); + if (!element) { + ContextMenu.push({ + options: [ + navigator.clipboard && { + label: "Paste", + action: () => this.pasteFromClipboard(x, y) + } + ], + top: e.clientY, + left: e.clientX + }); + return; + } + + if (!element.isSelected) { + clearSelection(elements); + element.isSelected = true; + this.forceUpdate(); + } + + ContextMenu.push({ + options: [ + navigator.clipboard && { + label: "Copy", + action: this.copyToClipboard + }, + navigator.clipboard && { + label: "Paste", + action: () => this.pasteFromClipboard(x, y) + }, + { label: "Delete", action: this.deleteSelectedElements }, + { label: "Move Forward", action: this.moveOneRight }, + { label: "Send to Front", action: this.moveAllRight }, + { label: "Move Backwards", action: this.moveOneLeft }, + { label: "Send to Back", action: this.moveAllLeft } + ], + top: e.clientY, + left: e.clientX + }); + }} onMouseDown={e => { if (lastMouseUp !== null) { // Unfortunately, sometimes we don't get a mouseup after a mousedown, @@ -716,19 +787,33 @@ class App extends React.Component<{}, AppState> { switch (resizeHandle) { case "nw": element.width += element.x - lastX; - element.height += element.y - lastY; element.x = lastX; - element.y = lastY; + if (e.shiftKey) { + element.y += element.height - element.width; + element.height = element.width; + } else { + element.height += element.y - lastY; + element.y = lastY; + } break; case "ne": element.width = lastX - element.x; - element.height += element.y - lastY; - element.y = lastY; + if (e.shiftKey) { + element.y += element.height - element.width; + element.height = element.width; + } else { + element.height += element.y - lastY; + element.y = lastY; + } break; case "sw": element.width += element.x - lastX; element.x = lastX; - element.height = lastY - element.y; + if (e.shiftKey) { + element.height = element.width; + } else { + element.height = lastY - element.y; + } break; case "se": element.width += x - lastX; @@ -908,6 +993,40 @@ class App extends React.Component<{}, AppState> { })); }; + private addElementsFromPaste = (paste: string, x?: number, y?: number) => { + let parsedElements; + try { + parsedElements = JSON.parse(paste); + } catch (e) {} + if ( + Array.isArray(parsedElements) && + parsedElements.length > 0 && + parsedElements[0].type // need to implement a better check here... + ) { + clearSelection(elements); + + let dx: number; + let dy: number; + if (x) { + let minX = Math.min(...parsedElements.map(element => element.x)); + dx = x - minX; + } + if (y) { + let minY = Math.min(...parsedElements.map(element => element.y)); + dy = y - minY; + } + + parsedElements.forEach(parsedElement => { + parsedElement.x = dx ? parsedElement.x + dx : 10 - this.state.scrollX; + parsedElement.y = dy ? parsedElement.y + dy : 10 - this.state.scrollY; + parsedElement.seed = randomSeed(); + generateDraw(parsedElement); + elements.push(parsedElement); + }); + this.forceUpdate(); + } + }; + componentDidUpdate() { renderScene(elements, rc, canvas, { scrollX: this.state.scrollX, diff --git a/src/styles.scss b/src/styles.scss index 43f157f1c..969174ea9 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -149,7 +149,8 @@ button { border-color: #d6d4d4; } - &:active, &.active { + &:active, + &.active { background-color: #bdbebc; border-color: #bdbebc; }