Merge branch 'master' into save-state-in-url

This commit is contained in:
Christopher Chedeau 2020-01-06 19:07:49 -08:00 committed by GitHub
commit 4a9c601eec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 375 additions and 61 deletions

View file

@ -7,7 +7,7 @@
## Try it now ## Try it now
Go to https://excalidraw.com to start sketching Go to https://www.excalidraw.com to start sketching
## Testimonials ## Testimonials
@ -15,6 +15,8 @@ Go to https://excalidraw.com to start sketching
<a href="https://twitter.com/dan_abramov/status/1213762494428262400"><img width="398" src="https://user-images.githubusercontent.com/197597/71783990-4d395880-2fa3-11ea-9ad7-186138db5003.png"></a> <a href="https://twitter.com/dan_abramov/status/1213762494428262400"><img width="398" src="https://user-images.githubusercontent.com/197597/71783990-4d395880-2fa3-11ea-9ad7-186138db5003.png"></a>
<a href="https://twitter.com/kyehohenberger/status/1214288572037025792"><img width="423" src="https://user-images.githubusercontent.com/197597/71851802-34f13880-308c-11ea-9416-191099e6349c.png"></a>
## Run the code ## Run the code

View file

@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import { TwitterPicker } from "react-color"; import { TwitterPicker } from "react-color";
import { Popover } from "./Popover";
export function ColorPicker({ export function ColorPicker({
color, color,
@ -17,8 +18,7 @@ export function ColorPicker({
onClick={() => setActive(!isActive)} onClick={() => setActive(!isActive)}
/> />
{isActive ? ( {isActive ? (
<div className="popover"> <Popover onCloseRequest={() => setActive(false)}>
<div className="cover" onClick={() => setActive(false)} />
<TwitterPicker <TwitterPicker
colors={[ colors={[
"#000000", "#000000",
@ -39,7 +39,7 @@ export function ColorPicker({
onChange(changedColor.hex); onChange(changedColor.hex);
}} }}
/> />
</div> </Popover>
) : null} ) : null}
<input <input
type="text" type="text"

View file

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

View file

@ -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 (
<Popover onCloseRequest={onCloseRequest} top={top} left={left}>
<ul className="context-menu" onContextMenu={e => e.preventDefault()}>
{options.map((option, idx) => (
<li
key={idx}
className="context-menu__option"
onClick={onCloseRequest}
>
<ContextMenuOption {...option} />
</li>
))}
</ul>
</Popover>
);
}
function ContextMenuOption({ label, action }: ContextMenuOption) {
return (
<button className="context-menu-option" onClick={action}>
{label}
</button>
);
}
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<ContextMenuOption>();
params.options.forEach(option => {
if (option) {
options.push(option);
}
});
if (options.length) {
render(
<ContextMenu
top={params.top}
left={params.left}
options={options}
onCloseRequest={handleClose}
/>,
getContextMenuNode()
);
}
}
};

View file

@ -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 (
<div className="popover" style={{ top: top, left: left }}>
<div
className="cover"
onClick={onCloseRequest}
onContextMenu={e => {
e.preventDefault();
if (onCloseRequest) onCloseRequest();
}}
/>
{children}
</div>
);
}

View file

@ -13,3 +13,4 @@ export { hitTest } from "./collision";
export { resizeTest } from "./resizeTest"; export { resizeTest } from "./resizeTest";
export { generateDraw } from "./generateDraw"; export { generateDraw } from "./generateDraw";
export { isTextElement } from "./typeChecks"; export { isTextElement } from "./typeChecks";
export { textWysiwyg } from "./textWysiwyg";

View file

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

View file

@ -4,7 +4,13 @@ import rough from "roughjs/bin/wrappers/rough";
import { moveOneLeft, moveAllLeft, moveOneRight, moveAllRight } from "./zindex"; import { moveOneLeft, moveAllLeft, moveOneRight, moveAllRight } from "./zindex";
import { randomSeed } from "./random"; import { randomSeed } from "./random";
import { newElement, resizeTest, generateDraw, isTextElement } from "./element"; import {
newElement,
resizeTest,
generateDraw,
isTextElement,
textWysiwyg
} from "./element";
import { import {
renderScene, renderScene,
clearSelection, clearSelection,
@ -38,6 +44,7 @@ import { SHAPES, findShapeByKey, shapesShortcutKeys } from "./shapes";
import { createHistory } from "./history"; import { createHistory } from "./history";
import "./styles.scss"; import "./styles.scss";
import ContextMenu from "./components/ContextMenu";
const { elements } = createScene(); const { elements } = createScene();
const { history } = createHistory(); const { history } = createHistory();
@ -47,16 +54,23 @@ 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;
const KEYS = { export const KEYS = {
ARROW_LEFT: "ArrowLeft", ARROW_LEFT: "ArrowLeft",
ARROW_RIGHT: "ArrowRight", ARROW_RIGHT: "ArrowRight",
ARROW_DOWN: "ArrowDown", ARROW_DOWN: "ArrowDown",
ARROW_UP: "ArrowUp", ARROW_UP: "ArrowUp",
ESCAPE: "Escape", ESCAPE: "Escape",
ENTER: "Enter",
DELETE: "Delete", DELETE: "Delete",
BACKSPACE: "Backspace" BACKSPACE: "Backspace"
}; };
const META_KEY = /Mac|iPod|iPhone|iPad/.test(window.navigator.platform)
? "metaKey"
: "ctrlKey";
let COPIED_STYLES: string = "{}";
function isArrowKey(keyCode: string) { function isArrowKey(keyCode: string) {
return ( return (
keyCode === KEYS.ARROW_LEFT || keyCode === KEYS.ARROW_LEFT ||
@ -70,9 +84,8 @@ function resetCursor() {
document.documentElement.style.cursor = ""; document.documentElement.style.cursor = "";
} }
function addTextElement(element: ExcalidrawTextElement) { function addTextElement(element: ExcalidrawTextElement, text = "") {
resetCursor(); resetCursor();
const text = prompt("What text do you want?");
if (text === null || text === "") { if (text === null || text === "") {
return false; return false;
} }
@ -148,8 +161,7 @@ class App extends React.Component<{}, AppState> {
this.forceUpdate(); this.forceUpdate();
event.preventDefault(); event.preventDefault();
} else if (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) { } else if (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) {
deleteSelectedElements(elements); this.deleteSelectedElements();
this.forceUpdate();
event.preventDefault(); event.preventDefault();
} else if (isArrowKey(event.key)) { } else if (isArrowKey(event.key)) {
const step = event.shiftKey const step = event.shiftKey
@ -168,7 +180,7 @@ class App extends React.Component<{}, AppState> {
// Send backward: Cmd-Shift-Alt-B // Send backward: Cmd-Shift-Alt-B
} else if ( } else if (
event.metaKey && event[META_KEY] &&
event.shiftKey && event.shiftKey &&
event.altKey && event.altKey &&
event.code === "KeyB" event.code === "KeyB"
@ -177,13 +189,13 @@ class App extends React.Component<{}, AppState> {
event.preventDefault(); event.preventDefault();
// Send to back: Cmd-Shift-B // 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(); this.moveAllLeft();
event.preventDefault(); event.preventDefault();
// Bring forward: Cmd-Shift-Alt-F // Bring forward: Cmd-Shift-Alt-F
} else if ( } else if (
event.metaKey && event[META_KEY] &&
event.shiftKey && event.shiftKey &&
event.altKey && event.altKey &&
event.code === "KeyF" event.code === "KeyF"
@ -192,12 +204,11 @@ class App extends React.Component<{}, AppState> {
event.preventDefault(); event.preventDefault();
// Bring to front: Cmd-Shift-F // 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(); this.moveAllRight();
event.preventDefault(); event.preventDefault();
// Select all: Cmd-A // Select all: Cmd-A
} else if (event.metaKey && event.code === "KeyA") { } else if (event[META_KEY] && event.code === "KeyA") {
elements.forEach(element => { elements.forEach(element => {
element.isSelected = true; element.isSelected = true;
}); });
@ -205,7 +216,7 @@ class App extends React.Component<{}, AppState> {
event.preventDefault(); 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.metaKey && event.code === "KeyZ") { } else if (event[META_KEY] && event.code === "KeyZ") {
if (event.shiftKey) { if (event.shiftKey) {
// Redo action // Redo action
history.redoOnce(elements); history.redoOnce(elements);
@ -215,6 +226,29 @@ 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") {
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();
} }
}; };
@ -286,6 +320,23 @@ class App extends React.Component<{}, AppState> {
this.setState({ currentItemBackgroundColor: color }); 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() { public render() {
const canvasWidth = window.innerWidth - CANVAS_WINDOW_OFFSET_LEFT; const canvasWidth = window.innerWidth - CANVAS_WINDOW_OFFSET_LEFT;
const canvasHeight = window.innerHeight - CANVAS_WINDOW_OFFSET_TOP; const canvasHeight = window.innerHeight - CANVAS_WINDOW_OFFSET_TOP;
@ -311,25 +362,7 @@ class App extends React.Component<{}, AppState> {
}} }}
onPaste={e => { onPaste={e => {
const paste = e.clipboardData.getData("text"); const paste = e.clipboardData.getData("text");
let parsedElements; this.addElementsFromPaste(paste);
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();
}
e.preventDefault(); e.preventDefault();
}} }}
> >
@ -556,6 +589,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 => { onMouseDown={e => {
if (lastMouseUp !== null) { if (lastMouseUp !== null) {
// Unfortunately, sometimes we don't get a mouseup after a mousedown, // Unfortunately, sometimes we don't get a mouseup after a mousedown,
@ -656,22 +737,23 @@ class App extends React.Component<{}, AppState> {
} }
if (isTextElement(element)) { if (isTextElement(element)) {
if (!addTextElement(element)) { textWysiwyg(e.clientX, e.clientY, text => {
return; addTextElement(element, text);
} generateDraw(element);
elements.push(element);
element.isSelected = true;
this.setState({
draggingElement: null,
elementType: "selection"
});
});
return;
} }
generateDraw(element); generateDraw(element);
elements.push(element); elements.push(element);
if (this.state.elementType === "text") { this.setState({ draggingElement: element });
this.setState({
draggingElement: null,
elementType: "selection"
});
element.isSelected = true;
} else {
this.setState({ draggingElement: element });
}
let lastX = x; let lastX = x;
let lastY = y; let lastY = y;
@ -892,20 +974,17 @@ class App extends React.Component<{}, AppState> {
100 100
); );
if (!addTextElement(element as ExcalidrawTextElement)) { textWysiwyg(e.clientX, e.clientY, text => {
return; addTextElement(element as ExcalidrawTextElement, text);
} generateDraw(element);
elements.push(element);
element.isSelected = true;
generateDraw(element); this.setState({
elements.push(element); draggingElement: null,
elementType: "selection"
this.setState({ });
draggingElement: null,
elementType: "selection"
}); });
element.isSelected = true;
this.forceUpdate();
}} }}
/> />
</div> </div>
@ -926,6 +1005,40 @@ class App extends React.Component<{}, AppState> {
saveToURL(elements, this.state); saveToURL(elements, this.state);
}, 300); }, 300);
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() { componentDidUpdate() {
renderScene(elements, rc, canvas, { renderScene(elements, rc, canvas, {
scrollX: this.state.scrollX, scrollX: this.state.scrollX,

View file

@ -149,7 +149,8 @@ button {
border-color: #d6d4d4; border-color: #d6d4d4;
} }
&:active, &.active { &:active,
&.active {
background-color: #bdbebc; background-color: #bdbebc;
border-color: #bdbebc; border-color: #bdbebc;
} }