Merge branch 'master' into paste-in-center

This commit is contained in:
Faustino Kialungila 2020-01-09 10:29:46 +01:00
commit 4150589e08
36 changed files with 642 additions and 334 deletions

1
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1 @@
open_collective: excalidraw

3
.gitignore vendored
View file

@ -18,3 +18,6 @@ yarn.lock
.vscode/
.DS_Store
# build files
static/

View file

@ -17,6 +17,9 @@ Go to https://www.excalidraw.com to start sketching
<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>
<a href="https://twitter.com/jordwalke/status/1214858186789806080"><img width="434" src="https://user-images.githubusercontent.com/197597/72036874-07a1b780-3251-11ea-99e8-6bafd93483a0.png"></a>
<a href="https://twitter.com/lucasazzola/status/1215126440330416128"><img width="429" src="https://user-images.githubusercontent.com/197597/72039003-48e99580-3258-11ea-8daa-85dd055f2a82.png">
## Run the code

14
package-lock.json generated
View file

@ -1473,6 +1473,7 @@
"version": "24.0.25",
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-24.0.25.tgz",
"integrity": "sha512-hnP1WpjN4KbGEK4dLayul6lgtys6FPz0UfxMeMQCv0M+sTnzN3ConfiO72jHgLxl119guHgI8gLqDOrRLsyp2g==",
"dev": true,
"requires": {
"jest-diff": "^24.3.0"
}
@ -1487,6 +1488,14 @@
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
"integrity": "sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA=="
},
"@types/nanoid": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/nanoid/-/nanoid-2.1.0.tgz",
"integrity": "sha512-xdkn/oRTA0GSNPLIKZgHWqDTWZsVrieKomxJBOQUK9YDD+zfSgmwD5t4WJYra5S7XyhTw7tfvwznW+pFexaepQ==",
"requires": {
"@types/node": "*"
}
},
"@types/node": {
"version": "13.1.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-13.1.2.tgz",
@ -9848,6 +9857,11 @@
"resolved": "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz",
"integrity": "sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg=="
},
"nanoid": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-2.1.9.tgz",
"integrity": "sha512-J2X7aUpdmTlkAuSe9WaQ5DsTZZPW1r/zmEWKsGhbADO6Gm9FMd2ZzJ8NhsmP4OtA9oFhXfxNqPlreHEDOGB4sg=="
},
"nanomatch": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz",

View file

@ -6,6 +6,7 @@
"keywords": [],
"main": "src/index.js",
"dependencies": {
"nanoid": "^2.1.9",
"react": "16.12.0",
"react-color": "^2.17.3",
"react-dom": "16.12.0",
@ -14,6 +15,7 @@
},
"devDependencies": {
"@types/jest": "^24.0.25",
"@types/nanoid": "^2.1.0",
"@types/react": "16.9.17",
"@types/react-color": "^3.0.1",
"@types/react-dom": "16.9.4",
@ -49,7 +51,7 @@
"git add"
],
"*.{js,ts,tsx}": [
"eslint"
"eslint --max-warnings 0"
]
}
}

43
src/components/Panel.tsx Normal file
View file

@ -0,0 +1,43 @@
import React, { useState } from "react";
interface PanelProps {
title: string;
defaultCollapsed?: boolean;
hide?: boolean;
}
export const Panel: React.FC<PanelProps> = ({
title,
children,
defaultCollapsed = false,
hide = false
}) => {
const [collapsed, setCollapsed] = useState(defaultCollapsed);
if (hide) return null;
return (
<div className="panel">
<h4>{title}</h4>
<button
className="btn-panel-collapse"
type="button"
onClick={e => {
e.preventDefault();
setCollapsed(collapsed => !collapsed);
}}
>
{
<span
className={`btn-panel-collapse-icon ${
collapsed ? "btn-panel-collapse-icon-closed" : ""
}`}
>
</span>
}
</button>
{!collapsed && <div className="panelColumn">{children}</div>}
</div>
);
};

View file

@ -1,6 +1,7 @@
import React from "react";
import { ColorPicker } from "../ColorPicker";
import { Panel } from "../Panel";
interface PanelCanvasProps {
viewBackgroundColor: string;
@ -14,22 +15,19 @@ export const PanelCanvas: React.FC<PanelCanvasProps> = ({
onClearCanvas
}) => {
return (
<>
<h4>Canvas</h4>
<div className="panelColumn">
<h5>Canvas Background Color</h5>
<ColorPicker
color={viewBackgroundColor}
onChange={color => onViewBackgroundColorChange(color)}
/>
<button
type="button"
onClick={onClearCanvas}
title="Clear the canvas & reset background color"
>
Clear canvas
</button>
</div>
</>
<Panel title="Canvas">
<h5>Canvas Background Color</h5>
<ColorPicker
color={viewBackgroundColor}
onChange={color => onViewBackgroundColorChange(color)}
/>
<button
type="button"
onClick={onClearCanvas}
title="Clear the canvas & reset background color"
>
Clear canvas
</button>
</Panel>
);
};

View file

@ -1,5 +1,6 @@
import React from "react";
import { EditableText } from "../EditableText";
import { Panel } from "../Panel";
interface PanelExportProps {
projectName: string;
@ -21,8 +22,7 @@ export const PanelExport: React.FC<PanelExportProps> = ({
onExportAsPNG
}) => {
return (
<>
<h4>Export</h4>
<Panel title="Export">
<div className="panelColumn">
<h5>Name</h5>
{projectName && (
@ -47,6 +47,6 @@ export const PanelExport: React.FC<PanelExportProps> = ({
<button onClick={onSaveScene}>Save as...</button>
<button onClick={onLoadScene}>Load file...</button>
</div>
</>
</Panel>
);
};

View file

@ -14,8 +14,7 @@ export const PanelSelection: React.FC<PanelSelectionProps> = ({
onSendToBack
}) => {
return (
<>
<h4>Selection</h4>
<div>
<div className="buttonList">
<button type="button" onClick={onBringForward}>
Bring forward
@ -30,6 +29,6 @@ export const PanelSelection: React.FC<PanelSelectionProps> = ({
Send to back
</button>
</div>
</>
</div>
);
};

View file

@ -2,6 +2,7 @@ import React from "react";
import { SHAPES } from "../../shapes";
import { capitalizeString } from "../../utils";
import { Panel } from "../Panel";
interface PanelToolsProps {
activeTool: string;
@ -13,8 +14,7 @@ export const PanelTools: React.FC<PanelToolsProps> = ({
onToolChange
}) => {
return (
<>
<h4>Shapes</h4>
<Panel title="Shapes">
<div className="panelTools">
{SHAPES.map(({ value, icon }) => (
<label
@ -33,6 +33,6 @@ export const PanelTools: React.FC<PanelToolsProps> = ({
</label>
))}
</div>
</>
</Panel>
);
};

View file

@ -1,9 +1,11 @@
import { SceneState } from "../scene/types";
import { ExcalidrawElement } from "./types";
import { SceneScroll } from "../scene/types";
type Sides = "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se";
export function handlerRectangles(
element: ExcalidrawElement,
sceneState: SceneState
{ scrollX, scrollY }: SceneScroll
) {
const elementX1 = element.x;
const elementX2 = element.x + element.width;
@ -12,22 +14,22 @@ export function handlerRectangles(
const margin = 4;
const minimumSize = 40;
const handlers: { [handler: string]: number[] } = {};
const handlers = {} as { [T in Sides]: number[] };
const marginX = element.width < 0 ? 8 : -8;
const marginY = element.height < 0 ? 8 : -8;
if (Math.abs(elementX2 - elementX1) > minimumSize) {
handlers["n"] = [
elementX1 + (elementX2 - elementX1) / 2 + sceneState.scrollX - 4,
elementY1 - margin + sceneState.scrollY + marginY,
elementX1 + (elementX2 - elementX1) / 2 + scrollX - 4,
elementY1 - margin + scrollY + marginY,
8,
8
];
handlers["s"] = [
elementX1 + (elementX2 - elementX1) / 2 + sceneState.scrollX - 4,
elementY2 - margin + sceneState.scrollY - marginY,
elementX1 + (elementX2 - elementX1) / 2 + scrollX - 4,
elementY2 - margin + scrollY - marginY,
8,
8
];
@ -35,41 +37,41 @@ export function handlerRectangles(
if (Math.abs(elementY2 - elementY1) > minimumSize) {
handlers["w"] = [
elementX1 - margin + sceneState.scrollX + marginX,
elementY1 + (elementY2 - elementY1) / 2 + sceneState.scrollY - 4,
elementX1 - margin + scrollX + marginX,
elementY1 + (elementY2 - elementY1) / 2 + scrollY - 4,
8,
8
];
handlers["e"] = [
elementX2 - margin + sceneState.scrollX - marginX,
elementY1 + (elementY2 - elementY1) / 2 + sceneState.scrollY - 4,
elementX2 - margin + scrollX - marginX,
elementY1 + (elementY2 - elementY1) / 2 + scrollY - 4,
8,
8
];
}
handlers["nw"] = [
elementX1 - margin + sceneState.scrollX + marginX,
elementY1 - margin + sceneState.scrollY + marginY,
elementX1 - margin + scrollX + marginX,
elementY1 - margin + scrollY + marginY,
8,
8
]; // nw
handlers["ne"] = [
elementX2 - margin + sceneState.scrollX - marginX,
elementY1 - margin + sceneState.scrollY + marginY,
elementX2 - margin + scrollX - marginX,
elementY1 - margin + scrollY + marginY,
8,
8
]; // ne
handlers["sw"] = [
elementX1 - margin + sceneState.scrollX + marginX,
elementY2 - margin + sceneState.scrollY - marginY,
elementX1 - margin + scrollX + marginX,
elementY2 - margin + scrollY - marginY,
8,
8
]; // sw
handlers["se"] = [
elementX2 - margin + sceneState.scrollX - marginX,
elementY2 - margin + sceneState.scrollY - marginY,
elementX2 - margin + scrollX - marginX,
elementY2 - margin + scrollY - marginY,
8,
8
]; // se
@ -78,7 +80,7 @@ export function handlerRectangles(
return {
nw: handlers.nw,
se: handlers.se
};
} as typeof handlers;
}
return handlers;

View file

@ -1,4 +1,4 @@
export { newElement } from "./newElement";
export { newElement, duplicateElement } from "./newElement";
export {
getElementAbsoluteCoords,
getDiamondPoints,

View file

@ -1,4 +1,5 @@
import { randomSeed } from "../random";
import nanoid from "nanoid";
export function newElement(
type: string,
@ -14,6 +15,7 @@ export function newElement(
height = 0
) {
const element = {
id: nanoid(),
type,
x,
y,
@ -30,3 +32,10 @@ export function newElement(
};
return element;
}
export function duplicateElement(element: ReturnType<typeof newElement>) {
const copy = { ...element };
copy.id = nanoid();
copy.seed = randomSeed();
return copy;
}

View file

@ -1,32 +1,51 @@
import { ExcalidrawElement } from "./types";
import { SceneState } from "../scene/types";
import { handlerRectangles } from "./handlerRectangles";
import { SceneScroll } from "../scene/types";
type HandlerRectanglesRet = keyof ReturnType<typeof handlerRectangles>;
export function resizeTest(
element: ExcalidrawElement,
x: number,
y: number,
sceneState: SceneState
): string | false {
if (element.type === "text") return false;
{ scrollX, scrollY }: SceneScroll
): HandlerRectanglesRet | false {
if (!element.isSelected || element.type === "text") return false;
const handlers = handlerRectangles(element, sceneState);
const handlers = handlerRectangles(element, { scrollX, scrollY });
const filter = Object.keys(handlers).filter(key => {
const handler = handlers[key];
const handler = handlers[key as HandlerRectanglesRet]!;
return (
x + sceneState.scrollX >= handler[0] &&
x + sceneState.scrollX <= handler[0] + handler[2] &&
y + sceneState.scrollY >= handler[1] &&
y + sceneState.scrollY <= handler[1] + handler[3]
x + scrollX >= handler[0] &&
x + scrollX <= handler[0] + handler[2] &&
y + scrollY >= handler[1] &&
y + scrollY <= handler[1] + handler[3]
);
});
if (filter.length > 0) {
return filter[0];
return filter[0] as HandlerRectanglesRet;
}
return false;
}
export function getElementWithResizeHandler(
elements: ExcalidrawElement[],
{ x, y }: { x: number; y: number },
{ scrollX, scrollY }: SceneScroll
) {
return elements.reduce((result, element) => {
if (result) {
return result;
}
const resizeHandle = resizeTest(element, x, y, {
scrollX,
scrollY
});
return resizeHandle ? { element, resizeHandle } : null;
}, null as { element: ExcalidrawElement; resizeHandle: ReturnType<typeof resizeTest> } | null);
}

View file

@ -1,4 +1,4 @@
import { KEYS } from "../index";
import { KEYS } from "../keys";
type TextWysiwygParams = {
initText: string;
@ -17,25 +17,37 @@ export function textWysiwyg({
font,
onSubmit
}: TextWysiwygParams) {
const input = document.createElement("input");
input.value = initText;
Object.assign(input.style, {
// Using contenteditable here as it has dynamic width.
// But this solution has an issue — it allows to paste
// multiline text, which is not currently supported
const editable = document.createElement("div");
editable.contentEditable = "plaintext-only";
editable.tabIndex = 0;
editable.innerText = initText;
editable.dataset.type = "wysiwyg";
Object.assign(editable.style, {
color: strokeColor,
position: "absolute",
top: y - 8 + "px",
top: y + "px",
left: x + "px",
transform: "translate(-50%, -50%)",
boxShadow: "none",
textAlign: "center",
width: (window.innerWidth - x) * 2 + "px",
display: "inline-block",
font: font,
border: "none",
background: "transparent"
padding: "4px",
outline: "transparent",
whiteSpace: "nowrap"
});
input.onkeydown = ev => {
editable.onkeydown = ev => {
if (ev.key === KEYS.ESCAPE) {
ev.preventDefault();
if (initText) {
editable.innerText = initText;
handleSubmit();
return;
}
cleanup();
return;
}
@ -44,28 +56,34 @@ export function textWysiwyg({
handleSubmit();
}
};
input.onblur = handleSubmit;
editable.onblur = handleSubmit;
function stopEvent(ev: Event) {
ev.stopPropagation();
}
function handleSubmit() {
if (input.value) {
onSubmit(input.value);
if (editable.innerText) {
onSubmit(editable.innerText);
}
cleanup();
}
function cleanup() {
input.onblur = null;
input.onkeydown = null;
editable.onblur = null;
editable.onkeydown = null;
window.removeEventListener("wheel", stopEvent, true);
document.body.removeChild(input);
document.body.removeChild(editable);
}
window.addEventListener("wheel", stopEvent, true);
document.body.appendChild(input);
input.focus();
input.select();
document.body.appendChild(editable);
editable.focus();
const selection = window.getSelection();
if (selection) {
const range = document.createRange();
range.selectNodeContents(editable);
selection.removeAllRanges();
selection.addRange(range);
}
}

View file

@ -5,5 +5,7 @@ export type ExcalidrawTextElement = ExcalidrawElement & {
type: "text";
font: string;
text: string;
actualBoundingBoxAscent: number;
// for backward compatibility
actualBoundingBoxAscent?: number;
baseline: number;
};

View file

@ -1,11 +1,13 @@
import React from "react";
import ReactDOM from "react-dom";
import rough from "roughjs/bin/wrappers/rough";
import { RoughCanvas } from "roughjs/bin/canvas";
import { moveOneLeft, moveAllLeft, moveOneRight, moveAllRight } from "./zindex";
import { randomSeed } from "./random";
import {
newElement,
duplicateElement,
resizeTest,
isTextElement,
textWysiwyg,
@ -27,61 +29,42 @@ import {
hasBackground,
hasStroke,
getElementAtPosition,
createScene
createScene,
getElementContainingPosition,
hasText
} from "./scene";
import { renderScene } from "./renderer";
import { AppState } from "./types";
import { ExcalidrawElement, ExcalidrawTextElement } from "./element/types";
import { getDateTime, isInputLike } from "./utils";
import { getDateTime, isInputLike, measureText } from "./utils";
import { KEYS, META_KEY, isArrowKey } from "./keys";
import { ButtonSelect } from "./components/ButtonSelect";
import { findShapeByKey, shapesShortcutKeys } from "./shapes";
import { createHistory } from "./history";
import "./styles.scss";
import ContextMenu from "./components/ContextMenu";
import { PanelTools } from "./components/panels/PanelTools";
import { PanelSelection } from "./components/panels/PanelSelection";
import { PanelColor } from "./components/panels/PanelColor";
import { PanelExport } from "./components/panels/PanelExport";
import { PanelCanvas } from "./components/panels/PanelCanvas";
import { Panel } from "./components/Panel";
import "./styles.scss";
import { getElementWithResizeHandler } from "./element/resizeTest";
const { elements } = createScene();
const { history } = createHistory();
const DEFAULT_PROJECT_NAME = `excalidraw-${getDateTime()}`;
const CANVAS_WINDOW_OFFSET_LEFT = 250;
const CANVAS_WINDOW_OFFSET_TOP = 0;
export const KEYS = {
ARROW_LEFT: "ArrowLeft",
ARROW_RIGHT: "ArrowRight",
ARROW_DOWN: "ArrowDown",
ARROW_UP: "ArrowUp",
ENTER: "Enter",
ESCAPE: "Escape",
DELETE: "Delete",
BACKSPACE: "Backspace"
};
const META_KEY = /Mac|iPod|iPhone|iPad/.test(window.navigator.platform)
? "metaKey"
: "ctrlKey";
let copiedStyles: string = "{}";
function isArrowKey(keyCode: string) {
return (
keyCode === KEYS.ARROW_LEFT ||
keyCode === KEYS.ARROW_RIGHT ||
keyCode === KEYS.ARROW_DOWN ||
keyCode === KEYS.ARROW_UP
);
}
function resetCursor() {
document.documentElement.style.cursor = "";
}
@ -95,36 +78,42 @@ function addTextElement(
if (text === null || text === "") {
return false;
}
const metrics = measureText(text, font);
element.text = text;
element.font = font;
const currentFont = context.font;
context.font = element.font;
const textMeasure = context.measureText(element.text);
const width = textMeasure.width;
const actualBoundingBoxAscent =
textMeasure.actualBoundingBoxAscent || parseInt(font);
const actualBoundingBoxDescent = textMeasure.actualBoundingBoxDescent || 0;
element.actualBoundingBoxAscent = actualBoundingBoxAscent;
context.font = currentFont;
const height = actualBoundingBoxAscent + actualBoundingBoxDescent;
// Center the text
element.x -= width / 2;
element.y -= actualBoundingBoxAscent;
element.width = width;
element.height = height;
element.x -= metrics.width / 2;
element.y -= metrics.height / 2;
element.width = metrics.width;
element.height = metrics.height;
element.baseline = metrics.baseline;
return true;
}
const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
const ELEMENT_TRANSLATE_AMOUNT = 1;
const TEXT_TO_CENTER_SNAP_THRESHOLD = 30;
let lastCanvasWidth = -1;
let lastCanvasHeight = -1;
let lastMouseUp: ((e: any) => void) | null = null;
class App extends React.Component<{}, AppState> {
export function viewportCoordsToSceneCoords(
{ clientX, clientY }: { clientX: number; clientY: number },
{ scrollX, scrollY }: { scrollX: number; scrollY: number }
) {
const x = clientX - CANVAS_WINDOW_OFFSET_LEFT - scrollX;
const y = clientY - CANVAS_WINDOW_OFFSET_TOP - scrollY;
return { x, y };
}
export class App extends React.Component<{}, AppState> {
canvas: HTMLCanvasElement | null = null;
rc: RoughCanvas | null = null;
public componentDidMount() {
document.addEventListener("keydown", this.onKeyDown, false);
document.addEventListener("mousemove", this.getCurrentCursorPosition);
@ -287,6 +276,10 @@ class App extends React.Component<{}, AppState> {
element.fillStyle = pastedElement?.fillStyle;
element.opacity = pastedElement?.opacity;
element.roughness = pastedElement?.roughness;
if (isTextElement(element)) {
element.font = pastedElement?.font;
this.redrawTextBoundingBox(element);
}
}
});
this.forceUpdate();
@ -359,6 +352,14 @@ class App extends React.Component<{}, AppState> {
}
};
private redrawTextBoundingBox = (element: ExcalidrawTextElement) => {
const metrics = measureText(element.text, element.font);
element.width = metrics.width;
element.height = metrics.height;
element.baseline = metrics.baseline;
this.forceUpdate();
};
public render() {
const canvasWidth = window.innerWidth - CANVAS_WINDOW_OFFSET_LEFT;
const canvasHeight = window.innerHeight - CANVAS_WINDOW_OFFSET_TOP;
@ -399,112 +400,162 @@ class App extends React.Component<{}, AppState> {
this.forceUpdate();
}}
/>
{someElementIsSelected(elements) && (
<div className="panelColumn">
<PanelSelection
onBringForward={this.moveOneRight}
onBringToFront={this.moveAllRight}
onSendBackward={this.moveOneLeft}
onSendToBack={this.moveAllLeft}
/>
<Panel title="Selection" hide={!someElementIsSelected(elements)}>
<PanelSelection
onBringForward={this.moveOneRight}
onBringToFront={this.moveAllRight}
onSendBackward={this.moveOneLeft}
onSendToBack={this.moveAllLeft}
/>
<PanelColor
title="Stroke Color"
onColorChange={this.changeStrokeColor}
colorValue={getSelectedAttribute(
elements,
element => element.strokeColor
)}
/>
{hasBackground(elements) && (
<>
<PanelColor
title="Background Color"
onColorChange={this.changeBackgroundColor}
colorValue={getSelectedAttribute(
elements,
element => element.backgroundColor
)}
/>
<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 => {
this.changeProperty(element => {
element.fillStyle = value;
});
}}
/>
</>
<PanelColor
title="Stroke Color"
onColorChange={this.changeStrokeColor}
colorValue={getSelectedAttribute(
elements,
element => element.strokeColor
)}
/>
{hasStroke(elements) && (
<>
<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 => {
this.changeProperty(element => {
element.strokeWidth = value;
});
}}
/>
{hasBackground(elements) && (
<>
<PanelColor
title="Background Color"
onColorChange={this.changeBackgroundColor}
colorValue={getSelectedAttribute(
elements,
element => element.backgroundColor
)}
/>
<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 =>
this.changeProperty(element => {
element.roughness = value;
})
}
/>
</>
)}
<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 => {
this.changeProperty(element => {
element.fillStyle = value;
});
}}
/>
</>
)}
<h5>Opacity</h5>
<input
type="range"
min="0"
max="100"
onChange={this.changeOpacity}
value={
getSelectedAttribute(elements, element => element.opacity) ||
0 /* Put the opacity at 0 if there are two conflicting ones */
}
/>
{hasStroke(elements) && (
<>
<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 => {
this.changeProperty(element => {
element.strokeWidth = value;
});
}}
/>
<button onClick={this.deleteSelectedElements}>
Delete selected
</button>
</div>
)}
<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 =>
this.changeProperty(element => {
element.roughness = value;
})
}
/>
</>
)}
{hasText(elements) && (
<>
<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 =>
this.changeProperty(element => {
if (isTextElement(element)) {
element.font = `${value}px ${
element.font.split("px ")[1]
}`;
this.redrawTextBoundingBox(element);
}
})
}
/>
<h5>Font familly</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 =>
this.changeProperty(element => {
if (isTextElement(element)) {
element.font = `${
element.font.split("px ")[0]
}px ${value}`;
this.redrawTextBoundingBox(element);
}
})
}
/>
</>
)}
<h5>Opacity</h5>
<input
type="range"
min="0"
max="100"
onChange={this.changeOpacity}
value={
getSelectedAttribute(elements, element => element.opacity) ||
0 /* Put the opacity at 0 if there are two conflicting ones */
}
/>
<button onClick={this.deleteSelectedElements}>
Delete selected
</button>
</Panel>
<PanelCanvas
onClearCanvas={this.clearCanvas}
onViewBackgroundColorChange={val =>
@ -515,7 +566,9 @@ class App extends React.Component<{}, AppState> {
<PanelExport
projectName={this.state.name}
onProjectNameChange={this.updateProjectName}
onExportAsPNG={() => exportAsPNG(elements, canvas, this.state)}
onExportAsPNG={() =>
exportAsPNG(elements, this.canvas!, this.state)
}
exportBackground={this.state.exportBackground}
onExportBackgroundChange={val =>
this.setState({ exportBackground: val })
@ -535,6 +588,10 @@ class App extends React.Component<{}, AppState> {
width={canvasWidth * window.devicePixelRatio}
height={canvasHeight * window.devicePixelRatio}
ref={canvas => {
if (this.canvas === null) {
this.canvas = canvas;
this.rc = rough.canvas(this.canvas!);
}
if (this.removeWheelEventListener) {
this.removeWheelEventListener();
this.removeWheelEventListener = undefined;
@ -563,9 +620,7 @@ 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 { x, y } = viewportCoordsToSceneCoords(e, this.state);
const element = getElementAtPosition(elements, x, y);
if (!element) {
@ -642,9 +697,8 @@ class App extends React.Component<{}, AppState> {
this.state.scrollY
);
const x =
e.clientX - CANVAS_WINDOW_OFFSET_LEFT - this.state.scrollX;
const y = e.clientY - CANVAS_WINDOW_OFFSET_TOP - this.state.scrollY;
const { x, y } = viewportCoordsToSceneCoords(e, this.state);
const element = newElement(
this.state.elementType,
x,
@ -656,28 +710,23 @@ class App extends React.Component<{}, AppState> {
1,
100
);
let resizeHandle: string | false = false;
type ResizeTestType = ReturnType<typeof resizeTest>;
let resizeHandle: ResizeTestType = false;
let isDraggingElements = false;
let isResizingElements = false;
if (this.state.elementType === "selection") {
const resizeElement = elements.find(element => {
return resizeTest(element, x, y, {
scrollX: this.state.scrollX,
scrollY: this.state.scrollY,
viewBackgroundColor: this.state.viewBackgroundColor
});
});
const resizeElement = getElementWithResizeHandler(
elements,
{ x, y },
this.state
);
this.setState({
resizingElement: resizeElement ? resizeElement : null
resizingElement: resizeElement ? resizeElement.element : null
});
if (resizeElement) {
resizeHandle = resizeTest(resizeElement, x, y, {
scrollX: this.state.scrollX,
scrollY: this.state.scrollY,
viewBackgroundColor: this.state.viewBackgroundColor
});
resizeHandle = resizeElement.resizeHandle;
document.documentElement.style.cursor = `${resizeHandle}-resize`;
isResizingElements = true;
} else {
@ -686,15 +735,27 @@ class App extends React.Component<{}, AppState> {
// If we click on something
if (hitElement) {
if (hitElement.isSelected) {
// If that element is not already selected, do nothing,
// If that element is already selected, do nothing,
// we're likely going to drag it
} else {
// We unselect every other elements unless shift is pressed
if (!e.shiftKey) {
clearSelection(elements);
}
// No matter what, we select it
hitElement.isSelected = true;
}
// No matter what, we select it
hitElement.isSelected = true;
// We duplicate the selected element if alt is pressed on Mouse down
if (e.altKey) {
elements.push(
...elements.reduce((duplicates, element) => {
if (element.isSelected) {
duplicates.push(duplicateElement(element));
element.isSelected = false;
}
return duplicates;
}, [] as typeof elements)
);
}
} else {
// If we don't click on anything, let's remove all the selected elements
@ -710,10 +771,25 @@ class App extends React.Component<{}, AppState> {
}
if (isTextElement(element)) {
let textX = e.clientX;
let textY = e.clientY;
if (!e.altKey) {
const snappedToCenterPosition = this.getTextWysiwygSnappedToCenterPosition(
x,
y
);
if (snappedToCenterPosition) {
element.x = snappedToCenterPosition.elementCenterX;
element.y = snappedToCenterPosition.elementCenterY;
textX = snappedToCenterPosition.wysiwygX;
textY = snappedToCenterPosition.wysiwygY;
}
}
textWysiwyg({
initText: "",
x: e.clientX,
y: e.clientY,
x: textX,
y: textY,
strokeColor: this.state.currentItemStrokeColor,
font: this.state.currentItemFont,
onSubmit: text => {
@ -774,10 +850,8 @@ class App extends React.Component<{}, AppState> {
const el = this.state.resizingElement;
const selectedElements = elements.filter(el => el.isSelected);
if (selectedElements.length === 1) {
const x =
e.clientX - CANVAS_WINDOW_OFFSET_LEFT - this.state.scrollX;
const y =
e.clientY - CANVAS_WINDOW_OFFSET_TOP - this.state.scrollY;
const { x, y } = viewportCoordsToSceneCoords(e, this.state);
selectedElements.forEach(element => {
switch (resizeHandle) {
case "nw":
@ -849,10 +923,8 @@ class App extends React.Component<{}, AppState> {
if (isDraggingElements) {
const selectedElements = elements.filter(el => el.isSelected);
if (selectedElements.length) {
const x =
e.clientX - CANVAS_WINDOW_OFFSET_LEFT - this.state.scrollX;
const y =
e.clientY - CANVAS_WINDOW_OFFSET_TOP - this.state.scrollY;
const { x, y } = viewportCoordsToSceneCoords(e, this.state);
selectedElements.forEach(element => {
element.x += x - lastX;
element.y += y - lastY;
@ -936,16 +1008,9 @@ class App extends React.Component<{}, AppState> {
this.forceUpdate();
}}
onDoubleClick={e => {
const x =
e.clientX - CANVAS_WINDOW_OFFSET_LEFT - this.state.scrollX;
const y = e.clientY - CANVAS_WINDOW_OFFSET_TOP - this.state.scrollY;
const { x, y } = viewportCoordsToSceneCoords(e, this.state);
const elementAtPosition = getElementAtPosition(elements, x, y);
if (elementAtPosition && !isTextElement(elementAtPosition)) {
return;
} else if (elementAtPosition) {
elements.splice(elements.indexOf(elementAtPosition), 1);
this.forceUpdate();
}
const element = newElement(
"text",
@ -962,12 +1027,15 @@ class App extends React.Component<{}, AppState> {
let initText = "";
let textX = e.clientX;
let textY = e.clientY;
if (elementAtPosition) {
if (elementAtPosition && isTextElement(elementAtPosition)) {
elements.splice(elements.indexOf(elementAtPosition), 1);
this.forceUpdate();
Object.assign(element, elementAtPosition);
// x and y will change after calling addTextElement function
element.x = elementAtPosition.x + elementAtPosition.width / 2;
element.y =
elementAtPosition.y + elementAtPosition.actualBoundingBoxAscent;
element.y = elementAtPosition.y + elementAtPosition.height / 2;
initText = elementAtPosition.text;
textX =
this.state.scrollX +
@ -978,7 +1046,19 @@ class App extends React.Component<{}, AppState> {
this.state.scrollY +
elementAtPosition.y +
CANVAS_WINDOW_OFFSET_TOP +
elementAtPosition.actualBoundingBoxAscent;
elementAtPosition.height / 2;
} else if (!e.altKey) {
const snappedToCenterPosition = this.getTextWysiwygSnappedToCenterPosition(
x,
y
);
if (snappedToCenterPosition) {
element.x = snappedToCenterPosition.elementCenterX;
element.y = snappedToCenterPosition.elementCenterY;
textX = snappedToCenterPosition.wysiwygX;
textY = snappedToCenterPosition.wysiwygY;
}
}
textWysiwyg({
@ -1002,6 +1082,34 @@ class App extends React.Component<{}, AppState> {
}
});
}}
onMouseMove={e => {
const hasDeselectedButton = Boolean(e.buttons);
if (hasDeselectedButton || this.state.elementType !== "selection") {
return;
}
const { x, y } = viewportCoordsToSceneCoords(e, this.state);
const resizeElement = getElementWithResizeHandler(
elements,
{ x, y },
this.state
);
if (resizeElement && resizeElement.resizeHandle) {
document.documentElement.style.cursor = `${resizeElement.resizeHandle}-resize`;
return;
}
const hitElement = getElementAtPosition(elements, x, y);
if (hitElement) {
const resizeHandle = resizeTest(hitElement, x, y, {
scrollX: this.state.scrollX,
scrollY: this.state.scrollY
});
document.documentElement.style.cursor = resizeHandle
? `${resizeHandle}-resize`
: `move`;
} else {
document.documentElement.style.cursor = ``;
}
}}
/>
</div>
);
@ -1028,14 +1136,14 @@ class App extends React.Component<{}, AppState> {
) {
clearSelection(elements);
const minX = Math.min(...parsedElements.map(element => element.x));
const minY = Math.min(...parsedElements.map(element => element.y));
let subCanvasX1 = Infinity;
let subCanvasX2 = 0;
let subCanvasY1 = Infinity;
let subCanvasY2 = 0;
const minX = Math.min(...parsedElements.map(element => element.x));
const minY = Math.min(...parsedElements.map(element => element.y));
const distance = (x: number, y: number) => {
return Math.abs(x > y ? x - y : y - x);
};
@ -1054,27 +1162,56 @@ class App extends React.Component<{}, AppState> {
const dx =
this.state.cursorX -
this.state.scrollX -
canvas.offsetLeft -
CANVAS_WINDOW_OFFSET_LEFT -
elementsWidth;
const dy =
this.state.cursorY -
this.state.scrollY -
canvas.offsetTop -
CANVAS_WINDOW_OFFSET_TOP -
elementsHeight;
parsedElements.forEach(parsedElement => {
parsedElement.x += dx - minX;
parsedElement.y += dy - minY;
parsedElement.seed = randomSeed();
elements.push(parsedElement);
const duplicate = duplicateElement(parsedElement);
duplicate.x += dx - minX;
duplicate.y += dy - minY;
elements.push(duplicate);
});
this.forceUpdate();
}
};
private getTextWysiwygSnappedToCenterPosition(x: number, y: number) {
const elementClickedInside = getElementContainingPosition(elements, x, y);
if (elementClickedInside) {
const elementCenterX =
elementClickedInside.x + elementClickedInside.width / 2;
const elementCenterY =
elementClickedInside.y + elementClickedInside.height / 2;
const distanceToCenter = Math.hypot(
x - elementCenterX,
y - elementCenterY
);
const isSnappedToCenter =
distanceToCenter < TEXT_TO_CENTER_SNAP_THRESHOLD;
if (isSnappedToCenter) {
const wysiwygX =
this.state.scrollX +
elementClickedInside.x +
CANVAS_WINDOW_OFFSET_LEFT +
elementClickedInside.width / 2;
const wysiwygY =
this.state.scrollY +
elementClickedInside.y +
CANVAS_WINDOW_OFFSET_TOP +
elementClickedInside.height / 2;
return { wysiwygX, wysiwygY, elementCenterX, elementCenterY };
}
}
}
componentDidUpdate() {
renderScene(elements, rc, canvas, {
renderScene(elements, this.rc!, this.canvas!, {
scrollX: this.state.scrollX,
scrollY: this.state.scrollY,
viewBackgroundColor: this.state.viewBackgroundColor
@ -1090,8 +1227,3 @@ class App extends React.Component<{}, AppState> {
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
const canvas = document.getElementById("canvas") as HTMLCanvasElement;
const rc = rough.canvas(canvas);
const context = canvas.getContext("2d")!;
ReactDOM.render(<App />, rootElement);

23
src/keys.ts Normal file
View file

@ -0,0 +1,23 @@
export const KEYS = {
ARROW_LEFT: "ArrowLeft",
ARROW_RIGHT: "ArrowRight",
ARROW_DOWN: "ArrowDown",
ARROW_UP: "ArrowUp",
ENTER: "Enter",
ESCAPE: "Escape",
DELETE: "Delete",
BACKSPACE: "Backspace"
};
export const META_KEY = /Mac|iPod|iPhone|iPad/.test(window.navigator.platform)
? "metaKey"
: "ctrlKey";
export function isArrowKey(keyCode: string) {
return (
keyCode === KEYS.ARROW_LEFT ||
keyCode === KEYS.ARROW_RIGHT ||
keyCode === KEYS.ARROW_DOWN ||
keyCode === KEYS.ARROW_UP
);
}

View file

@ -131,7 +131,9 @@ export function renderElement(
context.fillText(
element.text,
element.x + scrollX,
element.y + element.actualBoundingBoxAscent + scrollY
element.y +
scrollY +
(element.baseline || element.actualBoundingBoxAscent || 0)
);
context.fillStyle = fillStyle;
context.font = font;

View file

@ -3,7 +3,7 @@ import { RoughCanvas } from "roughjs/bin/canvas";
import { ExcalidrawElement } from "../element/types";
import { getElementAbsoluteCoords, handlerRectangles } from "../element";
import { roundRect } from "../scene/roundRect";
import { roundRect } from "./roundRect";
import { SceneState } from "../scene/types";
import {
getScrollBars,

View file

@ -1,5 +1,6 @@
import { ExcalidrawElement } from "../element/types";
import { hitTest } from "../element/collision";
import { getElementAbsoluteCoords } from "../element";
export const hasBackground = (elements: ExcalidrawElement[]) =>
elements.some(
@ -20,6 +21,9 @@ export const hasStroke = (elements: ExcalidrawElement[]) =>
element.type === "arrow")
);
export const hasText = (elements: ExcalidrawElement[]) =>
elements.some(element => element.isSelected && element.type === "text");
export function getElementAtPosition(
elements: ExcalidrawElement[],
x: number,
@ -36,3 +40,20 @@ export function getElementAtPosition(
return hitElement;
}
export function getElementContainingPosition(
elements: ExcalidrawElement[],
x: number,
y: number
) {
let hitElement = null;
// We need to to hit testing from front (end of the array) to back (beginning of the array)
for (let i = elements.length - 1; i >= 0; --i) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[i]);
if (x1 < x && x < x2 && y1 < y && y < y2) {
hitElement = elements[i];
break;
}
}
return hitElement;
}

View file

@ -6,6 +6,7 @@ import { getElementAbsoluteCoords } from "../element";
import { renderScene } from "../renderer";
import { AppState } from "../types";
import nanoid from "nanoid";
const LOCAL_STORAGE_KEY = "excalidraw";
const LOCAL_STORAGE_KEY_STATE = "excalidraw-state";
@ -143,6 +144,7 @@ function restore(
: savedElements)
);
elements.forEach((element: ExcalidrawElement) => {
element.id = element.id || nanoid();
element.fillStyle = element.fillStyle || "hachure";
element.strokeWidth = element.strokeWidth || 1;
element.roughness = element.roughness || 1;

View file

@ -14,5 +14,11 @@ export {
restoreFromLocalStorage,
saveToLocalStorage
} from "./data";
export { hasBackground, hasStroke, getElementAtPosition } from "./comparisons";
export {
hasBackground,
hasStroke,
getElementAtPosition,
getElementContainingPosition,
hasText
} from "./comparisons";
export { createScene } from "./createScene";

View file

@ -7,6 +7,11 @@ export type SceneState = {
viewBackgroundColor: string | null;
};
export type SceneScroll = {
scrollX: number;
scrollY: number;
};
export interface Scene {
elements: ExcalidrawTextElement[];
}

View file

@ -30,6 +30,27 @@ body {
margin: 10px 0 10px 0;
}
.panel {
position: relative;
.btn-panel-collapse {
position: absolute;
top: -2px;
right: 5px;
background: none;
margin: 0px;
color: black;
}
.btn-panel-collapse-icon {
transform: none;
display: inline-block;
}
.btn-panel-collapse-icon-closed {
transform: rotateZ(90deg);
}
}
.panelTools {
display: flex;
flex-wrap: wrap;

View file

@ -18,8 +18,36 @@ export function isInputLike(
target: Element | EventTarget | null
): target is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement {
return (
(target instanceof HTMLElement && target.dataset.type === "wysiwyg") ||
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement
);
}
// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
export function measureText(text: string, font: string) {
const line = document.createElement("div");
const body = document.body;
line.style.position = "absolute";
line.style.whiteSpace = "nowrap";
line.style.font = font;
body.appendChild(line);
// Now we can measure width and height of the letter
line.innerHTML = text;
const width = line.offsetWidth;
const height = line.offsetHeight;
// Now creating 1px sized item that will be aligned to baseline
// to calculate baseline shift
const span = document.createElement("span");
span.style.display = "inline-block";
span.style.overflow = "hidden";
span.style.width = "1px";
span.style.height = "1px";
line.appendChild(span);
// Baseline is important for positioning text on canvas
const baseline = span.offsetTop + span.offsetHeight;
document.body.removeChild(line);
return { width, height, baseline };
}

View file

@ -1,2 +0,0 @@
@font-face{font-family:Virgil;src:url(https://uploads.codesandbox.io/uploads/user/ed077012-e728-4a42-8395-cbd299149d62/AflB-FG_Virgil.ttf);font-display:swap}body{margin:0;font-family:Arial,Helvetica,sans-serif}.container{display:flex;position:fixed;top:0;bottom:0;left:0;right:0}.sidePanel{width:230px;background-color:#eee;padding:10px;overflow-y:auto}.sidePanel h4{margin:10px 0}.sidePanel .panelTools{display:flex;justify-content:space-between}.sidePanel .panelTools label{margin:0}.sidePanel .panelColumn{display:flex;flex-direction:column}.tool{position:relative}.tool input[type=radio]{position:absolute;opacity:0;width:0;height:0}.tool input[type=radio]+.toolIcon{background-color:#ddd;width:41px;height:41px;display:flex;justify-content:center;align-items:center;border-radius:3px}.tool input[type=radio]+.toolIcon svg{height:1em}.tool input[type=radio]:hover+.toolIcon{background-color:#e7e5e5}.tool input[type=radio]:checked+.toolIcon{background-color:#bdbebc}.tool input[type=radio]:focus+.toolIcon{box-shadow:0 0 0 2px #4682b4}label{margin-right:6px}label span{display:inline-block}input[type=number]{width:30px}input[type=color]{margin:2px}input{margin-right:5px}input:focus{box-shadow:0 0 0 2px #4682b4}button,input:focus{outline:transparent}button{background-color:#ddd;border:1px solid #ccc;border-radius:4px;margin:2px 0;padding:5px}button:focus{box-shadow:0 0 0 2px #4682b4}button:hover{background-color:#e7e5e5;border-color:#d6d4d4}button:active{background-color:#bdbebc;border-color:#bdbebc}button:disabled{cursor:not-allowed}
/*# sourceMappingURL=main.4b4142f8.chunk.css.map */

View file

@ -1 +0,0 @@
{"version":3,"sources":["styles.scss"],"names":[],"mappings":"AACA,WACE,kBAAqB,CACrB,4GAA+G,CAC/G,iBAAkB,CAGpB,KACE,QAAS,CACT,sCAAyC,CAC1C,WAGC,YAAa,CACb,cAAe,CACf,KAAM,CACN,QAAS,CACT,MAAO,CACP,OAAQ,CACT,WAGC,WAAY,CACZ,qBAAsB,CAEtB,YAAa,CACb,eAAgB,CALlB,cAQI,aAAqB,CARzB,uBAYI,YAAa,CACb,6BAA8B,CAblC,6BAgBM,QAAS,CAhBf,wBAqBI,YAAa,CACb,qBAAsB,CACvB,MAID,iBAAkB,CADpB,wBAII,iBAAkB,CAClB,SAAU,CACV,OAAQ,CACR,QAAS,CAPb,kCAYM,qBAAsB,CAEtB,UAAW,CACX,WAAY,CAEZ,YAAa,CACb,sBAAuB,CACvB,kBAAmB,CAEnB,iBAAkB,CArBxB,sCAwBQ,UAAW,CAxBnB,wCA4BM,wBAAyB,CA5B/B,0CA+BM,wBAAyB,CA/B/B,wCAkCM,4BAA+B,CAChC,MAKH,gBAAiB,CADnB,WAGI,oBAAqB,CACtB,mBAID,UAAW,CACZ,kBAGC,UAAW,CACZ,MAGC,gBAAiB,CADnB,YAKI,4BAA+B,CAChC,mBAFC,mBAWkB,CATnB,OAID,qBAAsB,CACtB,qBAAsB,CACtB,iBAAkB,CAClB,YAAa,CACb,WACoB,CANtB,aASI,4BAA+B,CATnC,aAaI,wBAAyB,CACzB,oBAAqB,CAdzB,cAkBI,wBAAyB,CACzB,oBAAqB,CAnBzB,gBAuBI,kBAAmB","file":"main.4b4142f8.chunk.css","sourcesContent":["/* http://www.eaglefonts.com/fg-virgil-ttf-131249.htm */\n@font-face {\n font-family: \"Virgil\";\n src: url(\"https://uploads.codesandbox.io/uploads/user/ed077012-e728-4a42-8395-cbd299149d62/AflB-FG_Virgil.ttf\");\n font-display: swap;\n}\n\nbody {\n margin: 0;\n font-family: Arial, Helvetica, sans-serif;\n}\n\n.container {\n display: flex;\n position: fixed;\n top: 0;\n bottom: 0;\n left: 0;\n right: 0;\n}\n\n.sidePanel {\n width: 230px;\n background-color: #eee;\n\n padding: 10px;\n overflow-y: auto;\n\n h4 {\n margin: 10px 0 10px 0;\n }\n\n .panelTools {\n display: flex;\n justify-content: space-between;\n\n label {\n margin: 0;\n }\n }\n\n .panelColumn {\n display: flex;\n flex-direction: column;\n }\n}\n\n.tool {\n position: relative;\n\n input[type=\"radio\"] {\n position: absolute;\n opacity: 0;\n width: 0;\n height: 0;\n }\n\n input[type=\"radio\"] {\n & + .toolIcon {\n background-color: #ddd;\n\n width: 41px;\n height: 41px;\n\n display: flex;\n justify-content: center;\n align-items: center;\n\n border-radius: 3px;\n\n svg {\n height: 1em;\n }\n }\n &:hover + .toolIcon {\n background-color: #e7e5e5;\n }\n &:checked + .toolIcon {\n background-color: #bdbebc;\n }\n &:focus + .toolIcon {\n box-shadow: 0 0 0 2px steelblue;\n }\n }\n}\n\nlabel {\n margin-right: 6px;\n span {\n display: inline-block;\n }\n}\n\ninput[type=\"number\"] {\n width: 30px;\n}\n\ninput[type=\"color\"] {\n margin: 2px;\n}\n\ninput {\n margin-right: 5px;\n\n &:focus {\n outline: transparent;\n box-shadow: 0 0 0 2px steelblue;\n }\n}\n\nbutton {\n background-color: #ddd;\n border: 1px solid #ccc;\n border-radius: 4px;\n margin: 2px 0;\n padding: 5px;\n outline: transparent;\n\n &:focus {\n box-shadow: 0 0 0 2px steelblue;\n }\n\n &:hover {\n background-color: #e7e5e5;\n border-color: #d6d4d4;\n }\n\n &:active {\n background-color: #bdbebc;\n border-color: #bdbebc;\n }\n\n &:disabled {\n cursor: not-allowed;\n }\n}\n"]}

File diff suppressed because one or more lines are too long

View file

@ -1,32 +0,0 @@
/*
object-assign
(c) Sindre Sorhus
@license MIT
*/
/** @license React v16.12.0
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/** @license React v16.12.0
* react-dom.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/** @license React v0.18.0
* scheduler.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,2 +0,0 @@
!function(e){function r(r){for(var n,l,a=r[0],f=r[1],i=r[2],p=0,s=[];p<a.length;p++)l=a[p],Object.prototype.hasOwnProperty.call(o,l)&&o[l]&&s.push(o[l][0]),o[l]=0;for(n in f)Object.prototype.hasOwnProperty.call(f,n)&&(e[n]=f[n]);for(c&&c(r);s.length;)s.shift()();return u.push.apply(u,i||[]),t()}function t(){for(var e,r=0;r<u.length;r++){for(var t=u[r],n=!0,a=1;a<t.length;a++){var f=t[a];0!==o[f]&&(n=!1)}n&&(u.splice(r--,1),e=l(l.s=t[0]))}return e}var n={},o={1:0},u=[];function l(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,l),t.l=!0,t.exports}l.m=e,l.c=n,l.d=function(e,r,t){l.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},l.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},l.t=function(e,r){if(1&r&&(e=l(e)),8&r)return e;if(4&r&&"object"===typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(l.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&r&&"string"!=typeof e)for(var n in e)l.d(t,n,function(r){return e[r]}.bind(null,n));return t},l.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return l.d(r,"a",r),r},l.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},l.p="/";var a=this.webpackJsonpreact=this.webpackJsonpreact||[],f=a.push.bind(a);a.push=r,a=a.slice();for(var i=0;i<a.length;i++)r(a[i]);var c=f;t()}([]);
//# sourceMappingURL=runtime-main.b019aae8.js.map

File diff suppressed because one or more lines are too long