Factor out collaboration code (#2313)
Co-authored-by: Lipis <lipiridis@gmail.com> Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
parent
d8a0dc3b4d
commit
e617ccc252
@ -64,7 +64,6 @@ export const actionClearCanvas = register({
|
|||||||
exportEmbedScene: appState.exportEmbedScene,
|
exportEmbedScene: appState.exportEmbedScene,
|
||||||
gridSize: appState.gridSize,
|
gridSize: appState.gridSize,
|
||||||
shouldAddWatermark: appState.shouldAddWatermark,
|
shouldAddWatermark: appState.shouldAddWatermark,
|
||||||
username: appState.username,
|
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
|
@ -34,7 +34,6 @@ export const actionGoToCollaborator = register({
|
|||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData, id }) => {
|
PanelComponent: ({ appState, updateData, id }) => {
|
||||||
const clientId = id;
|
const clientId = id;
|
||||||
|
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -47,9 +47,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
cursorButton: "up",
|
cursorButton: "up",
|
||||||
scrolledOutside: false,
|
scrolledOutside: false,
|
||||||
name: `${t("labels.untitled")}-${getDateTime()}`,
|
name: `${t("labels.untitled")}-${getDateTime()}`,
|
||||||
username: "",
|
|
||||||
isBindingEnabled: true,
|
isBindingEnabled: true,
|
||||||
isCollaborating: false,
|
|
||||||
isResizing: false,
|
isResizing: false,
|
||||||
isRotating: false,
|
isRotating: false,
|
||||||
selectionElement: null,
|
selectionElement: null,
|
||||||
@ -61,7 +59,6 @@ export const getDefaultAppState = (): Omit<
|
|||||||
lastPointerDownWith: "mouse",
|
lastPointerDownWith: "mouse",
|
||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
previousSelectedElementIds: {},
|
previousSelectedElementIds: {},
|
||||||
collaborators: new Map(),
|
|
||||||
shouldCacheIgnoreZoom: false,
|
shouldCacheIgnoreZoom: false,
|
||||||
showShortcutsDialog: false,
|
showShortcutsDialog: false,
|
||||||
suggestedBindings: [],
|
suggestedBindings: [],
|
||||||
@ -73,6 +70,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
height: window.innerHeight,
|
height: window.innerHeight,
|
||||||
isLibraryOpen: false,
|
isLibraryOpen: false,
|
||||||
fileHandle: null,
|
fileHandle: null,
|
||||||
|
collaborators: new Map(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -92,7 +90,6 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
|
config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
|
||||||
) => config)({
|
) => config)({
|
||||||
appearance: { browser: true, export: false },
|
appearance: { browser: true, export: false },
|
||||||
collaborators: { browser: false, export: false },
|
|
||||||
currentItemBackgroundColor: { browser: true, export: false },
|
currentItemBackgroundColor: { browser: true, export: false },
|
||||||
currentItemFillStyle: { browser: true, export: false },
|
currentItemFillStyle: { browser: true, export: false },
|
||||||
currentItemFontFamily: { browser: true, export: false },
|
currentItemFontFamily: { browser: true, export: false },
|
||||||
@ -121,7 +118,6 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
gridSize: { browser: true, export: true },
|
gridSize: { browser: true, export: true },
|
||||||
height: { browser: false, export: false },
|
height: { browser: false, export: false },
|
||||||
isBindingEnabled: { browser: false, export: false },
|
isBindingEnabled: { browser: false, export: false },
|
||||||
isCollaborating: { browser: false, export: false },
|
|
||||||
isLibraryOpen: { browser: false, export: false },
|
isLibraryOpen: { browser: false, export: false },
|
||||||
isLoading: { browser: false, export: false },
|
isLoading: { browser: false, export: false },
|
||||||
isResizing: { browser: false, export: false },
|
isResizing: { browser: false, export: false },
|
||||||
@ -142,7 +138,6 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
shouldCacheIgnoreZoom: { browser: true, export: false },
|
shouldCacheIgnoreZoom: { browser: true, export: false },
|
||||||
showShortcutsDialog: { browser: false, export: false },
|
showShortcutsDialog: { browser: false, export: false },
|
||||||
suggestedBindings: { browser: false, export: false },
|
suggestedBindings: { browser: false, export: false },
|
||||||
username: { browser: true, export: false },
|
|
||||||
viewBackgroundColor: { browser: true, export: true },
|
viewBackgroundColor: { browser: true, export: true },
|
||||||
width: { browser: false, export: false },
|
width: { browser: false, export: false },
|
||||||
zenModeEnabled: { browser: true, export: false },
|
zenModeEnabled: { browser: true, export: false },
|
||||||
@ -150,6 +145,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
offsetTop: { browser: false, export: false },
|
offsetTop: { browser: false, export: false },
|
||||||
offsetLeft: { browser: false, export: false },
|
offsetLeft: { browser: false, export: false },
|
||||||
fileHandle: { browser: false, export: false },
|
fileHandle: { browser: false, export: false },
|
||||||
|
collaborators: { browser: false, export: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
|
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
|
||||||
|
@ -15,8 +15,6 @@ import {
|
|||||||
getCursorForResizingElement,
|
getCursorForResizingElement,
|
||||||
getPerfectElementSize,
|
getPerfectElementSize,
|
||||||
getNormalizedDimensions,
|
getNormalizedDimensions,
|
||||||
getSceneVersion,
|
|
||||||
getSyncableElements,
|
|
||||||
newLinearElement,
|
newLinearElement,
|
||||||
transformElements,
|
transformElements,
|
||||||
getElementWithTransformHandleType,
|
getElementWithTransformHandleType,
|
||||||
@ -42,17 +40,16 @@ import {
|
|||||||
isSomeElementSelected,
|
isSomeElementSelected,
|
||||||
calculateScrollCenter,
|
calculateScrollCenter,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
import {
|
import { loadFromBlob, exportCanvas } from "../data";
|
||||||
decryptAESGEM,
|
|
||||||
loadScene,
|
|
||||||
loadFromBlob,
|
|
||||||
SOCKET_SERVER,
|
|
||||||
exportCanvas,
|
|
||||||
} from "../data";
|
|
||||||
import Portal from "./Portal";
|
|
||||||
|
|
||||||
import { renderScene } from "../renderer";
|
import { renderScene } from "../renderer";
|
||||||
import { AppState, GestureEvent, Gesture, ExcalidrawProps } from "../types";
|
import {
|
||||||
|
AppState,
|
||||||
|
GestureEvent,
|
||||||
|
Gesture,
|
||||||
|
ExcalidrawProps,
|
||||||
|
SceneData,
|
||||||
|
} from "../types";
|
||||||
import {
|
import {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
@ -75,6 +72,9 @@ import {
|
|||||||
sceneCoordsToViewportCoords,
|
sceneCoordsToViewportCoords,
|
||||||
setCursorForShape,
|
setCursorForShape,
|
||||||
tupleToCoors,
|
tupleToCoors,
|
||||||
|
ResolvablePromise,
|
||||||
|
resolvablePromise,
|
||||||
|
withBatchedUpdates,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
import {
|
import {
|
||||||
KEYS,
|
KEYS,
|
||||||
@ -116,28 +116,20 @@ import {
|
|||||||
DRAGGING_THRESHOLD,
|
DRAGGING_THRESHOLD,
|
||||||
TEXT_TO_CENTER_SNAP_THRESHOLD,
|
TEXT_TO_CENTER_SNAP_THRESHOLD,
|
||||||
LINE_CONFIRM_THRESHOLD,
|
LINE_CONFIRM_THRESHOLD,
|
||||||
SCENE,
|
|
||||||
EVENT,
|
EVENT,
|
||||||
ENV,
|
ENV,
|
||||||
CANVAS_ONLY_ACTIONS,
|
CANVAS_ONLY_ACTIONS,
|
||||||
DEFAULT_VERTICAL_ALIGN,
|
DEFAULT_VERTICAL_ALIGN,
|
||||||
GRID_SIZE,
|
GRID_SIZE,
|
||||||
LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
|
|
||||||
MIME_TYPES,
|
MIME_TYPES,
|
||||||
} from "../constants";
|
|
||||||
import {
|
|
||||||
INITIAL_SCENE_UPDATE_TIMEOUT,
|
|
||||||
TAP_TWICE_TIMEOUT,
|
TAP_TWICE_TIMEOUT,
|
||||||
SYNC_FULL_SCENE_INTERVAL_MS,
|
|
||||||
TOUCH_CTX_MENU_TIMEOUT,
|
TOUCH_CTX_MENU_TIMEOUT,
|
||||||
} from "../time_constants";
|
} from "../constants";
|
||||||
|
|
||||||
import LayerUI from "./LayerUI";
|
import LayerUI from "./LayerUI";
|
||||||
import { ScrollBars, SceneState } from "../scene/types";
|
import { ScrollBars, SceneState } from "../scene/types";
|
||||||
import { generateCollaborationLink, getCollaborationLinkData } from "../data";
|
|
||||||
import { mutateElement } from "../element/mutateElement";
|
import { mutateElement } from "../element/mutateElement";
|
||||||
import { invalidateShapeForElement } from "../renderer/renderElement";
|
import { invalidateShapeForElement } from "../renderer/renderElement";
|
||||||
import { unstable_batchedUpdates } from "react-dom";
|
|
||||||
import {
|
import {
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
isLinearElementType,
|
isLinearElementType,
|
||||||
@ -146,7 +138,6 @@ import {
|
|||||||
} from "../element/typeChecks";
|
} from "../element/typeChecks";
|
||||||
import { actionFinalize, actionDeleteSelected } from "../actions";
|
import { actionFinalize, actionDeleteSelected } from "../actions";
|
||||||
|
|
||||||
import throttle from "lodash.throttle";
|
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import {
|
import {
|
||||||
getSelectedGroupIds,
|
getSelectedGroupIds,
|
||||||
@ -175,32 +166,15 @@ import {
|
|||||||
import { MaybeTransformHandleType } from "../element/transformHandles";
|
import { MaybeTransformHandleType } from "../element/transformHandles";
|
||||||
import { renderSpreadsheet } from "../charts";
|
import { renderSpreadsheet } from "../charts";
|
||||||
import { isValidLibrary } from "../data/json";
|
import { isValidLibrary } from "../data/json";
|
||||||
import {
|
|
||||||
loadFromFirebase,
|
|
||||||
saveToFirebase,
|
|
||||||
isSavedToFirebase,
|
|
||||||
} from "../data/firebase";
|
|
||||||
import { getNewZoom } from "../scene/zoom";
|
import { getNewZoom } from "../scene/zoom";
|
||||||
|
import { restore } from "../data/restore";
|
||||||
import {
|
import {
|
||||||
EVENT_DIALOG,
|
EVENT_DIALOG,
|
||||||
EVENT_LIBRARY,
|
EVENT_LIBRARY,
|
||||||
EVENT_SHAPE,
|
EVENT_SHAPE,
|
||||||
EVENT_SHARE,
|
|
||||||
trackEvent,
|
trackEvent,
|
||||||
} from "../analytics";
|
} from "../analytics";
|
||||||
|
|
||||||
/**
|
|
||||||
* @param func handler taking at most single parameter (event).
|
|
||||||
*/
|
|
||||||
const withBatchedUpdates = <
|
|
||||||
TFunction extends ((event: any) => void) | (() => void)
|
|
||||||
>(
|
|
||||||
func: Parameters<TFunction>["length"] extends 0 | 1 ? TFunction : never,
|
|
||||||
) =>
|
|
||||||
((event) => {
|
|
||||||
unstable_batchedUpdates(func as TFunction, event);
|
|
||||||
}) as TFunction;
|
|
||||||
|
|
||||||
const { history } = createHistory();
|
const { history } = createHistory();
|
||||||
|
|
||||||
let didTapTwice: boolean = false;
|
let didTapTwice: boolean = false;
|
||||||
@ -275,58 +249,77 @@ export type PointerDownState = Readonly<{
|
|||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type ExcalidrawImperativeAPI =
|
export type ExcalidrawImperativeAPI = {
|
||||||
| {
|
|
||||||
updateScene: InstanceType<typeof App>["updateScene"];
|
updateScene: InstanceType<typeof App>["updateScene"];
|
||||||
resetScene: InstanceType<typeof App>["resetScene"];
|
resetScene: InstanceType<typeof App>["resetScene"];
|
||||||
resetHistory: InstanceType<typeof App>["resetHistory"];
|
|
||||||
getSceneElementsIncludingDeleted: InstanceType<
|
getSceneElementsIncludingDeleted: InstanceType<
|
||||||
typeof App
|
typeof App
|
||||||
>["getSceneElementsIncludingDeleted"];
|
>["getSceneElementsIncludingDeleted"];
|
||||||
}
|
history: {
|
||||||
| undefined;
|
clear: InstanceType<typeof App>["resetHistory"];
|
||||||
|
};
|
||||||
|
setScrollToCenter: InstanceType<typeof App>["setScrollToCenter"];
|
||||||
|
getSceneElements: InstanceType<typeof App>["getSceneElements"];
|
||||||
|
readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>;
|
||||||
|
ready: true;
|
||||||
|
};
|
||||||
|
|
||||||
class App extends React.Component<ExcalidrawProps, AppState> {
|
class App extends React.Component<ExcalidrawProps, AppState> {
|
||||||
canvas: HTMLCanvasElement | null = null;
|
canvas: HTMLCanvasElement | null = null;
|
||||||
rc: RoughCanvas | null = null;
|
rc: RoughCanvas | null = null;
|
||||||
portal: Portal;
|
|
||||||
private lastBroadcastedOrReceivedSceneVersion: number = -1;
|
|
||||||
unmounted: boolean = false;
|
unmounted: boolean = false;
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
private excalidrawRef: any;
|
private excalidrawContainerRef = React.createRef<HTMLDivElement>();
|
||||||
private socketInitializationTimer: any;
|
|
||||||
|
|
||||||
public static defaultProps: Partial<ExcalidrawProps> = {
|
public static defaultProps: Partial<ExcalidrawProps> = {
|
||||||
width: window.innerWidth,
|
width: window.innerWidth,
|
||||||
height: window.innerHeight,
|
height: window.innerHeight,
|
||||||
};
|
};
|
||||||
private scene: Scene;
|
private scene: Scene;
|
||||||
|
|
||||||
constructor(props: ExcalidrawProps) {
|
constructor(props: ExcalidrawProps) {
|
||||||
super(props);
|
super(props);
|
||||||
const defaultAppState = getDefaultAppState();
|
const defaultAppState = getDefaultAppState();
|
||||||
|
|
||||||
const { width, height, offsetLeft, offsetTop, user, forwardedRef } = props;
|
const {
|
||||||
|
width = window.innerWidth,
|
||||||
|
height = window.innerHeight,
|
||||||
|
offsetLeft,
|
||||||
|
offsetTop,
|
||||||
|
excalidrawRef,
|
||||||
|
} = props;
|
||||||
this.state = {
|
this.state = {
|
||||||
...defaultAppState,
|
...defaultAppState,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
username: user?.name || "",
|
|
||||||
...this.getCanvasOffsets({ offsetLeft, offsetTop }),
|
...this.getCanvasOffsets({ offsetLeft, offsetTop }),
|
||||||
};
|
};
|
||||||
if (forwardedRef && "current" in forwardedRef) {
|
if (excalidrawRef) {
|
||||||
forwardedRef.current = {
|
const readyPromise =
|
||||||
|
typeof excalidrawRef === "function"
|
||||||
|
? resolvablePromise<ExcalidrawImperativeAPI>()
|
||||||
|
: excalidrawRef.current!.readyPromise;
|
||||||
|
const api: ExcalidrawImperativeAPI = {
|
||||||
|
ready: true,
|
||||||
|
readyPromise,
|
||||||
updateScene: this.updateScene,
|
updateScene: this.updateScene,
|
||||||
resetScene: this.resetScene,
|
resetScene: this.resetScene,
|
||||||
resetHistory: this.resetHistory,
|
|
||||||
getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted,
|
getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted,
|
||||||
};
|
history: {
|
||||||
|
clear: this.resetHistory,
|
||||||
|
},
|
||||||
|
setScrollToCenter: this.setScrollToCenter,
|
||||||
|
getSceneElements: this.getSceneElements,
|
||||||
|
} as const;
|
||||||
|
if (typeof excalidrawRef === "function") {
|
||||||
|
excalidrawRef(api);
|
||||||
|
} else {
|
||||||
|
excalidrawRef.current = api;
|
||||||
|
}
|
||||||
|
readyPromise.resolve(api);
|
||||||
}
|
}
|
||||||
this.scene = new Scene();
|
this.scene = new Scene();
|
||||||
this.portal = new Portal(this);
|
|
||||||
|
|
||||||
this.excalidrawRef = React.createRef();
|
|
||||||
this.actionManager = new ActionManager(
|
this.actionManager = new ActionManager(
|
||||||
this.syncActionResult,
|
this.syncActionResult,
|
||||||
() => this.state,
|
() => this.state,
|
||||||
@ -347,7 +340,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
offsetLeft,
|
offsetLeft,
|
||||||
} = this.state;
|
} = this.state;
|
||||||
|
|
||||||
const { onUsernameChange } = this.props;
|
const { onCollabButtonClick } = this.props;
|
||||||
const canvasScale = window.devicePixelRatio;
|
const canvasScale = window.devicePixelRatio;
|
||||||
|
|
||||||
const canvasWidth = canvasDOMWidth * canvasScale;
|
const canvasWidth = canvasDOMWidth * canvasScale;
|
||||||
@ -356,7 +349,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="excalidraw"
|
className="excalidraw"
|
||||||
ref={this.excalidrawRef}
|
ref={this.excalidrawContainerRef}
|
||||||
style={{
|
style={{
|
||||||
width: canvasDOMWidth,
|
width: canvasDOMWidth,
|
||||||
height: canvasDOMHeight,
|
height: canvasDOMHeight,
|
||||||
@ -370,12 +363,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
setAppState={this.setAppState}
|
setAppState={this.setAppState}
|
||||||
actionManager={this.actionManager}
|
actionManager={this.actionManager}
|
||||||
elements={this.scene.getElements()}
|
elements={this.scene.getElements()}
|
||||||
onRoomCreate={this.openPortal}
|
onCollabButtonClick={onCollabButtonClick}
|
||||||
onRoomDestroy={this.closePortal}
|
|
||||||
onUsernameChange={(username) => {
|
|
||||||
onUsernameChange && onUsernameChange(username);
|
|
||||||
this.setState({ username });
|
|
||||||
}}
|
|
||||||
onLockToggle={this.toggleLock}
|
onLockToggle={this.toggleLock}
|
||||||
onInsertShape={(elements) =>
|
onInsertShape={(elements) =>
|
||||||
this.addElementsFromPasteOrLibrary(elements)
|
this.addElementsFromPasteOrLibrary(elements)
|
||||||
@ -383,6 +371,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
zenModeEnabled={zenModeEnabled}
|
zenModeEnabled={zenModeEnabled}
|
||||||
toggleZenMode={this.toggleZenMode}
|
toggleZenMode={this.toggleZenMode}
|
||||||
lng={getLanguage().lng}
|
lng={getLanguage().lng}
|
||||||
|
isCollaborating={this.props.isCollaborating || false}
|
||||||
/>
|
/>
|
||||||
<main>
|
<main>
|
||||||
<canvas
|
<canvas
|
||||||
@ -410,18 +399,14 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
|
|
||||||
this.lastBroadcastedOrReceivedSceneVersion = version;
|
|
||||||
};
|
|
||||||
|
|
||||||
public getLastBroadcastedOrReceivedSceneVersion = () => {
|
|
||||||
return this.lastBroadcastedOrReceivedSceneVersion;
|
|
||||||
};
|
|
||||||
|
|
||||||
public getSceneElementsIncludingDeleted = () => {
|
public getSceneElementsIncludingDeleted = () => {
|
||||||
return this.scene.getElementsIncludingDeleted();
|
return this.scene.getElementsIncludingDeleted();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public getSceneElements = () => {
|
||||||
|
return this.scene.getElements();
|
||||||
|
};
|
||||||
|
|
||||||
private syncActionResult = withBatchedUpdates(
|
private syncActionResult = withBatchedUpdates(
|
||||||
(actionResult: ActionResult) => {
|
(actionResult: ActionResult) => {
|
||||||
if (this.unmounted || actionResult === false) {
|
if (this.unmounted || actionResult === false) {
|
||||||
@ -454,8 +439,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
...actionResult.appState,
|
...actionResult.appState,
|
||||||
editingElement:
|
editingElement:
|
||||||
editingElement || actionResult.appState?.editingElement || null,
|
editingElement || actionResult.appState?.editingElement || null,
|
||||||
isCollaborating: state.isCollaborating,
|
|
||||||
collaborators: state.collaborators,
|
|
||||||
width: state.width,
|
width: state.width,
|
||||||
height: state.height,
|
height: state.height,
|
||||||
offsetTop: state.offsetTop,
|
offsetTop: state.offsetTop,
|
||||||
@ -482,7 +465,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
private onUnload = () => {
|
private onUnload = () => {
|
||||||
this.destroySocketClient();
|
|
||||||
this.onBlur();
|
this.onBlur();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -499,46 +481,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
this.onSceneUpdated();
|
this.onSceneUpdated();
|
||||||
};
|
};
|
||||||
|
|
||||||
private shouldForceLoadScene(
|
|
||||||
scene: ResolutionType<typeof loadScene>,
|
|
||||||
): boolean {
|
|
||||||
if (!scene.elements.length) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const roomMatch = getCollaborationLinkData(window.location.href);
|
|
||||||
|
|
||||||
if (!roomMatch) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const roomId = roomMatch[1];
|
|
||||||
|
|
||||||
let collabForceLoadFlag;
|
|
||||||
try {
|
|
||||||
collabForceLoadFlag = localStorage?.getItem(
|
|
||||||
LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
|
|
||||||
);
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
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 === roomId && Date.now() - timestamp < 15000) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private importLibraryFromUrl = async (url: string) => {
|
private importLibraryFromUrl = async (url: string) => {
|
||||||
window.history.replaceState({}, "Excalidraw", window.location.origin);
|
window.history.replaceState({}, "Excalidraw", window.location.origin);
|
||||||
try {
|
try {
|
||||||
@ -569,17 +511,21 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
history.clear();
|
history.clear();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Completely resets scene & history.
|
/**
|
||||||
// Do not use for clear scene user action.
|
* Resets scene & history.
|
||||||
private resetScene = withBatchedUpdates(() => {
|
* ! Do not use to clear scene user action !
|
||||||
|
*/
|
||||||
|
private resetScene = withBatchedUpdates(
|
||||||
|
(opts?: { resetLoadingState: boolean }) => {
|
||||||
this.scene.replaceAllElements([]);
|
this.scene.replaceAllElements([]);
|
||||||
this.setState({
|
this.setState((state) => ({
|
||||||
...getDefaultAppState(),
|
...getDefaultAppState(),
|
||||||
|
isLoading: opts?.resetLoadingState ? false : state.isLoading,
|
||||||
appearance: this.state.appearance,
|
appearance: this.state.appearance,
|
||||||
username: this.state.username,
|
}));
|
||||||
});
|
|
||||||
this.resetHistory();
|
this.resetHistory();
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
private initializeScene = async () => {
|
private initializeScene = async () => {
|
||||||
if ("launchQueue" in window && "LaunchParams" in window) {
|
if ("launchQueue" in window && "LaunchParams" in window) {
|
||||||
@ -609,65 +555,19 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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_-]+)$/,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!this.state.isLoading) {
|
if (!this.state.isLoading) {
|
||||||
this.setState({ isLoading: true });
|
this.setState({ isLoading: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
let scene = await loadScene(null, null, this.props.initialData);
|
let initialData = null;
|
||||||
|
try {
|
||||||
let isCollaborationScene = !!getCollaborationLinkData(window.location.href);
|
initialData = (await this.props.initialData) || null;
|
||||||
const isExternalScene = !!(id || jsonMatch || isCollaborationScene);
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
if (isExternalScene) {
|
|
||||||
if (
|
|
||||||
this.shouldForceLoadScene(scene) ||
|
|
||||||
window.confirm(t("alerts.loadSceneOverridePrompt"))
|
|
||||||
) {
|
|
||||||
// Backwards compatibility with legacy url format
|
|
||||||
if (id) {
|
|
||||||
scene = await loadScene(id, null, this.props.initialData);
|
|
||||||
} else if (jsonMatch) {
|
|
||||||
scene = await loadScene(
|
|
||||||
jsonMatch[1],
|
|
||||||
jsonMatch[2],
|
|
||||||
this.props.initialData,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!isCollaborationScene) {
|
|
||||||
window.history.replaceState({}, "Excalidraw", window.location.origin);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// https://github.com/excalidraw/excalidraw/issues/1919
|
|
||||||
if (document.hidden) {
|
|
||||||
window.addEventListener("focus", () => this.initializeScene(), {
|
|
||||||
once: true,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isCollaborationScene = false;
|
const scene = restore(initialData, null);
|
||||||
window.history.replaceState({}, "Excalidraw", window.location.origin);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.state.isLoading) {
|
|
||||||
this.setState({ isLoading: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCollaborationScene) {
|
|
||||||
// when joining a room we don't want user's local scene data to be merged
|
|
||||||
// into the remote scene
|
|
||||||
this.resetScene();
|
|
||||||
this.initializeSocketClient({ showLoadingState: true });
|
|
||||||
trackEvent(EVENT_SHARE, "session join");
|
|
||||||
} else if (scene) {
|
|
||||||
if (scene.appState) {
|
|
||||||
scene.appState = {
|
scene.appState = {
|
||||||
...scene.appState,
|
...scene.appState,
|
||||||
...calculateScrollCenter(
|
...calculateScrollCenter(
|
||||||
@ -679,16 +579,18 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
),
|
),
|
||||||
|
isLoading: false,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
this.resetHistory();
|
this.resetHistory();
|
||||||
this.syncActionResult({
|
this.syncActionResult({
|
||||||
...scene,
|
...scene,
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const addToLibraryUrl = searchParams.get("addLibrary");
|
const addToLibraryUrl = new URLSearchParams(window.location.search).get(
|
||||||
|
"addLibrary",
|
||||||
|
);
|
||||||
|
|
||||||
if (addToLibraryUrl) {
|
if (addToLibraryUrl) {
|
||||||
await this.importLibraryFromUrl(addToLibraryUrl);
|
await this.importLibraryFromUrl(addToLibraryUrl);
|
||||||
@ -752,12 +654,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
this.setState({});
|
this.setState({});
|
||||||
});
|
});
|
||||||
|
|
||||||
private onHashChange = (_: HashChangeEvent) => {
|
|
||||||
if (window.location.hash.length > 1) {
|
|
||||||
this.initializeScene();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private removeEventListeners() {
|
private removeEventListeners() {
|
||||||
document.removeEventListener(EVENT.COPY, this.onCopy);
|
document.removeEventListener(EVENT.COPY, this.onCopy);
|
||||||
document.removeEventListener(EVENT.PASTE, this.pasteFromClipboard);
|
document.removeEventListener(EVENT.PASTE, this.pasteFromClipboard);
|
||||||
@ -775,7 +671,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
window.removeEventListener(EVENT.BLUR, this.onBlur, false);
|
window.removeEventListener(EVENT.BLUR, this.onBlur, false);
|
||||||
window.removeEventListener(EVENT.DRAG_OVER, this.disableEvent, false);
|
window.removeEventListener(EVENT.DRAG_OVER, this.disableEvent, false);
|
||||||
window.removeEventListener(EVENT.DROP, this.disableEvent, false);
|
window.removeEventListener(EVENT.DROP, this.disableEvent, false);
|
||||||
window.removeEventListener(EVENT.HASHCHANGE, this.onHashChange, false);
|
|
||||||
|
|
||||||
document.removeEventListener(
|
document.removeEventListener(
|
||||||
EVENT.GESTURE_START,
|
EVENT.GESTURE_START,
|
||||||
@ -792,7 +687,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
this.onGestureEnd as any,
|
this.onGestureEnd as any,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private addEventListeners() {
|
private addEventListeners() {
|
||||||
@ -811,7 +705,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
window.addEventListener(EVENT.BLUR, this.onBlur, false);
|
window.addEventListener(EVENT.BLUR, this.onBlur, false);
|
||||||
window.addEventListener(EVENT.DRAG_OVER, this.disableEvent, false);
|
window.addEventListener(EVENT.DRAG_OVER, this.disableEvent, false);
|
||||||
window.addEventListener(EVENT.DROP, this.disableEvent, false);
|
window.addEventListener(EVENT.DROP, this.disableEvent, false);
|
||||||
window.addEventListener(EVENT.HASHCHANGE, this.onHashChange, false);
|
|
||||||
|
|
||||||
// rerender text elements on font load to fix #637 && #1553
|
// rerender text elements on font load to fix #637 && #1553
|
||||||
document.fonts?.addEventListener?.("loadingdone", this.onFontLoaded);
|
document.fonts?.addEventListener?.("loadingdone", this.onFontLoaded);
|
||||||
@ -832,42 +725,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
this.onGestureEnd as any,
|
this.onGestureEnd as any,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
|
|
||||||
if (this.state.isCollaborating && this.portal.roomId) {
|
|
||||||
try {
|
|
||||||
localStorage?.setItem(
|
|
||||||
LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
|
|
||||||
JSON.stringify({
|
|
||||||
timestamp: Date.now(),
|
|
||||||
room: this.portal.roomId,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
const syncableElements = getSyncableElements(
|
|
||||||
this.scene.getElementsIncludingDeleted(),
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
this.state.isCollaborating &&
|
|
||||||
!isSavedToFirebase(this.portal, syncableElements)
|
|
||||||
) {
|
|
||||||
// this won't run in time if user decides to leave the site, but
|
|
||||||
// the purpose is to run in immediately after user decides to stay
|
|
||||||
this.saveCollabRoomToFirebase(syncableElements);
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
// NOTE: modern browsers no longer allow showing a custom message here
|
|
||||||
event.returnValue = "";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
queueBroadcastAllElements = throttle(() => {
|
|
||||||
this.portal.broadcastScene(SCENE.UPDATE, /* syncAll */ true);
|
|
||||||
}, SYNC_FULL_SCENE_INTERVAL_MS);
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps: ExcalidrawProps, prevState: AppState) {
|
componentDidUpdate(prevProps: ExcalidrawProps, prevState: AppState) {
|
||||||
if (
|
if (
|
||||||
prevProps.width !== this.props.width ||
|
prevProps.width !== this.props.width ||
|
||||||
@ -878,8 +737,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
prevProps.offsetTop !== this.props.offsetTop)
|
prevProps.offsetTop !== this.props.offsetTop)
|
||||||
) {
|
) {
|
||||||
this.setState({
|
this.setState({
|
||||||
width: this.props.width,
|
width: this.props.width ?? window.innerWidth,
|
||||||
height: this.props.height,
|
height: this.props.height ?? window.innerHeight,
|
||||||
...this.getCanvasOffsets(this.props),
|
...this.getCanvasOffsets(this.props),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -990,19 +849,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
this.setState({ scrolledOutside });
|
this.setState({ scrolledOutside });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
getSceneVersion(this.scene.getElementsIncludingDeleted()) >
|
|
||||||
this.lastBroadcastedOrReceivedSceneVersion
|
|
||||||
) {
|
|
||||||
this.portal.broadcastScene(SCENE.UPDATE, /* syncAll */ false);
|
|
||||||
this.queueBroadcastAllElements();
|
|
||||||
}
|
|
||||||
|
|
||||||
history.record(this.state, this.scene.getElementsIncludingDeleted());
|
history.record(this.state, this.scene.getElementsIncludingDeleted());
|
||||||
|
|
||||||
if (this.props.onChange) {
|
this.props.onChange?.(this.scene.getElementsIncludingDeleted(), this.state);
|
||||||
this.props.onChange(this.scene.getElementsIncludingDeleted(), this.state);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy/paste
|
// Copy/paste
|
||||||
@ -1254,31 +1103,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
gesture.pointers.delete(event.pointerId);
|
gesture.pointers.delete(event.pointerId);
|
||||||
};
|
};
|
||||||
|
|
||||||
openPortal = async () => {
|
|
||||||
window.history.pushState(
|
|
||||||
{},
|
|
||||||
"Excalidraw",
|
|
||||||
await generateCollaborationLink(),
|
|
||||||
);
|
|
||||||
// remove deleted elements from elements array & history to ensure we don't
|
|
||||||
// expose potentially sensitive user data in case user manually deletes
|
|
||||||
// existing elements (or clears scene), which would otherwise be persisted
|
|
||||||
// to database even if deleted before creating the room.
|
|
||||||
history.clear();
|
|
||||||
history.resumeRecording();
|
|
||||||
this.scene.replaceAllElements(this.scene.getElements());
|
|
||||||
|
|
||||||
await this.initializeSocketClient({ showLoadingState: false });
|
|
||||||
trackEvent(EVENT_SHARE, "session start");
|
|
||||||
};
|
|
||||||
|
|
||||||
closePortal = () => {
|
|
||||||
this.saveCollabRoomToFirebase();
|
|
||||||
window.history.pushState({}, "Excalidraw", window.location.origin);
|
|
||||||
this.destroySocketClient();
|
|
||||||
trackEvent(EVENT_SHARE, "session end");
|
|
||||||
};
|
|
||||||
|
|
||||||
toggleLock = () => {
|
toggleLock = () => {
|
||||||
this.setState((prevState) => {
|
this.setState((prevState) => {
|
||||||
trackEvent(EVENT_SHAPE, "lock", !prevState.elementLocked ? "on" : "off");
|
trackEvent(EVENT_SHAPE, "lock", !prevState.elementLocked ? "on" : "off");
|
||||||
@ -1313,54 +1137,11 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
private handleRemoteSceneUpdate = (
|
public updateScene = withBatchedUpdates((sceneData: SceneData) => {
|
||||||
elements: readonly ExcalidrawElement[],
|
if (sceneData.commitToHistory) {
|
||||||
{
|
|
||||||
init = false,
|
|
||||||
initFromSnapshot = false,
|
|
||||||
}: { init?: boolean; initFromSnapshot?: boolean } = {},
|
|
||||||
) => {
|
|
||||||
if (init) {
|
|
||||||
history.resumeRecording();
|
history.resumeRecording();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (init || initFromSnapshot) {
|
|
||||||
this.setScrollToCenter(elements);
|
|
||||||
}
|
|
||||||
const newElements = this.portal.reconcileElements(elements);
|
|
||||||
|
|
||||||
// Avoid broadcasting to the rest of the collaborators the scene
|
|
||||||
// we just received!
|
|
||||||
// Note: this needs to be set before updating the scene as it
|
|
||||||
// syncronously calls render.
|
|
||||||
this.setLastBroadcastedOrReceivedSceneVersion(getSceneVersion(newElements));
|
|
||||||
|
|
||||||
this.updateScene({ elements: newElements });
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
this.resetHistory();
|
|
||||||
|
|
||||||
if (!this.portal.socketInitialized && !initFromSnapshot) {
|
|
||||||
this.initializeSocket();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private destroySocketClient = () => {
|
|
||||||
this.setState({
|
|
||||||
isCollaborating: false,
|
|
||||||
collaborators: new Map(),
|
|
||||||
});
|
|
||||||
this.portal.close();
|
|
||||||
};
|
|
||||||
|
|
||||||
public updateScene = withBatchedUpdates(
|
|
||||||
(sceneData: {
|
|
||||||
elements: readonly ExcalidrawElement[];
|
|
||||||
appState?: AppState;
|
|
||||||
}) => {
|
|
||||||
// currently we only support syncing background color
|
// currently we only support syncing background color
|
||||||
if (sceneData.appState?.viewBackgroundColor) {
|
if (sceneData.appState?.viewBackgroundColor) {
|
||||||
this.setState({
|
this.setState({
|
||||||
@ -1368,147 +1149,14 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sceneData.elements) {
|
||||||
this.scene.replaceAllElements(sceneData.elements);
|
this.scene.replaceAllElements(sceneData.elements);
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
private initializeSocket = () => {
|
|
||||||
this.portal.socketInitialized = true;
|
|
||||||
clearTimeout(this.socketInitializationTimer);
|
|
||||||
if (this.state.isLoading && !this.unmounted) {
|
|
||||||
this.setState({ isLoading: false });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private initializeSocketClient = async (opts: {
|
|
||||||
showLoadingState: boolean;
|
|
||||||
}) => {
|
|
||||||
if (this.portal.socket) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const roomMatch = getCollaborationLinkData(window.location.href);
|
if (sceneData.collaborators) {
|
||||||
if (roomMatch) {
|
this.setState({ collaborators: sceneData.collaborators });
|
||||||
const roomId = roomMatch[1];
|
|
||||||
const roomKey = roomMatch[2];
|
|
||||||
|
|
||||||
// fallback in case you're not alone in the room but still don't receive
|
|
||||||
// initial SCENE_UPDATE message
|
|
||||||
this.socketInitializationTimer = setTimeout(
|
|
||||||
this.initializeSocket,
|
|
||||||
INITIAL_SCENE_UPDATE_TIMEOUT,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { default: socketIOClient }: any = await import("socket.io-client");
|
|
||||||
|
|
||||||
this.portal.open(socketIOClient(SOCKET_SERVER), roomId, roomKey);
|
|
||||||
|
|
||||||
// All socket listeners are moving to Portal
|
|
||||||
this.portal.socket!.on(
|
|
||||||
"client-broadcast",
|
|
||||||
async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
|
|
||||||
if (!this.portal.roomKey) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const decryptedData = await decryptAESGEM(
|
|
||||||
encryptedData,
|
|
||||||
this.portal.roomKey,
|
|
||||||
iv,
|
|
||||||
);
|
|
||||||
|
|
||||||
switch (decryptedData.type) {
|
|
||||||
case "INVALID_RESPONSE":
|
|
||||||
return;
|
|
||||||
case SCENE.INIT: {
|
|
||||||
if (!this.portal.socketInitialized) {
|
|
||||||
const remoteElements = decryptedData.payload.elements;
|
|
||||||
this.handleRemoteSceneUpdate(remoteElements, { init: true });
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case SCENE.UPDATE:
|
|
||||||
this.handleRemoteSceneUpdate(decryptedData.payload.elements);
|
|
||||||
break;
|
|
||||||
case "MOUSE_LOCATION": {
|
|
||||||
const {
|
|
||||||
socketId,
|
|
||||||
pointer,
|
|
||||||
button,
|
|
||||||
username,
|
|
||||||
selectedElementIds,
|
|
||||||
} = decryptedData.payload;
|
|
||||||
// NOTE purposefully mutating collaborators map in case of
|
|
||||||
// pointer updates so as not to trigger LayerUI rerender
|
|
||||||
this.setState((state) => {
|
|
||||||
if (!state.collaborators.has(socketId)) {
|
|
||||||
state.collaborators.set(socketId, {});
|
|
||||||
}
|
|
||||||
const user = state.collaborators.get(socketId)!;
|
|
||||||
user.pointer = pointer;
|
|
||||||
user.button = button;
|
|
||||||
user.selectedElementIds = selectedElementIds;
|
|
||||||
user.username = username;
|
|
||||||
state.collaborators.set(socketId, user);
|
|
||||||
return state;
|
|
||||||
});
|
});
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
this.portal.socket!.on("first-in-room", () => {
|
|
||||||
if (this.portal.socket) {
|
|
||||||
this.portal.socket.off("first-in-room");
|
|
||||||
}
|
|
||||||
this.initializeSocket();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
isCollaborating: true,
|
|
||||||
isLoading: opts.showLoadingState ? true : this.state.isLoading,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const elements = await loadFromFirebase(roomId, roomKey);
|
|
||||||
if (elements) {
|
|
||||||
this.handleRemoteSceneUpdate(elements, { initFromSnapshot: true });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// log the error and move on. other peers will sync us the scene.
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
saveCollabRoomToFirebase = async (
|
|
||||||
syncableElements: ExcalidrawElement[] = getSyncableElements(
|
|
||||||
this.scene.getElementsIncludingDeleted(),
|
|
||||||
),
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
await saveToFirebase(this.portal, syncableElements);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private onSceneUpdated = () => {
|
private onSceneUpdated = () => {
|
||||||
this.setState({});
|
this.setState({});
|
||||||
@ -3989,14 +3637,12 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
|
|
||||||
if (isNaN(pointer.x) || isNaN(pointer.y)) {
|
if (isNaN(pointer.x) || isNaN(pointer.y)) {
|
||||||
// sometimes the pointer goes off screen
|
// sometimes the pointer goes off screen
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
this.portal.socket &&
|
|
||||||
// do not broadcast when more than 1 pointer since that shows flickering on the other side
|
this.props.onPointerUpdate?.({
|
||||||
gesture.pointers.size < 2 &&
|
|
||||||
this.portal.broadcastMouseLocation({
|
|
||||||
pointer,
|
pointer,
|
||||||
button,
|
button,
|
||||||
|
pointersMap: gesture.pointers,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -4017,8 +3663,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
offsetTop: offsets.offsetTop,
|
offsetTop: offsets.offsetTop,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (this.excalidrawRef?.current) {
|
if (this.excalidrawContainerRef?.current?.parentElement) {
|
||||||
const parentElement = this.excalidrawRef.current.parentElement;
|
const parentElement = this.excalidrawContainerRef.current.parentElement;
|
||||||
const { left, top } = parentElement.getBoundingClientRect();
|
const { left, top } = parentElement.getBoundingClientRect();
|
||||||
return {
|
return {
|
||||||
offsetLeft:
|
offsetLeft:
|
||||||
@ -4048,6 +3694,9 @@ declare global {
|
|||||||
history: SceneHistory;
|
history: SceneHistory;
|
||||||
app: InstanceType<typeof App>;
|
app: InstanceType<typeof App>;
|
||||||
library: typeof Library;
|
library: typeof Library;
|
||||||
|
collab: InstanceType<
|
||||||
|
typeof import("../excalidraw-app/collab/CollabWrapper").default
|
||||||
|
>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -4056,10 +3705,11 @@ if (
|
|||||||
process.env.NODE_ENV === ENV.TEST ||
|
process.env.NODE_ENV === ENV.TEST ||
|
||||||
process.env.NODE_ENV === ENV.DEVELOPMENT
|
process.env.NODE_ENV === ENV.DEVELOPMENT
|
||||||
) {
|
) {
|
||||||
window.h = {} as Window["h"];
|
window.h = window.h || ({} as Window["h"]);
|
||||||
|
|
||||||
Object.defineProperties(window.h, {
|
Object.defineProperties(window.h, {
|
||||||
elements: {
|
elements: {
|
||||||
|
configurable: true,
|
||||||
get() {
|
get() {
|
||||||
return this.app.scene.getElementsIncludingDeleted();
|
return this.app.scene.getElementsIncludingDeleted();
|
||||||
},
|
},
|
||||||
@ -4068,9 +3718,11 @@ if (
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
history: {
|
history: {
|
||||||
|
configurable: true,
|
||||||
get: () => history,
|
get: () => history,
|
||||||
},
|
},
|
||||||
library: {
|
library: {
|
||||||
|
configurable: true,
|
||||||
value: Library,
|
value: Library,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
29
src/components/CollabButton.scss
Normal file
29
src/components/CollabButton.scss
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
@import "../css/_variables";
|
||||||
|
|
||||||
|
.excalidraw {
|
||||||
|
.CollabButton.is-collaborating {
|
||||||
|
background-color: var(--button-special-active-background-color);
|
||||||
|
|
||||||
|
.ToolIcon__icon svg {
|
||||||
|
color: var(--icon-green-fill-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.CollabButton-collaborators {
|
||||||
|
:root[dir="ltr"] & {
|
||||||
|
right: -5px;
|
||||||
|
}
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
left: -5px;
|
||||||
|
}
|
||||||
|
min-width: 1em;
|
||||||
|
position: absolute;
|
||||||
|
bottom: -5px;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: $oc-green-6;
|
||||||
|
color: $oc-white;
|
||||||
|
font-size: 0.7em;
|
||||||
|
font-family: var(--ui-font);
|
||||||
|
}
|
||||||
|
}
|
44
src/components/CollabButton.tsx
Normal file
44
src/components/CollabButton.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import React from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { ToolButton } from "./ToolButton";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import useIsMobile from "../is-mobile";
|
||||||
|
import { users } from "./icons";
|
||||||
|
|
||||||
|
import "./CollabButton.scss";
|
||||||
|
import { EVENT_DIALOG, trackEvent } from "../analytics";
|
||||||
|
|
||||||
|
const CollabButton = ({
|
||||||
|
isCollaborating,
|
||||||
|
collaboratorCount,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
isCollaborating: boolean;
|
||||||
|
collaboratorCount: number;
|
||||||
|
onClick: () => void;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ToolButton
|
||||||
|
className={clsx("CollabButton", {
|
||||||
|
"is-collaborating": isCollaborating,
|
||||||
|
})}
|
||||||
|
onClick={() => {
|
||||||
|
trackEvent(EVENT_DIALOG, "collaboration");
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
|
icon={users}
|
||||||
|
type="button"
|
||||||
|
title={t("buttons.roomDialog")}
|
||||||
|
aria-label={t("buttons.roomDialog")}
|
||||||
|
showAriaLabel={useIsMobile()}
|
||||||
|
>
|
||||||
|
{collaboratorCount > 0 && (
|
||||||
|
<div className="CollabButton-collaborators">{collaboratorCount}</div>
|
||||||
|
)}
|
||||||
|
</ToolButton>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CollabButton;
|
@ -28,7 +28,7 @@ import { ExportType } from "../scene/types";
|
|||||||
import { MobileMenu } from "./MobileMenu";
|
import { MobileMenu } from "./MobileMenu";
|
||||||
import { ZoomActions, SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
import { ZoomActions, SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||||
import { Section } from "./Section";
|
import { Section } from "./Section";
|
||||||
import { RoomDialog } from "./RoomDialog";
|
import CollabButton from "./CollabButton";
|
||||||
import { ErrorDialog } from "./ErrorDialog";
|
import { ErrorDialog } from "./ErrorDialog";
|
||||||
import { ShortcutsDialog } from "./ShortcutsDialog";
|
import { ShortcutsDialog } from "./ShortcutsDialog";
|
||||||
import { LoadingMessage } from "./LoadingMessage";
|
import { LoadingMessage } from "./LoadingMessage";
|
||||||
@ -58,14 +58,13 @@ interface LayerUIProps {
|
|||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
onRoomCreate: () => void;
|
onCollabButtonClick?: () => void;
|
||||||
onUsernameChange: (username: string) => void;
|
|
||||||
onRoomDestroy: () => void;
|
|
||||||
onLockToggle: () => void;
|
onLockToggle: () => void;
|
||||||
onInsertShape: (elements: LibraryItem) => void;
|
onInsertShape: (elements: LibraryItem) => void;
|
||||||
zenModeEnabled: boolean;
|
zenModeEnabled: boolean;
|
||||||
toggleZenMode: () => void;
|
toggleZenMode: () => void;
|
||||||
lng: string;
|
lng: string;
|
||||||
|
isCollaborating: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useOnClickOutside = (
|
const useOnClickOutside = (
|
||||||
@ -299,13 +298,12 @@ const LayerUI = ({
|
|||||||
setAppState,
|
setAppState,
|
||||||
canvas,
|
canvas,
|
||||||
elements,
|
elements,
|
||||||
onRoomCreate,
|
onCollabButtonClick,
|
||||||
onUsernameChange,
|
|
||||||
onRoomDestroy,
|
|
||||||
onLockToggle,
|
onLockToggle,
|
||||||
onInsertShape,
|
onInsertShape,
|
||||||
zenModeEnabled,
|
zenModeEnabled,
|
||||||
toggleZenMode,
|
toggleZenMode,
|
||||||
|
isCollaborating,
|
||||||
}: LayerUIProps) => {
|
}: LayerUIProps) => {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
@ -400,17 +398,13 @@ const LayerUI = ({
|
|||||||
{actionManager.renderAction("saveAsScene")}
|
{actionManager.renderAction("saveAsScene")}
|
||||||
{renderExportDialog()}
|
{renderExportDialog()}
|
||||||
{actionManager.renderAction("clearCanvas")}
|
{actionManager.renderAction("clearCanvas")}
|
||||||
<RoomDialog
|
{onCollabButtonClick && (
|
||||||
isCollaborating={appState.isCollaborating}
|
<CollabButton
|
||||||
|
isCollaborating={isCollaborating}
|
||||||
collaboratorCount={appState.collaborators.size}
|
collaboratorCount={appState.collaborators.size}
|
||||||
username={appState.username}
|
onClick={onCollabButtonClick}
|
||||||
onUsernameChange={onUsernameChange}
|
|
||||||
onRoomCreate={onRoomCreate}
|
|
||||||
onRoomDestroy={onRoomDestroy}
|
|
||||||
setErrorMessage={(message: string) =>
|
|
||||||
setAppState({ errorMessage: message })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
<BackgroundPickerAndDarkModeToggle
|
<BackgroundPickerAndDarkModeToggle
|
||||||
actionManager={actionManager}
|
actionManager={actionManager}
|
||||||
@ -602,11 +596,10 @@ const LayerUI = ({
|
|||||||
libraryMenu={libraryMenu}
|
libraryMenu={libraryMenu}
|
||||||
exportButton={renderExportDialog()}
|
exportButton={renderExportDialog()}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
onUsernameChange={onUsernameChange}
|
onCollabButtonClick={onCollabButtonClick}
|
||||||
onRoomCreate={onRoomCreate}
|
|
||||||
onRoomDestroy={onRoomDestroy}
|
|
||||||
onLockToggle={onLockToggle}
|
onLockToggle={onLockToggle}
|
||||||
canvas={canvas}
|
canvas={canvas}
|
||||||
|
isCollaborating={isCollaborating}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="layer-ui__wrapper">
|
<div className="layer-ui__wrapper">
|
||||||
|
@ -12,7 +12,7 @@ import { HintViewer } from "./HintViewer";
|
|||||||
import { calculateScrollCenter } from "../scene";
|
import { calculateScrollCenter } from "../scene";
|
||||||
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||||
import { Section } from "./Section";
|
import { Section } from "./Section";
|
||||||
import { RoomDialog } from "./RoomDialog";
|
import CollabButton from "./CollabButton";
|
||||||
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
|
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
|
||||||
import { LockIcon } from "./LockIcon";
|
import { LockIcon } from "./LockIcon";
|
||||||
import { LoadingMessage } from "./LoadingMessage";
|
import { LoadingMessage } from "./LoadingMessage";
|
||||||
@ -27,11 +27,10 @@ type MobileMenuProps = {
|
|||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
libraryMenu: JSX.Element | null;
|
libraryMenu: JSX.Element | null;
|
||||||
onRoomCreate: () => void;
|
onCollabButtonClick?: () => void;
|
||||||
onUsernameChange: (username: string) => void;
|
|
||||||
onRoomDestroy: () => void;
|
|
||||||
onLockToggle: () => void;
|
onLockToggle: () => void;
|
||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
|
isCollaborating: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MobileMenu = ({
|
export const MobileMenu = ({
|
||||||
@ -41,11 +40,10 @@ export const MobileMenu = ({
|
|||||||
actionManager,
|
actionManager,
|
||||||
exportButton,
|
exportButton,
|
||||||
setAppState,
|
setAppState,
|
||||||
onRoomCreate,
|
onCollabButtonClick,
|
||||||
onUsernameChange,
|
|
||||||
onRoomDestroy,
|
|
||||||
onLockToggle,
|
onLockToggle,
|
||||||
canvas,
|
canvas,
|
||||||
|
isCollaborating,
|
||||||
}: MobileMenuProps) => (
|
}: MobileMenuProps) => (
|
||||||
<>
|
<>
|
||||||
{appState.isLoading && <LoadingMessage />}
|
{appState.isLoading && <LoadingMessage />}
|
||||||
@ -94,17 +92,13 @@ export const MobileMenu = ({
|
|||||||
{actionManager.renderAction("saveAsScene")}
|
{actionManager.renderAction("saveAsScene")}
|
||||||
{exportButton}
|
{exportButton}
|
||||||
{actionManager.renderAction("clearCanvas")}
|
{actionManager.renderAction("clearCanvas")}
|
||||||
<RoomDialog
|
{onCollabButtonClick && (
|
||||||
isCollaborating={appState.isCollaborating}
|
<CollabButton
|
||||||
|
isCollaborating={isCollaborating}
|
||||||
collaboratorCount={appState.collaborators.size}
|
collaboratorCount={appState.collaborators.size}
|
||||||
username={appState.username}
|
onClick={onCollabButtonClick}
|
||||||
onUsernameChange={onUsernameChange}
|
|
||||||
onRoomCreate={onRoomCreate}
|
|
||||||
onRoomDestroy={onRoomDestroy}
|
|
||||||
setErrorMessage={(message: string) =>
|
|
||||||
setAppState({ errorMessage: message })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<BackgroundPickerAndDarkModeToggle
|
<BackgroundPickerAndDarkModeToggle
|
||||||
actionManager={actionManager}
|
actionManager={actionManager}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
|
@ -1,202 +0,0 @@
|
|||||||
import clsx from "clsx";
|
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
|
||||||
import { EVENT_DIALOG, EVENT_SHARE, trackEvent } from "../analytics";
|
|
||||||
import { copyTextToSystemClipboard } from "../clipboard";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
import useIsMobile from "../is-mobile";
|
|
||||||
import { KEYS } from "../keys";
|
|
||||||
import { AppState } from "../types";
|
|
||||||
import { Dialog } from "./Dialog";
|
|
||||||
import { clipboard, start, stop, users } from "./icons";
|
|
||||||
import "./RoomDialog.scss";
|
|
||||||
import { ToolButton } from "./ToolButton";
|
|
||||||
|
|
||||||
const RoomModal = ({
|
|
||||||
activeRoomLink,
|
|
||||||
username,
|
|
||||||
onUsernameChange,
|
|
||||||
onRoomCreate,
|
|
||||||
onRoomDestroy,
|
|
||||||
onPressingEnter,
|
|
||||||
setErrorMessage,
|
|
||||||
}: {
|
|
||||||
activeRoomLink: string;
|
|
||||||
username: string;
|
|
||||||
onUsernameChange: (username: string) => void;
|
|
||||||
onRoomCreate: () => void;
|
|
||||||
onRoomDestroy: () => void;
|
|
||||||
onPressingEnter: () => void;
|
|
||||||
setErrorMessage: (message: string) => void;
|
|
||||||
}) => {
|
|
||||||
const roomLinkInput = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const copyRoomLink = async () => {
|
|
||||||
try {
|
|
||||||
await copyTextToSystemClipboard(activeRoomLink);
|
|
||||||
trackEvent(EVENT_SHARE, "copy link");
|
|
||||||
} catch (error) {
|
|
||||||
setErrorMessage(error.message);
|
|
||||||
}
|
|
||||||
if (roomLinkInput.current) {
|
|
||||||
roomLinkInput.current.select();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const selectInput = (event: React.MouseEvent<HTMLInputElement>) => {
|
|
||||||
if (event.target !== document.activeElement) {
|
|
||||||
event.preventDefault();
|
|
||||||
(event.target as HTMLInputElement).select();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="RoomDialog-modal">
|
|
||||||
{!activeRoomLink && (
|
|
||||||
<>
|
|
||||||
<p>{t("roomDialog.desc_intro")}</p>
|
|
||||||
<p>{`🔒 ${t("roomDialog.desc_privacy")}`}</p>
|
|
||||||
<div className="RoomDialog-sessionStartButtonContainer">
|
|
||||||
<ToolButton
|
|
||||||
className="RoomDialog-startSession"
|
|
||||||
type="button"
|
|
||||||
icon={start}
|
|
||||||
title={t("roomDialog.button_startSession")}
|
|
||||||
aria-label={t("roomDialog.button_startSession")}
|
|
||||||
showAriaLabel={true}
|
|
||||||
onClick={onRoomCreate}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{activeRoomLink && (
|
|
||||||
<>
|
|
||||||
<p>{t("roomDialog.desc_inProgressIntro")}</p>
|
|
||||||
<p>{t("roomDialog.desc_shareLink")}</p>
|
|
||||||
<div className="RoomDialog-linkContainer">
|
|
||||||
<ToolButton
|
|
||||||
type="button"
|
|
||||||
icon={clipboard}
|
|
||||||
title={t("labels.copy")}
|
|
||||||
aria-label={t("labels.copy")}
|
|
||||||
onClick={copyRoomLink}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
value={activeRoomLink}
|
|
||||||
readOnly={true}
|
|
||||||
className="RoomDialog-link"
|
|
||||||
ref={roomLinkInput}
|
|
||||||
onPointerDown={selectInput}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="RoomDialog-usernameContainer">
|
|
||||||
<label className="RoomDialog-usernameLabel" htmlFor="username">
|
|
||||||
{t("labels.yourName")}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="username"
|
|
||||||
value={username || ""}
|
|
||||||
className="RoomDialog-username TextInput"
|
|
||||||
onChange={(event) => onUsernameChange(event.target.value)}
|
|
||||||
onBlur={() => trackEvent(EVENT_SHARE, "name")}
|
|
||||||
onKeyPress={(event) =>
|
|
||||||
event.key === KEYS.ENTER && onPressingEnter()
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p>
|
|
||||||
<span role="img" aria-hidden="true" className="RoomDialog-emoji">
|
|
||||||
{"🔒"}
|
|
||||||
</span>{" "}
|
|
||||||
{t("roomDialog.desc_privacy")}
|
|
||||||
</p>
|
|
||||||
<p>{t("roomDialog.desc_exitSession")}</p>
|
|
||||||
<div className="RoomDialog-sessionStartButtonContainer">
|
|
||||||
<ToolButton
|
|
||||||
className="RoomDialog-stopSession"
|
|
||||||
type="button"
|
|
||||||
icon={stop}
|
|
||||||
title={t("roomDialog.button_stopSession")}
|
|
||||||
aria-label={t("roomDialog.button_stopSession")}
|
|
||||||
showAriaLabel={true}
|
|
||||||
onClick={onRoomDestroy}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RoomDialog = ({
|
|
||||||
isCollaborating,
|
|
||||||
collaboratorCount,
|
|
||||||
username,
|
|
||||||
onUsernameChange,
|
|
||||||
onRoomCreate,
|
|
||||||
onRoomDestroy,
|
|
||||||
setErrorMessage,
|
|
||||||
}: {
|
|
||||||
isCollaborating: AppState["isCollaborating"];
|
|
||||||
collaboratorCount: number;
|
|
||||||
username: string;
|
|
||||||
onUsernameChange: (username: string) => void;
|
|
||||||
onRoomCreate: () => void;
|
|
||||||
onRoomDestroy: () => void;
|
|
||||||
setErrorMessage: (message: string) => void;
|
|
||||||
}) => {
|
|
||||||
const [modalIsShown, setModalIsShown] = useState(false);
|
|
||||||
const [activeRoomLink, setActiveRoomLink] = useState("");
|
|
||||||
|
|
||||||
const triggerButton = useRef<HTMLButtonElement>(null);
|
|
||||||
|
|
||||||
const handleClose = React.useCallback(() => {
|
|
||||||
setModalIsShown(false);
|
|
||||||
triggerButton.current?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setActiveRoomLink(isCollaborating ? window.location.href : "");
|
|
||||||
}, [isCollaborating]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ToolButton
|
|
||||||
className={clsx("RoomDialog-modalButton", {
|
|
||||||
"is-collaborating": isCollaborating,
|
|
||||||
})}
|
|
||||||
onClick={() => {
|
|
||||||
trackEvent(EVENT_DIALOG, "collaboration");
|
|
||||||
setModalIsShown(true);
|
|
||||||
}}
|
|
||||||
icon={users}
|
|
||||||
type="button"
|
|
||||||
title={t("buttons.roomDialog")}
|
|
||||||
aria-label={t("buttons.roomDialog")}
|
|
||||||
showAriaLabel={useIsMobile()}
|
|
||||||
ref={triggerButton}
|
|
||||||
>
|
|
||||||
{collaboratorCount > 0 && (
|
|
||||||
<div className="RoomDialog-modalButton-collaborators">
|
|
||||||
{collaboratorCount}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ToolButton>
|
|
||||||
{modalIsShown && (
|
|
||||||
<Dialog
|
|
||||||
maxWidth={800}
|
|
||||||
onCloseRequest={handleClose}
|
|
||||||
title={t("labels.createRoom")}
|
|
||||||
>
|
|
||||||
<RoomModal
|
|
||||||
activeRoomLink={activeRoomLink}
|
|
||||||
username={username}
|
|
||||||
onUsernameChange={onUsernameChange}
|
|
||||||
onRoomCreate={onRoomCreate}
|
|
||||||
onRoomDestroy={onRoomDestroy}
|
|
||||||
onPressingEnter={handleClose}
|
|
||||||
setErrorMessage={setErrorMessage}
|
|
||||||
/>
|
|
||||||
</Dialog>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@ -21,11 +21,6 @@ export const POINTER_BUTTON = {
|
|||||||
TOUCH: -1,
|
TOUCH: -1,
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum SCENE {
|
|
||||||
INIT = "SCENE_INIT",
|
|
||||||
UPDATE = "SCENE_UPDATE",
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum EVENT {
|
export enum EVENT {
|
||||||
COPY = "copy",
|
COPY = "copy",
|
||||||
PASTE = "paste",
|
PASTE = "paste",
|
||||||
@ -56,11 +51,6 @@ export const ENV = {
|
|||||||
DEVELOPMENT: "development",
|
DEVELOPMENT: "development",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BROADCAST = {
|
|
||||||
SERVER_VOLATILE: "server-volatile-broadcast",
|
|
||||||
SERVER: "server-broadcast",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CLASSES = {
|
export const CLASSES = {
|
||||||
SHAPE_ACTIONS_MENU: "App-menu__left",
|
SHAPE_ACTIONS_MENU: "App-menu__left",
|
||||||
};
|
};
|
||||||
@ -83,16 +73,15 @@ export const CANVAS_ONLY_ACTIONS = ["selectAll"];
|
|||||||
|
|
||||||
export const GRID_SIZE = 20; // TODO make it configurable?
|
export const GRID_SIZE = 20; // TODO make it configurable?
|
||||||
|
|
||||||
export const LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG = "collabLinkForceLoadFlag";
|
|
||||||
|
|
||||||
export const MIME_TYPES = {
|
export const MIME_TYPES = {
|
||||||
excalidraw: "application/vnd.excalidraw+json",
|
excalidraw: "application/vnd.excalidraw+json",
|
||||||
excalidrawlib: "application/vnd.excalidrawlib+json",
|
excalidrawlib: "application/vnd.excalidrawlib+json",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const STORAGE_KEYS = {
|
export const STORAGE_KEYS = {
|
||||||
LOCAL_STORAGE_ELEMENTS: "excalidraw",
|
|
||||||
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
|
|
||||||
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
|
|
||||||
LOCAL_STORAGE_LIBRARY: "excalidraw-library",
|
LOCAL_STORAGE_LIBRARY: "excalidraw-library",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// time in milliseconds
|
||||||
|
export const TAP_TWICE_TIMEOUT = 300;
|
||||||
|
export const TOUCH_CTX_MENU_TIMEOUT = 500;
|
||||||
|
@ -12,162 +12,14 @@ import {
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { exportToCanvas, exportToSvg } from "../scene/export";
|
import { exportToCanvas, exportToSvg } from "../scene/export";
|
||||||
import { ExportType } from "../scene/types";
|
import { ExportType } from "../scene/types";
|
||||||
import { AppState } from "../types";
|
|
||||||
import { canvasToBlob } from "./blob";
|
import { canvasToBlob } from "./blob";
|
||||||
|
import { AppState } from "../types";
|
||||||
import { serializeAsJSON } from "./json";
|
import { serializeAsJSON } from "./json";
|
||||||
import { restore } from "./restore";
|
|
||||||
import { ImportedDataState } from "./types";
|
|
||||||
|
|
||||||
export { loadFromBlob } from "./blob";
|
export { loadFromBlob } from "./blob";
|
||||||
export { loadFromJSON, saveAsJSON } from "./json";
|
export { loadFromJSON, saveAsJSON } from "./json";
|
||||||
|
|
||||||
const BACKEND_GET = process.env.REACT_APP_BACKEND_V1_GET_URL;
|
|
||||||
|
|
||||||
const BACKEND_V2_POST = process.env.REACT_APP_BACKEND_V2_POST_URL;
|
const BACKEND_V2_POST = process.env.REACT_APP_BACKEND_V2_POST_URL;
|
||||||
const BACKEND_V2_GET = process.env.REACT_APP_BACKEND_V2_GET_URL;
|
|
||||||
|
|
||||||
export const SOCKET_SERVER = process.env.REACT_APP_SOCKET_SERVER_URL;
|
|
||||||
|
|
||||||
export type EncryptedData = {
|
|
||||||
data: ArrayBuffer;
|
|
||||||
iv: Uint8Array;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SocketUpdateDataSource = {
|
|
||||||
SCENE_INIT: {
|
|
||||||
type: "SCENE_INIT";
|
|
||||||
payload: {
|
|
||||||
elements: readonly ExcalidrawElement[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
SCENE_UPDATE: {
|
|
||||||
type: "SCENE_UPDATE";
|
|
||||||
payload: {
|
|
||||||
elements: readonly ExcalidrawElement[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
MOUSE_LOCATION: {
|
|
||||||
type: "MOUSE_LOCATION";
|
|
||||||
payload: {
|
|
||||||
socketId: string;
|
|
||||||
pointer: { x: number; y: number };
|
|
||||||
button: "down" | "up";
|
|
||||||
selectedElementIds: AppState["selectedElementIds"];
|
|
||||||
username: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SocketUpdateDataIncoming =
|
|
||||||
| SocketUpdateDataSource[keyof SocketUpdateDataSource]
|
|
||||||
| {
|
|
||||||
type: "INVALID_RESPONSE";
|
|
||||||
};
|
|
||||||
|
|
||||||
const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2);
|
|
||||||
|
|
||||||
const generateRandomID = async () => {
|
|
||||||
const arr = new Uint8Array(10);
|
|
||||||
window.crypto.getRandomValues(arr);
|
|
||||||
return Array.from(arr, byteToHex).join("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateEncryptionKey = async () => {
|
|
||||||
const key = await window.crypto.subtle.generateKey(
|
|
||||||
{
|
|
||||||
name: "AES-GCM",
|
|
||||||
length: 128,
|
|
||||||
},
|
|
||||||
true, // extractable
|
|
||||||
["encrypt", "decrypt"],
|
|
||||||
);
|
|
||||||
return (await window.crypto.subtle.exportKey("jwk", key)).k;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createIV = () => {
|
|
||||||
const arr = new Uint8Array(12);
|
|
||||||
return window.crypto.getRandomValues(arr);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getCollaborationLinkData = (link: string) => {
|
|
||||||
if (link.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const hash = new URL(link).hash;
|
|
||||||
return hash.match(/^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const generateCollaborationLink = async () => {
|
|
||||||
const id = await generateRandomID();
|
|
||||||
const key = await generateEncryptionKey();
|
|
||||||
return `${window.location.origin}${window.location.pathname}#room=${id},${key}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getImportedKey = (key: string, usage: KeyUsage) =>
|
|
||||||
window.crypto.subtle.importKey(
|
|
||||||
"jwk",
|
|
||||||
{
|
|
||||||
alg: "A128GCM",
|
|
||||||
ext: true,
|
|
||||||
k: key,
|
|
||||||
key_ops: ["encrypt", "decrypt"],
|
|
||||||
kty: "oct",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "AES-GCM",
|
|
||||||
length: 128,
|
|
||||||
},
|
|
||||||
false, // extractable
|
|
||||||
[usage],
|
|
||||||
);
|
|
||||||
|
|
||||||
export const encryptAESGEM = async (
|
|
||||||
data: Uint8Array,
|
|
||||||
key: string,
|
|
||||||
): Promise<EncryptedData> => {
|
|
||||||
const importedKey = await getImportedKey(key, "encrypt");
|
|
||||||
const iv = createIV();
|
|
||||||
return {
|
|
||||||
data: await window.crypto.subtle.encrypt(
|
|
||||||
{
|
|
||||||
name: "AES-GCM",
|
|
||||||
iv,
|
|
||||||
},
|
|
||||||
importedKey,
|
|
||||||
data,
|
|
||||||
),
|
|
||||||
iv,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const decryptAESGEM = async (
|
|
||||||
data: ArrayBuffer,
|
|
||||||
key: string,
|
|
||||||
iv: Uint8Array,
|
|
||||||
): Promise<SocketUpdateDataIncoming> => {
|
|
||||||
try {
|
|
||||||
const importedKey = await getImportedKey(key, "decrypt");
|
|
||||||
const decrypted = await window.crypto.subtle.decrypt(
|
|
||||||
{
|
|
||||||
name: "AES-GCM",
|
|
||||||
iv,
|
|
||||||
},
|
|
||||||
importedKey,
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
|
|
||||||
const decodedData = new TextDecoder("utf-8").decode(
|
|
||||||
new Uint8Array(decrypted) as any,
|
|
||||||
);
|
|
||||||
return JSON.parse(decodedData);
|
|
||||||
} catch (error) {
|
|
||||||
window.alert(t("alerts.decryptFailed"));
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
type: "INVALID_RESPONSE",
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const exportToBackend = async (
|
export const exportToBackend = async (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@ -226,53 +78,6 @@ export const exportToBackend = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const importFromBackend = async (
|
|
||||||
id: string | null,
|
|
||||||
privateKey?: string | null,
|
|
||||||
): Promise<ImportedDataState> => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
privateKey ? `${BACKEND_V2_GET}${id}` : `${BACKEND_GET}${id}.json`,
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
window.alert(t("alerts.importBackendFailed"));
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
let data: ImportedDataState;
|
|
||||||
if (privateKey) {
|
|
||||||
const buffer = await response.arrayBuffer();
|
|
||||||
const key = await getImportedKey(privateKey, "decrypt");
|
|
||||||
const iv = new Uint8Array(12);
|
|
||||||
const decrypted = await window.crypto.subtle.decrypt(
|
|
||||||
{
|
|
||||||
name: "AES-GCM",
|
|
||||||
iv,
|
|
||||||
},
|
|
||||||
key,
|
|
||||||
buffer,
|
|
||||||
);
|
|
||||||
// We need to convert the decrypted array buffer to a string
|
|
||||||
const string = new window.TextDecoder("utf-8").decode(
|
|
||||||
new Uint8Array(decrypted) as any,
|
|
||||||
);
|
|
||||||
data = JSON.parse(string);
|
|
||||||
} else {
|
|
||||||
// Legacy format
|
|
||||||
data = await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
trackEvent(EVENT_IO, "import");
|
|
||||||
return {
|
|
||||||
elements: data.elements || null,
|
|
||||||
appState: data.appState || null,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
window.alert(t("alerts.importBackendFailed"));
|
|
||||||
console.error(error);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const exportCanvas = async (
|
export const exportCanvas = async (
|
||||||
type: ExportType,
|
type: ExportType,
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
@ -378,30 +183,3 @@ export const exportCanvas = async (
|
|||||||
tempCanvas.remove();
|
tempCanvas.remove();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadScene = async (
|
|
||||||
id: string | null,
|
|
||||||
privateKey: string | null,
|
|
||||||
// Supply initialData even if importing from backend to ensure we restore
|
|
||||||
// localStorage user settings which we do not persist on server.
|
|
||||||
// Non-optional so we don't forget to pass it even if `undefined`.
|
|
||||||
initialData: ImportedDataState | undefined | null,
|
|
||||||
) => {
|
|
||||||
let data;
|
|
||||||
if (id != null) {
|
|
||||||
// the private key is used to decrypt the content from the server, take
|
|
||||||
// extra care not to leak it
|
|
||||||
data = restore(
|
|
||||||
await importFromBackend(id, privateKey),
|
|
||||||
initialData?.appState,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
data = restore(initialData || {}, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
elements: data.elements,
|
|
||||||
appState: data.appState,
|
|
||||||
commitToHistory: false,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
@ -173,7 +173,7 @@ const restoreAppState = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const restore = (
|
export const restore = (
|
||||||
data: ImportedDataState,
|
data: ImportedDataState | null,
|
||||||
/**
|
/**
|
||||||
* Local AppState (`this.state` or initial state from localStorage) so that we
|
* Local AppState (`this.state` or initial state from localStorage) so that we
|
||||||
* don't overwrite local state with default values (when values not
|
* don't overwrite local state with default values (when values not
|
||||||
@ -183,7 +183,7 @@ export const restore = (
|
|||||||
localAppState: Partial<AppState> | null | undefined,
|
localAppState: Partial<AppState> | null | undefined,
|
||||||
): DataState => {
|
): DataState => {
|
||||||
return {
|
return {
|
||||||
elements: restoreElements(data.elements),
|
elements: restoreElements(data?.elements),
|
||||||
appState: restoreAppState(data.appState, localAppState || null),
|
appState: restoreAppState(data?.appState, localAppState || null),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
14
src/excalidraw-app/app_constants.ts
Normal file
14
src/excalidraw-app/app_constants.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
// time constants (ms)
|
||||||
|
export const SAVE_TO_LOCAL_STORAGE_TIMEOUT = 300;
|
||||||
|
export const INITIAL_SCENE_UPDATE_TIMEOUT = 5000;
|
||||||
|
export const SYNC_FULL_SCENE_INTERVAL_MS = 20000;
|
||||||
|
|
||||||
|
export const BROADCAST = {
|
||||||
|
SERVER_VOLATILE: "server-volatile-broadcast",
|
||||||
|
SERVER: "server-broadcast",
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum SCENE {
|
||||||
|
INIT = "SCENE_INIT",
|
||||||
|
UPDATE = "SCENE_UPDATE",
|
||||||
|
}
|
476
src/excalidraw-app/collab/CollabWrapper.tsx
Normal file
476
src/excalidraw-app/collab/CollabWrapper.tsx
Normal file
@ -0,0 +1,476 @@
|
|||||||
|
import React, { PureComponent } from "react";
|
||||||
|
import throttle from "lodash.throttle";
|
||||||
|
|
||||||
|
import { ENV, EVENT } from "../../constants";
|
||||||
|
|
||||||
|
import {
|
||||||
|
decryptAESGEM,
|
||||||
|
SocketUpdateDataSource,
|
||||||
|
getCollaborationLinkData,
|
||||||
|
generateCollaborationLink,
|
||||||
|
SOCKET_SERVER,
|
||||||
|
} from "../data";
|
||||||
|
import { isSavedToFirebase, saveToFirebase } from "../data/firebase";
|
||||||
|
|
||||||
|
import Portal from "./Portal";
|
||||||
|
import { AppState, Collaborator, Gesture } from "../../types";
|
||||||
|
import { ExcalidrawElement } from "../../element/types";
|
||||||
|
import {
|
||||||
|
importUsernameFromLocalStorage,
|
||||||
|
saveUsernameToLocalStorage,
|
||||||
|
STORAGE_KEYS,
|
||||||
|
} from "../data/localStorage";
|
||||||
|
import { resolvablePromise, withBatchedUpdates } from "../../utils";
|
||||||
|
import {
|
||||||
|
getSceneVersion,
|
||||||
|
getSyncableElements,
|
||||||
|
} from "../../packages/excalidraw/index";
|
||||||
|
import RoomDialog from "./RoomDialog";
|
||||||
|
import { ErrorDialog } from "../../components/ErrorDialog";
|
||||||
|
import { ImportedDataState } from "../../data/types";
|
||||||
|
import { ExcalidrawImperativeAPI } from "../../components/App";
|
||||||
|
import {
|
||||||
|
INITIAL_SCENE_UPDATE_TIMEOUT,
|
||||||
|
SCENE,
|
||||||
|
SYNC_FULL_SCENE_INTERVAL_MS,
|
||||||
|
} from "../app_constants";
|
||||||
|
import { EVENT_SHARE, trackEvent } from "../../analytics";
|
||||||
|
|
||||||
|
interface CollabState {
|
||||||
|
isCollaborating: boolean;
|
||||||
|
modalIsShown: boolean;
|
||||||
|
errorMessage: string;
|
||||||
|
username: string;
|
||||||
|
activeRoomLink: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CollabInstance = InstanceType<typeof CollabWrapper>;
|
||||||
|
|
||||||
|
export interface CollabAPI {
|
||||||
|
isCollaborating: CollabState["isCollaborating"];
|
||||||
|
username: CollabState["username"];
|
||||||
|
onPointerUpdate: CollabInstance["onPointerUpdate"];
|
||||||
|
initializeSocketClient: CollabInstance["initializeSocketClient"];
|
||||||
|
onCollabButtonClick: CollabInstance["onCollabButtonClick"];
|
||||||
|
broadcastElements: CollabInstance["broadcastElements"];
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReconciledElements = readonly ExcalidrawElement[] & {
|
||||||
|
_brand: "reconciledElements";
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: (collab: CollabAPI) => React.ReactNode;
|
||||||
|
// NOTE not type-safe because the refObject may in fact not be initialized
|
||||||
|
// with ExcalidrawImperativeAPI yet
|
||||||
|
excalidrawRef: React.MutableRefObject<ExcalidrawImperativeAPI>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||||
|
portal: Portal;
|
||||||
|
private socketInitializationTimer?: NodeJS.Timeout;
|
||||||
|
private excalidrawRef: Props["excalidrawRef"];
|
||||||
|
excalidrawAppState?: AppState;
|
||||||
|
private lastBroadcastedOrReceivedSceneVersion: number = -1;
|
||||||
|
private collaborators = new Map<string, Collaborator>();
|
||||||
|
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
isCollaborating: false,
|
||||||
|
modalIsShown: false,
|
||||||
|
errorMessage: "",
|
||||||
|
username: importUsernameFromLocalStorage() || "",
|
||||||
|
activeRoomLink: "",
|
||||||
|
};
|
||||||
|
this.portal = new Portal(this);
|
||||||
|
this.excalidrawRef = props.excalidrawRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
|
||||||
|
window.addEventListener(EVENT.UNLOAD, this.onUnload);
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.env.NODE_ENV === ENV.TEST ||
|
||||||
|
process.env.NODE_ENV === ENV.DEVELOPMENT
|
||||||
|
) {
|
||||||
|
window.h = window.h || ({} as Window["h"]);
|
||||||
|
Object.defineProperties(window.h, {
|
||||||
|
collab: {
|
||||||
|
configurable: true,
|
||||||
|
value: this,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
|
||||||
|
window.removeEventListener(EVENT.UNLOAD, this.onUnload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onUnload = () => {
|
||||||
|
this.destroySocketClient();
|
||||||
|
};
|
||||||
|
|
||||||
|
private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
|
||||||
|
const syncableElements = getSyncableElements(
|
||||||
|
this.getSceneElementsIncludingDeleted(),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
this.state.isCollaborating &&
|
||||||
|
!isSavedToFirebase(this.portal, syncableElements)
|
||||||
|
) {
|
||||||
|
// this won't run in time if user decides to leave the site, but
|
||||||
|
// the purpose is to run in immediately after user decides to stay
|
||||||
|
this.saveCollabRoomToFirebase(syncableElements);
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
// NOTE: modern browsers no longer allow showing a custom message here
|
||||||
|
event.returnValue = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state.isCollaborating || this.portal.roomId) {
|
||||||
|
try {
|
||||||
|
localStorage?.setItem(
|
||||||
|
STORAGE_KEYS.LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
|
||||||
|
JSON.stringify({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
room: this.portal.roomId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
saveCollabRoomToFirebase = async (
|
||||||
|
syncableElements: ExcalidrawElement[] = getSyncableElements(
|
||||||
|
this.excalidrawRef.current!.getSceneElementsIncludingDeleted(),
|
||||||
|
),
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await saveToFirebase(this.portal, syncableElements);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
openPortal = async () => {
|
||||||
|
window.history.pushState(
|
||||||
|
{},
|
||||||
|
"Excalidraw",
|
||||||
|
await generateCollaborationLink(),
|
||||||
|
);
|
||||||
|
const elements = this.excalidrawRef.current!.getSceneElements();
|
||||||
|
// remove deleted elements from elements array & history to ensure we don't
|
||||||
|
// expose potentially sensitive user data in case user manually deletes
|
||||||
|
// existing elements (or clears scene), which would otherwise be persisted
|
||||||
|
// to database even if deleted before creating the room.
|
||||||
|
this.excalidrawRef.current!.history.clear();
|
||||||
|
this.excalidrawRef.current!.updateScene({
|
||||||
|
elements,
|
||||||
|
commitToHistory: true,
|
||||||
|
});
|
||||||
|
trackEvent(EVENT_SHARE, "session start");
|
||||||
|
return this.initializeSocketClient();
|
||||||
|
};
|
||||||
|
|
||||||
|
closePortal = () => {
|
||||||
|
this.saveCollabRoomToFirebase();
|
||||||
|
window.history.pushState({}, "Excalidraw", window.location.origin);
|
||||||
|
this.destroySocketClient();
|
||||||
|
trackEvent(EVENT_SHARE, "session end");
|
||||||
|
};
|
||||||
|
|
||||||
|
private destroySocketClient = () => {
|
||||||
|
this.collaborators = new Map();
|
||||||
|
this.excalidrawRef.current!.updateScene({
|
||||||
|
collaborators: this.collaborators,
|
||||||
|
});
|
||||||
|
this.setState({
|
||||||
|
isCollaborating: false,
|
||||||
|
activeRoomLink: "",
|
||||||
|
});
|
||||||
|
this.portal.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
private initializeSocketClient = async (): Promise<ImportedDataState | null> => {
|
||||||
|
if (this.portal.socket) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scenePromise = resolvablePromise<ImportedDataState | null>();
|
||||||
|
|
||||||
|
const roomMatch = getCollaborationLinkData(window.location.href);
|
||||||
|
|
||||||
|
if (roomMatch) {
|
||||||
|
const roomId = roomMatch[1];
|
||||||
|
const roomKey = roomMatch[2];
|
||||||
|
|
||||||
|
// fallback in case you're not alone in the room but still don't receive
|
||||||
|
// initial SCENE_UPDATE message
|
||||||
|
this.socketInitializationTimer = setTimeout(() => {
|
||||||
|
this.initializeSocket();
|
||||||
|
scenePromise.resolve(null);
|
||||||
|
}, INITIAL_SCENE_UPDATE_TIMEOUT);
|
||||||
|
|
||||||
|
const { default: socketIOClient }: any = await import(
|
||||||
|
/* webpackChunkName: "socketIoClient" */ "socket.io-client"
|
||||||
|
);
|
||||||
|
|
||||||
|
this.portal.open(socketIOClient(SOCKET_SERVER), roomId, roomKey);
|
||||||
|
|
||||||
|
// All socket listeners are moving to Portal
|
||||||
|
this.portal.socket!.on(
|
||||||
|
"client-broadcast",
|
||||||
|
async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
|
||||||
|
if (!this.portal.roomKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const decryptedData = await decryptAESGEM(
|
||||||
|
encryptedData,
|
||||||
|
this.portal.roomKey,
|
||||||
|
iv,
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (decryptedData.type) {
|
||||||
|
case "INVALID_RESPONSE":
|
||||||
|
return;
|
||||||
|
case SCENE.INIT: {
|
||||||
|
if (!this.portal.socketInitialized) {
|
||||||
|
const remoteElements = decryptedData.payload.elements;
|
||||||
|
const reconciledElements = this.reconcileElements(
|
||||||
|
remoteElements,
|
||||||
|
);
|
||||||
|
this.handleRemoteSceneUpdate(reconciledElements, {
|
||||||
|
init: true,
|
||||||
|
});
|
||||||
|
this.initializeSocket();
|
||||||
|
scenePromise.resolve({ elements: reconciledElements });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case SCENE.UPDATE:
|
||||||
|
this.handleRemoteSceneUpdate(
|
||||||
|
this.reconcileElements(decryptedData.payload.elements),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "MOUSE_LOCATION": {
|
||||||
|
const {
|
||||||
|
pointer,
|
||||||
|
button,
|
||||||
|
username,
|
||||||
|
selectedElementIds,
|
||||||
|
} = decryptedData.payload;
|
||||||
|
const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
|
||||||
|
decryptedData.payload.socketId ||
|
||||||
|
// @ts-ignore legacy, see #2094 (#2097)
|
||||||
|
decryptedData.payload.socketID;
|
||||||
|
|
||||||
|
const collaborators = new Map(this.collaborators);
|
||||||
|
const user = collaborators.get(socketId) || {}!;
|
||||||
|
user.pointer = pointer;
|
||||||
|
user.button = button;
|
||||||
|
user.selectedElementIds = selectedElementIds;
|
||||||
|
user.username = username;
|
||||||
|
collaborators.set(socketId, user);
|
||||||
|
this.excalidrawRef.current!.updateScene({
|
||||||
|
collaborators,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.portal.socket!.on("first-in-room", () => {
|
||||||
|
if (this.portal.socket) {
|
||||||
|
this.portal.socket.off("first-in-room");
|
||||||
|
}
|
||||||
|
this.initializeSocket();
|
||||||
|
scenePromise.resolve(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
isCollaborating: true,
|
||||||
|
activeRoomLink: window.location.href,
|
||||||
|
});
|
||||||
|
|
||||||
|
return scenePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
private initializeSocket = () => {
|
||||||
|
this.portal.socketInitialized = true;
|
||||||
|
clearTimeout(this.socketInitializationTimer!);
|
||||||
|
};
|
||||||
|
|
||||||
|
private reconcileElements = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
): ReconciledElements => {
|
||||||
|
const newElements = this.portal.reconcileElements(elements);
|
||||||
|
|
||||||
|
// Avoid broadcasting to the rest of the collaborators the scene
|
||||||
|
// we just received!
|
||||||
|
// Note: this needs to be set before updating the scene as it
|
||||||
|
// syncronously calls render.
|
||||||
|
this.setLastBroadcastedOrReceivedSceneVersion(getSceneVersion(newElements));
|
||||||
|
|
||||||
|
return newElements as ReconciledElements;
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleRemoteSceneUpdate = (
|
||||||
|
elements: ReconciledElements,
|
||||||
|
{
|
||||||
|
init = false,
|
||||||
|
initFromSnapshot = false,
|
||||||
|
}: { init?: boolean; initFromSnapshot?: boolean } = {},
|
||||||
|
) => {
|
||||||
|
if (init || initFromSnapshot) {
|
||||||
|
this.excalidrawRef.current!.setScrollToCenter(elements);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.excalidrawRef.current!.updateScene({
|
||||||
|
elements,
|
||||||
|
commitToHistory: !!init,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
this.excalidrawRef.current!.history.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
setCollaborators(sockets: string[]) {
|
||||||
|
this.setState((state) => {
|
||||||
|
const collaborators: InstanceType<
|
||||||
|
typeof CollabWrapper
|
||||||
|
>["collaborators"] = new Map();
|
||||||
|
for (const socketId of sockets) {
|
||||||
|
if (this.collaborators.has(socketId)) {
|
||||||
|
collaborators.set(socketId, this.collaborators.get(socketId)!);
|
||||||
|
} else {
|
||||||
|
collaborators.set(socketId, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.collaborators = collaborators;
|
||||||
|
this.excalidrawRef.current!.updateScene({ collaborators });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
|
||||||
|
this.lastBroadcastedOrReceivedSceneVersion = version;
|
||||||
|
};
|
||||||
|
|
||||||
|
public getLastBroadcastedOrReceivedSceneVersion = () => {
|
||||||
|
return this.lastBroadcastedOrReceivedSceneVersion;
|
||||||
|
};
|
||||||
|
|
||||||
|
public getSceneElementsIncludingDeleted = () => {
|
||||||
|
return this.excalidrawRef.current!.getSceneElementsIncludingDeleted();
|
||||||
|
};
|
||||||
|
|
||||||
|
onPointerUpdate = (payload: {
|
||||||
|
pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
|
||||||
|
button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
|
||||||
|
pointersMap: Gesture["pointers"];
|
||||||
|
}) => {
|
||||||
|
payload.pointersMap.size < 2 &&
|
||||||
|
this.portal.socket &&
|
||||||
|
this.portal.broadcastMouseLocation(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
broadcastElements = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
state: AppState,
|
||||||
|
) => {
|
||||||
|
this.excalidrawAppState = state;
|
||||||
|
if (
|
||||||
|
getSceneVersion(elements) >
|
||||||
|
this.getLastBroadcastedOrReceivedSceneVersion()
|
||||||
|
) {
|
||||||
|
this.portal.broadcastScene(
|
||||||
|
SCENE.UPDATE,
|
||||||
|
getSyncableElements(elements),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements);
|
||||||
|
this.queueBroadcastAllElements();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
queueBroadcastAllElements = throttle(() => {
|
||||||
|
this.portal.broadcastScene(
|
||||||
|
SCENE.UPDATE,
|
||||||
|
getSyncableElements(
|
||||||
|
this.excalidrawRef.current!.getSceneElementsIncludingDeleted(),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const currentVersion = this.getLastBroadcastedOrReceivedSceneVersion();
|
||||||
|
const newVersion = Math.max(
|
||||||
|
currentVersion,
|
||||||
|
getSceneVersion(this.getSceneElementsIncludingDeleted()),
|
||||||
|
);
|
||||||
|
this.setLastBroadcastedOrReceivedSceneVersion(newVersion);
|
||||||
|
}, SYNC_FULL_SCENE_INTERVAL_MS);
|
||||||
|
|
||||||
|
handleClose = () => {
|
||||||
|
this.setState({ modalIsShown: false });
|
||||||
|
const collabIcon = document.querySelector(".CollabButton") as HTMLElement;
|
||||||
|
collabIcon.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
onUsernameChange = (username: string) => {
|
||||||
|
this.setState({ username });
|
||||||
|
saveUsernameToLocalStorage(username);
|
||||||
|
};
|
||||||
|
|
||||||
|
onCollabButtonClick = () => {
|
||||||
|
this.setState({
|
||||||
|
modalIsShown: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { children } = this.props;
|
||||||
|
const { modalIsShown, username, errorMessage, activeRoomLink } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{modalIsShown && (
|
||||||
|
<RoomDialog
|
||||||
|
handleClose={this.handleClose}
|
||||||
|
activeRoomLink={activeRoomLink}
|
||||||
|
username={username}
|
||||||
|
onUsernameChange={this.onUsernameChange}
|
||||||
|
onRoomCreate={this.openPortal}
|
||||||
|
onRoomDestroy={this.closePortal}
|
||||||
|
setErrorMessage={(errorMessage) => {
|
||||||
|
this.setState({ errorMessage });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{errorMessage && (
|
||||||
|
<ErrorDialog
|
||||||
|
message={errorMessage}
|
||||||
|
onClose={() => this.setState({ errorMessage: "" })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{children({
|
||||||
|
isCollaborating: this.state.isCollaborating,
|
||||||
|
username: this.state.username,
|
||||||
|
onPointerUpdate: this.onPointerUpdate,
|
||||||
|
initializeSocketClient: this.initializeSocketClient,
|
||||||
|
onCollabButtonClick: this.onCollabButtonClick,
|
||||||
|
broadcastElements: this.broadcastElements,
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CollabWrapper;
|
@ -1,24 +1,27 @@
|
|||||||
import { encryptAESGEM, SocketUpdateDataSource } from "../data";
|
import {
|
||||||
|
encryptAESGEM,
|
||||||
|
SocketUpdateData,
|
||||||
|
SocketUpdateDataSource,
|
||||||
|
} from "../data";
|
||||||
|
|
||||||
|
import CollabWrapper from "./CollabWrapper";
|
||||||
|
|
||||||
import { SocketUpdateData } from "../types";
|
|
||||||
import { BROADCAST, SCENE } from "../constants";
|
|
||||||
import App from "./App";
|
|
||||||
import {
|
import {
|
||||||
getElementMap,
|
getElementMap,
|
||||||
getSceneVersion,
|
|
||||||
getSyncableElements,
|
getSyncableElements,
|
||||||
} from "../element";
|
} from "../../packages/excalidraw/index";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../../element/types";
|
||||||
|
import { BROADCAST, SCENE } from "../app_constants";
|
||||||
|
|
||||||
class Portal {
|
class Portal {
|
||||||
app: App;
|
app: CollabWrapper;
|
||||||
socket: SocketIOClient.Socket | null = null;
|
socket: SocketIOClient.Socket | null = null;
|
||||||
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
|
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
|
||||||
roomId: string | null = null;
|
roomId: string | null = null;
|
||||||
roomKey: string | null = null;
|
roomKey: string | null = null;
|
||||||
broadcastedElementVersions: Map<string, number> = new Map();
|
broadcastedElementVersions: Map<string, number> = new Map();
|
||||||
|
|
||||||
constructor(app: App) {
|
constructor(app: CollabWrapper) {
|
||||||
this.app = app;
|
this.app = app;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,7 +37,11 @@ class Portal {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.socket.on("new-user", async (_socketId: string) => {
|
this.socket.on("new-user", async (_socketId: string) => {
|
||||||
this.broadcastScene(SCENE.INIT, /* syncAll */ true);
|
this.broadcastScene(
|
||||||
|
SCENE.INIT,
|
||||||
|
getSyncableElements(this.app.getSceneElementsIncludingDeleted()),
|
||||||
|
/* syncAll */ true,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
this.socket.on("room-user-change", (clients: string[]) => {
|
this.socket.on("room-user-change", (clients: string[]) => {
|
||||||
this.app.setCollaborators(clients);
|
this.app.setCollaborators(clients);
|
||||||
@ -81,16 +88,13 @@ class Portal {
|
|||||||
|
|
||||||
broadcastScene = async (
|
broadcastScene = async (
|
||||||
sceneType: SCENE.INIT | SCENE.UPDATE,
|
sceneType: SCENE.INIT | SCENE.UPDATE,
|
||||||
|
syncableElements: ExcalidrawElement[],
|
||||||
syncAll: boolean,
|
syncAll: boolean,
|
||||||
) => {
|
) => {
|
||||||
if (sceneType === SCENE.INIT && !syncAll) {
|
if (sceneType === SCENE.INIT && !syncAll) {
|
||||||
throw new Error("syncAll must be true when sending SCENE.INIT");
|
throw new Error("syncAll must be true when sending SCENE.INIT");
|
||||||
}
|
}
|
||||||
|
|
||||||
let syncableElements = getSyncableElements(
|
|
||||||
this.app.getSceneElementsIncludingDeleted(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!syncAll) {
|
if (!syncAll) {
|
||||||
// sync out only the elements we think we need to to save bandwidth.
|
// 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
|
// periodically we'll resync the whole thing to make sure no one diverges
|
||||||
@ -109,12 +113,6 @@ class Portal {
|
|||||||
elements: syncableElements,
|
elements: syncableElements,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const currentVersion = this.app.getLastBroadcastedOrReceivedSceneVersion();
|
|
||||||
const newVersion = Math.max(
|
|
||||||
currentVersion,
|
|
||||||
getSceneVersion(this.app.getSceneElementsIncludingDeleted()),
|
|
||||||
);
|
|
||||||
this.app.setLastBroadcastedOrReceivedSceneVersion(newVersion);
|
|
||||||
|
|
||||||
for (const syncableElement of syncableElements) {
|
for (const syncableElement of syncableElements) {
|
||||||
this.broadcastedElementVersions.set(
|
this.broadcastedElementVersions.set(
|
||||||
@ -148,7 +146,8 @@ class Portal {
|
|||||||
socketId: this.socket.id,
|
socketId: this.socket.id,
|
||||||
pointer: payload.pointer,
|
pointer: payload.pointer,
|
||||||
button: payload.button || "up",
|
button: payload.button || "up",
|
||||||
selectedElementIds: this.app.state.selectedElementIds,
|
selectedElementIds:
|
||||||
|
this.app.excalidrawAppState?.selectedElementIds || {},
|
||||||
username: this.app.state.username,
|
username: this.app.state.username,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -159,21 +158,24 @@ class Portal {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
reconcileElements = (sceneElements: readonly ExcalidrawElement[]) => {
|
reconcileElements = (
|
||||||
|
sceneElements: readonly ExcalidrawElement[],
|
||||||
|
): readonly ExcalidrawElement[] => {
|
||||||
const currentElements = this.app.getSceneElementsIncludingDeleted();
|
const currentElements = this.app.getSceneElementsIncludingDeleted();
|
||||||
// create a map of ids so we don't have to iterate
|
// create a map of ids so we don't have to iterate
|
||||||
// over the array more than once.
|
// over the array more than once.
|
||||||
const localElementMap = getElementMap(currentElements);
|
const localElementMap = getElementMap(currentElements);
|
||||||
|
|
||||||
// Reconcile
|
// Reconcile
|
||||||
const newElements = sceneElements
|
return (
|
||||||
|
sceneElements
|
||||||
.reduce((elements, element) => {
|
.reduce((elements, element) => {
|
||||||
// if the remote element references one that's currently
|
// if the remote element references one that's currently
|
||||||
// edited on local, skip it (it'll be added in the next step)
|
// edited on local, skip it (it'll be added in the next step)
|
||||||
if (
|
if (
|
||||||
element.id === this.app.state.editingElement?.id ||
|
element.id === this.app.excalidrawAppState?.editingElement?.id ||
|
||||||
element.id === this.app.state.resizingElement?.id ||
|
element.id === this.app.excalidrawAppState?.resizingElement?.id ||
|
||||||
element.id === this.app.state.draggingElement?.id
|
element.id === this.app.excalidrawAppState?.draggingElement?.id
|
||||||
) {
|
) {
|
||||||
return elements;
|
return elements;
|
||||||
}
|
}
|
||||||
@ -190,7 +192,9 @@ class Portal {
|
|||||||
localElementMap[element.id].versionNonce !== element.versionNonce
|
localElementMap[element.id].versionNonce !== element.versionNonce
|
||||||
) {
|
) {
|
||||||
// resolve conflicting edits deterministically by taking the one with the lowest versionNonce
|
// resolve conflicting edits deterministically by taking the one with the lowest versionNonce
|
||||||
if (localElementMap[element.id].versionNonce < element.versionNonce) {
|
if (
|
||||||
|
localElementMap[element.id].versionNonce < element.versionNonce
|
||||||
|
) {
|
||||||
elements.push(localElementMap[element.id]);
|
elements.push(localElementMap[element.id]);
|
||||||
} else {
|
} else {
|
||||||
// it should be highly unlikely that the two versionNonces are the same. if we are
|
// it should be highly unlikely that the two versionNonces are the same. if we are
|
||||||
@ -206,8 +210,8 @@ class Portal {
|
|||||||
return elements;
|
return elements;
|
||||||
}, [] as Mutable<typeof sceneElements>)
|
}, [] as Mutable<typeof sceneElements>)
|
||||||
// add local elements that weren't deleted or on remote
|
// add local elements that weren't deleted or on remote
|
||||||
.concat(...Object.values(localElementMap));
|
.concat(...Object.values(localElementMap))
|
||||||
return newElements;
|
);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -1,32 +1,6 @@
|
|||||||
@import "../css/_variables";
|
@import "../../css/_variables";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.RoomDialog-modalButton.is-collaborating {
|
|
||||||
background-color: var(--button-special-active-background-color);
|
|
||||||
|
|
||||||
.ToolIcon__icon svg {
|
|
||||||
color: var(--icon-green-fill-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.RoomDialog-modalButton-collaborators {
|
|
||||||
min-width: 1em;
|
|
||||||
position: absolute;
|
|
||||||
:root[dir="ltr"] & {
|
|
||||||
right: -5px;
|
|
||||||
}
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
left: -5px;
|
|
||||||
}
|
|
||||||
bottom: -5px;
|
|
||||||
padding: 3px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: $oc-green-6;
|
|
||||||
color: $oc-white;
|
|
||||||
font-size: 0.7em;
|
|
||||||
font-family: var(--ui-font);
|
|
||||||
}
|
|
||||||
|
|
||||||
.RoomDialog-linkContainer {
|
.RoomDialog-linkContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin: 1.5em 0;
|
margin: 1.5em 0;
|
136
src/excalidraw-app/collab/RoomDialog.tsx
Normal file
136
src/excalidraw-app/collab/RoomDialog.tsx
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import React, { useRef } from "react";
|
||||||
|
import { t } from "../../i18n";
|
||||||
|
import { Dialog } from "../../components/Dialog";
|
||||||
|
import { copyTextToSystemClipboard } from "../../clipboard";
|
||||||
|
import { ToolButton } from "../../components/ToolButton";
|
||||||
|
import { clipboard, start, stop } from "../../components/icons";
|
||||||
|
|
||||||
|
import "./RoomDialog.scss";
|
||||||
|
import { EVENT_SHARE, trackEvent } from "../../analytics";
|
||||||
|
|
||||||
|
const RoomDialog = ({
|
||||||
|
handleClose,
|
||||||
|
activeRoomLink,
|
||||||
|
username,
|
||||||
|
onUsernameChange,
|
||||||
|
onRoomCreate,
|
||||||
|
onRoomDestroy,
|
||||||
|
setErrorMessage,
|
||||||
|
}: {
|
||||||
|
handleClose: () => void;
|
||||||
|
activeRoomLink: string;
|
||||||
|
username: string;
|
||||||
|
onUsernameChange: (username: string) => void;
|
||||||
|
onRoomCreate: () => void;
|
||||||
|
onRoomDestroy: () => void;
|
||||||
|
setErrorMessage: (message: string) => void;
|
||||||
|
}) => {
|
||||||
|
const roomLinkInput = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const copyRoomLink = async () => {
|
||||||
|
try {
|
||||||
|
await copyTextToSystemClipboard(activeRoomLink);
|
||||||
|
trackEvent(EVENT_SHARE, "copy link");
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message);
|
||||||
|
}
|
||||||
|
if (roomLinkInput.current) {
|
||||||
|
roomLinkInput.current.select();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectInput = (event: React.MouseEvent<HTMLInputElement>) => {
|
||||||
|
if (event.target !== document.activeElement) {
|
||||||
|
event.preventDefault();
|
||||||
|
(event.target as HTMLInputElement).select();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRoomDialog = () => {
|
||||||
|
return (
|
||||||
|
<div className="RoomDialog-modal">
|
||||||
|
{!activeRoomLink && (
|
||||||
|
<>
|
||||||
|
<p>{t("roomDialog.desc_intro")}</p>
|
||||||
|
<p>{`🔒 ${t("roomDialog.desc_privacy")}`}</p>
|
||||||
|
<div className="RoomDialog-sessionStartButtonContainer">
|
||||||
|
<ToolButton
|
||||||
|
className="RoomDialog-startSession"
|
||||||
|
type="button"
|
||||||
|
icon={start}
|
||||||
|
title={t("roomDialog.button_startSession")}
|
||||||
|
aria-label={t("roomDialog.button_startSession")}
|
||||||
|
showAriaLabel={true}
|
||||||
|
onClick={onRoomCreate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{activeRoomLink && (
|
||||||
|
<>
|
||||||
|
<p>{t("roomDialog.desc_inProgressIntro")}</p>
|
||||||
|
<p>{t("roomDialog.desc_shareLink")}</p>
|
||||||
|
<div className="RoomDialog-linkContainer">
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
icon={clipboard}
|
||||||
|
title={t("labels.copy")}
|
||||||
|
aria-label={t("labels.copy")}
|
||||||
|
onClick={copyRoomLink}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={activeRoomLink}
|
||||||
|
readOnly={true}
|
||||||
|
className="RoomDialog-link"
|
||||||
|
ref={roomLinkInput}
|
||||||
|
onPointerDown={selectInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="RoomDialog-usernameContainer">
|
||||||
|
<label className="RoomDialog-usernameLabel" htmlFor="username">
|
||||||
|
{t("labels.yourName")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
value={username || ""}
|
||||||
|
className="RoomDialog-username TextInput"
|
||||||
|
onChange={(event) => onUsernameChange(event.target.value)}
|
||||||
|
onBlur={() => trackEvent(EVENT_SHARE, "name")}
|
||||||
|
onKeyPress={(event) => event.key === "Enter" && handleClose()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
<span role="img" aria-hidden="true" className="RoomDialog-emoji">
|
||||||
|
{"🔒"}
|
||||||
|
</span>{" "}
|
||||||
|
{t("roomDialog.desc_privacy")}
|
||||||
|
</p>
|
||||||
|
<p>{t("roomDialog.desc_exitSession")}</p>
|
||||||
|
<div className="RoomDialog-sessionStartButtonContainer">
|
||||||
|
<ToolButton
|
||||||
|
className="RoomDialog-stopSession"
|
||||||
|
type="button"
|
||||||
|
icon={stop}
|
||||||
|
title={t("roomDialog.button_stopSession")}
|
||||||
|
aria-label={t("roomDialog.button_stopSession")}
|
||||||
|
showAriaLabel={true}
|
||||||
|
onClick={onRoomDestroy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
maxWidth={800}
|
||||||
|
onCloseRequest={handleClose}
|
||||||
|
title={t("labels.createRoom")}
|
||||||
|
>
|
||||||
|
{renderRoomDialog()}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RoomDialog;
|
@ -1,8 +1,9 @@
|
|||||||
import { createIV, getImportedKey } from "./index";
|
import { getImportedKey } from "../data";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { createIV } from "./index";
|
||||||
import { getSceneVersion } from "../element";
|
import { ExcalidrawElement } from "../../element/types";
|
||||||
import Portal from "../components/Portal";
|
import { getSceneVersion } from "../../element";
|
||||||
import { restoreElements } from "./restore";
|
import Portal from "../collab/Portal";
|
||||||
|
import { restoreElements } from "../../data/restore";
|
||||||
|
|
||||||
let firebasePromise: Promise<
|
let firebasePromise: Promise<
|
||||||
typeof import("firebase/app").default
|
typeof import("firebase/app").default
|
||||||
@ -26,8 +27,7 @@ const getFirebase = async (): Promise<
|
|||||||
if (!firebasePromise) {
|
if (!firebasePromise) {
|
||||||
firebasePromise = loadFirebase();
|
firebasePromise = loadFirebase();
|
||||||
}
|
}
|
||||||
const firebase = await firebasePromise!;
|
return await firebasePromise!;
|
||||||
return firebase;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface FirebaseStoredScene {
|
interface FirebaseStoredScene {
|
230
src/excalidraw-app/data/index.ts
Normal file
230
src/excalidraw-app/data/index.ts
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
import { t } from "../../i18n";
|
||||||
|
import { ExcalidrawElement } from "../../element/types";
|
||||||
|
import { AppState } from "../../types";
|
||||||
|
import { ImportedDataState } from "../../data/types";
|
||||||
|
import { restore } from "../../data/restore";
|
||||||
|
import { EVENT_ACTION, trackEvent } from "../../analytics";
|
||||||
|
|
||||||
|
const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2);
|
||||||
|
|
||||||
|
const BACKEND_GET = process.env.REACT_APP_BACKEND_V1_GET_URL;
|
||||||
|
const BACKEND_V2_GET = process.env.REACT_APP_BACKEND_V2_GET_URL;
|
||||||
|
|
||||||
|
const generateRandomID = async () => {
|
||||||
|
const arr = new Uint8Array(10);
|
||||||
|
window.crypto.getRandomValues(arr);
|
||||||
|
return Array.from(arr, byteToHex).join("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateEncryptionKey = async () => {
|
||||||
|
const key = await window.crypto.subtle.generateKey(
|
||||||
|
{
|
||||||
|
name: "AES-GCM",
|
||||||
|
length: 128,
|
||||||
|
},
|
||||||
|
true, // extractable
|
||||||
|
["encrypt", "decrypt"],
|
||||||
|
);
|
||||||
|
return (await window.crypto.subtle.exportKey("jwk", key)).k;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SOCKET_SERVER = process.env.REACT_APP_SOCKET_SERVER_URL;
|
||||||
|
|
||||||
|
export type EncryptedData = {
|
||||||
|
data: ArrayBuffer;
|
||||||
|
iv: Uint8Array;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SocketUpdateDataSource = {
|
||||||
|
SCENE_INIT: {
|
||||||
|
type: "SCENE_INIT";
|
||||||
|
payload: {
|
||||||
|
elements: readonly ExcalidrawElement[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
SCENE_UPDATE: {
|
||||||
|
type: "SCENE_UPDATE";
|
||||||
|
payload: {
|
||||||
|
elements: readonly ExcalidrawElement[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
MOUSE_LOCATION: {
|
||||||
|
type: "MOUSE_LOCATION";
|
||||||
|
payload: {
|
||||||
|
socketId: string;
|
||||||
|
pointer: { x: number; y: number };
|
||||||
|
button: "down" | "up";
|
||||||
|
selectedElementIds: AppState["selectedElementIds"];
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SocketUpdateDataIncoming =
|
||||||
|
| SocketUpdateDataSource[keyof SocketUpdateDataSource]
|
||||||
|
| {
|
||||||
|
type: "INVALID_RESPONSE";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SocketUpdateData = SocketUpdateDataSource[keyof SocketUpdateDataSource] & {
|
||||||
|
_brand: "socketUpdateData";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createIV = () => {
|
||||||
|
const arr = new Uint8Array(12);
|
||||||
|
return window.crypto.getRandomValues(arr);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const encryptAESGEM = async (
|
||||||
|
data: Uint8Array,
|
||||||
|
key: string,
|
||||||
|
): Promise<EncryptedData> => {
|
||||||
|
const importedKey = await getImportedKey(key, "encrypt");
|
||||||
|
const iv = createIV();
|
||||||
|
return {
|
||||||
|
data: await window.crypto.subtle.encrypt(
|
||||||
|
{
|
||||||
|
name: "AES-GCM",
|
||||||
|
iv,
|
||||||
|
},
|
||||||
|
importedKey,
|
||||||
|
data,
|
||||||
|
),
|
||||||
|
iv,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decryptAESGEM = async (
|
||||||
|
data: ArrayBuffer,
|
||||||
|
key: string,
|
||||||
|
iv: Uint8Array,
|
||||||
|
): Promise<SocketUpdateDataIncoming> => {
|
||||||
|
try {
|
||||||
|
const importedKey = await getImportedKey(key, "decrypt");
|
||||||
|
const decrypted = await window.crypto.subtle.decrypt(
|
||||||
|
{
|
||||||
|
name: "AES-GCM",
|
||||||
|
iv,
|
||||||
|
},
|
||||||
|
importedKey,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
|
||||||
|
const decodedData = new TextDecoder("utf-8").decode(
|
||||||
|
new Uint8Array(decrypted) as any,
|
||||||
|
);
|
||||||
|
return JSON.parse(decodedData);
|
||||||
|
} catch (error) {
|
||||||
|
window.alert(t("alerts.decryptFailed"));
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "INVALID_RESPONSE",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCollaborationLinkData = (link: string) => {
|
||||||
|
if (link.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hash = new URL(link).hash;
|
||||||
|
return hash.match(/^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateCollaborationLink = async () => {
|
||||||
|
const id = await generateRandomID();
|
||||||
|
const key = await generateEncryptionKey();
|
||||||
|
return `${window.location.origin}${window.location.pathname}#room=${id},${key}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getImportedKey = (key: string, usage: KeyUsage) =>
|
||||||
|
window.crypto.subtle.importKey(
|
||||||
|
"jwk",
|
||||||
|
{
|
||||||
|
alg: "A128GCM",
|
||||||
|
ext: true,
|
||||||
|
k: key,
|
||||||
|
key_ops: ["encrypt", "decrypt"],
|
||||||
|
kty: "oct",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AES-GCM",
|
||||||
|
length: 128,
|
||||||
|
},
|
||||||
|
false, // extractable
|
||||||
|
[usage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const importFromBackend = async (
|
||||||
|
id: string | null,
|
||||||
|
privateKey?: string | null,
|
||||||
|
): Promise<ImportedDataState> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
privateKey ? `${BACKEND_V2_GET}${id}` : `${BACKEND_GET}${id}.json`,
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
window.alert(t("alerts.importBackendFailed"));
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
let data: ImportedDataState;
|
||||||
|
if (privateKey) {
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
const key = await getImportedKey(privateKey, "decrypt");
|
||||||
|
const iv = new Uint8Array(12);
|
||||||
|
const decrypted = await window.crypto.subtle.decrypt(
|
||||||
|
{
|
||||||
|
name: "AES-GCM",
|
||||||
|
iv,
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
buffer,
|
||||||
|
);
|
||||||
|
// We need to convert the decrypted array buffer to a string
|
||||||
|
const string = new window.TextDecoder("utf-8").decode(
|
||||||
|
new Uint8Array(decrypted) as any,
|
||||||
|
);
|
||||||
|
data = JSON.parse(string);
|
||||||
|
} else {
|
||||||
|
// Legacy format
|
||||||
|
data = await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
trackEvent(EVENT_ACTION, "import");
|
||||||
|
return {
|
||||||
|
elements: data.elements || null,
|
||||||
|
appState: data.appState || null,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
window.alert(t("alerts.importBackendFailed"));
|
||||||
|
console.error(error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadScene = async (
|
||||||
|
id: string | null,
|
||||||
|
privateKey: string | null,
|
||||||
|
// Supply initialData even if importing from backend to ensure we restore
|
||||||
|
// localStorage user settings which we do not persist on server.
|
||||||
|
// Non-optional so we don't forget to pass it even if `undefined`.
|
||||||
|
initialData: ImportedDataState | undefined | null,
|
||||||
|
) => {
|
||||||
|
let data;
|
||||||
|
if (id != null) {
|
||||||
|
// the private key is used to decrypt the content from the server, take
|
||||||
|
// extra care not to leak it
|
||||||
|
data = restore(
|
||||||
|
await importFromBackend(id, privateKey),
|
||||||
|
initialData?.appState,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
data = restore(initialData || null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
elements: data.elements,
|
||||||
|
appState: data.appState,
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
};
|
@ -1,8 +1,18 @@
|
|||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../../element/types";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../../types";
|
||||||
import { clearAppStateForLocalStorage, getDefaultAppState } from "../appState";
|
import {
|
||||||
import { STORAGE_KEYS } from "../constants";
|
clearAppStateForLocalStorage,
|
||||||
import { clearElementsForLocalStorage } from "../element";
|
getDefaultAppState,
|
||||||
|
} from "../../appState";
|
||||||
|
import { clearElementsForLocalStorage } from "../../element";
|
||||||
|
import { STORAGE_KEYS as APP_STORAGE_KEYS } from "../../constants";
|
||||||
|
|
||||||
|
export const STORAGE_KEYS = {
|
||||||
|
LOCAL_STORAGE_ELEMENTS: "excalidraw",
|
||||||
|
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
|
||||||
|
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
|
||||||
|
LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG: "collabLinkForceLoadFlag",
|
||||||
|
};
|
||||||
|
|
||||||
export const saveUsernameToLocalStorage = (username: string) => {
|
export const saveUsernameToLocalStorage = (username: string) => {
|
||||||
try {
|
try {
|
||||||
@ -92,7 +102,7 @@ export const getTotalStorageSize = () => {
|
|||||||
const appState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
|
const appState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
|
||||||
const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
|
const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
|
||||||
const elements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS);
|
const elements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS);
|
||||||
const library = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
|
const library = localStorage.getItem(APP_STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
|
||||||
|
|
||||||
const appStateSize = appState ? JSON.stringify(appState).length : 0;
|
const appStateSize = appState ? JSON.stringify(appState).length : 0;
|
||||||
const collabSize = collab ? JSON.stringify(collab).length : 0;
|
const collabSize = collab ? JSON.stringify(collab).length : 0;
|
@ -1,21 +1,35 @@
|
|||||||
import React, { useEffect, useLayoutEffect, useState } from "react";
|
import React, { useState, useLayoutEffect, useEffect, useRef } from "react";
|
||||||
import { EVENT_LOAD, trackEvent } from "../analytics";
|
|
||||||
import { LoadingMessage } from "../components/LoadingMessage";
|
import Excalidraw from "../packages/excalidraw/index";
|
||||||
import { TopErrorBoundary } from "../components/TopErrorBoundary";
|
|
||||||
import { EVENT } from "../constants";
|
|
||||||
import {
|
import {
|
||||||
getTotalStorageSize,
|
getTotalStorageSize,
|
||||||
importFromLocalStorage,
|
importFromLocalStorage,
|
||||||
importUsernameFromLocalStorage,
|
|
||||||
saveToLocalStorage,
|
saveToLocalStorage,
|
||||||
saveUsernameToLocalStorage,
|
STORAGE_KEYS,
|
||||||
} from "../data/localStorage";
|
} from "./data/localStorage";
|
||||||
|
|
||||||
import { ImportedDataState } from "../data/types";
|
import { ImportedDataState } from "../data/types";
|
||||||
|
import CollabWrapper, { CollabAPI } from "./collab/CollabWrapper";
|
||||||
|
import { TopErrorBoundary } from "../components/TopErrorBoundary";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { loadScene } from "./data";
|
||||||
|
import { getCollaborationLinkData } from "./data";
|
||||||
|
import { EVENT } from "../constants";
|
||||||
|
import { loadFromFirebase } from "./data/firebase";
|
||||||
|
import { ExcalidrawImperativeAPI } from "../components/App";
|
||||||
|
import { debounce, ResolvablePromise, resolvablePromise } from "../utils";
|
||||||
|
import { AppState, ExcalidrawAPIRefValue } from "../types";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import Excalidraw from "../packages/excalidraw/index";
|
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT } from "./app_constants";
|
||||||
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT } from "../time_constants";
|
import { EVENT_LOAD, EVENT_SHARE, trackEvent } from "../analytics";
|
||||||
import { AppState } from "../types";
|
|
||||||
import { debounce } from "../utils";
|
const excalidrawRef: React.MutableRefObject<ExcalidrawAPIRefValue> = {
|
||||||
|
current: {
|
||||||
|
readyPromise: resolvablePromise(),
|
||||||
|
ready: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const saveDebounced = debounce(
|
const saveDebounced = debounce(
|
||||||
(elements: readonly ExcalidrawElement[], state: AppState) => {
|
(elements: readonly ExcalidrawElement[], state: AppState) => {
|
||||||
@ -24,19 +38,145 @@ const saveDebounced = debounce(
|
|||||||
SAVE_TO_LOCAL_STORAGE_TIMEOUT,
|
SAVE_TO_LOCAL_STORAGE_TIMEOUT,
|
||||||
);
|
);
|
||||||
|
|
||||||
const onUsernameChange = (username: string) => {
|
|
||||||
saveUsernameToLocalStorage(username);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onBlur = () => {
|
const onBlur = () => {
|
||||||
saveDebounced.flush();
|
saveDebounced.flush();
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ExcalidrawApp() {
|
const shouldForceLoadScene = (
|
||||||
|
scene: ResolutionType<typeof loadScene>,
|
||||||
|
): boolean => {
|
||||||
|
if (!scene.elements.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomMatch = getCollaborationLinkData(window.location.href);
|
||||||
|
|
||||||
|
if (!roomMatch) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomId = roomMatch[1];
|
||||||
|
|
||||||
|
let collabForceLoadFlag;
|
||||||
|
try {
|
||||||
|
collabForceLoadFlag = localStorage?.getItem(
|
||||||
|
STORAGE_KEYS.LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
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 === roomId && Date.now() - timestamp < 15000) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Scene = ImportedDataState & { commitToHistory: boolean };
|
||||||
|
|
||||||
|
const initializeScene = async (opts: {
|
||||||
|
resetScene: ExcalidrawImperativeAPI["resetScene"];
|
||||||
|
initializeSocketClient: CollabAPI["initializeSocketClient"];
|
||||||
|
onLateInitialization?: (scene: Scene) => void;
|
||||||
|
}): Promise<Scene | null> => {
|
||||||
|
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_-]+)$/,
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialData = importFromLocalStorage();
|
||||||
|
|
||||||
|
let scene = await loadScene(null, null, initialData);
|
||||||
|
|
||||||
|
let isCollabScene = !!getCollaborationLinkData(window.location.href);
|
||||||
|
const isExternalScene = !!(id || jsonMatch || isCollabScene);
|
||||||
|
if (isExternalScene) {
|
||||||
|
if (
|
||||||
|
shouldForceLoadScene(scene) ||
|
||||||
|
window.confirm(t("alerts.loadSceneOverridePrompt"))
|
||||||
|
) {
|
||||||
|
// Backwards compatibility with legacy url format
|
||||||
|
if (id) {
|
||||||
|
scene = await loadScene(id, null, initialData);
|
||||||
|
} else if (jsonMatch) {
|
||||||
|
scene = await loadScene(jsonMatch[1], jsonMatch[2], initialData);
|
||||||
|
}
|
||||||
|
if (!isCollabScene) {
|
||||||
|
window.history.replaceState({}, "Excalidraw", window.location.origin);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// https://github.com/excalidraw/excalidraw/issues/1919
|
||||||
|
if (document.hidden) {
|
||||||
|
window.addEventListener(
|
||||||
|
"focus",
|
||||||
|
() =>
|
||||||
|
initializeScene(opts).then((_scene) => {
|
||||||
|
opts?.onLateInitialization?.(_scene || scene);
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
once: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
isCollabScene = false;
|
||||||
|
window.history.replaceState({}, "Excalidraw", window.location.origin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isCollabScene) {
|
||||||
|
// when joining a room we don't want user's local scene data to be merged
|
||||||
|
// into the remote scene
|
||||||
|
opts.resetScene();
|
||||||
|
const scenePromise = opts.initializeSocketClient();
|
||||||
|
trackEvent(EVENT_SHARE, "session join");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [, roomId, roomKey] = getCollaborationLinkData(
|
||||||
|
window.location.href,
|
||||||
|
)!;
|
||||||
|
const elements = await loadFromFirebase(roomId, roomKey);
|
||||||
|
if (elements) {
|
||||||
|
return {
|
||||||
|
elements,
|
||||||
|
commitToHistory: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(await scenePromise),
|
||||||
|
commitToHistory: true,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// log the error and move on. other peers will sync us the scene.
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} else if (scene) {
|
||||||
|
return scene;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ExcalidrawWrapper(props: { collab: CollabAPI }) {
|
||||||
|
// dimensions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const [dimensions, setDimensions] = useState({
|
const [dimensions, setDimensions] = useState({
|
||||||
width: window.innerWidth,
|
width: window.innerWidth,
|
||||||
height: window.innerHeight,
|
height: window.innerHeight,
|
||||||
});
|
});
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const onResize = () => {
|
const onResize = () => {
|
||||||
setDimensions({
|
setDimensions({
|
||||||
@ -50,12 +190,17 @@ export default function ExcalidrawApp() {
|
|||||||
return () => window.removeEventListener("resize", onResize);
|
return () => window.removeEventListener("resize", onResize);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [initialState, setInitialState] = useState<{
|
// initial state
|
||||||
data: ImportedDataState;
|
// ---------------------------------------------------------------------------
|
||||||
user: {
|
|
||||||
name: string | null;
|
const initialStatePromiseRef = useRef<{
|
||||||
};
|
promise: ResolvablePromise<ImportedDataState | null>;
|
||||||
} | null>(null);
|
}>({ promise: null! });
|
||||||
|
if (!initialStatePromiseRef.current.promise) {
|
||||||
|
initialStatePromiseRef.current.promise = resolvablePromise<ImportedDataState | null>();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { collab } = props;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storageSize = getTotalStorageSize();
|
const storageSize = getTotalStorageSize();
|
||||||
@ -64,35 +209,80 @@ export default function ExcalidrawApp() {
|
|||||||
} else {
|
} else {
|
||||||
trackEvent(EVENT_LOAD, "first time");
|
trackEvent(EVENT_LOAD, "first time");
|
||||||
}
|
}
|
||||||
setInitialState({
|
excalidrawRef.current!.readyPromise.then((excalidrawApi) => {
|
||||||
data: importFromLocalStorage(),
|
initializeScene({
|
||||||
user: {
|
resetScene: excalidrawApi.resetScene,
|
||||||
name: importUsernameFromLocalStorage(),
|
initializeSocketClient: collab.initializeSocketClient,
|
||||||
|
onLateInitialization: (scene) => {
|
||||||
|
initialStatePromiseRef.current.promise.resolve(scene);
|
||||||
},
|
},
|
||||||
|
}).then((scene) => {
|
||||||
|
initialStatePromiseRef.current.promise.resolve(scene);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const onHashChange = (_: HashChangeEvent) => {
|
||||||
|
const api = excalidrawRef.current!;
|
||||||
|
if (!api.ready) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (window.location.hash.length > 1) {
|
||||||
|
initializeScene({
|
||||||
|
resetScene: api.resetScene,
|
||||||
|
initializeSocketClient: collab.initializeSocketClient,
|
||||||
|
}).then((scene) => {
|
||||||
|
if (scene) {
|
||||||
|
api.updateScene(scene);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener(EVENT.HASHCHANGE, onHashChange, false);
|
||||||
window.addEventListener(EVENT.UNLOAD, onBlur, false);
|
window.addEventListener(EVENT.UNLOAD, onBlur, false);
|
||||||
window.addEventListener(EVENT.BLUR, onBlur, false);
|
window.addEventListener(EVENT.BLUR, onBlur, false);
|
||||||
return () => {
|
return () => {
|
||||||
|
window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false);
|
||||||
window.removeEventListener(EVENT.UNLOAD, onBlur, false);
|
window.removeEventListener(EVENT.UNLOAD, onBlur, false);
|
||||||
window.removeEventListener(EVENT.BLUR, onBlur, false);
|
window.removeEventListener(EVENT.BLUR, onBlur, false);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [collab.initializeSocketClient]);
|
||||||
|
|
||||||
return initialState ? (
|
const onChange = (
|
||||||
<TopErrorBoundary>
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
) => {
|
||||||
|
saveDebounced(elements, appState);
|
||||||
|
if (collab.isCollaborating) {
|
||||||
|
collab.broadcastElements(elements, appState);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
<Excalidraw
|
<Excalidraw
|
||||||
|
ref={excalidrawRef}
|
||||||
|
onChange={onChange}
|
||||||
width={dimensions.width}
|
width={dimensions.width}
|
||||||
height={dimensions.height}
|
height={dimensions.height}
|
||||||
onChange={saveDebounced}
|
initialData={initialStatePromiseRef.current.promise}
|
||||||
initialData={initialState.data}
|
user={{ name: collab.username }}
|
||||||
user={initialState.user}
|
onCollabButtonClick={collab.onCollabButtonClick}
|
||||||
onUsernameChange={onUsernameChange}
|
isCollaborating={collab.isCollaborating}
|
||||||
|
onPointerUpdate={collab.onPointerUpdate}
|
||||||
/>
|
/>
|
||||||
</TopErrorBoundary>
|
);
|
||||||
) : (
|
}
|
||||||
<LoadingMessage />
|
|
||||||
|
export default function ExcalidrawApp() {
|
||||||
|
return (
|
||||||
|
<TopErrorBoundary>
|
||||||
|
<CollabWrapper
|
||||||
|
excalidrawRef={
|
||||||
|
excalidrawRef as React.MutableRefObject<ExcalidrawImperativeAPI>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(collab) => <ExcalidrawWrapper collab={collab} />}
|
||||||
|
</CollabWrapper>
|
||||||
|
</TopErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
import React, { useEffect, forwardRef } from "react";
|
import React, { useEffect, forwardRef } from "react";
|
||||||
|
|
||||||
import { InitializeApp } from "../../components/InitializeApp";
|
import { InitializeApp } from "../../components/InitializeApp";
|
||||||
import App, { ExcalidrawImperativeAPI } from "../../components/App";
|
import App from "../../components/App";
|
||||||
|
|
||||||
import "../../css/app.scss";
|
import "../../css/app.scss";
|
||||||
import "../../css/styles.scss";
|
import "../../css/styles.scss";
|
||||||
|
|
||||||
import { ExcalidrawProps } from "../../types";
|
import { ExcalidrawAPIRefValue, ExcalidrawProps } from "../../types";
|
||||||
import { IsMobileProvider } from "../../is-mobile";
|
import { IsMobileProvider } from "../../is-mobile";
|
||||||
|
import { noop } from "../../utils";
|
||||||
|
|
||||||
const Excalidraw = (props: ExcalidrawProps) => {
|
const Excalidraw = (props: ExcalidrawProps) => {
|
||||||
const {
|
const {
|
||||||
@ -18,8 +19,10 @@ const Excalidraw = (props: ExcalidrawProps) => {
|
|||||||
onChange,
|
onChange,
|
||||||
initialData,
|
initialData,
|
||||||
user,
|
user,
|
||||||
onUsernameChange,
|
excalidrawRef,
|
||||||
forwardedRef,
|
onCollabButtonClick = noop,
|
||||||
|
isCollaborating,
|
||||||
|
onPointerUpdate,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -51,8 +54,10 @@ const Excalidraw = (props: ExcalidrawProps) => {
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
initialData={initialData}
|
initialData={initialData}
|
||||||
user={user}
|
user={user}
|
||||||
onUsernameChange={onUsernameChange}
|
excalidrawRef={excalidrawRef}
|
||||||
forwardedRef={forwardedRef}
|
onCollabButtonClick={onCollabButtonClick}
|
||||||
|
isCollaborating={isCollaborating}
|
||||||
|
onPointerUpdate={onPointerUpdate}
|
||||||
/>
|
/>
|
||||||
</IsMobileProvider>
|
</IsMobileProvider>
|
||||||
</InitializeApp>
|
</InitializeApp>
|
||||||
@ -79,7 +84,12 @@ const areEqual = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const forwardedRefComp = forwardRef<
|
const forwardedRefComp = forwardRef<
|
||||||
ExcalidrawImperativeAPI,
|
ExcalidrawAPIRefValue,
|
||||||
PublicExcalidrawProps
|
PublicExcalidrawProps
|
||||||
>((props, ref) => <Excalidraw {...props} forwardedRef={ref} />);
|
>((props, ref) => <Excalidraw {...props} excalidrawRef={ref} />);
|
||||||
export default React.memo(forwardedRefComp, areEqual);
|
export default React.memo(forwardedRefComp, areEqual);
|
||||||
|
export {
|
||||||
|
getSceneVersion,
|
||||||
|
getSyncableElements,
|
||||||
|
getElementMap,
|
||||||
|
} from "../../element";
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import { render } from "./test-utils";
|
import { render } from "./test-utils";
|
||||||
import App from "../components/App";
|
import ExcalidrawApp from "../excalidraw-app";
|
||||||
import { setLanguage } from "../i18n";
|
import { setLanguage } from "../i18n";
|
||||||
import { UI, Pointer, Keyboard } from "./helpers/ui";
|
import { UI, Pointer, Keyboard } from "./helpers/ui";
|
||||||
import { API } from "./helpers/api";
|
import { API } from "./helpers/api";
|
||||||
@ -20,15 +20,6 @@ const { h } = window;
|
|||||||
|
|
||||||
const mouse = new Pointer("mouse");
|
const mouse = new Pointer("mouse");
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
// Unmount ReactDOM from root
|
|
||||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
|
||||||
mouse.reset();
|
|
||||||
|
|
||||||
await setLanguage("en.json");
|
|
||||||
render(<App />);
|
|
||||||
});
|
|
||||||
|
|
||||||
const createAndSelectTwoRectangles = () => {
|
const createAndSelectTwoRectangles = () => {
|
||||||
UI.clickTool("rectangle");
|
UI.clickTool("rectangle");
|
||||||
mouse.down();
|
mouse.down();
|
||||||
@ -63,7 +54,17 @@ const createAndSelectTwoRectanglesWithDifferentSizes = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
it("aligns two objects correctly to the top", () => {
|
describe("aligning", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Unmount ReactDOM from root
|
||||||
|
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||||
|
mouse.reset();
|
||||||
|
|
||||||
|
await setLanguage("en.json");
|
||||||
|
await render(<ExcalidrawApp />);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aligns two objects correctly to the top", () => {
|
||||||
createAndSelectTwoRectangles();
|
createAndSelectTwoRectangles();
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
@ -82,9 +83,9 @@ it("aligns two objects correctly to the top", () => {
|
|||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(0);
|
expect(API.getSelectedElements()[1].y).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("aligns two objects correctly to the bottom", () => {
|
it("aligns two objects correctly to the bottom", () => {
|
||||||
createAndSelectTwoRectangles();
|
createAndSelectTwoRectangles();
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
@ -103,9 +104,9 @@ it("aligns two objects correctly to the bottom", () => {
|
|||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(110);
|
expect(API.getSelectedElements()[0].y).toEqual(110);
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(110);
|
expect(API.getSelectedElements()[1].y).toEqual(110);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("aligns two objects correctly to the left", () => {
|
it("aligns two objects correctly to the left", () => {
|
||||||
createAndSelectTwoRectangles();
|
createAndSelectTwoRectangles();
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
@ -124,9 +125,9 @@ it("aligns two objects correctly to the left", () => {
|
|||||||
// Check if y position did not change
|
// Check if y position did not change
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(110);
|
expect(API.getSelectedElements()[1].y).toEqual(110);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("aligns two objects correctly to the right", () => {
|
it("aligns two objects correctly to the right", () => {
|
||||||
createAndSelectTwoRectangles();
|
createAndSelectTwoRectangles();
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
@ -145,9 +146,9 @@ it("aligns two objects correctly to the right", () => {
|
|||||||
// Check if y position did not change
|
// Check if y position did not change
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(110);
|
expect(API.getSelectedElements()[1].y).toEqual(110);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("centers two objects with different sizes correctly vertically", () => {
|
it("centers two objects with different sizes correctly vertically", () => {
|
||||||
createAndSelectTwoRectanglesWithDifferentSizes();
|
createAndSelectTwoRectanglesWithDifferentSizes();
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
@ -164,9 +165,9 @@ it("centers two objects with different sizes correctly vertically", () => {
|
|||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(60);
|
expect(API.getSelectedElements()[0].y).toEqual(60);
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(55);
|
expect(API.getSelectedElements()[1].y).toEqual(55);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("centers two objects with different sizes correctly horizontally", () => {
|
it("centers two objects with different sizes correctly horizontally", () => {
|
||||||
createAndSelectTwoRectanglesWithDifferentSizes();
|
createAndSelectTwoRectanglesWithDifferentSizes();
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
@ -183,9 +184,9 @@ it("centers two objects with different sizes correctly horizontally", () => {
|
|||||||
// Check if y position did not change
|
// Check if y position did not change
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(110);
|
expect(API.getSelectedElements()[1].y).toEqual(110);
|
||||||
});
|
});
|
||||||
|
|
||||||
const createAndSelectGroupAndRectangle = () => {
|
const createAndSelectGroupAndRectangle = () => {
|
||||||
UI.clickTool("rectangle");
|
UI.clickTool("rectangle");
|
||||||
mouse.down();
|
mouse.down();
|
||||||
mouse.up(100, 100);
|
mouse.up(100, 100);
|
||||||
@ -213,9 +214,9 @@ const createAndSelectGroupAndRectangle = () => {
|
|||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
it("aligns a group with another element correctly to the top", () => {
|
it("aligns a group with another element correctly to the top", () => {
|
||||||
createAndSelectGroupAndRectangle();
|
createAndSelectGroupAndRectangle();
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
@ -227,9 +228,9 @@ it("aligns a group with another element correctly to the top", () => {
|
|||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
expect(API.getSelectedElements()[2].y).toEqual(0);
|
expect(API.getSelectedElements()[2].y).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("aligns a group with another element correctly to the bottom", () => {
|
it("aligns a group with another element correctly to the bottom", () => {
|
||||||
createAndSelectGroupAndRectangle();
|
createAndSelectGroupAndRectangle();
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
@ -241,9 +242,9 @@ it("aligns a group with another element correctly to the bottom", () => {
|
|||||||
expect(API.getSelectedElements()[0].y).toEqual(100);
|
expect(API.getSelectedElements()[0].y).toEqual(100);
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(200);
|
expect(API.getSelectedElements()[1].y).toEqual(200);
|
||||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("aligns a group with another element correctly to the left", () => {
|
it("aligns a group with another element correctly to the left", () => {
|
||||||
createAndSelectGroupAndRectangle();
|
createAndSelectGroupAndRectangle();
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
@ -255,9 +256,9 @@ it("aligns a group with another element correctly to the left", () => {
|
|||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
expect(API.getSelectedElements()[2].x).toEqual(0);
|
expect(API.getSelectedElements()[2].x).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("aligns a group with another element correctly to the right", () => {
|
it("aligns a group with another element correctly to the right", () => {
|
||||||
createAndSelectGroupAndRectangle();
|
createAndSelectGroupAndRectangle();
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
@ -269,9 +270,9 @@ it("aligns a group with another element correctly to the right", () => {
|
|||||||
expect(API.getSelectedElements()[0].x).toEqual(100);
|
expect(API.getSelectedElements()[0].x).toEqual(100);
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(200);
|
expect(API.getSelectedElements()[1].x).toEqual(200);
|
||||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("centers a group with another element correctly vertically", () => {
|
it("centers a group with another element correctly vertically", () => {
|
||||||
createAndSelectGroupAndRectangle();
|
createAndSelectGroupAndRectangle();
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
@ -283,9 +284,9 @@ it("centers a group with another element correctly vertically", () => {
|
|||||||
expect(API.getSelectedElements()[0].y).toEqual(50);
|
expect(API.getSelectedElements()[0].y).toEqual(50);
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(150);
|
expect(API.getSelectedElements()[1].y).toEqual(150);
|
||||||
expect(API.getSelectedElements()[2].y).toEqual(100);
|
expect(API.getSelectedElements()[2].y).toEqual(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("centers a group with another element correctly horizontally", () => {
|
it("centers a group with another element correctly horizontally", () => {
|
||||||
createAndSelectGroupAndRectangle();
|
createAndSelectGroupAndRectangle();
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
@ -297,9 +298,9 @@ it("centers a group with another element correctly horizontally", () => {
|
|||||||
expect(API.getSelectedElements()[0].x).toEqual(50);
|
expect(API.getSelectedElements()[0].x).toEqual(50);
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(150);
|
expect(API.getSelectedElements()[1].x).toEqual(150);
|
||||||
expect(API.getSelectedElements()[2].x).toEqual(100);
|
expect(API.getSelectedElements()[2].x).toEqual(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
const createAndSelectTwoGroups = () => {
|
const createAndSelectTwoGroups = () => {
|
||||||
UI.clickTool("rectangle");
|
UI.clickTool("rectangle");
|
||||||
mouse.down();
|
mouse.down();
|
||||||
mouse.up(100, 100);
|
mouse.up(100, 100);
|
||||||
@ -339,9 +340,9 @@ const createAndSelectTwoGroups = () => {
|
|||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
it("aligns two groups correctly to the top", () => {
|
it("aligns two groups correctly to the top", () => {
|
||||||
createAndSelectTwoGroups();
|
createAndSelectTwoGroups();
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
@ -355,9 +356,9 @@ it("aligns two groups correctly to the top", () => {
|
|||||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
expect(API.getSelectedElements()[2].y).toEqual(0);
|
expect(API.getSelectedElements()[2].y).toEqual(0);
|
||||||
expect(API.getSelectedElements()[3].y).toEqual(100);
|
expect(API.getSelectedElements()[3].y).toEqual(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("aligns two groups correctly to the bottom", () => {
|
it("aligns two groups correctly to the bottom", () => {
|
||||||
createAndSelectTwoGroups();
|
createAndSelectTwoGroups();
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
@ -371,9 +372,9 @@ it("aligns two groups correctly to the bottom", () => {
|
|||||||
expect(API.getSelectedElements()[1].y).toEqual(300);
|
expect(API.getSelectedElements()[1].y).toEqual(300);
|
||||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||||
expect(API.getSelectedElements()[3].y).toEqual(300);
|
expect(API.getSelectedElements()[3].y).toEqual(300);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("aligns two groups correctly to the left", () => {
|
it("aligns two groups correctly to the left", () => {
|
||||||
createAndSelectTwoGroups();
|
createAndSelectTwoGroups();
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
@ -387,9 +388,9 @@ it("aligns two groups correctly to the left", () => {
|
|||||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
expect(API.getSelectedElements()[2].x).toEqual(0);
|
expect(API.getSelectedElements()[2].x).toEqual(0);
|
||||||
expect(API.getSelectedElements()[3].x).toEqual(100);
|
expect(API.getSelectedElements()[3].x).toEqual(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("aligns two groups correctly to the right", () => {
|
it("aligns two groups correctly to the right", () => {
|
||||||
createAndSelectTwoGroups();
|
createAndSelectTwoGroups();
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
@ -403,9 +404,9 @@ it("aligns two groups correctly to the right", () => {
|
|||||||
expect(API.getSelectedElements()[1].x).toEqual(300);
|
expect(API.getSelectedElements()[1].x).toEqual(300);
|
||||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||||
expect(API.getSelectedElements()[3].x).toEqual(300);
|
expect(API.getSelectedElements()[3].x).toEqual(300);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("centers two groups correctly vertically", () => {
|
it("centers two groups correctly vertically", () => {
|
||||||
createAndSelectTwoGroups();
|
createAndSelectTwoGroups();
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
@ -419,9 +420,9 @@ it("centers two groups correctly vertically", () => {
|
|||||||
expect(API.getSelectedElements()[1].y).toEqual(200);
|
expect(API.getSelectedElements()[1].y).toEqual(200);
|
||||||
expect(API.getSelectedElements()[2].y).toEqual(100);
|
expect(API.getSelectedElements()[2].y).toEqual(100);
|
||||||
expect(API.getSelectedElements()[3].y).toEqual(200);
|
expect(API.getSelectedElements()[3].y).toEqual(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("centers two groups correctly horizontally", () => {
|
it("centers two groups correctly horizontally", () => {
|
||||||
createAndSelectTwoGroups();
|
createAndSelectTwoGroups();
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
@ -435,9 +436,9 @@ it("centers two groups correctly horizontally", () => {
|
|||||||
expect(API.getSelectedElements()[1].x).toEqual(200);
|
expect(API.getSelectedElements()[1].x).toEqual(200);
|
||||||
expect(API.getSelectedElements()[2].x).toEqual(100);
|
expect(API.getSelectedElements()[2].x).toEqual(100);
|
||||||
expect(API.getSelectedElements()[3].x).toEqual(200);
|
expect(API.getSelectedElements()[3].x).toEqual(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
const createAndSelectNestedGroupAndRectangle = () => {
|
const createAndSelectNestedGroupAndRectangle = () => {
|
||||||
UI.clickTool("rectangle");
|
UI.clickTool("rectangle");
|
||||||
mouse.down();
|
mouse.down();
|
||||||
mouse.up(100, 100);
|
mouse.up(100, 100);
|
||||||
@ -480,9 +481,9 @@ const createAndSelectNestedGroupAndRectangle = () => {
|
|||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
it("aligns nested group and other element correctly to the top", () => {
|
it("aligns nested group and other element correctly to the top", () => {
|
||||||
createAndSelectNestedGroupAndRectangle();
|
createAndSelectNestedGroupAndRectangle();
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
@ -496,9 +497,9 @@ it("aligns nested group and other element correctly to the top", () => {
|
|||||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||||
expect(API.getSelectedElements()[3].y).toEqual(0);
|
expect(API.getSelectedElements()[3].y).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("aligns nested group and other element correctly to the bottom", () => {
|
it("aligns nested group and other element correctly to the bottom", () => {
|
||||||
createAndSelectNestedGroupAndRectangle();
|
createAndSelectNestedGroupAndRectangle();
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
@ -512,9 +513,9 @@ it("aligns nested group and other element correctly to the bottom", () => {
|
|||||||
expect(API.getSelectedElements()[1].y).toEqual(200);
|
expect(API.getSelectedElements()[1].y).toEqual(200);
|
||||||
expect(API.getSelectedElements()[2].y).toEqual(300);
|
expect(API.getSelectedElements()[2].y).toEqual(300);
|
||||||
expect(API.getSelectedElements()[3].y).toEqual(300);
|
expect(API.getSelectedElements()[3].y).toEqual(300);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("aligns nested group and other element correctly to the left", () => {
|
it("aligns nested group and other element correctly to the left", () => {
|
||||||
createAndSelectNestedGroupAndRectangle();
|
createAndSelectNestedGroupAndRectangle();
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
@ -528,9 +529,9 @@ it("aligns nested group and other element correctly to the left", () => {
|
|||||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||||
expect(API.getSelectedElements()[3].x).toEqual(0);
|
expect(API.getSelectedElements()[3].x).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("aligns nested group and other element correctly to the right", () => {
|
it("aligns nested group and other element correctly to the right", () => {
|
||||||
createAndSelectNestedGroupAndRectangle();
|
createAndSelectNestedGroupAndRectangle();
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
@ -544,9 +545,9 @@ it("aligns nested group and other element correctly to the right", () => {
|
|||||||
expect(API.getSelectedElements()[1].x).toEqual(200);
|
expect(API.getSelectedElements()[1].x).toEqual(200);
|
||||||
expect(API.getSelectedElements()[2].x).toEqual(300);
|
expect(API.getSelectedElements()[2].x).toEqual(300);
|
||||||
expect(API.getSelectedElements()[3].x).toEqual(300);
|
expect(API.getSelectedElements()[3].x).toEqual(300);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("centers nested group and other element correctly vertically", () => {
|
it("centers nested group and other element correctly vertically", () => {
|
||||||
createAndSelectNestedGroupAndRectangle();
|
createAndSelectNestedGroupAndRectangle();
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
@ -560,9 +561,9 @@ it("centers nested group and other element correctly vertically", () => {
|
|||||||
expect(API.getSelectedElements()[1].y).toEqual(150);
|
expect(API.getSelectedElements()[1].y).toEqual(150);
|
||||||
expect(API.getSelectedElements()[2].y).toEqual(250);
|
expect(API.getSelectedElements()[2].y).toEqual(250);
|
||||||
expect(API.getSelectedElements()[3].y).toEqual(150);
|
expect(API.getSelectedElements()[3].y).toEqual(150);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("centers nested group and other element correctly horizontally", () => {
|
it("centers nested group and other element correctly horizontally", () => {
|
||||||
createAndSelectNestedGroupAndRectangle();
|
createAndSelectNestedGroupAndRectangle();
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
@ -576,4 +577,5 @@ it("centers nested group and other element correctly horizontally", () => {
|
|||||||
expect(API.getSelectedElements()[1].x).toEqual(150);
|
expect(API.getSelectedElements()[1].x).toEqual(150);
|
||||||
expect(API.getSelectedElements()[2].x).toEqual(250);
|
expect(API.getSelectedElements()[2].x).toEqual(250);
|
||||||
expect(API.getSelectedElements()[3].x).toEqual(150);
|
expect(API.getSelectedElements()[3].x).toEqual(150);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { render, waitFor } from "./test-utils";
|
import { render, waitFor } from "./test-utils";
|
||||||
import App from "../components/App";
|
import ExcalidrawApp from "../excalidraw-app";
|
||||||
import { API } from "./helpers/api";
|
import { API } from "./helpers/api";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
|
|
||||||
@ -10,18 +10,15 @@ describe("appState", () => {
|
|||||||
it("drag&drop file doesn't reset non-persisted appState", async () => {
|
it("drag&drop file doesn't reset non-persisted appState", async () => {
|
||||||
const defaultAppState = getDefaultAppState();
|
const defaultAppState = getDefaultAppState();
|
||||||
const exportBackground = !defaultAppState.exportBackground;
|
const exportBackground = !defaultAppState.exportBackground;
|
||||||
render(
|
|
||||||
<App
|
await render(<ExcalidrawApp />, {
|
||||||
initialData={{
|
localStorageData: {
|
||||||
appState: {
|
appState: {
|
||||||
...defaultAppState,
|
|
||||||
exportBackground,
|
exportBackground,
|
||||||
viewBackgroundColor: "#F00",
|
viewBackgroundColor: "#F00",
|
||||||
},
|
},
|
||||||
elements: [],
|
},
|
||||||
}}
|
});
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.state.exportBackground).toBe(exportBackground);
|
expect(h.state.exportBackground).toBe(exportBackground);
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { render } from "./test-utils";
|
import { render } from "./test-utils";
|
||||||
import App from "../components/App";
|
import ExcalidrawApp from "../excalidraw-app";
|
||||||
import { UI, Pointer, Keyboard } from "./helpers/ui";
|
import { UI, Pointer, Keyboard } from "./helpers/ui";
|
||||||
import { getTransformHandles } from "../element/transformHandles";
|
import { getTransformHandles } from "../element/transformHandles";
|
||||||
import { API } from "./helpers/api";
|
import { API } from "./helpers/api";
|
||||||
@ -11,8 +11,8 @@ const { h } = window;
|
|||||||
const mouse = new Pointer("mouse");
|
const mouse = new Pointer("mouse");
|
||||||
|
|
||||||
describe("element binding", () => {
|
describe("element binding", () => {
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
render(<App />);
|
await render(<ExcalidrawApp />);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rotation of arrow should rebind both ends", () => {
|
it("rotation of arrow should rebind both ends", () => {
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { render, waitFor } from "./test-utils";
|
import { render, updateSceneData, waitFor } from "./test-utils";
|
||||||
import App from "../components/App";
|
import ExcalidrawApp from "../excalidraw-app";
|
||||||
import { API } from "./helpers/api";
|
import { API } from "./helpers/api";
|
||||||
import { createUndoAction } from "../actions/actionHistory";
|
import { createUndoAction } from "../actions/actionHistory";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
Object.defineProperty(window, "crypto", {
|
Object.defineProperty(window, "crypto", {
|
||||||
@ -17,7 +16,7 @@ Object.defineProperty(window, "crypto", {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
jest.mock("../data/firebase.ts", () => {
|
jest.mock("../excalidraw-app/data/firebase.ts", () => {
|
||||||
const loadFromFirebase = async () => null;
|
const loadFromFirebase = async () => null;
|
||||||
const saveToFirebase = () => {};
|
const saveToFirebase = () => {};
|
||||||
const isSavedToFirebase = () => true;
|
const isSavedToFirebase = () => true;
|
||||||
@ -42,17 +41,18 @@ jest.mock("socket.io-client", () => {
|
|||||||
|
|
||||||
describe("collaboration", () => {
|
describe("collaboration", () => {
|
||||||
it("creating room should reset deleted elements", async () => {
|
it("creating room should reset deleted elements", async () => {
|
||||||
render(
|
await render(<ExcalidrawApp />);
|
||||||
<App
|
// To update the scene with deleted elements before starting collab
|
||||||
initialData={{
|
updateSceneData({
|
||||||
elements: [
|
elements: [
|
||||||
API.createElement({ type: "rectangle", id: "A" }),
|
API.createElement({ type: "rectangle", id: "A" }),
|
||||||
API.createElement({ type: "rectangle", id: "B", isDeleted: true }),
|
API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
id: "B",
|
||||||
|
isDeleted: true,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
}}
|
});
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
expect.objectContaining({ id: "A" }),
|
expect.objectContaining({ id: "A" }),
|
||||||
@ -60,8 +60,7 @@ describe("collaboration", () => {
|
|||||||
]);
|
]);
|
||||||
expect(API.getStateHistory().length).toBe(1);
|
expect(API.getStateHistory().length).toBe(1);
|
||||||
});
|
});
|
||||||
|
h.collab.openPortal();
|
||||||
await h.app.openPortal();
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
||||||
expect(API.getStateHistory().length).toBe(1);
|
expect(API.getStateHistory().length).toBe(1);
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import App from "../components/App";
|
import ExcalidrawApp from "../excalidraw-app";
|
||||||
import * as Renderer from "../renderer/renderScene";
|
import * as Renderer from "../renderer/renderScene";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { render, fireEvent } from "./test-utils";
|
import { render, fireEvent } from "./test-utils";
|
||||||
@ -20,8 +20,8 @@ beforeEach(() => {
|
|||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
describe("add element to the scene when pointer dragging long enough", () => {
|
describe("add element to the scene when pointer dragging long enough", () => {
|
||||||
it("rectangle", () => {
|
it("rectangle", async () => {
|
||||||
const { getByToolName, container } = render(<App />);
|
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||||
// select tool
|
// select tool
|
||||||
const tool = getByToolName("rectangle");
|
const tool = getByToolName("rectangle");
|
||||||
fireEvent.click(tool);
|
fireEvent.click(tool);
|
||||||
@ -37,7 +37,7 @@ describe("add element to the scene when pointer dragging long enough", () => {
|
|||||||
// finish (position does not matter)
|
// finish (position does not matter)
|
||||||
fireEvent.pointerUp(canvas);
|
fireEvent.pointerUp(canvas);
|
||||||
|
|
||||||
expect(renderScene).toHaveBeenCalledTimes(5);
|
expect(renderScene).toHaveBeenCalledTimes(6);
|
||||||
expect(h.state.selectionElement).toBeNull();
|
expect(h.state.selectionElement).toBeNull();
|
||||||
|
|
||||||
expect(h.elements.length).toEqual(1);
|
expect(h.elements.length).toEqual(1);
|
||||||
@ -51,8 +51,8 @@ describe("add element to the scene when pointer dragging long enough", () => {
|
|||||||
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ellipse", () => {
|
it("ellipse", async () => {
|
||||||
const { getByToolName, container } = render(<App />);
|
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||||
// select tool
|
// select tool
|
||||||
const tool = getByToolName("ellipse");
|
const tool = getByToolName("ellipse");
|
||||||
fireEvent.click(tool);
|
fireEvent.click(tool);
|
||||||
@ -68,7 +68,7 @@ describe("add element to the scene when pointer dragging long enough", () => {
|
|||||||
// finish (position does not matter)
|
// finish (position does not matter)
|
||||||
fireEvent.pointerUp(canvas);
|
fireEvent.pointerUp(canvas);
|
||||||
|
|
||||||
expect(renderScene).toHaveBeenCalledTimes(5);
|
expect(renderScene).toHaveBeenCalledTimes(6);
|
||||||
expect(h.state.selectionElement).toBeNull();
|
expect(h.state.selectionElement).toBeNull();
|
||||||
|
|
||||||
expect(h.elements.length).toEqual(1);
|
expect(h.elements.length).toEqual(1);
|
||||||
@ -82,8 +82,8 @@ describe("add element to the scene when pointer dragging long enough", () => {
|
|||||||
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("diamond", () => {
|
it("diamond", async () => {
|
||||||
const { getByToolName, container } = render(<App />);
|
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||||
// select tool
|
// select tool
|
||||||
const tool = getByToolName("diamond");
|
const tool = getByToolName("diamond");
|
||||||
fireEvent.click(tool);
|
fireEvent.click(tool);
|
||||||
@ -99,7 +99,7 @@ describe("add element to the scene when pointer dragging long enough", () => {
|
|||||||
// finish (position does not matter)
|
// finish (position does not matter)
|
||||||
fireEvent.pointerUp(canvas);
|
fireEvent.pointerUp(canvas);
|
||||||
|
|
||||||
expect(renderScene).toHaveBeenCalledTimes(5);
|
expect(renderScene).toHaveBeenCalledTimes(6);
|
||||||
expect(h.state.selectionElement).toBeNull();
|
expect(h.state.selectionElement).toBeNull();
|
||||||
|
|
||||||
expect(h.elements.length).toEqual(1);
|
expect(h.elements.length).toEqual(1);
|
||||||
@ -113,8 +113,8 @@ describe("add element to the scene when pointer dragging long enough", () => {
|
|||||||
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("arrow", () => {
|
it("arrow", async () => {
|
||||||
const { getByToolName, container } = render(<App />);
|
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||||
// select tool
|
// select tool
|
||||||
const tool = getByToolName("arrow");
|
const tool = getByToolName("arrow");
|
||||||
fireEvent.click(tool);
|
fireEvent.click(tool);
|
||||||
@ -130,7 +130,7 @@ describe("add element to the scene when pointer dragging long enough", () => {
|
|||||||
// finish (position does not matter)
|
// finish (position does not matter)
|
||||||
fireEvent.pointerUp(canvas);
|
fireEvent.pointerUp(canvas);
|
||||||
|
|
||||||
expect(renderScene).toHaveBeenCalledTimes(5);
|
expect(renderScene).toHaveBeenCalledTimes(6);
|
||||||
expect(h.state.selectionElement).toBeNull();
|
expect(h.state.selectionElement).toBeNull();
|
||||||
|
|
||||||
expect(h.elements.length).toEqual(1);
|
expect(h.elements.length).toEqual(1);
|
||||||
@ -148,8 +148,8 @@ describe("add element to the scene when pointer dragging long enough", () => {
|
|||||||
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("line", () => {
|
it("line", async () => {
|
||||||
const { getByToolName, container } = render(<App />);
|
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||||
// select tool
|
// select tool
|
||||||
const tool = getByToolName("line");
|
const tool = getByToolName("line");
|
||||||
fireEvent.click(tool);
|
fireEvent.click(tool);
|
||||||
@ -165,7 +165,7 @@ describe("add element to the scene when pointer dragging long enough", () => {
|
|||||||
// finish (position does not matter)
|
// finish (position does not matter)
|
||||||
fireEvent.pointerUp(canvas);
|
fireEvent.pointerUp(canvas);
|
||||||
|
|
||||||
expect(renderScene).toHaveBeenCalledTimes(5);
|
expect(renderScene).toHaveBeenCalledTimes(6);
|
||||||
expect(h.state.selectionElement).toBeNull();
|
expect(h.state.selectionElement).toBeNull();
|
||||||
|
|
||||||
expect(h.elements.length).toEqual(1);
|
expect(h.elements.length).toEqual(1);
|
||||||
@ -184,8 +184,8 @@ describe("add element to the scene when pointer dragging long enough", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("do not add element to the scene if size is too small", () => {
|
describe("do not add element to the scene if size is too small", () => {
|
||||||
it("rectangle", () => {
|
it("rectangle", async () => {
|
||||||
const { getByToolName, container } = render(<App />);
|
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||||
// select tool
|
// select tool
|
||||||
const tool = getByToolName("rectangle");
|
const tool = getByToolName("rectangle");
|
||||||
fireEvent.click(tool);
|
fireEvent.click(tool);
|
||||||
@ -198,13 +198,13 @@ describe("do not add element to the scene if size is too small", () => {
|
|||||||
// finish (position does not matter)
|
// finish (position does not matter)
|
||||||
fireEvent.pointerUp(canvas);
|
fireEvent.pointerUp(canvas);
|
||||||
|
|
||||||
expect(renderScene).toHaveBeenCalledTimes(4);
|
expect(renderScene).toHaveBeenCalledTimes(5);
|
||||||
expect(h.state.selectionElement).toBeNull();
|
expect(h.state.selectionElement).toBeNull();
|
||||||
expect(h.elements.length).toEqual(0);
|
expect(h.elements.length).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ellipse", () => {
|
it("ellipse", async () => {
|
||||||
const { getByToolName, container } = render(<App />);
|
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||||
// select tool
|
// select tool
|
||||||
const tool = getByToolName("ellipse");
|
const tool = getByToolName("ellipse");
|
||||||
fireEvent.click(tool);
|
fireEvent.click(tool);
|
||||||
@ -217,13 +217,13 @@ describe("do not add element to the scene if size is too small", () => {
|
|||||||
// finish (position does not matter)
|
// finish (position does not matter)
|
||||||
fireEvent.pointerUp(canvas);
|
fireEvent.pointerUp(canvas);
|
||||||
|
|
||||||
expect(renderScene).toHaveBeenCalledTimes(4);
|
expect(renderScene).toHaveBeenCalledTimes(5);
|
||||||
expect(h.state.selectionElement).toBeNull();
|
expect(h.state.selectionElement).toBeNull();
|
||||||
expect(h.elements.length).toEqual(0);
|
expect(h.elements.length).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("diamond", () => {
|
it("diamond", async () => {
|
||||||
const { getByToolName, container } = render(<App />);
|
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||||
// select tool
|
// select tool
|
||||||
const tool = getByToolName("diamond");
|
const tool = getByToolName("diamond");
|
||||||
fireEvent.click(tool);
|
fireEvent.click(tool);
|
||||||
@ -236,13 +236,13 @@ describe("do not add element to the scene if size is too small", () => {
|
|||||||
// finish (position does not matter)
|
// finish (position does not matter)
|
||||||
fireEvent.pointerUp(canvas);
|
fireEvent.pointerUp(canvas);
|
||||||
|
|
||||||
expect(renderScene).toHaveBeenCalledTimes(4);
|
expect(renderScene).toHaveBeenCalledTimes(5);
|
||||||
expect(h.state.selectionElement).toBeNull();
|
expect(h.state.selectionElement).toBeNull();
|
||||||
expect(h.elements.length).toEqual(0);
|
expect(h.elements.length).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("arrow", () => {
|
it("arrow", async () => {
|
||||||
const { getByToolName, container } = render(<App />);
|
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||||
// select tool
|
// select tool
|
||||||
const tool = getByToolName("arrow");
|
const tool = getByToolName("arrow");
|
||||||
fireEvent.click(tool);
|
fireEvent.click(tool);
|
||||||
@ -258,13 +258,13 @@ describe("do not add element to the scene if size is too small", () => {
|
|||||||
// we need to finalize it because arrows and lines enter multi-mode
|
// we need to finalize it because arrows and lines enter multi-mode
|
||||||
fireEvent.keyDown(document, { key: KEYS.ENTER });
|
fireEvent.keyDown(document, { key: KEYS.ENTER });
|
||||||
|
|
||||||
expect(renderScene).toHaveBeenCalledTimes(5);
|
expect(renderScene).toHaveBeenCalledTimes(6);
|
||||||
expect(h.state.selectionElement).toBeNull();
|
expect(h.state.selectionElement).toBeNull();
|
||||||
expect(h.elements.length).toEqual(0);
|
expect(h.elements.length).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("line", () => {
|
it("line", async () => {
|
||||||
const { getByToolName, container } = render(<App />);
|
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||||
// select tool
|
// select tool
|
||||||
const tool = getByToolName("line");
|
const tool = getByToolName("line");
|
||||||
fireEvent.click(tool);
|
fireEvent.click(tool);
|
||||||
@ -280,7 +280,7 @@ describe("do not add element to the scene if size is too small", () => {
|
|||||||
// we need to finalize it because arrows and lines enter multi-mode
|
// we need to finalize it because arrows and lines enter multi-mode
|
||||||
fireEvent.keyDown(document, { key: KEYS.ENTER });
|
fireEvent.keyDown(document, { key: KEYS.ENTER });
|
||||||
|
|
||||||
expect(renderScene).toHaveBeenCalledTimes(5);
|
expect(renderScene).toHaveBeenCalledTimes(6);
|
||||||
expect(h.state.selectionElement).toBeNull();
|
expect(h.state.selectionElement).toBeNull();
|
||||||
expect(h.elements.length).toEqual(0);
|
expect(h.elements.length).toEqual(0);
|
||||||
});
|
});
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { render, waitFor } from "./test-utils";
|
import { render, waitFor } from "./test-utils";
|
||||||
import App from "../components/App";
|
import ExcalidrawApp from "../excalidraw-app";
|
||||||
import { API } from "./helpers/api";
|
import { API } from "./helpers/api";
|
||||||
import {
|
import {
|
||||||
encodePngMetadata,
|
encodePngMetadata,
|
||||||
@ -38,8 +38,8 @@ Object.defineProperty(window, "TextDecoder", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("export", () => {
|
describe("export", () => {
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
render(<App />);
|
await render(<ExcalidrawApp />);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("export embedded png and reimport", async () => {
|
it("export embedded png and reimport", async () => {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { render } from "./test-utils";
|
import { render } from "./test-utils";
|
||||||
import App from "../components/App";
|
import ExcalidrawApp from "../excalidraw-app";
|
||||||
import { UI } from "./helpers/ui";
|
import { UI } from "./helpers/ui";
|
||||||
import { API } from "./helpers/api";
|
import { API } from "./helpers/api";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
@ -11,17 +11,14 @@ const { h } = window;
|
|||||||
|
|
||||||
describe("history", () => {
|
describe("history", () => {
|
||||||
it("initializing scene should end up with single history entry", async () => {
|
it("initializing scene should end up with single history entry", async () => {
|
||||||
render(
|
await render(<ExcalidrawApp />, {
|
||||||
<App
|
localStorageData: {
|
||||||
initialData={{
|
elements: [API.createElement({ type: "rectangle", id: "A" })],
|
||||||
appState: {
|
appState: {
|
||||||
...getDefaultAppState(),
|
|
||||||
zenModeEnabled: true,
|
zenModeEnabled: true,
|
||||||
},
|
},
|
||||||
elements: [API.createElement({ type: "rectangle", id: "A" })],
|
},
|
||||||
}}
|
});
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => expect(h.state.zenModeEnabled).toBe(true));
|
await waitFor(() => expect(h.state.zenModeEnabled).toBe(true));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
@ -61,17 +58,14 @@ describe("history", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("scene import via drag&drop should create new history entry", async () => {
|
it("scene import via drag&drop should create new history entry", async () => {
|
||||||
render(
|
await render(<ExcalidrawApp />, {
|
||||||
<App
|
localStorageData: {
|
||||||
initialData={{
|
elements: [API.createElement({ type: "rectangle", id: "A" })],
|
||||||
appState: {
|
appState: {
|
||||||
...getDefaultAppState(),
|
|
||||||
viewBackgroundColor: "#FFF",
|
viewBackgroundColor: "#FFF",
|
||||||
},
|
},
|
||||||
elements: [API.createElement({ type: "rectangle", id: "A" })],
|
},
|
||||||
}}
|
});
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => expect(h.state.viewBackgroundColor).toBe("#FFF"));
|
await waitFor(() => expect(h.state.viewBackgroundColor).toBe("#FFF"));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { render, waitFor } from "./test-utils";
|
import { render, waitFor } from "./test-utils";
|
||||||
import App from "../components/App";
|
import ExcalidrawApp from "../excalidraw-app";
|
||||||
import { API } from "./helpers/api";
|
import { API } from "./helpers/api";
|
||||||
import { MIME_TYPES } from "../constants";
|
import { MIME_TYPES } from "../constants";
|
||||||
import { LibraryItem } from "../types";
|
import { LibraryItem } from "../types";
|
||||||
@ -8,9 +8,9 @@ import { LibraryItem } from "../types";
|
|||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
describe("library", () => {
|
describe("library", () => {
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
h.library.resetLibrary();
|
h.library.resetLibrary();
|
||||||
render(<App />);
|
await render(<ExcalidrawApp />);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("import library via drag&drop", async () => {
|
it("import library via drag&drop", async () => {
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import { render, fireEvent } from "./test-utils";
|
import { render, fireEvent } from "./test-utils";
|
||||||
import App from "../components/App";
|
import ExcalidrawApp from "../excalidraw-app";
|
||||||
import * as Renderer from "../renderer/renderScene";
|
import * as Renderer from "../renderer/renderScene";
|
||||||
import { reseed } from "../random";
|
import { reseed } from "../random";
|
||||||
import { bindOrUnbindLinearElement } from "../element/binding";
|
import { bindOrUnbindLinearElement } from "../element/binding";
|
||||||
@ -26,8 +26,8 @@ beforeEach(() => {
|
|||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
describe("move element", () => {
|
describe("move element", () => {
|
||||||
it("rectangle", () => {
|
it("rectangle", async () => {
|
||||||
const { getByToolName, container } = render(<App />);
|
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||||
const canvas = container.querySelector("canvas")!;
|
const canvas = container.querySelector("canvas")!;
|
||||||
|
|
||||||
{
|
{
|
||||||
@ -38,7 +38,7 @@ describe("move element", () => {
|
|||||||
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
|
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
|
||||||
fireEvent.pointerUp(canvas);
|
fireEvent.pointerUp(canvas);
|
||||||
|
|
||||||
expect(renderScene).toHaveBeenCalledTimes(5);
|
expect(renderScene).toHaveBeenCalledTimes(6);
|
||||||
expect(h.state.selectionElement).toBeNull();
|
expect(h.state.selectionElement).toBeNull();
|
||||||
expect(h.elements.length).toEqual(1);
|
expect(h.elements.length).toEqual(1);
|
||||||
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
||||||
@ -59,8 +59,8 @@ describe("move element", () => {
|
|||||||
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rectangles with binding arrow", () => {
|
it("rectangles with binding arrow", async () => {
|
||||||
render(<App />);
|
await render(<ExcalidrawApp />);
|
||||||
|
|
||||||
// create elements
|
// create elements
|
||||||
const rectA = UI.createElement("rectangle", { size: 100 });
|
const rectA = UI.createElement("rectangle", { size: 100 });
|
||||||
@ -77,7 +77,7 @@ describe("move element", () => {
|
|||||||
// select the second rectangles
|
// select the second rectangles
|
||||||
new Pointer("mouse").clickOn(rectB);
|
new Pointer("mouse").clickOn(rectB);
|
||||||
|
|
||||||
expect(renderScene).toHaveBeenCalledTimes(19);
|
expect(renderScene).toHaveBeenCalledTimes(20);
|
||||||
expect(h.state.selectionElement).toBeNull();
|
expect(h.state.selectionElement).toBeNull();
|
||||||
expect(h.elements.length).toEqual(3);
|
expect(h.elements.length).toEqual(3);
|
||||||
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
|
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
|
||||||
@ -108,8 +108,8 @@ describe("move element", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("duplicate element on move when ALT is clicked", () => {
|
describe("duplicate element on move when ALT is clicked", () => {
|
||||||
it("rectangle", () => {
|
it("rectangle", async () => {
|
||||||
const { getByToolName, container } = render(<App />);
|
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||||
const canvas = container.querySelector("canvas")!;
|
const canvas = container.querySelector("canvas")!;
|
||||||
|
|
||||||
{
|
{
|
||||||
@ -120,7 +120,7 @@ describe("duplicate element on move when ALT is clicked", () => {
|
|||||||
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
|
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
|
||||||
fireEvent.pointerUp(canvas);
|
fireEvent.pointerUp(canvas);
|
||||||
|
|
||||||
expect(renderScene).toHaveBeenCalledTimes(5);
|
expect(renderScene).toHaveBeenCalledTimes(6);
|
||||||
expect(h.state.selectionElement).toBeNull();
|
expect(h.state.selectionElement).toBeNull();
|
||||||
expect(h.elements.length).toEqual(1);
|
expect(h.elements.length).toEqual(1);
|
||||||
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import { render, fireEvent } from "./test-utils";
|
import { render, fireEvent } from "./test-utils";
|
||||||
import App from "../components/App";
|
import ExcalidrawApp from "../excalidraw-app";
|
||||||
import * as Renderer from "../renderer/renderScene";
|
import * as Renderer from "../renderer/renderScene";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { ExcalidrawLinearElement } from "../element/types";
|
import { ExcalidrawLinearElement } from "../element/types";
|
||||||
@ -20,8 +20,8 @@ beforeEach(() => {
|
|||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
describe("remove shape in non linear elements", () => {
|
describe("remove shape in non linear elements", () => {
|
||||||
it("rectangle", () => {
|
it("rectangle", async () => {
|
||||||
const { getByToolName, container } = render(<App />);
|
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||||
// select tool
|
// select tool
|
||||||
const tool = getByToolName("rectangle");
|
const tool = getByToolName("rectangle");
|
||||||
fireEvent.click(tool);
|
fireEvent.click(tool);
|
||||||
@ -30,12 +30,12 @@ describe("remove shape in non linear elements", () => {
|
|||||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||||
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
|
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
|
||||||
|
|
||||||
expect(renderScene).toHaveBeenCalledTimes(4);
|
expect(renderScene).toHaveBeenCalledTimes(5);
|
||||||
expect(h.elements.length).toEqual(0);
|
expect(h.elements.length).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ellipse", () => {
|
it("ellipse", async () => {
|
||||||
const { getByToolName, container } = render(<App />);
|
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||||
// select tool
|
// select tool
|
||||||
const tool = getByToolName("ellipse");
|
const tool = getByToolName("ellipse");
|
||||||
fireEvent.click(tool);
|
fireEvent.click(tool);
|
||||||
@ -44,12 +44,12 @@ describe("remove shape in non linear elements", () => {
|
|||||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||||
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
|
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
|
||||||
|
|
||||||
expect(renderScene).toHaveBeenCalledTimes(4);
|
expect(renderScene).toHaveBeenCalledTimes(5);
|
||||||
expect(h.elements.length).toEqual(0);
|
expect(h.elements.length).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("diamond", () => {
|
it("diamond", async () => {
|
||||||
const { getByToolName, container } = render(<App />);
|
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||||
// select tool
|
// select tool
|
||||||
const tool = getByToolName("diamond");
|
const tool = getByToolName("diamond");
|
||||||
fireEvent.click(tool);
|
fireEvent.click(tool);
|
||||||
@ -58,14 +58,14 @@ describe("remove shape in non linear elements", () => {
|
|||||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||||
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
|
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
|
||||||
|
|
||||||
expect(renderScene).toHaveBeenCalledTimes(4);
|
expect(renderScene).toHaveBeenCalledTimes(5);
|
||||||
expect(h.elements.length).toEqual(0);
|
expect(h.elements.length).toEqual(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("multi point mode in linear elements", () => {
|
describe("multi point mode in linear elements", () => {
|
||||||
it("arrow", () => {
|
it("arrow", async () => {
|
||||||
const { getByToolName, container } = render(<App />);
|
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||||
// select tool
|
// select tool
|
||||||
const tool = getByToolName("arrow");
|
const tool = getByToolName("arrow");
|
||||||
fireEvent.click(tool);
|
fireEvent.click(tool);
|
||||||
@ -88,7 +88,7 @@ describe("multi point mode in linear elements", () => {
|
|||||||
fireEvent.pointerUp(canvas);
|
fireEvent.pointerUp(canvas);
|
||||||
fireEvent.keyDown(document, { key: KEYS.ENTER });
|
fireEvent.keyDown(document, { key: KEYS.ENTER });
|
||||||
|
|
||||||
expect(renderScene).toHaveBeenCalledTimes(11);
|
expect(renderScene).toHaveBeenCalledTimes(12);
|
||||||
expect(h.elements.length).toEqual(1);
|
expect(h.elements.length).toEqual(1);
|
||||||
|
|
||||||
const element = h.elements[0] as ExcalidrawLinearElement;
|
const element = h.elements[0] as ExcalidrawLinearElement;
|
||||||
@ -105,8 +105,8 @@ describe("multi point mode in linear elements", () => {
|
|||||||
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("line", () => {
|
it("line", async () => {
|
||||||
const { getByToolName, container } = render(<App />);
|
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||||
// select tool
|
// select tool
|
||||||
const tool = getByToolName("line");
|
const tool = getByToolName("line");
|
||||||
fireEvent.click(tool);
|
fireEvent.click(tool);
|
||||||
@ -129,7 +129,7 @@ describe("multi point mode in linear elements", () => {
|
|||||||
fireEvent.pointerUp(canvas);
|
fireEvent.pointerUp(canvas);
|
||||||
fireEvent.keyDown(document, { key: KEYS.ENTER });
|
fireEvent.keyDown(document, { key: KEYS.ENTER });
|
||||||
|
|
||||||
expect(renderScene).toHaveBeenCalledTimes(11);
|
expect(renderScene).toHaveBeenCalledTimes(12);
|
||||||
expect(h.elements.length).toEqual(1);
|
expect(h.elements.length).toEqual(1);
|
||||||
|
|
||||||
const element = h.elements[0] as ExcalidrawLinearElement;
|
const element = h.elements[0] as ExcalidrawLinearElement;
|
||||||
|
@ -9,7 +9,7 @@ import {
|
|||||||
fireEvent,
|
fireEvent,
|
||||||
GlobalTestState,
|
GlobalTestState,
|
||||||
} from "./test-utils";
|
} from "./test-utils";
|
||||||
import App from "../components/App";
|
import Excalidraw from "../packages/excalidraw/index";
|
||||||
import { setLanguage } from "../i18n";
|
import { setLanguage } from "../i18n";
|
||||||
import { setDateTimeForTests } from "../utils";
|
import { setDateTimeForTests } from "../utils";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
@ -97,7 +97,7 @@ beforeEach(async () => {
|
|||||||
finger2.reset();
|
finger2.reset();
|
||||||
|
|
||||||
await setLanguage("en.json");
|
await setLanguage("en.json");
|
||||||
render(<App offsetLeft={0} offsetTop={0} />);
|
await render(<Excalidraw offsetLeft={0} offsetTop={0} />);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import { render, fireEvent } from "./test-utils";
|
import { render, fireEvent } from "./test-utils";
|
||||||
import App from "../components/App";
|
import ExcalidrawApp from "../excalidraw-app";
|
||||||
import * as Renderer from "../renderer/renderScene";
|
import * as Renderer from "../renderer/renderScene";
|
||||||
import { reseed } from "../random";
|
import { reseed } from "../random";
|
||||||
import { UI, Pointer, Keyboard } from "./helpers/ui";
|
import { UI, Pointer, Keyboard } from "./helpers/ui";
|
||||||
@ -22,8 +22,8 @@ beforeEach(() => {
|
|||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
describe("resize element", () => {
|
describe("resize element", () => {
|
||||||
it("rectangle", () => {
|
it("rectangle", async () => {
|
||||||
const { getByToolName, container } = render(<App />);
|
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||||
const canvas = container.querySelector("canvas")!;
|
const canvas = container.querySelector("canvas")!;
|
||||||
|
|
||||||
{
|
{
|
||||||
@ -34,7 +34,7 @@ describe("resize element", () => {
|
|||||||
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
|
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
|
||||||
fireEvent.pointerUp(canvas);
|
fireEvent.pointerUp(canvas);
|
||||||
|
|
||||||
expect(renderScene).toHaveBeenCalledTimes(5);
|
expect(renderScene).toHaveBeenCalledTimes(6);
|
||||||
expect(h.state.selectionElement).toBeNull();
|
expect(h.state.selectionElement).toBeNull();
|
||||||
expect(h.elements.length).toEqual(1);
|
expect(h.elements.length).toEqual(1);
|
||||||
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
||||||
@ -65,8 +65,8 @@ describe("resize element", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("resize element with aspect ratio when SHIFT is clicked", () => {
|
describe("resize element with aspect ratio when SHIFT is clicked", () => {
|
||||||
it("rectangle", () => {
|
it("rectangle", async () => {
|
||||||
render(<App />);
|
await render(<ExcalidrawApp />);
|
||||||
|
|
||||||
const rectangle = UI.createElement("rectangle", {
|
const rectangle = UI.createElement("rectangle", {
|
||||||
x: 0,
|
x: 0,
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import { render, fireEvent } from "./test-utils";
|
import { render, fireEvent } from "./test-utils";
|
||||||
import App from "../components/App";
|
import ExcalidrawApp from "../excalidraw-app";
|
||||||
import * as Renderer from "../renderer/renderScene";
|
import * as Renderer from "../renderer/renderScene";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { reseed } from "../random";
|
import { reseed } from "../random";
|
||||||
@ -19,8 +19,8 @@ beforeEach(() => {
|
|||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
describe("selection element", () => {
|
describe("selection element", () => {
|
||||||
it("create selection element on pointer down", () => {
|
it("create selection element on pointer down", async () => {
|
||||||
const { getByToolName, container } = render(<App />);
|
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||||
// select tool
|
// select tool
|
||||||
const tool = getByToolName("selection");
|
const tool = getByToolName("selection");
|
||||||
fireEvent.click(tool);
|
fireEvent.click(tool);
|
||||||
@ -28,7 +28,7 @@ describe("selection element", () => {
|
|||||||
const canvas = container.querySelector("canvas")!;
|
const canvas = container.querySelector("canvas")!;
|
||||||
fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
|
fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
|
||||||
|
|
||||||
expect(renderScene).toHaveBeenCalledTimes(2);
|
expect(renderScene).toHaveBeenCalledTimes(3);
|
||||||
const selectionElement = h.state.selectionElement!;
|
const selectionElement = h.state.selectionElement!;
|
||||||
expect(selectionElement).not.toBeNull();
|
expect(selectionElement).not.toBeNull();
|
||||||
expect(selectionElement.type).toEqual("selection");
|
expect(selectionElement.type).toEqual("selection");
|
||||||
@ -39,8 +39,8 @@ describe("selection element", () => {
|
|||||||
fireEvent.pointerUp(canvas);
|
fireEvent.pointerUp(canvas);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resize selection element on pointer move", () => {
|
it("resize selection element on pointer move", async () => {
|
||||||
const { getByToolName, container } = render(<App />);
|
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||||
// select tool
|
// select tool
|
||||||
const tool = getByToolName("selection");
|
const tool = getByToolName("selection");
|
||||||
fireEvent.click(tool);
|
fireEvent.click(tool);
|
||||||
@ -49,7 +49,7 @@ describe("selection element", () => {
|
|||||||
fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
|
fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
|
||||||
fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 });
|
fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 });
|
||||||
|
|
||||||
expect(renderScene).toHaveBeenCalledTimes(3);
|
expect(renderScene).toHaveBeenCalledTimes(4);
|
||||||
const selectionElement = h.state.selectionElement!;
|
const selectionElement = h.state.selectionElement!;
|
||||||
expect(selectionElement).not.toBeNull();
|
expect(selectionElement).not.toBeNull();
|
||||||
expect(selectionElement.type).toEqual("selection");
|
expect(selectionElement.type).toEqual("selection");
|
||||||
@ -60,8 +60,8 @@ describe("selection element", () => {
|
|||||||
fireEvent.pointerUp(canvas);
|
fireEvent.pointerUp(canvas);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("remove selection element on pointer up", () => {
|
it("remove selection element on pointer up", async () => {
|
||||||
const { getByToolName, container } = render(<App />);
|
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||||
// select tool
|
// select tool
|
||||||
const tool = getByToolName("selection");
|
const tool = getByToolName("selection");
|
||||||
fireEvent.click(tool);
|
fireEvent.click(tool);
|
||||||
@ -71,14 +71,14 @@ describe("selection element", () => {
|
|||||||
fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 });
|
fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 });
|
||||||
fireEvent.pointerUp(canvas);
|
fireEvent.pointerUp(canvas);
|
||||||
|
|
||||||
expect(renderScene).toHaveBeenCalledTimes(4);
|
expect(renderScene).toHaveBeenCalledTimes(5);
|
||||||
expect(h.state.selectionElement).toBeNull();
|
expect(h.state.selectionElement).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("select single element on the scene", () => {
|
describe("select single element on the scene", () => {
|
||||||
it("rectangle", () => {
|
it("rectangle", async () => {
|
||||||
const { getByToolName, container } = render(<App />);
|
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||||
const canvas = container.querySelector("canvas")!;
|
const canvas = container.querySelector("canvas")!;
|
||||||
{
|
{
|
||||||
// create element
|
// create element
|
||||||
@ -96,7 +96,7 @@ describe("select single element on the scene", () => {
|
|||||||
fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
|
fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
|
||||||
fireEvent.pointerUp(canvas);
|
fireEvent.pointerUp(canvas);
|
||||||
|
|
||||||
expect(renderScene).toHaveBeenCalledTimes(8);
|
expect(renderScene).toHaveBeenCalledTimes(9);
|
||||||
expect(h.state.selectionElement).toBeNull();
|
expect(h.state.selectionElement).toBeNull();
|
||||||
expect(h.elements.length).toEqual(1);
|
expect(h.elements.length).toEqual(1);
|
||||||
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
||||||
@ -104,8 +104,8 @@ describe("select single element on the scene", () => {
|
|||||||
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("diamond", () => {
|
it("diamond", async () => {
|
||||||
const { getByToolName, container } = render(<App />);
|
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||||
const canvas = container.querySelector("canvas")!;
|
const canvas = container.querySelector("canvas")!;
|
||||||
{
|
{
|
||||||
// create element
|
// create element
|
||||||
@ -123,7 +123,7 @@ describe("select single element on the scene", () => {
|
|||||||
fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
|
fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
|
||||||
fireEvent.pointerUp(canvas);
|
fireEvent.pointerUp(canvas);
|
||||||
|
|
||||||
expect(renderScene).toHaveBeenCalledTimes(8);
|
expect(renderScene).toHaveBeenCalledTimes(9);
|
||||||
expect(h.state.selectionElement).toBeNull();
|
expect(h.state.selectionElement).toBeNull();
|
||||||
expect(h.elements.length).toEqual(1);
|
expect(h.elements.length).toEqual(1);
|
||||||
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
||||||
@ -131,8 +131,8 @@ describe("select single element on the scene", () => {
|
|||||||
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ellipse", () => {
|
it("ellipse", async () => {
|
||||||
const { getByToolName, container } = render(<App />);
|
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||||
const canvas = container.querySelector("canvas")!;
|
const canvas = container.querySelector("canvas")!;
|
||||||
{
|
{
|
||||||
// create element
|
// create element
|
||||||
@ -150,7 +150,7 @@ describe("select single element on the scene", () => {
|
|||||||
fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
|
fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
|
||||||
fireEvent.pointerUp(canvas);
|
fireEvent.pointerUp(canvas);
|
||||||
|
|
||||||
expect(renderScene).toHaveBeenCalledTimes(8);
|
expect(renderScene).toHaveBeenCalledTimes(9);
|
||||||
expect(h.state.selectionElement).toBeNull();
|
expect(h.state.selectionElement).toBeNull();
|
||||||
expect(h.elements.length).toEqual(1);
|
expect(h.elements.length).toEqual(1);
|
||||||
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
||||||
@ -158,8 +158,8 @@ describe("select single element on the scene", () => {
|
|||||||
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("arrow", () => {
|
it("arrow", async () => {
|
||||||
const { getByToolName, container } = render(<App />);
|
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||||
const canvas = container.querySelector("canvas")!;
|
const canvas = container.querySelector("canvas")!;
|
||||||
{
|
{
|
||||||
// create element
|
// create element
|
||||||
@ -190,15 +190,15 @@ describe("select single element on the scene", () => {
|
|||||||
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
|
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
|
||||||
fireEvent.pointerUp(canvas);
|
fireEvent.pointerUp(canvas);
|
||||||
|
|
||||||
expect(renderScene).toHaveBeenCalledTimes(8);
|
expect(renderScene).toHaveBeenCalledTimes(9);
|
||||||
expect(h.state.selectionElement).toBeNull();
|
expect(h.state.selectionElement).toBeNull();
|
||||||
expect(h.elements.length).toEqual(1);
|
expect(h.elements.length).toEqual(1);
|
||||||
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
||||||
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("arrow escape", () => {
|
it("arrow escape", async () => {
|
||||||
const { getByToolName, container } = render(<App />);
|
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||||
const canvas = container.querySelector("canvas")!;
|
const canvas = container.querySelector("canvas")!;
|
||||||
{
|
{
|
||||||
// create element
|
// create element
|
||||||
@ -229,7 +229,7 @@ describe("select single element on the scene", () => {
|
|||||||
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
|
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
|
||||||
fireEvent.pointerUp(canvas);
|
fireEvent.pointerUp(canvas);
|
||||||
|
|
||||||
expect(renderScene).toHaveBeenCalledTimes(8);
|
expect(renderScene).toHaveBeenCalledTimes(9);
|
||||||
expect(h.state.selectionElement).toBeNull();
|
expect(h.state.selectionElement).toBeNull();
|
||||||
expect(h.elements.length).toEqual(1);
|
expect(h.elements.length).toEqual(1);
|
||||||
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
||||||
|
@ -5,9 +5,14 @@ import {
|
|||||||
queries,
|
queries,
|
||||||
RenderResult,
|
RenderResult,
|
||||||
RenderOptions,
|
RenderOptions,
|
||||||
|
waitFor,
|
||||||
} from "@testing-library/react";
|
} from "@testing-library/react";
|
||||||
|
|
||||||
import * as toolQueries from "./queries/toolQueries";
|
import * as toolQueries from "./queries/toolQueries";
|
||||||
|
import { ImportedDataState } from "../data/types";
|
||||||
|
import { STORAGE_KEYS } from "../excalidraw-app/data/localStorage";
|
||||||
|
|
||||||
|
import { SceneData } from "../types";
|
||||||
|
|
||||||
const customQueries = {
|
const customQueries = {
|
||||||
...queries,
|
...queries,
|
||||||
@ -16,17 +21,40 @@ const customQueries = {
|
|||||||
|
|
||||||
type TestRenderFn = (
|
type TestRenderFn = (
|
||||||
ui: React.ReactElement,
|
ui: React.ReactElement,
|
||||||
options?: Omit<RenderOptions, "queries">,
|
options?: Omit<
|
||||||
) => RenderResult<typeof customQueries>;
|
RenderOptions & { localStorageData?: ImportedDataState },
|
||||||
|
"queries"
|
||||||
|
>,
|
||||||
|
) => Promise<RenderResult<typeof customQueries>>;
|
||||||
|
|
||||||
|
const renderApp: TestRenderFn = async (ui, options) => {
|
||||||
|
if (options?.localStorageData) {
|
||||||
|
initLocalStorage(options.localStorageData);
|
||||||
|
delete options.localStorageData;
|
||||||
|
}
|
||||||
|
|
||||||
const renderApp: TestRenderFn = (ui, options) => {
|
|
||||||
const renderResult = render(ui, {
|
const renderResult = render(ui, {
|
||||||
queries: customQueries,
|
queries: customQueries,
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
|
|
||||||
GlobalTestState.renderResult = renderResult;
|
GlobalTestState.renderResult = renderResult;
|
||||||
GlobalTestState.canvas = renderResult.container.querySelector("canvas")!;
|
|
||||||
|
Object.defineProperty(GlobalTestState, "canvas", {
|
||||||
|
// must be a getter because at the time of ExcalidrawApp render the
|
||||||
|
// child App component isn't likely mounted yet (and thus canvas not
|
||||||
|
// present in DOM)
|
||||||
|
get() {
|
||||||
|
return renderResult.container.querySelector("canvas")!;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const canvas = renderResult.container.querySelector("canvas");
|
||||||
|
if (!canvas) {
|
||||||
|
throw new Error("not initialized yet");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return renderResult;
|
return renderResult;
|
||||||
};
|
};
|
||||||
@ -49,7 +77,28 @@ export class GlobalTestState {
|
|||||||
*/
|
*/
|
||||||
static renderResult: RenderResult<typeof customQueries> = null!;
|
static renderResult: RenderResult<typeof customQueries> = null!;
|
||||||
/**
|
/**
|
||||||
* automatically updated on each call to render()
|
* retrieves canvas for currently rendered app instance
|
||||||
*/
|
*/
|
||||||
static canvas: HTMLCanvasElement = null!;
|
static get canvas(): HTMLCanvasElement {
|
||||||
|
return null!;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const initLocalStorage = (data: ImportedDataState) => {
|
||||||
|
if (data.elements) {
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
||||||
|
JSON.stringify(data.elements),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data.appState) {
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
|
||||||
|
JSON.stringify(data.appState),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateSceneData = (data: SceneData) => {
|
||||||
|
(window.h.collab as any).excalidrawRef.current.updateScene(data);
|
||||||
|
};
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import { render } from "./test-utils";
|
import { render } from "./test-utils";
|
||||||
import App from "../components/App";
|
import ExcalidrawApp from "../excalidraw-app";
|
||||||
import { reseed } from "../random";
|
import { reseed } from "../random";
|
||||||
import {
|
import {
|
||||||
actionSendBackward,
|
actionSendBackward,
|
||||||
@ -107,8 +107,8 @@ const assertZindex = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("z-index manipulation", () => {
|
describe("z-index manipulation", () => {
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
render(<App />);
|
await render(<ExcalidrawApp />);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("send back", () => {
|
it("send back", () => {
|
||||||
|
@ -1,6 +0,0 @@
|
|||||||
// time in milliseconds
|
|
||||||
export const TAP_TWICE_TIMEOUT = 300;
|
|
||||||
export const INITIAL_SCENE_UPDATE_TIMEOUT = 5000;
|
|
||||||
export const SYNC_FULL_SCENE_INTERVAL_MS = 20000;
|
|
||||||
export const TOUCH_CTX_MENU_TIMEOUT = 500;
|
|
||||||
export const SAVE_TO_LOCAL_STORAGE_TIMEOUT = 300;
|
|
43
src/types.ts
43
src/types.ts
@ -11,11 +11,11 @@ import {
|
|||||||
} from "./element/types";
|
} from "./element/types";
|
||||||
import { SHAPES } from "./shapes";
|
import { SHAPES } from "./shapes";
|
||||||
import { Point as RoughPoint } from "roughjs/bin/geometry";
|
import { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||||
import { SocketUpdateDataSource } from "./data";
|
|
||||||
import { LinearElementEditor } from "./element/linearElementEditor";
|
import { LinearElementEditor } from "./element/linearElementEditor";
|
||||||
import { SuggestedBinding } from "./element/binding";
|
import { SuggestedBinding } from "./element/binding";
|
||||||
import { ImportedDataState } from "./data/types";
|
import { ImportedDataState } from "./data/types";
|
||||||
import { ExcalidrawImperativeAPI } from "./components/App";
|
import { ExcalidrawImperativeAPI } from "./components/App";
|
||||||
|
import type { ResolvablePromise } from "./utils";
|
||||||
|
|
||||||
export type FlooredNumber = number & { _brand: "FlooredNumber" };
|
export type FlooredNumber = number & { _brand: "FlooredNumber" };
|
||||||
export type Point = Readonly<RoughPoint>;
|
export type Point = Readonly<RoughPoint>;
|
||||||
@ -69,8 +69,6 @@ export type AppState = {
|
|||||||
cursorButton: "up" | "down";
|
cursorButton: "up" | "down";
|
||||||
scrolledOutside: boolean;
|
scrolledOutside: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
username: string;
|
|
||||||
isCollaborating: boolean;
|
|
||||||
isResizing: boolean;
|
isResizing: boolean;
|
||||||
isRotating: boolean;
|
isRotating: boolean;
|
||||||
zoom: Zoom;
|
zoom: Zoom;
|
||||||
@ -78,7 +76,6 @@ export type AppState = {
|
|||||||
lastPointerDownWith: PointerType;
|
lastPointerDownWith: PointerType;
|
||||||
selectedElementIds: { [id: string]: boolean };
|
selectedElementIds: { [id: string]: boolean };
|
||||||
previousSelectedElementIds: { [id: string]: boolean };
|
previousSelectedElementIds: { [id: string]: boolean };
|
||||||
collaborators: Map<string, Collaborator>;
|
|
||||||
shouldCacheIgnoreZoom: boolean;
|
shouldCacheIgnoreZoom: boolean;
|
||||||
showShortcutsDialog: boolean;
|
showShortcutsDialog: boolean;
|
||||||
zenModeEnabled: boolean;
|
zenModeEnabled: boolean;
|
||||||
@ -97,6 +94,7 @@ export type AppState = {
|
|||||||
|
|
||||||
isLibraryOpen: boolean;
|
isLibraryOpen: boolean;
|
||||||
fileHandle: import("browser-nativefs").FileSystemHandle | null;
|
fileHandle: import("browser-nativefs").FileSystemHandle | null;
|
||||||
|
collaborators: Map<string, Collaborator>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NormalizedZoomValue = number & { _brand: "normalizedZoom" };
|
export type NormalizedZoomValue = number & { _brand: "normalizedZoom" };
|
||||||
@ -126,16 +124,22 @@ export declare class GestureEvent extends UIEvent {
|
|||||||
readonly scale: number;
|
readonly scale: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SocketUpdateData = SocketUpdateDataSource[keyof SocketUpdateDataSource] & {
|
|
||||||
_brand: "socketUpdateData";
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LibraryItem = readonly NonDeleted<ExcalidrawElement>[];
|
export type LibraryItem = readonly NonDeleted<ExcalidrawElement>[];
|
||||||
export type LibraryItems = readonly LibraryItem[];
|
export type LibraryItems = readonly LibraryItem[];
|
||||||
|
|
||||||
|
export type ExcalidrawAPIRefValue =
|
||||||
|
| (ExcalidrawImperativeAPI & {
|
||||||
|
readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>;
|
||||||
|
ready: true;
|
||||||
|
})
|
||||||
|
| {
|
||||||
|
readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>;
|
||||||
|
ready: false;
|
||||||
|
};
|
||||||
|
|
||||||
export interface ExcalidrawProps {
|
export interface ExcalidrawProps {
|
||||||
width: number;
|
width?: number;
|
||||||
height: number;
|
height?: number;
|
||||||
/** if not supplied, calculated by Excalidraw */
|
/** if not supplied, calculated by Excalidraw */
|
||||||
offsetLeft?: number;
|
offsetLeft?: number;
|
||||||
/** if not supplied, calculated by Excalidraw */
|
/** if not supplied, calculated by Excalidraw */
|
||||||
@ -144,10 +148,23 @@ export interface ExcalidrawProps {
|
|||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
) => void;
|
) => void;
|
||||||
initialData?: ImportedDataState;
|
initialData?: ImportedDataState | null | Promise<ImportedDataState | null>;
|
||||||
user?: {
|
user?: {
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
};
|
};
|
||||||
onUsernameChange?: (username: string) => void;
|
excalidrawRef?: ForwardRef<ExcalidrawAPIRefValue>;
|
||||||
forwardedRef: ForwardRef<ExcalidrawImperativeAPI>;
|
onCollabButtonClick?: () => void;
|
||||||
|
isCollaborating?: boolean;
|
||||||
|
onPointerUpdate?: (payload: {
|
||||||
|
pointer: { x: number; y: number };
|
||||||
|
button: "down" | "up";
|
||||||
|
pointersMap: Gesture["pointers"];
|
||||||
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SceneData = {
|
||||||
|
elements?: ImportedDataState["elements"];
|
||||||
|
appState?: ImportedDataState["appState"];
|
||||||
|
collaborators?: Map<string, Collaborator>;
|
||||||
|
commitToHistory?: boolean;
|
||||||
|
};
|
||||||
|
33
src/utils.ts
33
src/utils.ts
@ -6,6 +6,7 @@ import {
|
|||||||
} from "./constants";
|
} from "./constants";
|
||||||
import { FontFamily, FontString } from "./element/types";
|
import { FontFamily, FontString } from "./element/types";
|
||||||
import { Zoom } from "./types";
|
import { Zoom } from "./types";
|
||||||
|
import { unstable_batchedUpdates } from "react-dom";
|
||||||
|
|
||||||
export const SVG_NS = "http://www.w3.org/2000/svg";
|
export const SVG_NS = "http://www.w3.org/2000/svg";
|
||||||
|
|
||||||
@ -128,7 +129,9 @@ export const debounce = <T extends any[]>(
|
|||||||
};
|
};
|
||||||
ret.flush = () => {
|
ret.flush = () => {
|
||||||
clearTimeout(handle);
|
clearTimeout(handle);
|
||||||
|
if (lastArgs) {
|
||||||
fn(...lastArgs);
|
fn(...lastArgs);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
return ret;
|
return ret;
|
||||||
};
|
};
|
||||||
@ -303,3 +306,33 @@ export const isTransparent = (color: string) => {
|
|||||||
color === colors.elementBackground[0]
|
color === colors.elementBackground[0]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const noop = () => ({});
|
||||||
|
|
||||||
|
export type ResolvablePromise<T> = Promise<T> & {
|
||||||
|
resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
};
|
||||||
|
export const resolvablePromise = <T>() => {
|
||||||
|
let resolve!: any;
|
||||||
|
let reject!: any;
|
||||||
|
const promise = new Promise((_resolve, _reject) => {
|
||||||
|
resolve = _resolve;
|
||||||
|
reject = _reject;
|
||||||
|
});
|
||||||
|
(promise as any).resolve = resolve;
|
||||||
|
(promise as any).reject = reject;
|
||||||
|
return promise as ResolvablePromise<T>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param func handler taking at most single parameter (event).
|
||||||
|
*/
|
||||||
|
export const withBatchedUpdates = <
|
||||||
|
TFunction extends ((event: any) => void) | (() => void)
|
||||||
|
>(
|
||||||
|
func: Parameters<TFunction>["length"] extends 0 | 1 ? TFunction : never,
|
||||||
|
) =>
|
||||||
|
((event) => {
|
||||||
|
unstable_batchedUpdates(func as TFunction, event);
|
||||||
|
}) as TFunction;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user