mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
feat: move contextMenu into the component tree and control via appState (#6021)
This commit is contained in:
parent
b704705ed8
commit
7e135c4e22
15 changed files with 1752 additions and 398 deletions
|
@ -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(),
|
||||
);
|
||||
): ContextMenuItems => {
|
||||
const options: ContextMenuItems = [];
|
||||
|
||||
const maybeUngroupAction = actionUngroup.contextItemPredicate!(
|
||||
this.actionManager.getElementsIncludingDeleted(),
|
||||
this.actionManager.getAppState(),
|
||||
);
|
||||
options.push(actionCopyAsPng, actionCopyAsSvg);
|
||||
|
||||
const maybeFlipHorizontal = actionFlipHorizontal.contextItemPredicate!(
|
||||
this.actionManager.getElementsIncludingDeleted(),
|
||||
this.actionManager.getAppState(),
|
||||
);
|
||||
// canvas contextMenu
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const maybeFlipVertical = actionFlipVertical.contextItemPredicate!(
|
||||
this.actionManager.getElementsIncludingDeleted(),
|
||||
this.actionManager.getAppState(),
|
||||
);
|
||||
|
||||
const mayBeAllowUnbinding = actionUnbindText.contextItemPredicate(
|
||||
this.actionManager.getElementsIncludingDeleted(),
|
||||
this.actionManager.getAppState(),
|
||||
);
|
||||
|
||||
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) => {
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
color: var(--popup-text-color);
|
||||
}
|
||||
|
||||
.context-menu-option {
|
||||
.context-menu-item {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 9.5rem;
|
||||
|
@ -43,16 +43,16 @@
|
|||
}
|
||||
|
||||
&.dangerous {
|
||||
.context-menu-option__label {
|
||||
.context-menu-item__label {
|
||||
color: $oc-red-7;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-option__label {
|
||||
.context-menu-item__label {
|
||||
justify-self: start;
|
||||
margin-inline-end: 20px;
|
||||
}
|
||||
.context-menu-option__shortcut {
|
||||
.context-menu-item__shortcut {
|
||||
justify-self: end;
|
||||
opacity: 0.6;
|
||||
font-family: inherit;
|
||||
|
@ -60,37 +60,37 @@
|
|||
}
|
||||
}
|
||||
|
||||
.context-menu-option:hover {
|
||||
.context-menu-item:hover {
|
||||
color: var(--popup-bg-color);
|
||||
background-color: var(--select-highlight-color);
|
||||
|
||||
&.dangerous {
|
||||
.context-menu-option__label {
|
||||
.context-menu-item__label {
|
||||
color: var(--popup-bg-color);
|
||||
}
|
||||
background-color: $oc-red-6;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-option:focus {
|
||||
.context-menu-item:focus {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@include isMobile {
|
||||
.context-menu-option {
|
||||
.context-menu-item {
|
||||
display: block;
|
||||
|
||||
.context-menu-option__label {
|
||||
.context-menu-item__label {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.context-menu-option__shortcut {
|
||||
.context-menu-item__shortcut {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-option-separator {
|
||||
.context-menu-item-separator {
|
||||
border: none;
|
||||
border-top: 1px solid $oc-gray-5;
|
||||
}
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { createRoot, Root } from "react-dom/client";
|
||||
import clsx from "clsx";
|
||||
import { Popover } from "./Popover";
|
||||
import { t } from "../i18n";
|
||||
|
@ -10,135 +9,116 @@ import {
|
|||
} from "../actions/shortcuts";
|
||||
import { Action } from "../actions/types";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { AppState } from "../types";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import {
|
||||
useExcalidrawAppState,
|
||||
useExcalidrawElements,
|
||||
useExcalidrawSetAppState,
|
||||
} from "./App";
|
||||
import React from "react";
|
||||
|
||||
export type ContextMenuOption = "separator" | Action;
|
||||
export type ContextMenuItem = typeof CONTEXT_MENU_SEPARATOR | Action;
|
||||
|
||||
export type ContextMenuItems = (ContextMenuItem | false | null | undefined)[];
|
||||
|
||||
type ContextMenuProps = {
|
||||
options: ContextMenuOption[];
|
||||
onCloseRequest?(): void;
|
||||
actionManager: ActionManager;
|
||||
items: ContextMenuItems;
|
||||
top: number;
|
||||
left: number;
|
||||
actionManager: ActionManager;
|
||||
appState: Readonly<AppState>;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
};
|
||||
|
||||
const ContextMenu = ({
|
||||
options,
|
||||
onCloseRequest,
|
||||
top,
|
||||
left,
|
||||
actionManager,
|
||||
appState,
|
||||
elements,
|
||||
}: ContextMenuProps) => {
|
||||
return (
|
||||
<Popover
|
||||
onCloseRequest={onCloseRequest}
|
||||
top={top}
|
||||
left={left}
|
||||
fitInViewport={true}
|
||||
offsetLeft={appState.offsetLeft}
|
||||
offsetTop={appState.offsetTop}
|
||||
viewportWidth={appState.width}
|
||||
viewportHeight={appState.height}
|
||||
>
|
||||
<ul
|
||||
className="context-menu"
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
{options.map((option, idx) => {
|
||||
if (option === "separator") {
|
||||
return <hr key={idx} className="context-menu-option-separator" />;
|
||||
}
|
||||
export const CONTEXT_MENU_SEPARATOR = "separator";
|
||||
|
||||
const actionName = option.name;
|
||||
let label = "";
|
||||
if (option.contextItemLabel) {
|
||||
if (typeof option.contextItemLabel === "function") {
|
||||
label = t(option.contextItemLabel(elements, appState));
|
||||
} else {
|
||||
label = t(option.contextItemLabel);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<li key={idx} data-testid={actionName} onClick={onCloseRequest}>
|
||||
<button
|
||||
className={clsx("context-menu-option", {
|
||||
dangerous: actionName === "deleteSelectedElements",
|
||||
checkmark: option.checked?.(appState),
|
||||
})}
|
||||
onClick={() =>
|
||||
actionManager.executeAction(option, "contextMenu")
|
||||
}
|
||||
>
|
||||
<div className="context-menu-option__label">{label}</div>
|
||||
<kbd className="context-menu-option__shortcut">
|
||||
{actionName
|
||||
? getShortcutFromShortcutName(actionName as ShortcutName)
|
||||
: ""}
|
||||
</kbd>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
export const ContextMenu = React.memo(
|
||||
({ actionManager, items, top, left }: ContextMenuProps) => {
|
||||
const appState = useExcalidrawAppState();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const elements = useExcalidrawElements();
|
||||
|
||||
const contextMenuRoots = new WeakMap<HTMLElement, Root>();
|
||||
|
||||
const getContextMenuRoot = (container: HTMLElement): Root => {
|
||||
let contextMenuRoot = contextMenuRoots.get(container);
|
||||
if (contextMenuRoot) {
|
||||
return contextMenuRoot;
|
||||
}
|
||||
contextMenuRoot = createRoot(
|
||||
container.querySelector(".excalidraw-contextMenuContainer")!,
|
||||
);
|
||||
contextMenuRoots.set(container, contextMenuRoot);
|
||||
return contextMenuRoot;
|
||||
};
|
||||
|
||||
const handleClose = (container: HTMLElement) => {
|
||||
const contextMenuRoot = contextMenuRoots.get(container);
|
||||
if (contextMenuRoot) {
|
||||
contextMenuRoot.unmount();
|
||||
contextMenuRoots.delete(container);
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
push(params: {
|
||||
options: (ContextMenuOption | false | null | undefined)[];
|
||||
top: ContextMenuProps["top"];
|
||||
left: ContextMenuProps["left"];
|
||||
actionManager: ContextMenuProps["actionManager"];
|
||||
appState: Readonly<AppState>;
|
||||
container: HTMLElement;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
}) {
|
||||
const options = Array.of<ContextMenuOption>();
|
||||
params.options.forEach((option) => {
|
||||
if (option) {
|
||||
options.push(option);
|
||||
const filteredItems = items.reduce((acc: ContextMenuItem[], item) => {
|
||||
if (
|
||||
item &&
|
||||
(item === CONTEXT_MENU_SEPARATOR ||
|
||||
!item.contextItemPredicate ||
|
||||
item.contextItemPredicate(
|
||||
elements,
|
||||
appState,
|
||||
actionManager.app.props,
|
||||
actionManager.app,
|
||||
))
|
||||
) {
|
||||
acc.push(item);
|
||||
}
|
||||
});
|
||||
if (options.length) {
|
||||
getContextMenuRoot(params.container).render(
|
||||
<ContextMenu
|
||||
top={params.top}
|
||||
left={params.left}
|
||||
options={options}
|
||||
onCloseRequest={() => handleClose(params.container)}
|
||||
actionManager={params.actionManager}
|
||||
appState={params.appState}
|
||||
elements={params.elements}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
onCloseRequest={() => setAppState({ contextMenu: null })}
|
||||
top={top}
|
||||
left={left}
|
||||
fitInViewport={true}
|
||||
offsetLeft={appState.offsetLeft}
|
||||
offsetTop={appState.offsetTop}
|
||||
viewportWidth={appState.width}
|
||||
viewportHeight={appState.height}
|
||||
>
|
||||
<ul
|
||||
className="context-menu"
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
{filteredItems.map((item, idx) => {
|
||||
if (item === CONTEXT_MENU_SEPARATOR) {
|
||||
if (
|
||||
!filteredItems[idx - 1] ||
|
||||
filteredItems[idx - 1] === CONTEXT_MENU_SEPARATOR
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return <hr key={idx} className="context-menu-item-separator" />;
|
||||
}
|
||||
|
||||
const actionName = item.name;
|
||||
let label = "";
|
||||
if (item.contextItemLabel) {
|
||||
if (typeof item.contextItemLabel === "function") {
|
||||
label = t(item.contextItemLabel(elements, appState));
|
||||
} else {
|
||||
label = t(item.contextItemLabel);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
key={idx}
|
||||
data-testid={actionName}
|
||||
onClick={() => {
|
||||
// we need update state before executing the action in case
|
||||
// the action uses the appState it's being passed (that still
|
||||
// contains a defined contextMenu) to return the next state.
|
||||
setAppState({ contextMenu: null }, () => {
|
||||
actionManager.executeAction(item, "contextMenu");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className={clsx("context-menu-item", {
|
||||
dangerous: actionName === "deleteSelectedElements",
|
||||
checkmark: item.checked?.(appState),
|
||||
})}
|
||||
>
|
||||
<div className="context-menu-item__label">{label}</div>
|
||||
<kbd className="context-menu-item__shortcut">
|
||||
{actionName
|
||||
? getShortcutFromShortcutName(actionName as ShortcutName)
|
||||
: ""}
|
||||
</kbd>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
};
|
||||
);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue