2020-03-07 10:20:38 -05:00
|
|
|
import React from "react";
|
|
|
|
|
|
|
|
import rough from "roughjs/bin/rough";
|
|
|
|
import { RoughCanvas } from "roughjs/bin/canvas";
|
2020-05-12 20:10:11 +01:00
|
|
|
import { simplify, Point } from "points-on-curve";
|
2020-04-12 16:24:52 +05:30
|
|
|
import { FlooredNumber, SocketUpdateData } from "../types";
|
2020-03-07 10:20:38 -05:00
|
|
|
|
|
|
|
import {
|
|
|
|
newElement,
|
|
|
|
newTextElement,
|
|
|
|
duplicateElement,
|
|
|
|
resizeTest,
|
|
|
|
isInvisiblySmallElement,
|
|
|
|
isTextElement,
|
|
|
|
textWysiwyg,
|
|
|
|
getCommonBounds,
|
|
|
|
getCursorForResizingElement,
|
|
|
|
getPerfectElementSize,
|
2020-05-28 11:41:34 +02:00
|
|
|
getNormalizedDimensions,
|
2020-03-14 20:46:57 -07:00
|
|
|
getElementMap,
|
|
|
|
getDrawingVersion,
|
|
|
|
getSyncableElements,
|
2020-03-17 20:55:40 +01:00
|
|
|
newLinearElement,
|
2020-04-07 17:49:59 +09:00
|
|
|
resizeElements,
|
|
|
|
getElementWithResizeHandler,
|
2020-05-09 17:57:00 +09:00
|
|
|
getResizeOffsetXY,
|
2020-05-11 00:41:36 +09:00
|
|
|
getResizeArrowDirection,
|
2020-04-07 17:49:59 +09:00
|
|
|
getResizeHandlerFromCoords,
|
2020-04-11 17:10:56 +01:00
|
|
|
isNonDeletedElement,
|
2020-06-25 21:21:27 +02:00
|
|
|
updateTextElement,
|
2020-06-24 00:24:52 +09:00
|
|
|
dragSelectedElements,
|
|
|
|
getDragOffsetXY,
|
|
|
|
dragNewElement,
|
2020-03-07 10:20:38 -05:00
|
|
|
} from "../element";
|
|
|
|
import {
|
|
|
|
getElementsWithinSelection,
|
|
|
|
isOverScrollBars,
|
|
|
|
getElementAtPosition,
|
|
|
|
getElementContainingPosition,
|
|
|
|
getNormalizedZoom,
|
|
|
|
getSelectedElements,
|
2020-03-15 10:06:41 -07:00
|
|
|
globalSceneState,
|
2020-03-07 10:20:38 -05:00
|
|
|
isSomeElementSelected,
|
2020-04-03 21:22:26 +02:00
|
|
|
calculateScrollCenter,
|
2020-03-07 10:20:38 -05:00
|
|
|
} from "../scene";
|
basic Socket.io implementation of collaborative editing (#879)
* Enable collaborative syncing for elements
* Don't fall back to local storage if using a room, as that is confusing
* Use remote socket server
* Send updates to new users when they join
* ~
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* Enable collaborative syncing for elements
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* prettier
* Fix bug with remote pointers not changing on scroll
* Enable collaborative syncing for elements
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* fix syncing bugs and add a button to start syncing mid session
* Fix bug with remote pointers not changing on scroll
* remove UI for collaboration
* remove link
* clean up lingering unused UI
* set random IV passed per encrypted message, reduce room id length, refactored socket broadcasting API, rename room_id to room, removed throttling of pointer movement
* fix package.json conflict
2020-03-09 08:48:25 -07:00
|
|
|
import {
|
|
|
|
decryptAESGEM,
|
|
|
|
saveToLocalStorage,
|
|
|
|
loadScene,
|
|
|
|
loadFromBlob,
|
|
|
|
SOCKET_SERVER,
|
2020-03-14 21:25:07 +01:00
|
|
|
SocketUpdateDataSource,
|
2020-03-14 22:53:18 +01:00
|
|
|
exportCanvas,
|
basic Socket.io implementation of collaborative editing (#879)
* Enable collaborative syncing for elements
* Don't fall back to local storage if using a room, as that is confusing
* Use remote socket server
* Send updates to new users when they join
* ~
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* Enable collaborative syncing for elements
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* prettier
* Fix bug with remote pointers not changing on scroll
* Enable collaborative syncing for elements
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* fix syncing bugs and add a button to start syncing mid session
* Fix bug with remote pointers not changing on scroll
* remove UI for collaboration
* remove link
* clean up lingering unused UI
* set random IV passed per encrypted message, reduce room id length, refactored socket broadcasting API, rename room_id to room, removed throttling of pointer movement
* fix package.json conflict
2020-03-09 08:48:25 -07:00
|
|
|
} from "../data";
|
2020-04-12 16:24:52 +05:30
|
|
|
import Portal from "./Portal";
|
2020-03-07 10:20:38 -05:00
|
|
|
|
|
|
|
import { renderScene } from "../renderer";
|
|
|
|
import { AppState, GestureEvent, Gesture } from "../types";
|
2020-06-25 21:21:27 +02:00
|
|
|
import {
|
|
|
|
ExcalidrawElement,
|
2020-07-07 20:40:39 +05:30
|
|
|
ExcalidrawProps,
|
2020-06-25 21:21:27 +02:00
|
|
|
ExcalidrawTextElement,
|
|
|
|
NonDeleted,
|
|
|
|
} from "../element/types";
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-06-24 00:24:52 +09:00
|
|
|
import { distance2d, isPathALoop, getGridPoint } from "../math";
|
2020-04-09 01:46:47 -07:00
|
|
|
|
2020-03-07 10:20:38 -05:00
|
|
|
import {
|
|
|
|
isWritableElement,
|
|
|
|
isInputLike,
|
|
|
|
isToolIcon,
|
|
|
|
debounce,
|
|
|
|
distance,
|
|
|
|
resetCursor,
|
|
|
|
viewportCoordsToSceneCoords,
|
|
|
|
sceneCoordsToViewportCoords,
|
2020-04-06 22:26:54 +02:00
|
|
|
setCursorForShape,
|
2020-03-07 10:20:38 -05:00
|
|
|
} from "../utils";
|
2020-04-22 16:57:17 +01:00
|
|
|
import {
|
|
|
|
KEYS,
|
|
|
|
isArrowKey,
|
|
|
|
getResizeCenterPointKey,
|
|
|
|
getResizeWithSidesSameLengthKey,
|
2020-06-24 00:24:52 +09:00
|
|
|
getRotateWithDiscreteAngleKey,
|
2020-04-22 16:57:17 +01:00
|
|
|
} from "../keys";
|
2020-03-07 10:20:38 -05:00
|
|
|
|
|
|
|
import { findShapeByKey, shapesShortcutKeys } from "../shapes";
|
2020-03-18 20:44:05 +01:00
|
|
|
import { createHistory, SceneHistory } from "../history";
|
2020-03-07 10:20:38 -05:00
|
|
|
|
|
|
|
import ContextMenu from "./ContextMenu";
|
|
|
|
|
|
|
|
import { ActionManager } from "../actions/manager";
|
|
|
|
import "../actions";
|
|
|
|
import { actions } from "../actions/register";
|
|
|
|
|
|
|
|
import { ActionResult } from "../actions/types";
|
|
|
|
import { getDefaultAppState } from "../appState";
|
2020-05-27 16:46:11 +02:00
|
|
|
import { t, getLanguage } from "../i18n";
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-03-14 22:53:18 +01:00
|
|
|
import {
|
|
|
|
copyToAppClipboard,
|
|
|
|
getClipboardContent,
|
|
|
|
probablySupportsClipboardBlob,
|
2020-04-05 16:13:17 -07:00
|
|
|
probablySupportsClipboardWriteText,
|
2020-03-14 22:53:18 +01:00
|
|
|
} from "../clipboard";
|
2020-03-07 10:20:38 -05:00
|
|
|
import { normalizeScroll } from "../scene";
|
|
|
|
import { getCenter, getDistance } from "../gesture";
|
|
|
|
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
|
2020-04-12 06:12:02 +05:30
|
|
|
|
2020-03-07 10:20:38 -05:00
|
|
|
import {
|
|
|
|
CURSOR_TYPE,
|
|
|
|
ELEMENT_SHIFT_TRANSLATE_AMOUNT,
|
|
|
|
ELEMENT_TRANSLATE_AMOUNT,
|
|
|
|
POINTER_BUTTON,
|
|
|
|
DRAGGING_THRESHOLD,
|
|
|
|
TEXT_TO_CENTER_SNAP_THRESHOLD,
|
2020-04-09 01:46:47 -07:00
|
|
|
LINE_CONFIRM_THRESHOLD,
|
2020-04-12 06:12:02 +05:30
|
|
|
SCENE,
|
|
|
|
EVENT,
|
|
|
|
ENV,
|
2020-05-30 18:56:17 +05:30
|
|
|
CANVAS_ONLY_ACTIONS,
|
2020-06-25 21:21:27 +02:00
|
|
|
DEFAULT_VERTICAL_ALIGN,
|
2020-06-24 00:24:52 +09:00
|
|
|
GRID_SIZE,
|
2020-07-08 22:55:26 +02:00
|
|
|
LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
|
2020-03-07 10:20:38 -05:00
|
|
|
} from "../constants";
|
2020-04-12 06:12:02 +05:30
|
|
|
import {
|
|
|
|
INITAL_SCENE_UPDATE_TIMEOUT,
|
|
|
|
TAP_TWICE_TIMEOUT,
|
2020-05-30 18:56:17 +05:30
|
|
|
SYNC_FULL_SCENE_INTERVAL_MS,
|
2020-07-02 22:12:56 +01:00
|
|
|
TOUCH_CTX_MENU_TIMEOUT,
|
2020-04-12 06:12:02 +05:30
|
|
|
} from "../time_constants";
|
|
|
|
|
2020-04-18 01:54:19 +05:30
|
|
|
import LayerUI from "./LayerUI";
|
2020-04-04 16:02:16 +02:00
|
|
|
import { ScrollBars, SceneState } from "../scene/types";
|
basic Socket.io implementation of collaborative editing (#879)
* Enable collaborative syncing for elements
* Don't fall back to local storage if using a room, as that is confusing
* Use remote socket server
* Send updates to new users when they join
* ~
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* Enable collaborative syncing for elements
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* prettier
* Fix bug with remote pointers not changing on scroll
* Enable collaborative syncing for elements
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* fix syncing bugs and add a button to start syncing mid session
* Fix bug with remote pointers not changing on scroll
* remove UI for collaboration
* remove link
* clean up lingering unused UI
* set random IV passed per encrypted message, reduce room id length, refactored socket broadcasting API, rename room_id to room, removed throttling of pointer movement
* fix package.json conflict
2020-03-09 08:48:25 -07:00
|
|
|
import { generateCollaborationLink, getCollaborationLinkData } from "../data";
|
2020-03-10 20:11:02 -07:00
|
|
|
import { mutateElement, newElementWith } from "../element/mutateElement";
|
2020-03-14 21:48:51 -07:00
|
|
|
import { invalidateShapeForElement } from "../renderer/renderElement";
|
2020-03-16 19:07:47 -07:00
|
|
|
import { unstable_batchedUpdates } from "react-dom";
|
|
|
|
import { SceneStateCallbackRemover } from "../scene/globalScene";
|
2020-03-17 20:55:40 +01:00
|
|
|
import { isLinearElement } from "../element/typeChecks";
|
2020-05-30 22:48:57 +02:00
|
|
|
import { actionFinalize, actionDeleteSelected } from "../actions";
|
2020-04-11 17:13:10 +01:00
|
|
|
import {
|
|
|
|
restoreUsernameFromLocalStorage,
|
|
|
|
saveUsernameToLocalStorage,
|
|
|
|
} from "../data/localStorage";
|
2020-03-16 19:07:47 -07:00
|
|
|
|
2020-05-07 14:13:18 -07:00
|
|
|
import throttle from "lodash.throttle";
|
2020-06-01 11:35:44 +02:00
|
|
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
2020-05-26 13:07:46 -07:00
|
|
|
import {
|
|
|
|
getSelectedGroupIds,
|
|
|
|
selectGroupsForSelectedElements,
|
|
|
|
isElementInGroup,
|
|
|
|
getSelectedGroupIdForElement,
|
|
|
|
} from "../groups";
|
2020-05-07 14:13:18 -07:00
|
|
|
|
2020-03-19 14:51:05 +01:00
|
|
|
/**
|
|
|
|
* @param func handler taking at most single parameter (event).
|
|
|
|
*/
|
2020-05-20 16:21:37 +03:00
|
|
|
const withBatchedUpdates = <
|
2020-03-16 19:07:47 -07:00
|
|
|
TFunction extends ((event: any) => void) | (() => void)
|
2020-05-20 16:21:37 +03:00
|
|
|
>(
|
|
|
|
func: Parameters<TFunction>["length"] extends 0 | 1 ? TFunction : never,
|
|
|
|
) =>
|
|
|
|
((event) => {
|
2020-04-14 12:30:58 +03:00
|
|
|
unstable_batchedUpdates(func as TFunction, event);
|
|
|
|
}) as TFunction;
|
2020-03-07 10:20:38 -05:00
|
|
|
|
|
|
|
const { history } = createHistory();
|
|
|
|
|
2020-04-04 14:55:36 +02:00
|
|
|
let didTapTwice: boolean = false;
|
|
|
|
let tappedTwiceTimer = 0;
|
2020-03-07 10:20:38 -05:00
|
|
|
let cursorX = 0;
|
|
|
|
let cursorY = 0;
|
|
|
|
let isHoldingSpace: boolean = false;
|
|
|
|
let isPanning: boolean = false;
|
|
|
|
let isDraggingScrollBar: boolean = false;
|
|
|
|
let currentScrollBars: ScrollBars = { horizontal: null, vertical: null };
|
2020-07-02 22:12:56 +01:00
|
|
|
let touchTimeout = 0;
|
|
|
|
let touchMoving = false;
|
2020-03-07 10:20:38 -05:00
|
|
|
|
|
|
|
let lastPointerUp: ((event: any) => void) | null = null;
|
|
|
|
const gesture: Gesture = {
|
2020-03-08 19:25:16 -07:00
|
|
|
pointers: new Map(),
|
2020-03-07 10:20:38 -05:00
|
|
|
lastCenter: null,
|
|
|
|
initialDistance: null,
|
|
|
|
initialScale: null,
|
|
|
|
};
|
|
|
|
|
2020-07-07 20:40:39 +05:30
|
|
|
class App extends React.Component<ExcalidrawProps, AppState> {
|
2020-04-07 15:29:43 -07:00
|
|
|
canvas: HTMLCanvasElement | null = null;
|
|
|
|
rc: RoughCanvas | null = null;
|
2020-04-27 10:56:08 -07:00
|
|
|
portal: Portal = new Portal(this);
|
2020-03-14 20:46:57 -07:00
|
|
|
lastBroadcastedOrReceivedSceneVersion: number = -1;
|
2020-05-07 14:13:18 -07:00
|
|
|
broadcastedElementVersions: Map<string, number> = new Map();
|
2020-03-16 19:07:47 -07:00
|
|
|
removeSceneCallback: SceneStateCallbackRemover | null = null;
|
2020-05-30 18:56:17 +05:30
|
|
|
unmounted: boolean = false;
|
2020-03-07 10:20:38 -05:00
|
|
|
actionManager: ActionManager;
|
2020-03-26 18:28:26 +01:00
|
|
|
|
2020-07-07 20:40:39 +05:30
|
|
|
public static defaultProps: Partial<ExcalidrawProps> = {
|
|
|
|
width: window.innerWidth,
|
|
|
|
height: window.innerHeight,
|
2020-03-26 18:28:26 +01:00
|
|
|
};
|
|
|
|
|
2020-03-07 10:20:38 -05:00
|
|
|
constructor(props: any) {
|
|
|
|
super(props);
|
2020-07-07 20:40:39 +05:30
|
|
|
const defaultAppState = getDefaultAppState();
|
|
|
|
|
|
|
|
const { width, height } = props;
|
|
|
|
this.state = {
|
|
|
|
...defaultAppState,
|
|
|
|
isLoading: true,
|
|
|
|
width,
|
|
|
|
height,
|
|
|
|
};
|
|
|
|
|
2020-03-07 10:20:38 -05:00
|
|
|
this.actionManager = new ActionManager(
|
|
|
|
this.syncActionResult,
|
|
|
|
() => this.state,
|
2020-04-08 09:49:52 -07:00
|
|
|
() => globalSceneState.getElementsIncludingDeleted(),
|
2020-03-07 10:20:38 -05:00
|
|
|
);
|
|
|
|
this.actionManager.registerAll(actions);
|
|
|
|
|
|
|
|
this.actionManager.registerAction(createUndoAction(history));
|
|
|
|
this.actionManager.registerAction(createRedoAction(history));
|
|
|
|
}
|
|
|
|
|
2020-03-22 10:24:50 -07:00
|
|
|
public render() {
|
2020-07-07 20:40:39 +05:30
|
|
|
const {
|
|
|
|
zenModeEnabled,
|
|
|
|
width: canvasDOMWidth,
|
|
|
|
height: canvasDOMHeight,
|
|
|
|
} = this.state;
|
2020-03-22 10:24:50 -07:00
|
|
|
|
|
|
|
const canvasScale = window.devicePixelRatio;
|
|
|
|
|
|
|
|
const canvasWidth = canvasDOMWidth * canvasScale;
|
|
|
|
const canvasHeight = canvasDOMHeight * canvasScale;
|
|
|
|
|
|
|
|
return (
|
2020-06-08 16:06:35 +05:30
|
|
|
<div className="excalidraw">
|
2020-03-22 10:24:50 -07:00
|
|
|
<LayerUI
|
|
|
|
canvas={this.canvas}
|
|
|
|
appState={this.state}
|
|
|
|
setAppState={this.setAppState}
|
|
|
|
actionManager={this.actionManager}
|
2020-04-08 09:49:52 -07:00
|
|
|
elements={globalSceneState.getElements()}
|
2020-04-07 15:29:43 -07:00
|
|
|
onRoomCreate={this.openPortal}
|
|
|
|
onRoomDestroy={this.closePortal}
|
2020-04-11 17:13:10 +01:00
|
|
|
onUsernameChange={(username) => {
|
2020-04-11 21:23:12 +01:00
|
|
|
saveUsernameToLocalStorage(username);
|
2020-04-11 17:13:10 +01:00
|
|
|
this.setState({
|
|
|
|
username,
|
|
|
|
});
|
|
|
|
}}
|
2020-03-24 19:51:49 +09:00
|
|
|
onLockToggle={this.toggleLock}
|
2020-04-25 18:43:02 +05:30
|
|
|
zenModeEnabled={zenModeEnabled}
|
|
|
|
toggleZenMode={this.toggleZenMode}
|
2020-05-27 16:46:11 +02:00
|
|
|
lng={getLanguage().lng}
|
2020-03-22 10:24:50 -07:00
|
|
|
/>
|
|
|
|
<main>
|
|
|
|
<canvas
|
|
|
|
id="canvas"
|
|
|
|
style={{
|
|
|
|
width: canvasDOMWidth,
|
|
|
|
height: canvasDOMHeight,
|
|
|
|
}}
|
|
|
|
width={canvasWidth}
|
|
|
|
height={canvasHeight}
|
2020-04-04 14:55:36 +02:00
|
|
|
ref={this.handleCanvasRef}
|
2020-03-22 10:24:50 -07:00
|
|
|
onContextMenu={this.handleCanvasContextMenu}
|
|
|
|
onPointerDown={this.handleCanvasPointerDown}
|
|
|
|
onDoubleClick={this.handleCanvasDoubleClick}
|
|
|
|
onPointerMove={this.handleCanvasPointerMove}
|
|
|
|
onPointerUp={this.removePointer}
|
|
|
|
onPointerCancel={this.removePointer}
|
2020-07-02 22:12:56 +01:00
|
|
|
onTouchMove={this.handleTouchMove}
|
2020-04-04 15:27:53 +02:00
|
|
|
onDrop={this.handleCanvasOnDrop}
|
2020-03-22 10:24:50 -07:00
|
|
|
>
|
|
|
|
{t("labels.drawingCanvas")}
|
|
|
|
</canvas>
|
|
|
|
</main>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2020-07-07 13:53:44 +02:00
|
|
|
private syncActionResult = withBatchedUpdates(
|
|
|
|
(actionResult: ActionResult) => {
|
|
|
|
if (this.unmounted || actionResult === false) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
let editingElement: AppState["editingElement"] | null = null;
|
|
|
|
if (actionResult.elements) {
|
|
|
|
actionResult.elements.forEach((element) => {
|
|
|
|
if (
|
|
|
|
this.state.editingElement?.id === element.id &&
|
|
|
|
this.state.editingElement !== element &&
|
|
|
|
isNonDeletedElement(element)
|
|
|
|
) {
|
|
|
|
editingElement = element;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
globalSceneState.replaceAllElements(actionResult.elements);
|
|
|
|
if (actionResult.commitToHistory) {
|
|
|
|
history.resumeRecording();
|
2020-04-11 17:10:56 +01:00
|
|
|
}
|
2020-03-07 10:20:38 -05:00
|
|
|
}
|
|
|
|
|
2020-07-07 13:53:44 +02:00
|
|
|
if (actionResult.appState || editingElement) {
|
|
|
|
if (actionResult.commitToHistory) {
|
|
|
|
history.resumeRecording();
|
|
|
|
}
|
|
|
|
this.setState(
|
|
|
|
(state) => ({
|
|
|
|
...actionResult.appState,
|
|
|
|
editingElement:
|
|
|
|
editingElement || actionResult.appState?.editingElement || null,
|
|
|
|
isCollaborating: state.isCollaborating,
|
|
|
|
collaborators: state.collaborators,
|
|
|
|
}),
|
|
|
|
() => {
|
|
|
|
if (actionResult.syncHistory) {
|
|
|
|
history.setCurrentState(
|
|
|
|
this.state,
|
|
|
|
globalSceneState.getElementsIncludingDeleted(),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
},
|
|
|
|
);
|
2020-03-07 10:20:38 -05:00
|
|
|
}
|
2020-07-07 13:53:44 +02:00
|
|
|
},
|
|
|
|
);
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-03-22 10:24:50 -07:00
|
|
|
// Lifecycle
|
|
|
|
|
2020-03-26 18:28:26 +01:00
|
|
|
private onBlur = withBatchedUpdates(() => {
|
2020-03-22 10:24:50 -07:00
|
|
|
isHoldingSpace = false;
|
|
|
|
this.saveDebounced();
|
|
|
|
this.saveDebounced.flush();
|
|
|
|
});
|
|
|
|
|
2020-03-26 18:28:26 +01:00
|
|
|
private onUnload = () => {
|
|
|
|
this.destroySocketClient();
|
|
|
|
this.onBlur();
|
|
|
|
};
|
|
|
|
|
2020-03-23 13:05:07 +02:00
|
|
|
private disableEvent: EventHandlerNonNull = (event) => {
|
2020-03-22 10:24:50 -07:00
|
|
|
event.preventDefault();
|
|
|
|
};
|
2020-03-26 18:28:26 +01:00
|
|
|
|
2020-05-08 10:42:51 +02:00
|
|
|
private onFontLoaded = () => {
|
|
|
|
globalSceneState.getElementsIncludingDeleted().forEach((element) => {
|
|
|
|
if (isTextElement(element)) {
|
|
|
|
invalidateShapeForElement(element);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
this.onSceneUpdated();
|
|
|
|
};
|
|
|
|
|
2020-07-08 22:55:26 +02:00
|
|
|
private shouldForceLoadScene(
|
|
|
|
scene: ResolutionType<typeof loadScene>,
|
|
|
|
): boolean {
|
|
|
|
if (!scene.elements.length) {
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
const roomMatch = getCollaborationLinkData(window.location.href);
|
|
|
|
|
|
|
|
if (!roomMatch) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
const collabForceLoadFlag = localStorage.getItem(
|
|
|
|
LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
|
|
|
|
);
|
|
|
|
if (collabForceLoadFlag) {
|
|
|
|
try {
|
|
|
|
const {
|
|
|
|
room: previousRoom,
|
|
|
|
timestamp,
|
|
|
|
}: { room: string; timestamp: number } = JSON.parse(
|
|
|
|
collabForceLoadFlag,
|
|
|
|
);
|
|
|
|
// if loading same room as the one previously unloaded within 15sec
|
|
|
|
// force reload without prompting
|
|
|
|
if (previousRoom === roomMatch[1] && Date.now() - timestamp < 15000) {
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
} catch {}
|
|
|
|
}
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2020-03-26 18:28:26 +01:00
|
|
|
private initializeScene = async () => {
|
|
|
|
const searchParams = new URLSearchParams(window.location.search);
|
|
|
|
const id = searchParams.get("id");
|
|
|
|
const jsonMatch = window.location.hash.match(
|
|
|
|
/^#json=([0-9]+),([a-zA-Z0-9_-]+)$/,
|
|
|
|
);
|
|
|
|
|
2020-07-08 22:55:26 +02:00
|
|
|
let scene = await loadScene(null);
|
|
|
|
|
|
|
|
let isCollaborationScene = !!getCollaborationLinkData(window.location.href);
|
|
|
|
const isExternalScene = !!(id || jsonMatch || isCollaborationScene);
|
|
|
|
|
|
|
|
if (isExternalScene) {
|
|
|
|
if (
|
|
|
|
this.shouldForceLoadScene(scene) ||
|
|
|
|
window.confirm(t("alerts.loadSceneOverridePrompt"))
|
|
|
|
) {
|
|
|
|
// Backwards compatibility with legacy url format
|
|
|
|
if (id) {
|
|
|
|
scene = await loadScene(id);
|
|
|
|
} else if (jsonMatch) {
|
|
|
|
scene = await loadScene(jsonMatch[1], jsonMatch[2]);
|
|
|
|
}
|
|
|
|
if (!isCollaborationScene) {
|
|
|
|
window.history.replaceState({}, "Excalidraw", window.location.origin);
|
|
|
|
}
|
2020-03-26 18:28:26 +01:00
|
|
|
} else {
|
2020-07-08 22:55:26 +02:00
|
|
|
isCollaborationScene = false;
|
|
|
|
window.history.replaceState({}, "Excalidraw", window.location.origin);
|
2020-03-26 18:28:26 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (this.state.isLoading) {
|
|
|
|
this.setState({ isLoading: false });
|
|
|
|
}
|
|
|
|
|
|
|
|
if (isCollaborationScene) {
|
|
|
|
this.initializeSocketClient({ showLoadingState: true });
|
2020-07-08 22:55:26 +02:00
|
|
|
} else if (scene) {
|
|
|
|
this.syncActionResult(scene);
|
2020-03-26 18:28:26 +01:00
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2020-03-22 10:24:50 -07:00
|
|
|
public async componentDidMount() {
|
|
|
|
if (
|
2020-05-30 18:56:17 +05:30
|
|
|
process.env.NODE_ENV === ENV.TEST ||
|
|
|
|
process.env.NODE_ENV === ENV.DEVELOPMENT
|
2020-03-22 10:24:50 -07:00
|
|
|
) {
|
|
|
|
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);
|
|
|
|
},
|
|
|
|
},
|
2020-03-26 08:28:50 +01:00
|
|
|
app: {
|
|
|
|
configurable: true,
|
|
|
|
value: this,
|
|
|
|
},
|
2020-03-22 10:24:50 -07:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
this.removeSceneCallback = globalSceneState.addCallback(
|
|
|
|
this.onSceneUpdated,
|
|
|
|
);
|
|
|
|
|
2020-05-30 18:56:17 +05:30
|
|
|
this.addEventListeners();
|
2020-03-26 18:28:26 +01:00
|
|
|
this.initializeScene();
|
2020-03-22 10:24:50 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
public componentWillUnmount() {
|
|
|
|
this.unmounted = true;
|
|
|
|
this.removeSceneCallback!();
|
2020-05-30 18:56:17 +05:30
|
|
|
this.removeEventListeners();
|
2020-07-02 22:12:56 +01:00
|
|
|
|
|
|
|
clearTimeout(touchTimeout);
|
2020-05-30 18:56:17 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
private onResize = withBatchedUpdates(() => {
|
|
|
|
globalSceneState
|
|
|
|
.getElementsIncludingDeleted()
|
|
|
|
.forEach((element) => invalidateShapeForElement(element));
|
|
|
|
this.setState({});
|
|
|
|
});
|
2020-03-22 10:24:50 -07:00
|
|
|
|
2020-05-30 18:56:17 +05:30
|
|
|
private removeEventListeners() {
|
2020-04-12 06:12:02 +05:30
|
|
|
document.removeEventListener(EVENT.COPY, this.onCopy);
|
|
|
|
document.removeEventListener(EVENT.PASTE, this.pasteFromClipboard);
|
|
|
|
document.removeEventListener(EVENT.CUT, this.onCut);
|
2020-03-22 10:24:50 -07:00
|
|
|
|
2020-04-12 06:12:02 +05:30
|
|
|
document.removeEventListener(EVENT.KEYDOWN, this.onKeyDown, false);
|
2020-03-22 10:24:50 -07:00
|
|
|
document.removeEventListener(
|
2020-04-12 06:12:02 +05:30
|
|
|
EVENT.MOUSE_MOVE,
|
2020-03-22 10:24:50 -07:00
|
|
|
this.updateCurrentCursorPosition,
|
|
|
|
false,
|
|
|
|
);
|
2020-04-12 06:12:02 +05:30
|
|
|
document.removeEventListener(EVENT.KEYUP, this.onKeyUp);
|
|
|
|
window.removeEventListener(EVENT.RESIZE, this.onResize, false);
|
|
|
|
window.removeEventListener(EVENT.UNLOAD, this.onUnload, false);
|
|
|
|
window.removeEventListener(EVENT.BLUR, this.onBlur, false);
|
|
|
|
window.removeEventListener(EVENT.DRAG_OVER, this.disableEvent, false);
|
|
|
|
window.removeEventListener(EVENT.DROP, this.disableEvent, false);
|
2020-03-22 10:24:50 -07:00
|
|
|
|
|
|
|
document.removeEventListener(
|
2020-04-12 06:12:02 +05:30
|
|
|
EVENT.GESTURE_START,
|
2020-03-22 10:24:50 -07:00
|
|
|
this.onGestureStart as any,
|
|
|
|
false,
|
|
|
|
);
|
|
|
|
document.removeEventListener(
|
2020-04-12 06:12:02 +05:30
|
|
|
EVENT.GESTURE_CHANGE,
|
2020-03-22 10:24:50 -07:00
|
|
|
this.onGestureChange as any,
|
|
|
|
false,
|
|
|
|
);
|
2020-04-12 06:12:02 +05:30
|
|
|
document.removeEventListener(
|
|
|
|
EVENT.GESTURE_END,
|
|
|
|
this.onGestureEnd as any,
|
|
|
|
false,
|
|
|
|
);
|
|
|
|
window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
|
2020-03-22 10:24:50 -07:00
|
|
|
}
|
2020-05-30 18:56:17 +05:30
|
|
|
|
|
|
|
private addEventListeners() {
|
|
|
|
document.addEventListener(EVENT.COPY, this.onCopy);
|
|
|
|
document.addEventListener(EVENT.PASTE, this.pasteFromClipboard);
|
|
|
|
document.addEventListener(EVENT.CUT, this.onCut);
|
|
|
|
|
|
|
|
document.addEventListener(EVENT.KEYDOWN, this.onKeyDown, false);
|
|
|
|
document.addEventListener(EVENT.KEYUP, this.onKeyUp, { passive: true });
|
|
|
|
document.addEventListener(
|
|
|
|
EVENT.MOUSE_MOVE,
|
|
|
|
this.updateCurrentCursorPosition,
|
|
|
|
);
|
|
|
|
window.addEventListener(EVENT.RESIZE, this.onResize, false);
|
|
|
|
window.addEventListener(EVENT.UNLOAD, this.onUnload, false);
|
|
|
|
window.addEventListener(EVENT.BLUR, this.onBlur, false);
|
|
|
|
window.addEventListener(EVENT.DRAG_OVER, this.disableEvent, false);
|
|
|
|
window.addEventListener(EVENT.DROP, this.disableEvent, false);
|
|
|
|
|
|
|
|
// rerender text elements on font load to fix #637 && #1553
|
|
|
|
document.fonts?.addEventListener?.("loadingdone", this.onFontLoaded);
|
|
|
|
|
|
|
|
// Safari-only desktop pinch zoom
|
|
|
|
document.addEventListener(
|
|
|
|
EVENT.GESTURE_START,
|
|
|
|
this.onGestureStart as any,
|
|
|
|
false,
|
|
|
|
);
|
|
|
|
document.addEventListener(
|
|
|
|
EVENT.GESTURE_CHANGE,
|
|
|
|
this.onGestureChange as any,
|
|
|
|
false,
|
|
|
|
);
|
|
|
|
document.addEventListener(
|
|
|
|
EVENT.GESTURE_END,
|
|
|
|
this.onGestureEnd as any,
|
|
|
|
false,
|
|
|
|
);
|
|
|
|
window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
|
|
|
|
}
|
2020-03-22 10:24:50 -07:00
|
|
|
|
|
|
|
private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
|
2020-07-08 22:55:26 +02:00
|
|
|
if (this.state.isCollaborating && this.portal.roomID) {
|
|
|
|
localStorage.setItem(
|
|
|
|
LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
|
|
|
|
JSON.stringify({
|
|
|
|
timestamp: Date.now(),
|
|
|
|
room: this.portal.roomID,
|
|
|
|
}),
|
|
|
|
);
|
|
|
|
}
|
2020-03-22 10:24:50 -07:00
|
|
|
if (
|
|
|
|
this.state.isCollaborating &&
|
2020-04-08 09:49:52 -07:00
|
|
|
globalSceneState.getElements().length > 0
|
2020-03-22 10:24:50 -07:00
|
|
|
) {
|
|
|
|
event.preventDefault();
|
|
|
|
// NOTE: modern browsers no longer allow showing a custom message here
|
|
|
|
event.returnValue = "";
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
2020-05-07 14:13:18 -07:00
|
|
|
queueBroadcastAllElements = throttle(() => {
|
|
|
|
this.broadcastScene(SCENE.UPDATE, /* syncAll */ true);
|
|
|
|
}, SYNC_FULL_SCENE_INTERVAL_MS);
|
|
|
|
|
2020-07-07 20:40:39 +05:30
|
|
|
componentDidUpdate(prevProps: ExcalidrawProps) {
|
|
|
|
const { width: prevWidth, height: prevHeight } = prevProps;
|
|
|
|
const { width: currentWidth, height: currentHeight } = this.props;
|
|
|
|
if (prevWidth !== currentWidth || prevHeight !== currentHeight) {
|
|
|
|
this.setState({
|
|
|
|
width: currentWidth,
|
|
|
|
height: currentHeight,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2020-04-08 10:18:56 -07:00
|
|
|
if (this.state.isCollaborating && !this.portal.socket) {
|
2020-03-26 18:28:26 +01:00
|
|
|
this.initializeSocketClient({ showLoadingState: true });
|
2020-03-22 10:24:50 -07:00
|
|
|
}
|
2020-04-04 16:12:19 +01:00
|
|
|
|
2020-06-01 11:35:44 +02:00
|
|
|
if (
|
|
|
|
this.state.editingLinearElement &&
|
|
|
|
!this.state.selectedElementIds[this.state.editingLinearElement.elementId]
|
|
|
|
) {
|
|
|
|
// defer so that the commitToHistory flag isn't reset via current update
|
|
|
|
setTimeout(() => {
|
|
|
|
this.actionManager.executeAction(actionFinalize);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2020-04-04 16:12:19 +01:00
|
|
|
const cursorButton: {
|
|
|
|
[id: string]: string | undefined;
|
|
|
|
} = {};
|
2020-04-04 16:02:16 +02:00
|
|
|
const pointerViewportCoords: SceneState["remotePointerViewportCoords"] = {};
|
|
|
|
const remoteSelectedElementIds: SceneState["remoteSelectedElementIds"] = {};
|
2020-04-07 14:02:42 +01:00
|
|
|
const pointerUsernames: { [id: string]: string } = {};
|
2020-03-22 10:24:50 -07:00
|
|
|
this.state.collaborators.forEach((user, socketID) => {
|
2020-04-04 16:02:16 +02:00
|
|
|
if (user.selectedElementIds) {
|
|
|
|
for (const id of Object.keys(user.selectedElementIds)) {
|
|
|
|
if (!(id in remoteSelectedElementIds)) {
|
|
|
|
remoteSelectedElementIds[id] = [];
|
|
|
|
}
|
|
|
|
remoteSelectedElementIds[id].push(socketID);
|
|
|
|
}
|
|
|
|
}
|
2020-03-22 10:24:50 -07:00
|
|
|
if (!user.pointer) {
|
|
|
|
return;
|
|
|
|
}
|
2020-04-07 14:02:42 +01:00
|
|
|
if (user.username) {
|
|
|
|
pointerUsernames[socketID] = user.username;
|
|
|
|
}
|
2020-03-22 10:24:50 -07:00
|
|
|
pointerViewportCoords[socketID] = sceneCoordsToViewportCoords(
|
|
|
|
{
|
|
|
|
sceneX: user.pointer.x,
|
|
|
|
sceneY: user.pointer.y,
|
|
|
|
},
|
|
|
|
this.state,
|
|
|
|
this.canvas,
|
|
|
|
window.devicePixelRatio,
|
|
|
|
);
|
2020-04-04 16:12:19 +01:00
|
|
|
cursorButton[socketID] = user.button;
|
2020-03-22 10:24:50 -07:00
|
|
|
});
|
2020-04-08 09:49:52 -07:00
|
|
|
const elements = globalSceneState.getElements();
|
2020-03-22 10:24:50 -07:00
|
|
|
const { atLeastOneVisibleElement, scrollBars } = renderScene(
|
2020-04-08 09:49:52 -07:00
|
|
|
elements.filter((element) => {
|
2020-04-03 14:16:14 +02:00
|
|
|
// don't render text element that's being currently edited (it's
|
|
|
|
// rendered on remote only)
|
|
|
|
return (
|
|
|
|
!this.state.editingElement ||
|
|
|
|
this.state.editingElement.type !== "text" ||
|
|
|
|
element.id !== this.state.editingElement.id
|
|
|
|
);
|
|
|
|
}),
|
2020-03-22 10:24:50 -07:00
|
|
|
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,
|
2020-04-04 16:12:19 +01:00
|
|
|
remotePointerButton: cursorButton,
|
2020-04-04 16:02:16 +02:00
|
|
|
remoteSelectedElementIds: remoteSelectedElementIds,
|
2020-04-07 14:02:42 +01:00
|
|
|
remotePointerUsernames: pointerUsernames,
|
2020-03-28 16:59:36 -07:00
|
|
|
shouldCacheIgnoreZoom: this.state.shouldCacheIgnoreZoom,
|
2020-03-22 10:24:50 -07:00
|
|
|
},
|
|
|
|
{
|
|
|
|
renderOptimizations: true,
|
|
|
|
},
|
|
|
|
);
|
|
|
|
if (scrollBars) {
|
|
|
|
currentScrollBars = scrollBars;
|
|
|
|
}
|
2020-06-25 21:21:27 +02:00
|
|
|
const scrolledOutside =
|
|
|
|
// hide when editing text
|
|
|
|
this.state.editingElement?.type === "text"
|
|
|
|
? false
|
|
|
|
: !atLeastOneVisibleElement && elements.length > 0;
|
2020-03-22 10:24:50 -07:00
|
|
|
if (this.state.scrolledOutside !== scrolledOutside) {
|
|
|
|
this.setState({ scrolledOutside: scrolledOutside });
|
|
|
|
}
|
|
|
|
this.saveDebounced();
|
|
|
|
|
|
|
|
if (
|
2020-04-08 09:49:52 -07:00
|
|
|
getDrawingVersion(globalSceneState.getElementsIncludingDeleted()) >
|
2020-03-22 10:24:50 -07:00
|
|
|
this.lastBroadcastedOrReceivedSceneVersion
|
|
|
|
) {
|
2020-05-07 14:13:18 -07:00
|
|
|
this.broadcastScene(SCENE.UPDATE, /* syncAll */ false);
|
|
|
|
this.queueBroadcastAllElements();
|
2020-03-22 10:24:50 -07:00
|
|
|
}
|
|
|
|
|
2020-04-08 09:49:52 -07:00
|
|
|
history.record(this.state, globalSceneState.getElementsIncludingDeleted());
|
2020-03-22 10:24:50 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
// Copy/paste
|
|
|
|
|
|
|
|
private onCut = withBatchedUpdates((event: ClipboardEvent) => {
|
|
|
|
if (isWritableElement(event.target)) {
|
|
|
|
return;
|
|
|
|
}
|
2020-03-27 15:18:14 -07:00
|
|
|
this.copyAll();
|
2020-05-30 22:48:57 +02:00
|
|
|
this.actionManager.executeAction(actionDeleteSelected);
|
2020-03-22 10:24:50 -07:00
|
|
|
event.preventDefault();
|
|
|
|
});
|
|
|
|
|
|
|
|
private onCopy = withBatchedUpdates((event: ClipboardEvent) => {
|
|
|
|
if (isWritableElement(event.target)) {
|
|
|
|
return;
|
|
|
|
}
|
2020-03-27 15:18:14 -07:00
|
|
|
this.copyAll();
|
2020-03-22 10:24:50 -07:00
|
|
|
event.preventDefault();
|
|
|
|
});
|
2020-03-27 15:18:14 -07:00
|
|
|
|
|
|
|
private copyAll = () => {
|
2020-04-08 09:49:52 -07:00
|
|
|
copyToAppClipboard(globalSceneState.getElements(), this.state);
|
2020-03-22 10:24:50 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
private copyToClipboardAsPng = () => {
|
2020-04-08 09:49:52 -07:00
|
|
|
const elements = globalSceneState.getElements();
|
|
|
|
|
|
|
|
const selectedElements = getSelectedElements(elements, this.state);
|
2020-03-22 10:24:50 -07:00
|
|
|
exportCanvas(
|
|
|
|
"clipboard",
|
2020-04-08 09:49:52 -07:00
|
|
|
selectedElements.length ? selectedElements : elements,
|
2020-03-22 10:24:50 -07:00
|
|
|
this.state,
|
|
|
|
this.canvas!,
|
|
|
|
this.state,
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
2020-04-05 16:13:17 -07:00
|
|
|
private copyToClipboardAsSvg = () => {
|
|
|
|
const selectedElements = getSelectedElements(
|
2020-04-08 09:49:52 -07:00
|
|
|
globalSceneState.getElements(),
|
2020-04-05 16:13:17 -07:00
|
|
|
this.state,
|
|
|
|
);
|
|
|
|
exportCanvas(
|
|
|
|
"clipboard-svg",
|
|
|
|
selectedElements.length
|
|
|
|
? selectedElements
|
2020-04-08 09:49:52 -07:00
|
|
|
: globalSceneState.getElements(),
|
2020-04-05 16:13:17 -07:00
|
|
|
this.state,
|
|
|
|
this.canvas!,
|
|
|
|
this.state,
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
2020-05-30 18:56:17 +05:30
|
|
|
private static resetTapTwice() {
|
2020-04-12 06:12:02 +05:30
|
|
|
didTapTwice = false;
|
|
|
|
}
|
|
|
|
|
2020-04-04 14:55:36 +02:00
|
|
|
private onTapStart = (event: TouchEvent) => {
|
|
|
|
if (!didTapTwice) {
|
|
|
|
didTapTwice = true;
|
|
|
|
clearTimeout(tappedTwiceTimer);
|
2020-04-12 06:12:02 +05:30
|
|
|
tappedTwiceTimer = window.setTimeout(
|
2020-05-30 18:56:17 +05:30
|
|
|
App.resetTapTwice,
|
2020-04-12 06:12:02 +05:30
|
|
|
TAP_TWICE_TIMEOUT,
|
|
|
|
);
|
2020-04-04 14:55:36 +02:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
// insert text only if we tapped twice with a single finger
|
|
|
|
// event.touches.length === 1 will also prevent inserting text when user's zooming
|
|
|
|
if (didTapTwice && event.touches.length === 1) {
|
|
|
|
const [touch] = event.touches;
|
|
|
|
// @ts-ignore
|
|
|
|
this.handleCanvasDoubleClick({
|
|
|
|
clientX: touch.clientX,
|
|
|
|
clientY: touch.clientY,
|
|
|
|
});
|
|
|
|
didTapTwice = false;
|
|
|
|
clearTimeout(tappedTwiceTimer);
|
|
|
|
}
|
|
|
|
event.preventDefault();
|
2020-06-02 18:41:40 +02:00
|
|
|
if (event.touches.length === 2) {
|
|
|
|
this.setState({
|
|
|
|
selectedElementIds: {},
|
|
|
|
});
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
private onTapEnd = (event: TouchEvent) => {
|
|
|
|
event.preventDefault();
|
|
|
|
if (event.touches.length > 0) {
|
|
|
|
const { previousSelectedElementIds } = this.state;
|
|
|
|
this.setState({
|
|
|
|
previousSelectedElementIds: {},
|
|
|
|
selectedElementIds: previousSelectedElementIds,
|
|
|
|
});
|
|
|
|
}
|
2020-04-04 14:55:36 +02:00
|
|
|
};
|
|
|
|
|
2020-03-22 10:24:50 -07:00
|
|
|
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
|
2020-03-28 15:43:09 -07:00
|
|
|
event &&
|
|
|
|
(!(elementUnderCursor instanceof HTMLCanvasElement) ||
|
|
|
|
isWritableElement(target))
|
2020-03-22 10:24:50 -07:00
|
|
|
) {
|
2020-03-28 15:43:09 -07:00
|
|
|
return;
|
|
|
|
}
|
2020-06-06 13:09:04 -07:00
|
|
|
const data = await getClipboardContent(
|
|
|
|
this.state,
|
|
|
|
cursorX,
|
|
|
|
cursorY,
|
|
|
|
event,
|
|
|
|
);
|
|
|
|
if (data.error) {
|
|
|
|
alert(data.error);
|
|
|
|
} else if (data.elements) {
|
2020-03-28 15:43:09 -07:00
|
|
|
this.addElementsFromPaste(data.elements);
|
|
|
|
} else if (data.text) {
|
|
|
|
this.addTextFromPaste(data.text);
|
2020-03-22 10:24:50 -07:00
|
|
|
}
|
2020-03-28 15:43:09 -07:00
|
|
|
this.selectShapeTool("selection");
|
|
|
|
event?.preventDefault();
|
2020-03-22 10:24:50 -07:00
|
|
|
},
|
|
|
|
);
|
|
|
|
|
|
|
|
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;
|
2020-05-26 13:07:46 -07:00
|
|
|
const groupIdMap = new Map();
|
2020-03-22 10:24:50 -07:00
|
|
|
|
2020-03-23 13:05:07 +02:00
|
|
|
const newElements = clipboardElements.map((element) =>
|
2020-05-26 13:07:46 -07:00
|
|
|
duplicateElement(this.state.editingGroupId, groupIdMap, element, {
|
2020-03-22 10:24:50 -07:00
|
|
|
x: element.x + dx - minX,
|
|
|
|
y: element.y + dy - minY,
|
|
|
|
}),
|
|
|
|
);
|
|
|
|
|
|
|
|
globalSceneState.replaceAllElements([
|
2020-04-08 09:49:52 -07:00
|
|
|
...globalSceneState.getElementsIncludingDeleted(),
|
2020-03-22 10:24:50 -07:00
|
|
|
...newElements,
|
|
|
|
]);
|
|
|
|
history.resumeRecording();
|
|
|
|
this.setState({
|
|
|
|
selectedElementIds: newElements.reduce((map, element) => {
|
|
|
|
map[element.id] = true;
|
|
|
|
return map;
|
|
|
|
}, {} as any),
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
2020-03-28 15:43:09 -07:00
|
|
|
private addTextFromPaste(text: any) {
|
|
|
|
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,
|
2020-05-14 17:04:33 +02:00
|
|
|
strokeStyle: this.state.currentItemStrokeStyle,
|
2020-03-28 15:43:09 -07:00
|
|
|
roughness: this.state.currentItemRoughness,
|
|
|
|
opacity: this.state.currentItemOpacity,
|
|
|
|
text: text,
|
2020-05-27 15:14:50 +02:00
|
|
|
fontSize: this.state.currentItemFontSize,
|
|
|
|
fontFamily: this.state.currentItemFontFamily,
|
2020-04-08 21:00:27 +01:00
|
|
|
textAlign: this.state.currentItemTextAlign,
|
2020-06-25 21:21:27 +02:00
|
|
|
verticalAlign: DEFAULT_VERTICAL_ALIGN,
|
2020-03-28 15:43:09 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
globalSceneState.replaceAllElements([
|
2020-04-08 09:49:52 -07:00
|
|
|
...globalSceneState.getElementsIncludingDeleted(),
|
2020-03-28 15:43:09 -07:00
|
|
|
element,
|
|
|
|
]);
|
|
|
|
this.setState({ selectedElementIds: { [element.id]: true } });
|
|
|
|
history.resumeRecording();
|
|
|
|
}
|
|
|
|
|
2020-03-22 10:24:50 -07:00
|
|
|
// Collaboration
|
|
|
|
|
|
|
|
setAppState = (obj: any) => {
|
|
|
|
this.setState(obj);
|
|
|
|
};
|
|
|
|
|
|
|
|
removePointer = (event: React.PointerEvent<HTMLElement>) => {
|
2020-07-02 22:12:56 +01:00
|
|
|
// remove touch handler for context menu on touch devices
|
|
|
|
if (event.pointerType === "touch" && touchTimeout) {
|
|
|
|
clearTimeout(touchTimeout);
|
|
|
|
touchMoving = false;
|
|
|
|
}
|
|
|
|
|
2020-03-22 10:24:50 -07:00
|
|
|
gesture.pointers.delete(event.pointerId);
|
|
|
|
};
|
|
|
|
|
2020-04-07 15:29:43 -07:00
|
|
|
openPortal = async () => {
|
2020-03-22 10:24:50 -07:00
|
|
|
window.history.pushState(
|
|
|
|
{},
|
|
|
|
"Excalidraw",
|
|
|
|
await generateCollaborationLink(),
|
2020-03-08 14:10:42 -07:00
|
|
|
);
|
2020-03-26 18:28:26 +01:00
|
|
|
this.initializeSocketClient({ showLoadingState: false });
|
2020-03-22 10:24:50 -07:00
|
|
|
};
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-04-07 15:29:43 -07:00
|
|
|
closePortal = () => {
|
2020-03-22 10:24:50 -07:00
|
|
|
window.history.pushState({}, "Excalidraw", window.location.origin);
|
|
|
|
this.destroySocketClient();
|
|
|
|
};
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-03-22 10:24:50 -07:00
|
|
|
toggleLock = () => {
|
2020-03-23 13:05:07 +02:00
|
|
|
this.setState((prevState) => ({
|
2020-03-22 10:24:50 -07:00
|
|
|
elementLocked: !prevState.elementLocked,
|
|
|
|
elementType: prevState.elementLocked
|
|
|
|
? "selection"
|
|
|
|
: prevState.elementType,
|
|
|
|
}));
|
2020-03-07 10:20:38 -05:00
|
|
|
};
|
|
|
|
|
2020-04-25 18:43:02 +05:30
|
|
|
toggleZenMode = () => {
|
|
|
|
this.setState({
|
|
|
|
zenModeEnabled: !this.state.zenModeEnabled,
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
2020-06-24 00:24:52 +09:00
|
|
|
toggleGridMode = () => {
|
|
|
|
this.setState({
|
|
|
|
gridSize: this.state.gridSize ? null : GRID_SIZE,
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
2020-03-11 19:42:18 +01:00
|
|
|
private destroySocketClient = () => {
|
|
|
|
this.setState({
|
|
|
|
isCollaborating: false,
|
2020-03-12 12:19:56 +01:00
|
|
|
collaborators: new Map(),
|
2020-03-11 19:42:18 +01:00
|
|
|
});
|
2020-04-08 10:18:56 -07:00
|
|
|
this.portal.close();
|
2020-03-11 19:42:18 +01:00
|
|
|
};
|
|
|
|
|
2020-05-27 00:21:03 +05:30
|
|
|
private initializeSocketClient = async (opts: {
|
|
|
|
showLoadingState: boolean;
|
|
|
|
}) => {
|
2020-04-08 10:18:56 -07:00
|
|
|
if (this.portal.socket) {
|
basic Socket.io implementation of collaborative editing (#879)
* Enable collaborative syncing for elements
* Don't fall back to local storage if using a room, as that is confusing
* Use remote socket server
* Send updates to new users when they join
* ~
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* Enable collaborative syncing for elements
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* prettier
* Fix bug with remote pointers not changing on scroll
* Enable collaborative syncing for elements
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* fix syncing bugs and add a button to start syncing mid session
* Fix bug with remote pointers not changing on scroll
* remove UI for collaboration
* remove link
* clean up lingering unused UI
* set random IV passed per encrypted message, reduce room id length, refactored socket broadcasting API, rename room_id to room, removed throttling of pointer movement
* fix package.json conflict
2020-03-09 08:48:25 -07:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
const roomMatch = getCollaborationLinkData(window.location.href);
|
|
|
|
if (roomMatch) {
|
2020-03-26 18:28:26 +01:00
|
|
|
const initialize = () => {
|
2020-04-08 10:18:56 -07:00
|
|
|
this.portal.socketInitialized = true;
|
2020-03-26 18:28:26 +01:00
|
|
|
clearTimeout(initializationTimer);
|
|
|
|
if (this.state.isLoading && !this.unmounted) {
|
|
|
|
this.setState({ isLoading: false });
|
|
|
|
}
|
|
|
|
};
|
|
|
|
// fallback in case you're not alone in the room but still don't receive
|
|
|
|
// initial SCENE_UPDATE message
|
2020-04-12 06:12:02 +05:30
|
|
|
const initializationTimer = setTimeout(
|
|
|
|
initialize,
|
|
|
|
INITAL_SCENE_UPDATE_TIMEOUT,
|
|
|
|
);
|
2020-03-26 18:28:26 +01:00
|
|
|
|
2020-03-29 11:35:56 +09:00
|
|
|
const updateScene = (
|
2020-04-12 06:12:02 +05:30
|
|
|
decryptedData: SocketUpdateDataSource[SCENE.INIT | SCENE.UPDATE],
|
2020-04-03 21:22:26 +02:00
|
|
|
{ scrollToContent = false }: { scrollToContent?: boolean } = {},
|
2020-03-29 11:35:56 +09:00
|
|
|
) => {
|
|
|
|
const { elements: remoteElements } = decryptedData.payload;
|
2020-04-03 14:16:14 +02:00
|
|
|
|
2020-04-03 21:22:26 +02:00
|
|
|
if (scrollToContent) {
|
|
|
|
this.setState({
|
|
|
|
...this.state,
|
|
|
|
...calculateScrollCenter(
|
2020-04-12 06:12:02 +05:30
|
|
|
remoteElements.filter((element: { isDeleted: boolean }) => {
|
2020-04-03 21:22:26 +02:00
|
|
|
return !element.isDeleted;
|
|
|
|
}),
|
2020-05-30 17:32:32 +05:30
|
|
|
this.state,
|
|
|
|
this.canvas,
|
2020-04-03 21:22:26 +02:00
|
|
|
),
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2020-03-29 11:35:56 +09:00
|
|
|
// Perform reconciliation - in collaboration, if we encounter
|
|
|
|
// elements with more staler versions than ours, ignore them
|
|
|
|
// and keep ours.
|
|
|
|
if (
|
2020-04-08 09:49:52 -07:00
|
|
|
globalSceneState.getElementsIncludingDeleted() == null ||
|
|
|
|
globalSceneState.getElementsIncludingDeleted().length === 0
|
2020-03-29 11:35:56 +09:00
|
|
|
) {
|
2020-04-03 14:16:14 +02:00
|
|
|
globalSceneState.replaceAllElements(remoteElements);
|
2020-03-29 11:35:56 +09:00
|
|
|
} else {
|
|
|
|
// create a map of ids so we don't have to iterate
|
|
|
|
// over the array more than once.
|
|
|
|
const localElementMap = getElementMap(
|
2020-04-08 09:49:52 -07:00
|
|
|
globalSceneState.getElementsIncludingDeleted(),
|
2020-03-29 11:35:56 +09:00
|
|
|
);
|
|
|
|
|
|
|
|
// Reconcile
|
2020-04-03 14:16:14 +02:00
|
|
|
const newElements = remoteElements
|
2020-03-28 21:25:40 -07:00
|
|
|
.reduce((elements, element) => {
|
|
|
|
// if the remote element references one that's currently
|
|
|
|
// edited on local, skip it (it'll be added in the next
|
|
|
|
// step)
|
|
|
|
if (
|
|
|
|
element.id === this.state.editingElement?.id ||
|
|
|
|
element.id === this.state.resizingElement?.id ||
|
|
|
|
element.id === this.state.draggingElement?.id
|
|
|
|
) {
|
|
|
|
return elements;
|
|
|
|
}
|
2020-03-29 11:35:56 +09:00
|
|
|
|
2020-03-28 21:25:40 -07:00
|
|
|
if (
|
|
|
|
localElementMap.hasOwnProperty(element.id) &&
|
|
|
|
localElementMap[element.id].version > element.version
|
|
|
|
) {
|
|
|
|
elements.push(localElementMap[element.id]);
|
|
|
|
delete localElementMap[element.id];
|
|
|
|
} else if (
|
|
|
|
localElementMap.hasOwnProperty(element.id) &&
|
|
|
|
localElementMap[element.id].version === element.version &&
|
|
|
|
localElementMap[element.id].versionNonce !==
|
|
|
|
element.versionNonce
|
|
|
|
) {
|
|
|
|
// resolve conflicting edits deterministically by taking the one with the lowest versionNonce
|
2020-03-29 11:35:56 +09:00
|
|
|
if (
|
2020-03-28 21:25:40 -07:00
|
|
|
localElementMap[element.id].versionNonce <
|
|
|
|
element.versionNonce
|
2020-03-29 11:35:56 +09:00
|
|
|
) {
|
|
|
|
elements.push(localElementMap[element.id]);
|
|
|
|
} else {
|
2020-03-28 21:25:40 -07:00
|
|
|
// it should be highly unlikely that the two versionNonces are the same. if we are
|
|
|
|
// really worried about this, we can replace the versionNonce with the socket id.
|
2020-03-29 11:35:56 +09:00
|
|
|
elements.push(element);
|
|
|
|
}
|
2020-03-28 21:25:40 -07:00
|
|
|
delete localElementMap[element.id];
|
|
|
|
} else {
|
|
|
|
elements.push(element);
|
|
|
|
delete localElementMap[element.id];
|
|
|
|
}
|
2020-03-29 11:35:56 +09:00
|
|
|
|
2020-03-28 21:25:40 -07:00
|
|
|
return elements;
|
2020-04-03 14:16:14 +02:00
|
|
|
}, [] as Mutable<typeof remoteElements>)
|
2020-03-28 21:25:40 -07:00
|
|
|
// add local elements that weren't deleted or on remote
|
|
|
|
.concat(...Object.values(localElementMap));
|
|
|
|
|
|
|
|
// Avoid broadcasting to the rest of the collaborators the scene
|
|
|
|
// we just received!
|
|
|
|
// Note: this needs to be set before replaceAllElements as it
|
|
|
|
// syncronously calls render.
|
|
|
|
this.lastBroadcastedOrReceivedSceneVersion = getDrawingVersion(
|
|
|
|
newElements,
|
2020-03-29 11:35:56 +09:00
|
|
|
);
|
2020-03-28 21:25:40 -07:00
|
|
|
|
|
|
|
globalSceneState.replaceAllElements(newElements);
|
2020-03-29 11:35:56 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
// We haven't yet implemented multiplayer undo functionality, so we clear the undo stack
|
|
|
|
// when we receive any messages from another peer. This UX can be pretty rough -- if you
|
|
|
|
// undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
|
|
|
|
// right now we think this is the right tradeoff.
|
|
|
|
history.clear();
|
2020-05-30 18:56:17 +05:30
|
|
|
if (!this.portal.socketInitialized) {
|
2020-03-29 11:35:56 +09:00
|
|
|
initialize();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2020-05-27 00:21:03 +05:30
|
|
|
const { default: socketIOClient }: any = await import(
|
|
|
|
/* webpackChunkName: "socketIoClient" */ "socket.io-client"
|
|
|
|
);
|
|
|
|
|
2020-04-08 10:18:56 -07:00
|
|
|
this.portal.open(
|
|
|
|
socketIOClient(SOCKET_SERVER),
|
|
|
|
roomMatch[1],
|
|
|
|
roomMatch[2],
|
|
|
|
);
|
2020-04-07 15:29:43 -07:00
|
|
|
|
2020-04-27 10:56:08 -07:00
|
|
|
// All socket listeners are moving to Portal
|
2020-04-08 10:18:56 -07:00
|
|
|
this.portal.socket!.on(
|
basic Socket.io implementation of collaborative editing (#879)
* Enable collaborative syncing for elements
* Don't fall back to local storage if using a room, as that is confusing
* Use remote socket server
* Send updates to new users when they join
* ~
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* Enable collaborative syncing for elements
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* prettier
* Fix bug with remote pointers not changing on scroll
* Enable collaborative syncing for elements
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* fix syncing bugs and add a button to start syncing mid session
* Fix bug with remote pointers not changing on scroll
* remove UI for collaboration
* remove link
* clean up lingering unused UI
* set random IV passed per encrypted message, reduce room id length, refactored socket broadcasting API, rename room_id to room, removed throttling of pointer movement
* fix package.json conflict
2020-03-09 08:48:25 -07:00
|
|
|
"client-broadcast",
|
|
|
|
async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
|
2020-04-08 10:18:56 -07:00
|
|
|
if (!this.portal.roomKey) {
|
basic Socket.io implementation of collaborative editing (#879)
* Enable collaborative syncing for elements
* Don't fall back to local storage if using a room, as that is confusing
* Use remote socket server
* Send updates to new users when they join
* ~
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* Enable collaborative syncing for elements
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* prettier
* Fix bug with remote pointers not changing on scroll
* Enable collaborative syncing for elements
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* fix syncing bugs and add a button to start syncing mid session
* Fix bug with remote pointers not changing on scroll
* remove UI for collaboration
* remove link
* clean up lingering unused UI
* set random IV passed per encrypted message, reduce room id length, refactored socket broadcasting API, rename room_id to room, removed throttling of pointer movement
* fix package.json conflict
2020-03-09 08:48:25 -07:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
const decryptedData = await decryptAESGEM(
|
|
|
|
encryptedData,
|
2020-04-08 10:18:56 -07:00
|
|
|
this.portal.roomKey,
|
basic Socket.io implementation of collaborative editing (#879)
* Enable collaborative syncing for elements
* Don't fall back to local storage if using a room, as that is confusing
* Use remote socket server
* Send updates to new users when they join
* ~
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* Enable collaborative syncing for elements
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* prettier
* Fix bug with remote pointers not changing on scroll
* Enable collaborative syncing for elements
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* fix syncing bugs and add a button to start syncing mid session
* Fix bug with remote pointers not changing on scroll
* remove UI for collaboration
* remove link
* clean up lingering unused UI
* set random IV passed per encrypted message, reduce room id length, refactored socket broadcasting API, rename room_id to room, removed throttling of pointer movement
* fix package.json conflict
2020-03-09 08:48:25 -07:00
|
|
|
iv,
|
|
|
|
);
|
|
|
|
|
|
|
|
switch (decryptedData.type) {
|
|
|
|
case "INVALID_RESPONSE":
|
|
|
|
return;
|
2020-04-12 06:12:02 +05:30
|
|
|
case SCENE.INIT: {
|
2020-04-08 10:18:56 -07:00
|
|
|
if (!this.portal.socketInitialized) {
|
2020-04-03 21:22:26 +02:00
|
|
|
updateScene(decryptedData, { scrollToContent: true });
|
basic Socket.io implementation of collaborative editing (#879)
* Enable collaborative syncing for elements
* Don't fall back to local storage if using a room, as that is confusing
* Use remote socket server
* Send updates to new users when they join
* ~
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* Enable collaborative syncing for elements
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* prettier
* Fix bug with remote pointers not changing on scroll
* Enable collaborative syncing for elements
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* fix syncing bugs and add a button to start syncing mid session
* Fix bug with remote pointers not changing on scroll
* remove UI for collaboration
* remove link
* clean up lingering unused UI
* set random IV passed per encrypted message, reduce room id length, refactored socket broadcasting API, rename room_id to room, removed throttling of pointer movement
* fix package.json conflict
2020-03-09 08:48:25 -07:00
|
|
|
}
|
|
|
|
break;
|
2020-03-26 18:28:26 +01:00
|
|
|
}
|
2020-04-12 06:12:02 +05:30
|
|
|
case SCENE.UPDATE:
|
2020-03-29 11:35:56 +09:00
|
|
|
updateScene(decryptedData);
|
|
|
|
break;
|
2020-03-26 18:28:26 +01:00
|
|
|
case "MOUSE_LOCATION": {
|
2020-04-04 16:02:16 +02:00
|
|
|
const {
|
|
|
|
socketID,
|
|
|
|
pointerCoords,
|
2020-04-04 16:12:19 +01:00
|
|
|
button,
|
2020-04-07 14:02:42 +01:00
|
|
|
username,
|
2020-04-04 16:02:16 +02:00
|
|
|
selectedElementIds,
|
|
|
|
} = decryptedData.payload;
|
2020-03-23 13:05:07 +02:00
|
|
|
this.setState((state) => {
|
2020-03-16 00:38:37 -07:00
|
|
|
if (!state.collaborators.has(socketID)) {
|
|
|
|
state.collaborators.set(socketID, {});
|
|
|
|
}
|
|
|
|
const user = state.collaborators.get(socketID)!;
|
2020-03-14 20:46:57 -07:00
|
|
|
user.pointer = pointerCoords;
|
2020-04-04 16:12:19 +01:00
|
|
|
user.button = button;
|
2020-04-04 16:02:16 +02:00
|
|
|
user.selectedElementIds = selectedElementIds;
|
2020-04-07 14:02:42 +01:00
|
|
|
user.username = username;
|
2020-03-14 20:46:57 -07:00
|
|
|
state.collaborators.set(socketID, user);
|
|
|
|
return state;
|
basic Socket.io implementation of collaborative editing (#879)
* Enable collaborative syncing for elements
* Don't fall back to local storage if using a room, as that is confusing
* Use remote socket server
* Send updates to new users when they join
* ~
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* Enable collaborative syncing for elements
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* prettier
* Fix bug with remote pointers not changing on scroll
* Enable collaborative syncing for elements
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* fix syncing bugs and add a button to start syncing mid session
* Fix bug with remote pointers not changing on scroll
* remove UI for collaboration
* remove link
* clean up lingering unused UI
* set random IV passed per encrypted message, reduce room id length, refactored socket broadcasting API, rename room_id to room, removed throttling of pointer movement
* fix package.json conflict
2020-03-09 08:48:25 -07:00
|
|
|
});
|
|
|
|
break;
|
2020-03-26 18:28:26 +01:00
|
|
|
}
|
basic Socket.io implementation of collaborative editing (#879)
* Enable collaborative syncing for elements
* Don't fall back to local storage if using a room, as that is confusing
* Use remote socket server
* Send updates to new users when they join
* ~
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* Enable collaborative syncing for elements
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* prettier
* Fix bug with remote pointers not changing on scroll
* Enable collaborative syncing for elements
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* fix syncing bugs and add a button to start syncing mid session
* Fix bug with remote pointers not changing on scroll
* remove UI for collaboration
* remove link
* clean up lingering unused UI
* set random IV passed per encrypted message, reduce room id length, refactored socket broadcasting API, rename room_id to room, removed throttling of pointer movement
* fix package.json conflict
2020-03-09 08:48:25 -07:00
|
|
|
}
|
|
|
|
},
|
|
|
|
);
|
2020-04-08 10:18:56 -07:00
|
|
|
this.portal.socket!.on("first-in-room", () => {
|
|
|
|
if (this.portal.socket) {
|
|
|
|
this.portal.socket.off("first-in-room");
|
basic Socket.io implementation of collaborative editing (#879)
* Enable collaborative syncing for elements
* Don't fall back to local storage if using a room, as that is confusing
* Use remote socket server
* Send updates to new users when they join
* ~
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* Enable collaborative syncing for elements
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* prettier
* Fix bug with remote pointers not changing on scroll
* Enable collaborative syncing for elements
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* fix syncing bugs and add a button to start syncing mid session
* Fix bug with remote pointers not changing on scroll
* remove UI for collaboration
* remove link
* clean up lingering unused UI
* set random IV passed per encrypted message, reduce room id length, refactored socket broadcasting API, rename room_id to room, removed throttling of pointer movement
* fix package.json conflict
2020-03-09 08:48:25 -07:00
|
|
|
}
|
2020-03-26 18:28:26 +01:00
|
|
|
initialize();
|
basic Socket.io implementation of collaborative editing (#879)
* Enable collaborative syncing for elements
* Don't fall back to local storage if using a room, as that is confusing
* Use remote socket server
* Send updates to new users when they join
* ~
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* Enable collaborative syncing for elements
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* prettier
* Fix bug with remote pointers not changing on scroll
* Enable collaborative syncing for elements
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* fix syncing bugs and add a button to start syncing mid session
* Fix bug with remote pointers not changing on scroll
* remove UI for collaboration
* remove link
* clean up lingering unused UI
* set random IV passed per encrypted message, reduce room id length, refactored socket broadcasting API, rename room_id to room, removed throttling of pointer movement
* fix package.json conflict
2020-03-09 08:48:25 -07:00
|
|
|
});
|
2020-03-26 18:28:26 +01:00
|
|
|
|
|
|
|
this.setState({
|
|
|
|
isCollaborating: true,
|
|
|
|
isLoading: opts.showLoadingState ? true : this.state.isLoading,
|
|
|
|
});
|
basic Socket.io implementation of collaborative editing (#879)
* Enable collaborative syncing for elements
* Don't fall back to local storage if using a room, as that is confusing
* Use remote socket server
* Send updates to new users when they join
* ~
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* Enable collaborative syncing for elements
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* prettier
* Fix bug with remote pointers not changing on scroll
* Enable collaborative syncing for elements
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* fix syncing bugs and add a button to start syncing mid session
* Fix bug with remote pointers not changing on scroll
* remove UI for collaboration
* remove link
* clean up lingering unused UI
* set random IV passed per encrypted message, reduce room id length, refactored socket broadcasting API, rename room_id to room, removed throttling of pointer movement
* fix package.json conflict
2020-03-09 08:48:25 -07:00
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2020-04-28 09:49:00 -07:00
|
|
|
// Portal-only
|
|
|
|
setCollaborators(sockets: string[]) {
|
|
|
|
this.setState((state) => {
|
|
|
|
const collaborators: typeof state.collaborators = new Map();
|
|
|
|
for (const socketID of sockets) {
|
|
|
|
if (state.collaborators.has(socketID)) {
|
|
|
|
collaborators.set(socketID, state.collaborators.get(socketID)!);
|
|
|
|
} else {
|
|
|
|
collaborators.set(socketID, {});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return {
|
|
|
|
...state,
|
|
|
|
collaborators,
|
|
|
|
};
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2020-03-14 21:25:07 +01:00
|
|
|
private broadcastMouseLocation = (payload: {
|
|
|
|
pointerCoords: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointerCoords"];
|
2020-04-04 16:12:19 +01:00
|
|
|
button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
|
2020-03-14 21:25:07 +01:00
|
|
|
}) => {
|
2020-04-08 10:18:56 -07:00
|
|
|
if (this.portal.socket?.id) {
|
2020-03-14 21:25:07 +01:00
|
|
|
const data: SocketUpdateDataSource["MOUSE_LOCATION"] = {
|
|
|
|
type: "MOUSE_LOCATION",
|
|
|
|
payload: {
|
2020-04-08 10:18:56 -07:00
|
|
|
socketID: this.portal.socket.id,
|
2020-03-14 21:25:07 +01:00
|
|
|
pointerCoords: payload.pointerCoords,
|
2020-04-04 16:12:19 +01:00
|
|
|
button: payload.button || "up",
|
2020-04-04 16:02:16 +02:00
|
|
|
selectedElementIds: this.state.selectedElementIds,
|
2020-04-07 14:02:42 +01:00
|
|
|
username: this.state.username,
|
2020-03-14 21:25:07 +01:00
|
|
|
},
|
|
|
|
};
|
2020-04-08 10:18:56 -07:00
|
|
|
return this.portal._broadcastSocketData(
|
2020-04-12 16:24:52 +05:30
|
|
|
data as SocketUpdateData,
|
2020-04-09 02:13:32 -07:00
|
|
|
true, // volatile
|
2020-03-14 21:25:07 +01:00
|
|
|
);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2020-04-27 10:56:08 -07:00
|
|
|
// maybe should move to Portal
|
2020-05-07 14:13:18 -07:00
|
|
|
broadcastScene = (sceneType: SCENE.INIT | SCENE.UPDATE, syncAll: boolean) => {
|
|
|
|
if (sceneType === SCENE.INIT && !syncAll) {
|
|
|
|
throw new Error("syncAll must be true when sending SCENE.INIT");
|
|
|
|
}
|
|
|
|
|
|
|
|
let syncableElements = getSyncableElements(
|
|
|
|
globalSceneState.getElementsIncludingDeleted(),
|
|
|
|
);
|
|
|
|
|
|
|
|
if (!syncAll) {
|
|
|
|
// sync out only the elements we think we need to to save bandwidth.
|
|
|
|
// periodically we'll resync the whole thing to make sure no one diverges
|
|
|
|
// due to a dropped message (server goes down etc).
|
|
|
|
syncableElements = syncableElements.filter(
|
|
|
|
(syncableElement) =>
|
|
|
|
!this.broadcastedElementVersions.has(syncableElement.id) ||
|
|
|
|
syncableElement.version >
|
|
|
|
this.broadcastedElementVersions.get(syncableElement.id)!,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2020-03-29 11:35:56 +09:00
|
|
|
const data: SocketUpdateDataSource[typeof sceneType] = {
|
|
|
|
type: sceneType,
|
2020-03-14 21:25:07 +01:00
|
|
|
payload: {
|
2020-05-07 14:13:18 -07:00
|
|
|
elements: syncableElements,
|
2020-03-14 21:25:07 +01:00
|
|
|
},
|
|
|
|
};
|
2020-03-14 20:46:57 -07:00
|
|
|
this.lastBroadcastedOrReceivedSceneVersion = Math.max(
|
|
|
|
this.lastBroadcastedOrReceivedSceneVersion,
|
2020-04-08 09:49:52 -07:00
|
|
|
getDrawingVersion(globalSceneState.getElementsIncludingDeleted()),
|
2020-03-14 20:46:57 -07:00
|
|
|
);
|
2020-05-07 14:13:18 -07:00
|
|
|
for (const syncableElement of syncableElements) {
|
|
|
|
this.broadcastedElementVersions.set(
|
|
|
|
syncableElement.id,
|
|
|
|
syncableElement.version,
|
|
|
|
);
|
|
|
|
}
|
2020-04-12 16:24:52 +05:30
|
|
|
return this.portal._broadcastSocketData(data as SocketUpdateData);
|
2020-03-14 21:25:07 +01:00
|
|
|
};
|
|
|
|
|
2020-03-16 19:07:47 -07:00
|
|
|
private onSceneUpdated = () => {
|
2020-03-15 10:06:41 -07:00
|
|
|
this.setState({});
|
|
|
|
};
|
|
|
|
|
2020-03-16 19:07:47 -07:00
|
|
|
private updateCurrentCursorPosition = withBatchedUpdates(
|
|
|
|
(event: MouseEvent) => {
|
|
|
|
cursorX = event.x;
|
|
|
|
cursorY = event.y;
|
|
|
|
},
|
|
|
|
);
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-04-27 10:56:08 -07:00
|
|
|
restoreUserName() {
|
|
|
|
const username = restoreUsernameFromLocalStorage();
|
|
|
|
|
|
|
|
if (username !== null) {
|
|
|
|
this.setState({
|
|
|
|
username,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-03-22 10:24:50 -07:00
|
|
|
// Input handling
|
|
|
|
|
2020-03-16 19:07:47 -07:00
|
|
|
private onKeyDown = withBatchedUpdates((event: KeyboardEvent) => {
|
2020-04-17 00:18:45 +02:00
|
|
|
// ensures we don't prevent devTools select-element feature
|
|
|
|
if (event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === "C") {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2020-03-07 10:20:38 -05:00
|
|
|
if (
|
|
|
|
(isWritableElement(event.target) && event.key !== KEYS.ESCAPE) ||
|
|
|
|
// case: using arrows to move between buttons
|
|
|
|
(isArrowKey(event.key) && isInputLike(event.target))
|
|
|
|
) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2020-04-05 15:58:00 +03:00
|
|
|
if (event.key === KEYS.QUESTION_MARK) {
|
|
|
|
this.setState({
|
|
|
|
showShortcutsDialog: true,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2020-04-25 18:43:02 +05:30
|
|
|
if (
|
|
|
|
!event[KEYS.CTRL_OR_CMD] &&
|
|
|
|
event.altKey &&
|
|
|
|
event.keyCode === KEYS.Z_KEY_CODE
|
|
|
|
) {
|
|
|
|
this.toggleZenMode();
|
|
|
|
}
|
|
|
|
|
2020-06-24 00:24:52 +09:00
|
|
|
if (event[KEYS.CTRL_OR_CMD] && event.keyCode === KEYS.GRID_KEY_CODE) {
|
|
|
|
this.toggleGridMode();
|
|
|
|
}
|
|
|
|
|
2020-03-14 22:53:18 +01:00
|
|
|
if (event.code === "KeyC" && event.altKey && event.shiftKey) {
|
|
|
|
this.copyToClipboardAsPng();
|
|
|
|
event.preventDefault();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2020-03-07 10:20:38 -05:00
|
|
|
if (this.actionManager.handleKeyDown(event)) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const shape = findShapeByKey(event.key);
|
|
|
|
|
|
|
|
if (isArrowKey(event.key)) {
|
2020-06-24 00:24:52 +09:00
|
|
|
const step =
|
|
|
|
(this.state.gridSize &&
|
|
|
|
(event.shiftKey ? ELEMENT_TRANSLATE_AMOUNT : this.state.gridSize)) ||
|
|
|
|
(event.shiftKey
|
|
|
|
? ELEMENT_SHIFT_TRANSLATE_AMOUNT
|
|
|
|
: ELEMENT_TRANSLATE_AMOUNT);
|
2020-03-15 10:06:41 -07:00
|
|
|
globalSceneState.replaceAllElements(
|
2020-04-08 09:49:52 -07:00
|
|
|
globalSceneState.getElementsIncludingDeleted().map((el) => {
|
2020-03-14 21:48:51 -07:00
|
|
|
if (this.state.selectedElementIds[el.id]) {
|
|
|
|
const update: { x?: number; y?: number } = {};
|
|
|
|
if (event.key === KEYS.ARROW_LEFT) {
|
|
|
|
update.x = el.x - step;
|
|
|
|
} else if (event.key === KEYS.ARROW_RIGHT) {
|
|
|
|
update.x = el.x + step;
|
|
|
|
} else if (event.key === KEYS.ARROW_UP) {
|
|
|
|
update.y = el.y - step;
|
|
|
|
} else if (event.key === KEYS.ARROW_DOWN) {
|
|
|
|
update.y = el.y + step;
|
|
|
|
}
|
|
|
|
return newElementWith(el, update);
|
2020-03-07 10:20:38 -05:00
|
|
|
}
|
2020-03-14 21:48:51 -07:00
|
|
|
return el;
|
|
|
|
}),
|
|
|
|
);
|
2020-03-07 10:20:38 -05:00
|
|
|
event.preventDefault();
|
2020-03-26 01:12:51 +09:00
|
|
|
} else if (event.key === KEYS.ENTER) {
|
|
|
|
const selectedElements = getSelectedElements(
|
2020-04-08 09:49:52 -07:00
|
|
|
globalSceneState.getElements(),
|
2020-03-26 01:12:51 +09:00
|
|
|
this.state,
|
|
|
|
);
|
|
|
|
|
|
|
|
if (
|
2020-06-01 11:35:44 +02:00
|
|
|
selectedElements.length === 1 &&
|
|
|
|
isLinearElement(selectedElements[0])
|
|
|
|
) {
|
|
|
|
if (
|
|
|
|
!this.state.editingLinearElement ||
|
|
|
|
this.state.editingLinearElement.elementId !== selectedElements[0].id
|
|
|
|
) {
|
|
|
|
history.resumeRecording();
|
|
|
|
this.setState({
|
|
|
|
editingLinearElement: new LinearElementEditor(selectedElements[0]),
|
|
|
|
});
|
|
|
|
}
|
|
|
|
} else if (
|
2020-03-26 01:12:51 +09:00
|
|
|
selectedElements.length === 1 &&
|
|
|
|
!isLinearElement(selectedElements[0])
|
|
|
|
) {
|
|
|
|
const selectedElement = selectedElements[0];
|
|
|
|
this.startTextEditing({
|
2020-06-25 21:21:27 +02:00
|
|
|
sceneX: selectedElement.x + selectedElement.width / 2,
|
|
|
|
sceneY: selectedElement.y + selectedElement.height / 2,
|
2020-03-26 01:12:51 +09:00
|
|
|
});
|
|
|
|
event.preventDefault();
|
|
|
|
return;
|
|
|
|
}
|
2020-03-07 10:20:38 -05:00
|
|
|
} else if (
|
|
|
|
!event.ctrlKey &&
|
|
|
|
!event.altKey &&
|
2020-03-22 10:24:50 -07:00
|
|
|
!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;
|
|
|
|
}
|
|
|
|
});
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-03-22 10:24:50 -07:00
|
|
|
private onKeyUp = withBatchedUpdates((event: KeyboardEvent) => {
|
|
|
|
if (event.key === KEYS.SPACE) {
|
|
|
|
if (this.state.elementType === "selection") {
|
|
|
|
resetCursor();
|
|
|
|
} else {
|
2020-04-06 22:26:54 +02:00
|
|
|
setCursorForShape(this.state.elementType);
|
2020-05-26 13:07:46 -07:00
|
|
|
this.setState({
|
|
|
|
selectedElementIds: {},
|
|
|
|
selectedGroupIds: {},
|
|
|
|
editingGroupId: null,
|
|
|
|
});
|
2020-03-07 10:20:38 -05:00
|
|
|
}
|
2020-03-22 10:24:50 -07:00
|
|
|
isHoldingSpace = false;
|
|
|
|
}
|
|
|
|
});
|
2020-03-07 10:20:38 -05:00
|
|
|
|
|
|
|
private selectShapeTool(elementType: AppState["elementType"]) {
|
|
|
|
if (!isHoldingSpace) {
|
|
|
|
setCursorForShape(elementType);
|
|
|
|
}
|
|
|
|
if (isToolIcon(document.activeElement)) {
|
|
|
|
document.activeElement.blur();
|
|
|
|
}
|
|
|
|
if (elementType !== "selection") {
|
2020-05-26 13:07:46 -07:00
|
|
|
this.setState({
|
|
|
|
elementType,
|
|
|
|
selectedElementIds: {},
|
|
|
|
selectedGroupIds: {},
|
|
|
|
editingGroupId: null,
|
|
|
|
});
|
2020-03-08 10:20:55 -07:00
|
|
|
} else {
|
|
|
|
this.setState({ elementType });
|
2020-03-07 10:20:38 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-03-16 19:07:47 -07:00
|
|
|
private onGestureStart = withBatchedUpdates((event: GestureEvent) => {
|
2020-03-07 10:20:38 -05:00
|
|
|
event.preventDefault();
|
2020-06-02 18:41:40 +02:00
|
|
|
this.setState({
|
|
|
|
selectedElementIds: {},
|
|
|
|
});
|
2020-03-07 10:20:38 -05:00
|
|
|
gesture.initialScale = this.state.zoom;
|
2020-03-16 19:07:47 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
private onGestureChange = withBatchedUpdates((event: GestureEvent) => {
|
2020-03-07 10:20:38 -05:00
|
|
|
event.preventDefault();
|
|
|
|
|
|
|
|
this.setState({
|
|
|
|
zoom: getNormalizedZoom(gesture.initialScale! * event.scale),
|
|
|
|
});
|
2020-03-16 19:07:47 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
private onGestureEnd = withBatchedUpdates((event: GestureEvent) => {
|
2020-03-07 10:20:38 -05:00
|
|
|
event.preventDefault();
|
2020-06-02 18:41:40 +02:00
|
|
|
const { previousSelectedElementIds } = this.state;
|
|
|
|
this.setState({
|
|
|
|
previousSelectedElementIds: {},
|
|
|
|
selectedElementIds: previousSelectedElementIds,
|
|
|
|
});
|
2020-03-07 10:20:38 -05:00
|
|
|
gesture.initialScale = null;
|
2020-03-16 19:07:47 -07:00
|
|
|
});
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-03-15 10:06:41 -07:00
|
|
|
private setElements = (elements: readonly ExcalidrawElement[]) => {
|
|
|
|
globalSceneState.replaceAllElements(elements);
|
|
|
|
};
|
|
|
|
|
2020-04-03 14:16:14 +02:00
|
|
|
private handleTextWysiwyg(
|
|
|
|
element: ExcalidrawTextElement,
|
|
|
|
{
|
|
|
|
isExistingElement = false,
|
2020-06-25 21:21:27 +02:00
|
|
|
}: {
|
|
|
|
isExistingElement?: boolean;
|
|
|
|
},
|
2020-04-03 14:16:14 +02:00
|
|
|
) {
|
|
|
|
const resetSelection = () => {
|
|
|
|
this.setState({
|
|
|
|
draggingElement: null,
|
|
|
|
editingElement: null,
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
const updateElement = (text: string) => {
|
|
|
|
globalSceneState.replaceAllElements([
|
2020-04-08 09:49:52 -07:00
|
|
|
...globalSceneState.getElementsIncludingDeleted().map((_element) => {
|
2020-06-25 21:21:27 +02:00
|
|
|
if (_element.id === element.id && isTextElement(_element)) {
|
|
|
|
return updateTextElement(_element, {
|
2020-04-03 14:16:14 +02:00
|
|
|
text,
|
2020-06-25 21:21:27 +02:00
|
|
|
isDeleted: !text.trim(),
|
2020-04-03 14:16:14 +02:00
|
|
|
});
|
|
|
|
}
|
|
|
|
return _element;
|
|
|
|
}),
|
|
|
|
]);
|
|
|
|
};
|
|
|
|
|
2020-04-12 15:57:57 +02:00
|
|
|
textWysiwyg({
|
|
|
|
id: element.id,
|
2020-04-03 14:16:14 +02:00
|
|
|
zoom: this.state.zoom,
|
2020-06-25 21:21:27 +02:00
|
|
|
getViewportCoords: (x, y) => {
|
|
|
|
const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
|
|
|
|
{ sceneX: x, sceneY: y },
|
|
|
|
this.state,
|
|
|
|
this.canvas,
|
|
|
|
window.devicePixelRatio,
|
|
|
|
);
|
|
|
|
return [viewportX, viewportY];
|
|
|
|
},
|
2020-04-03 14:16:14 +02:00
|
|
|
onChange: withBatchedUpdates((text) => {
|
2020-06-25 21:21:27 +02:00
|
|
|
updateElement(text);
|
2020-04-03 14:16:14 +02:00
|
|
|
}),
|
|
|
|
onSubmit: withBatchedUpdates((text) => {
|
|
|
|
updateElement(text);
|
|
|
|
this.setState((prevState) => ({
|
|
|
|
selectedElementIds: {
|
|
|
|
...prevState.selectedElementIds,
|
|
|
|
[element.id]: true,
|
|
|
|
},
|
|
|
|
}));
|
|
|
|
if (this.state.elementLocked) {
|
|
|
|
setCursorForShape(this.state.elementType);
|
|
|
|
}
|
|
|
|
history.resumeRecording();
|
|
|
|
resetSelection();
|
|
|
|
}),
|
|
|
|
onCancel: withBatchedUpdates(() => {
|
2020-06-25 21:21:27 +02:00
|
|
|
updateElement("");
|
2020-04-03 14:16:14 +02:00
|
|
|
if (isExistingElement) {
|
|
|
|
history.resumeRecording();
|
|
|
|
}
|
|
|
|
resetSelection();
|
|
|
|
}),
|
|
|
|
});
|
2020-04-11 17:10:56 +01:00
|
|
|
// deselect all other elements when inserting text
|
2020-05-26 13:07:46 -07:00
|
|
|
this.setState({
|
|
|
|
selectedElementIds: {},
|
|
|
|
selectedGroupIds: {},
|
|
|
|
editingGroupId: null,
|
|
|
|
});
|
2020-04-03 14:16:14 +02:00
|
|
|
|
|
|
|
// do an initial update to re-initialize element position since we were
|
|
|
|
// modifying element's x/y for sake of editor (case: syncing to remote)
|
|
|
|
updateElement(element.text);
|
|
|
|
}
|
|
|
|
|
2020-06-25 21:21:27 +02:00
|
|
|
private getTextElementAtPosition(
|
|
|
|
x: number,
|
|
|
|
y: number,
|
|
|
|
): NonDeleted<ExcalidrawTextElement> | null {
|
|
|
|
const element = getElementAtPosition(
|
2020-04-08 09:49:52 -07:00
|
|
|
globalSceneState.getElements(),
|
2020-03-08 18:09:45 -07:00
|
|
|
this.state,
|
|
|
|
x,
|
|
|
|
y,
|
|
|
|
this.state.zoom,
|
|
|
|
);
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-06-25 21:21:27 +02:00
|
|
|
if (element && isTextElement(element) && !element.isDeleted) {
|
|
|
|
return element;
|
|
|
|
}
|
|
|
|
return null;
|
|
|
|
}
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-06-25 21:21:27 +02:00
|
|
|
private startTextEditing = ({
|
|
|
|
sceneX,
|
|
|
|
sceneY,
|
|
|
|
insertAtParentCenter = true,
|
|
|
|
}: {
|
|
|
|
/** X position to insert text at */
|
|
|
|
sceneX: number;
|
|
|
|
/** Y position to insert text at */
|
|
|
|
sceneY: number;
|
|
|
|
/** whether to attempt to insert at element center if applicable */
|
|
|
|
insertAtParentCenter?: boolean;
|
|
|
|
}) => {
|
|
|
|
const existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-06-25 21:21:27 +02:00
|
|
|
const parentCenterPosition =
|
|
|
|
insertAtParentCenter &&
|
|
|
|
this.getTextWysiwygSnappedToCenterPosition(
|
|
|
|
sceneX,
|
|
|
|
sceneY,
|
2020-03-08 18:09:45 -07:00
|
|
|
this.state,
|
|
|
|
this.canvas,
|
2020-03-15 12:25:18 -07:00
|
|
|
window.devicePixelRatio,
|
2020-03-08 18:09:45 -07:00
|
|
|
);
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-06-25 21:21:27 +02:00
|
|
|
const element = existingTextElement
|
|
|
|
? existingTextElement
|
|
|
|
: newTextElement({
|
|
|
|
x: parentCenterPosition
|
|
|
|
? parentCenterPosition.elementCenterX
|
|
|
|
: sceneX,
|
|
|
|
y: parentCenterPosition
|
|
|
|
? parentCenterPosition.elementCenterY
|
|
|
|
: sceneY,
|
|
|
|
strokeColor: this.state.currentItemStrokeColor,
|
|
|
|
backgroundColor: this.state.currentItemBackgroundColor,
|
|
|
|
fillStyle: this.state.currentItemFillStyle,
|
|
|
|
strokeWidth: this.state.currentItemStrokeWidth,
|
|
|
|
strokeStyle: this.state.currentItemStrokeStyle,
|
|
|
|
roughness: this.state.currentItemRoughness,
|
|
|
|
opacity: this.state.currentItemOpacity,
|
|
|
|
text: "",
|
|
|
|
fontSize: this.state.currentItemFontSize,
|
|
|
|
fontFamily: this.state.currentItemFontFamily,
|
|
|
|
textAlign: parentCenterPosition
|
|
|
|
? "center"
|
|
|
|
: this.state.currentItemTextAlign,
|
|
|
|
verticalAlign: parentCenterPosition
|
|
|
|
? "middle"
|
|
|
|
: DEFAULT_VERTICAL_ALIGN,
|
|
|
|
});
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-06-25 21:21:27 +02:00
|
|
|
this.setState({ editingElement: element });
|
|
|
|
|
|
|
|
if (existingTextElement) {
|
|
|
|
// if text element is no longer centered to a container, reset
|
|
|
|
// verticalAlign to default because it's currently internal-only
|
|
|
|
if (!parentCenterPosition || element.textAlign !== "center") {
|
|
|
|
mutateElement(element, { verticalAlign: DEFAULT_VERTICAL_ALIGN });
|
|
|
|
}
|
2020-04-03 14:16:14 +02:00
|
|
|
} else {
|
|
|
|
globalSceneState.replaceAllElements([
|
2020-04-08 09:49:52 -07:00
|
|
|
...globalSceneState.getElementsIncludingDeleted(),
|
2020-04-03 14:16:14 +02:00
|
|
|
element,
|
|
|
|
]);
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-06-25 21:21:27 +02:00
|
|
|
// case: creating new text not centered to parent elemenent → offset Y
|
|
|
|
// so that the text is centered to cursor position
|
|
|
|
if (!parentCenterPosition) {
|
|
|
|
mutateElement(element, {
|
|
|
|
y: element.y - element.baseline / 2,
|
|
|
|
});
|
2020-03-08 18:09:45 -07:00
|
|
|
}
|
|
|
|
}
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-04-03 14:16:14 +02:00
|
|
|
this.setState({
|
|
|
|
editingElement: element,
|
|
|
|
});
|
2020-03-17 23:11:27 -07:00
|
|
|
|
2020-04-03 14:16:14 +02:00
|
|
|
this.handleTextWysiwyg(element, {
|
2020-06-25 21:21:27 +02:00
|
|
|
isExistingElement: !!existingTextElement,
|
2020-03-08 18:09:45 -07:00
|
|
|
});
|
|
|
|
};
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-03-26 01:12:51 +09:00
|
|
|
private handleCanvasDoubleClick = (
|
|
|
|
event: React.MouseEvent<HTMLCanvasElement>,
|
|
|
|
) => {
|
|
|
|
// case: double-clicking with arrow/line tool selected would both create
|
|
|
|
// text and enter multiElement mode
|
|
|
|
if (this.state.multiElement) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2020-06-01 11:35:44 +02:00
|
|
|
const selectedElements = getSelectedElements(
|
|
|
|
globalSceneState.getElements(),
|
|
|
|
this.state,
|
|
|
|
);
|
|
|
|
|
|
|
|
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
|
|
|
|
if (
|
|
|
|
!this.state.editingLinearElement ||
|
|
|
|
this.state.editingLinearElement.elementId !== selectedElements[0].id
|
|
|
|
) {
|
|
|
|
history.resumeRecording();
|
|
|
|
this.setState({
|
|
|
|
editingLinearElement: new LinearElementEditor(selectedElements[0]),
|
|
|
|
});
|
|
|
|
}
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
resetCursor();
|
|
|
|
|
2020-06-25 21:21:27 +02:00
|
|
|
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
2020-03-26 01:12:51 +09:00
|
|
|
event,
|
|
|
|
this.state,
|
|
|
|
this.canvas,
|
|
|
|
window.devicePixelRatio,
|
|
|
|
);
|
|
|
|
|
2020-05-26 13:07:46 -07:00
|
|
|
const selectedGroupIds = getSelectedGroupIds(this.state);
|
|
|
|
|
|
|
|
if (selectedGroupIds.length > 0) {
|
|
|
|
const elements = globalSceneState.getElements();
|
|
|
|
const hitElement = getElementAtPosition(
|
|
|
|
elements,
|
|
|
|
this.state,
|
2020-06-25 21:21:27 +02:00
|
|
|
sceneX,
|
|
|
|
sceneY,
|
2020-05-26 13:07:46 -07:00
|
|
|
this.state.zoom,
|
|
|
|
);
|
|
|
|
|
|
|
|
const selectedGroupId =
|
|
|
|
hitElement &&
|
|
|
|
getSelectedGroupIdForElement(hitElement, this.state.selectedGroupIds);
|
|
|
|
|
|
|
|
if (selectedGroupId) {
|
|
|
|
this.setState((prevState) =>
|
|
|
|
selectGroupsForSelectedElements(
|
|
|
|
{
|
|
|
|
...prevState,
|
|
|
|
editingGroupId: selectedGroupId,
|
|
|
|
selectedElementIds: { [hitElement!.id]: true },
|
|
|
|
selectedGroupIds: {},
|
|
|
|
},
|
|
|
|
globalSceneState.getElements(),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
resetCursor();
|
|
|
|
|
2020-03-26 01:12:51 +09:00
|
|
|
this.startTextEditing({
|
2020-06-25 21:21:27 +02:00
|
|
|
sceneX,
|
|
|
|
sceneY,
|
|
|
|
insertAtParentCenter: !event.altKey,
|
2020-03-26 01:12:51 +09:00
|
|
|
});
|
|
|
|
};
|
|
|
|
|
2020-03-08 18:09:45 -07:00
|
|
|
private handleCanvasPointerMove = (
|
|
|
|
event: React.PointerEvent<HTMLCanvasElement>,
|
|
|
|
) => {
|
2020-04-04 16:12:19 +01:00
|
|
|
this.savePointer(event.clientX, event.clientY, this.state.cursorButton);
|
|
|
|
|
2020-03-09 17:17:26 +01:00
|
|
|
if (gesture.pointers.has(event.pointerId)) {
|
|
|
|
gesture.pointers.set(event.pointerId, {
|
|
|
|
x: event.clientX,
|
|
|
|
y: event.clientY,
|
|
|
|
});
|
|
|
|
}
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-03-08 19:25:16 -07:00
|
|
|
if (gesture.pointers.size === 2) {
|
2020-03-08 18:09:45 -07:00
|
|
|
const center = getCenter(gesture.pointers);
|
|
|
|
const deltaX = center.x - gesture.lastCenter!.x;
|
|
|
|
const deltaY = center.y - gesture.lastCenter!.y;
|
|
|
|
gesture.lastCenter = center;
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-03-08 19:25:16 -07:00
|
|
|
const distance = getDistance(Array.from(gesture.pointers.values()));
|
2020-03-08 18:09:45 -07:00
|
|
|
const scaleFactor = distance / gesture.initialDistance!;
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-03-08 18:09:45 -07:00
|
|
|
this.setState({
|
|
|
|
scrollX: normalizeScroll(this.state.scrollX + deltaX / this.state.zoom),
|
|
|
|
scrollY: normalizeScroll(this.state.scrollY + deltaY / this.state.zoom),
|
|
|
|
zoom: getNormalizedZoom(gesture.initialScale! * scaleFactor),
|
2020-03-28 16:59:36 -07:00
|
|
|
shouldCacheIgnoreZoom: true,
|
2020-03-08 18:09:45 -07:00
|
|
|
});
|
2020-03-28 16:59:36 -07:00
|
|
|
this.resetShouldCacheIgnoreZoomDebounced();
|
2020-03-08 18:09:45 -07:00
|
|
|
} else {
|
|
|
|
gesture.lastCenter = gesture.initialDistance = gesture.initialScale = null;
|
|
|
|
}
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-03-08 18:09:45 -07:00
|
|
|
if (isHoldingSpace || isPanning || isDraggingScrollBar) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const {
|
|
|
|
isOverHorizontalScrollBar,
|
|
|
|
isOverVerticalScrollBar,
|
|
|
|
} = isOverScrollBars(currentScrollBars, event.clientX, event.clientY);
|
|
|
|
const isOverScrollBar =
|
|
|
|
isOverVerticalScrollBar || isOverHorizontalScrollBar;
|
|
|
|
if (!this.state.draggingElement && !this.state.multiElement) {
|
|
|
|
if (isOverScrollBar) {
|
|
|
|
resetCursor();
|
|
|
|
} else {
|
|
|
|
setCursorForShape(this.state.elementType);
|
|
|
|
}
|
|
|
|
}
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-06-01 11:35:44 +02:00
|
|
|
const { x: scenePointerX, y: scenePointerY } = viewportCoordsToSceneCoords(
|
2020-03-08 18:09:45 -07:00
|
|
|
event,
|
|
|
|
this.state,
|
|
|
|
this.canvas,
|
2020-03-15 12:25:18 -07:00
|
|
|
window.devicePixelRatio,
|
2020-03-08 18:09:45 -07:00
|
|
|
);
|
2020-06-01 11:35:44 +02:00
|
|
|
|
|
|
|
if (
|
|
|
|
this.state.editingLinearElement &&
|
|
|
|
this.state.editingLinearElement.draggingElementPointIndex === null
|
|
|
|
) {
|
|
|
|
const editingLinearElement = LinearElementEditor.handlePointerMove(
|
|
|
|
event,
|
|
|
|
scenePointerX,
|
|
|
|
scenePointerY,
|
|
|
|
this.state.editingLinearElement,
|
|
|
|
);
|
|
|
|
if (editingLinearElement !== this.state.editingLinearElement) {
|
|
|
|
this.setState({ editingLinearElement });
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-03-08 18:09:45 -07:00
|
|
|
if (this.state.multiElement) {
|
|
|
|
const { multiElement } = this.state;
|
2020-03-18 16:43:06 +01:00
|
|
|
const { x: rx, y: ry } = multiElement;
|
2020-03-14 21:48:51 -07:00
|
|
|
|
2020-03-18 16:43:06 +01:00
|
|
|
const { points, lastCommittedPoint } = multiElement;
|
|
|
|
const lastPoint = points[points.length - 1];
|
|
|
|
|
|
|
|
setCursorForShape(this.state.elementType);
|
|
|
|
|
|
|
|
if (lastPoint === lastCommittedPoint) {
|
|
|
|
// if we haven't yet created a temp point and we're beyond commit-zone
|
|
|
|
// threshold, add a point
|
|
|
|
if (
|
2020-06-01 11:35:44 +02:00
|
|
|
distance2d(
|
|
|
|
scenePointerX - rx,
|
|
|
|
scenePointerY - ry,
|
|
|
|
lastPoint[0],
|
|
|
|
lastPoint[1],
|
|
|
|
) >= LINE_CONFIRM_THRESHOLD
|
2020-03-18 16:43:06 +01:00
|
|
|
) {
|
|
|
|
mutateElement(multiElement, {
|
2020-06-01 11:35:44 +02:00
|
|
|
points: [...points, [scenePointerX - rx, scenePointerY - ry]],
|
2020-03-18 16:43:06 +01:00
|
|
|
});
|
|
|
|
} else {
|
|
|
|
document.documentElement.style.cursor = CURSOR_TYPE.POINTER;
|
|
|
|
// in this branch, we're inside the commit zone, and no uncommitted
|
|
|
|
// point exists. Thus do nothing (don't add/remove points).
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
// cursor moved inside commit zone, and there's uncommitted point,
|
|
|
|
// thus remove it
|
|
|
|
if (
|
|
|
|
points.length > 2 &&
|
|
|
|
lastCommittedPoint &&
|
|
|
|
distance2d(
|
2020-06-01 11:35:44 +02:00
|
|
|
scenePointerX - rx,
|
|
|
|
scenePointerY - ry,
|
2020-03-18 16:43:06 +01:00
|
|
|
lastCommittedPoint[0],
|
|
|
|
lastCommittedPoint[1],
|
2020-04-09 01:46:47 -07:00
|
|
|
) < LINE_CONFIRM_THRESHOLD
|
2020-03-18 16:43:06 +01:00
|
|
|
) {
|
|
|
|
document.documentElement.style.cursor = CURSOR_TYPE.POINTER;
|
|
|
|
mutateElement(multiElement, {
|
|
|
|
points: points.slice(0, -1),
|
|
|
|
});
|
|
|
|
} else {
|
2020-04-09 01:46:47 -07:00
|
|
|
if (isPathALoop(points)) {
|
|
|
|
document.documentElement.style.cursor = CURSOR_TYPE.POINTER;
|
|
|
|
}
|
2020-03-18 16:43:06 +01:00
|
|
|
// update last uncommitted point
|
|
|
|
mutateElement(multiElement, {
|
2020-06-01 11:35:44 +02:00
|
|
|
points: [
|
|
|
|
...points.slice(0, -1),
|
|
|
|
[scenePointerX - rx, scenePointerY - ry],
|
|
|
|
],
|
2020-03-18 16:43:06 +01:00
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
2020-03-08 18:09:45 -07:00
|
|
|
return;
|
|
|
|
}
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-03-08 18:09:45 -07:00
|
|
|
const hasDeselectedButton = Boolean(event.buttons);
|
2020-04-06 22:26:54 +02:00
|
|
|
if (
|
|
|
|
hasDeselectedButton ||
|
|
|
|
(this.state.elementType !== "selection" &&
|
|
|
|
this.state.elementType !== "text")
|
|
|
|
) {
|
2020-03-08 18:09:45 -07:00
|
|
|
return;
|
|
|
|
}
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-04-08 09:49:52 -07:00
|
|
|
const elements = globalSceneState.getElements();
|
|
|
|
|
|
|
|
const selectedElements = getSelectedElements(elements, this.state);
|
2020-06-01 11:35:44 +02:00
|
|
|
if (
|
|
|
|
selectedElements.length === 1 &&
|
|
|
|
!isOverScrollBar &&
|
|
|
|
!this.state.editingLinearElement
|
|
|
|
) {
|
2020-04-07 17:49:59 +09:00
|
|
|
const elementWithResizeHandler = getElementWithResizeHandler(
|
2020-04-08 09:49:52 -07:00
|
|
|
elements,
|
2020-03-08 18:09:45 -07:00
|
|
|
this.state,
|
2020-06-01 11:35:44 +02:00
|
|
|
scenePointerX,
|
|
|
|
scenePointerY,
|
2020-03-08 18:09:45 -07:00
|
|
|
this.state.zoom,
|
|
|
|
event.pointerType,
|
|
|
|
);
|
2020-04-07 17:49:59 +09:00
|
|
|
if (elementWithResizeHandler && elementWithResizeHandler.resizeHandle) {
|
2020-03-08 18:09:45 -07:00
|
|
|
document.documentElement.style.cursor = getCursorForResizingElement(
|
2020-04-07 17:49:59 +09:00
|
|
|
elementWithResizeHandler,
|
2020-03-08 18:09:45 -07:00
|
|
|
);
|
|
|
|
return;
|
|
|
|
}
|
2020-04-07 17:49:59 +09:00
|
|
|
} else if (selectedElements.length > 1 && !isOverScrollBar) {
|
2020-06-08 18:25:20 +09:00
|
|
|
const resizeHandle = getResizeHandlerFromCoords(
|
|
|
|
getCommonBounds(selectedElements),
|
|
|
|
scenePointerX,
|
|
|
|
scenePointerY,
|
|
|
|
this.state.zoom,
|
|
|
|
event.pointerType,
|
|
|
|
);
|
|
|
|
if (resizeHandle) {
|
|
|
|
document.documentElement.style.cursor = getCursorForResizingElement({
|
|
|
|
resizeHandle,
|
|
|
|
});
|
|
|
|
return;
|
2020-04-07 17:49:59 +09:00
|
|
|
}
|
2020-03-08 18:09:45 -07:00
|
|
|
}
|
|
|
|
const hitElement = getElementAtPosition(
|
2020-04-08 09:49:52 -07:00
|
|
|
elements,
|
2020-03-08 18:09:45 -07:00
|
|
|
this.state,
|
2020-06-01 11:35:44 +02:00
|
|
|
scenePointerX,
|
|
|
|
scenePointerY,
|
2020-03-08 18:09:45 -07:00
|
|
|
this.state.zoom,
|
|
|
|
);
|
2020-04-06 22:26:54 +02:00
|
|
|
if (this.state.elementType === "text") {
|
|
|
|
document.documentElement.style.cursor = isTextElement(hitElement)
|
|
|
|
? CURSOR_TYPE.TEXT
|
|
|
|
: CURSOR_TYPE.CROSSHAIR;
|
|
|
|
} else {
|
|
|
|
document.documentElement.style.cursor =
|
|
|
|
hitElement && !isOverScrollBar ? "move" : "";
|
|
|
|
}
|
2020-03-08 18:09:45 -07:00
|
|
|
};
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-07-02 22:12:56 +01:00
|
|
|
// set touch moving for mobile context menu
|
|
|
|
private handleTouchMove = (event: React.TouchEvent<HTMLCanvasElement>) => {
|
|
|
|
touchMoving = true;
|
|
|
|
};
|
|
|
|
|
2020-03-08 18:09:45 -07:00
|
|
|
private handleCanvasPointerDown = (
|
|
|
|
event: React.PointerEvent<HTMLCanvasElement>,
|
|
|
|
) => {
|
2020-04-14 12:33:57 +02:00
|
|
|
event.persist();
|
|
|
|
|
2020-07-02 22:12:56 +01:00
|
|
|
// deal with opening context menu on touch devices
|
|
|
|
if (event.pointerType === "touch") {
|
|
|
|
touchMoving = false;
|
|
|
|
|
|
|
|
// open the context menu with the first touch's clientX and clientY
|
|
|
|
// if the touch is not moving
|
|
|
|
touchTimeout = window.setTimeout(() => {
|
|
|
|
if (!touchMoving) {
|
|
|
|
this.openContextMenu({
|
|
|
|
clientX: event.clientX,
|
|
|
|
clientY: event.clientY,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}, TOUCH_CTX_MENU_TIMEOUT);
|
|
|
|
}
|
|
|
|
|
2020-03-08 18:09:45 -07:00
|
|
|
if (lastPointerUp !== null) {
|
|
|
|
// Unfortunately, sometimes we don't get a pointerup after a pointerdown,
|
|
|
|
// 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 pointerdown
|
|
|
|
lastPointerUp(event);
|
|
|
|
}
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-03-08 18:09:45 -07:00
|
|
|
if (isPanning) {
|
|
|
|
return;
|
|
|
|
}
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-04-04 16:12:19 +01:00
|
|
|
this.setState({
|
|
|
|
lastPointerDownWith: event.pointerType,
|
|
|
|
cursorButton: "down",
|
|
|
|
});
|
|
|
|
this.savePointer(event.clientX, event.clientY, "down");
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-03-08 18:09:45 -07:00
|
|
|
// pan canvas on wheel button drag or space+drag
|
|
|
|
if (
|
2020-03-08 19:25:16 -07:00
|
|
|
gesture.pointers.size === 0 &&
|
2020-03-08 18:09:45 -07:00
|
|
|
(event.button === POINTER_BUTTON.WHEEL ||
|
|
|
|
(event.button === POINTER_BUTTON.MAIN && isHoldingSpace))
|
|
|
|
) {
|
|
|
|
isPanning = true;
|
2020-04-13 15:10:26 +02:00
|
|
|
|
|
|
|
let nextPastePrevented = false;
|
|
|
|
const isLinux = /Linux/.test(window.navigator.platform);
|
|
|
|
|
2020-03-08 18:09:45 -07:00
|
|
|
document.documentElement.style.cursor = CURSOR_TYPE.GRABBING;
|
|
|
|
let { clientX: lastX, clientY: lastY } = event;
|
2020-03-16 19:07:47 -07:00
|
|
|
const onPointerMove = withBatchedUpdates((event: PointerEvent) => {
|
2020-03-08 18:09:45 -07:00
|
|
|
const deltaX = lastX - event.clientX;
|
|
|
|
const deltaY = lastY - event.clientY;
|
|
|
|
lastX = event.clientX;
|
|
|
|
lastY = event.clientY;
|
|
|
|
|
2020-04-13 15:10:26 +02:00
|
|
|
/*
|
|
|
|
* Prevent paste event if we move while middle clicking on Linux.
|
|
|
|
* See issue #1383.
|
|
|
|
*/
|
|
|
|
if (
|
|
|
|
isLinux &&
|
|
|
|
!nextPastePrevented &&
|
|
|
|
(Math.abs(deltaX) > 1 || Math.abs(deltaY) > 1)
|
|
|
|
) {
|
|
|
|
nextPastePrevented = true;
|
|
|
|
|
|
|
|
/* Prevent the next paste event */
|
|
|
|
const preventNextPaste = (event: ClipboardEvent) => {
|
|
|
|
document.body.removeEventListener(EVENT.PASTE, preventNextPaste);
|
|
|
|
event.stopPropagation();
|
|
|
|
};
|
|
|
|
|
|
|
|
/*
|
|
|
|
* Reenable next paste in case of disabled middle click paste for
|
|
|
|
* any reason:
|
|
|
|
* - rigth click paste
|
|
|
|
* - empty clipboard
|
|
|
|
*/
|
|
|
|
const enableNextPaste = () => {
|
|
|
|
setTimeout(() => {
|
|
|
|
document.body.removeEventListener(EVENT.PASTE, preventNextPaste);
|
|
|
|
window.removeEventListener(EVENT.POINTER_UP, enableNextPaste);
|
|
|
|
}, 100);
|
|
|
|
};
|
|
|
|
|
|
|
|
document.body.addEventListener(EVENT.PASTE, preventNextPaste);
|
|
|
|
window.addEventListener(EVENT.POINTER_UP, enableNextPaste);
|
|
|
|
}
|
|
|
|
|
2020-03-08 18:09:45 -07:00
|
|
|
this.setState({
|
|
|
|
scrollX: normalizeScroll(
|
|
|
|
this.state.scrollX - deltaX / this.state.zoom,
|
|
|
|
),
|
|
|
|
scrollY: normalizeScroll(
|
|
|
|
this.state.scrollY - deltaY / this.state.zoom,
|
|
|
|
),
|
|
|
|
});
|
|
|
|
});
|
2020-03-16 19:07:47 -07:00
|
|
|
const teardown = withBatchedUpdates(
|
|
|
|
(lastPointerUp = () => {
|
|
|
|
lastPointerUp = null;
|
|
|
|
isPanning = false;
|
|
|
|
if (!isHoldingSpace) {
|
|
|
|
setCursorForShape(this.state.elementType);
|
|
|
|
}
|
2020-04-04 16:12:19 +01:00
|
|
|
this.setState({
|
|
|
|
cursorButton: "up",
|
|
|
|
});
|
|
|
|
this.savePointer(event.clientX, event.clientY, "up");
|
2020-04-12 06:12:02 +05:30
|
|
|
window.removeEventListener(EVENT.POINTER_MOVE, onPointerMove);
|
|
|
|
window.removeEventListener(EVENT.POINTER_UP, teardown);
|
|
|
|
window.removeEventListener(EVENT.BLUR, teardown);
|
2020-03-16 19:07:47 -07:00
|
|
|
}),
|
|
|
|
);
|
2020-04-12 06:12:02 +05:30
|
|
|
window.addEventListener(EVENT.BLUR, teardown);
|
|
|
|
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove, {
|
2020-03-08 18:09:45 -07:00
|
|
|
passive: true,
|
|
|
|
});
|
2020-04-12 06:12:02 +05:30
|
|
|
window.addEventListener(EVENT.POINTER_UP, teardown);
|
2020-03-08 18:09:45 -07:00
|
|
|
return;
|
|
|
|
}
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-03-08 18:09:45 -07:00
|
|
|
// only handle left mouse button or touch
|
|
|
|
if (
|
|
|
|
event.button !== POINTER_BUTTON.MAIN &&
|
|
|
|
event.button !== POINTER_BUTTON.TOUCH
|
|
|
|
) {
|
|
|
|
return;
|
|
|
|
}
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-03-08 19:25:16 -07:00
|
|
|
gesture.pointers.set(event.pointerId, {
|
2020-03-08 18:09:45 -07:00
|
|
|
x: event.clientX,
|
|
|
|
y: event.clientY,
|
|
|
|
});
|
2020-03-08 19:25:16 -07:00
|
|
|
|
|
|
|
if (gesture.pointers.size === 2) {
|
2020-03-08 18:09:45 -07:00
|
|
|
gesture.lastCenter = getCenter(gesture.pointers);
|
|
|
|
gesture.initialScale = this.state.zoom;
|
2020-03-08 19:25:16 -07:00
|
|
|
gesture.initialDistance = getDistance(
|
|
|
|
Array.from(gesture.pointers.values()),
|
|
|
|
);
|
2020-03-08 18:09:45 -07:00
|
|
|
}
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-03-08 18:09:45 -07:00
|
|
|
// fixes pointermove causing selection of UI texts #32
|
|
|
|
event.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();
|
|
|
|
}
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-03-08 18:09:45 -07:00
|
|
|
// don't select while panning
|
2020-03-08 19:25:16 -07:00
|
|
|
if (gesture.pointers.size > 1) {
|
2020-03-08 18:09:45 -07:00
|
|
|
return;
|
|
|
|
}
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-03-08 18:09:45 -07:00
|
|
|
// Handle scrollbars dragging
|
|
|
|
const {
|
|
|
|
isOverHorizontalScrollBar,
|
|
|
|
isOverVerticalScrollBar,
|
|
|
|
} = isOverScrollBars(currentScrollBars, event.clientX, event.clientY);
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-03-08 18:09:45 -07:00
|
|
|
const { x, y } = viewportCoordsToSceneCoords(
|
|
|
|
event,
|
|
|
|
this.state,
|
|
|
|
this.canvas,
|
2020-03-15 12:25:18 -07:00
|
|
|
window.devicePixelRatio,
|
2020-03-08 18:09:45 -07:00
|
|
|
);
|
|
|
|
let lastX = x;
|
|
|
|
let lastY = y;
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-03-08 18:09:45 -07:00
|
|
|
if (
|
|
|
|
(isOverHorizontalScrollBar || isOverVerticalScrollBar) &&
|
|
|
|
!this.state.multiElement
|
|
|
|
) {
|
|
|
|
isDraggingScrollBar = true;
|
|
|
|
lastX = event.clientX;
|
|
|
|
lastY = event.clientY;
|
2020-03-16 19:07:47 -07:00
|
|
|
const onPointerMove = withBatchedUpdates((event: PointerEvent) => {
|
2020-03-08 18:09:45 -07:00
|
|
|
const target = event.target;
|
|
|
|
if (!(target instanceof HTMLElement)) {
|
|
|
|
return;
|
|
|
|
}
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-03-08 18:09:45 -07:00
|
|
|
if (isOverHorizontalScrollBar) {
|
|
|
|
const x = event.clientX;
|
|
|
|
const dx = x - lastX;
|
|
|
|
this.setState({
|
|
|
|
scrollX: normalizeScroll(this.state.scrollX - dx / this.state.zoom),
|
|
|
|
});
|
|
|
|
lastX = x;
|
|
|
|
return;
|
|
|
|
}
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-03-08 18:09:45 -07:00
|
|
|
if (isOverVerticalScrollBar) {
|
|
|
|
const y = event.clientY;
|
|
|
|
const dy = y - lastY;
|
|
|
|
this.setState({
|
|
|
|
scrollY: normalizeScroll(this.state.scrollY - dy / this.state.zoom),
|
|
|
|
});
|
|
|
|
lastY = y;
|
|
|
|
}
|
2020-03-16 19:07:47 -07:00
|
|
|
});
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-03-16 19:07:47 -07:00
|
|
|
const onPointerUp = withBatchedUpdates(() => {
|
2020-03-08 18:09:45 -07:00
|
|
|
isDraggingScrollBar = false;
|
|
|
|
setCursorForShape(this.state.elementType);
|
|
|
|
lastPointerUp = null;
|
2020-04-04 16:12:19 +01:00
|
|
|
this.setState({
|
|
|
|
cursorButton: "up",
|
|
|
|
});
|
|
|
|
this.savePointer(event.clientX, event.clientY, "up");
|
2020-04-12 06:12:02 +05:30
|
|
|
window.removeEventListener(EVENT.POINTER_MOVE, onPointerMove);
|
|
|
|
window.removeEventListener(EVENT.POINTER_UP, onPointerUp);
|
2020-03-16 19:07:47 -07:00
|
|
|
});
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-03-08 18:09:45 -07:00
|
|
|
lastPointerUp = onPointerUp;
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-04-12 06:12:02 +05:30
|
|
|
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
|
|
|
|
window.addEventListener(EVENT.POINTER_UP, onPointerUp);
|
2020-03-08 18:09:45 -07:00
|
|
|
return;
|
|
|
|
}
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-03-08 18:09:45 -07:00
|
|
|
const originX = x;
|
|
|
|
const originY = y;
|
2020-06-24 00:24:52 +09:00
|
|
|
const [originGridX, originGridY] = getGridPoint(
|
|
|
|
originX,
|
|
|
|
originY,
|
|
|
|
this.state.gridSize,
|
|
|
|
);
|
2020-03-08 18:09:45 -07:00
|
|
|
|
|
|
|
type ResizeTestType = ReturnType<typeof resizeTest>;
|
|
|
|
let resizeHandle: ResizeTestType = false;
|
2020-04-07 17:49:59 +09:00
|
|
|
const setResizeHandle = (nextResizeHandle: ResizeTestType) => {
|
|
|
|
resizeHandle = nextResizeHandle;
|
|
|
|
};
|
2020-05-09 17:57:00 +09:00
|
|
|
let resizeOffsetXY: [number, number] = [0, 0];
|
2020-05-11 00:41:36 +09:00
|
|
|
let resizeArrowDirection: "origin" | "end" = "origin";
|
2020-03-08 18:09:45 -07:00
|
|
|
let isResizingElements = false;
|
|
|
|
let draggingOccurred = false;
|
2020-06-24 20:38:42 +09:00
|
|
|
let dragOffsetXY: [number, number] | null = null;
|
2020-03-08 18:09:45 -07:00
|
|
|
let hitElement: ExcalidrawElement | null = null;
|
2020-03-17 15:21:35 -07:00
|
|
|
let hitElementWasAddedToSelection = false;
|
2020-04-22 16:57:17 +01:00
|
|
|
|
2020-03-08 18:09:45 -07:00
|
|
|
if (this.state.elementType === "selection") {
|
2020-04-08 09:49:52 -07:00
|
|
|
const elements = globalSceneState.getElements();
|
|
|
|
const selectedElements = getSelectedElements(elements, this.state);
|
2020-06-01 11:35:44 +02:00
|
|
|
if (selectedElements.length === 1 && !this.state.editingLinearElement) {
|
2020-04-07 17:49:59 +09:00
|
|
|
const elementWithResizeHandler = getElementWithResizeHandler(
|
2020-04-08 09:49:52 -07:00
|
|
|
elements,
|
2020-04-07 17:49:59 +09:00
|
|
|
this.state,
|
2020-06-01 11:35:44 +02:00
|
|
|
x,
|
|
|
|
y,
|
2020-04-07 17:49:59 +09:00
|
|
|
this.state.zoom,
|
|
|
|
event.pointerType,
|
2020-03-08 18:09:45 -07:00
|
|
|
);
|
2020-04-07 17:49:59 +09:00
|
|
|
if (elementWithResizeHandler) {
|
|
|
|
this.setState({
|
|
|
|
resizingElement: elementWithResizeHandler
|
|
|
|
? elementWithResizeHandler.element
|
|
|
|
: null,
|
|
|
|
});
|
|
|
|
resizeHandle = elementWithResizeHandler.resizeHandle;
|
|
|
|
document.documentElement.style.cursor = getCursorForResizingElement(
|
|
|
|
elementWithResizeHandler,
|
|
|
|
);
|
|
|
|
isResizingElements = true;
|
|
|
|
}
|
|
|
|
} else if (selectedElements.length > 1) {
|
2020-06-08 18:25:20 +09:00
|
|
|
resizeHandle = getResizeHandlerFromCoords(
|
|
|
|
getCommonBounds(selectedElements),
|
|
|
|
x,
|
|
|
|
y,
|
|
|
|
this.state.zoom,
|
|
|
|
event.pointerType,
|
|
|
|
);
|
|
|
|
if (resizeHandle) {
|
|
|
|
document.documentElement.style.cursor = getCursorForResizingElement({
|
|
|
|
resizeHandle,
|
|
|
|
});
|
|
|
|
isResizingElements = true;
|
2020-04-07 17:49:59 +09:00
|
|
|
}
|
|
|
|
}
|
2020-05-09 17:57:00 +09:00
|
|
|
if (isResizingElements) {
|
|
|
|
resizeOffsetXY = getResizeOffsetXY(
|
|
|
|
resizeHandle,
|
|
|
|
selectedElements,
|
|
|
|
x,
|
|
|
|
y,
|
|
|
|
);
|
2020-05-11 00:41:36 +09:00
|
|
|
if (
|
|
|
|
selectedElements.length === 1 &&
|
|
|
|
isLinearElement(selectedElements[0]) &&
|
|
|
|
selectedElements[0].points.length === 2
|
|
|
|
) {
|
|
|
|
resizeArrowDirection = getResizeArrowDirection(
|
|
|
|
resizeHandle,
|
|
|
|
selectedElements[0],
|
|
|
|
);
|
|
|
|
}
|
2020-05-09 17:57:00 +09:00
|
|
|
}
|
2020-04-07 17:49:59 +09:00
|
|
|
if (!isResizingElements) {
|
2020-06-01 11:35:44 +02:00
|
|
|
if (this.state.editingLinearElement) {
|
|
|
|
const ret = LinearElementEditor.handlePointerDown(
|
|
|
|
event,
|
|
|
|
this.state,
|
|
|
|
(appState) => this.setState(appState),
|
|
|
|
history,
|
|
|
|
x,
|
|
|
|
y,
|
|
|
|
);
|
|
|
|
if (ret.hitElement) {
|
|
|
|
hitElement = ret.hitElement;
|
|
|
|
}
|
|
|
|
if (ret.didAddPoint) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// hitElement may already be set above, so check first
|
|
|
|
hitElement =
|
|
|
|
hitElement ||
|
|
|
|
getElementAtPosition(elements, this.state, x, y, this.state.zoom);
|
|
|
|
|
2020-03-08 18:09:45 -07:00
|
|
|
// clear selection if shift is not clicked
|
|
|
|
if (
|
|
|
|
!(hitElement && this.state.selectedElementIds[hitElement.id]) &&
|
|
|
|
!event.shiftKey
|
|
|
|
) {
|
2020-05-26 13:07:46 -07:00
|
|
|
this.setState((prevState) => ({
|
|
|
|
selectedElementIds: {},
|
|
|
|
selectedGroupIds: {},
|
|
|
|
editingGroupId:
|
|
|
|
prevState.editingGroupId &&
|
|
|
|
hitElement &&
|
|
|
|
isElementInGroup(hitElement, prevState.editingGroupId)
|
|
|
|
? prevState.editingGroupId
|
|
|
|
: null,
|
|
|
|
}));
|
2020-06-02 18:41:40 +02:00
|
|
|
const { selectedElementIds } = this.state;
|
|
|
|
this.setState({
|
|
|
|
selectedElementIds: {},
|
|
|
|
previousSelectedElementIds: selectedElementIds,
|
|
|
|
});
|
2020-03-08 18:09:45 -07:00
|
|
|
}
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-03-08 18:09:45 -07:00
|
|
|
// 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 (!this.state.selectedElementIds[hitElement.id]) {
|
2020-05-26 13:07:46 -07:00
|
|
|
// if we are currently editing a group, treat all selections outside of the group
|
|
|
|
// as exiting editing mode.
|
|
|
|
if (
|
|
|
|
this.state.editingGroupId &&
|
|
|
|
!isElementInGroup(hitElement, this.state.editingGroupId)
|
|
|
|
) {
|
|
|
|
this.setState({
|
|
|
|
selectedElementIds: {},
|
|
|
|
selectedGroupIds: {},
|
|
|
|
editingGroupId: null,
|
|
|
|
});
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
this.setState((prevState) => {
|
|
|
|
return selectGroupsForSelectedElements(
|
|
|
|
{
|
|
|
|
...prevState,
|
|
|
|
selectedElementIds: {
|
|
|
|
...prevState.selectedElementIds,
|
|
|
|
[hitElement!.id]: true,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
globalSceneState.getElements(),
|
|
|
|
);
|
|
|
|
});
|
|
|
|
// TODO: this is strange...
|
2020-03-15 10:06:41 -07:00
|
|
|
globalSceneState.replaceAllElements(
|
2020-04-08 09:49:52 -07:00
|
|
|
globalSceneState.getElementsIncludingDeleted(),
|
2020-03-15 10:06:41 -07:00
|
|
|
);
|
2020-03-17 15:21:35 -07:00
|
|
|
hitElementWasAddedToSelection = true;
|
2020-03-08 18:09:45 -07:00
|
|
|
}
|
|
|
|
}
|
2020-06-02 18:41:40 +02:00
|
|
|
|
|
|
|
const { selectedElementIds } = this.state;
|
|
|
|
this.setState({
|
|
|
|
previousSelectedElementIds: selectedElementIds,
|
|
|
|
});
|
2020-03-08 18:09:45 -07:00
|
|
|
}
|
|
|
|
} else {
|
2020-05-26 13:07:46 -07:00
|
|
|
this.setState({
|
|
|
|
selectedElementIds: {},
|
|
|
|
selectedGroupIds: {},
|
|
|
|
editingGroupId: null,
|
|
|
|
});
|
2020-03-08 18:09:45 -07:00
|
|
|
}
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-03-17 20:55:40 +01:00
|
|
|
if (this.state.elementType === "text") {
|
2020-03-08 18:09:45 -07:00
|
|
|
// if we're currently still editing text, clicking outside
|
|
|
|
// should only finalize it, not create another (irrespective
|
|
|
|
// of state.elementLocked)
|
|
|
|
if (this.state.editingElement?.type === "text") {
|
|
|
|
return;
|
|
|
|
}
|
2020-03-17 20:55:40 +01:00
|
|
|
|
2020-04-06 22:26:54 +02:00
|
|
|
this.startTextEditing({
|
2020-06-25 21:21:27 +02:00
|
|
|
sceneX: x,
|
|
|
|
sceneY: y,
|
|
|
|
insertAtParentCenter: !event.altKey,
|
2020-03-17 20:55:40 +01:00
|
|
|
});
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-03-08 18:09:45 -07:00
|
|
|
resetCursor();
|
|
|
|
if (!this.state.elementLocked) {
|
|
|
|
this.setState({
|
|
|
|
elementType: "selection",
|
|
|
|
});
|
|
|
|
}
|
|
|
|
return;
|
|
|
|
} else if (
|
|
|
|
this.state.elementType === "arrow" ||
|
2020-05-12 20:10:11 +01:00
|
|
|
this.state.elementType === "draw" ||
|
2020-03-08 18:09:45 -07:00
|
|
|
this.state.elementType === "line"
|
|
|
|
) {
|
|
|
|
if (this.state.multiElement) {
|
|
|
|
const { multiElement } = this.state;
|
2020-03-18 16:43:06 +01:00
|
|
|
|
2020-04-09 01:46:47 -07:00
|
|
|
// finalize if completing a loop
|
|
|
|
if (multiElement.type === "line" && isPathALoop(multiElement.points)) {
|
|
|
|
mutateElement(multiElement, {
|
|
|
|
lastCommittedPoint:
|
|
|
|
multiElement.points[multiElement.points.length - 1],
|
|
|
|
});
|
|
|
|
this.actionManager.executeAction(actionFinalize);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2020-03-18 16:43:06 +01:00
|
|
|
const { x: rx, y: ry, lastCommittedPoint } = multiElement;
|
|
|
|
|
|
|
|
// clicking inside commit zone → finalize arrow
|
|
|
|
if (
|
|
|
|
multiElement.points.length > 1 &&
|
|
|
|
lastCommittedPoint &&
|
|
|
|
distance2d(
|
|
|
|
x - rx,
|
|
|
|
y - ry,
|
|
|
|
lastCommittedPoint[0],
|
|
|
|
lastCommittedPoint[1],
|
2020-04-09 01:46:47 -07:00
|
|
|
) < LINE_CONFIRM_THRESHOLD
|
2020-03-18 16:43:06 +01:00
|
|
|
) {
|
|
|
|
this.actionManager.executeAction(actionFinalize);
|
|
|
|
return;
|
|
|
|
}
|
2020-04-09 01:46:47 -07:00
|
|
|
|
2020-03-23 13:05:07 +02:00
|
|
|
this.setState((prevState) => ({
|
2020-03-08 18:09:45 -07:00
|
|
|
selectedElementIds: {
|
|
|
|
...prevState.selectedElementIds,
|
|
|
|
[multiElement.id]: true,
|
|
|
|
},
|
|
|
|
}));
|
2020-03-18 16:43:06 +01:00
|
|
|
// clicking outside commit zone → update reference for last committed
|
|
|
|
// point
|
2020-03-14 21:48:51 -07:00
|
|
|
mutateElement(multiElement, {
|
2020-03-18 16:43:06 +01:00
|
|
|
lastCommittedPoint:
|
|
|
|
multiElement.points[multiElement.points.length - 1],
|
2020-03-14 21:48:51 -07:00
|
|
|
});
|
2020-03-18 16:43:06 +01:00
|
|
|
document.documentElement.style.cursor = CURSOR_TYPE.POINTER;
|
2020-03-08 18:09:45 -07:00
|
|
|
} else {
|
2020-06-24 00:24:52 +09:00
|
|
|
const [gridX, gridY] = getGridPoint(
|
|
|
|
x,
|
|
|
|
y,
|
|
|
|
this.state.elementType === "draw" ? null : this.state.gridSize,
|
|
|
|
);
|
2020-03-17 20:55:40 +01:00
|
|
|
const element = newLinearElement({
|
|
|
|
type: this.state.elementType,
|
2020-06-24 00:24:52 +09:00
|
|
|
x: gridX,
|
|
|
|
y: gridY,
|
2020-03-17 20:55:40 +01:00
|
|
|
strokeColor: this.state.currentItemStrokeColor,
|
|
|
|
backgroundColor: this.state.currentItemBackgroundColor,
|
|
|
|
fillStyle: this.state.currentItemFillStyle,
|
|
|
|
strokeWidth: this.state.currentItemStrokeWidth,
|
2020-05-14 17:04:33 +02:00
|
|
|
strokeStyle: this.state.currentItemStrokeStyle,
|
2020-03-17 20:55:40 +01:00
|
|
|
roughness: this.state.currentItemRoughness,
|
|
|
|
opacity: this.state.currentItemOpacity,
|
|
|
|
});
|
2020-03-23 13:05:07 +02:00
|
|
|
this.setState((prevState) => ({
|
2020-03-08 18:09:45 -07:00
|
|
|
selectedElementIds: {
|
|
|
|
...prevState.selectedElementIds,
|
|
|
|
[element.id]: false,
|
|
|
|
},
|
|
|
|
}));
|
2020-03-14 21:48:51 -07:00
|
|
|
mutateElement(element, {
|
|
|
|
points: [...element.points, [0, 0]],
|
|
|
|
});
|
2020-03-15 10:06:41 -07:00
|
|
|
globalSceneState.replaceAllElements([
|
2020-04-08 09:49:52 -07:00
|
|
|
...globalSceneState.getElementsIncludingDeleted(),
|
2020-03-15 10:06:41 -07:00
|
|
|
element,
|
|
|
|
]);
|
2020-03-08 18:09:45 -07:00
|
|
|
this.setState({
|
|
|
|
draggingElement: element,
|
2020-03-12 21:28:58 +01:00
|
|
|
editingElement: element,
|
2020-03-08 18:09:45 -07:00
|
|
|
});
|
|
|
|
}
|
|
|
|
} else {
|
2020-06-24 00:24:52 +09:00
|
|
|
const [gridX, gridY] = getGridPoint(x, y, this.state.gridSize);
|
2020-03-17 20:55:40 +01:00
|
|
|
const element = newElement({
|
|
|
|
type: this.state.elementType,
|
2020-06-24 00:24:52 +09:00
|
|
|
x: gridX,
|
|
|
|
y: gridY,
|
2020-03-17 20:55:40 +01:00
|
|
|
strokeColor: this.state.currentItemStrokeColor,
|
|
|
|
backgroundColor: this.state.currentItemBackgroundColor,
|
|
|
|
fillStyle: this.state.currentItemFillStyle,
|
|
|
|
strokeWidth: this.state.currentItemStrokeWidth,
|
2020-05-14 17:04:33 +02:00
|
|
|
strokeStyle: this.state.currentItemStrokeStyle,
|
2020-03-17 20:55:40 +01:00
|
|
|
roughness: this.state.currentItemRoughness,
|
|
|
|
opacity: this.state.currentItemOpacity,
|
2020-03-12 21:28:58 +01:00
|
|
|
});
|
2020-03-17 20:55:40 +01:00
|
|
|
|
|
|
|
if (element.type === "selection") {
|
|
|
|
this.setState({
|
|
|
|
selectionElement: element,
|
2020-03-20 16:46:06 +01:00
|
|
|
draggingElement: element,
|
2020-03-17 20:55:40 +01:00
|
|
|
});
|
|
|
|
} else {
|
|
|
|
globalSceneState.replaceAllElements([
|
2020-04-08 09:49:52 -07:00
|
|
|
...globalSceneState.getElementsIncludingDeleted(),
|
2020-03-17 20:55:40 +01:00
|
|
|
element,
|
|
|
|
]);
|
|
|
|
this.setState({
|
|
|
|
multiElement: null,
|
|
|
|
draggingElement: element,
|
|
|
|
editingElement: element,
|
|
|
|
});
|
|
|
|
}
|
2020-03-08 18:09:45 -07:00
|
|
|
}
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-04-11 13:37:43 +02:00
|
|
|
let selectedElementWasDuplicated = false;
|
|
|
|
|
2020-03-16 19:07:47 -07:00
|
|
|
const onPointerMove = withBatchedUpdates((event: PointerEvent) => {
|
2020-06-24 20:38:42 +09:00
|
|
|
// We need to initialize dragOffsetXY only after we've updated
|
|
|
|
// `state.selectedElementIds` on pointerDown. Doing it here in pointerMove
|
|
|
|
// event handler should hopefully ensure we're already working with
|
|
|
|
// the updated state.
|
|
|
|
if (dragOffsetXY === null) {
|
|
|
|
dragOffsetXY = getDragOffsetXY(
|
|
|
|
getSelectedElements(globalSceneState.getElements(), this.state),
|
|
|
|
originX,
|
|
|
|
originY,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2020-03-08 18:09:45 -07:00
|
|
|
const target = event.target;
|
|
|
|
if (!(target instanceof HTMLElement)) {
|
|
|
|
return;
|
|
|
|
}
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-03-08 18:09:45 -07:00
|
|
|
if (isOverHorizontalScrollBar) {
|
|
|
|
const x = event.clientX;
|
|
|
|
const dx = x - lastX;
|
|
|
|
this.setState({
|
|
|
|
scrollX: normalizeScroll(this.state.scrollX - dx / this.state.zoom),
|
|
|
|
});
|
|
|
|
lastX = x;
|
|
|
|
return;
|
|
|
|
}
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-03-08 18:09:45 -07:00
|
|
|
if (isOverVerticalScrollBar) {
|
|
|
|
const y = event.clientY;
|
|
|
|
const dy = y - lastY;
|
|
|
|
this.setState({
|
|
|
|
scrollY: normalizeScroll(this.state.scrollY - dy / this.state.zoom),
|
|
|
|
});
|
|
|
|
lastY = y;
|
|
|
|
return;
|
|
|
|
}
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-04-07 17:49:59 +09:00
|
|
|
const { x, y } = viewportCoordsToSceneCoords(
|
|
|
|
event,
|
|
|
|
this.state,
|
|
|
|
this.canvas,
|
|
|
|
window.devicePixelRatio,
|
|
|
|
);
|
2020-06-24 00:24:52 +09:00
|
|
|
const [gridX, gridY] = getGridPoint(x, y, this.state.gridSize);
|
2020-04-07 17:49:59 +09:00
|
|
|
|
2020-05-12 20:10:11 +01:00
|
|
|
// for arrows/lines, don't start dragging until a given threshold
|
2020-03-08 18:09:45 -07:00
|
|
|
// to ensure we don't create a 2-point arrow by mistake when
|
|
|
|
// user clicks mouse in a way that it moves a tiny bit (thus
|
|
|
|
// triggering pointermove)
|
|
|
|
if (
|
|
|
|
!draggingOccurred &&
|
|
|
|
(this.state.elementType === "arrow" ||
|
|
|
|
this.state.elementType === "line")
|
|
|
|
) {
|
|
|
|
if (distance2d(x, y, originX, originY) < DRAGGING_THRESHOLD) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-05-09 17:57:00 +09:00
|
|
|
if (isResizingElements) {
|
|
|
|
const selectedElements = getSelectedElements(
|
|
|
|
globalSceneState.getElements(),
|
|
|
|
this.state,
|
|
|
|
);
|
|
|
|
this.setState({
|
|
|
|
isResizing: resizeHandle && resizeHandle !== "rotation",
|
|
|
|
isRotating: resizeHandle === "rotation",
|
|
|
|
});
|
2020-06-24 00:24:52 +09:00
|
|
|
const [resizeX, resizeY] = getGridPoint(
|
|
|
|
x - resizeOffsetXY[0],
|
|
|
|
y - resizeOffsetXY[1],
|
|
|
|
this.state.gridSize,
|
|
|
|
);
|
2020-05-11 00:41:36 +09:00
|
|
|
if (
|
|
|
|
resizeElements(
|
|
|
|
resizeHandle,
|
|
|
|
setResizeHandle,
|
|
|
|
selectedElements,
|
|
|
|
resizeArrowDirection,
|
2020-06-24 00:24:52 +09:00
|
|
|
getRotateWithDiscreteAngleKey(event),
|
|
|
|
getResizeWithSidesSameLengthKey(event),
|
|
|
|
getResizeCenterPointKey(event),
|
|
|
|
resizeX,
|
|
|
|
resizeY,
|
2020-05-11 00:41:36 +09:00
|
|
|
)
|
|
|
|
) {
|
2020-05-09 17:57:00 +09:00
|
|
|
return;
|
|
|
|
}
|
2020-03-08 18:09:45 -07:00
|
|
|
}
|
|
|
|
|
2020-06-01 11:35:44 +02:00
|
|
|
if (this.state.editingLinearElement) {
|
|
|
|
const didDrag = LinearElementEditor.handlePointDragging(
|
|
|
|
this.state,
|
|
|
|
(appState) => this.setState(appState),
|
|
|
|
x,
|
|
|
|
y,
|
|
|
|
lastX,
|
|
|
|
lastY,
|
|
|
|
);
|
|
|
|
|
|
|
|
if (didDrag) {
|
|
|
|
lastX = x;
|
|
|
|
lastY = y;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-03-08 18:09:45 -07:00
|
|
|
if (hitElement && this.state.selectedElementIds[hitElement.id]) {
|
|
|
|
// Marking that click was used for dragging to check
|
|
|
|
// if elements should be deselected on pointerup
|
|
|
|
draggingOccurred = true;
|
2020-03-14 21:48:51 -07:00
|
|
|
const selectedElements = getSelectedElements(
|
2020-04-08 09:49:52 -07:00
|
|
|
globalSceneState.getElements(),
|
2020-03-14 21:48:51 -07:00
|
|
|
this.state,
|
|
|
|
);
|
2020-03-08 18:09:45 -07:00
|
|
|
if (selectedElements.length > 0) {
|
2020-06-24 00:24:52 +09:00
|
|
|
const [dragX, dragY] = getGridPoint(
|
|
|
|
x - dragOffsetXY[0],
|
|
|
|
y - dragOffsetXY[1],
|
|
|
|
this.state.gridSize,
|
2020-03-08 18:09:45 -07:00
|
|
|
);
|
2020-06-24 00:24:52 +09:00
|
|
|
dragSelectedElements(selectedElements, dragX, dragY);
|
2020-04-11 13:37:43 +02:00
|
|
|
|
|
|
|
// We duplicate the selected element if alt is pressed on pointer move
|
|
|
|
if (event.altKey && !selectedElementWasDuplicated) {
|
|
|
|
// Move the currently selected elements to the top of the z index stack, and
|
|
|
|
// put the duplicates where the selected elements used to be.
|
|
|
|
// (the origin point where the dragging started)
|
|
|
|
|
|
|
|
selectedElementWasDuplicated = true;
|
|
|
|
|
|
|
|
const nextElements = [];
|
|
|
|
const elementsToAppend = [];
|
2020-05-26 13:07:46 -07:00
|
|
|
const groupIdMap = new Map();
|
2020-04-11 13:37:43 +02:00
|
|
|
for (const element of globalSceneState.getElementsIncludingDeleted()) {
|
|
|
|
if (
|
|
|
|
this.state.selectedElementIds[element.id] ||
|
|
|
|
// case: the state.selectedElementIds might not have been
|
|
|
|
// updated yet by the time this mousemove event is fired
|
|
|
|
(element.id === hitElement.id && hitElementWasAddedToSelection)
|
|
|
|
) {
|
2020-05-26 13:07:46 -07:00
|
|
|
const duplicatedElement = duplicateElement(
|
|
|
|
this.state.editingGroupId,
|
|
|
|
groupIdMap,
|
|
|
|
element,
|
|
|
|
);
|
2020-06-24 00:24:52 +09:00
|
|
|
const [originDragX, originDragY] = getGridPoint(
|
|
|
|
originX - dragOffsetXY[0],
|
|
|
|
originY - dragOffsetXY[1],
|
|
|
|
this.state.gridSize,
|
|
|
|
);
|
2020-04-11 13:37:43 +02:00
|
|
|
mutateElement(duplicatedElement, {
|
2020-06-24 00:24:52 +09:00
|
|
|
x: duplicatedElement.x + (originDragX - dragX),
|
|
|
|
y: duplicatedElement.y + (originDragY - dragY),
|
2020-04-11 13:37:43 +02:00
|
|
|
});
|
|
|
|
nextElements.push(duplicatedElement);
|
|
|
|
elementsToAppend.push(element);
|
|
|
|
} else {
|
|
|
|
nextElements.push(element);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
globalSceneState.replaceAllElements([
|
|
|
|
...nextElements,
|
|
|
|
...elementsToAppend,
|
|
|
|
]);
|
|
|
|
}
|
2020-03-08 18:09:45 -07:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2020-03-17 20:55:40 +01:00
|
|
|
if (isLinearElement(draggingElement)) {
|
2020-03-08 18:09:45 -07:00
|
|
|
draggingOccurred = true;
|
|
|
|
const points = draggingElement.points;
|
2020-06-24 00:24:52 +09:00
|
|
|
let dx: number;
|
|
|
|
let dy: number;
|
|
|
|
if (draggingElement.type === "draw") {
|
|
|
|
dx = x - draggingElement.x;
|
|
|
|
dy = y - draggingElement.y;
|
|
|
|
} else {
|
|
|
|
dx = gridX - draggingElement.x;
|
|
|
|
dy = gridY - draggingElement.y;
|
|
|
|
}
|
2020-03-08 18:09:45 -07:00
|
|
|
|
2020-06-24 00:24:52 +09:00
|
|
|
if (getRotateWithDiscreteAngleKey(event) && points.length === 2) {
|
2020-03-08 18:09:45 -07:00
|
|
|
({ width: dx, height: dy } = getPerfectElementSize(
|
|
|
|
this.state.elementType,
|
|
|
|
dx,
|
|
|
|
dy,
|
|
|
|
));
|
|
|
|
}
|
|
|
|
|
|
|
|
if (points.length === 1) {
|
2020-03-14 21:48:51 -07:00
|
|
|
mutateElement(draggingElement, { points: [...points, [dx, dy]] });
|
2020-03-08 18:09:45 -07:00
|
|
|
} else if (points.length > 1) {
|
2020-05-12 20:10:11 +01:00
|
|
|
if (draggingElement.type === "draw") {
|
|
|
|
mutateElement(draggingElement, {
|
|
|
|
points: simplify([...(points as Point[]), [dx, dy]], 0.7),
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
mutateElement(draggingElement, {
|
|
|
|
points: [...points.slice(0, -1), [dx, dy]],
|
|
|
|
});
|
|
|
|
}
|
2020-03-08 18:09:45 -07:00
|
|
|
}
|
2020-06-24 00:24:52 +09:00
|
|
|
} else if (draggingElement.type === "selection") {
|
|
|
|
dragNewElement(
|
|
|
|
draggingElement,
|
|
|
|
this.state.elementType,
|
|
|
|
originX,
|
|
|
|
originY,
|
|
|
|
x,
|
|
|
|
y,
|
|
|
|
distance(originX, x),
|
|
|
|
distance(originY, y),
|
|
|
|
getResizeWithSidesSameLengthKey(event),
|
|
|
|
getResizeCenterPointKey(event),
|
|
|
|
);
|
2020-03-08 18:09:45 -07:00
|
|
|
} else {
|
2020-06-24 00:24:52 +09:00
|
|
|
dragNewElement(
|
|
|
|
draggingElement,
|
|
|
|
this.state.elementType,
|
|
|
|
originGridX,
|
|
|
|
originGridY,
|
|
|
|
gridX,
|
|
|
|
gridY,
|
|
|
|
distance(originGridX, gridX),
|
|
|
|
distance(originGridY, gridY),
|
|
|
|
getResizeWithSidesSameLengthKey(event),
|
|
|
|
getResizeCenterPointKey(event),
|
|
|
|
);
|
2020-03-08 18:09:45 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
if (this.state.elementType === "selection") {
|
2020-04-08 09:49:52 -07:00
|
|
|
const elements = globalSceneState.getElements();
|
|
|
|
if (!event.shiftKey && isSomeElementSelected(elements, this.state)) {
|
2020-05-26 13:07:46 -07:00
|
|
|
this.setState({
|
|
|
|
selectedElementIds: {},
|
|
|
|
selectedGroupIds: {},
|
|
|
|
editingGroupId: null,
|
|
|
|
});
|
2020-03-08 18:09:45 -07:00
|
|
|
}
|
|
|
|
const elementsWithinSelection = getElementsWithinSelection(
|
2020-04-08 09:49:52 -07:00
|
|
|
elements,
|
2020-03-08 18:09:45 -07:00
|
|
|
draggingElement,
|
|
|
|
);
|
2020-05-26 13:07:46 -07:00
|
|
|
this.setState((prevState) =>
|
|
|
|
selectGroupsForSelectedElements(
|
|
|
|
{
|
|
|
|
...prevState,
|
|
|
|
selectedElementIds: {
|
|
|
|
...prevState.selectedElementIds,
|
|
|
|
...elementsWithinSelection.reduce((map, element) => {
|
|
|
|
map[element.id] = true;
|
|
|
|
return map;
|
|
|
|
}, {} as any),
|
|
|
|
},
|
|
|
|
},
|
|
|
|
globalSceneState.getElements(),
|
|
|
|
),
|
|
|
|
);
|
2020-03-08 18:09:45 -07:00
|
|
|
}
|
2020-03-16 19:07:47 -07:00
|
|
|
});
|
2020-03-08 18:09:45 -07:00
|
|
|
|
2020-04-04 16:12:19 +01:00
|
|
|
const onPointerUp = withBatchedUpdates((childEvent: PointerEvent) => {
|
2020-03-08 18:09:45 -07:00
|
|
|
const {
|
|
|
|
draggingElement,
|
|
|
|
resizingElement,
|
|
|
|
multiElement,
|
|
|
|
elementType,
|
|
|
|
elementLocked,
|
|
|
|
} = this.state;
|
|
|
|
|
|
|
|
this.setState({
|
|
|
|
isResizing: false,
|
2020-04-02 17:40:26 +09:00
|
|
|
isRotating: false,
|
2020-03-08 18:09:45 -07:00
|
|
|
resizingElement: null,
|
|
|
|
selectionElement: null,
|
2020-04-04 16:12:19 +01:00
|
|
|
cursorButton: "up",
|
2020-06-25 21:21:27 +02:00
|
|
|
// text elements are reset on finalize, and resetting on pointerup
|
|
|
|
// may cause issues with double taps
|
|
|
|
editingElement:
|
|
|
|
multiElement || isTextElement(this.state.editingElement)
|
|
|
|
? this.state.editingElement
|
|
|
|
: null,
|
2020-03-08 18:09:45 -07:00
|
|
|
});
|
|
|
|
|
2020-04-04 16:12:19 +01:00
|
|
|
this.savePointer(childEvent.clientX, childEvent.clientY, "up");
|
|
|
|
|
2020-06-01 11:35:44 +02:00
|
|
|
// if moving start/end point towards start/end point within threshold,
|
|
|
|
// close the loop
|
|
|
|
if (this.state.editingLinearElement) {
|
|
|
|
const editingLinearElement = LinearElementEditor.handlePointerUp(
|
|
|
|
this.state.editingLinearElement,
|
|
|
|
);
|
|
|
|
if (editingLinearElement !== this.state.editingLinearElement) {
|
|
|
|
this.setState({ editingLinearElement });
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-03-08 18:09:45 -07:00
|
|
|
lastPointerUp = null;
|
2020-04-04 16:12:19 +01:00
|
|
|
|
2020-04-12 06:12:02 +05:30
|
|
|
window.removeEventListener(EVENT.POINTER_MOVE, onPointerMove);
|
|
|
|
window.removeEventListener(EVENT.POINTER_UP, onPointerUp);
|
2020-03-08 18:09:45 -07:00
|
|
|
|
2020-05-12 20:10:11 +01:00
|
|
|
if (draggingElement?.type === "draw") {
|
|
|
|
this.actionManager.executeAction(actionFinalize);
|
|
|
|
return;
|
|
|
|
}
|
2020-03-17 20:55:40 +01:00
|
|
|
if (isLinearElement(draggingElement)) {
|
2020-03-08 18:09:45 -07:00
|
|
|
if (draggingElement!.points.length > 1) {
|
|
|
|
history.resumeRecording();
|
|
|
|
}
|
|
|
|
if (!draggingOccurred && draggingElement && !multiElement) {
|
|
|
|
const { x, y } = viewportCoordsToSceneCoords(
|
2020-04-04 16:12:19 +01:00
|
|
|
childEvent,
|
2020-03-08 18:09:45 -07:00
|
|
|
this.state,
|
|
|
|
this.canvas,
|
2020-03-15 12:25:18 -07:00
|
|
|
window.devicePixelRatio,
|
2020-03-08 18:09:45 -07:00
|
|
|
);
|
2020-03-14 21:48:51 -07:00
|
|
|
mutateElement(draggingElement, {
|
|
|
|
points: [
|
|
|
|
...draggingElement.points,
|
|
|
|
[x - draggingElement.x, y - draggingElement.y],
|
|
|
|
],
|
|
|
|
});
|
2020-03-12 21:28:58 +01:00
|
|
|
this.setState({
|
2020-03-17 20:55:40 +01:00
|
|
|
multiElement: draggingElement,
|
2020-03-12 21:28:58 +01:00
|
|
|
editingElement: this.state.draggingElement,
|
|
|
|
});
|
2020-03-08 18:09:45 -07:00
|
|
|
} else if (draggingOccurred && !multiElement) {
|
|
|
|
if (!elementLocked) {
|
|
|
|
resetCursor();
|
2020-03-23 13:05:07 +02:00
|
|
|
this.setState((prevState) => ({
|
2020-03-08 18:09:45 -07:00
|
|
|
draggingElement: null,
|
|
|
|
elementType: "selection",
|
|
|
|
selectedElementIds: {
|
|
|
|
...prevState.selectedElementIds,
|
|
|
|
[this.state.draggingElement!.id]: true,
|
|
|
|
},
|
|
|
|
}));
|
|
|
|
} else {
|
2020-03-23 13:05:07 +02:00
|
|
|
this.setState((prevState) => ({
|
2020-03-08 18:09:45 -07:00
|
|
|
draggingElement: null,
|
|
|
|
selectedElementIds: {
|
|
|
|
...prevState.selectedElementIds,
|
|
|
|
[this.state.draggingElement!.id]: true,
|
|
|
|
},
|
|
|
|
}));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (
|
|
|
|
elementType !== "selection" &&
|
|
|
|
draggingElement &&
|
|
|
|
isInvisiblySmallElement(draggingElement)
|
|
|
|
) {
|
|
|
|
// remove invisible element which was added in onPointerDown
|
2020-03-15 10:06:41 -07:00
|
|
|
globalSceneState.replaceAllElements(
|
2020-04-08 09:49:52 -07:00
|
|
|
globalSceneState.getElementsIncludingDeleted().slice(0, -1),
|
2020-03-15 10:06:41 -07:00
|
|
|
);
|
2020-03-08 18:09:45 -07:00
|
|
|
this.setState({
|
|
|
|
draggingElement: null,
|
|
|
|
});
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2020-05-28 11:41:34 +02:00
|
|
|
if (draggingElement) {
|
|
|
|
mutateElement(
|
|
|
|
draggingElement,
|
|
|
|
getNormalizedDimensions(draggingElement),
|
|
|
|
);
|
|
|
|
}
|
2020-03-08 18:09:45 -07:00
|
|
|
|
|
|
|
if (resizingElement) {
|
|
|
|
history.resumeRecording();
|
|
|
|
}
|
|
|
|
|
|
|
|
if (resizingElement && isInvisiblySmallElement(resizingElement)) {
|
2020-03-15 10:06:41 -07:00
|
|
|
globalSceneState.replaceAllElements(
|
|
|
|
globalSceneState
|
2020-04-08 09:49:52 -07:00
|
|
|
.getElementsIncludingDeleted()
|
2020-03-23 13:05:07 +02:00
|
|
|
.filter((el) => el.id !== resizingElement.id),
|
2020-03-14 21:48:51 -07:00
|
|
|
);
|
2020-03-08 18:09:45 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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 pointerdown phase) we need to keep
|
|
|
|
// selection unchanged
|
2020-05-26 13:07:46 -07:00
|
|
|
if (
|
|
|
|
getSelectedGroupIds(this.state).length === 0 &&
|
|
|
|
hitElement &&
|
|
|
|
!draggingOccurred &&
|
|
|
|
!hitElementWasAddedToSelection
|
|
|
|
) {
|
2020-04-04 16:12:19 +01:00
|
|
|
if (childEvent.shiftKey) {
|
2020-03-23 13:05:07 +02:00
|
|
|
this.setState((prevState) => ({
|
2020-03-08 18:09:45 -07:00
|
|
|
selectedElementIds: {
|
|
|
|
...prevState.selectedElementIds,
|
|
|
|
[hitElement!.id]: false,
|
|
|
|
},
|
|
|
|
}));
|
|
|
|
} else {
|
2020-04-07 17:49:59 +09:00
|
|
|
this.setState((_prevState) => ({
|
2020-03-08 18:09:45 -07:00
|
|
|
selectedElementIds: { [hitElement!.id]: true },
|
|
|
|
}));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (draggingElement === null) {
|
|
|
|
// if no element is clicked, clear the selection and redraw
|
2020-05-26 13:07:46 -07:00
|
|
|
this.setState({
|
|
|
|
selectedElementIds: {},
|
|
|
|
selectedGroupIds: {},
|
|
|
|
editingGroupId: null,
|
|
|
|
});
|
2020-03-08 18:09:45 -07:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!elementLocked) {
|
2020-03-23 13:05:07 +02:00
|
|
|
this.setState((prevState) => ({
|
2020-03-08 18:09:45 -07:00
|
|
|
selectedElementIds: {
|
|
|
|
...prevState.selectedElementIds,
|
|
|
|
[draggingElement.id]: true,
|
|
|
|
},
|
|
|
|
}));
|
|
|
|
}
|
|
|
|
|
|
|
|
if (
|
|
|
|
elementType !== "selection" ||
|
2020-04-08 09:49:52 -07:00
|
|
|
isSomeElementSelected(globalSceneState.getElements(), this.state)
|
2020-03-08 18:09:45 -07:00
|
|
|
) {
|
|
|
|
history.resumeRecording();
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!elementLocked) {
|
|
|
|
resetCursor();
|
|
|
|
this.setState({
|
|
|
|
draggingElement: null,
|
|
|
|
elementType: "selection",
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
this.setState({
|
|
|
|
draggingElement: null,
|
|
|
|
});
|
|
|
|
}
|
2020-03-16 19:07:47 -07:00
|
|
|
});
|
2020-03-08 18:09:45 -07:00
|
|
|
|
|
|
|
lastPointerUp = onPointerUp;
|
|
|
|
|
2020-04-12 06:12:02 +05:30
|
|
|
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
|
|
|
|
window.addEventListener(EVENT.POINTER_UP, onPointerUp);
|
2020-03-08 18:09:45 -07:00
|
|
|
};
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-04-04 14:55:36 +02:00
|
|
|
private handleCanvasRef = (canvas: HTMLCanvasElement) => {
|
|
|
|
// canvas is null when unmounting
|
|
|
|
if (canvas !== null) {
|
|
|
|
this.canvas = canvas;
|
|
|
|
this.rc = rough.canvas(this.canvas);
|
|
|
|
|
2020-04-12 06:12:02 +05:30
|
|
|
this.canvas.addEventListener(EVENT.WHEEL, this.handleWheel, {
|
2020-04-04 14:55:36 +02:00
|
|
|
passive: false,
|
|
|
|
});
|
2020-04-12 06:12:02 +05:30
|
|
|
this.canvas.addEventListener(EVENT.TOUCH_START, this.onTapStart);
|
2020-06-02 18:41:40 +02:00
|
|
|
this.canvas.addEventListener(EVENT.TOUCH_END, this.onTapEnd);
|
2020-04-04 14:55:36 +02:00
|
|
|
} else {
|
2020-04-12 06:12:02 +05:30
|
|
|
this.canvas?.removeEventListener(EVENT.WHEEL, this.handleWheel);
|
|
|
|
this.canvas?.removeEventListener(EVENT.TOUCH_START, this.onTapStart);
|
2020-06-02 18:41:40 +02:00
|
|
|
this.canvas?.removeEventListener(EVENT.TOUCH_END, this.onTapEnd);
|
2020-04-04 14:55:36 +02:00
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2020-04-04 15:27:53 +02:00
|
|
|
private handleCanvasOnDrop = (event: React.DragEvent<HTMLCanvasElement>) => {
|
|
|
|
const file = event.dataTransfer?.files[0];
|
|
|
|
if (
|
|
|
|
file?.type === "application/json" ||
|
|
|
|
file?.name.endsWith(".excalidraw")
|
|
|
|
) {
|
|
|
|
this.setState({ isLoading: true });
|
|
|
|
loadFromBlob(file)
|
|
|
|
.then(({ elements, appState }) =>
|
|
|
|
this.syncActionResult({
|
|
|
|
elements,
|
|
|
|
appState: {
|
|
|
|
...(appState || this.state),
|
|
|
|
isLoading: false,
|
|
|
|
},
|
|
|
|
commitToHistory: false,
|
|
|
|
}),
|
|
|
|
)
|
|
|
|
.catch((error) => {
|
2020-04-10 10:58:09 +01:00
|
|
|
this.setState({ isLoading: false, errorMessage: error.message });
|
2020-04-04 15:27:53 +02:00
|
|
|
});
|
|
|
|
} else {
|
|
|
|
this.setState({
|
|
|
|
isLoading: false,
|
|
|
|
errorMessage: t("alerts.couldNotLoadInvalidFile"),
|
|
|
|
});
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2020-03-21 09:03:17 -07:00
|
|
|
private handleCanvasContextMenu = (
|
|
|
|
event: React.PointerEvent<HTMLCanvasElement>,
|
|
|
|
) => {
|
|
|
|
event.preventDefault();
|
2020-07-02 22:12:56 +01:00
|
|
|
this.openContextMenu(event);
|
|
|
|
};
|
2020-03-21 09:03:17 -07:00
|
|
|
|
2020-07-02 22:12:56 +01:00
|
|
|
private openContextMenu = ({
|
|
|
|
clientX,
|
|
|
|
clientY,
|
|
|
|
}: {
|
|
|
|
clientX: number;
|
|
|
|
clientY: number;
|
|
|
|
}) => {
|
2020-03-21 09:03:17 -07:00
|
|
|
const { x, y } = viewportCoordsToSceneCoords(
|
2020-07-02 22:12:56 +01:00
|
|
|
{ clientX, clientY },
|
2020-03-21 09:03:17 -07:00
|
|
|
this.state,
|
|
|
|
this.canvas,
|
|
|
|
window.devicePixelRatio,
|
|
|
|
);
|
|
|
|
|
2020-04-08 09:49:52 -07:00
|
|
|
const elements = globalSceneState.getElements();
|
2020-03-21 09:03:17 -07:00
|
|
|
const element = getElementAtPosition(
|
2020-04-08 09:49:52 -07:00
|
|
|
elements,
|
2020-03-21 09:03:17 -07:00
|
|
|
this.state,
|
|
|
|
x,
|
|
|
|
y,
|
|
|
|
this.state.zoom,
|
|
|
|
);
|
|
|
|
if (!element) {
|
|
|
|
ContextMenu.push({
|
|
|
|
options: [
|
|
|
|
navigator.clipboard && {
|
|
|
|
label: t("labels.paste"),
|
|
|
|
action: () => this.pasteFromClipboard(null),
|
|
|
|
},
|
|
|
|
probablySupportsClipboardBlob &&
|
2020-04-08 09:49:52 -07:00
|
|
|
elements.length > 0 && {
|
2020-03-21 09:03:17 -07:00
|
|
|
label: t("labels.copyAsPng"),
|
|
|
|
action: this.copyToClipboardAsPng,
|
|
|
|
},
|
2020-04-05 16:13:17 -07:00
|
|
|
probablySupportsClipboardWriteText &&
|
2020-04-08 09:49:52 -07:00
|
|
|
elements.length > 0 && {
|
2020-04-05 16:13:17 -07:00
|
|
|
label: t("labels.copyAsSvg"),
|
|
|
|
action: this.copyToClipboardAsSvg,
|
|
|
|
},
|
2020-03-23 13:05:07 +02:00
|
|
|
...this.actionManager.getContextMenuItems((action) =>
|
2020-05-30 18:56:17 +05:30
|
|
|
CANVAS_ONLY_ACTIONS.includes(action.name),
|
2020-03-21 09:03:17 -07:00
|
|
|
),
|
2020-06-24 00:24:52 +09:00
|
|
|
{
|
|
|
|
label: t("labels.toggleGridMode"),
|
|
|
|
action: this.toggleGridMode,
|
|
|
|
},
|
2020-03-21 09:03:17 -07:00
|
|
|
],
|
2020-07-02 22:12:56 +01:00
|
|
|
top: clientY,
|
|
|
|
left: clientX,
|
2020-03-21 09:03:17 -07:00
|
|
|
});
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!this.state.selectedElementIds[element.id]) {
|
|
|
|
this.setState({ selectedElementIds: { [element.id]: true } });
|
|
|
|
}
|
|
|
|
|
|
|
|
ContextMenu.push({
|
|
|
|
options: [
|
|
|
|
navigator.clipboard && {
|
|
|
|
label: t("labels.copy"),
|
2020-03-27 15:18:14 -07:00
|
|
|
action: this.copyAll,
|
2020-03-21 09:03:17 -07:00
|
|
|
},
|
|
|
|
navigator.clipboard && {
|
|
|
|
label: t("labels.paste"),
|
|
|
|
action: () => this.pasteFromClipboard(null),
|
|
|
|
},
|
|
|
|
probablySupportsClipboardBlob && {
|
|
|
|
label: t("labels.copyAsPng"),
|
|
|
|
action: this.copyToClipboardAsPng,
|
|
|
|
},
|
2020-04-05 16:13:17 -07:00
|
|
|
probablySupportsClipboardWriteText && {
|
|
|
|
label: t("labels.copyAsSvg"),
|
|
|
|
action: this.copyToClipboardAsSvg,
|
|
|
|
},
|
2020-03-21 09:03:17 -07:00
|
|
|
...this.actionManager.getContextMenuItems(
|
2020-05-30 18:56:17 +05:30
|
|
|
(action) => !CANVAS_ONLY_ACTIONS.includes(action.name),
|
2020-03-21 09:03:17 -07:00
|
|
|
),
|
|
|
|
],
|
2020-07-02 22:12:56 +01:00
|
|
|
top: clientY,
|
|
|
|
left: clientX,
|
2020-03-21 09:03:17 -07:00
|
|
|
});
|
|
|
|
};
|
|
|
|
|
2020-03-16 19:07:47 -07:00
|
|
|
private handleWheel = withBatchedUpdates((event: WheelEvent) => {
|
2020-03-07 10:20:38 -05:00
|
|
|
event.preventDefault();
|
|
|
|
const { deltaX, deltaY } = event;
|
2020-06-02 18:41:40 +02:00
|
|
|
const { selectedElementIds, previousSelectedElementIds } = this.state;
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-03-09 15:19:38 +01:00
|
|
|
// note that event.ctrlKey is necessary to handle pinch zooming
|
|
|
|
if (event.metaKey || event.ctrlKey) {
|
2020-03-07 10:20:38 -05:00
|
|
|
const sign = Math.sign(deltaY);
|
|
|
|
const MAX_STEP = 10;
|
|
|
|
let delta = Math.abs(deltaY);
|
|
|
|
if (delta > MAX_STEP) {
|
|
|
|
delta = MAX_STEP;
|
|
|
|
}
|
|
|
|
delta *= sign;
|
2020-06-02 18:41:40 +02:00
|
|
|
if (Object.keys(previousSelectedElementIds).length !== 0) {
|
|
|
|
setTimeout(() => {
|
|
|
|
this.setState({
|
|
|
|
selectedElementIds: previousSelectedElementIds,
|
|
|
|
previousSelectedElementIds: {},
|
|
|
|
});
|
|
|
|
}, 1000);
|
|
|
|
}
|
2020-03-07 10:20:38 -05:00
|
|
|
this.setState(({ zoom }) => ({
|
|
|
|
zoom: getNormalizedZoom(zoom - delta / 100),
|
2020-06-02 18:41:40 +02:00
|
|
|
selectedElementIds: {},
|
|
|
|
previousSelectedElementIds:
|
|
|
|
Object.keys(selectedElementIds).length !== 0
|
|
|
|
? selectedElementIds
|
|
|
|
: previousSelectedElementIds,
|
2020-03-07 10:20:38 -05:00
|
|
|
}));
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2020-04-30 22:34:38 +02:00
|
|
|
// scroll horizontally when shift pressed
|
|
|
|
if (event.shiftKey) {
|
|
|
|
this.setState(({ zoom, scrollX }) => ({
|
2020-05-02 22:15:28 +02:00
|
|
|
// on Mac, shift+wheel tends to result in deltaX
|
|
|
|
scrollX: normalizeScroll(scrollX - (deltaY || deltaX) / zoom),
|
2020-04-30 22:34:38 +02:00
|
|
|
}));
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2020-03-07 10:20:38 -05:00
|
|
|
this.setState(({ zoom, scrollX, scrollY }) => ({
|
|
|
|
scrollX: normalizeScroll(scrollX - deltaX / zoom),
|
|
|
|
scrollY: normalizeScroll(scrollY - deltaY / zoom),
|
|
|
|
}));
|
2020-03-16 19:07:47 -07:00
|
|
|
});
|
2020-03-07 10:20:38 -05:00
|
|
|
|
2020-03-28 14:30:41 -07:00
|
|
|
private getTextWysiwygSnappedToCenterPosition(
|
|
|
|
x: number,
|
|
|
|
y: number,
|
|
|
|
state: {
|
|
|
|
scrollX: FlooredNumber;
|
|
|
|
scrollY: FlooredNumber;
|
|
|
|
zoom: number;
|
|
|
|
},
|
|
|
|
canvas: HTMLCanvasElement | null,
|
|
|
|
scale: number,
|
|
|
|
) {
|
2020-03-14 21:48:51 -07:00
|
|
|
const elementClickedInside = getElementContainingPosition(
|
2020-06-25 21:21:27 +02:00
|
|
|
globalSceneState
|
|
|
|
.getElementsIncludingDeleted()
|
|
|
|
.filter((element) => !isTextElement(element)),
|
2020-03-14 21:48:51 -07:00
|
|
|
x,
|
|
|
|
y,
|
|
|
|
);
|
2020-03-07 10:20:38 -05:00
|
|
|
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) {
|
2020-06-25 21:21:27 +02:00
|
|
|
const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
|
2020-03-28 14:30:41 -07:00
|
|
|
{ sceneX: elementCenterX, sceneY: elementCenterY },
|
|
|
|
state,
|
|
|
|
canvas,
|
|
|
|
scale,
|
|
|
|
);
|
2020-06-25 21:21:27 +02:00
|
|
|
return { viewportX, viewportY, elementCenterX, elementCenterY };
|
2020-03-07 10:20:38 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-04-04 16:12:19 +01:00
|
|
|
private savePointer = (x: number, y: number, button: "up" | "down") => {
|
|
|
|
if (!x || !y) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const pointerCoords = viewportCoordsToSceneCoords(
|
|
|
|
{ clientX: x, clientY: y },
|
|
|
|
this.state,
|
|
|
|
this.canvas,
|
|
|
|
window.devicePixelRatio,
|
|
|
|
);
|
|
|
|
|
basic Socket.io implementation of collaborative editing (#879)
* Enable collaborative syncing for elements
* Don't fall back to local storage if using a room, as that is confusing
* Use remote socket server
* Send updates to new users when they join
* ~
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* Enable collaborative syncing for elements
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* prettier
* Fix bug with remote pointers not changing on scroll
* Enable collaborative syncing for elements
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* fix syncing bugs and add a button to start syncing mid session
* Fix bug with remote pointers not changing on scroll
* remove UI for collaboration
* remove link
* clean up lingering unused UI
* set random IV passed per encrypted message, reduce room id length, refactored socket broadcasting API, rename room_id to room, removed throttling of pointer movement
* fix package.json conflict
2020-03-09 08:48:25 -07:00
|
|
|
if (isNaN(pointerCoords.x) || isNaN(pointerCoords.y)) {
|
|
|
|
// sometimes the pointer goes off screen
|
|
|
|
return;
|
|
|
|
}
|
2020-04-08 10:18:56 -07:00
|
|
|
this.portal.socket &&
|
2020-04-11 15:26:27 -07:00
|
|
|
// do not broadcast when more than 1 pointer since that shows flickering on the other side
|
|
|
|
gesture.pointers.size < 2 &&
|
2020-04-04 16:12:19 +01:00
|
|
|
this.broadcastMouseLocation({
|
|
|
|
pointerCoords,
|
|
|
|
button,
|
|
|
|
});
|
basic Socket.io implementation of collaborative editing (#879)
* Enable collaborative syncing for elements
* Don't fall back to local storage if using a room, as that is confusing
* Use remote socket server
* Send updates to new users when they join
* ~
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* Enable collaborative syncing for elements
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* prettier
* Fix bug with remote pointers not changing on scroll
* Enable collaborative syncing for elements
* add mouse tracking
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* Add Live button and app state to support tracking collaborator counts
* enable collaboration, rooms, and mouse tracking
* fix syncing bugs and add a button to start syncing mid session
* fix syncing bugs and add a button to start syncing mid session
* Fix bug with remote pointers not changing on scroll
* remove UI for collaboration
* remove link
* clean up lingering unused UI
* set random IV passed per encrypted message, reduce room id length, refactored socket broadcasting API, rename room_id to room, removed throttling of pointer movement
* fix package.json conflict
2020-03-09 08:48:25 -07:00
|
|
|
};
|
|
|
|
|
2020-03-28 16:59:36 -07:00
|
|
|
private resetShouldCacheIgnoreZoomDebounced = debounce(() => {
|
|
|
|
this.setState({ shouldCacheIgnoreZoom: false });
|
2020-04-02 23:56:14 -07:00
|
|
|
}, 300);
|
2020-03-28 16:59:36 -07:00
|
|
|
|
2020-03-07 10:20:38 -05:00
|
|
|
private saveDebounced = debounce(() => {
|
2020-04-08 09:49:52 -07:00
|
|
|
saveToLocalStorage(
|
|
|
|
globalSceneState.getElementsIncludingDeleted(),
|
|
|
|
this.state,
|
|
|
|
);
|
2020-03-07 10:20:38 -05:00
|
|
|
}, 300);
|
|
|
|
}
|
2020-03-18 20:44:05 +01:00
|
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// TEST HOOKS
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
declare global {
|
|
|
|
interface Window {
|
|
|
|
h: {
|
|
|
|
elements: readonly ExcalidrawElement[];
|
|
|
|
state: AppState;
|
2020-03-26 08:28:50 +01:00
|
|
|
setState: React.Component<any, AppState>["setState"];
|
2020-03-18 20:44:05 +01:00
|
|
|
history: SceneHistory;
|
2020-03-26 08:28:50 +01:00
|
|
|
app: InstanceType<typeof App>;
|
2020-03-18 20:44:05 +01:00
|
|
|
};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-04-12 06:12:02 +05:30
|
|
|
if (
|
|
|
|
process.env.NODE_ENV === ENV.TEST ||
|
|
|
|
process.env.NODE_ENV === ENV.DEVELOPMENT
|
|
|
|
) {
|
2020-03-18 20:44:05 +01:00
|
|
|
window.h = {} as Window["h"];
|
|
|
|
|
|
|
|
Object.defineProperties(window.h, {
|
|
|
|
elements: {
|
|
|
|
get() {
|
2020-04-08 09:49:52 -07:00
|
|
|
return globalSceneState.getElementsIncludingDeleted();
|
2020-03-18 20:44:05 +01:00
|
|
|
},
|
|
|
|
set(elements: ExcalidrawElement[]) {
|
|
|
|
return globalSceneState.replaceAllElements(elements);
|
|
|
|
},
|
|
|
|
},
|
|
|
|
history: {
|
2020-05-20 16:21:37 +03:00
|
|
|
get: () => history,
|
2020-03-18 20:44:05 +01:00
|
|
|
},
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2020-04-12 16:24:52 +05:30
|
|
|
export default App;
|