1
0
mirror of https://github.com/excalidraw/excalidraw.git synced 2024-11-10 11:35:52 +01:00

feat: move contextMenu into the component tree and control via appState (#6021)

This commit is contained in:
David Luzar 2022-12-21 12:47:09 +01:00 committed by GitHub
parent b704705ed8
commit 7e135c4e22
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1752 additions and 398 deletions

@ -3,6 +3,7 @@ import { register } from "./register";
import { import {
copyTextToSystemClipboard, copyTextToSystemClipboard,
copyToClipboard, copyToClipboard,
probablySupportsClipboardBlob,
probablySupportsClipboardWriteText, probablySupportsClipboardWriteText,
} from "../clipboard"; } from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected"; import { actionDeleteSelected } from "./actionDeleteSelected";
@ -23,11 +24,31 @@ export const actionCopy = register({
commitToHistory: false, commitToHistory: false,
}; };
}, },
contextItemPredicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.copy", contextItemLabel: "labels.copy",
// don't supply a shortcut since we handle this conditionally via onCopy event // don't supply a shortcut since we handle this conditionally via onCopy event
keyTest: undefined, keyTest: undefined,
}); });
export const actionPaste = register({
name: "paste",
trackEvent: { category: "element" },
perform: (elements: any, appStates: any, data, app) => {
app.pasteFromClipboard(null);
return {
commitToHistory: false,
};
},
contextItemPredicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.paste",
// don't supply a shortcut since we handle this conditionally via onCopy event
keyTest: undefined,
});
export const actionCut = register({ export const actionCut = register({
name: "cut", name: "cut",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
@ -35,6 +56,9 @@ export const actionCut = register({
actionCopy.perform(elements, appState, data, app); actionCopy.perform(elements, appState, data, app);
return actionDeleteSelected.perform(elements, appState); return actionDeleteSelected.perform(elements, appState);
}, },
contextItemPredicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.cut", contextItemLabel: "labels.cut",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X,
}); });
@ -77,6 +101,9 @@ export const actionCopyAsSvg = register({
}; };
} }
}, },
contextItemPredicate: (elements) => {
return probablySupportsClipboardWriteText && elements.length > 0;
},
contextItemLabel: "labels.copyAsSvg", contextItemLabel: "labels.copyAsSvg",
}); });
@ -131,6 +158,9 @@ export const actionCopyAsPng = register({
}; };
} }
}, },
contextItemPredicate: (elements) => {
return probablySupportsClipboardBlob && elements.length > 0;
},
contextItemLabel: "labels.copyAsPng", contextItemLabel: "labels.copyAsPng",
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey, keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
}); });

@ -20,6 +20,9 @@ export const actionToggleGridMode = register({
}; };
}, },
checked: (appState: AppState) => appState.gridSize !== null, checked: (appState: AppState) => appState.gridSize !== null,
contextItemPredicate: (element, appState, props) => {
return typeof props.gridModeEnabled === "undefined";
},
contextItemLabel: "labels.showGrid", contextItemLabel: "labels.showGrid",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
}); });

@ -41,15 +41,9 @@ export const actionToggleLock = register({
: "labels.elementLock.lock"; : "labels.elementLock.lock";
} }
if (selected.length > 1) { return getOperation(selected) === "lock"
return getOperation(selected) === "lock" ? "labels.elementLock.lockAll"
? "labels.elementLock.lockAll" : "labels.elementLock.unlockAll";
: "labels.elementLock.unlockAll";
}
throw new Error(
"Unexpected zero elements to lock/unlock. This should never happen.",
);
}, },
keyTest: (event, appState, elements) => { keyTest: (event, appState, elements) => {
return ( return (

@ -18,6 +18,9 @@ export const actionToggleViewMode = register({
}; };
}, },
checked: (appState) => appState.viewModeEnabled, checked: (appState) => appState.viewModeEnabled,
contextItemPredicate: (elements, appState, appProps) => {
return typeof appProps.viewModeEnabled === "undefined";
},
contextItemLabel: "labels.viewMode", contextItemLabel: "labels.viewMode",
keyTest: (event) => keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R, !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R,

@ -18,6 +18,9 @@ export const actionToggleZenMode = register({
}; };
}, },
checked: (appState) => appState.zenModeEnabled, checked: (appState) => appState.zenModeEnabled,
contextItemPredicate: (elements, appState, appProps) => {
return typeof appProps.zenModeEnabled === "undefined";
},
contextItemLabel: "buttons.zenMode", contextItemLabel: "buttons.zenMode",
keyTest: (event) => keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z, !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,

@ -143,6 +143,8 @@ export interface Action {
contextItemPredicate?: ( contextItemPredicate?: (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
appProps: ExcalidrawProps,
app: AppClassProperties,
) => boolean; ) => boolean;
checked?: (appState: Readonly<AppState>) => boolean; checked?: (appState: Readonly<AppState>) => boolean;
trackEvent: trackEvent:

@ -64,6 +64,7 @@ export const getDefaultAppState = (): Omit<
lastPointerDownWith: "mouse", lastPointerDownWith: "mouse",
multiElement: null, multiElement: null,
name: `${t("labels.untitled")}-${getDateTime()}`, name: `${t("labels.untitled")}-${getDateTime()}`,
contextMenu: null,
openMenu: null, openMenu: null,
openPopup: null, openPopup: null,
openSidebar: null, openSidebar: null,
@ -157,6 +158,7 @@ const APP_STATE_STORAGE_CONF = (<
name: { browser: true, export: false, server: false }, name: { browser: true, export: false, server: false },
offsetLeft: { browser: false, export: false, server: false }, offsetLeft: { browser: false, export: false, server: false },
offsetTop: { browser: false, export: false, server: false }, offsetTop: { browser: false, export: false, server: false },
contextMenu: { browser: false, export: false, server: false },
openMenu: { browser: true, export: false, server: false }, openMenu: { browser: true, export: false, server: false },
openPopup: { browser: false, export: false, server: false }, openPopup: { browser: false, export: false, server: false },
openSidebar: { browser: true, export: false, server: false }, openSidebar: { browser: true, export: false, server: false },

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

@ -19,7 +19,7 @@
color: var(--popup-text-color); color: var(--popup-text-color);
} }
.context-menu-option { .context-menu-item {
position: relative; position: relative;
width: 100%; width: 100%;
min-width: 9.5rem; min-width: 9.5rem;
@ -43,16 +43,16 @@
} }
&.dangerous { &.dangerous {
.context-menu-option__label { .context-menu-item__label {
color: $oc-red-7; color: $oc-red-7;
} }
} }
.context-menu-option__label { .context-menu-item__label {
justify-self: start; justify-self: start;
margin-inline-end: 20px; margin-inline-end: 20px;
} }
.context-menu-option__shortcut { .context-menu-item__shortcut {
justify-self: end; justify-self: end;
opacity: 0.6; opacity: 0.6;
font-family: inherit; font-family: inherit;
@ -60,37 +60,37 @@
} }
} }
.context-menu-option:hover { .context-menu-item:hover {
color: var(--popup-bg-color); color: var(--popup-bg-color);
background-color: var(--select-highlight-color); background-color: var(--select-highlight-color);
&.dangerous { &.dangerous {
.context-menu-option__label { .context-menu-item__label {
color: var(--popup-bg-color); color: var(--popup-bg-color);
} }
background-color: $oc-red-6; background-color: $oc-red-6;
} }
} }
.context-menu-option:focus { .context-menu-item:focus {
z-index: 1; z-index: 1;
} }
@include isMobile { @include isMobile {
.context-menu-option { .context-menu-item {
display: block; display: block;
.context-menu-option__label { .context-menu-item__label {
margin-inline-end: 0; margin-inline-end: 0;
} }
.context-menu-option__shortcut { .context-menu-item__shortcut {
display: none; display: none;
} }
} }
} }
.context-menu-option-separator { .context-menu-item-separator {
border: none; border: none;
border-top: 1px solid $oc-gray-5; border-top: 1px solid $oc-gray-5;
} }

@ -1,4 +1,3 @@
import { createRoot, Root } from "react-dom/client";
import clsx from "clsx"; import clsx from "clsx";
import { Popover } from "./Popover"; import { Popover } from "./Popover";
import { t } from "../i18n"; import { t } from "../i18n";
@ -10,135 +9,116 @@ import {
} from "../actions/shortcuts"; } from "../actions/shortcuts";
import { Action } from "../actions/types"; import { Action } from "../actions/types";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { AppState } from "../types"; import {
import { NonDeletedExcalidrawElement } from "../element/types"; 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 = { type ContextMenuProps = {
options: ContextMenuOption[]; actionManager: ActionManager;
onCloseRequest?(): void; items: ContextMenuItems;
top: number; top: number;
left: number; left: number;
actionManager: ActionManager;
appState: Readonly<AppState>;
elements: readonly NonDeletedExcalidrawElement[];
}; };
const ContextMenu = ({ export const CONTEXT_MENU_SEPARATOR = "separator";
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" />;
}
const actionName = option.name; export const ContextMenu = React.memo(
let label = ""; ({ actionManager, items, top, left }: ContextMenuProps) => {
if (option.contextItemLabel) { const appState = useExcalidrawAppState();
if (typeof option.contextItemLabel === "function") { const setAppState = useExcalidrawSetAppState();
label = t(option.contextItemLabel(elements, appState)); const elements = useExcalidrawElements();
} 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>
);
};
const contextMenuRoots = new WeakMap<HTMLElement, Root>(); const filteredItems = items.reduce((acc: ContextMenuItem[], item) => {
if (
const getContextMenuRoot = (container: HTMLElement): Root => { item &&
let contextMenuRoot = contextMenuRoots.get(container); (item === CONTEXT_MENU_SEPARATOR ||
if (contextMenuRoot) { !item.contextItemPredicate ||
return contextMenuRoot; item.contextItemPredicate(
} elements,
contextMenuRoot = createRoot( appState,
container.querySelector(".excalidraw-contextMenuContainer")!, actionManager.app.props,
); actionManager.app,
contextMenuRoots.set(container, contextMenuRoot); ))
return contextMenuRoot; ) {
}; acc.push(item);
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);
} }
}); return acc;
if (options.length) { }, []);
getContextMenuRoot(params.container).render(
<ContextMenu return (
top={params.top} <Popover
left={params.left} onCloseRequest={() => setAppState({ contextMenu: null })}
options={options} top={top}
onCloseRequest={() => handleClose(params.container)} left={left}
actionManager={params.actionManager} fitInViewport={true}
appState={params.appState} offsetLeft={appState.offsetLeft}
elements={params.elements} 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>
);
}, },
}; );

File diff suppressed because it is too large Load Diff

@ -9,6 +9,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -544,6 +545,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -1085,6 +1087,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -1991,6 +1994,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -2220,6 +2224,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -2752,6 +2757,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -3040,6 +3046,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -3223,6 +3230,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -3738,6 +3746,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "#fa5252", "currentItemBackgroundColor": "#fa5252",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -4005,6 +4014,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -4234,6 +4244,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -4509,6 +4520,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -4796,6 +4808,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -5213,6 +5226,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -5553,6 +5567,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -5866,6 +5881,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -6103,6 +6119,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -6288,6 +6305,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -6815,6 +6833,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -7179,6 +7198,7 @@ Object {
"type": "freedraw", "type": "freedraw",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -9530,6 +9550,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -9948,6 +9969,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "#fa5252", "currentItemBackgroundColor": "#fa5252",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -10236,6 +10258,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -10483,6 +10506,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -10803,6 +10827,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -10986,6 +11011,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -11169,6 +11195,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -11352,6 +11379,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -11588,6 +11616,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -11824,6 +11853,7 @@ Object {
"type": "freedraw", "type": "freedraw",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -12051,6 +12081,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -12287,6 +12318,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -12470,6 +12502,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -12706,6 +12739,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -12889,6 +12923,7 @@ Object {
"type": "freedraw", "type": "freedraw",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -13116,6 +13151,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -13299,6 +13335,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -14137,6 +14174,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -14425,6 +14463,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -14535,6 +14574,7 @@ Object {
"type": "rectangle", "type": "rectangle",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -14643,6 +14683,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -14829,6 +14870,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -15196,6 +15238,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -15826,6 +15869,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "#fa5252", "currentItemBackgroundColor": "#fa5252",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -16051,6 +16095,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -17013,6 +17058,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -17121,6 +17167,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -17979,6 +18026,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -18450,6 +18498,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -18790,6 +18839,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -18900,6 +18950,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -19470,6 +19521,7 @@ Object {
"type": "text", "type": "text",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
@ -19578,6 +19630,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",

@ -152,7 +152,7 @@ describe("element locking", () => {
expect(contextMenu).not.toBeNull(); expect(contextMenu).not.toBeNull();
expect( expect(
contextMenu?.querySelector( contextMenu?.querySelector(
`li[data-testid="toggleLock"] .context-menu-option__label`, `li[data-testid="toggleLock"] .context-menu-item__label`,
), ),
).toHaveTextContent(t("labels.elementLock.unlock")); ).toHaveTextContent(t("labels.elementLock.unlock"));
}); });

@ -9,6 +9,7 @@ Object {
"type": "selection", "type": "selection",
}, },
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",

@ -30,6 +30,7 @@ import { MaybeTransformHandleType } from "./element/transformHandles";
import Library from "./data/library"; import Library from "./data/library";
import type { FileSystemHandle } from "./data/filesystem"; import type { FileSystemHandle } from "./data/filesystem";
import type { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "./constants"; import type { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "./constants";
import { ContextMenuItems } from "./components/ContextMenu";
export type Point = Readonly<RoughPoint>; export type Point = Readonly<RoughPoint>;
@ -92,6 +93,11 @@ export type LastActiveToolBeforeEraser =
| null; | null;
export type AppState = { export type AppState = {
contextMenu: {
items: ContextMenuItems;
top: number;
left: number;
} | null;
showWelcomeScreen: boolean; showWelcomeScreen: boolean;
isLoading: boolean; isLoading: boolean;
errorMessage: string | null; errorMessage: string | null;
@ -147,6 +153,7 @@ export type AppState = {
isResizing: boolean; isResizing: boolean;
isRotating: boolean; isRotating: boolean;
zoom: Zoom; zoom: Zoom;
// mobile-only
openMenu: "canvas" | "shape" | null; openMenu: "canvas" | "shape" | null;
openPopup: openPopup:
| "canvasColorPicker" | "canvasColorPicker"
@ -407,6 +414,7 @@ export type AppClassProperties = {
files: BinaryFiles; files: BinaryFiles;
device: App["device"]; device: App["device"];
scene: App["scene"]; scene: App["scene"];
pasteFromClipboard: App["pasteFromClipboard"];
}; };
export type PointerDownState = Readonly<{ export type PointerDownState = Readonly<{