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