import React from "react"; import ReactDOM from "react-dom"; import rough from "roughjs/bin/wrappers/rough"; import { RoughCanvas } from "roughjs/bin/canvas"; import { TwitterPicker } from "react-color"; import { moveOneLeft, moveAllLeft, moveOneRight, moveAllRight } from "./zindex"; import { randomSeed } from "./random"; import { roundRect } from "./roundRect"; import { newElement, resizeTest, generateDraw, getElementAbsoluteX1, getElementAbsoluteX2, getElementAbsoluteY1, getElementAbsoluteY2, handlerRectangles, hitTest, isTextElement } from "./element"; import { SceneState } from "./scene/types"; import { ExcalidrawElement, ExcalidrawTextElement } from "./element/types"; import EditableText from "./components/EditableText"; import "./styles.scss"; const LOCAL_STORAGE_KEY = "excalidraw"; const LOCAL_STORAGE_KEY_STATE = "excalidraw-state"; const elements = Array.of(); 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 SCROLLBAR_WIDTH = 6; const SCROLLBAR_MIN_SIZE = 15; const SCROLLBAR_MARGIN = 4; const SCROLLBAR_COLOR = "rgba(0,0,0,0.3)"; const CANVAS_WINDOW_OFFSET_LEFT = 250; const CANVAS_WINDOW_OFFSET_TOP = 0; function getScrollBars( canvasWidth: number, canvasHeight: number, scrollX: number, scrollY: number ) { let minX = Infinity; let maxX = 0; let minY = Infinity; let maxY = 0; elements.forEach(element => { minX = Math.min(minX, getElementAbsoluteX1(element)); maxX = Math.max(maxX, getElementAbsoluteX2(element)); minY = Math.min(minY, getElementAbsoluteY1(element)); maxY = Math.max(maxY, getElementAbsoluteY2(element)); }); minX += scrollX; maxX += scrollX; minY += scrollY; maxY += scrollY; const leftOverflow = Math.max(-minX, 0); const rightOverflow = Math.max(-(canvasWidth - maxX), 0); const topOverflow = Math.max(-minY, 0); const bottomOverflow = Math.max(-(canvasHeight - maxY), 0); // horizontal scrollbar let horizontalScrollBar = null; if (leftOverflow || rightOverflow) { horizontalScrollBar = { x: Math.min( leftOverflow + SCROLLBAR_MARGIN, canvasWidth - SCROLLBAR_MIN_SIZE - SCROLLBAR_MARGIN ), y: canvasHeight - SCROLLBAR_WIDTH - SCROLLBAR_MARGIN, width: Math.max( canvasWidth - rightOverflow - leftOverflow - SCROLLBAR_MARGIN * 2, SCROLLBAR_MIN_SIZE ), height: SCROLLBAR_WIDTH }; } // vertical scrollbar let verticalScrollBar = null; if (topOverflow || bottomOverflow) { verticalScrollBar = { x: canvasWidth - SCROLLBAR_WIDTH - SCROLLBAR_MARGIN, y: Math.min( topOverflow + SCROLLBAR_MARGIN, canvasHeight - SCROLLBAR_MIN_SIZE - SCROLLBAR_MARGIN ), width: SCROLLBAR_WIDTH, height: Math.max( canvasHeight - bottomOverflow - topOverflow - SCROLLBAR_WIDTH * 2, SCROLLBAR_MIN_SIZE ) }; } return { horizontal: horizontalScrollBar, vertical: verticalScrollBar }; } function isOverScrollBars( x: number, y: number, canvasWidth: number, canvasHeight: number, scrollX: number, scrollY: number ) { const scrollBars = getScrollBars(canvasWidth, canvasHeight, scrollX, scrollY); const [isOverHorizontalScrollBar, isOverVerticalScrollBar] = [ scrollBars.horizontal, scrollBars.vertical ].map( scrollBar => scrollBar && scrollBar.x <= x && x <= scrollBar.x + scrollBar.width && scrollBar.y <= y && y <= scrollBar.y + scrollBar.height ); return { isOverHorizontalScrollBar, isOverVerticalScrollBar }; } function renderScene( rc: RoughCanvas, canvas: HTMLCanvasElement, sceneState: SceneState, // extra options, currently passed by export helper { offsetX, offsetY, renderScrollbars = true, renderSelection = true }: { offsetX?: number; offsetY?: number; renderScrollbars?: boolean; renderSelection?: boolean; } = {} ) { if (!canvas) return; const context = canvas.getContext("2d")!; const fillStyle = context.fillStyle; if (typeof sceneState.viewBackgroundColor === "string") { context.fillStyle = sceneState.viewBackgroundColor; context.fillRect(0, 0, canvas.width, canvas.height); } else { context.clearRect(0, 0, canvas.width, canvas.height); } context.fillStyle = fillStyle; const selectedIndices = getSelectedIndices(); sceneState = { ...sceneState, scrollX: typeof offsetX === "number" ? offsetX : sceneState.scrollX, scrollY: typeof offsetY === "number" ? offsetY : sceneState.scrollY }; elements.forEach(element => { element.draw(rc, context, sceneState); if (renderSelection && element.isSelected) { const margin = 4; const elementX1 = getElementAbsoluteX1(element); const elementX2 = getElementAbsoluteX2(element); const elementY1 = getElementAbsoluteY1(element); const elementY2 = getElementAbsoluteY2(element); const lineDash = context.getLineDash(); context.setLineDash([8, 4]); context.strokeRect( elementX1 - margin + sceneState.scrollX, elementY1 - margin + sceneState.scrollY, elementX2 - elementX1 + margin * 2, elementY2 - elementY1 + margin * 2 ); context.setLineDash(lineDash); if (element.type !== "text" && selectedIndices.length === 1) { const handlers = handlerRectangles(element, sceneState); Object.values(handlers).forEach(handler => { context.strokeRect(handler[0], handler[1], handler[2], handler[3]); }); } } }); if (renderScrollbars) { const scrollBars = getScrollBars( context.canvas.width / window.devicePixelRatio, context.canvas.height / window.devicePixelRatio, sceneState.scrollX, sceneState.scrollY ); const strokeStyle = context.strokeStyle; context.fillStyle = SCROLLBAR_COLOR; context.strokeStyle = "rgba(255,255,255,0.8)"; [scrollBars.horizontal, scrollBars.vertical].forEach(scrollBar => { if (scrollBar) roundRect( context, scrollBar.x, scrollBar.y, scrollBar.width, scrollBar.height, SCROLLBAR_WIDTH / 2 ); }); context.strokeStyle = strokeStyle; context.fillStyle = fillStyle; } } function saveAsJSON(name: string) { const serialized = JSON.stringify({ version: 1, source: window.location.origin, elements }); saveFile( `${name}.json`, "data:text/plain;charset=utf-8," + encodeURIComponent(serialized) ); } function loadFromJSON() { const input = document.createElement("input"); const reader = new FileReader(); input.type = "file"; input.accept = ".json"; input.onchange = () => { if (!input.files!.length) { alert("A file was not selected."); return; } reader.readAsText(input.files![0], "utf8"); }; input.click(); return new Promise(resolve => { reader.onloadend = () => { if (reader.readyState === FileReader.DONE) { const data = JSON.parse(reader.result as string); restore(data.elements, null); resolve(); } }; }); } function exportAsPNG({ exportBackground, exportPadding = 10, viewBackgroundColor, name }: { exportBackground: boolean; exportPadding?: number; viewBackgroundColor: string; scrollX: number; scrollY: number; name: string; }) { if (!elements.length) return window.alert("Cannot export empty canvas."); // calculate smallest area to fit the contents in let subCanvasX1 = Infinity; let subCanvasX2 = 0; let subCanvasY1 = Infinity; let subCanvasY2 = 0; elements.forEach(element => { subCanvasX1 = Math.min(subCanvasX1, getElementAbsoluteX1(element)); subCanvasX2 = Math.max(subCanvasX2, getElementAbsoluteX2(element)); subCanvasY1 = Math.min(subCanvasY1, getElementAbsoluteY1(element)); subCanvasY2 = Math.max(subCanvasY2, getElementAbsoluteY2(element)); }); function distance(x: number, y: number) { return Math.abs(x > y ? x - y : y - x); } const tempCanvas = document.createElement("canvas"); tempCanvas.style.display = "none"; document.body.appendChild(tempCanvas); tempCanvas.width = distance(subCanvasX1, subCanvasX2) + exportPadding * 2; tempCanvas.height = distance(subCanvasY1, subCanvasY2) + exportPadding * 2; renderScene( rough.canvas(tempCanvas), tempCanvas, { viewBackgroundColor: exportBackground ? viewBackgroundColor : null, scrollX: 0, scrollY: 0 }, { offsetX: -subCanvasX1 + exportPadding, offsetY: -subCanvasY1 + exportPadding, renderScrollbars: false, renderSelection: false } ); saveFile(`${name}.png`, tempCanvas.toDataURL("image/png")); // clean up the DOM if (tempCanvas !== canvas) tempCanvas.remove(); } function saveFile(name: string, data: string) { // create a temporary elem which we'll use to download the image const link = document.createElement("a"); link.setAttribute("download", name); link.setAttribute("href", data); link.click(); // clean up link.remove(); } function getDateTime() { const date = new Date(); const year = date.getFullYear(); const month = date.getMonth() + 1; const day = date.getDate(); const hr = date.getHours(); const min = date.getMinutes(); const secs = date.getSeconds(); return `${year}${month}${day}${hr}${min}${secs}`; } function isInputLike( target: Element | EventTarget | null ): target is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement { return ( target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement ); } function setSelection(selection: ExcalidrawElement) { const selectionX1 = getElementAbsoluteX1(selection); const selectionX2 = getElementAbsoluteX2(selection); const selectionY1 = getElementAbsoluteY1(selection); const selectionY2 = getElementAbsoluteY2(selection); elements.forEach(element => { const elementX1 = getElementAbsoluteX1(element); const elementX2 = getElementAbsoluteX2(element); const elementY1 = getElementAbsoluteY1(element); const elementY2 = getElementAbsoluteY2(element); element.isSelected = element.type !== "selection" && selectionX1 <= elementX1 && selectionY1 <= elementY1 && selectionX2 >= elementX2 && selectionY2 >= elementY2; }); } function clearSelection() { elements.forEach(element => { element.isSelected = false; }); } function resetCursor() { document.documentElement.style.cursor = ""; } function deleteSelectedElements() { for (let i = elements.length - 1; i >= 0; --i) { if (elements[i].isSelected) { elements.splice(i, 1); } } } function save(state: AppState) { localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(elements)); localStorage.setItem(LOCAL_STORAGE_KEY_STATE, JSON.stringify(state)); } function restoreFromLocalStorage() { const savedElements = localStorage.getItem(LOCAL_STORAGE_KEY); const savedState = localStorage.getItem(LOCAL_STORAGE_KEY_STATE); return restore(savedElements, savedState); } function restore( savedElements: string | ExcalidrawElement[] | null, savedState: string | null ) { try { if (savedElements) { elements.splice( 0, elements.length, ...(typeof savedElements === "string" ? JSON.parse(savedElements) : savedElements) ); elements.forEach((element: ExcalidrawElement) => { element.fillStyle = element.fillStyle || "hachure"; element.strokeWidth = element.strokeWidth || 1; element.roughness = element.roughness || 1; element.opacity = element.opacity === null || element.opacity === undefined ? 100 : element.opacity; generateDraw(element); }); } return savedState ? JSON.parse(savedState) : null; } catch (e) { elements.splice(0, elements.length); return null; } } type AppState = { draggingElement: ExcalidrawElement | null; resizingElement: ExcalidrawElement | null; elementType: string; exportBackground: boolean; currentItemStrokeColor: string; currentItemBackgroundColor: string; viewBackgroundColor: string; scrollX: number; scrollY: number; name: string; }; 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 capitalize(str: string) { return str.charAt(0).toUpperCase() + str.slice(1); } 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 getSelectedIndices() { const selectedIndices: number[] = []; elements.forEach((element, index) => { if (element.isSelected) { selectedIndices.push(index); } }); return selectedIndices; } const someElementIsSelected = () => elements.some(element => element.isSelected); const hasBackground = () => elements.some( element => element.isSelected && (element.type === "rectangle" || element.type === "ellipse" || element.type === "diamond") ); const hasStroke = () => elements.some( element => element.isSelected && (element.type === "rectangle" || element.type === "ellipse" || element.type === "diamond" || element.type === "arrow") ); function getSelectedAttribute( getAttribute: (element: ExcalidrawElement) => T ): T | null { const attributes = Array.from( new Set( elements .filter(element => element.isSelected) .map(element => getAttribute(element)) ) ); return attributes.length === 1 ? attributes[0] : null; } 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 getElementAtPosition(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) { if (hitTest(elements[i], x, y)) { hitElement = elements[i]; break; } } return hitElement; } 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() && ( <>
Background Color
element.backgroundColor )} onChange={color => this.changeBackgroundColor(color)} />
Fill
element.fillStyle)} onChange={value => { this.changeProperty(element => { element.fillStyle = value; }); }} /> )} {hasStroke() && ( <>
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( 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(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(); } // 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(); } isDraggingElements = someElementIsSelected(); 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(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(); 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(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(rc, canvas, { scrollX: this.state.scrollX, scrollY: this.state.scrollY, viewBackgroundColor: this.state.viewBackgroundColor }); save(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);