|
@@ -42,11 +42,7 @@ import { actions } from "../actions/register";
|
|
|
import { ActionResult } from "../actions/types";
|
|
|
import { trackEvent } from "../analytics";
|
|
|
import { getDefaultAppState, isEraserActive } from "../appState";
|
|
|
-import {
|
|
|
- parseClipboard,
|
|
|
- probablySupportsClipboardBlob,
|
|
|
- probablySupportsClipboardWriteText,
|
|
|
-} from "../clipboard";
|
|
|
+import { parseClipboard } from "../clipboard";
|
|
|
import {
|
|
|
APP_NAME,
|
|
|
CURSOR_TYPE,
|
|
@@ -227,7 +223,11 @@ import {
|
|
|
updateActiveTool,
|
|
|
getShortcutKey,
|
|
|
} from "../utils";
|
|
|
-import ContextMenu, { ContextMenuOption } from "./ContextMenu";
|
|
|
+import {
|
|
|
+ ContextMenu,
|
|
|
+ ContextMenuItems,
|
|
|
+ CONTEXT_MENU_SEPARATOR,
|
|
|
+} from "./ContextMenu";
|
|
|
import LayerUI from "./LayerUI";
|
|
|
import { Toast } from "./Toast";
|
|
|
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
|
|
@@ -274,6 +274,7 @@ import {
|
|
|
import { shouldShowBoundingBox } from "../element/transformHandles";
|
|
|
import { atom } from "jotai";
|
|
|
import { Fonts } from "../scene/Fonts";
|
|
|
+import { actionPaste } from "../actions/actionClipboard";
|
|
|
|
|
|
export const isMenuOpenAtom = atom(false);
|
|
|
export const isDropdownOpenAtom = atom(false);
|
|
@@ -383,7 +384,6 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
hitLinkElement?: NonDeletedExcalidrawElement;
|
|
|
lastPointerDown: React.PointerEvent<HTMLCanvasElement> | null = null;
|
|
|
lastPointerUp: React.PointerEvent<HTMLElement> | PointerEvent | null = null;
|
|
|
- contextMenuOpen: boolean = false;
|
|
|
lastScenePointer: { x: number; y: number } | null = null;
|
|
|
|
|
|
constructor(props: AppProps) {
|
|
@@ -602,6 +602,7 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
<div className="excalidraw-textEditorContainer" />
|
|
|
<div className="excalidraw-contextMenuContainer" />
|
|
|
{selectedElement.length === 1 &&
|
|
|
+ !this.state.contextMenu &&
|
|
|
this.state.showHyperlinkPopup && (
|
|
|
<Hyperlink
|
|
|
key={selectedElement[0].id}
|
|
@@ -618,6 +619,14 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
closable={this.state.toast.closable}
|
|
|
/>
|
|
|
)}
|
|
|
+ {this.state.contextMenu && (
|
|
|
+ <ContextMenu
|
|
|
+ items={this.state.contextMenu.items}
|
|
|
+ top={this.state.contextMenu.top}
|
|
|
+ left={this.state.contextMenu.left}
|
|
|
+ actionManager={this.actionManager}
|
|
|
+ />
|
|
|
+ )}
|
|
|
<main>{this.renderCanvas()}</main>
|
|
|
</ExcalidrawElementsContext.Provider>{" "}
|
|
|
</ExcalidrawAppStateContext.Provider>
|
|
@@ -644,8 +653,6 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
|
|
|
private syncActionResult = withBatchedUpdates(
|
|
|
(actionResult: ActionResult) => {
|
|
|
- // Since context menu closes when action triggered so setting to false
|
|
|
- this.contextMenuOpen = false;
|
|
|
if (this.unmounted || actionResult === false) {
|
|
|
return;
|
|
|
}
|
|
@@ -674,7 +681,7 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
this.addNewImagesToImageCache();
|
|
|
}
|
|
|
|
|
|
- if (actionResult.appState || editingElement) {
|
|
|
+ if (actionResult.appState || editingElement || this.state.contextMenu) {
|
|
|
if (actionResult.commitToHistory) {
|
|
|
this.history.resumeRecording();
|
|
|
}
|
|
@@ -700,12 +707,17 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
if (typeof this.props.name !== "undefined") {
|
|
|
name = this.props.name;
|
|
|
}
|
|
|
+
|
|
|
this.setState(
|
|
|
(state) => {
|
|
|
// using Object.assign instead of spread to fool TS 4.2.2+ into
|
|
|
// regarding the resulting type as not containing undefined
|
|
|
// (which the following expression will never contain)
|
|
|
return Object.assign(actionResult.appState || {}, {
|
|
|
+ // NOTE this will prevent opening context menu using an action
|
|
|
+ // or programmatically from the host, so it will need to be
|
|
|
+ // rewritten later
|
|
|
+ contextMenu: null,
|
|
|
editingElement:
|
|
|
editingElement || actionResult.appState?.editingElement || null,
|
|
|
viewModeEnabled,
|
|
@@ -1462,7 +1474,7 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
}
|
|
|
};
|
|
|
|
|
|
- private pasteFromClipboard = withBatchedUpdates(
|
|
|
+ public pasteFromClipboard = withBatchedUpdates(
|
|
|
async (event: ClipboardEvent | null) => {
|
|
|
const isPlainPaste = !!(IS_PLAIN_PASTE && event);
|
|
|
|
|
@@ -1470,7 +1482,7 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
const target = document.activeElement;
|
|
|
const isExcalidrawActive =
|
|
|
this.excalidrawContainerRef.current?.contains(target);
|
|
|
- if (!isExcalidrawActive) {
|
|
|
+ if (event && !isExcalidrawActive) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
@@ -1744,10 +1756,11 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
this.history.resumeRecording();
|
|
|
}
|
|
|
|
|
|
- // Collaboration
|
|
|
-
|
|
|
- setAppState: React.Component<any, AppState>["setState"] = (state) => {
|
|
|
- this.setState(state);
|
|
|
+ setAppState: React.Component<any, AppState>["setState"] = (
|
|
|
+ state,
|
|
|
+ callback,
|
|
|
+ ) => {
|
|
|
+ this.setState(state, callback);
|
|
|
};
|
|
|
|
|
|
removePointer = (event: React.PointerEvent<HTMLElement> | PointerEvent) => {
|
|
@@ -3101,7 +3114,7 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
hitElement &&
|
|
|
hitElement.link &&
|
|
|
this.state.selectedElementIds[hitElement.id] &&
|
|
|
- !this.contextMenuOpen &&
|
|
|
+ !this.state.contextMenu &&
|
|
|
!this.state.showHyperlinkPopup
|
|
|
) {
|
|
|
this.setState({ showHyperlinkPopup: "info" });
|
|
@@ -3323,6 +3336,14 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
private handleCanvasPointerDown = (
|
|
|
event: React.PointerEvent<HTMLCanvasElement>,
|
|
|
) => {
|
|
|
+ // since contextMenu options are potentially evaluated on each render,
|
|
|
+ // and an contextMenu action may depend on selection state, we must
|
|
|
+ // close the contextMenu before we update the selection on pointerDown
|
|
|
+ // (e.g. resetting selection)
|
|
|
+ if (this.state.contextMenu) {
|
|
|
+ this.setState({ contextMenu: null });
|
|
|
+ }
|
|
|
+
|
|
|
// remove any active selection when we start to interact with canvas
|
|
|
// (mainly, we care about removing selection outside the component which
|
|
|
// would prevent our copy handling otherwise)
|
|
@@ -3389,8 +3410,6 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- // Since context menu closes on pointer down so setting to false
|
|
|
- this.contextMenuOpen = false;
|
|
|
this.clearSelectionIfNotUsingSelection();
|
|
|
this.updateBindingEnabledOnPointerMove(event);
|
|
|
|
|
@@ -5949,7 +5968,17 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
includeLockedElements: true,
|
|
|
});
|
|
|
|
|
|
- const type = element ? "element" : "canvas";
|
|
|
+ const selectedElements = getSelectedElements(
|
|
|
+ this.scene.getNonDeletedElements(),
|
|
|
+ this.state,
|
|
|
+ );
|
|
|
+ const isHittignCommonBoundBox =
|
|
|
+ this.isHittingCommonBoundingBoxOfSelectedElements(
|
|
|
+ { x, y },
|
|
|
+ selectedElements,
|
|
|
+ );
|
|
|
+
|
|
|
+ const type = element || isHittignCommonBoundBox ? "element" : "canvas";
|
|
|
|
|
|
const container = this.excalidrawContainerRef.current!;
|
|
|
const { top: offsetTop, left: offsetLeft } =
|
|
@@ -5957,25 +5986,30 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
const left = event.clientX - offsetLeft;
|
|
|
const top = event.clientY - offsetTop;
|
|
|
|
|
|
- if (element && !this.state.selectedElementIds[element.id]) {
|
|
|
- this.setState(
|
|
|
- selectGroupsForSelectedElements(
|
|
|
- {
|
|
|
- ...this.state,
|
|
|
- selectedElementIds: { [element.id]: true },
|
|
|
- selectedLinearElement: isLinearElement(element)
|
|
|
- ? new LinearElementEditor(element, this.scene)
|
|
|
- : null,
|
|
|
- },
|
|
|
- this.scene.getNonDeletedElements(),
|
|
|
- ),
|
|
|
- () => {
|
|
|
- this._openContextMenu({ top, left }, type);
|
|
|
- },
|
|
|
- );
|
|
|
- } else {
|
|
|
- this._openContextMenu({ top, left }, type);
|
|
|
- }
|
|
|
+ trackEvent("contextMenu", "openContextMenu", type);
|
|
|
+
|
|
|
+ this.setState(
|
|
|
+ {
|
|
|
+ ...(element && !this.state.selectedElementIds[element.id]
|
|
|
+ ? selectGroupsForSelectedElements(
|
|
|
+ {
|
|
|
+ ...this.state,
|
|
|
+ selectedElementIds: { [element.id]: true },
|
|
|
+ selectedLinearElement: isLinearElement(element)
|
|
|
+ ? new LinearElementEditor(element, this.scene)
|
|
|
+ : null,
|
|
|
+ },
|
|
|
+ this.scene.getNonDeletedElements(),
|
|
|
+ )
|
|
|
+ : this.state),
|
|
|
+ showHyperlinkPopup: false,
|
|
|
+ },
|
|
|
+ () => {
|
|
|
+ this.setState({
|
|
|
+ contextMenu: { top, left, items: this.getContextMenuItems(type) },
|
|
|
+ });
|
|
|
+ },
|
|
|
+ );
|
|
|
};
|
|
|
|
|
|
private maybeDragNewGenericElement = (
|
|
@@ -6083,215 +6117,84 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
return false;
|
|
|
};
|
|
|
|
|
|
- /** @private use this.handleCanvasContextMenu */
|
|
|
- private _openContextMenu = (
|
|
|
- {
|
|
|
- left,
|
|
|
- top,
|
|
|
- }: {
|
|
|
- left: number;
|
|
|
- top: number;
|
|
|
- },
|
|
|
+ private getContextMenuItems = (
|
|
|
type: "canvas" | "element",
|
|
|
- ) => {
|
|
|
- trackEvent("contextMenu", "openContextMenu", type);
|
|
|
- if (this.state.showHyperlinkPopup) {
|
|
|
- this.setState({ showHyperlinkPopup: false });
|
|
|
- }
|
|
|
- this.contextMenuOpen = true;
|
|
|
- const maybeGroupAction = actionGroup.contextItemPredicate!(
|
|
|
- this.actionManager.getElementsIncludingDeleted(),
|
|
|
- this.actionManager.getAppState(),
|
|
|
- );
|
|
|
-
|
|
|
- const maybeUngroupAction = actionUngroup.contextItemPredicate!(
|
|
|
- this.actionManager.getElementsIncludingDeleted(),
|
|
|
- this.actionManager.getAppState(),
|
|
|
- );
|
|
|
+ ): ContextMenuItems => {
|
|
|
+ const options: ContextMenuItems = [];
|
|
|
|
|
|
- const maybeFlipHorizontal = actionFlipHorizontal.contextItemPredicate!(
|
|
|
- this.actionManager.getElementsIncludingDeleted(),
|
|
|
- this.actionManager.getAppState(),
|
|
|
- );
|
|
|
-
|
|
|
- const maybeFlipVertical = actionFlipVertical.contextItemPredicate!(
|
|
|
- this.actionManager.getElementsIncludingDeleted(),
|
|
|
- this.actionManager.getAppState(),
|
|
|
- );
|
|
|
+ options.push(actionCopyAsPng, actionCopyAsSvg);
|
|
|
|
|
|
- const mayBeAllowUnbinding = actionUnbindText.contextItemPredicate(
|
|
|
- this.actionManager.getElementsIncludingDeleted(),
|
|
|
- this.actionManager.getAppState(),
|
|
|
- );
|
|
|
+ // canvas contextMenu
|
|
|
+ // -------------------------------------------------------------------------
|
|
|
|
|
|
- const mayBeAllowBinding = actionBindText.contextItemPredicate(
|
|
|
- this.actionManager.getElementsIncludingDeleted(),
|
|
|
- this.actionManager.getAppState(),
|
|
|
- );
|
|
|
-
|
|
|
- const mayBeAllowToggleLineEditing =
|
|
|
- actionToggleLinearEditor.contextItemPredicate(
|
|
|
- this.actionManager.getElementsIncludingDeleted(),
|
|
|
- this.actionManager.getAppState(),
|
|
|
- );
|
|
|
-
|
|
|
- const separator = "separator";
|
|
|
-
|
|
|
- const elements = this.scene.getNonDeletedElements();
|
|
|
-
|
|
|
- const selectedElements = getSelectedElements(
|
|
|
- this.scene.getNonDeletedElements(),
|
|
|
- this.state,
|
|
|
- );
|
|
|
-
|
|
|
- const options: ContextMenuOption[] = [];
|
|
|
- if (probablySupportsClipboardBlob && elements.length > 0) {
|
|
|
- options.push(actionCopyAsPng);
|
|
|
- }
|
|
|
-
|
|
|
- if (probablySupportsClipboardWriteText && elements.length > 0) {
|
|
|
- options.push(actionCopyAsSvg);
|
|
|
- }
|
|
|
-
|
|
|
- if (
|
|
|
- type === "element" &&
|
|
|
- copyText.contextItemPredicate(elements, this.state) &&
|
|
|
- probablySupportsClipboardWriteText
|
|
|
- ) {
|
|
|
- options.push(copyText);
|
|
|
- }
|
|
|
if (type === "canvas") {
|
|
|
- const viewModeOptions = [
|
|
|
- ...options,
|
|
|
- typeof this.props.gridModeEnabled === "undefined" &&
|
|
|
+ if (this.state.viewModeEnabled) {
|
|
|
+ return [
|
|
|
+ ...options,
|
|
|
actionToggleGridMode,
|
|
|
- typeof this.props.zenModeEnabled === "undefined" && actionToggleZenMode,
|
|
|
- typeof this.props.viewModeEnabled === "undefined" &&
|
|
|
+ actionToggleZenMode,
|
|
|
actionToggleViewMode,
|
|
|
+ actionToggleStats,
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ return [
|
|
|
+ actionPaste,
|
|
|
+ CONTEXT_MENU_SEPARATOR,
|
|
|
+ actionCopyAsPng,
|
|
|
+ actionCopyAsSvg,
|
|
|
+ copyText,
|
|
|
+ CONTEXT_MENU_SEPARATOR,
|
|
|
+ actionSelectAll,
|
|
|
+ CONTEXT_MENU_SEPARATOR,
|
|
|
+ actionToggleGridMode,
|
|
|
+ actionToggleZenMode,
|
|
|
+ actionToggleViewMode,
|
|
|
actionToggleStats,
|
|
|
];
|
|
|
-
|
|
|
- if (this.state.viewModeEnabled) {
|
|
|
- ContextMenu.push({
|
|
|
- options: viewModeOptions,
|
|
|
- top,
|
|
|
- left,
|
|
|
- actionManager: this.actionManager,
|
|
|
- appState: this.state,
|
|
|
- container: this.excalidrawContainerRef.current!,
|
|
|
- elements,
|
|
|
- });
|
|
|
- } else {
|
|
|
- ContextMenu.push({
|
|
|
- options: [
|
|
|
- this.device.isMobile &&
|
|
|
- navigator.clipboard && {
|
|
|
- trackEvent: false,
|
|
|
- name: "paste",
|
|
|
- perform: (elements, appStates) => {
|
|
|
- this.pasteFromClipboard(null);
|
|
|
- return {
|
|
|
- commitToHistory: false,
|
|
|
- };
|
|
|
- },
|
|
|
- contextItemLabel: "labels.paste",
|
|
|
- },
|
|
|
- this.device.isMobile && navigator.clipboard && separator,
|
|
|
- probablySupportsClipboardBlob &&
|
|
|
- elements.length > 0 &&
|
|
|
- actionCopyAsPng,
|
|
|
- probablySupportsClipboardWriteText &&
|
|
|
- elements.length > 0 &&
|
|
|
- actionCopyAsSvg,
|
|
|
- probablySupportsClipboardWriteText &&
|
|
|
- selectedElements.length > 0 &&
|
|
|
- copyText,
|
|
|
- ((probablySupportsClipboardBlob && elements.length > 0) ||
|
|
|
- (probablySupportsClipboardWriteText && elements.length > 0)) &&
|
|
|
- separator,
|
|
|
- actionSelectAll,
|
|
|
- separator,
|
|
|
- typeof this.props.gridModeEnabled === "undefined" &&
|
|
|
- actionToggleGridMode,
|
|
|
- typeof this.props.zenModeEnabled === "undefined" &&
|
|
|
- actionToggleZenMode,
|
|
|
- typeof this.props.viewModeEnabled === "undefined" &&
|
|
|
- actionToggleViewMode,
|
|
|
- actionToggleStats,
|
|
|
- ],
|
|
|
- top,
|
|
|
- left,
|
|
|
- actionManager: this.actionManager,
|
|
|
- appState: this.state,
|
|
|
- container: this.excalidrawContainerRef.current!,
|
|
|
- elements,
|
|
|
- });
|
|
|
- }
|
|
|
- } else if (type === "element") {
|
|
|
- if (this.state.viewModeEnabled) {
|
|
|
- ContextMenu.push({
|
|
|
- options: [navigator.clipboard && actionCopy, ...options],
|
|
|
- top,
|
|
|
- left,
|
|
|
- actionManager: this.actionManager,
|
|
|
- appState: this.state,
|
|
|
- container: this.excalidrawContainerRef.current!,
|
|
|
- elements,
|
|
|
- });
|
|
|
- } else {
|
|
|
- ContextMenu.push({
|
|
|
- options: [
|
|
|
- this.device.isMobile && actionCut,
|
|
|
- this.device.isMobile && navigator.clipboard && actionCopy,
|
|
|
- this.device.isMobile &&
|
|
|
- navigator.clipboard && {
|
|
|
- name: "paste",
|
|
|
- trackEvent: false,
|
|
|
- perform: (elements, appStates) => {
|
|
|
- this.pasteFromClipboard(null);
|
|
|
- return {
|
|
|
- commitToHistory: false,
|
|
|
- };
|
|
|
- },
|
|
|
- contextItemLabel: "labels.paste",
|
|
|
- },
|
|
|
- this.device.isMobile && separator,
|
|
|
- ...options,
|
|
|
- separator,
|
|
|
- actionCopyStyles,
|
|
|
- actionPasteStyles,
|
|
|
- separator,
|
|
|
- maybeGroupAction && actionGroup,
|
|
|
- mayBeAllowUnbinding && actionUnbindText,
|
|
|
- mayBeAllowBinding && actionBindText,
|
|
|
- maybeUngroupAction && actionUngroup,
|
|
|
- (maybeGroupAction || maybeUngroupAction) && separator,
|
|
|
- actionAddToLibrary,
|
|
|
- separator,
|
|
|
- actionSendBackward,
|
|
|
- actionBringForward,
|
|
|
- actionSendToBack,
|
|
|
- actionBringToFront,
|
|
|
- separator,
|
|
|
- maybeFlipHorizontal && actionFlipHorizontal,
|
|
|
- maybeFlipVertical && actionFlipVertical,
|
|
|
- (maybeFlipHorizontal || maybeFlipVertical) && separator,
|
|
|
- mayBeAllowToggleLineEditing && actionToggleLinearEditor,
|
|
|
- actionLink.contextItemPredicate(elements, this.state) && actionLink,
|
|
|
- actionDuplicateSelection,
|
|
|
- actionToggleLock,
|
|
|
- separator,
|
|
|
- actionDeleteSelected,
|
|
|
- ],
|
|
|
- top,
|
|
|
- left,
|
|
|
- actionManager: this.actionManager,
|
|
|
- appState: this.state,
|
|
|
- container: this.excalidrawContainerRef.current!,
|
|
|
- elements,
|
|
|
- });
|
|
|
- }
|
|
|
}
|
|
|
+
|
|
|
+ // element contextMenu
|
|
|
+ // -------------------------------------------------------------------------
|
|
|
+
|
|
|
+ options.push(copyText);
|
|
|
+
|
|
|
+ if (this.state.viewModeEnabled) {
|
|
|
+ return [actionCopy, ...options];
|
|
|
+ }
|
|
|
+
|
|
|
+ return [
|
|
|
+ actionCut,
|
|
|
+ actionCopy,
|
|
|
+ actionPaste,
|
|
|
+ CONTEXT_MENU_SEPARATOR,
|
|
|
+ ...options,
|
|
|
+ CONTEXT_MENU_SEPARATOR,
|
|
|
+ actionCopyStyles,
|
|
|
+ actionPasteStyles,
|
|
|
+ CONTEXT_MENU_SEPARATOR,
|
|
|
+ actionGroup,
|
|
|
+ actionUnbindText,
|
|
|
+ actionBindText,
|
|
|
+ actionUngroup,
|
|
|
+ CONTEXT_MENU_SEPARATOR,
|
|
|
+ actionAddToLibrary,
|
|
|
+ CONTEXT_MENU_SEPARATOR,
|
|
|
+ actionSendBackward,
|
|
|
+ actionBringForward,
|
|
|
+ actionSendToBack,
|
|
|
+ actionBringToFront,
|
|
|
+ CONTEXT_MENU_SEPARATOR,
|
|
|
+ actionFlipHorizontal,
|
|
|
+ actionFlipVertical,
|
|
|
+ CONTEXT_MENU_SEPARATOR,
|
|
|
+ actionToggleLinearEditor,
|
|
|
+ actionLink,
|
|
|
+ actionDuplicateSelection,
|
|
|
+ actionToggleLock,
|
|
|
+ CONTEXT_MENU_SEPARATOR,
|
|
|
+ actionDeleteSelected,
|
|
|
+ ];
|
|
|
};
|
|
|
|
|
|
private handleWheel = withBatchedUpdates((event: WheelEvent) => {
|