|
@@ -167,6 +167,84 @@ export class App extends React.Component<any, AppState> {
|
|
|
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 (
|
|
|
+ <div className="container">
|
|
|
+ <LayerUI
|
|
|
+ canvas={this.canvas}
|
|
|
+ appState={this.state}
|
|
|
+ setAppState={this.setAppState}
|
|
|
+ actionManager={this.actionManager}
|
|
|
+ elements={globalSceneState.getAllElements().filter(element => {
|
|
|
+ return !element.isDeleted;
|
|
|
+ })}
|
|
|
+ setElements={this.setElements}
|
|
|
+ language={getLanguage()}
|
|
|
+ onRoomCreate={this.createRoom}
|
|
|
+ onRoomDestroy={this.destroyRoom}
|
|
|
+ onToggleLock={this.toggleLock}
|
|
|
+ />
|
|
|
+ <main>
|
|
|
+ <canvas
|
|
|
+ id="canvas"
|
|
|
+ style={{
|
|
|
+ width: canvasDOMWidth,
|
|
|
+ height: canvasDOMHeight,
|
|
|
+ }}
|
|
|
+ width={canvasWidth}
|
|
|
+ height={canvasHeight}
|
|
|
+ ref={canvas => {
|
|
|
+ // 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")}
|
|
|
+ </canvas>
|
|
|
+ </main>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
private syncActionResult = withBatchedUpdates((res: ActionResult) => {
|
|
|
if (this.unmounted) {
|
|
|
return;
|
|
@@ -190,37 +268,374 @@ export class App extends React.Component<any, AppState> {
|
|
|
}
|
|
|
});
|
|
|
|
|
|
- private onCut = withBatchedUpdates((event: ClipboardEvent) => {
|
|
|
- if (isWritableElement(event.target)) {
|
|
|
- return;
|
|
|
- }
|
|
|
- copyToAppClipboard(globalSceneState.getAllElements(), this.state);
|
|
|
- const { elements: nextElements, appState } = deleteSelectedElements(
|
|
|
- globalSceneState.getAllElements(),
|
|
|
- this.state,
|
|
|
+ // 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<typeof setState>) => {
|
|
|
+ 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;
|
|
|
+ }
|
|
|
+ copyToAppClipboard(globalSceneState.getAllElements(), this.state);
|
|
|
+ const { elements: nextElements, appState } = deleteSelectedElements(
|
|
|
+ globalSceneState.getAllElements(),
|
|
|
+ this.state,
|
|
|
+ );
|
|
|
+ globalSceneState.replaceAllElements(nextElements);
|
|
|
+ history.resumeRecording();
|
|
|
+ this.setState({ ...appState });
|
|
|
+ event.preventDefault();
|
|
|
+ });
|
|
|
+
|
|
|
+ private onCopy = withBatchedUpdates((event: ClipboardEvent) => {
|
|
|
+ if (isWritableElement(event.target)) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ copyToAppClipboard(globalSceneState.getAllElements(), this.state);
|
|
|
+ event.preventDefault();
|
|
|
+ });
|
|
|
+ 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 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<HTMLElement>) => {
|
|
|
+ gesture.pointers.delete(event.pointerId);
|
|
|
+ };
|
|
|
+
|
|
|
+ createRoom = async () => {
|
|
|
+ window.history.pushState(
|
|
|
+ {},
|
|
|
+ "Excalidraw",
|
|
|
+ await generateCollaborationLink(),
|
|
|
);
|
|
|
- globalSceneState.replaceAllElements(nextElements);
|
|
|
- history.resumeRecording();
|
|
|
- this.setState({ ...appState });
|
|
|
- event.preventDefault();
|
|
|
- });
|
|
|
-
|
|
|
- private onCopy = withBatchedUpdates((event: ClipboardEvent) => {
|
|
|
- if (isWritableElement(event.target)) {
|
|
|
- return;
|
|
|
- }
|
|
|
- copyToAppClipboard(globalSceneState.getAllElements(), this.state);
|
|
|
- event.preventDefault();
|
|
|
- });
|
|
|
+ this.initializeSocketClient();
|
|
|
+ };
|
|
|
|
|
|
- private onUnload = withBatchedUpdates(() => {
|
|
|
- isHoldingSpace = false;
|
|
|
- this.saveDebounced();
|
|
|
- this.saveDebounced.flush();
|
|
|
- });
|
|
|
+ destroyRoom = () => {
|
|
|
+ window.history.pushState({}, "Excalidraw", window.location.origin);
|
|
|
+ this.destroySocketClient();
|
|
|
+ };
|
|
|
|
|
|
- private disableEvent: EventHandlerNonNull = event => {
|
|
|
- event.preventDefault();
|
|
|
+ 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<any, AppState> {
|
|
|
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<typeof setState>) => {
|
|
|
- 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<any, AppState> {
|
|
|
},
|
|
|
);
|
|
|
|
|
|
+ // Input handling
|
|
|
+
|
|
|
private onKeyDown = withBatchedUpdates((event: KeyboardEvent) => {
|
|
|
if (
|
|
|
(isWritableElement(event.target) && event.key !== KEYS.ESCAPE) ||
|
|
@@ -628,104 +921,35 @@ export class App extends React.Component<any, AppState> {
|
|
|
} else if (
|
|
|
!event.ctrlKey &&
|
|
|
!event.altKey &&
|
|
|
- !event.metaKey &&
|
|
|
- this.state.draggingElement === null
|
|
|
- ) {
|
|
|
- if (shapesShortcutKeys.includes(event.key.toLowerCase())) {
|
|
|
- this.selectShapeTool(shape);
|
|
|
- } else if (event.key === "q") {
|
|
|
- this.toggleLock();
|
|
|
- }
|
|
|
- }
|
|
|
- if (event.key === KEYS.SPACE && gesture.pointers.size === 0) {
|
|
|
- isHoldingSpace = true;
|
|
|
- document.documentElement.style.cursor = CURSOR_TYPE.GRABBING;
|
|
|
- }
|
|
|
- });
|
|
|
-
|
|
|
- private onKeyUp = withBatchedUpdates((event: KeyboardEvent) => {
|
|
|
- if (event.key === KEYS.SPACE) {
|
|
|
- if (this.state.elementType === "selection") {
|
|
|
- resetCursor();
|
|
|
- } else {
|
|
|
- document.documentElement.style.cursor =
|
|
|
- this.state.elementType === "text"
|
|
|
- ? CURSOR_TYPE.TEXT
|
|
|
- : CURSOR_TYPE.CROSSHAIR;
|
|
|
- this.setState({ selectedElementIds: {} });
|
|
|
- }
|
|
|
- isHoldingSpace = false;
|
|
|
- }
|
|
|
- });
|
|
|
-
|
|
|
- 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,
|
|
|
- });
|
|
|
+ !event.metaKey &&
|
|
|
+ this.state.draggingElement === null
|
|
|
+ ) {
|
|
|
+ if (shapesShortcutKeys.includes(event.key.toLowerCase())) {
|
|
|
+ this.selectShapeTool(shape);
|
|
|
+ } else if (event.key === "q") {
|
|
|
+ this.toggleLock();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (event.key === KEYS.SPACE && gesture.pointers.size === 0) {
|
|
|
+ isHoldingSpace = true;
|
|
|
+ document.documentElement.style.cursor = CURSOR_TYPE.GRABBING;
|
|
|
+ }
|
|
|
+ });
|
|
|
|
|
|
- globalSceneState.replaceAllElements([
|
|
|
- ...globalSceneState.getAllElements(),
|
|
|
- element,
|
|
|
- ]);
|
|
|
- this.setState({ selectedElementIds: { [element.id]: true } });
|
|
|
- history.resumeRecording();
|
|
|
- }
|
|
|
- this.selectShapeTool("selection");
|
|
|
- event?.preventDefault();
|
|
|
+ private onKeyUp = withBatchedUpdates((event: KeyboardEvent) => {
|
|
|
+ if (event.key === KEYS.SPACE) {
|
|
|
+ if (this.state.elementType === "selection") {
|
|
|
+ resetCursor();
|
|
|
+ } else {
|
|
|
+ document.documentElement.style.cursor =
|
|
|
+ this.state.elementType === "text"
|
|
|
+ ? CURSOR_TYPE.TEXT
|
|
|
+ : CURSOR_TYPE.CROSSHAIR;
|
|
|
+ this.setState({ selectedElementIds: {} });
|
|
|
}
|
|
|
- },
|
|
|
- );
|
|
|
+ isHoldingSpace = false;
|
|
|
+ }
|
|
|
+ });
|
|
|
|
|
|
private selectShapeTool(elementType: AppState["elementType"]) {
|
|
|
if (!isHoldingSpace) {
|
|
@@ -759,119 +983,10 @@ export class App extends React.Component<any, AppState> {
|
|
|
gesture.initialScale = null;
|
|
|
});
|
|
|
|
|
|
- setAppState = (obj: any) => {
|
|
|
- this.setState(obj);
|
|
|
- };
|
|
|
-
|
|
|
- removePointer = (event: React.PointerEvent<HTMLElement>) => {
|
|
|
- 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 (
|
|
|
- <div className="container">
|
|
|
- <LayerUI
|
|
|
- canvas={this.canvas}
|
|
|
- appState={this.state}
|
|
|
- setAppState={this.setAppState}
|
|
|
- actionManager={this.actionManager}
|
|
|
- elements={globalSceneState.getAllElements().filter(element => {
|
|
|
- return !element.isDeleted;
|
|
|
- })}
|
|
|
- setElements={this.setElements}
|
|
|
- language={getLanguage()}
|
|
|
- onRoomCreate={this.createRoom}
|
|
|
- onRoomDestroy={this.destroyRoom}
|
|
|
- onToggleLock={this.toggleLock}
|
|
|
- />
|
|
|
- <main>
|
|
|
- <canvas
|
|
|
- id="canvas"
|
|
|
- style={{
|
|
|
- width: canvasDOMWidth,
|
|
|
- height: canvasDOMHeight,
|
|
|
- }}
|
|
|
- width={canvasWidth}
|
|
|
- height={canvasHeight}
|
|
|
- ref={canvas => {
|
|
|
- // 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")}
|
|
|
- </canvas>
|
|
|
- </main>
|
|
|
- </div>
|
|
|
- );
|
|
|
- }
|
|
|
-
|
|
|
private handleCanvasDoubleClick = (
|
|
|
event: React.MouseEvent<HTMLCanvasElement>,
|
|
|
) => {
|
|
@@ -2287,55 +2402,6 @@ export class App extends React.Component<any, AppState> {
|
|
|
}));
|
|
|
});
|
|
|
|
|
|
- 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<any, AppState> {
|
|
|
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());
|
|
|
- }
|
|
|
}
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|