mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
Merge branch 'master' into windows-shortcuts
This commit is contained in:
commit
d557920347
7 changed files with 298 additions and 32 deletions
|
@ -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
|
|||
|
||||
<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
|
||||
|
||||
|
|
|
@ -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 ? (
|
||||
<div className="popover">
|
||||
<div className="cover" onClick={() => setActive(false)} />
|
||||
<Popover onCloseRequest={() => setActive(false)}>
|
||||
<TwitterPicker
|
||||
colors={[
|
||||
"#000000",
|
||||
|
@ -39,12 +39,13 @@ export function ColorPicker({
|
|||
onChange(changedColor.hex);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Popover>
|
||||
) : null}
|
||||
<input
|
||||
type="text"
|
||||
className="swatch-input"
|
||||
value={color || ""}
|
||||
onPaste={e => onChange(e.clipboardData.getData("text"))}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
|
34
src/components/ContextMenu.css
Normal file
34
src/components/ContextMenu.css
Normal 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;
|
||||
}
|
85
src/components/ContextMenu.tsx
Normal file
85
src/components/ContextMenu.tsx
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
24
src/components/Popover.tsx
Normal file
24
src/components/Popover.tsx
Normal 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>
|
||||
);
|
||||
}
|
165
src/index.tsx
165
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;
|
||||
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;
|
||||
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;
|
||||
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,
|
||||
|
|
|
@ -149,7 +149,8 @@ button {
|
|||
border-color: #d6d4d4;
|
||||
}
|
||||
|
||||
&:active, &.active {
|
||||
&:active,
|
||||
&.active {
|
||||
background-color: #bdbebc;
|
||||
border-color: #bdbebc;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue