import React from "react"; import ReactDOM from "react-dom"; import rough from "roughjs/bin/wrappers/rough"; import { TwitterPicker } from "react-color"; import { moveOneLeft, moveAllLeft, moveOneRight, moveAllRight } from "./zindex"; import { randomSeed } from "./random"; import { newElement, resizeTest, generateDraw, isTextElement } from "./element"; import { renderScene, clearSelection, getSelectedIndices, deleteSelectedElements, setSelection, isOverScrollBars, someElementIsSelected, getSelectedAttribute, loadFromJSON, saveAsJSON, exportAsPNG, restoreFromLocalStorage, saveToLocalStorage, hasBackground, hasStroke, getElementAtPosition, createScene } from "./scene"; import { AppState } from "./types"; import { ExcalidrawElement, ExcalidrawTextElement } from "./element/types"; import { getDateTime, capitalizeString, isInputLike } from "./utils"; import EditableText from "./components/EditableText"; import "./styles.scss"; const { elements } = createScene(); const DEFAULT_PROJECT_NAME = `excalidraw-${getDateTime()}`; let skipHistory = false; const stateHistory: string[] = []; const redoStack: string[] = []; function generateHistoryCurrentEntry() { return JSON.stringify( elements.map(element => ({ ...element, isSelected: false })) ); } function pushHistoryEntry(newEntry: string) { if ( stateHistory.length > 0 && stateHistory[stateHistory.length - 1] === newEntry ) { // If the last entry is the same as this one, ignore it return; } stateHistory.push(newEntry); } function restoreHistoryEntry(entry: string) { const newElements = JSON.parse(entry); elements.splice(0, elements.length); newElements.forEach((newElement: ExcalidrawElement) => { generateDraw(newElement); elements.push(newElement); }); // When restoring, we shouldn't add an history entry otherwise we'll be stuck with it and can't go back skipHistory = true; } const CANVAS_WINDOW_OFFSET_LEFT = 250; const CANVAS_WINDOW_OFFSET_TOP = 0; const KEYS = { ARROW_LEFT: "ArrowLeft", ARROW_RIGHT: "ArrowRight", ARROW_DOWN: "ArrowDown", ARROW_UP: "ArrowUp", ESCAPE: "Escape", DELETE: "Delete", BACKSPACE: "Backspace" }; // We inline font-awesome icons in order to save on js size rather than including the font awesome react library const SHAPES = [ { icon: ( // fa-mouse-pointer ), value: "selection" }, { icon: ( // fa-square ), value: "rectangle" }, { icon: ( // custom ), value: "diamond" }, { icon: ( // fa-circle ), value: "ellipse" }, { icon: ( // fa-long-arrow-alt-right ), value: "arrow" }, { icon: ( // fa-font ), value: "text" } ]; const shapesShortcutKeys = SHAPES.map(shape => shape.value[0]); function findElementByKey(key: string) { const defaultElement = "selection"; return SHAPES.reduce((element, shape) => { if (shape.value[0] !== key) return element; return shape.value; }, defaultElement); } 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 = ""; } function addTextElement(element: ExcalidrawTextElement) { resetCursor(); const text = prompt("What text do you want?"); if (text === null || text === "") { return false; } const fontSize = 20; element.text = text; element.font = `${fontSize}px Virgil`; const font = context.font; context.font = element.font; const textMeasure = context.measureText(element.text); const width = textMeasure.width; const actualBoundingBoxAscent = textMeasure.actualBoundingBoxAscent || fontSize; const actualBoundingBoxDescent = textMeasure.actualBoundingBoxDescent || 0; element.actualBoundingBoxAscent = actualBoundingBoxAscent; context.font = font; const height = actualBoundingBoxAscent + actualBoundingBoxDescent; // Center the text element.x -= width / 2; element.y -= actualBoundingBoxAscent; element.width = width; element.height = height; return true; } function ButtonSelect({ options, value, onChange }: { options: { value: T; text: string }[]; value: T | null; onChange: (value: T) => void; }) { return (
{options.map(option => ( ))}
); } function ColorPicker({ color, onChange }: { color: string | null; onChange: (color: string) => void; }) { const [isActive, setActive] = React.useState(false); return (
Stroke Color
element.strokeColor )} onChange={color => this.changeStrokeColor(color)} /> {hasBackground(elements) && ( <>
Background Color
element.backgroundColor )} onChange={color => this.changeBackgroundColor(color)} />
Fill
element.fillStyle )} onChange={value => { this.changeProperty(element => { element.fillStyle = value; }); }} /> )} {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; }) } /> )}
Opacity
element.opacity) || 0 /* Put the opacity at 0 if there are two conflicting ones */ } /> )}

Canvas

Canvas Background Color
this.setState({ viewBackgroundColor: color })} />

Export

Name
{this.state.name && ( this.updateProjectName(name)} /> )}
Image
Scene
{ if (this.removeWheelEventListener) { this.removeWheelEventListener(); this.removeWheelEventListener = undefined; } if (canvas) { canvas.addEventListener("wheel", this.handleWheel, { passive: false }); this.removeWheelEventListener = () => canvas.removeEventListener("wheel", this.handleWheel); // Whenever React sets the width/height of the canvas element, // the context loses the scale transform. We need to re-apply it if ( canvasWidth !== lastCanvasWidth || canvasHeight !== lastCanvasHeight ) { lastCanvasWidth = canvasWidth; lastCanvasHeight = canvasHeight; canvas .getContext("2d")! .scale(window.devicePixelRatio, window.devicePixelRatio); } } }} onMouseDown={e => { if (lastMouseUp !== null) { // Unfortunately, sometimes we don't get a mouseup after a mousedown, // this can happen when a contextual menu or alert is triggered. In order to avoid // being in a weird state, we clean up on the next mousedown lastMouseUp(e); } // only handle left mouse button if (e.button !== 0) return; // fixes mousemove causing selection of UI texts #32 e.preventDefault(); // Preventing the event above disables default behavior // of defocusing potentially focused input, which is what we want // when clicking inside the canvas. if (isInputLike(document.activeElement)) { document.activeElement.blur(); } // Handle scrollbars dragging const { isOverHorizontalScrollBar, isOverVerticalScrollBar } = isOverScrollBars( elements, e.clientX - CANVAS_WINDOW_OFFSET_LEFT, e.clientY - CANVAS_WINDOW_OFFSET_TOP, canvasWidth, canvasHeight, this.state.scrollX, 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 element = newElement( this.state.elementType, x, y, this.state.currentItemStrokeColor, this.state.currentItemBackgroundColor, "hachure", 1, 1, 100 ); let resizeHandle: string | false = 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 }); }); this.setState({ resizingElement: resizeElement ? resizeElement : null }); if (resizeElement) { resizeHandle = resizeTest(resizeElement, x, y, { scrollX: this.state.scrollX, scrollY: this.state.scrollY, viewBackgroundColor: this.state.viewBackgroundColor }); document.documentElement.style.cursor = `${resizeHandle}-resize`; isResizingElements = true; } else { const hitElement = getElementAtPosition(elements, x, y); // If we click on something if (hitElement) { if (hitElement.isSelected) { // If that element is not 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; } } else { // If we don't click on anything, let's remove all the selected elements clearSelection(elements); } isDraggingElements = someElementIsSelected(elements); if (isDraggingElements) { document.documentElement.style.cursor = "move"; } } } if (isTextElement(element)) { if (!addTextElement(element)) { return; } } generateDraw(element); elements.push(element); if (this.state.elementType === "text") { this.setState({ draggingElement: null, elementType: "selection" }); element.isSelected = true; } else { this.setState({ draggingElement: element }); } let lastX = x; let lastY = y; if (isOverHorizontalScrollBar || isOverVerticalScrollBar) { lastX = e.clientX - CANVAS_WINDOW_OFFSET_LEFT; lastY = e.clientY - CANVAS_WINDOW_OFFSET_TOP; } const onMouseMove = (e: MouseEvent) => { const target = e.target; if (!(target instanceof HTMLElement)) { return; } if (isOverHorizontalScrollBar) { const x = e.clientX - CANVAS_WINDOW_OFFSET_LEFT; const dx = x - lastX; this.setState(state => ({ scrollX: state.scrollX - dx })); lastX = x; return; } if (isOverVerticalScrollBar) { const y = e.clientY - CANVAS_WINDOW_OFFSET_TOP; const dy = y - lastY; this.setState(state => ({ scrollY: state.scrollY - dy })); lastY = y; return; } if (isResizingElements && this.state.resizingElement) { 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; selectedElements.forEach(element => { switch (resizeHandle) { case "nw": element.width += element.x - lastX; element.height += element.y - lastY; element.x = lastX; element.y = lastY; break; case "ne": element.width = lastX - element.x; element.height += element.y - lastY; element.y = lastY; break; case "sw": element.width += element.x - lastX; element.x = lastX; element.height = lastY - element.y; break; case "se": element.width += x - lastX; if (e.shiftKey) { element.height = element.width; } else { element.height += y - lastY; } break; case "n": element.height += element.y - lastY; element.y = lastY; break; case "w": element.width += element.x - lastX; element.x = lastX; break; case "s": element.height = lastY - element.y; break; case "e": element.width = lastX - element.x; break; } el.x = element.x; el.y = element.y; generateDraw(el); }); lastX = x; lastY = y; // We don't want to save history when resizing an element skipHistory = true; this.forceUpdate(); return; } } 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; selectedElements.forEach(element => { element.x += x - lastX; element.y += y - lastY; }); lastX = x; lastY = y; // We don't want to save history when dragging an element to initially size it skipHistory = true; this.forceUpdate(); return; } } // It is very important to read this.state within each move event, // otherwise we would read a stale one! const draggingElement = this.state.draggingElement; if (!draggingElement) return; let width = e.clientX - CANVAS_WINDOW_OFFSET_LEFT - draggingElement.x - this.state.scrollX; let height = e.clientY - CANVAS_WINDOW_OFFSET_TOP - draggingElement.y - this.state.scrollY; draggingElement.width = width; // Make a perfect square or circle when shift is enabled draggingElement.height = e.shiftKey ? Math.abs(width) * Math.sign(height) : height; generateDraw(draggingElement); if (this.state.elementType === "selection") { setSelection(elements, draggingElement); } // We don't want to save history when moving an element skipHistory = true; this.forceUpdate(); }; const onMouseUp = (e: MouseEvent) => { const { draggingElement, elementType } = this.state; lastMouseUp = null; window.removeEventListener("mousemove", onMouseMove); window.removeEventListener("mouseup", onMouseUp); resetCursor(); // if no element is clicked, clear the selection and redraw if (draggingElement === null) { clearSelection(elements); this.forceUpdate(); return; } if (elementType === "selection") { if (isDraggingElements) { isDraggingElements = false; } elements.pop(); } else { draggingElement.isSelected = true; } this.setState({ draggingElement: null, elementType: "selection" }); this.forceUpdate(); }; lastMouseUp = onMouseUp; window.addEventListener("mousemove", onMouseMove); window.addEventListener("mouseup", onMouseUp); // We don't want to save history on mouseDown, only on mouseUp when it's fully configured skipHistory = true; 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; if (getElementAtPosition(elements, x, y)) { return; } const element = newElement( "text", x, y, this.state.currentItemStrokeColor, this.state.currentItemBackgroundColor, "hachure", 1, 1, 100 ); if (!addTextElement(element as ExcalidrawTextElement)) { return; } generateDraw(element); elements.push(element); this.setState({ draggingElement: null, elementType: "selection" }); element.isSelected = true; this.forceUpdate(); }} /> ); } private handleWheel = (e: WheelEvent) => { e.preventDefault(); const { deltaX, deltaY } = e; this.setState(state => ({ scrollX: state.scrollX - deltaX, scrollY: state.scrollY - deltaY })); }; componentDidUpdate() { renderScene(elements, rc, canvas, { scrollX: this.state.scrollX, scrollY: this.state.scrollY, viewBackgroundColor: this.state.viewBackgroundColor }); saveToLocalStorage(elements, this.state); if (!skipHistory) { pushHistoryEntry(generateHistoryCurrentEntry()); redoStack.splice(0, redoStack.length); } skipHistory = false; } } 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);