diff --git a/src/index.tsx b/src/index.tsx index 44eaf88dd..36779bd37 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -28,6 +28,36 @@ 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; +} + function hitTest(element: ExcalidrawElement, x: number, y: number): boolean { // For shapes that are composed of lines, we only enable point-selection when the distance // of the click is less than x pixels of any of the lines that the shape is composed of @@ -1173,3 +1203,823 @@ let lastCanvasWidth = -1; let lastCanvasHeight = -1; let lastMouseUp: ((e: any) => void) | null = null; + +class App extends React.Component<{}, AppState> { + public componentDidMount() { + document.addEventListener("keydown", this.onKeyDown, false); + window.addEventListener("resize", this.onResize, false); + + const savedState = restoreFromLocalStorage(); + if (savedState) { + this.setState(savedState); + } + } + + public componentWillUnmount() { + document.removeEventListener("keydown", this.onKeyDown, false); + window.removeEventListener("resize", this.onResize, false); + } + + public state: AppState = { + draggingElement: null, + resizingElement: null, + elementType: "selection", + exportBackground: true, + currentItemStrokeColor: "#000000", + currentItemBackgroundColor: "#ffffff", + viewBackgroundColor: "#ffffff", + scrollX: 0, + scrollY: 0, + name: DEFAULT_PROJECT_NAME + }; + + private onResize = () => { + this.forceUpdate(); + }; + + private onKeyDown = (event: KeyboardEvent) => { + if (isInputLike(event.target)) return; + + if (event.key === KEYS.ESCAPE) { + clearSelection(); + this.forceUpdate(); + event.preventDefault(); + } else if (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) { + deleteSelectedElements(); + this.forceUpdate(); + event.preventDefault(); + } else if (isArrowKey(event.key)) { + const step = event.shiftKey + ? ELEMENT_SHIFT_TRANSLATE_AMOUNT + : ELEMENT_TRANSLATE_AMOUNT; + elements.forEach(element => { + if (element.isSelected) { + 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; + } + }); + this.forceUpdate(); + event.preventDefault(); + + // Send backward: Cmd-Shift-Alt-B + } else if ( + event.metaKey && + event.shiftKey && + event.altKey && + event.code === "KeyB" + ) { + this.moveOneLeft(); + event.preventDefault(); + + // Send to back: Cmd-Shift-B + } else if (event.metaKey && event.shiftKey && event.code === "KeyB") { + this.moveAllLeft(); + event.preventDefault(); + + // Bring forward: Cmd-Shift-Alt-F + } else if ( + event.metaKey && + event.shiftKey && + event.altKey && + event.code === "KeyF" + ) { + this.moveOneRight(); + event.preventDefault(); + + // Bring to front: Cmd-Shift-F + } else if (event.metaKey && event.shiftKey && event.code === "KeyF") { + this.moveAllRight(); + event.preventDefault(); + + // Select all: Cmd-A + } else if (event.metaKey && event.code === "KeyA") { + elements.forEach(element => { + element.isSelected = true; + }); + this.forceUpdate(); + event.preventDefault(); + } else if (shapesShortcutKeys.includes(event.key.toLowerCase())) { + this.setState({ elementType: findElementByKey(event.key) }); + } else if (event.metaKey && event.code === "KeyZ") { + const currentEntry = generateHistoryCurrentEntry(); + if (event.shiftKey) { + // Redo action + const entryToRestore = redoStack.pop(); + if (entryToRestore !== undefined) { + restoreHistoryEntry(entryToRestore); + stateHistory.push(currentEntry); + } + } else { + // undo action + let lastEntry = stateHistory.pop(); + // If nothing was changed since last, take the previous one + if (currentEntry === lastEntry) { + lastEntry = stateHistory.pop(); + } + if (lastEntry !== undefined) { + restoreHistoryEntry(lastEntry); + redoStack.push(currentEntry); + } + } + this.forceUpdate(); + event.preventDefault(); + } + }; + + private deleteSelectedElements = () => { + deleteSelectedElements(); + this.forceUpdate(); + }; + + private clearCanvas = () => { + if (window.confirm("This will clear the whole canvas. Are you sure?")) { + elements.splice(0, elements.length); + this.setState({ + viewBackgroundColor: "#ffffff", + scrollX: 0, + scrollY: 0 + }); + this.forceUpdate(); + } + }; + + private moveAllLeft = () => { + moveAllLeft(elements, getSelectedIndices()); + this.forceUpdate(); + }; + + private moveOneLeft = () => { + moveOneLeft(elements, getSelectedIndices()); + this.forceUpdate(); + }; + + private moveAllRight = () => { + moveAllRight(elements, getSelectedIndices()); + this.forceUpdate(); + }; + + private moveOneRight = () => { + moveOneRight(elements, getSelectedIndices()); + this.forceUpdate(); + }; + + private removeWheelEventListener: (() => void) | undefined; + + private updateProjectName(name: string): void { + this.setState({ name }); + } + + private changeProperty = (callback: (element: ExcalidrawElement) => void) => { + elements.forEach(element => { + if (element.isSelected) { + callback(element); + generateDraw(element); + } + }); + + this.forceUpdate(); + }; + + private changeOpacity = (event: React.ChangeEvent) => { + this.changeProperty(element => (element.opacity = +event.target.value)); + }; + + private changeStrokeColor = (color: string) => { + this.changeProperty(element => (element.strokeColor = color)); + this.setState({ currentItemStrokeColor: color }); + }; + + private changeBackgroundColor = (color: string) => { + this.changeProperty(element => (element.backgroundColor = color)); + this.setState({ currentItemBackgroundColor: color }); + }; + + public render() { + const canvasWidth = window.innerWidth - CANVAS_WINDOW_OFFSET_LEFT; + const canvasHeight = window.innerHeight - CANVAS_WINDOW_OFFSET_TOP; + + return ( +
{ + e.clipboardData.setData( + "text/plain", + JSON.stringify(elements.filter(element => element.isSelected)) + ); + deleteSelectedElements(); + this.forceUpdate(); + e.preventDefault(); + }} + onCopy={e => { + e.clipboardData.setData( + "text/plain", + JSON.stringify(elements.filter(element => element.isSelected)) + ); + e.preventDefault(); + }} + onPaste={e => { + const paste = e.clipboardData.getData("text"); + 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... + ) { + clearSelection(); + parsedElements.forEach(parsedElement => { + parsedElement.x += 10; + parsedElement.y += 10; + parsedElement.seed = randomSeed(); + generateDraw(parsedElement); + elements.push(parsedElement); + }); + this.forceUpdate(); + } + e.preventDefault(); + }} + > +
+

Shapes

+
+ {SHAPES.map(({ value, icon }) => ( + + ))} +
+ {someElementIsSelected() && ( +
+

Selection

+
+ + + + +
+
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);