diff --git a/src/components/App.tsx b/src/components/App.tsx index f628e9dd..4c0bf4cd 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -167,6 +167,84 @@ export class App extends React.Component { this.actionManager.registerAction(createRedoAction(history)); } + public render() { + const canvasDOMWidth = window.innerWidth; + const canvasDOMHeight = window.innerHeight; + + const canvasScale = window.devicePixelRatio; + + const canvasWidth = canvasDOMWidth * canvasScale; + const canvasHeight = canvasDOMHeight * canvasScale; + + return ( +
+ { + return !element.isDeleted; + })} + setElements={this.setElements} + language={getLanguage()} + onRoomCreate={this.createRoom} + onRoomDestroy={this.destroyRoom} + onToggleLock={this.toggleLock} + /> +
+ { + // canvas is null when unmounting + if (canvas !== null) { + this.canvas = canvas; + this.rc = rough.canvas(this.canvas); + + this.canvas.addEventListener("wheel", this.handleWheel, { + passive: false, + }); + } else { + this.canvas?.removeEventListener("wheel", this.handleWheel); + } + }} + onContextMenu={this.handleCanvasContextMenu} + onPointerDown={this.handleCanvasPointerDown} + onDoubleClick={this.handleCanvasDoubleClick} + onPointerMove={this.handleCanvasPointerMove} + onPointerUp={this.removePointer} + onPointerCancel={this.removePointer} + onDrop={event => { + const file = event.dataTransfer.files[0]; + if ( + file?.type === "application/json" || + file?.name.endsWith(".excalidraw") + ) { + loadFromBlob(file) + .then(({ elements, appState }) => + this.syncActionResult({ + elements, + appState, + commitToHistory: false, + }), + ) + .catch(error => console.error(error)); + } + }} + > + {t("labels.drawingCanvas")} + +
+
+ ); + } + private syncActionResult = withBatchedUpdates((res: ActionResult) => { if (this.unmounted) { return; @@ -190,6 +268,214 @@ export class App extends React.Component { } }); + // Lifecycle + + private onUnload = withBatchedUpdates(() => { + isHoldingSpace = false; + this.saveDebounced(); + this.saveDebounced.flush(); + }); + + private disableEvent: EventHandlerNonNull = event => { + event.preventDefault(); + }; + private unmounted = false; + + public async componentDidMount() { + if ( + process.env.NODE_ENV === "test" || + process.env.NODE_ENV === "development" + ) { + const setState = this.setState.bind(this); + Object.defineProperties(window.h, { + state: { + configurable: true, + get: () => { + return this.state; + }, + }, + setState: { + configurable: true, + value: (...args: Parameters) => { + return this.setState(...args); + }, + }, + }); + } + + this.removeSceneCallback = globalSceneState.addCallback( + this.onSceneUpdated, + ); + + document.addEventListener("copy", this.onCopy); + document.addEventListener("paste", this.pasteFromClipboard); + document.addEventListener("cut", this.onCut); + + document.addEventListener("keydown", this.onKeyDown, false); + document.addEventListener("keyup", this.onKeyUp, { passive: true }); + document.addEventListener("mousemove", this.updateCurrentCursorPosition); + window.addEventListener("resize", this.onResize, false); + window.addEventListener("unload", this.onUnload, false); + window.addEventListener("blur", this.onUnload, false); + window.addEventListener("dragover", this.disableEvent, false); + window.addEventListener("drop", this.disableEvent, false); + + // Safari-only desktop pinch zoom + document.addEventListener( + "gesturestart", + this.onGestureStart as any, + false, + ); + document.addEventListener( + "gesturechange", + this.onGestureChange as any, + false, + ); + document.addEventListener("gestureend", this.onGestureEnd as any, false); + + const searchParams = new URLSearchParams(window.location.search); + const id = searchParams.get("id"); + + if (id) { + // Backwards compatibility with legacy url format + const scene = await loadScene(id); + this.syncActionResult(scene); + } + + const jsonMatch = window.location.hash.match( + /^#json=([0-9]+),([a-zA-Z0-9_-]+)$/, + ); + if (jsonMatch) { + const scene = await loadScene(jsonMatch[1], jsonMatch[2]); + this.syncActionResult(scene); + return; + } + + const roomMatch = getCollaborationLinkData(window.location.href); + if (roomMatch) { + this.initializeSocketClient(); + return; + } + const scene = await loadScene(null); + this.syncActionResult(scene); + + window.addEventListener("beforeunload", this.beforeUnload); + } + + public componentWillUnmount() { + this.unmounted = true; + this.removeSceneCallback!(); + + document.removeEventListener("copy", this.onCopy); + document.removeEventListener("paste", this.pasteFromClipboard); + document.removeEventListener("cut", this.onCut); + + document.removeEventListener("keydown", this.onKeyDown, false); + document.removeEventListener( + "mousemove", + this.updateCurrentCursorPosition, + false, + ); + document.removeEventListener("keyup", this.onKeyUp); + window.removeEventListener("resize", this.onResize, false); + window.removeEventListener("unload", this.onUnload, false); + window.removeEventListener("blur", this.onUnload, false); + window.removeEventListener("dragover", this.disableEvent, false); + window.removeEventListener("drop", this.disableEvent, false); + + document.removeEventListener( + "gesturestart", + this.onGestureStart as any, + false, + ); + document.removeEventListener( + "gesturechange", + this.onGestureChange as any, + false, + ); + document.removeEventListener("gestureend", this.onGestureEnd as any, false); + window.removeEventListener("beforeunload", this.beforeUnload); + } + private onResize = withBatchedUpdates(() => { + globalSceneState + .getAllElements() + .forEach(element => invalidateShapeForElement(element)); + this.setState({}); + }); + + private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => { + if ( + this.state.isCollaborating && + hasNonDeletedElements(globalSceneState.getAllElements()) + ) { + event.preventDefault(); + // NOTE: modern browsers no longer allow showing a custom message here + event.returnValue = ""; + } + }); + + componentDidUpdate() { + if (this.state.isCollaborating && !this.socket) { + this.initializeSocketClient(); + } + const pointerViewportCoords: { + [id: string]: { x: number; y: number }; + } = {}; + this.state.collaborators.forEach((user, socketID) => { + if (!user.pointer) { + return; + } + pointerViewportCoords[socketID] = sceneCoordsToViewportCoords( + { + sceneX: user.pointer.x, + sceneY: user.pointer.y, + }, + this.state, + this.canvas, + window.devicePixelRatio, + ); + }); + const { atLeastOneVisibleElement, scrollBars } = renderScene( + globalSceneState.getAllElements(), + this.state, + this.state.selectionElement, + window.devicePixelRatio, + this.rc!, + this.canvas!, + { + scrollX: this.state.scrollX, + scrollY: this.state.scrollY, + viewBackgroundColor: this.state.viewBackgroundColor, + zoom: this.state.zoom, + remotePointerViewportCoords: pointerViewportCoords, + }, + { + renderOptimizations: true, + }, + ); + if (scrollBars) { + currentScrollBars = scrollBars; + } + const scrolledOutside = + !atLeastOneVisibleElement && + hasNonDeletedElements(globalSceneState.getAllElements()); + if (this.state.scrolledOutside !== scrolledOutside) { + this.setState({ scrolledOutside: scrolledOutside }); + } + this.saveDebounced(); + + if ( + getDrawingVersion(globalSceneState.getAllElements()) > + this.lastBroadcastedOrReceivedSceneVersion + ) { + this.broadcastSceneUpdate(); + } + + history.record(this.state, globalSceneState.getAllElements()); + } + + // Copy/paste + private onCut = withBatchedUpdates((event: ClipboardEvent) => { if (isWritableElement(event.target)) { return; @@ -212,15 +498,144 @@ export class App extends React.Component { copyToAppClipboard(globalSceneState.getAllElements(), this.state); event.preventDefault(); }); + private copyToAppClipboard = () => { + copyToAppClipboard(globalSceneState.getAllElements(), this.state); + }; - private onUnload = withBatchedUpdates(() => { - isHoldingSpace = false; - this.saveDebounced(); - this.saveDebounced.flush(); - }); + private copyToClipboardAsPng = () => { + const selectedElements = getSelectedElements( + globalSceneState.getAllElements(), + this.state, + ); + exportCanvas( + "clipboard", + selectedElements.length + ? selectedElements + : globalSceneState.getAllElements(), + this.state, + this.canvas!, + this.state, + ); + }; - private disableEvent: EventHandlerNonNull = event => { - event.preventDefault(); + private pasteFromClipboard = withBatchedUpdates( + async (event: ClipboardEvent | null) => { + // #686 + const target = document.activeElement; + const elementUnderCursor = document.elementFromPoint(cursorX, cursorY); + if ( + // if no ClipboardEvent supplied, assume we're pasting via contextMenu + // thus these checks don't make sense + !event || + (elementUnderCursor instanceof HTMLCanvasElement && + !isWritableElement(target)) + ) { + const data = await getClipboardContent(event); + if (data.elements) { + this.addElementsFromPaste(data.elements); + } else if (data.text) { + const { x, y } = viewportCoordsToSceneCoords( + { clientX: cursorX, clientY: cursorY }, + this.state, + this.canvas, + window.devicePixelRatio, + ); + + const element = newTextElement({ + x: x, + y: y, + strokeColor: this.state.currentItemStrokeColor, + backgroundColor: this.state.currentItemBackgroundColor, + fillStyle: this.state.currentItemFillStyle, + strokeWidth: this.state.currentItemStrokeWidth, + roughness: this.state.currentItemRoughness, + opacity: this.state.currentItemOpacity, + text: data.text, + font: this.state.currentItemFont, + }); + + globalSceneState.replaceAllElements([ + ...globalSceneState.getAllElements(), + element, + ]); + this.setState({ selectedElementIds: { [element.id]: true } }); + history.resumeRecording(); + } + this.selectShapeTool("selection"); + event?.preventDefault(); + } + }, + ); + + private addElementsFromPaste = ( + clipboardElements: readonly ExcalidrawElement[], + ) => { + const [minX, minY, maxX, maxY] = getCommonBounds(clipboardElements); + + const elementsCenterX = distance(minX, maxX) / 2; + const elementsCenterY = distance(minY, maxY) / 2; + + const { x, y } = viewportCoordsToSceneCoords( + { clientX: cursorX, clientY: cursorY }, + this.state, + this.canvas, + window.devicePixelRatio, + ); + + const dx = x - elementsCenterX; + const dy = y - elementsCenterY; + + const newElements = clipboardElements.map(element => + duplicateElement(element, { + x: element.x + dx - minX, + y: element.y + dy - minY, + }), + ); + + globalSceneState.replaceAllElements([ + ...globalSceneState.getAllElements(), + ...newElements, + ]); + history.resumeRecording(); + this.setState({ + selectedElementIds: newElements.reduce((map, element) => { + map[element.id] = true; + return map; + }, {} as any), + }); + }; + + // Collaboration + + setAppState = (obj: any) => { + this.setState(obj); + }; + + removePointer = (event: React.PointerEvent) => { + gesture.pointers.delete(event.pointerId); + }; + + createRoom = async () => { + window.history.pushState( + {}, + "Excalidraw", + await generateCollaborationLink(), + ); + this.initializeSocketClient(); + }; + + destroyRoom = () => { + window.history.pushState({}, "Excalidraw", window.location.origin); + this.destroySocketClient(); + }; + + toggleLock = () => { + this.setState(prevState => ({ + elementLocked: !prevState.elementLocked, + elementType: prevState.elementLocked + ? "selection" + : prevState.elementType, + })); }; private destroySocketClient = () => { @@ -448,132 +863,8 @@ export class App extends React.Component { this.setState({}); }; - private unmounted = false; - public async componentDidMount() { - if ( - process.env.NODE_ENV === "test" || - process.env.NODE_ENV === "development" - ) { - const setState = this.setState.bind(this); - Object.defineProperties(window.h, { - state: { - configurable: true, - get: () => { - return this.state; - }, - }, - setState: { - configurable: true, - value: (...args: Parameters) => { - return this.setState(...args); - }, - }, - }); - } - - this.removeSceneCallback = globalSceneState.addCallback( - this.onSceneUpdated, - ); - - document.addEventListener("copy", this.onCopy); - document.addEventListener("paste", this.pasteFromClipboard); - document.addEventListener("cut", this.onCut); - - document.addEventListener("keydown", this.onKeyDown, false); - document.addEventListener("keyup", this.onKeyUp, { passive: true }); - document.addEventListener("mousemove", this.updateCurrentCursorPosition); - window.addEventListener("resize", this.onResize, false); - window.addEventListener("unload", this.onUnload, false); - window.addEventListener("blur", this.onUnload, false); - window.addEventListener("dragover", this.disableEvent, false); - window.addEventListener("drop", this.disableEvent, false); - - // Safari-only desktop pinch zoom - document.addEventListener( - "gesturestart", - this.onGestureStart as any, - false, - ); - document.addEventListener( - "gesturechange", - this.onGestureChange as any, - false, - ); - document.addEventListener("gestureend", this.onGestureEnd as any, false); - - const searchParams = new URLSearchParams(window.location.search); - const id = searchParams.get("id"); - - if (id) { - // Backwards compatibility with legacy url format - const scene = await loadScene(id); - this.syncActionResult(scene); - } - - const jsonMatch = window.location.hash.match( - /^#json=([0-9]+),([a-zA-Z0-9_-]+)$/, - ); - if (jsonMatch) { - const scene = await loadScene(jsonMatch[1], jsonMatch[2]); - this.syncActionResult(scene); - return; - } - - const roomMatch = getCollaborationLinkData(window.location.href); - if (roomMatch) { - this.initializeSocketClient(); - return; - } - const scene = await loadScene(null); - this.syncActionResult(scene); - - window.addEventListener("beforeunload", this.beforeUnload); - } - - public componentWillUnmount() { - this.unmounted = true; - this.removeSceneCallback!(); - - document.removeEventListener("copy", this.onCopy); - document.removeEventListener("paste", this.pasteFromClipboard); - document.removeEventListener("cut", this.onCut); - - document.removeEventListener("keydown", this.onKeyDown, false); - document.removeEventListener( - "mousemove", - this.updateCurrentCursorPosition, - false, - ); - document.removeEventListener("keyup", this.onKeyUp); - window.removeEventListener("resize", this.onResize, false); - window.removeEventListener("unload", this.onUnload, false); - window.removeEventListener("blur", this.onUnload, false); - window.removeEventListener("dragover", this.disableEvent, false); - window.removeEventListener("drop", this.disableEvent, false); - - document.removeEventListener( - "gesturestart", - this.onGestureStart as any, - false, - ); - document.removeEventListener( - "gesturechange", - this.onGestureChange as any, - false, - ); - document.removeEventListener("gestureend", this.onGestureEnd as any, false); - window.removeEventListener("beforeunload", this.beforeUnload); - } - public state: AppState = getDefaultAppState(); - private onResize = withBatchedUpdates(() => { - globalSceneState - .getAllElements() - .forEach(element => invalidateShapeForElement(element)); - this.setState({}); - }); - private updateCurrentCursorPosition = withBatchedUpdates( (event: MouseEvent) => { cursorX = event.x; @@ -581,6 +872,8 @@ export class App extends React.Component { }, ); + // Input handling + private onKeyDown = withBatchedUpdates((event: KeyboardEvent) => { if ( (isWritableElement(event.target) && event.key !== KEYS.ESCAPE) || @@ -658,75 +951,6 @@ export class App extends React.Component { } }); - private copyToAppClipboard = () => { - copyToAppClipboard(globalSceneState.getAllElements(), this.state); - }; - - private copyToClipboardAsPng = () => { - const selectedElements = getSelectedElements( - globalSceneState.getAllElements(), - this.state, - ); - exportCanvas( - "clipboard", - selectedElements.length - ? selectedElements - : globalSceneState.getAllElements(), - this.state, - this.canvas!, - this.state, - ); - }; - - private pasteFromClipboard = withBatchedUpdates( - async (event: ClipboardEvent | null) => { - // #686 - const target = document.activeElement; - const elementUnderCursor = document.elementFromPoint(cursorX, cursorY); - if ( - // if no ClipboardEvent supplied, assume we're pasting via contextMenu - // thus these checks don't make sense - !event || - (elementUnderCursor instanceof HTMLCanvasElement && - !isWritableElement(target)) - ) { - const data = await getClipboardContent(event); - if (data.elements) { - this.addElementsFromPaste(data.elements); - } else if (data.text) { - const { x, y } = viewportCoordsToSceneCoords( - { clientX: cursorX, clientY: cursorY }, - this.state, - this.canvas, - window.devicePixelRatio, - ); - - const element = newTextElement({ - x: x, - y: y, - strokeColor: this.state.currentItemStrokeColor, - backgroundColor: this.state.currentItemBackgroundColor, - fillStyle: this.state.currentItemFillStyle, - strokeWidth: this.state.currentItemStrokeWidth, - roughness: this.state.currentItemRoughness, - opacity: this.state.currentItemOpacity, - text: data.text, - font: this.state.currentItemFont, - }); - - globalSceneState.replaceAllElements([ - ...globalSceneState.getAllElements(), - element, - ]); - this.setState({ selectedElementIds: { [element.id]: true } }); - history.resumeRecording(); - } - this.selectShapeTool("selection"); - event?.preventDefault(); - } - }, - ); - private selectShapeTool(elementType: AppState["elementType"]) { if (!isHoldingSpace) { setCursorForShape(elementType); @@ -759,119 +983,10 @@ export class App extends React.Component { gesture.initialScale = null; }); - setAppState = (obj: any) => { - this.setState(obj); - }; - - removePointer = (event: React.PointerEvent) => { - gesture.pointers.delete(event.pointerId); - }; - - createRoom = async () => { - window.history.pushState( - {}, - "Excalidraw", - await generateCollaborationLink(), - ); - this.initializeSocketClient(); - }; - - destroyRoom = () => { - window.history.pushState({}, "Excalidraw", window.location.origin); - this.destroySocketClient(); - }; - - toggleLock = () => { - this.setState(prevState => ({ - elementLocked: !prevState.elementLocked, - elementType: prevState.elementLocked - ? "selection" - : prevState.elementType, - })); - }; - private setElements = (elements: readonly ExcalidrawElement[]) => { globalSceneState.replaceAllElements(elements); }; - public render() { - const canvasDOMWidth = window.innerWidth; - const canvasDOMHeight = window.innerHeight; - - const canvasScale = window.devicePixelRatio; - - const canvasWidth = canvasDOMWidth * canvasScale; - const canvasHeight = canvasDOMHeight * canvasScale; - - return ( -
- { - return !element.isDeleted; - })} - setElements={this.setElements} - language={getLanguage()} - onRoomCreate={this.createRoom} - onRoomDestroy={this.destroyRoom} - onToggleLock={this.toggleLock} - /> -
- { - // canvas is null when unmounting - if (canvas !== null) { - this.canvas = canvas; - this.rc = rough.canvas(this.canvas); - - this.canvas.addEventListener("wheel", this.handleWheel, { - passive: false, - }); - } else { - this.canvas?.removeEventListener("wheel", this.handleWheel); - } - }} - onContextMenu={this.handleCanvasContextMenu} - onPointerDown={this.handleCanvasPointerDown} - onDoubleClick={this.handleCanvasDoubleClick} - onPointerMove={this.handleCanvasPointerMove} - onPointerUp={this.removePointer} - onPointerCancel={this.removePointer} - onDrop={event => { - const file = event.dataTransfer.files[0]; - if ( - file?.type === "application/json" || - file?.name.endsWith(".excalidraw") - ) { - loadFromBlob(file) - .then(({ elements, appState }) => - this.syncActionResult({ - elements, - appState, - commitToHistory: false, - }), - ) - .catch(error => console.error(error)); - } - }} - > - {t("labels.drawingCanvas")} - -
-
- ); - } - private handleCanvasDoubleClick = ( event: React.MouseEvent, ) => { @@ -2287,55 +2402,6 @@ export class App extends React.Component { })); }); - private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => { - if ( - this.state.isCollaborating && - hasNonDeletedElements(globalSceneState.getAllElements()) - ) { - event.preventDefault(); - // NOTE: modern browsers no longer allow showing a custom message here - event.returnValue = ""; - } - }); - - private addElementsFromPaste = ( - clipboardElements: readonly ExcalidrawElement[], - ) => { - const [minX, minY, maxX, maxY] = getCommonBounds(clipboardElements); - - const elementsCenterX = distance(minX, maxX) / 2; - const elementsCenterY = distance(minY, maxY) / 2; - - const { x, y } = viewportCoordsToSceneCoords( - { clientX: cursorX, clientY: cursorY }, - this.state, - this.canvas, - window.devicePixelRatio, - ); - - const dx = x - elementsCenterX; - const dy = y - elementsCenterY; - - const newElements = clipboardElements.map(element => - duplicateElement(element, { - x: element.x + dx - minX, - y: element.y + dy - minY, - }), - ); - - globalSceneState.replaceAllElements([ - ...globalSceneState.getAllElements(), - ...newElements, - ]); - history.resumeRecording(); - this.setState({ - selectedElementIds: newElements.reduce((map, element) => { - map[element.id] = true; - return map; - }, {} as any), - }); - }; - private getTextWysiwygSnappedToCenterPosition(x: number, y: number) { const elementClickedInside = getElementContainingPosition( globalSceneState.getAllElements(), @@ -2378,66 +2444,6 @@ export class App extends React.Component { private saveDebounced = debounce(() => { saveToLocalStorage(globalSceneState.getAllElements(), this.state); }, 300); - - componentDidUpdate() { - if (this.state.isCollaborating && !this.socket) { - this.initializeSocketClient(); - } - const pointerViewportCoords: { - [id: string]: { x: number; y: number }; - } = {}; - this.state.collaborators.forEach((user, socketID) => { - if (!user.pointer) { - return; - } - pointerViewportCoords[socketID] = sceneCoordsToViewportCoords( - { - sceneX: user.pointer.x, - sceneY: user.pointer.y, - }, - this.state, - this.canvas, - window.devicePixelRatio, - ); - }); - const { atLeastOneVisibleElement, scrollBars } = renderScene( - globalSceneState.getAllElements(), - this.state, - this.state.selectionElement, - window.devicePixelRatio, - this.rc!, - this.canvas!, - { - scrollX: this.state.scrollX, - scrollY: this.state.scrollY, - viewBackgroundColor: this.state.viewBackgroundColor, - zoom: this.state.zoom, - remotePointerViewportCoords: pointerViewportCoords, - }, - { - renderOptimizations: true, - }, - ); - if (scrollBars) { - currentScrollBars = scrollBars; - } - const scrolledOutside = - !atLeastOneVisibleElement && - hasNonDeletedElements(globalSceneState.getAllElements()); - if (this.state.scrolledOutside !== scrolledOutside) { - this.setState({ scrolledOutside: scrolledOutside }); - } - this.saveDebounced(); - - if ( - getDrawingVersion(globalSceneState.getAllElements()) > - this.lastBroadcastedOrReceivedSceneVersion - ) { - this.broadcastSceneUpdate(); - } - - history.record(this.state, globalSceneState.getAllElements()); - } } // -----------------------------------------------------------------------------