From 8a91f4fe7bb2f3d6bf84ecdc8962c8abfd0f5078 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 6 Jan 2020 13:57:04 -0800 Subject: [PATCH 1/6] One more testimonial (#221) --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 1e3d1e930..73a258af8 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ Go to https://excalidraw.com to start sketching + + ## Run the code From 9305a33dba419ab646cd7d289ee27effbb184eb5 Mon Sep 17 00:00:00 2001 From: Faustino Kialungila Date: Mon, 6 Jan 2020 23:22:48 +0100 Subject: [PATCH 2/6] Copy and paste styles (#219) * copy and paste styles * save copied styles in memory --- src/index.tsx | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/index.tsx b/src/index.tsx index 90be31e50..2cdc5465c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -55,6 +55,8 @@ const KEYS = { BACKSPACE: "Backspace" }; +let COPIED_STYLES: string = "{}"; + function isArrowKey(keyCode: string) { return ( keyCode === KEYS.ARROW_LEFT || @@ -192,7 +194,6 @@ class App extends React.Component<{}, AppState> { } else if (event.metaKey && event.shiftKey && event.code === "KeyF") { this.moveAllRight(); event.preventDefault(); - // Select all: Cmd-A } else if (event.metaKey && event.code === "KeyA") { elements.forEach(element => { @@ -212,6 +213,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(); } }; From 9fe3fe5091a338371261865a707b5227973e9c1c Mon Sep 17 00:00:00 2001 From: Ben Kraft Date: Mon, 6 Jan 2020 15:42:37 -0800 Subject: [PATCH 3/6] Fix URL in README (#222) Looks like excalidraw.com is an SSL error, but www.excalidraw.com works great. It's possible the github pages config could be changed to make both work, but this seemed easier to fix. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 73a258af8..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 From 257f697a98b3f86cdf87e467a0159a9d4a02c125 Mon Sep 17 00:00:00 2001 From: Timur Khazamov Date: Tue, 7 Jan 2020 07:50:59 +0500 Subject: [PATCH 4/6] Context menu with some commands (#217) --- src/components/ColorPicker.tsx | 6 +- src/components/ContextMenu.css | 34 +++++++++ src/components/ContextMenu.tsx | 85 +++++++++++++++++++++++ src/components/Popover.tsx | 24 +++++++ src/index.tsx | 123 +++++++++++++++++++++++++++------ src/styles.scss | 3 +- 6 files changed, 250 insertions(+), 25 deletions(-) create mode 100644 src/components/ContextMenu.css create mode 100644 src/components/ContextMenu.tsx create mode 100644 src/components/Popover.tsx diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index a415db92e..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} +
    e.preventDefault()}> + {options.map((option, idx) => ( +
  • + +
  • + ))} +
+ + ); +} + +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 2cdc5465c..f16a59cc2 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(); @@ -147,8 +148,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 @@ -307,6 +307,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; @@ -332,25 +349,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 - this.state.scrollX; - parsedElement.y = 10 - this.state.scrollY; - parsedElement.seed = randomSeed(); - generateDraw(parsedElement); - elements.push(parsedElement); - }); - this.forceUpdate(); - } + this.addElementsFromPaste(paste); e.preventDefault(); }} > @@ -577,6 +576,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, @@ -942,6 +989,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; } From b2eb2807cc01d94974be6dea1411288329d57914 Mon Sep 17 00:00:00 2001 From: Anirban Sengupta Date: Tue, 7 Jan 2020 08:27:38 +0530 Subject: [PATCH 5/6] Use Ctrl instead of Cmd for keyboard shortcuts on Windows (#216) Co-authored-by: Christopher Chedeau --- src/index.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index f16a59cc2..cf30ed229 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -56,6 +56,10 @@ const KEYS = { BACKSPACE: "Backspace" }; +const META_KEY = /Mac|iPod|iPhone|iPad/.test(window.navigator.platform) + ? "metaKey" + : "ctrlKey"; + let COPIED_STYLES: string = "{}"; function isArrowKey(keyCode: string) { @@ -167,7 +171,7 @@ class App extends React.Component<{}, AppState> { // Send backward: Cmd-Shift-Alt-B } else if ( - event.metaKey && + event[META_KEY] && event.shiftKey && event.altKey && event.code === "KeyB" @@ -176,13 +180,13 @@ class App extends React.Component<{}, AppState> { event.preventDefault(); // Send to back: Cmd-Shift-B - } else if (event.metaKey && event.shiftKey && event.code === "KeyB") { + } else if (event[META_KEY] && event.shiftKey && event.code === "KeyB") { this.moveAllLeft(); event.preventDefault(); // Bring forward: Cmd-Shift-Alt-F } else if ( - event.metaKey && + event[META_KEY] && event.shiftKey && event.altKey && event.code === "KeyF" @@ -191,11 +195,11 @@ class App extends React.Component<{}, AppState> { event.preventDefault(); // Bring to front: Cmd-Shift-F - } else if (event.metaKey && event.shiftKey && event.code === "KeyF") { + } else if (event[META_KEY] && event.shiftKey && event.code === "KeyF") { this.moveAllRight(); event.preventDefault(); // Select all: Cmd-A - } else if (event.metaKey && event.code === "KeyA") { + } else if (event[META_KEY] && event.code === "KeyA") { elements.forEach(element => { element.isSelected = true; }); @@ -203,7 +207,7 @@ class App extends React.Component<{}, AppState> { event.preventDefault(); } else if (shapesShortcutKeys.includes(event.key.toLowerCase())) { this.setState({ elementType: findShapeByKey(event.key) }); - } else if (event.metaKey && event.code === "KeyZ") { + } else if (event[META_KEY] && event.code === "KeyZ") { if (event.shiftKey) { // Redo action history.redoOnce(elements); From abbc04df0e5a3b9265ada12a73bc9b8a810a329c Mon Sep 17 00:00:00 2001 From: Timur Khazamov Date: Tue, 7 Jan 2020 08:03:05 +0500 Subject: [PATCH 6/6] Wysiwyg text (#200) --- src/element/index.ts | 1 + src/element/textWysiwyg.tsx | 54 +++++++++++++++++++++++++++++++++ src/index.tsx | 60 ++++++++++++++++++++----------------- 3 files changed, 87 insertions(+), 28 deletions(-) create mode 100644 src/element/textWysiwyg.tsx diff --git a/src/element/index.ts b/src/element/index.ts index 2a96d8cf9..1f6722f9f 100644 --- a/src/element/index.ts +++ b/src/element/index.ts @@ -13,3 +13,4 @@ export { hitTest } from "./collision"; export { resizeTest } from "./resizeTest"; export { generateDraw } from "./generateDraw"; export { isTextElement } from "./typeChecks"; +export { textWysiwyg } from "./textWysiwyg"; diff --git a/src/element/textWysiwyg.tsx b/src/element/textWysiwyg.tsx new file mode 100644 index 000000000..e31a59415 --- /dev/null +++ b/src/element/textWysiwyg.tsx @@ -0,0 +1,54 @@ +import { KEYS } from "../index"; + +export function textWysiwyg( + x: number, + y: number, + onSubmit: (text: string) => void +) { + const input = document.createElement("input"); + + Object.assign(input.style, { + position: "absolute", + top: y - 8 + "px", + left: x + "px", + transform: "translate(-50%, -50%)", + boxShadow: "none", + textAlign: "center", + width: (window.innerWidth - x) * 2 + "px", + fontSize: "20px", + fontFamily: "Virgil", + border: "none", + background: "transparent" + }); + + input.onkeydown = ev => { + if (ev.key === KEYS.ESCAPE) { + cleanup(); + return; + } + if (ev.key === KEYS.ENTER) { + handleSubmit(); + } + }; + input.onblur = handleSubmit; + + function stopEvent(ev: Event) { + ev.stopPropagation(); + } + + function handleSubmit() { + if (input.value) { + onSubmit(input.value); + } + cleanup(); + } + + function cleanup() { + window.removeEventListener("wheel", stopEvent, true); + document.body.removeChild(input); + } + + window.addEventListener("wheel", stopEvent, true); + document.body.appendChild(input); + input.focus(); +} diff --git a/src/index.tsx b/src/index.tsx index cf30ed229..edb1110f1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -4,7 +4,13 @@ import rough from "roughjs/bin/wrappers/rough"; import { moveOneLeft, moveAllLeft, moveOneRight, moveAllRight } from "./zindex"; import { randomSeed } from "./random"; -import { newElement, resizeTest, generateDraw, isTextElement } from "./element"; +import { + newElement, + resizeTest, + generateDraw, + isTextElement, + textWysiwyg +} from "./element"; import { renderScene, clearSelection, @@ -46,12 +52,13 @@ const DEFAULT_PROJECT_NAME = `excalidraw-${getDateTime()}`; const CANVAS_WINDOW_OFFSET_LEFT = 250; const CANVAS_WINDOW_OFFSET_TOP = 0; -const KEYS = { +export const KEYS = { ARROW_LEFT: "ArrowLeft", ARROW_RIGHT: "ArrowRight", ARROW_DOWN: "ArrowDown", ARROW_UP: "ArrowUp", ESCAPE: "Escape", + ENTER: "Enter", DELETE: "Delete", BACKSPACE: "Backspace" }; @@ -75,9 +82,8 @@ function resetCursor() { document.documentElement.style.cursor = ""; } -function addTextElement(element: ExcalidrawTextElement) { +function addTextElement(element: ExcalidrawTextElement, text = "") { resetCursor(); - const text = prompt("What text do you want?"); if (text === null || text === "") { return false; } @@ -728,22 +734,23 @@ class App extends React.Component<{}, AppState> { } if (isTextElement(element)) { - if (!addTextElement(element)) { - return; - } + textWysiwyg(e.clientX, e.clientY, text => { + addTextElement(element, text); + generateDraw(element); + elements.push(element); + element.isSelected = true; + + this.setState({ + draggingElement: null, + elementType: "selection" + }); + }); + return; } generateDraw(element); elements.push(element); - if (this.state.elementType === "text") { - this.setState({ - draggingElement: null, - elementType: "selection" - }); - element.isSelected = true; - } else { - this.setState({ draggingElement: element }); - } + this.setState({ draggingElement: element }); let lastX = x; let lastY = y; @@ -964,20 +971,17 @@ class App extends React.Component<{}, AppState> { 100 ); - if (!addTextElement(element as ExcalidrawTextElement)) { - return; - } + textWysiwyg(e.clientX, e.clientY, text => { + addTextElement(element as ExcalidrawTextElement, text); + generateDraw(element); + elements.push(element); + element.isSelected = true; - generateDraw(element); - elements.push(element); - - this.setState({ - draggingElement: null, - elementType: "selection" + this.setState({ + draggingElement: null, + elementType: "selection" + }); }); - element.isSelected = true; - - this.forceUpdate(); }} />