diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..6e86b8bfe --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +open_collective: excalidraw diff --git a/.gitignore b/.gitignore index 46e15b10f..fa6c4cf5a 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,7 @@ yarn.lock # Editors .vscode/ -.DS_Store \ No newline at end of file +.DS_Store + +# build files +static/ diff --git a/README.md b/README.md index cb9ebe6a9..c9458a507 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,9 @@ Go to https://www.excalidraw.com to start sketching + + + ## Run the code diff --git a/package-lock.json b/package-lock.json index 6243dba91..c93986759 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index cfd2ee8eb..432faa66f 100644 --- a/package.json +++ b/package.json @@ -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" ] } } diff --git a/src/components/Panel.tsx b/src/components/Panel.tsx new file mode 100644 index 000000000..9435e59e4 --- /dev/null +++ b/src/components/Panel.tsx @@ -0,0 +1,43 @@ +import React, { useState } from "react"; + +interface PanelProps { + title: string; + defaultCollapsed?: boolean; + hide?: boolean; +} + +export const Panel: React.FC = ({ + title, + children, + defaultCollapsed = false, + hide = false +}) => { + const [collapsed, setCollapsed] = useState(defaultCollapsed); + + if (hide) return null; + + return ( +
+

{title}

+ + {!collapsed &&
{children}
} +
+ ); +}; diff --git a/src/components/panels/PanelCanvas.tsx b/src/components/panels/PanelCanvas.tsx index f647e63c4..9208b0d90 100644 --- a/src/components/panels/PanelCanvas.tsx +++ b/src/components/panels/PanelCanvas.tsx @@ -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 = ({ onClearCanvas }) => { return ( - <> -

Canvas

-
-
Canvas Background Color
- onViewBackgroundColorChange(color)} - /> - -
- + +
Canvas Background Color
+ onViewBackgroundColorChange(color)} + /> + +
); }; diff --git a/src/components/panels/PanelExport.tsx b/src/components/panels/PanelExport.tsx index 9c36aba2e..81bee94ca 100644 --- a/src/components/panels/PanelExport.tsx +++ b/src/components/panels/PanelExport.tsx @@ -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 = ({ onExportAsPNG }) => { return ( - <> -

Export

+
Name
{projectName && ( @@ -47,6 +47,6 @@ export const PanelExport: React.FC = ({
- +
); }; diff --git a/src/components/panels/PanelSelection.tsx b/src/components/panels/PanelSelection.tsx index 57eb3e56b..6e366c697 100644 --- a/src/components/panels/PanelSelection.tsx +++ b/src/components/panels/PanelSelection.tsx @@ -14,8 +14,7 @@ export const PanelSelection: React.FC = ({ onSendToBack }) => { return ( - <> -

Selection

+
- +
); }; diff --git a/src/components/panels/PanelTools.tsx b/src/components/panels/PanelTools.tsx index b6345a08c..4ab9db1a7 100644 --- a/src/components/panels/PanelTools.tsx +++ b/src/components/panels/PanelTools.tsx @@ -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 = ({ onToolChange }) => { return ( - <> -

Shapes

+
{SHAPES.map(({ value, icon }) => ( ))}
- +
); }; diff --git a/src/element/handlerRectangles.ts b/src/element/handlerRectangles.ts index e261ac978..985751109 100644 --- a/src/element/handlerRectangles.ts +++ b/src/element/handlerRectangles.ts @@ -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; diff --git a/src/element/index.ts b/src/element/index.ts index ca53c4379..3348ba030 100644 --- a/src/element/index.ts +++ b/src/element/index.ts @@ -1,4 +1,4 @@ -export { newElement } from "./newElement"; +export { newElement, duplicateElement } from "./newElement"; export { getElementAbsoluteCoords, getDiamondPoints, diff --git a/src/element/newElement.ts b/src/element/newElement.ts index 78586edf1..de5369dbd 100644 --- a/src/element/newElement.ts +++ b/src/element/newElement.ts @@ -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) { + const copy = { ...element }; + copy.id = nanoid(); + copy.seed = randomSeed(); + return copy; +} diff --git a/src/element/resizeTest.ts b/src/element/resizeTest.ts index 8af1b148d..51ecf7ef8 100644 --- a/src/element/resizeTest.ts +++ b/src/element/resizeTest.ts @@ -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; 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 } | null); +} diff --git a/src/element/textWysiwyg.tsx b/src/element/textWysiwyg.tsx index 4935754f8..1046e375c 100644 --- a/src/element/textWysiwyg.tsx +++ b/src/element/textWysiwyg.tsx @@ -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); + } } diff --git a/src/element/types.ts b/src/element/types.ts index 1662bfdf0..e350ad65b 100644 --- a/src/element/types.ts +++ b/src/element/types.ts @@ -5,5 +5,7 @@ export type ExcalidrawTextElement = ExcalidrawElement & { type: "text"; font: string; text: string; - actualBoundingBoxAscent: number; + // for backward compatibility + actualBoundingBoxAscent?: number; + baseline: number; }; diff --git a/src/index.tsx b/src/index.tsx index e3fbc3e7b..7b2353b55 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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) && ( -
- + + - element.strokeColor - )} - /> - - {hasBackground(elements) && ( - <> - element.backgroundColor - )} - /> - -
Fill
- element.fillStyle - )} - onChange={value => { - this.changeProperty(element => { - element.fillStyle = value; - }); - }} - /> - + element.strokeColor )} + /> - {hasStroke(elements) && ( - <> -
Stroke Width
- element.strokeWidth - )} - onChange={value => { - this.changeProperty(element => { - element.strokeWidth = value; - }); - }} - /> + {hasBackground(elements) && ( + <> + element.backgroundColor + )} + /> -
Sloppiness
- element.roughness - )} - onChange={value => - this.changeProperty(element => { - element.roughness = value; - }) - } - /> - - )} +
Fill
+ element.fillStyle + )} + onChange={value => { + this.changeProperty(element => { + element.fillStyle = value; + }); + }} + /> + + )} -
Opacity
- element.opacity) || - 0 /* Put the opacity at 0 if there are two conflicting ones */ - } - /> + {hasStroke(elements) && ( + <> +
Stroke Width
+ element.strokeWidth + )} + onChange={value => { + this.changeProperty(element => { + element.strokeWidth = value; + }); + }} + /> - -
- )} +
Sloppiness
+ element.roughness + )} + onChange={value => + this.changeProperty(element => { + element.roughness = value; + }) + } + /> + + )} + + {hasText(elements) && ( + <> +
Font size
+ + 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); + } + }) + } + /> +
Font familly
+ + 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); + } + }) + } + /> + + )} + +
Opacity
+ element.opacity) || + 0 /* Put the opacity at 0 if there are two conflicting ones */ + } + /> + + + @@ -515,7 +566,9 @@ class App extends React.Component<{}, AppState> { 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; + 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 = ``; + } + }} /> ); @@ -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(, rootElement); -const canvas = document.getElementById("canvas") as HTMLCanvasElement; -const rc = rough.canvas(canvas); -const context = canvas.getContext("2d")!; - -ReactDOM.render(, rootElement); diff --git a/src/keys.ts b/src/keys.ts new file mode 100644 index 000000000..8147b56b2 --- /dev/null +++ b/src/keys.ts @@ -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 + ); +} diff --git a/src/renderer/renderElement.ts b/src/renderer/renderElement.ts index 3c6b1d90e..d40efafcf 100644 --- a/src/renderer/renderElement.ts +++ b/src/renderer/renderElement.ts @@ -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; diff --git a/src/renderer/renderScene.ts b/src/renderer/renderScene.ts index dc4c59345..0901d47d2 100644 --- a/src/renderer/renderScene.ts +++ b/src/renderer/renderScene.ts @@ -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, diff --git a/src/scene/roundRect.ts b/src/renderer/roundRect.ts similarity index 100% rename from src/scene/roundRect.ts rename to src/renderer/roundRect.ts diff --git a/src/scene/comparisons.ts b/src/scene/comparisons.ts index 0acc4a332..30418a93b 100644 --- a/src/scene/comparisons.ts +++ b/src/scene/comparisons.ts @@ -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; +} diff --git a/src/scene/data.ts b/src/scene/data.ts index 892efef55..bfcaf4903 100644 --- a/src/scene/data.ts +++ b/src/scene/data.ts @@ -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; diff --git a/src/scene/index.ts b/src/scene/index.ts index 71e78d69b..171315f40 100644 --- a/src/scene/index.ts +++ b/src/scene/index.ts @@ -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"; diff --git a/src/scene/types.ts b/src/scene/types.ts index 41a2f1b2a..c8904dca5 100644 --- a/src/scene/types.ts +++ b/src/scene/types.ts @@ -7,6 +7,11 @@ export type SceneState = { viewBackgroundColor: string | null; }; +export type SceneScroll = { + scrollX: number; + scrollY: number; +}; + export interface Scene { elements: ExcalidrawTextElement[]; } diff --git a/src/styles.scss b/src/styles.scss index 969174ea9..9652ebac2 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -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; diff --git a/src/utils.ts b/src/utils.ts index a62ecd070..dcec48817 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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 }; +} diff --git a/static/css/main.4b4142f8.chunk.css b/static/css/main.4b4142f8.chunk.css deleted file mode 100644 index f6b04d5c6..000000000 --- a/static/css/main.4b4142f8.chunk.css +++ /dev/null @@ -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 */ \ No newline at end of file diff --git a/static/css/main.4b4142f8.chunk.css.map b/static/css/main.4b4142f8.chunk.css.map deleted file mode 100644 index 5cf9155b0..000000000 --- a/static/css/main.4b4142f8.chunk.css.map +++ /dev/null @@ -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"]} \ No newline at end of file diff --git a/static/js/2.3fb278bc.chunk.js b/static/js/2.3fb278bc.chunk.js deleted file mode 100644 index 090a90131..000000000 --- a/static/js/2.3fb278bc.chunk.js +++ /dev/null @@ -1,3 +0,0 @@ -/*! For license information please see 2.3fb278bc.chunk.js.LICENSE */ -(this.webpackJsonpreact=this.webpackJsonpreact||[]).push([[2],[function(e,t,n){"use strict";e.exports=n(13)},function(e,t,n){"use strict";function r(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}n.d(t,"a",(function(){return r}))},function(e,t,n){"use strict";function r(e,t){for(var n=0;n0?m-=2*Math.PI:o&&m<0&&(m+=2*Math.PI),this._numSegs=Math.ceil(Math.abs(m/(Math.PI/2))),this._delta=m/this._numSegs,this._T=8/3*Math.sin(this._delta/4)*Math.sin(this._delta/4)/Math.sin(this._delta/2)}}return Object(a.a)(e,[{key:"getNextSegment",value:function(){if(this._segIndex===this._numSegs)return null;var e=Math.cos(this._theta),t=Math.sin(this._theta),n=this._theta+this._delta,r=Math.cos(n),a=Math.sin(n),i=[this._cosPhi*this._rx*r-this._sinPhi*this._ry*a+this._C[0],this._sinPhi*this._rx*r+this._cosPhi*this._ry*a+this._C[1]],l=[this._from[0]+this._T*(-this._cosPhi*this._rx*t-this._sinPhi*this._ry*e),this._from[1]+this._T*(-this._sinPhi*this._rx*t+this._cosPhi*this._ry*e)],o=[i[0]+this._T*(this._cosPhi*this._rx*a+this._sinPhi*this._ry*r),i[1]+this._T*(this._sinPhi*this._rx*a-this._cosPhi*this._ry*r)];return this._theta=n,this._from=[i[0],i[1]],this._segIndex++,{cp1:l,cp2:o,to:i}}},{key:"calculateVectorAngle",value:function(e,t,n,r){var a=Math.atan2(t,e),i=Math.atan2(r,n);return i>=a?i-a:2*Math.PI-(a-i)}}]),e}(),v=function(){function e(t,n){Object(r.a)(this,e),this.sets=t,this.closed=n}return Object(a.a)(e,[{key:"fit",value:function(e){var t=[],n=!0,r=!1,a=void 0;try{for(var i,l=this.sets[Symbol.iterator]();!(n=(i=l.next()).done);n=!0){var o=i.value,u=o.length,s=Math.floor(e*u);if(s<5){if(u<=5)continue;s=5}t.push(this.reduce(o,s))}}catch(m){r=!0,a=m}finally{try{n||null==l.return||l.return()}finally{if(r)throw a}}for(var c="",f=0,d=t;ft;){for(var r=[],a=-1,i=-1,l=1;l0))break;n.splice(i,1)}return n}}]),e}(),m=function(){function e(t,n){Object(r.a)(this,e),this.xi=Number.MAX_VALUE,this.yi=Number.MAX_VALUE,this.px1=t[0],this.py1=t[1],this.px2=n[0],this.py2=n[1],this.a=this.py2-this.py1,this.b=this.px1-this.px2,this.c=this.px2*this.py1-this.px1*this.py2,this._undefined=0===this.a&&0===this.b&&0===this.c}return Object(a.a)(e,[{key:"isUndefined",value:function(){return this._undefined}},{key:"intersects",value:function(e){if(this.isUndefined()||e.isUndefined())return!1;var t=Number.MAX_VALUE,n=Number.MAX_VALUE,r=0,a=0,i=this.a,l=this.b,o=this.c;return Math.abs(l)>1e-5&&(t=-i/l,r=-o/l),Math.abs(e.b)>1e-5&&(n=-e.a/e.b,a=-e.c/e.b),t===Number.MAX_VALUE?n===Number.MAX_VALUE?-o/i===-e.c/e.a&&(this.py1>=Math.min(e.py1,e.py2)&&this.py1<=Math.max(e.py1,e.py2)?(this.xi=this.px1,this.yi=this.py1,!0):this.py2>=Math.min(e.py1,e.py2)&&this.py2<=Math.max(e.py1,e.py2)&&(this.xi=this.px2,this.yi=this.py2,!0)):(this.xi=this.px1,this.yi=n*this.xi+a,!((this.py1-this.yi)*(this.yi-this.py2)<-1e-5||(e.py1-this.yi)*(this.yi-e.py2)<-1e-5)&&(!(Math.abs(e.a)<1e-5)||!((e.px1-this.xi)*(this.xi-e.px2)<-1e-5))):n===Number.MAX_VALUE?(this.xi=e.px1,this.yi=t*this.xi+r,!((e.py1-this.yi)*(this.yi-e.py2)<-1e-5||(this.py1-this.yi)*(this.yi-this.py2)<-1e-5)&&(!(Math.abs(i)<1e-5)||!((this.px1-this.xi)*(this.xi-this.px2)<-1e-5))):t===n?r===a&&(this.px1>=Math.min(e.px1,e.px2)&&this.px1<=Math.max(e.py1,e.py2)?(this.xi=this.px1,this.yi=this.py1,!0):this.px2>=Math.min(e.px1,e.px2)&&this.px2<=Math.max(e.px1,e.px2)&&(this.xi=this.px2,this.yi=this.py2,!0)):(this.xi=(a-r)/(t-n),this.yi=t*this.xi+r,!((this.px1-this.xi)*(this.xi-this.px2)<-1e-5||(e.px1-this.xi)*(this.xi-e.px2)<-1e-5))}}]),e}();var y=function(){function e(t,n,a,i,l,o,u,s){Object(r.a)(this,e),this.deltaX=0,this.hGap=0,this.top=t,this.bottom=n,this.left=a,this.right=i,this.gap=l,this.sinAngle=o,this.tanAngle=s,Math.abs(o)<1e-4?this.pos=a+l:Math.abs(o)>.9999?this.pos=t+l:(this.deltaX=(n-t)*Math.abs(s),this.pos=a-Math.abs(this.deltaX),this.hGap=Math.abs(l/u),this.sLeft=new m([a,n],[a,t]),this.sRight=new m([i,n],[i,t]))}return Object(a.a)(e,[{key:"nextLine",value:function(){if(Math.abs(this.sinAngle)<1e-4){if(this.pos.9999){if(this.posthis.right&&r>this.right;)if(this.pos+=this.hGap,n=this.pos-this.deltaX/2,r=this.pos+this.deltaX/2,this.pos>this.right+this.deltaX)return null;var l=new m([n,a],[r,i]);this.sLeft&&l.intersects(this.sLeft)&&(n=l.xi,a=l.yi),this.sRight&&l.intersects(this.sRight)&&(r=l.xi,i=l.yi),this.tanAngle>0&&(n=this.right-(n-this.left),r=this.right-(r-this.left));var o=[n,a,r,i];return this.pos+=this.hGap,o}}return null}}]),e}();function g(e){var t=e[0],n=e[1];return Math.sqrt(Math.pow(t[0]-n[0],2)+Math.pow(t[1]-n[1],2))}function b(e,t){for(var n=[],r=new m([e[0],e[1]],[e[2],e[3]]),a=0;a2&&void 0!==arguments[2]&&arguments[2],r=w(e,t),a=this.renderLines(r,t,n);return{type:"fillSketch",ops:a}}},{key:"_fillEllipse",value:function(e,t,n,r,a){var i=arguments.length>5&&void 0!==arguments[5]&&arguments[5],l=x(this.helper,e,t,n,r,a),o=this.renderLines(l,a,i);return{type:"fillSketch",ops:o}}},{key:"renderLines",value:function(e,t,n){var r=[],a=null,i=!0,l=!1,o=void 0;try{for(var u,s=e[Symbol.iterator]();!(i=(u=s.next()).done);i=!0){var c=u.value;r=r.concat(this.helper.doubleLineOps(c[0][0],c[0][1],c[1][0],c[1][1],t)),n&&a&&(r=r.concat(this.helper.doubleLineOps(a[0],a[1],c[0][0],c[0][1],t))),a=c[1]}}catch(f){l=!0,o=f}finally{try{i||null==s.return||s.return()}finally{if(l)throw o}}return r}}]),e}(),T=function(e){function t(){return Object(r.a)(this,t),Object(i.a)(this,Object(l.a)(t).apply(this,arguments))}return Object(o.a)(t,e),Object(a.a)(t,[{key:"fillPolygon",value:function(e,t){return this._fillPolygon(e,t,!0)}},{key:"fillEllipse",value:function(e,t,n,r,a){return this._fillEllipse(e,t,n,r,a,!0)}}]),t}(E),S=function(e){function t(){return Object(r.a)(this,t),Object(i.a)(this,Object(l.a)(t).apply(this,arguments))}return Object(o.a)(t,e),Object(a.a)(t,[{key:"fillPolygon",value:function(e,t){var n=this._fillPolygon(e,t),r=Object.assign({},t,{hachureAngle:t.hachureAngle+90}),a=this._fillPolygon(e,r);return n.ops=n.ops.concat(a.ops),n}},{key:"fillEllipse",value:function(e,t,n,r,a){var i=this._fillEllipse(e,t,n,r,a),l=Object.assign({},a,{hachureAngle:a.hachureAngle+90}),o=this._fillEllipse(e,t,n,r,l);return i.ops=i.ops.concat(o.ops),i}}]),t}(E),_=function(){function e(t){Object(r.a)(this,e),this.helper=t}return Object(a.a)(e,[{key:"fillPolygon",value:function(e,t){var n=w(e,t=Object.assign({},t,{curveStepCount:4,hachureAngle:0}));return this.dotsOnLines(n,t)}},{key:"fillEllipse",value:function(e,t,n,r,a){a=Object.assign({},a,{curveStepCount:4,hachureAngle:0});var i=x(this.helper,e,t,n,r,a);return this.dotsOnLines(i,a)}},{key:"fillArc",value:function(e,t,n,r,a,i,l){return null}},{key:"dotsOnLines",value:function(e,t){var n=[],r=t.hachureGap;r<0&&(r=4*t.strokeWidth),r=Math.max(r,.1);var a=t.fillWeight;a<0&&(a=t.strokeWidth/2);var i=!0,l=!1,o=void 0;try{for(var u,s=e[Symbol.iterator]();!(i=(u=s.next()).done);i=!0)for(var c=u.value,f=g(c)/r,d=Math.ceil(f)-1,p=Math.atan((c[1][1]-c[0][1])/(c[1][0]-c[0][0])),h=0;h0?t.hachureGap:4*t.strokeWidth,o=[];if(e.length>2)for(var u=0;u=n[0]&&t[0]<=n[1]&&t[1]>=r[0]&&t[1]<=r[1]&&s.push(t)}))},d=0;dc[0]&&(s=e[1],c=e[0]);for(var f=Math.atan((c[1]-s[1])/(c[0]-s[0])),d=0;du[0]&&(o=e[1],u=e[0]);for(var s=Math.atan((u[1]-o[1])/(u[0]-o[0])),c=0;c2){for(var a=[],i=0;i2*Math.PI&&(p=0,h=2*Math.PI);var v=2*Math.PI/u.curveStepCount,m=Math.min(v/2,(h-p)/2),y=K(m,s,c,f,d,p,h,1,u),g=K(m,s,c,f,d,p,h,1.5,u),b=y.concat(g);return l&&(o?b=(b=b.concat($(s,c,s+f*Math.cos(p),c+d*Math.sin(p),u))).concat($(s,c,s+f*Math.cos(h),c+d*Math.sin(h),u)):(b.push({op:"lineTo",data:[s,c]}),b.push({op:"lineTo",data:[s+f*Math.cos(p),c+d*Math.sin(p)]}))),{type:"path",ops:b}}function U(e,t){var n=[];if(e.length){var r=t.maxRandomnessOffset||0,a=e.length;if(a>2){n.push({op:"move",data:[e[0][0]+B(r,t),e[0][1]+B(r,t)]});for(var i=1;i2*Math.PI&&(d=0,p=2*Math.PI);for(var h=(p-d)/l.curveStepCount,v=[],m=d;m<=p;m+=h)v.push([u+c*Math.cos(m),s+f*Math.sin(m)]);return v.push([u+c*Math.cos(p),s+f*Math.sin(p)]),v.push([u,s]),F(v,l)}function V(e,t,n){return n.roughness*(Math.random()*(t-e)+e)}function B(e,t){return V(-e,e,t)}function $(e,t,n,r,a){var i=q(e,t,n,r,a,!0,!1),l=q(e,t,n,r,a,!0,!0);return i.concat(l)}function q(e,t,n,r,a,i,l){var o=Math.pow(e-n,2)+Math.pow(t-r,2),u=a.maxRandomnessOffset||0;u*u*100>o&&(u=Math.sqrt(o)/10);var s=u/2,c=.2+.2*Math.random(),f=a.bowing*a.maxRandomnessOffset*(r-t)/200,d=a.bowing*a.maxRandomnessOffset*(e-n)/200;f=B(f,a),d=B(d,a);var p=[],h=function(){return B(s,a)},v=function(){return B(u,a)};return i&&(l?p.push({op:"move",data:[e+h(),t+h()]}):p.push({op:"move",data:[e+B(u,a),t+B(u,a)]})),l?p.push({op:"bcurveTo",data:[f+e+(n-e)*c+h(),d+t+(r-t)*c+h(),f+e+2*(n-e)*c+h(),d+t+2*(r-t)*c+h(),n+h(),r+h()]}):p.push({op:"bcurveTo",data:[f+e+(n-e)*c+v(),d+t+(r-t)*c+v(),f+e+2*(n-e)*c+v(),d+t+2*(r-t)*c+v(),n+v(),r+v()]}),p}function H(e,t,n){var r=[];r.push([e[0][0]+B(t,n),e[0][1]+B(t,n)]),r.push([e[0][0]+B(t,n),e[0][1]+B(t,n)]);for(var a=1;a3){var i=[],l=1-n.curveTightness;a.push({op:"move",data:[e[1][0],e[1][1]]});for(var o=1;o+2=2){var l=+t.data[0],o=+t.data[1];i&&(l+=e.x,o+=e.y);var u=1*(r.maxRandomnessOffset||0);l+=B(u,r),o+=B(u,r),e.setPosition(l,o),a.push({op:"move",data:[l,o]})}break;case"L":case"l":var s="l"===t.key;if(t.data.length>=2){var c=+t.data[0],f=+t.data[1];s&&(c+=e.x,f+=e.y),a=a.concat($(e.x,e.y,c,f,r)),e.setPosition(c,f)}break;case"H":case"h":var d="h"===t.key;if(t.data.length){var p=+t.data[0];d&&(p+=e.x),a=a.concat($(e.x,e.y,p,e.y,r)),e.setPosition(p,e.y)}break;case"V":case"v":var v="v"===t.key;if(t.data.length){var m=+t.data[0];v&&(m+=e.y),a=a.concat($(e.x,e.y,e.x,m,r)),e.setPosition(e.x,m)}break;case"Z":case"z":e.first&&(a=a.concat($(e.x,e.y,e.first[0],e.first[1],r)),e.setPosition(e.first[0],e.first[1]),e.first=null);break;case"C":case"c":var y="c"===t.key;if(t.data.length>=6){var g=+t.data[0],b=+t.data[1],k=+t.data[2],w=+t.data[3],x=+t.data[4],E=+t.data[5];y&&(g+=e.x,k+=e.x,x+=e.x,b+=e.y,w+=e.y,E+=e.y);var T=X(g,b,k,w,x,E,e,r);a=a.concat(T),e.bezierReflectionPoint=[x+(x-k),E+(E-w)]}break;case"S":case"s":var S="s"===t.key;if(t.data.length>=4){var _=+t.data[0],P=+t.data[1],C=+t.data[2],M=+t.data[3];S&&(_+=e.x,C+=e.x,P+=e.y,M+=e.y);var O=_,N=P,z=n?n.key:"",A=null;"c"!==z&&"C"!==z&&"s"!==z&&"S"!==z||(A=e.bezierReflectionPoint),A&&(O=A[0],N=A[1]);var I=X(O,N,_,P,C,M,e,r);a=a.concat(I),e.bezierReflectionPoint=[C+(C-_),M+(M-P)]}break;case"Q":case"q":var R="q"===t.key;if(t.data.length>=4){var j=+t.data[0],L=+t.data[1],D=+t.data[2],U=+t.data[3];R&&(j+=e.x,D+=e.x,L+=e.y,U+=e.y);var F=1*(1+.2*r.roughness),W=1.5*(1+.22*r.roughness);a.push({op:"move",data:[e.x+B(F,r),e.y+B(F,r)]});var V=[D+B(F,r),U+B(F,r)];a.push({op:"qcurveTo",data:[j+B(F,r),L+B(F,r),V[0],V[1]]}),a.push({op:"move",data:[e.x+B(W,r),e.y+B(W,r)]}),V=[D+B(W,r),U+B(W,r)],a.push({op:"qcurveTo",data:[j+B(W,r),L+B(W,r),V[0],V[1]]}),e.setPosition(V[0],V[1]),e.quadReflectionPoint=[D+(D-j),U+(U-L)]}break;case"T":case"t":var q="t"===t.key;if(t.data.length>=2){var H=+t.data[0],Q=+t.data[1];q&&(H+=e.x,Q+=e.y);var G=H,K=Q,Y=n?n.key:"",Z=null;"q"!==Y&&"Q"!==Y&&"t"!==Y&&"T"!==Y||(Z=e.quadReflectionPoint),Z&&(G=Z[0],K=Z[1]);var J=1*(1+.2*r.roughness),ee=1.5*(1+.22*r.roughness);a.push({op:"move",data:[e.x+B(J,r),e.y+B(J,r)]});var te=[H+B(J,r),Q+B(J,r)];a.push({op:"qcurveTo",data:[G+B(J,r),K+B(J,r),te[0],te[1]]}),a.push({op:"move",data:[e.x+B(ee,r),e.y+B(ee,r)]}),te=[H+B(ee,r),Q+B(ee,r)],a.push({op:"qcurveTo",data:[G+B(ee,r),K+B(ee,r),te[0],te[1]]}),e.setPosition(te[0],te[1]),e.quadReflectionPoint=[H+(H-G),Q+(Q-K)]}break;case"A":case"a":var ne="a"===t.key;if(t.data.length>=7){var re=+t.data[0],ae=+t.data[1],ie=+t.data[2],le=+t.data[3],oe=+t.data[4],ue=+t.data[5],se=+t.data[6];if(ne&&(ue+=e.x,se+=e.y),ue===e.x&&se===e.y)break;if(0===re||0===ae)a=a.concat($(e.x,e.y,ue,se,r)),e.setPosition(ue,se);else for(var ce=0;ce<1;ce++)for(var fe=new h([e.x,e.y],[ue,se],[re,ae],ie,!!le,!!oe),de=fe.getNextSegment();de;){var pe=X(de.cp1[0],de.cp1[1],de.cp2[0],de.cp2[1],de.to[0],de.to[1],e,r);a=a.concat(pe),de=fe.getNextSegment()}}}return a}var Z=function(e){function t(){return Object(r.a)(this,t),Object(i.a)(this,Object(l.a)(t).apply(this,arguments))}return Object(o.a)(t,e),Object(a.a)(t,[{key:"line",value:function(e,t,n,r,a){var i=this._options(a);return this._drawable("line",[A(e,t,n,r,i)],i)}},{key:"rectangle",value:function(e,t,n,r,a){var i=this._options(a),l=[];if(i.fill){var o=[[e,t],[e+n,t],[e+n,t+r],[e,t+r]];"solid"===i.fillStyle?l.push(U(o,i)):l.push(F(o,i))}return l.push(R(e,t,n,r,i)),this._drawable("rectangle",l,i)}},{key:"ellipse",value:function(e,t,n,r,a){var i=this._options(a),l=[];if(i.fill)if("solid"===i.fillStyle){var o=L(e,t,n,r,i);o.type="fillPath",l.push(o)}else l.push(function(e,t,n,r,a){return N(a,z).fillEllipse(e,t,n,r,a)}(e,t,n,r,i));return l.push(L(e,t,n,r,i)),this._drawable("ellipse",l,i)}},{key:"circle",value:function(e,t,n,r){var a=this.ellipse(e,t,n,n,r);return a.shape="circle",a}},{key:"linearPath",value:function(e,t){var n=this._options(t);return this._drawable("linearPath",[I(e,!1,n)],n)}},{key:"arc",value:function(e,t,n,r,a,i){var l=arguments.length>6&&void 0!==arguments[6]&&arguments[6],o=arguments.length>7?arguments[7]:void 0,u=this._options(o),s=[];if(l&&u.fill)if("solid"===u.fillStyle){var c=D(e,t,n,r,a,i,!0,!1,u);c.type="fillPath",s.push(c)}else s.push(W(e,t,n,r,a,i,u));return s.push(D(e,t,n,r,a,i,l,!0,u)),this._drawable("arc",s,u)}},{key:"curve",value:function(e,t){var n=this._options(t);return this._drawable("curve",[j(e,n)],n)}},{key:"polygon",value:function(e,t){var n=this._options(t),r=[];if(n.fill)if("solid"===n.fillStyle)r.push(U(e,n));else{var a=this.computePolygonSize(e),i=F([[0,0],[a[0],0],[a[0],a[1]],[0,a[1]]],n);i.type="path2Dpattern",i.size=a,i.path=this.polygonPath(e),r.push(i)}return r.push(I(e,!0,n)),this._drawable("polygon",r,n)}},{key:"path",value:function(e,t){var n=this._options(t),r=[];if(!e)return this._drawable("path",r,n);if(n.fill)if("solid"===n.fillStyle){var a={type:"path2Dfill",path:e,ops:[]};r.push(a)}else{var i=this.computePathSize(e),l=F([[0,0],[i[0],0],[i[0],i[1]],[0,i[1]]],n);l.type="path2Dpattern",l.size=i,l.path=e,r.push(l)}return r.push(function(e,t){e=(e||"").replace(/\n/g," ").replace(/(-\s)/g,"-").replace("/(ss)/g"," ");var n=new p(e);if(t.simplification){var r=new v(n.linearPoints,n.closed).fit(t.simplification);n=new p(r)}for(var a=[],i=n.segments||[],l=0;l0?i[l-1]:null,t);o&&o.length&&(a=a.concat(o))}return{type:"path",ops:a}}(e,n)),this._drawable("path",r,n)}}]),t}(s),J="undefined"!==typeof document,ee=function(e){function t(e,n){var a;return Object(r.a)(this,t),(a=Object(i.a)(this,Object(l.a)(t).call(this,e))).gen=new Z(n||null,a.canvas),a}return Object(o.a)(t,e),Object(a.a)(t,[{key:"getDefaultOptions",value:function(){return this.gen.defaultOptions}},{key:"line",value:function(e,t,n,r,a){var i=this.gen.line(e,t,n,r,a);return this.draw(i),i}},{key:"rectangle",value:function(e,t,n,r,a){var i=this.gen.rectangle(e,t,n,r,a);return this.draw(i),i}},{key:"ellipse",value:function(e,t,n,r,a){var i=this.gen.ellipse(e,t,n,r,a);return this.draw(i),i}},{key:"circle",value:function(e,t,n,r){var a=this.gen.circle(e,t,n,r);return this.draw(a),a}},{key:"linearPath",value:function(e,t){var n=this.gen.linearPath(e,t);return this.draw(n),n}},{key:"polygon",value:function(e,t){var n=this.gen.polygon(e,t);return this.draw(n),n}},{key:"arc",value:function(e,t,n,r,a,i){var l=arguments.length>6&&void 0!==arguments[6]&&arguments[6],o=arguments.length>7?arguments[7]:void 0,u=this.gen.arc(e,t,n,r,a,i,l,o);return this.draw(u),u}},{key:"curve",value:function(e,t){var n=this.gen.curve(e,t);return this.draw(n),n}},{key:"path",value:function(e,t){var n=this.gen.path(e,t);return this.draw(n),n}},{key:"generator",get:function(){return this.gen}}]),t}(function(){function e(t){Object(r.a)(this,e),this.canvas=t,this.ctx=this.canvas.getContext("2d")}return Object(a.a)(e,[{key:"draw",value:function(e){var t=e.sets||[],n=e.options||this.getDefaultOptions(),r=this.ctx,a=!0,i=!1,l=void 0;try{for(var o,u=t[Symbol.iterator]();!(a=(o=u.next()).done);a=!0){var s=o.value;switch(s.type){case"path":r.save(),r.strokeStyle=n.stroke,r.lineWidth=n.strokeWidth,this._drawToContext(r,s),r.restore();break;case"fillPath":r.save(),r.fillStyle=n.fill||"",this._drawToContext(r,s),r.restore();break;case"fillSketch":this.fillSketch(r,s,n);break;case"path2Dfill":this.ctx.save(),this.ctx.fillStyle=n.fill||"";var c=new Path2D(s.path);this.ctx.fill(c),this.ctx.restore();break;case"path2Dpattern":var f=this.canvas.ownerDocument||J&&document;if(f){var d=s.size,p=f.createElement("canvas"),h=p.getContext("2d"),v=this.computeBBox(s.path);v&&(v.width||v.height)?(p.width=this.canvas.width,p.height=this.canvas.height,h.translate(v.x||0,v.y||0)):(p.width=d[0],p.height=d[1]),this.fillSketch(h,s,n),this.ctx.save(),this.ctx.fillStyle=this.ctx.createPattern(p,"repeat");var m=new Path2D(s.path);this.ctx.fill(m),this.ctx.restore()}else console.error("Cannot render path2Dpattern. No defs/document defined.")}}}catch(y){i=!0,l=y}finally{try{a||null==u.return||u.return()}finally{if(i)throw l}}}},{key:"computeBBox",value:function(e){if(J)try{var t="http://www.w3.org/2000/svg",n=document.createElementNS(t,"svg");n.setAttribute("width","0"),n.setAttribute("height","0");var r=self.document.createElementNS(t,"path");r.setAttribute("d",e),n.appendChild(r),document.body.appendChild(n);var a=r.getBBox();return document.body.removeChild(n),a}catch(i){}return null}},{key:"fillSketch",value:function(e,t,n){var r=n.fillWeight;r<0&&(r=n.strokeWidth/2),e.save(),e.strokeStyle=n.fill||"",e.lineWidth=r,this._drawToContext(e,t),e.restore()}},{key:"_drawToContext",value:function(e,t){e.beginPath();var n=!0,r=!1,a=void 0;try{for(var i,l=t.ops[Symbol.iterator]();!(n=(i=l.next()).done);n=!0){var o=i.value,u=o.data;switch(o.op){case"move":e.moveTo(u[0],u[1]);break;case"bcurveTo":e.bezierCurveTo(u[0],u[1],u[2],u[3],u[4],u[5]);break;case"qcurveTo":e.quadraticCurveTo(u[0],u[1],u[2],u[3]);break;case"lineTo":e.lineTo(u[0],u[1])}}}catch(s){r=!0,a=s}finally{try{n||null==l.return||l.return()}finally{if(r)throw a}}"fillPath"===t.type?e.fill():e.stroke()}}]),e}()),te="undefined"!==typeof document,ne=function(e){function t(e,n){var a;return Object(r.a)(this,t),(a=Object(i.a)(this,Object(l.a)(t).call(this,e))).gen=new Z(n||null,a.svg),a}return Object(o.a)(t,e),Object(a.a)(t,[{key:"getDefaultOptions",value:function(){return this.gen.defaultOptions}},{key:"opsToPath",value:function(e){return this.gen.opsToPath(e)}},{key:"line",value:function(e,t,n,r,a){var i=this.gen.line(e,t,n,r,a);return this.draw(i)}},{key:"rectangle",value:function(e,t,n,r,a){var i=this.gen.rectangle(e,t,n,r,a);return this.draw(i)}},{key:"ellipse",value:function(e,t,n,r,a){var i=this.gen.ellipse(e,t,n,r,a);return this.draw(i)}},{key:"circle",value:function(e,t,n,r){var a=this.gen.circle(e,t,n,r);return this.draw(a)}},{key:"linearPath",value:function(e,t){var n=this.gen.linearPath(e,t);return this.draw(n)}},{key:"polygon",value:function(e,t){var n=this.gen.polygon(e,t);return this.draw(n)}},{key:"arc",value:function(e,t,n,r,a,i){var l=arguments.length>6&&void 0!==arguments[6]&&arguments[6],o=arguments.length>7?arguments[7]:void 0,u=this.gen.arc(e,t,n,r,a,i,l,o);return this.draw(u)}},{key:"curve",value:function(e,t){var n=this.gen.curve(e,t);return this.draw(n)}},{key:"path",value:function(e,t){var n=this.gen.path(e,t);return this.draw(n)}},{key:"generator",get:function(){return this.gen}}]),t}(function(){function e(t){Object(r.a)(this,e),this.svg=t}return Object(a.a)(e,[{key:"draw",value:function(e){var t=e.sets||[],n=e.options||this.getDefaultOptions(),r=this.svg.ownerDocument||window.document,a=r.createElementNS("http://www.w3.org/2000/svg","g"),i=!0,l=!1,o=void 0;try{for(var u,s=t[Symbol.iterator]();!(i=(u=s.next()).done);i=!0){var c=u.value,f=null;switch(c.type){case"path":(f=r.createElementNS("http://www.w3.org/2000/svg","path")).setAttribute("d",this.opsToPath(c)),f.style.stroke=n.stroke,f.style.strokeWidth=n.strokeWidth+"",f.style.fill="none";break;case"fillPath":(f=r.createElementNS("http://www.w3.org/2000/svg","path")).setAttribute("d",this.opsToPath(c)),f.style.stroke="none",f.style.strokeWidth="0",f.style.fill=n.fill||null;break;case"fillSketch":f=this.fillSketch(r,c,n);break;case"path2Dfill":(f=r.createElementNS("http://www.w3.org/2000/svg","path")).setAttribute("d",c.path||""),f.style.stroke="none",f.style.strokeWidth="0",f.style.fill=n.fill||null;break;case"path2Dpattern":if(this.defs){var d=c.size,p=r.createElementNS("http://www.w3.org/2000/svg","pattern"),h="rough-".concat(Math.floor(Math.random()*(Number.MAX_SAFE_INTEGER||999999)));p.setAttribute("id",h),p.setAttribute("x","0"),p.setAttribute("y","0"),p.setAttribute("width","1"),p.setAttribute("height","1"),p.setAttribute("height","1"),p.setAttribute("viewBox","0 0 ".concat(Math.round(d[0])," ").concat(Math.round(d[1]))),p.setAttribute("patternUnits","objectBoundingBox");var v=this.fillSketch(r,c,n);p.appendChild(v),this.defs.appendChild(p),(f=r.createElementNS("http://www.w3.org/2000/svg","path")).setAttribute("d",c.path||""),f.style.stroke="none",f.style.strokeWidth="0",f.style.fill="url(#".concat(h,")")}else console.error("Cannot render path2Dpattern. No defs/document defined.")}f&&a.appendChild(f)}}catch(m){l=!0,o=m}finally{try{i||null==s.return||s.return()}finally{if(l)throw o}}return a}},{key:"fillSketch",value:function(e,t,n){var r=n.fillWeight;r<0&&(r=n.strokeWidth/2);var a=e.createElementNS("http://www.w3.org/2000/svg","path");return a.setAttribute("d",this.opsToPath(t)),a.style.stroke=n.fill||null,a.style.strokeWidth=r+"",a.style.fill="none",a}},{key:"defs",get:function(){var e=this.svg.ownerDocument||te&&document;if(e&&!this._defs){var t=e.createElementNS("http://www.w3.org/2000/svg","defs");this.svg.firstChild?this.svg.insertBefore(t,this.svg.firstChild):this.svg.appendChild(t),this._defs=t}return this._defs||null}}]),e}());t.a={canvas:function(e,t){return new ee(e,t)},svg:function(e,t){return new ne(e,t)},generator:function(e,t){return new Z(e,t)}}},function(e,t,n){"use strict";!function e(){if("undefined"!==typeof __REACT_DEVTOOLS_GLOBAL_HOOK__&&"function"===typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE){0;try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(e)}catch(t){console.error(t)}}}(),e.exports=n(14)},function(e,t,n){"use strict";function r(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function a(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function i(e){for(var t=1;tN.length&&N.push(e)}function I(e,t,n){return null==e?0:function e(t,n,r,a){var o=typeof t;"undefined"!==o&&"boolean"!==o||(t=null);var u=!1;if(null===t)u=!0;else switch(o){case"string":case"number":u=!0;break;case"object":switch(t.$$typeof){case i:case l:u=!0}}if(u)return r(a,t,""===n?"."+R(t,0):n),1;if(u=0,n=""===n?".":n+":",Array.isArray(t))for(var s=0;s
elem which we'll use to download the image\n const link = document.createElement(\"a\");\n link.setAttribute(\"download\", name);\n link.setAttribute(\"href\", data);\n link.click();\n\n // clean up\n link.remove();\n}\n\nfunction rotate(x1: number, y1: number, x2: number, y2: number, angle: number) {\n // π‘Žβ€²π‘₯=(π‘Žπ‘₯βˆ’π‘π‘₯)cosπœƒβˆ’(π‘Žπ‘¦βˆ’π‘π‘¦)sinπœƒ+𝑐π‘₯\n // π‘Žβ€²π‘¦=(π‘Žπ‘₯βˆ’π‘π‘₯)sinπœƒ+(π‘Žπ‘¦βˆ’π‘π‘¦)cosπœƒ+𝑐𝑦.\n // https://math.stackexchange.com/questions/2204520/how-do-i-rotate-a-line-segment-in-a-specific-point-on-the-line\n return [\n (x1 - x2) * Math.cos(angle) - (y1 - y2) * Math.sin(angle) + x2,\n (x1 - x2) * Math.sin(angle) + (y1 - y2) * Math.cos(angle) + y2\n ];\n}\n\n// Casting second argument (DrawingSurface) to any,\n// because it is requred by TS definitions and not required at runtime\nconst generator = rough.generator(null, null as any);\n\nfunction isTextElement(\n element: ExcalidrawElement\n): element is ExcalidrawTextElement {\n return element.type === \"text\";\n}\n\nfunction isInputLike(\n target: Element | EventTarget | null\n): target is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement {\n return (\n target instanceof HTMLInputElement ||\n target instanceof HTMLTextAreaElement ||\n target instanceof HTMLSelectElement\n );\n}\n\nfunction getArrowPoints(element: ExcalidrawElement) {\n const x1 = 0;\n const y1 = 0;\n const x2 = element.width;\n const y2 = element.height;\n\n const size = 30; // pixels\n const distance = Math.hypot(x2 - x1, y2 - y1);\n // Scale down the arrow until we hit a certain size so that it doesn't look weird\n const minSize = Math.min(size, distance / 2);\n const xs = x2 - ((x2 - x1) / distance) * minSize;\n const ys = y2 - ((y2 - y1) / distance) * minSize;\n\n const angle = 20; // degrees\n const [x3, y3] = rotate(xs, ys, x2, y2, (-angle * Math.PI) / 180);\n const [x4, y4] = rotate(xs, ys, x2, y2, (angle * Math.PI) / 180);\n\n return [x1, y1, x2, y2, x3, y3, x4, y4];\n}\n\nfunction generateDraw(element: ExcalidrawElement) {\n if (element.type === \"selection\") {\n element.draw = (rc, context, { scrollX, scrollY }) => {\n const fillStyle = context.fillStyle;\n context.fillStyle = \"rgba(0, 0, 255, 0.10)\";\n context.fillRect(\n element.x + scrollX,\n element.y + scrollY,\n element.width,\n element.height\n );\n context.fillStyle = fillStyle;\n };\n } else if (element.type === \"rectangle\") {\n const shape = withCustomMathRandom(element.seed, () => {\n return generator.rectangle(0, 0, element.width, element.height, {\n stroke: element.strokeColor,\n fill: element.backgroundColor\n });\n });\n element.draw = (rc, context, { scrollX, scrollY }) => {\n context.translate(element.x + scrollX, element.y + scrollY);\n rc.draw(shape);\n context.translate(-element.x - scrollX, -element.y - scrollY);\n };\n } else if (element.type === \"ellipse\") {\n const shape = withCustomMathRandom(element.seed, () =>\n generator.ellipse(\n element.width / 2,\n element.height / 2,\n element.width,\n element.height,\n { stroke: element.strokeColor, fill: element.backgroundColor }\n )\n );\n element.draw = (rc, context, { scrollX, scrollY }) => {\n context.translate(element.x + scrollX, element.y + scrollY);\n rc.draw(shape);\n context.translate(-element.x - scrollX, -element.y - scrollY);\n };\n } else if (element.type === \"arrow\") {\n const [x1, y1, x2, y2, x3, y3, x4, y4] = getArrowPoints(element);\n const shapes = withCustomMathRandom(element.seed, () => [\n // \\\n generator.line(x3, y3, x2, y2, { stroke: element.strokeColor }),\n // -----\n generator.line(x1, y1, x2, y2, { stroke: element.strokeColor }),\n // /\n generator.line(x4, y4, x2, y2, { stroke: element.strokeColor })\n ]);\n\n element.draw = (rc, context, { scrollX, scrollY }) => {\n context.translate(element.x + scrollX, element.y + scrollY);\n shapes.forEach(shape => rc.draw(shape));\n context.translate(-element.x - scrollX, -element.y - scrollY);\n };\n return;\n } else if (isTextElement(element)) {\n element.draw = (rc, context, { scrollX, scrollY }) => {\n const font = context.font;\n context.font = element.font;\n const fillStyle = context.fillStyle;\n context.fillStyle = element.strokeColor;\n context.fillText(\n element.text,\n element.x + scrollX,\n element.y + element.actualBoundingBoxAscent + scrollY\n );\n context.fillStyle = fillStyle;\n context.font = font;\n };\n } else {\n throw new Error(\"Unimplemented type \" + element.type);\n }\n}\n\n// If the element is created from right to left, the width is going to be negative\n// This set of functions retrieves the absolute position of the 4 points.\n// We can't just always normalize it since we need to remember the fact that an arrow\n// is pointing left or right.\nfunction getElementAbsoluteX1(element: ExcalidrawElement) {\n return element.width >= 0 ? element.x : element.x + element.width;\n}\nfunction getElementAbsoluteX2(element: ExcalidrawElement) {\n return element.width >= 0 ? element.x + element.width : element.x;\n}\nfunction getElementAbsoluteY1(element: ExcalidrawElement) {\n return element.height >= 0 ? element.y : element.y + element.height;\n}\nfunction getElementAbsoluteY2(element: ExcalidrawElement) {\n return element.height >= 0 ? element.y + element.height : element.y;\n}\n\nfunction setSelection(selection: ExcalidrawElement) {\n const selectionX1 = getElementAbsoluteX1(selection);\n const selectionX2 = getElementAbsoluteX2(selection);\n const selectionY1 = getElementAbsoluteY1(selection);\n const selectionY2 = getElementAbsoluteY2(selection);\n elements.forEach(element => {\n const elementX1 = getElementAbsoluteX1(element);\n const elementX2 = getElementAbsoluteX2(element);\n const elementY1 = getElementAbsoluteY1(element);\n const elementY2 = getElementAbsoluteY2(element);\n element.isSelected =\n element.type !== \"selection\" &&\n selectionX1 <= elementX1 &&\n selectionY1 <= elementY1 &&\n selectionX2 >= elementX2 &&\n selectionY2 >= elementY2;\n });\n}\n\nfunction clearSelection() {\n elements.forEach(element => {\n element.isSelected = false;\n });\n}\n\nfunction resetCursor() {\n document.documentElement.style.cursor = \"\";\n}\n\nfunction deleteSelectedElements() {\n for (let i = elements.length - 1; i >= 0; --i) {\n if (elements[i].isSelected) {\n elements.splice(i, 1);\n }\n }\n}\n\nfunction save(state: AppState) {\n localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(elements));\n localStorage.setItem(LOCAL_STORAGE_KEY_STATE, JSON.stringify(state));\n}\n\nfunction restoreFromLocalStorage() {\n const savedElements = localStorage.getItem(LOCAL_STORAGE_KEY);\n const savedState = localStorage.getItem(LOCAL_STORAGE_KEY_STATE);\n\n return restore(savedElements, savedState);\n}\n\nfunction restore(\n savedElements: string | ExcalidrawElement[] | null,\n savedState: string | null\n) {\n try {\n if (savedElements) {\n elements.splice(\n 0,\n elements.length,\n ...(typeof savedElements === \"string\"\n ? JSON.parse(savedElements)\n : savedElements)\n );\n elements.forEach((element: ExcalidrawElement) => generateDraw(element));\n }\n\n return savedState ? JSON.parse(savedState) : null;\n } catch (e) {\n elements.splice(0, elements.length);\n return null;\n }\n}\n\ntype AppState = {\n draggingElement: ExcalidrawElement | null;\n resizingElement: ExcalidrawElement | null;\n elementType: string;\n exportBackground: boolean;\n currentItemStrokeColor: string;\n currentItemBackgroundColor: string;\n viewBackgroundColor: string;\n scrollX: number;\n scrollY: number;\n};\n\nconst KEYS = {\n ARROW_LEFT: \"ArrowLeft\",\n ARROW_RIGHT: \"ArrowRight\",\n ARROW_DOWN: \"ArrowDown\",\n ARROW_UP: \"ArrowUp\",\n ESCAPE: \"Escape\",\n DELETE: \"Delete\",\n BACKSPACE: \"Backspace\"\n};\n\n// We inline font-awesome icons in order to save on js size rather than including the font awesome react library\nconst SHAPES = [\n {\n icon: (\n // fa-mouse-pointer\n \n \n \n ),\n value: \"selection\"\n },\n {\n icon: (\n // fa-square\n \n \n \n ),\n value: \"rectangle\"\n },\n {\n icon: (\n // fa-circle\n \n \n \n ),\n value: \"ellipse\"\n },\n {\n icon: (\n // fa-long-arrow-alt-right\n \n \n \n ),\n value: \"arrow\"\n },\n {\n icon: (\n // fa-font\n \n \n \n ),\n value: \"text\"\n }\n];\n\nconst shapesShortcutKeys = SHAPES.map(shape => shape.value[0]);\n\nfunction findElementByKey(key: string) {\n const defaultElement = \"selection\";\n return SHAPES.reduce((element, shape) => {\n if (shape.value[0] !== key) return element;\n\n return shape.value;\n }, defaultElement);\n}\n\nfunction isArrowKey(keyCode: string) {\n return (\n keyCode === KEYS.ARROW_LEFT ||\n keyCode === KEYS.ARROW_RIGHT ||\n keyCode === KEYS.ARROW_DOWN ||\n keyCode === KEYS.ARROW_UP\n );\n}\n\nfunction getSelectedIndices() {\n const selectedIndices: number[] = [];\n elements.forEach((element, index) => {\n if (element.isSelected) {\n selectedIndices.push(index);\n }\n });\n return selectedIndices;\n}\n\nconst someElementIsSelected = () =>\n elements.some(element => element.isSelected);\n\nconst ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;\nconst ELEMENT_TRANSLATE_AMOUNT = 1;\n\nlet lastCanvasWidth = -1;\nlet lastCanvasHeight = -1;\n\nlet lastMouseUp: ((e: any) => void) | null = null;\n\nclass App extends React.Component<{}, AppState> {\n public componentDidMount() {\n document.addEventListener(\"keydown\", this.onKeyDown, false);\n window.addEventListener(\"resize\", this.onResize, false);\n\n const savedState = restoreFromLocalStorage();\n if (savedState) {\n this.setState(savedState);\n }\n }\n\n public componentWillUnmount() {\n document.removeEventListener(\"keydown\", this.onKeyDown, false);\n window.removeEventListener(\"resize\", this.onResize, false);\n }\n\n public state: AppState = {\n draggingElement: null,\n resizingElement: null,\n elementType: \"selection\",\n exportBackground: true,\n currentItemStrokeColor: \"#000000\",\n currentItemBackgroundColor: \"#ffffff\",\n viewBackgroundColor: \"#ffffff\",\n scrollX: 0,\n scrollY: 0\n };\n\n private onResize = () => {\n this.forceUpdate();\n };\n\n private onKeyDown = (event: KeyboardEvent) => {\n if (isInputLike(event.target)) return;\n\n if (event.key === KEYS.ESCAPE) {\n clearSelection();\n this.forceUpdate();\n event.preventDefault();\n } else if (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) {\n deleteSelectedElements();\n this.forceUpdate();\n event.preventDefault();\n } else if (isArrowKey(event.key)) {\n const step = event.shiftKey\n ? ELEMENT_SHIFT_TRANSLATE_AMOUNT\n : ELEMENT_TRANSLATE_AMOUNT;\n elements.forEach(element => {\n if (element.isSelected) {\n if (event.key === KEYS.ARROW_LEFT) element.x -= step;\n else if (event.key === KEYS.ARROW_RIGHT) element.x += step;\n else if (event.key === KEYS.ARROW_UP) element.y -= step;\n else if (event.key === KEYS.ARROW_DOWN) element.y += step;\n }\n });\n this.forceUpdate();\n event.preventDefault();\n\n // Send backward: Cmd-Shift-Alt-B\n } else if (\n event.metaKey &&\n event.shiftKey &&\n event.altKey &&\n event.code === \"KeyB\"\n ) {\n this.moveOneLeft();\n event.preventDefault();\n\n // Send to back: Cmd-Shift-B\n } else if (event.metaKey && event.shiftKey && event.code === \"KeyB\") {\n this.moveAllLeft();\n event.preventDefault();\n\n // Bring forward: Cmd-Shift-Alt-F\n } else if (\n event.metaKey &&\n event.shiftKey &&\n event.altKey &&\n event.code === \"KeyF\"\n ) {\n this.moveOneRight();\n event.preventDefault();\n\n // Bring to front: Cmd-Shift-F\n } else if (event.metaKey && event.shiftKey && event.code === \"KeyF\") {\n this.moveAllRight();\n event.preventDefault();\n\n // Select all: Cmd-A\n } else if (event.metaKey && event.code === \"KeyA\") {\n elements.forEach(element => {\n element.isSelected = true;\n });\n this.forceUpdate();\n event.preventDefault();\n } else if (shapesShortcutKeys.includes(event.key.toLowerCase())) {\n this.setState({ elementType: findElementByKey(event.key) });\n } else if (event.metaKey && event.code === \"KeyZ\") {\n let lastEntry = stateHistory.pop();\n // If nothing was changed since last, take the previous one\n if (generateHistoryCurrentEntry() === lastEntry) {\n lastEntry = stateHistory.pop();\n }\n if (lastEntry !== undefined) {\n restoreHistoryEntry(lastEntry);\n }\n this.forceUpdate();\n event.preventDefault();\n }\n };\n\n private deleteSelectedElements = () => {\n deleteSelectedElements();\n this.forceUpdate();\n };\n\n private clearCanvas = () => {\n if (window.confirm(\"This will clear the whole canvas. Are you sure?\")) {\n elements.splice(0, elements.length);\n this.setState({\n viewBackgroundColor: \"#ffffff\",\n scrollX: 0,\n scrollY: 0\n });\n this.forceUpdate();\n }\n };\n\n private moveAllLeft = () => {\n moveAllLeft(elements, getSelectedIndices());\n this.forceUpdate();\n };\n\n private moveOneLeft = () => {\n moveOneLeft(elements, getSelectedIndices());\n this.forceUpdate();\n };\n\n private moveAllRight = () => {\n moveAllRight(elements, getSelectedIndices());\n this.forceUpdate();\n };\n\n private moveOneRight = () => {\n moveOneRight(elements, getSelectedIndices());\n this.forceUpdate();\n };\n\n private removeWheelEventListener: (() => void) | undefined;\n\n public render() {\n const canvasWidth = window.innerWidth - CANVAS_WINDOW_OFFSET_LEFT;\n const canvasHeight = window.innerHeight - CANVAS_WINDOW_OFFSET_TOP;\n\n return (\n {\n e.clipboardData.setData(\n \"text/plain\",\n JSON.stringify(elements.filter(element => element.isSelected))\n );\n deleteSelectedElements();\n this.forceUpdate();\n e.preventDefault();\n }}\n onCopy={e => {\n e.clipboardData.setData(\n \"text/plain\",\n JSON.stringify(elements.filter(element => element.isSelected))\n );\n e.preventDefault();\n }}\n onPaste={e => {\n const paste = e.clipboardData.getData(\"text\");\n let parsedElements;\n try {\n parsedElements = JSON.parse(paste);\n } catch (e) {}\n if (\n Array.isArray(parsedElements) &&\n parsedElements.length > 0 &&\n parsedElements[0].type // need to implement a better check here...\n ) {\n clearSelection();\n parsedElements.forEach(parsedElement => {\n parsedElement.x += 10;\n parsedElement.y += 10;\n parsedElement.seed = randomSeed();\n generateDraw(parsedElement);\n elements.push(parsedElement);\n });\n this.forceUpdate();\n }\n e.preventDefault();\n }}\n >\n
\n

Shapes

\n
\n {SHAPES.map(({ value, icon }) => (\n \n ))}\n
\n

Colors

\n
\n \n \n \n
\n

Canvas

\n
\n \n Clear canvas\n \n
\n

Export

\n
\n {\n exportAsPNG(this.state);\n }}\n >\n Export to png\n \n \n
\n

Save/Load

\n
\n {\n saveAsJSON();\n }}\n >\n Save as...\n \n {\n loadFromJSON().then(() => this.forceUpdate());\n }}\n >\n Load file...\n \n
\n {someElementIsSelected() && (\n <>\n

Shape options

\n
\n \n \n \n \n \n
\n \n )}\n
\n {\n if (this.removeWheelEventListener) {\n this.removeWheelEventListener();\n this.removeWheelEventListener = undefined;\n }\n if (canvas) {\n canvas.addEventListener(\"wheel\", this.handleWheel, {\n passive: false\n });\n this.removeWheelEventListener = () =>\n canvas.removeEventListener(\"wheel\", this.handleWheel);\n\n // Whenever React sets the width/height of the canvas element,\n // the context loses the scale transform. We need to re-apply it\n if (\n canvasWidth !== lastCanvasWidth ||\n canvasHeight !== lastCanvasHeight\n ) {\n lastCanvasWidth = canvasWidth;\n lastCanvasHeight = canvasHeight;\n canvas\n .getContext(\"2d\")!\n .scale(window.devicePixelRatio, window.devicePixelRatio);\n }\n }\n }}\n onMouseDown={e => {\n if (lastMouseUp !== null) {\n // Unfortunately, sometimes we don't get a mouseup after a mousedown,\n // this can happen when a contextual menu or alert is triggered. In order to avoid\n // being in a weird state, we clean up on the next mousedown\n lastMouseUp(e);\n }\n // only handle left mouse button\n if (e.button !== 0) return;\n // fixes mousemove causing selection of UI texts #32\n e.preventDefault();\n // Preventing the event above disables default behavior\n // of defocusing potentially focused input, which is what we want\n // when clicking inside the canvas.\n if (isInputLike(document.activeElement)) {\n document.activeElement.blur();\n }\n\n const x =\n e.clientX - CANVAS_WINDOW_OFFSET_LEFT - this.state.scrollX;\n const y = e.clientY - CANVAS_WINDOW_OFFSET_TOP - this.state.scrollY;\n const element = newElement(\n this.state.elementType,\n x,\n y,\n this.state.currentItemStrokeColor,\n this.state.currentItemBackgroundColor\n );\n let resizeHandle: string | false = false;\n let isDraggingElements = false;\n let isResizingElements = false;\n if (this.state.elementType === \"selection\") {\n const resizeElement = elements.find(element => {\n return resizeTest(element, x, y, {\n scrollX: this.state.scrollX,\n scrollY: this.state.scrollY,\n viewBackgroundColor: this.state.viewBackgroundColor\n });\n });\n\n this.setState({\n resizingElement: resizeElement ? resizeElement : null\n });\n\n if (resizeElement) {\n resizeHandle = resizeTest(resizeElement, x, y, {\n scrollX: this.state.scrollX,\n scrollY: this.state.scrollY,\n viewBackgroundColor: this.state.viewBackgroundColor\n });\n document.documentElement.style.cursor = `${resizeHandle}-resize`;\n isResizingElements = true;\n } else {\n let hitElement = null;\n // We need to to hit testing from front (end of the array) to back (beginning of the array)\n for (let i = elements.length - 1; i >= 0; --i) {\n if (hitTest(elements[i], x, y)) {\n hitElement = elements[i];\n break;\n }\n }\n\n // If we click on something\n if (hitElement) {\n if (hitElement.isSelected) {\n // If that element is not already selected, do nothing,\n // we're likely going to drag it\n } else {\n // We unselect every other elements unless shift is pressed\n if (!e.shiftKey) {\n clearSelection();\n }\n // No matter what, we select it\n hitElement.isSelected = true;\n }\n } else {\n // If we don't click on anything, let's remove all the selected elements\n clearSelection();\n }\n\n isDraggingElements = someElementIsSelected();\n\n if (isDraggingElements) {\n document.documentElement.style.cursor = \"move\";\n }\n }\n }\n\n if (isTextElement(element)) {\n resetCursor();\n const text = prompt(\"What text do you want?\");\n if (text === null) {\n return;\n }\n element.text = text;\n element.font = \"20px Virgil\";\n const font = context.font;\n context.font = element.font;\n const {\n actualBoundingBoxAscent,\n actualBoundingBoxDescent,\n width\n } = context.measureText(element.text);\n element.actualBoundingBoxAscent = actualBoundingBoxAscent;\n context.font = font;\n const height = actualBoundingBoxAscent + actualBoundingBoxDescent;\n // Center the text\n element.x -= width / 2;\n element.y -= actualBoundingBoxAscent;\n element.width = width;\n element.height = height;\n }\n\n generateDraw(element);\n elements.push(element);\n if (this.state.elementType === \"text\") {\n this.setState({\n draggingElement: null,\n elementType: \"selection\"\n });\n element.isSelected = true;\n } else {\n this.setState({ draggingElement: element });\n }\n\n let lastX = x;\n let lastY = y;\n\n const onMouseMove = (e: MouseEvent) => {\n const target = e.target;\n if (!(target instanceof HTMLElement)) {\n return;\n }\n\n if (isResizingElements && this.state.resizingElement) {\n const el = this.state.resizingElement;\n const selectedElements = elements.filter(el => el.isSelected);\n if (selectedElements.length === 1) {\n const x =\n e.clientX - CANVAS_WINDOW_OFFSET_LEFT - this.state.scrollX;\n const y =\n e.clientY - CANVAS_WINDOW_OFFSET_TOP - this.state.scrollY;\n selectedElements.forEach(element => {\n switch (resizeHandle) {\n case \"nw\":\n element.width += element.x - lastX;\n element.height += element.y - lastY;\n element.x = lastX;\n element.y = lastY;\n break;\n case \"ne\":\n element.width = lastX - element.x;\n element.height += element.y - lastY;\n element.y = lastY;\n break;\n case \"sw\":\n element.width += element.x - lastX;\n element.x = lastX;\n element.height = lastY - element.y;\n break;\n case \"se\":\n element.width += x - lastX;\n if (e.shiftKey) {\n element.height = element.width;\n } else {\n element.height += y - lastY;\n }\n break;\n case \"n\":\n element.height += element.y - lastY;\n element.y = lastY;\n break;\n case \"w\":\n element.width += element.x - lastX;\n element.x = lastX;\n break;\n case \"s\":\n element.height = lastY - element.y;\n break;\n case \"e\":\n element.width = lastX - element.x;\n break;\n }\n\n el.x = element.x;\n el.y = element.y;\n generateDraw(el);\n });\n lastX = x;\n lastY = y;\n // We don't want to save history when resizing an element\n skipHistory = true;\n this.forceUpdate();\n return;\n }\n }\n\n if (isDraggingElements) {\n const selectedElements = elements.filter(el => el.isSelected);\n if (selectedElements.length) {\n const x =\n e.clientX - CANVAS_WINDOW_OFFSET_LEFT - this.state.scrollX;\n const y =\n e.clientY - CANVAS_WINDOW_OFFSET_TOP - this.state.scrollY;\n selectedElements.forEach(element => {\n element.x += x - lastX;\n element.y += y - lastY;\n });\n lastX = x;\n lastY = y;\n // We don't want to save history when dragging an element to initially size it\n skipHistory = true;\n this.forceUpdate();\n return;\n }\n }\n\n // It is very important to read this.state within each move event,\n // otherwise we would read a stale one!\n const draggingElement = this.state.draggingElement;\n if (!draggingElement) return;\n let width =\n e.clientX -\n CANVAS_WINDOW_OFFSET_LEFT -\n draggingElement.x -\n this.state.scrollX;\n let height =\n e.clientY -\n CANVAS_WINDOW_OFFSET_TOP -\n draggingElement.y -\n this.state.scrollY;\n draggingElement.width = width;\n // Make a perfect square or circle when shift is enabled\n draggingElement.height = e.shiftKey ? width : height;\n\n generateDraw(draggingElement);\n\n if (this.state.elementType === \"selection\") {\n setSelection(draggingElement);\n }\n // We don't want to save history when moving an element\n skipHistory = true;\n this.forceUpdate();\n };\n\n const onMouseUp = (e: MouseEvent) => {\n const { draggingElement, elementType } = this.state;\n\n lastMouseUp = null;\n window.removeEventListener(\"mousemove\", onMouseMove);\n window.removeEventListener(\"mouseup\", onMouseUp);\n\n resetCursor();\n\n // if no element is clicked, clear the selection and redraw\n if (draggingElement === null) {\n clearSelection();\n this.forceUpdate();\n return;\n }\n\n if (elementType === \"selection\") {\n if (isDraggingElements) {\n isDraggingElements = false;\n }\n elements.pop();\n } else {\n draggingElement.isSelected = true;\n }\n\n this.setState({\n draggingElement: null,\n elementType: \"selection\"\n });\n this.forceUpdate();\n };\n\n lastMouseUp = onMouseUp;\n\n window.addEventListener(\"mousemove\", onMouseMove);\n window.addEventListener(\"mouseup\", onMouseUp);\n\n // We don't want to save history on mouseDown, only on mouseUp when it's fully configured\n skipHistory = true;\n this.forceUpdate();\n }}\n />\n \n );\n }\n\n private handleWheel = (e: WheelEvent) => {\n e.preventDefault();\n const { deltaX, deltaY } = e;\n this.setState(state => ({\n scrollX: state.scrollX - deltaX,\n scrollY: state.scrollY - deltaY\n }));\n };\n\n componentDidUpdate() {\n renderScene(rc, canvas, {\n scrollX: this.state.scrollX,\n scrollY: this.state.scrollY,\n viewBackgroundColor: this.state.viewBackgroundColor\n });\n save(this.state);\n if (!skipHistory) {\n pushHistoryEntry(generateHistoryCurrentEntry());\n }\n skipHistory = false;\n }\n}\n\nconst rootElement = document.getElementById(\"root\");\nReactDOM.render(, rootElement);\nconst canvas = document.getElementById(\"canvas\") as HTMLCanvasElement;\nconst rc = rough.canvas(canvas);\nconst context = canvas.getContext(\"2d\")!;\n\nReactDOM.render(, rootElement);\n"],"sourceRoot":""} \ No newline at end of file diff --git a/static/js/runtime-main.b019aae8.js b/static/js/runtime-main.b019aae8.js deleted file mode 100644 index ce5869661..000000000 --- a/static/js/runtime-main.b019aae8.js +++ /dev/null @@ -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