import React from "react"; import ReactDOM from "react-dom"; import rough from "roughjs/bin/rough"; import { RoughCanvas } from "roughjs/bin/canvas"; import { newElement, newTextElement, duplicateElement, resizeTest, normalizeResizeHandle, isInvisiblySmallElement, isTextElement, textWysiwyg, getCommonBounds, getCursorForResizingElement, getPerfectElementSize, resizePerfectLineForNWHandler, normalizeDimensions, } from "./element"; import { clearSelection, deleteSelectedElements, getElementsWithinSelection, isOverScrollBars, restoreFromLocalStorage, saveToLocalStorage, getElementAtPosition, createScene, getElementContainingPosition, hasBackground, hasStroke, hasText, exportCanvas, importFromBackend, } from "./scene"; import { renderScene } from "./renderer"; import { AppState } from "./types"; import { ExcalidrawElement } from "./element/types"; import { isInputLike, debounce, capitalizeString, distance } from "./utils"; import { KEYS, isArrowKey } from "./keys"; import { findShapeByKey, shapesShortcutKeys, SHAPES } from "./shapes"; import { createHistory } from "./history"; import ContextMenu from "./components/ContextMenu"; import "./styles.scss"; import { getElementWithResizeHandler } from "./element/resizeTest"; import { ActionManager, actionDeleteSelected, actionSendBackward, actionBringForward, actionSendToBack, actionBringToFront, actionSelectAll, actionChangeStrokeColor, actionChangeBackgroundColor, actionChangeOpacity, actionChangeStrokeWidth, actionChangeFillStyle, actionChangeSloppiness, actionChangeFontSize, actionChangeFontFamily, actionChangeViewBackgroundColor, actionClearCanvas, actionChangeProjectName, actionChangeExportBackground, actionLoadScene, actionSaveScene, actionCopyStyles, actionPasteStyles, } from "./actions"; import { Action, ActionResult } from "./actions/types"; import { getDefaultAppState } from "./appState"; import { Island } from "./components/Island"; import Stack from "./components/Stack"; import { FixedSideContainer } from "./components/FixedSideContainer"; import { ToolButton } from "./components/ToolButton"; import { LockIcon } from "./components/LockIcon"; import { ExportDialog } from "./components/ExportDialog"; import { withTranslation } from "react-i18next"; import { LanguageList } from "./components/LanguageList"; import i18n, { languages, parseDetectedLang } from "./i18n"; let { elements } = createScene(); const { history } = createHistory(); const CANVAS_WINDOW_OFFSET_LEFT = 0; const CANVAS_WINDOW_OFFSET_TOP = 0; function resetCursor() { document.documentElement.style.cursor = ""; } const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5; const ELEMENT_TRANSLATE_AMOUNT = 1; const TEXT_TO_CENTER_SNAP_THRESHOLD = 30; const CURSOR_TYPE = { TEXT: "text", CROSSHAIR: "crosshair", }; let lastCanvasWidth = -1; let lastCanvasHeight = -1; let lastMouseUp: ((e: any) => void) | null = null; 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 }; } function pickAppStatePropertiesForHistory( appState: AppState, ): Partial { return { exportBackground: appState.exportBackground, currentItemStrokeColor: appState.currentItemStrokeColor, currentItemBackgroundColor: appState.currentItemBackgroundColor, currentItemFillStyle: appState.currentItemFillStyle, currentItemStrokeWidth: appState.currentItemStrokeWidth, currentItemRoughness: appState.currentItemRoughness, currentItemOpacity: appState.currentItemOpacity, currentItemFont: appState.currentItemFont, viewBackgroundColor: appState.viewBackgroundColor, name: appState.name, }; } let cursorX = 0; let cursorY = 0; export class App extends React.Component { canvas: HTMLCanvasElement | null = null; rc: RoughCanvas | null = null; actionManager: ActionManager = new ActionManager(); canvasOnlyActions: Array; constructor(props: any) { super(props); this.actionManager.registerAction(actionDeleteSelected); this.actionManager.registerAction(actionSendToBack); this.actionManager.registerAction(actionBringToFront); this.actionManager.registerAction(actionSendBackward); this.actionManager.registerAction(actionBringForward); this.actionManager.registerAction(actionSelectAll); this.actionManager.registerAction(actionChangeStrokeColor); this.actionManager.registerAction(actionChangeBackgroundColor); this.actionManager.registerAction(actionChangeFillStyle); this.actionManager.registerAction(actionChangeStrokeWidth); this.actionManager.registerAction(actionChangeOpacity); this.actionManager.registerAction(actionChangeSloppiness); this.actionManager.registerAction(actionChangeFontSize); this.actionManager.registerAction(actionChangeFontFamily); this.actionManager.registerAction(actionChangeViewBackgroundColor); this.actionManager.registerAction(actionClearCanvas); this.actionManager.registerAction(actionChangeProjectName); this.actionManager.registerAction(actionChangeExportBackground); this.actionManager.registerAction(actionSaveScene); this.actionManager.registerAction(actionLoadScene); this.actionManager.registerAction(actionCopyStyles); this.actionManager.registerAction(actionPasteStyles); this.canvasOnlyActions = [actionSelectAll]; } private syncActionResult = (res: ActionResult) => { if (res.elements !== undefined) { elements = res.elements; this.setState({}); } if (res.appState !== undefined) { this.setState({ ...res.appState }); } }; private onCut = (e: ClipboardEvent) => { if (isInputLike(e.target)) return; e.clipboardData?.setData( "text/plain", JSON.stringify( elements .filter(element => element.isSelected) .map(({ shape, ...el }) => el), ), ); elements = deleteSelectedElements(elements); this.setState({}); e.preventDefault(); }; private onCopy = (e: ClipboardEvent) => { if (isInputLike(e.target)) return; e.clipboardData?.setData( "text/plain", JSON.stringify( elements .filter(element => element.isSelected) .map(({ shape, ...el }) => el), ), ); e.preventDefault(); }; private onPaste = (e: ClipboardEvent) => { if (isInputLike(e.target)) return; const paste = e.clipboardData?.getData("text") || ""; this.addElementsFromPaste(paste); e.preventDefault(); }; private onUnload = () => { this.saveDebounced(); this.saveDebounced.flush(); }; public shouldComponentUpdate(props: any, nextState: AppState) { if (!history.isRecording()) { // temporary hack to fix #592 // eslint-disable-next-line react/no-direct-mutation-state this.state = nextState; this.componentDidUpdate(); return false; } return true; } public async componentDidMount() { document.addEventListener("copy", this.onCopy); document.addEventListener("paste", this.onPaste); document.addEventListener("cut", this.onCut); document.addEventListener("keydown", this.onKeyDown, false); document.addEventListener("mousemove", this.updateCurrentCursorPosition); window.addEventListener("resize", this.onResize, false); window.addEventListener("unload", this.onUnload, false); let data; const searchParams = new URLSearchParams(window.location.search); if (searchParams.get("id") != null) { data = await importFromBackend(searchParams.get("id")); window.history.replaceState({}, "Excalidraw", window.location.origin); } else { data = restoreFromLocalStorage(); } if (data.elements) { elements = data.elements; } if (data.appState) { this.setState(data.appState); } else { this.setState({}); } } public componentWillUnmount() { document.removeEventListener("copy", this.onCopy); document.removeEventListener("paste", this.onPaste); document.removeEventListener("cut", this.onCut); document.removeEventListener("keydown", this.onKeyDown, false); document.removeEventListener( "mousemove", this.updateCurrentCursorPosition, false, ); window.removeEventListener("resize", this.onResize, false); window.removeEventListener("unload", this.onUnload, false); } public state: AppState = getDefaultAppState(); private onResize = () => { this.setState({}); }; private updateCurrentCursorPosition = (e: MouseEvent) => { cursorX = e.x; cursorY = e.y; }; private onKeyDown = (event: KeyboardEvent) => { if (event.key === KEYS.ESCAPE && !this.state.draggingElement) { elements = clearSelection(elements); this.setState({}); this.setState({ elementType: "selection" }); if (window.document.activeElement instanceof HTMLElement) { window.document.activeElement.blur(); } event.preventDefault(); return; } if (isInputLike(event.target)) return; const actionResult = this.actionManager.handleKeyDown( event, elements, this.state, ); if (actionResult) { this.syncActionResult(actionResult); if (actionResult) return; } const shape = findShapeByKey(event.key); if (isArrowKey(event.key)) { const step = event.shiftKey ? ELEMENT_SHIFT_TRANSLATE_AMOUNT : ELEMENT_TRANSLATE_AMOUNT; elements = elements.map(el => { if (el.isSelected) { const element = { ...el }; if (event.key === KEYS.ARROW_LEFT) element.x -= step; else if (event.key === KEYS.ARROW_RIGHT) element.x += step; else if (event.key === KEYS.ARROW_UP) element.y -= step; else if (event.key === KEYS.ARROW_DOWN) element.y += step; return element; } return el; }); this.setState({}); event.preventDefault(); } else if ( shapesShortcutKeys.includes(event.key.toLowerCase()) && !event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey && this.state.draggingElement === null ) { if (shape === "text") { document.documentElement.style.cursor = CURSOR_TYPE.TEXT; } else { document.documentElement.style.cursor = CURSOR_TYPE.CROSSHAIR; } this.setState({ elementType: shape }); } else if (event[KEYS.META] && event.code === "KeyZ") { event.preventDefault(); if (event.shiftKey) { // Redo action const data = history.redoOnce(); if (data !== null) { elements = data.elements; this.setState(data.appState); } } else { // undo action const data = history.undoOnce(); if (data !== null) { elements = data.elements; this.setState(data.appState); } } } }; private removeWheelEventListener: (() => void) | undefined; private copyToClipboard = () => { const text = JSON.stringify( elements .filter(element => element.isSelected) .map(({ shape, ...el }) => el), ); if ("clipboard" in navigator && "writeText" in navigator.clipboard) { navigator.clipboard.writeText(text); } else { document.execCommand("copy"); } }; private pasteFromClipboard = () => { if ("clipboard" in navigator && "readText" in navigator.clipboard) { navigator.clipboard .readText() .then(text => this.addElementsFromPaste(text)); } }; private renderSelectedShapeActions(elements: readonly ExcalidrawElement[]) { const { t } = this.props; const { elementType, editingElement } = this.state; const targetElements = editingElement ? [editingElement] : elements.filter(el => el.isSelected); if (!targetElements.length && elementType === "selection") { return null; } return (
{this.actionManager.renderAction( "changeStrokeColor", elements, this.state, this.syncActionResult, t, )} {(hasBackground(elementType) || targetElements.some(element => hasBackground(element.type))) && ( <> {this.actionManager.renderAction( "changeBackgroundColor", elements, this.state, this.syncActionResult, t, )} {this.actionManager.renderAction( "changeFillStyle", elements, this.state, this.syncActionResult, t, )} )} {(hasStroke(elementType) || targetElements.some(element => hasStroke(element.type))) && ( <> {this.actionManager.renderAction( "changeStrokeWidth", elements, this.state, this.syncActionResult, t, )} {this.actionManager.renderAction( "changeSloppiness", elements, this.state, this.syncActionResult, t, )} )} {(hasText(elementType) || targetElements.some(element => hasText(element.type))) && ( <> {this.actionManager.renderAction( "changeFontSize", elements, this.state, this.syncActionResult, t, )} {this.actionManager.renderAction( "changeFontFamily", elements, this.state, this.syncActionResult, t, )} )} {this.actionManager.renderAction( "changeOpacity", elements, this.state, this.syncActionResult, t, )} {this.actionManager.renderAction( "deleteSelectedElements", elements, this.state, this.syncActionResult, t, )}
); } private renderShapesSwitcher() { const { t } = this.props; return ( <> {SHAPES.map(({ value, icon }, index) => { const label = t(`toolBar.${value}`); return ( { this.setState({ elementType: value }); elements = clearSelection(elements); document.documentElement.style.cursor = value === "text" ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR; this.setState({}); }} > ); })} ); } private renderCanvasActions() { const { t } = this.props; return ( {this.actionManager.renderAction( "loadScene", elements, this.state, this.syncActionResult, t, )} {this.actionManager.renderAction( "saveScene", elements, this.state, this.syncActionResult, t, )} { if (this.canvas) exportCanvas("png", exportedElements, this.canvas, { exportBackground: this.state.exportBackground, name: this.state.name, viewBackgroundColor: this.state.viewBackgroundColor, scale, }); }} onExportToClipboard={(exportedElements, scale) => { if (this.canvas) exportCanvas("clipboard", exportedElements, this.canvas, { exportBackground: this.state.exportBackground, name: this.state.name, viewBackgroundColor: this.state.viewBackgroundColor, scale, }); }} onExportToBackend={exportedElements => { if (this.canvas) exportCanvas( "backend", exportedElements.map(element => ({ ...element, isSelected: false, })), this.canvas, this.state, ); }} /> {this.actionManager.renderAction( "clearCanvas", elements, this.state, this.syncActionResult, t, )} {this.actionManager.renderAction( "changeViewBackgroundColor", elements, this.state, this.syncActionResult, t, )} ); } public render() { const canvasWidth = window.innerWidth - CANVAS_WINDOW_OFFSET_LEFT; const canvasHeight = window.innerHeight - CANVAS_WINDOW_OFFSET_TOP; const { t } = this.props; return (

{t("headings.canvasActions")}

{this.renderCanvasActions()}

{t("headings.selectedShapeActions")}

{this.renderSelectedShapeActions(elements)}

{t("headings.shapes")}

{this.renderShapesSwitcher()}
{ this.setState({ elementLocked: !this.state.elementLocked, elementType: this.state.elementLocked ? "selection" : this.state.elementType, }); }} title={t("toolBar.lock")} />
{ if (this.canvas === null) { this.canvas = canvas; this.rc = rough.canvas(this.canvas!); } 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); } } }} onContextMenu={e => { e.preventDefault(); const { x, y } = viewportCoordsToSceneCoords(e, this.state); const element = getElementAtPosition(elements, x, y); if (!element) { ContextMenu.push({ options: [ navigator.clipboard && { label: t("labels.paste"), action: () => this.pasteFromClipboard(), }, ...this.actionManager.getContextMenuItems( elements, this.state, this.syncActionResult, action => this.canvasOnlyActions.includes(action), t, ), ], top: e.clientY, left: e.clientX, }); return; } if (!element.isSelected) { elements = clearSelection(elements); element.isSelected = true; this.setState({}); } ContextMenu.push({ options: [ navigator.clipboard && { label: t("labels.copy"), action: this.copyToClipboard, }, navigator.clipboard && { label: t("labels.paste"), action: () => this.pasteFromClipboard(), }, ...this.actionManager.getContextMenuItems( elements, this.state, this.syncActionResult, action => !this.canvasOnlyActions.includes(action), t, ), ], top: e.clientY, left: e.clientX, }); }} 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); } // pan canvas on wheel button drag if (e.button === 1) { let { clientX: lastX, clientY: lastY } = e; const onMouseMove = (e: MouseEvent) => { document.documentElement.style.cursor = `grabbing`; let deltaX = lastX - e.clientX; let deltaY = lastY - e.clientY; lastX = e.clientX; lastY = e.clientY; // We don't want to save history when panning around history.skipRecording(); this.setState(state => ({ scrollX: state.scrollX - deltaX, scrollY: state.scrollY - deltaY, })); }; const onMouseUp = (lastMouseUp = (e: MouseEvent) => { lastMouseUp = null; resetCursor(); window.removeEventListener("mousemove", onMouseMove); window.removeEventListener("mouseup", onMouseUp); }); window.addEventListener("mousemove", onMouseMove, { passive: true, }); window.addEventListener("mouseup", onMouseUp); return; } // 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 element, which is what we // want when clicking inside the canvas. if (document.activeElement instanceof HTMLElement) { 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, y } = viewportCoordsToSceneCoords(e, this.state); const originX = x; const originY = y; let element = newElement( this.state.elementType, x, y, this.state.currentItemStrokeColor, this.state.currentItemBackgroundColor, this.state.currentItemFillStyle, this.state.currentItemStrokeWidth, this.state.currentItemRoughness, this.state.currentItemOpacity, ); if (isTextElement(element)) { element = newTextElement( element, "", this.state.currentItemFont, ); } type ResizeTestType = ReturnType; let resizeHandle: ResizeTestType = false; let isResizingElements = false; let draggingOccurred = false; let hitElement: ExcalidrawElement | null = null; let elementIsAddedToSelection = false; if (this.state.elementType === "selection") { const resizeElement = getElementWithResizeHandler( elements, { x, y }, this.state, ); this.setState({ resizingElement: resizeElement ? resizeElement.element : null, }); if (resizeElement) { resizeHandle = resizeElement.resizeHandle; document.documentElement.style.cursor = getCursorForResizingElement( resizeElement, ); isResizingElements = true; } else { hitElement = getElementAtPosition(elements, x, y); // clear selection if shift is not clicked if (!hitElement?.isSelected && !e.shiftKey) { elements = clearSelection(elements); } // If we click on something if (hitElement) { // deselect if item is selected // if shift is not clicked, this will always return true // otherwise, it will trigger selection based on current // state of the box if (!hitElement.isSelected) { hitElement.isSelected = true; elementIsAddedToSelection = true; } // We duplicate the selected element if alt is pressed on Mouse down if (e.altKey) { elements = [ ...elements.map(element => ({ ...element, isSelected: false, })), ...elements .filter(element => element.isSelected) .map(element => { const newElement = duplicateElement(element); newElement.isSelected = true; return newElement; }), ]; } } } } else { elements = clearSelection(elements); } 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; } } const resetSelection = () => { this.setState({ draggingElement: null, editingElement: null, elementType: "selection", }); }; textWysiwyg({ initText: "", x: textX, y: textY, strokeColor: this.state.currentItemStrokeColor, opacity: this.state.currentItemOpacity, font: this.state.currentItemFont, onSubmit: text => { if (text) { elements = [ ...elements, { ...newTextElement( element, text, this.state.currentItemFont, ), isSelected: true, }, ]; } resetSelection(); }, onCancel: () => { resetSelection(); }, }); this.setState({ elementType: "selection", editingElement: element, }); return; } elements = [...elements, element]; 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; // We don't want to save history when scrolling history.skipRecording(); this.setState(state => ({ scrollX: state.scrollX - dx })); lastX = x; return; } if (isOverVerticalScrollBar) { const y = e.clientY - CANVAS_WINDOW_OFFSET_TOP; const dy = y - lastY; // We don't want to save history when scrolling history.skipRecording(); 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, y } = viewportCoordsToSceneCoords(e, this.state); const deltaX = x - lastX; const deltaY = y - lastY; const element = selectedElements[0]; const isLinear = element.type === "line" || element.type === "arrow"; switch (resizeHandle) { case "nw": element.width -= deltaX; element.x += deltaX; if (e.shiftKey) { if (isLinear) { resizePerfectLineForNWHandler(element, x, y); } else { element.y += element.height - element.width; element.height = element.width; } } else { element.height -= deltaY; element.y += deltaY; } break; case "ne": element.width += deltaX; if (e.shiftKey) { element.y += element.height - element.width; element.height = element.width; } else { element.height -= deltaY; element.y += deltaY; } break; case "sw": element.width -= deltaX; element.x += deltaX; if (e.shiftKey) { element.height = element.width; } else { element.height += deltaY; } break; case "se": if (e.shiftKey) { if (isLinear) { const { width, height } = getPerfectElementSize( element.type, x - element.x, y - element.y, ); element.width = width; element.height = height; } else { element.width += deltaX; element.height = element.width; } } else { element.width += deltaX; element.height += deltaY; } break; case "n": element.height -= deltaY; element.y += deltaY; break; case "w": element.width -= deltaX; element.x += deltaX; break; case "s": element.height += deltaY; break; case "e": element.width += deltaX; break; } if (resizeHandle) { resizeHandle = normalizeResizeHandle( element, resizeHandle, ); } normalizeDimensions(element); document.documentElement.style.cursor = getCursorForResizingElement( { element, resizeHandle }, ); el.x = element.x; el.y = element.y; el.shape = null; lastX = x; lastY = y; // We don't want to save history when resizing an element history.skipRecording(); this.setState({}); return; } } if (hitElement?.isSelected) { // Marking that click was used for dragging to check // if elements should be deselected on mouseup draggingOccurred = true; const selectedElements = elements.filter(el => el.isSelected); if (selectedElements.length) { const { x, y } = viewportCoordsToSceneCoords(e, this.state); 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 history.skipRecording(); this.setState({}); 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; const { x, y } = viewportCoordsToSceneCoords(e, this.state); let width = distance(originX, x); let height = distance(originY, y); const isLinear = this.state.elementType === "line" || this.state.elementType === "arrow"; if (isLinear && x < originX) width = -width; if (isLinear && y < originY) height = -height; if (e.shiftKey) { ({ width, height } = getPerfectElementSize( this.state.elementType, width, !isLinear && y < originY ? -height : height, )); if (!isLinear && height < 0) height = -height; } if (!isLinear) { draggingElement.x = x < originX ? originX - width : originX; draggingElement.y = y < originY ? originY - height : originY; } draggingElement.width = width; draggingElement.height = height; draggingElement.shape = null; if (this.state.elementType === "selection") { if (!e.shiftKey) { elements = clearSelection(elements); } const elementsWithinSelection = getElementsWithinSelection( elements, draggingElement, ); elementsWithinSelection.forEach(element => { element.isSelected = true; }); } // We don't want to save history when moving an element history.skipRecording(); this.setState({}); }; const onMouseUp = (e: MouseEvent) => { const { draggingElement, resizingElement, elementType, elementLocked, } = this.state; lastMouseUp = null; window.removeEventListener("mousemove", onMouseMove); window.removeEventListener("mouseup", onMouseUp); if ( elementType !== "selection" && draggingElement && isInvisiblySmallElement(draggingElement) ) { // remove invisible element which was added in onMouseDown elements = elements.slice(0, -1); this.setState({ draggingElement: null, }); return; } if (normalizeDimensions(draggingElement)) { this.setState({}); } if ( resizingElement && isInvisiblySmallElement(resizingElement) ) { elements = elements.filter( el => el.id !== resizingElement.id, ); } // If click occurred on already selected element // it is needed to remove selection from other elements // or if SHIFT or META key pressed remove selection // from hitted element // // If click occurred and elements were dragged or some element // was added to selection (on mousedown phase) we need to keep // selection unchanged if ( hitElement && !draggingOccurred && !elementIsAddedToSelection ) { if (e.shiftKey) { hitElement.isSelected = false; } else { elements = clearSelection(elements); hitElement.isSelected = true; } } if (draggingElement === null) { // if no element is clicked, clear the selection and redraw elements = clearSelection(elements); this.setState({}); return; } if (elementType === "selection") { elements = elements.slice(0, -1); } else if (!elementLocked) { draggingElement.isSelected = true; } if (!elementLocked) { resetCursor(); this.setState({ draggingElement: null, elementType: "selection", }); } history.resumeRecording(); this.setState({}); }; 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 history.skipRecording(); this.setState({}); }} onDoubleClick={e => { const { x, y } = viewportCoordsToSceneCoords(e, this.state); const elementAtPosition = getElementAtPosition(elements, x, y); const element = elementAtPosition && isTextElement(elementAtPosition) ? elementAtPosition : newTextElement( newElement( "text", x, y, this.state.currentItemStrokeColor, this.state.currentItemBackgroundColor, this.state.currentItemFillStyle, this.state.currentItemStrokeWidth, this.state.currentItemRoughness, this.state.currentItemOpacity, ), "", // default text this.state.currentItemFont, // default font ); this.setState({ editingElement: element }); let textX = e.clientX; let textY = e.clientY; if (elementAtPosition && isTextElement(elementAtPosition)) { elements = elements.filter( element => element.id !== elementAtPosition.id, ); this.setState({}); textX = this.state.scrollX + elementAtPosition.x + CANVAS_WINDOW_OFFSET_LEFT + elementAtPosition.width / 2; textY = this.state.scrollY + elementAtPosition.y + CANVAS_WINDOW_OFFSET_TOP + elementAtPosition.height / 2; // x and y will change after calling newTextElement function element.x = elementAtPosition.x + elementAtPosition.width / 2; element.y = elementAtPosition.y + 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; } } const resetSelection = () => { this.setState({ draggingElement: null, editingElement: null, elementType: "selection", }); }; textWysiwyg({ initText: element.text, x: textX, y: textY, strokeColor: element.strokeColor, font: element.font, opacity: this.state.currentItemOpacity, onSubmit: text => { if (text) { elements = [ ...elements, { // we need to recreate the element to update dimensions & // position ...newTextElement(element, text, element.font), isSelected: true, }, ]; } resetSelection(); }, onCancel: () => { resetSelection(); }, }); }} onMouseMove={e => { const hasDeselectedButton = Boolean(e.buttons); if ( hasDeselectedButton || this.state.elementType !== "selection" ) { return; } const { x, y } = viewportCoordsToSceneCoords(e, this.state); const selectedElements = elements.filter(e => e.isSelected) .length; if (selectedElements === 1) { const resizeElement = getElementWithResizeHandler( elements, { x, y }, this.state, ); if (resizeElement && resizeElement.resizeHandle) { document.documentElement.style.cursor = getCursorForResizingElement( resizeElement, ); return; } } const hitElement = getElementAtPosition(elements, x, y); document.documentElement.style.cursor = hitElement ? "move" : ""; }} > {t("labels.drawingCanvas")}
{ i18n.changeLanguage(lng); }} languages={languages} currentLanguage={parseDetectedLang(i18n.language)} />
); } private handleWheel = (e: WheelEvent) => { e.preventDefault(); const { deltaX, deltaY } = e; // We don't want to save history when panning around history.skipRecording(); this.setState(state => ({ scrollX: state.scrollX - deltaX, scrollY: state.scrollY - deltaY, })); }; private addElementsFromPaste = (paste: string) => { let parsedElements; try { parsedElements = JSON.parse(paste); } catch (e) {} if ( Array.isArray(parsedElements) && parsedElements.length > 0 && parsedElements[0].type // need to implement a better check here... ) { elements = clearSelection(elements); const [minX, minY, maxX, maxY] = getCommonBounds(parsedElements); const elementsCenterX = distance(minX, maxX) / 2; const elementsCenterY = distance(minY, maxY) / 2; const dx = cursorX - this.state.scrollX - CANVAS_WINDOW_OFFSET_LEFT - elementsCenterX; const dy = cursorY - this.state.scrollY - CANVAS_WINDOW_OFFSET_TOP - elementsCenterY; elements = [ ...elements, ...parsedElements.map(parsedElement => { const duplicate = duplicateElement(parsedElement); duplicate.x += dx - minX; duplicate.y += dy - minY; return duplicate; }), ]; this.setState({}); } }; 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 }; } } } private saveDebounced = debounce(() => { saveToLocalStorage(elements, this.state); }, 300); componentDidUpdate() { renderScene(elements, this.rc!, this.canvas!, { scrollX: this.state.scrollX, scrollY: this.state.scrollY, viewBackgroundColor: this.state.viewBackgroundColor, }); this.saveDebounced(); if (history.isRecording()) { history.pushEntry( history.generateCurrentEntry( pickAppStatePropertiesForHistory(this.state), elements, ), ); } } } const AppWithTrans = withTranslation()(App); const rootElement = document.getElementById("root"); class TopErrorBoundary extends React.Component { state = { hasError: false, stack: "", localStorage: "" }; static getDerivedStateFromError(error: any) { console.error(error); return { hasError: true, localStorage: JSON.stringify({ ...localStorage }), stack: error.stack, }; } private selectTextArea(event: React.MouseEvent) { (event.target as HTMLTextAreaElement).select(); } private async createGithubIssue() { let body = ""; try { const templateStr = (await import("./bug-issue-template")).default; if (typeof templateStr === "string") { body = encodeURIComponent(templateStr); } } catch {} window.open( `https://github.com/excalidraw/excalidraw/issues/new?body=${body}`, ); } render() { if (this.state.hasError) { return (
Encountered an error. Please{" "} .
If reloading doesn't work. Try{" "} .
(This will unfortunately result in loss of work.)
Before doing so, we'd appreciate if you opened an issue on our{" "} . Please include the following error stack trace & localStorage content (provided it's not private):