aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/actions
diff options
context:
space:
mode:
Diffstat (limited to 'packages/excalidraw/actions')
-rw-r--r--packages/excalidraw/actions/actionAddToLibrary.ts63
-rw-r--r--packages/excalidraw/actions/actionAlign.tsx255
-rw-r--r--packages/excalidraw/actions/actionBoundText.tsx329
-rw-r--r--packages/excalidraw/actions/actionCanvas.tsx557
-rw-r--r--packages/excalidraw/actions/actionClipboard.tsx281
-rw-r--r--packages/excalidraw/actions/actionCropEditor.tsx55
-rw-r--r--packages/excalidraw/actions/actionDeleteSelected.test.tsx211
-rw-r--r--packages/excalidraw/actions/actionDeleteSelected.tsx311
-rw-r--r--packages/excalidraw/actions/actionDistribute.tsx110
-rw-r--r--packages/excalidraw/actions/actionDuplicateSelection.test.tsx530
-rw-r--r--packages/excalidraw/actions/actionDuplicateSelection.tsx359
-rw-r--r--packages/excalidraw/actions/actionElementLink.ts110
-rw-r--r--packages/excalidraw/actions/actionElementLock.test.tsx69
-rw-r--r--packages/excalidraw/actions/actionElementLock.ts119
-rw-r--r--packages/excalidraw/actions/actionExport.tsx309
-rw-r--r--packages/excalidraw/actions/actionFinalize.tsx223
-rw-r--r--packages/excalidraw/actions/actionFlip.test.tsx212
-rw-r--r--packages/excalidraw/actions/actionFlip.ts204
-rw-r--r--packages/excalidraw/actions/actionFrame.ts214
-rw-r--r--packages/excalidraw/actions/actionGroup.tsx308
-rw-r--r--packages/excalidraw/actions/actionHistory.tsx128
-rw-r--r--packages/excalidraw/actions/actionLinearEditor.tsx77
-rw-r--r--packages/excalidraw/actions/actionLink.tsx55
-rw-r--r--packages/excalidraw/actions/actionMenu.tsx81
-rw-r--r--packages/excalidraw/actions/actionNavigate.tsx133
-rw-r--r--packages/excalidraw/actions/actionProperties.test.tsx168
-rw-r--r--packages/excalidraw/actions/actionProperties.tsx1777
-rw-r--r--packages/excalidraw/actions/actionSelectAll.ts57
-rw-r--r--packages/excalidraw/actions/actionStyles.ts167
-rw-r--r--packages/excalidraw/actions/actionTextAutoResize.ts48
-rw-r--r--packages/excalidraw/actions/actionToggleGridMode.tsx32
-rw-r--r--packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx31
-rw-r--r--packages/excalidraw/actions/actionToggleSearchMenu.ts55
-rw-r--r--packages/excalidraw/actions/actionToggleStats.tsx26
-rw-r--r--packages/excalidraw/actions/actionToggleViewMode.tsx31
-rw-r--r--packages/excalidraw/actions/actionToggleZenMode.tsx31
-rw-r--r--packages/excalidraw/actions/actionZindex.tsx153
-rw-r--r--packages/excalidraw/actions/index.ts92
-rw-r--r--packages/excalidraw/actions/manager.tsx194
-rw-r--r--packages/excalidraw/actions/register.ts10
-rw-r--r--packages/excalidraw/actions/shortcuts.ts125
-rw-r--r--packages/excalidraw/actions/types.ts207
42 files changed, 8507 insertions, 0 deletions
diff --git a/packages/excalidraw/actions/actionAddToLibrary.ts b/packages/excalidraw/actions/actionAddToLibrary.ts
new file mode 100644
index 0000000..3186e3b
--- /dev/null
+++ b/packages/excalidraw/actions/actionAddToLibrary.ts
@@ -0,0 +1,63 @@
+import { register } from "./register";
+import { deepCopyElement } from "../element/newElement";
+import { randomId } from "../random";
+import { t } from "../i18n";
+import { LIBRARY_DISABLED_TYPES } from "../constants";
+import { CaptureUpdateAction } from "../store";
+
+export const actionAddToLibrary = register({
+ name: "addToLibrary",
+ trackEvent: { category: "element" },
+ perform: (elements, appState, _, app) => {
+ const selectedElements = app.scene.getSelectedElements({
+ selectedElementIds: appState.selectedElementIds,
+ includeBoundTextElement: true,
+ includeElementsInFrames: true,
+ });
+
+ for (const type of LIBRARY_DISABLED_TYPES) {
+ if (selectedElements.some((element) => element.type === type)) {
+ return {
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ appState: {
+ ...appState,
+ errorMessage: t(`errors.libraryElementTypeError.${type}`),
+ },
+ };
+ }
+ }
+
+ return app.library
+ .getLatestLibrary()
+ .then((items) => {
+ return app.library.setLibrary([
+ {
+ id: randomId(),
+ status: "unpublished",
+ elements: selectedElements.map(deepCopyElement),
+ created: Date.now(),
+ },
+ ...items,
+ ]);
+ })
+ .then(() => {
+ return {
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ appState: {
+ ...appState,
+ toast: { message: t("toast.addedToLibrary") },
+ },
+ };
+ })
+ .catch((error) => {
+ return {
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ appState: {
+ ...appState,
+ errorMessage: error.message,
+ },
+ };
+ });
+ },
+ label: "labels.addToLibrary",
+});
diff --git a/packages/excalidraw/actions/actionAlign.tsx b/packages/excalidraw/actions/actionAlign.tsx
new file mode 100644
index 0000000..53e8e61
--- /dev/null
+++ b/packages/excalidraw/actions/actionAlign.tsx
@@ -0,0 +1,255 @@
+import type { Alignment } from "../align";
+import { alignElements } from "../align";
+import {
+ AlignBottomIcon,
+ AlignLeftIcon,
+ AlignRightIcon,
+ AlignTopIcon,
+ CenterHorizontallyIcon,
+ CenterVerticallyIcon,
+} from "../components/icons";
+import { ToolButton } from "../components/ToolButton";
+import { getNonDeletedElements } from "../element";
+import { isFrameLikeElement } from "../element/typeChecks";
+import type { ExcalidrawElement } from "../element/types";
+import { updateFrameMembershipOfSelectedElements } from "../frame";
+import { t } from "../i18n";
+import { KEYS } from "../keys";
+import { isSomeElementSelected } from "../scene";
+import { CaptureUpdateAction } from "../store";
+import type { AppClassProperties, AppState, UIAppState } from "../types";
+import { arrayToMap, getShortcutKey } from "../utils";
+import { register } from "./register";
+
+export const alignActionsPredicate = (
+ appState: UIAppState,
+ app: AppClassProperties,
+) => {
+ const selectedElements = app.scene.getSelectedElements(appState);
+ return (
+ selectedElements.length > 1 &&
+ // TODO enable aligning frames when implemented properly
+ !selectedElements.some((el) => isFrameLikeElement(el))
+ );
+};
+
+const alignSelectedElements = (
+ elements: readonly ExcalidrawElement[],
+ appState: Readonly<AppState>,
+ app: AppClassProperties,
+ alignment: Alignment,
+) => {
+ const selectedElements = app.scene.getSelectedElements(appState);
+ const elementsMap = arrayToMap(elements);
+
+ const updatedElements = alignElements(
+ selectedElements,
+ elementsMap,
+ alignment,
+ app.scene,
+ );
+
+ const updatedElementsMap = arrayToMap(updatedElements);
+
+ return updateFrameMembershipOfSelectedElements(
+ elements.map((element) => updatedElementsMap.get(element.id) || element),
+ appState,
+ app,
+ );
+};
+
+export const actionAlignTop = register({
+ name: "alignTop",
+ label: "labels.alignTop",
+ icon: AlignTopIcon,
+ trackEvent: { category: "element" },
+ predicate: (elements, appState, appProps, app) =>
+ alignActionsPredicate(appState, app),
+ perform: (elements, appState, _, app) => {
+ return {
+ appState,
+ elements: alignSelectedElements(elements, appState, app, {
+ position: "start",
+ axis: "y",
+ }),
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ keyTest: (event) =>
+ event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
+ PanelComponent: ({ elements, appState, updateData, app }) => (
+ <ToolButton
+ hidden={!alignActionsPredicate(appState, app)}
+ type="button"
+ icon={AlignTopIcon}
+ onClick={() => updateData(null)}
+ title={`${t("labels.alignTop")} — ${getShortcutKey(
+ "CtrlOrCmd+Shift+Up",
+ )}`}
+ aria-label={t("labels.alignTop")}
+ visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
+ />
+ ),
+});
+
+export const actionAlignBottom = register({
+ name: "alignBottom",
+ label: "labels.alignBottom",
+ icon: AlignBottomIcon,
+ trackEvent: { category: "element" },
+ predicate: (elements, appState, appProps, app) =>
+ alignActionsPredicate(appState, app),
+ perform: (elements, appState, _, app) => {
+ return {
+ appState,
+ elements: alignSelectedElements(elements, appState, app, {
+ position: "end",
+ axis: "y",
+ }),
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ keyTest: (event) =>
+ event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
+ PanelComponent: ({ elements, appState, updateData, app }) => (
+ <ToolButton
+ hidden={!alignActionsPredicate(appState, app)}
+ type="button"
+ icon={AlignBottomIcon}
+ onClick={() => updateData(null)}
+ title={`${t("labels.alignBottom")} — ${getShortcutKey(
+ "CtrlOrCmd+Shift+Down",
+ )}`}
+ aria-label={t("labels.alignBottom")}
+ visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
+ />
+ ),
+});
+
+export const actionAlignLeft = register({
+ name: "alignLeft",
+ label: "labels.alignLeft",
+ icon: AlignLeftIcon,
+ trackEvent: { category: "element" },
+ predicate: (elements, appState, appProps, app) =>
+ alignActionsPredicate(appState, app),
+ perform: (elements, appState, _, app) => {
+ return {
+ appState,
+ elements: alignSelectedElements(elements, appState, app, {
+ position: "start",
+ axis: "x",
+ }),
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ keyTest: (event) =>
+ event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
+ PanelComponent: ({ elements, appState, updateData, app }) => (
+ <ToolButton
+ hidden={!alignActionsPredicate(appState, app)}
+ type="button"
+ icon={AlignLeftIcon}
+ onClick={() => updateData(null)}
+ title={`${t("labels.alignLeft")} — ${getShortcutKey(
+ "CtrlOrCmd+Shift+Left",
+ )}`}
+ aria-label={t("labels.alignLeft")}
+ visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
+ />
+ ),
+});
+
+export const actionAlignRight = register({
+ name: "alignRight",
+ label: "labels.alignRight",
+ icon: AlignRightIcon,
+ trackEvent: { category: "element" },
+ predicate: (elements, appState, appProps, app) =>
+ alignActionsPredicate(appState, app),
+ perform: (elements, appState, _, app) => {
+ return {
+ appState,
+ elements: alignSelectedElements(elements, appState, app, {
+ position: "end",
+ axis: "x",
+ }),
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ keyTest: (event) =>
+ event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
+ PanelComponent: ({ elements, appState, updateData, app }) => (
+ <ToolButton
+ hidden={!alignActionsPredicate(appState, app)}
+ type="button"
+ icon={AlignRightIcon}
+ onClick={() => updateData(null)}
+ title={`${t("labels.alignRight")} — ${getShortcutKey(
+ "CtrlOrCmd+Shift+Right",
+ )}`}
+ aria-label={t("labels.alignRight")}
+ visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
+ />
+ ),
+});
+
+export const actionAlignVerticallyCentered = register({
+ name: "alignVerticallyCentered",
+ label: "labels.centerVertically",
+ icon: CenterVerticallyIcon,
+ trackEvent: { category: "element" },
+ predicate: (elements, appState, appProps, app) =>
+ alignActionsPredicate(appState, app),
+ perform: (elements, appState, _, app) => {
+ return {
+ appState,
+ elements: alignSelectedElements(elements, appState, app, {
+ position: "center",
+ axis: "y",
+ }),
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ PanelComponent: ({ elements, appState, updateData, app }) => (
+ <ToolButton
+ hidden={!alignActionsPredicate(appState, app)}
+ type="button"
+ icon={CenterVerticallyIcon}
+ onClick={() => updateData(null)}
+ title={t("labels.centerVertically")}
+ aria-label={t("labels.centerVertically")}
+ visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
+ />
+ ),
+});
+
+export const actionAlignHorizontallyCentered = register({
+ name: "alignHorizontallyCentered",
+ label: "labels.centerHorizontally",
+ icon: CenterHorizontallyIcon,
+ trackEvent: { category: "element" },
+ predicate: (elements, appState, appProps, app) =>
+ alignActionsPredicate(appState, app),
+ perform: (elements, appState, _, app) => {
+ return {
+ appState,
+ elements: alignSelectedElements(elements, appState, app, {
+ position: "center",
+ axis: "x",
+ }),
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ PanelComponent: ({ elements, appState, updateData, app }) => (
+ <ToolButton
+ hidden={!alignActionsPredicate(appState, app)}
+ type="button"
+ icon={CenterHorizontallyIcon}
+ onClick={() => updateData(null)}
+ title={t("labels.centerHorizontally")}
+ aria-label={t("labels.centerHorizontally")}
+ visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
+ />
+ ),
+});
diff --git a/packages/excalidraw/actions/actionBoundText.tsx b/packages/excalidraw/actions/actionBoundText.tsx
new file mode 100644
index 0000000..b72ddee
--- /dev/null
+++ b/packages/excalidraw/actions/actionBoundText.tsx
@@ -0,0 +1,329 @@
+import {
+ BOUND_TEXT_PADDING,
+ ROUNDNESS,
+ TEXT_ALIGN,
+ VERTICAL_ALIGN,
+} from "../constants";
+import { isTextElement, newElement } from "../element";
+import { mutateElement } from "../element/mutateElement";
+import {
+ computeBoundTextPosition,
+ computeContainerDimensionForBoundText,
+ getBoundTextElement,
+ redrawTextBoundingBox,
+} from "../element/textElement";
+import {
+ getOriginalContainerHeightFromCache,
+ resetOriginalContainerCache,
+ updateOriginalContainerCache,
+} from "../element/containerCache";
+import {
+ hasBoundTextElement,
+ isTextBindableContainer,
+ isUsingAdaptiveRadius,
+} from "../element/typeChecks";
+import type {
+ ExcalidrawElement,
+ ExcalidrawLinearElement,
+ ExcalidrawTextContainer,
+ ExcalidrawTextElement,
+} from "../element/types";
+import type { AppState } from "../types";
+import type { Mutable } from "../utility-types";
+import { arrayToMap, getFontString } from "../utils";
+import { register } from "./register";
+import { syncMovedIndices } from "../fractionalIndex";
+import { CaptureUpdateAction } from "../store";
+import { measureText } from "../element/textMeasurements";
+
+export const actionUnbindText = register({
+ name: "unbindText",
+ label: "labels.unbindText",
+ trackEvent: { category: "element" },
+ predicate: (elements, appState, _, app) => {
+ const selectedElements = app.scene.getSelectedElements(appState);
+
+ return selectedElements.some((element) => hasBoundTextElement(element));
+ },
+ perform: (elements, appState, _, app) => {
+ const selectedElements = app.scene.getSelectedElements(appState);
+ const elementsMap = app.scene.getNonDeletedElementsMap();
+ selectedElements.forEach((element) => {
+ const boundTextElement = getBoundTextElement(element, elementsMap);
+ if (boundTextElement) {
+ const { width, height } = measureText(
+ boundTextElement.originalText,
+ getFontString(boundTextElement),
+ boundTextElement.lineHeight,
+ );
+ const originalContainerHeight = getOriginalContainerHeightFromCache(
+ element.id,
+ );
+ resetOriginalContainerCache(element.id);
+ const { x, y } = computeBoundTextPosition(
+ element,
+ boundTextElement,
+ elementsMap,
+ );
+ mutateElement(boundTextElement as ExcalidrawTextElement, {
+ containerId: null,
+ width,
+ height,
+ text: boundTextElement.originalText,
+ x,
+ y,
+ });
+ mutateElement(element, {
+ boundElements: element.boundElements?.filter(
+ (ele) => ele.id !== boundTextElement.id,
+ ),
+ height: originalContainerHeight
+ ? originalContainerHeight
+ : element.height,
+ });
+ }
+ });
+ return {
+ elements,
+ appState,
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+});
+
+export const actionBindText = register({
+ name: "bindText",
+ label: "labels.bindText",
+ trackEvent: { category: "element" },
+ predicate: (elements, appState, _, app) => {
+ const selectedElements = app.scene.getSelectedElements(appState);
+
+ if (selectedElements.length === 2) {
+ const textElement =
+ isTextElement(selectedElements[0]) ||
+ isTextElement(selectedElements[1]);
+
+ let bindingContainer;
+ if (isTextBindableContainer(selectedElements[0])) {
+ bindingContainer = selectedElements[0];
+ } else if (isTextBindableContainer(selectedElements[1])) {
+ bindingContainer = selectedElements[1];
+ }
+ if (
+ textElement &&
+ bindingContainer &&
+ getBoundTextElement(
+ bindingContainer,
+ app.scene.getNonDeletedElementsMap(),
+ ) === null
+ ) {
+ return true;
+ }
+ }
+ return false;
+ },
+ perform: (elements, appState, _, app) => {
+ const selectedElements = app.scene.getSelectedElements(appState);
+
+ let textElement: ExcalidrawTextElement;
+ let container: ExcalidrawTextContainer;
+
+ if (
+ isTextElement(selectedElements[0]) &&
+ isTextBindableContainer(selectedElements[1])
+ ) {
+ textElement = selectedElements[0];
+ container = selectedElements[1];
+ } else {
+ textElement = selectedElements[1] as ExcalidrawTextElement;
+ container = selectedElements[0] as ExcalidrawTextContainer;
+ }
+ mutateElement(textElement, {
+ containerId: container.id,
+ verticalAlign: VERTICAL_ALIGN.MIDDLE,
+ textAlign: TEXT_ALIGN.CENTER,
+ autoResize: true,
+ });
+ mutateElement(container, {
+ boundElements: (container.boundElements || []).concat({
+ type: "text",
+ id: textElement.id,
+ }),
+ });
+ const originalContainerHeight = container.height;
+ redrawTextBoundingBox(
+ textElement,
+ container,
+ app.scene.getNonDeletedElementsMap(),
+ );
+ // overwritting the cache with original container height so
+ // it can be restored when unbind
+ updateOriginalContainerCache(container.id, originalContainerHeight);
+
+ return {
+ elements: pushTextAboveContainer(elements, container, textElement),
+ appState: { ...appState, selectedElementIds: { [container.id]: true } },
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+});
+
+const pushTextAboveContainer = (
+ elements: readonly ExcalidrawElement[],
+ container: ExcalidrawElement,
+ textElement: ExcalidrawTextElement,
+) => {
+ const updatedElements = elements.slice();
+ const textElementIndex = updatedElements.findIndex(
+ (ele) => ele.id === textElement.id,
+ );
+ updatedElements.splice(textElementIndex, 1);
+
+ const containerIndex = updatedElements.findIndex(
+ (ele) => ele.id === container.id,
+ );
+ updatedElements.splice(containerIndex + 1, 0, textElement);
+ syncMovedIndices(updatedElements, arrayToMap([container, textElement]));
+
+ return updatedElements;
+};
+
+const pushContainerBelowText = (
+ elements: readonly ExcalidrawElement[],
+ container: ExcalidrawElement,
+ textElement: ExcalidrawTextElement,
+) => {
+ const updatedElements = elements.slice();
+ const containerIndex = updatedElements.findIndex(
+ (ele) => ele.id === container.id,
+ );
+ updatedElements.splice(containerIndex, 1);
+
+ const textElementIndex = updatedElements.findIndex(
+ (ele) => ele.id === textElement.id,
+ );
+ updatedElements.splice(textElementIndex, 0, container);
+ syncMovedIndices(updatedElements, arrayToMap([container, textElement]));
+
+ return updatedElements;
+};
+
+export const actionWrapTextInContainer = register({
+ name: "wrapTextInContainer",
+ label: "labels.createContainerFromText",
+ trackEvent: { category: "element" },
+ predicate: (elements, appState, _, app) => {
+ const selectedElements = app.scene.getSelectedElements(appState);
+ const areTextElements = selectedElements.every((el) => isTextElement(el));
+ return selectedElements.length > 0 && areTextElements;
+ },
+ perform: (elements, appState, _, app) => {
+ const selectedElements = app.scene.getSelectedElements(appState);
+ let updatedElements: readonly ExcalidrawElement[] = elements.slice();
+ const containerIds: Mutable<AppState["selectedElementIds"]> = {};
+
+ for (const textElement of selectedElements) {
+ if (isTextElement(textElement)) {
+ const container = newElement({
+ type: "rectangle",
+ backgroundColor: appState.currentItemBackgroundColor,
+ boundElements: [
+ ...(textElement.boundElements || []),
+ { id: textElement.id, type: "text" },
+ ],
+ angle: textElement.angle,
+ fillStyle: appState.currentItemFillStyle,
+ strokeColor: appState.currentItemStrokeColor,
+ roughness: appState.currentItemRoughness,
+ strokeWidth: appState.currentItemStrokeWidth,
+ strokeStyle: appState.currentItemStrokeStyle,
+ roundness:
+ appState.currentItemRoundness === "round"
+ ? {
+ type: isUsingAdaptiveRadius("rectangle")
+ ? ROUNDNESS.ADAPTIVE_RADIUS
+ : ROUNDNESS.PROPORTIONAL_RADIUS,
+ }
+ : null,
+ opacity: 100,
+ locked: false,
+ x: textElement.x - BOUND_TEXT_PADDING,
+ y: textElement.y - BOUND_TEXT_PADDING,
+ width: computeContainerDimensionForBoundText(
+ textElement.width,
+ "rectangle",
+ ),
+ height: computeContainerDimensionForBoundText(
+ textElement.height,
+ "rectangle",
+ ),
+ groupIds: textElement.groupIds,
+ frameId: textElement.frameId,
+ });
+
+ // update bindings
+ if (textElement.boundElements?.length) {
+ const linearElementIds = textElement.boundElements
+ .filter((ele) => ele.type === "arrow")
+ .map((el) => el.id);
+ const linearElements = updatedElements.filter((ele) =>
+ linearElementIds.includes(ele.id),
+ ) as ExcalidrawLinearElement[];
+ linearElements.forEach((ele) => {
+ let startBinding = ele.startBinding;
+ let endBinding = ele.endBinding;
+
+ if (startBinding?.elementId === textElement.id) {
+ startBinding = {
+ ...startBinding,
+ elementId: container.id,
+ };
+ }
+
+ if (endBinding?.elementId === textElement.id) {
+ endBinding = { ...endBinding, elementId: container.id };
+ }
+
+ if (startBinding || endBinding) {
+ mutateElement(ele, { startBinding, endBinding }, false);
+ }
+ });
+ }
+
+ mutateElement(
+ textElement,
+ {
+ containerId: container.id,
+ verticalAlign: VERTICAL_ALIGN.MIDDLE,
+ boundElements: null,
+ textAlign: TEXT_ALIGN.CENTER,
+ autoResize: true,
+ },
+ false,
+ );
+ redrawTextBoundingBox(
+ textElement,
+ container,
+ app.scene.getNonDeletedElementsMap(),
+ );
+
+ updatedElements = pushContainerBelowText(
+ [...updatedElements, container],
+ container,
+ textElement,
+ );
+
+ containerIds[container.id] = true;
+ }
+ }
+
+ return {
+ elements: updatedElements,
+ appState: {
+ ...appState,
+ selectedElementIds: containerIds,
+ },
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+});
diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx
new file mode 100644
index 0000000..903a6d8
--- /dev/null
+++ b/packages/excalidraw/actions/actionCanvas.tsx
@@ -0,0 +1,557 @@
+import { ColorPicker } from "../components/ColorPicker/ColorPicker";
+import {
+ handIcon,
+ MoonIcon,
+ SunIcon,
+ TrashIcon,
+ zoomAreaIcon,
+ ZoomInIcon,
+ ZoomOutIcon,
+ ZoomResetIcon,
+} from "../components/icons";
+import { ToolButton } from "../components/ToolButton";
+import {
+ CURSOR_TYPE,
+ MAX_ZOOM,
+ MIN_ZOOM,
+ THEME,
+ ZOOM_STEP,
+} from "../constants";
+import { getCommonBounds, getNonDeletedElements } from "../element";
+import type { ExcalidrawElement } from "../element/types";
+import { t } from "../i18n";
+import { CODES, KEYS } from "../keys";
+import { getNormalizedZoom } from "../scene";
+import { centerScrollOn } from "../scene/scroll";
+import { getStateForZoom } from "../scene/zoom";
+import type { AppState, Offsets } from "../types";
+import { getShortcutKey, updateActiveTool } from "../utils";
+import { register } from "./register";
+import { Tooltip } from "../components/Tooltip";
+import { newElementWith } from "../element/mutateElement";
+import {
+ getDefaultAppState,
+ isEraserActive,
+ isHandToolActive,
+} from "../appState";
+import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
+import type { SceneBounds } from "../element/bounds";
+import { setCursor } from "../cursor";
+import { CaptureUpdateAction } from "../store";
+import { clamp, roundToStep } from "@excalidraw/math";
+
+export const actionChangeViewBackgroundColor = register({
+ name: "changeViewBackgroundColor",
+ label: "labels.canvasBackground",
+ paletteName: "Change canvas background color",
+ trackEvent: false,
+ predicate: (elements, appState, props, app) => {
+ return (
+ !!app.props.UIOptions.canvasActions.changeViewBackgroundColor &&
+ !appState.viewModeEnabled
+ );
+ },
+ perform: (_, appState, value) => {
+ return {
+ appState: { ...appState, ...value },
+ captureUpdate: !!value.viewBackgroundColor
+ ? CaptureUpdateAction.IMMEDIATELY
+ : CaptureUpdateAction.EVENTUALLY,
+ };
+ },
+ PanelComponent: ({ elements, appState, updateData, appProps }) => {
+ // FIXME move me to src/components/mainMenu/DefaultItems.tsx
+ return (
+ <ColorPicker
+ palette={null}
+ topPicks={DEFAULT_CANVAS_BACKGROUND_PICKS}
+ label={t("labels.canvasBackground")}
+ type="canvasBackground"
+ color={appState.viewBackgroundColor}
+ onChange={(color) => updateData({ viewBackgroundColor: color })}
+ data-testid="canvas-background-picker"
+ elements={elements}
+ appState={appState}
+ updateData={updateData}
+ />
+ );
+ },
+});
+
+export const actionClearCanvas = register({
+ name: "clearCanvas",
+ label: "labels.clearCanvas",
+ paletteName: "Clear canvas",
+ icon: TrashIcon,
+ trackEvent: { category: "canvas" },
+ predicate: (elements, appState, props, app) => {
+ return (
+ !!app.props.UIOptions.canvasActions.clearCanvas &&
+ !appState.viewModeEnabled &&
+ appState.openDialog?.name !== "elementLinkSelector"
+ );
+ },
+ perform: (elements, appState, _, app) => {
+ app.imageCache.clear();
+ return {
+ elements: elements.map((element) =>
+ newElementWith(element, { isDeleted: true }),
+ ),
+ appState: {
+ ...getDefaultAppState(),
+ files: {},
+ theme: appState.theme,
+ penMode: appState.penMode,
+ penDetected: appState.penDetected,
+ exportBackground: appState.exportBackground,
+ exportEmbedScene: appState.exportEmbedScene,
+ gridSize: appState.gridSize,
+ gridStep: appState.gridStep,
+ gridModeEnabled: appState.gridModeEnabled,
+ stats: appState.stats,
+ pasteDialog: appState.pasteDialog,
+ activeTool:
+ appState.activeTool.type === "image"
+ ? { ...appState.activeTool, type: "selection" }
+ : appState.activeTool,
+ },
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+});
+
+export const actionZoomIn = register({
+ name: "zoomIn",
+ label: "buttons.zoomIn",
+ viewMode: true,
+ icon: ZoomInIcon,
+ trackEvent: { category: "canvas" },
+ perform: (_elements, appState, _, app) => {
+ return {
+ appState: {
+ ...appState,
+ ...getStateForZoom(
+ {
+ viewportX: appState.width / 2 + appState.offsetLeft,
+ viewportY: appState.height / 2 + appState.offsetTop,
+ nextZoom: getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
+ },
+ appState,
+ ),
+ userToFollow: null,
+ },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ },
+ PanelComponent: ({ updateData, appState }) => (
+ <ToolButton
+ type="button"
+ className="zoom-in-button zoom-button"
+ icon={ZoomInIcon}
+ title={`${t("buttons.zoomIn")} — ${getShortcutKey("CtrlOrCmd++")}`}
+ aria-label={t("buttons.zoomIn")}
+ disabled={appState.zoom.value >= MAX_ZOOM}
+ onClick={() => {
+ updateData(null);
+ }}
+ />
+ ),
+ keyTest: (event) =>
+ (event.code === CODES.EQUAL || event.code === CODES.NUM_ADD) &&
+ (event[KEYS.CTRL_OR_CMD] || event.shiftKey),
+});
+
+export const actionZoomOut = register({
+ name: "zoomOut",
+ label: "buttons.zoomOut",
+ icon: ZoomOutIcon,
+ viewMode: true,
+ trackEvent: { category: "canvas" },
+ perform: (_elements, appState, _, app) => {
+ return {
+ appState: {
+ ...appState,
+ ...getStateForZoom(
+ {
+ viewportX: appState.width / 2 + appState.offsetLeft,
+ viewportY: appState.height / 2 + appState.offsetTop,
+ nextZoom: getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
+ },
+ appState,
+ ),
+ userToFollow: null,
+ },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ },
+ PanelComponent: ({ updateData, appState }) => (
+ <ToolButton
+ type="button"
+ className="zoom-out-button zoom-button"
+ icon={ZoomOutIcon}
+ title={`${t("buttons.zoomOut")} — ${getShortcutKey("CtrlOrCmd+-")}`}
+ aria-label={t("buttons.zoomOut")}
+ disabled={appState.zoom.value <= MIN_ZOOM}
+ onClick={() => {
+ updateData(null);
+ }}
+ />
+ ),
+ keyTest: (event) =>
+ (event.code === CODES.MINUS || event.code === CODES.NUM_SUBTRACT) &&
+ (event[KEYS.CTRL_OR_CMD] || event.shiftKey),
+});
+
+export const actionResetZoom = register({
+ name: "resetZoom",
+ label: "buttons.resetZoom",
+ icon: ZoomResetIcon,
+ viewMode: true,
+ trackEvent: { category: "canvas" },
+ perform: (_elements, appState, _, app) => {
+ return {
+ appState: {
+ ...appState,
+ ...getStateForZoom(
+ {
+ viewportX: appState.width / 2 + appState.offsetLeft,
+ viewportY: appState.height / 2 + appState.offsetTop,
+ nextZoom: getNormalizedZoom(1),
+ },
+ appState,
+ ),
+ userToFollow: null,
+ },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ },
+ PanelComponent: ({ updateData, appState }) => (
+ <Tooltip label={t("buttons.resetZoom")} style={{ height: "100%" }}>
+ <ToolButton
+ type="button"
+ className="reset-zoom-button zoom-button"
+ title={t("buttons.resetZoom")}
+ aria-label={t("buttons.resetZoom")}
+ onClick={() => {
+ updateData(null);
+ }}
+ >
+ {(appState.zoom.value * 100).toFixed(0)}%
+ </ToolButton>
+ </Tooltip>
+ ),
+ keyTest: (event) =>
+ (event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
+ (event[KEYS.CTRL_OR_CMD] || event.shiftKey),
+});
+
+const zoomValueToFitBoundsOnViewport = (
+ bounds: SceneBounds,
+ viewportDimensions: { width: number; height: number },
+ viewportZoomFactor: number = 1, // default to 1 if not provided
+) => {
+ const [x1, y1, x2, y2] = bounds;
+ const commonBoundsWidth = x2 - x1;
+ const zoomValueForWidth = viewportDimensions.width / commonBoundsWidth;
+ const commonBoundsHeight = y2 - y1;
+ const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight;
+ const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight);
+
+ const adjustedZoomValue =
+ smallestZoomValue * clamp(viewportZoomFactor, 0.1, 1);
+
+ return Math.min(adjustedZoomValue, 1);
+};
+
+export const zoomToFitBounds = ({
+ bounds,
+ appState,
+ canvasOffsets,
+ fitToViewport = false,
+ viewportZoomFactor = 1,
+ minZoom = -Infinity,
+ maxZoom = Infinity,
+}: {
+ bounds: SceneBounds;
+ canvasOffsets?: Offsets;
+ appState: Readonly<AppState>;
+ /** whether to fit content to viewport (beyond >100%) */
+ fitToViewport: boolean;
+ /** zoom content to cover X of the viewport, when fitToViewport=true */
+ viewportZoomFactor?: number;
+ minZoom?: number;
+ maxZoom?: number;
+}) => {
+ viewportZoomFactor = clamp(viewportZoomFactor, MIN_ZOOM, MAX_ZOOM);
+
+ const [x1, y1, x2, y2] = bounds;
+ const centerX = (x1 + x2) / 2;
+ const centerY = (y1 + y2) / 2;
+
+ const canvasOffsetLeft = canvasOffsets?.left ?? 0;
+ const canvasOffsetTop = canvasOffsets?.top ?? 0;
+ const canvasOffsetRight = canvasOffsets?.right ?? 0;
+ const canvasOffsetBottom = canvasOffsets?.bottom ?? 0;
+
+ const effectiveCanvasWidth =
+ appState.width - canvasOffsetLeft - canvasOffsetRight;
+ const effectiveCanvasHeight =
+ appState.height - canvasOffsetTop - canvasOffsetBottom;
+
+ let adjustedZoomValue;
+
+ if (fitToViewport) {
+ const commonBoundsWidth = x2 - x1;
+ const commonBoundsHeight = y2 - y1;
+
+ adjustedZoomValue =
+ Math.min(
+ effectiveCanvasWidth / commonBoundsWidth,
+ effectiveCanvasHeight / commonBoundsHeight,
+ ) * viewportZoomFactor;
+ } else {
+ adjustedZoomValue = zoomValueToFitBoundsOnViewport(
+ bounds,
+ {
+ width: effectiveCanvasWidth,
+ height: effectiveCanvasHeight,
+ },
+ viewportZoomFactor,
+ );
+ }
+
+ const newZoomValue = getNormalizedZoom(
+ clamp(roundToStep(adjustedZoomValue, ZOOM_STEP, "floor"), minZoom, maxZoom),
+ );
+
+ const centerScroll = centerScrollOn({
+ scenePoint: { x: centerX, y: centerY },
+ viewportDimensions: {
+ width: appState.width,
+ height: appState.height,
+ },
+ offsets: canvasOffsets,
+ zoom: { value: newZoomValue },
+ });
+
+ return {
+ appState: {
+ ...appState,
+ scrollX: centerScroll.scrollX,
+ scrollY: centerScroll.scrollY,
+ zoom: { value: newZoomValue },
+ },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+};
+
+export const zoomToFit = ({
+ canvasOffsets,
+ targetElements,
+ appState,
+ fitToViewport,
+ viewportZoomFactor,
+ minZoom,
+ maxZoom,
+}: {
+ canvasOffsets?: Offsets;
+ targetElements: readonly ExcalidrawElement[];
+ appState: Readonly<AppState>;
+ /** whether to fit content to viewport (beyond >100%) */
+ fitToViewport: boolean;
+ /** zoom content to cover X of the viewport, when fitToViewport=true */
+ viewportZoomFactor?: number;
+ minZoom?: number;
+ maxZoom?: number;
+}) => {
+ const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
+
+ return zoomToFitBounds({
+ canvasOffsets,
+ bounds: commonBounds,
+ appState,
+ fitToViewport,
+ viewportZoomFactor,
+ minZoom,
+ maxZoom,
+ });
+};
+
+// Note, this action differs from actionZoomToFitSelection in that it doesn't
+// zoom beyond 100%. In other words, if the content is smaller than viewport
+// size, it won't be zoomed in.
+export const actionZoomToFitSelectionInViewport = register({
+ name: "zoomToFitSelectionInViewport",
+ label: "labels.zoomToFitViewport",
+ icon: zoomAreaIcon,
+ trackEvent: { category: "canvas" },
+ perform: (elements, appState, _, app) => {
+ const selectedElements = app.scene.getSelectedElements(appState);
+ return zoomToFit({
+ targetElements: selectedElements.length ? selectedElements : elements,
+ appState: {
+ ...appState,
+ userToFollow: null,
+ },
+ fitToViewport: false,
+ canvasOffsets: app.getEditorUIOffsets(),
+ });
+ },
+ // NOTE shift-2 should have been assigned actionZoomToFitSelection.
+ // TBD on how proceed
+ keyTest: (event) =>
+ event.code === CODES.TWO &&
+ event.shiftKey &&
+ !event.altKey &&
+ !event[KEYS.CTRL_OR_CMD],
+});
+
+export const actionZoomToFitSelection = register({
+ name: "zoomToFitSelection",
+ label: "helpDialog.zoomToSelection",
+ icon: zoomAreaIcon,
+ trackEvent: { category: "canvas" },
+ perform: (elements, appState, _, app) => {
+ const selectedElements = app.scene.getSelectedElements(appState);
+ return zoomToFit({
+ targetElements: selectedElements.length ? selectedElements : elements,
+ appState: {
+ ...appState,
+ userToFollow: null,
+ },
+ fitToViewport: true,
+ canvasOffsets: app.getEditorUIOffsets(),
+ });
+ },
+ // NOTE this action should use shift-2 per figma, alas
+ keyTest: (event) =>
+ event.code === CODES.THREE &&
+ event.shiftKey &&
+ !event.altKey &&
+ !event[KEYS.CTRL_OR_CMD],
+});
+
+export const actionZoomToFit = register({
+ name: "zoomToFit",
+ label: "helpDialog.zoomToFit",
+ icon: zoomAreaIcon,
+ viewMode: true,
+ trackEvent: { category: "canvas" },
+ perform: (elements, appState, _, app) =>
+ zoomToFit({
+ targetElements: elements,
+ appState: {
+ ...appState,
+ userToFollow: null,
+ },
+ fitToViewport: false,
+ canvasOffsets: app.getEditorUIOffsets(),
+ }),
+ keyTest: (event) =>
+ event.code === CODES.ONE &&
+ event.shiftKey &&
+ !event.altKey &&
+ !event[KEYS.CTRL_OR_CMD],
+});
+
+export const actionToggleTheme = register({
+ name: "toggleTheme",
+ label: (_, appState) => {
+ return appState.theme === THEME.DARK
+ ? "buttons.lightMode"
+ : "buttons.darkMode";
+ },
+ keywords: ["toggle", "dark", "light", "mode", "theme"],
+ icon: (appState) => (appState.theme === THEME.LIGHT ? MoonIcon : SunIcon),
+ viewMode: true,
+ trackEvent: { category: "canvas" },
+ perform: (_, appState, value) => {
+ return {
+ appState: {
+ ...appState,
+ theme:
+ value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
+ },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ },
+ keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
+ predicate: (elements, appState, props, app) => {
+ return !!app.props.UIOptions.canvasActions.toggleTheme;
+ },
+});
+
+export const actionToggleEraserTool = register({
+ name: "toggleEraserTool",
+ label: "toolBar.eraser",
+ trackEvent: { category: "toolbar" },
+ perform: (elements, appState) => {
+ let activeTool: AppState["activeTool"];
+
+ if (isEraserActive(appState)) {
+ activeTool = updateActiveTool(appState, {
+ ...(appState.activeTool.lastActiveTool || {
+ type: "selection",
+ }),
+ lastActiveToolBeforeEraser: null,
+ });
+ } else {
+ activeTool = updateActiveTool(appState, {
+ type: "eraser",
+ lastActiveToolBeforeEraser: appState.activeTool,
+ });
+ }
+
+ return {
+ appState: {
+ ...appState,
+ selectedElementIds: {},
+ selectedGroupIds: {},
+ activeEmbeddable: null,
+ activeTool,
+ },
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ keyTest: (event) => event.key === KEYS.E,
+});
+
+export const actionToggleHandTool = register({
+ name: "toggleHandTool",
+ label: "toolBar.hand",
+ paletteName: "Toggle hand tool",
+ trackEvent: { category: "toolbar" },
+ icon: handIcon,
+ viewMode: false,
+ perform: (elements, appState, _, app) => {
+ let activeTool: AppState["activeTool"];
+
+ if (isHandToolActive(appState)) {
+ activeTool = updateActiveTool(appState, {
+ ...(appState.activeTool.lastActiveTool || {
+ type: "selection",
+ }),
+ lastActiveToolBeforeEraser: null,
+ });
+ } else {
+ activeTool = updateActiveTool(appState, {
+ type: "hand",
+ lastActiveToolBeforeEraser: appState.activeTool,
+ });
+ setCursor(app.interactiveCanvas, CURSOR_TYPE.GRAB);
+ }
+
+ return {
+ appState: {
+ ...appState,
+ selectedElementIds: {},
+ selectedGroupIds: {},
+ activeEmbeddable: null,
+ activeTool,
+ },
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ keyTest: (event) =>
+ !event.altKey && !event[KEYS.CTRL_OR_CMD] && event.key === KEYS.H,
+});
diff --git a/packages/excalidraw/actions/actionClipboard.tsx b/packages/excalidraw/actions/actionClipboard.tsx
new file mode 100644
index 0000000..fffe7b3
--- /dev/null
+++ b/packages/excalidraw/actions/actionClipboard.tsx
@@ -0,0 +1,281 @@
+import { CODES, KEYS } from "../keys";
+import { register } from "./register";
+import {
+ copyTextToSystemClipboard,
+ copyToClipboard,
+ createPasteEvent,
+ probablySupportsClipboardBlob,
+ probablySupportsClipboardWriteText,
+ readSystemClipboard,
+} from "../clipboard";
+import { actionDeleteSelected } from "./actionDeleteSelected";
+import { exportCanvas, prepareElementsForExport } from "../data/index";
+import { getTextFromElements, isTextElement } from "../element";
+import { t } from "../i18n";
+import { isFirefox } from "../constants";
+import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
+import { CaptureUpdateAction } from "../store";
+
+export const actionCopy = register({
+ name: "copy",
+ label: "labels.copy",
+ icon: DuplicateIcon,
+ trackEvent: { category: "element" },
+ perform: async (elements, appState, event: ClipboardEvent | null, app) => {
+ const elementsToCopy = app.scene.getSelectedElements({
+ selectedElementIds: appState.selectedElementIds,
+ includeBoundTextElement: true,
+ includeElementsInFrames: true,
+ });
+
+ try {
+ await copyToClipboard(elementsToCopy, app.files, event);
+ } catch (error: any) {
+ return {
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ appState: {
+ ...appState,
+ errorMessage: error.message,
+ },
+ };
+ }
+
+ return {
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ },
+ // don't supply a shortcut since we handle this conditionally via onCopy event
+ keyTest: undefined,
+});
+
+export const actionPaste = register({
+ name: "paste",
+ label: "labels.paste",
+ trackEvent: { category: "element" },
+ perform: async (elements, appState, data, app) => {
+ let types;
+ try {
+ types = await readSystemClipboard();
+ } catch (error: any) {
+ if (error.name === "AbortError" || error.name === "NotAllowedError") {
+ // user probably aborted the action. Though not 100% sure, it's best
+ // to not annoy them with an error message.
+ return false;
+ }
+
+ console.error(`actionPaste ${error.name}: ${error.message}`);
+
+ if (isFirefox) {
+ return {
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ appState: {
+ ...appState,
+ errorMessage: t("hints.firefox_clipboard_write"),
+ },
+ };
+ }
+
+ return {
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ appState: {
+ ...appState,
+ errorMessage: t("errors.asyncPasteFailedOnRead"),
+ },
+ };
+ }
+
+ try {
+ app.pasteFromClipboard(createPasteEvent({ types }));
+ } catch (error: any) {
+ console.error(error);
+ return {
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ appState: {
+ ...appState,
+ errorMessage: t("errors.asyncPasteFailedOnParse"),
+ },
+ };
+ }
+
+ return {
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ },
+ // don't supply a shortcut since we handle this conditionally via onCopy event
+ keyTest: undefined,
+});
+
+export const actionCut = register({
+ name: "cut",
+ label: "labels.cut",
+ icon: cutIcon,
+ trackEvent: { category: "element" },
+ perform: (elements, appState, event: ClipboardEvent | null, app) => {
+ actionCopy.perform(elements, appState, event, app);
+ return actionDeleteSelected.perform(elements, appState, null, app);
+ },
+ keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X,
+});
+
+export const actionCopyAsSvg = register({
+ name: "copyAsSvg",
+ label: "labels.copyAsSvg",
+ icon: svgIcon,
+ trackEvent: { category: "element" },
+ perform: async (elements, appState, _data, app) => {
+ if (!app.canvas) {
+ return {
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ }
+
+ const { exportedElements, exportingFrame } = prepareElementsForExport(
+ elements,
+ appState,
+ true,
+ );
+
+ try {
+ await exportCanvas(
+ "clipboard-svg",
+ exportedElements,
+ appState,
+ app.files,
+ {
+ ...appState,
+ exportingFrame,
+ name: app.getName(),
+ },
+ );
+
+ const selectedElements = app.scene.getSelectedElements({
+ selectedElementIds: appState.selectedElementIds,
+ includeBoundTextElement: true,
+ includeElementsInFrames: true,
+ });
+
+ return {
+ appState: {
+ toast: {
+ message: t("toast.copyToClipboardAsSvg", {
+ exportSelection: selectedElements.length
+ ? t("toast.selection")
+ : t("toast.canvas"),
+ exportColorScheme: appState.exportWithDarkMode
+ ? t("buttons.darkMode")
+ : t("buttons.lightMode"),
+ }),
+ },
+ },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ } catch (error: any) {
+ console.error(error);
+ return {
+ appState: {
+ errorMessage: error.message,
+ },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ }
+ },
+ predicate: (elements) => {
+ return probablySupportsClipboardWriteText && elements.length > 0;
+ },
+ keywords: ["svg", "clipboard", "copy"],
+});
+
+export const actionCopyAsPng = register({
+ name: "copyAsPng",
+ label: "labels.copyAsPng",
+ icon: pngIcon,
+ trackEvent: { category: "element" },
+ perform: async (elements, appState, _data, app) => {
+ if (!app.canvas) {
+ return {
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ }
+ const selectedElements = app.scene.getSelectedElements({
+ selectedElementIds: appState.selectedElementIds,
+ includeBoundTextElement: true,
+ includeElementsInFrames: true,
+ });
+
+ const { exportedElements, exportingFrame } = prepareElementsForExport(
+ elements,
+ appState,
+ true,
+ );
+ try {
+ await exportCanvas("clipboard", exportedElements, appState, app.files, {
+ ...appState,
+ exportingFrame,
+ name: app.getName(),
+ });
+ return {
+ appState: {
+ ...appState,
+ toast: {
+ message: t("toast.copyToClipboardAsPng", {
+ exportSelection: selectedElements.length
+ ? t("toast.selection")
+ : t("toast.canvas"),
+ exportColorScheme: appState.exportWithDarkMode
+ ? t("buttons.darkMode")
+ : t("buttons.lightMode"),
+ }),
+ },
+ },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ } catch (error: any) {
+ console.error(error);
+ return {
+ appState: {
+ ...appState,
+ errorMessage: error.message,
+ },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ }
+ },
+ predicate: (elements) => {
+ return probablySupportsClipboardBlob && elements.length > 0;
+ },
+ keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
+ keywords: ["png", "clipboard", "copy"],
+});
+
+export const copyText = register({
+ name: "copyText",
+ label: "labels.copyText",
+ trackEvent: { category: "element" },
+ perform: (elements, appState, _, app) => {
+ const selectedElements = app.scene.getSelectedElements({
+ selectedElementIds: appState.selectedElementIds,
+ includeBoundTextElement: true,
+ });
+
+ try {
+ copyTextToSystemClipboard(getTextFromElements(selectedElements));
+ } catch (e) {
+ throw new Error(t("errors.copyToSystemClipboardFailed"));
+ }
+ return {
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ },
+ predicate: (elements, appState, _, app) => {
+ return (
+ probablySupportsClipboardWriteText &&
+ app.scene
+ .getSelectedElements({
+ selectedElementIds: appState.selectedElementIds,
+ includeBoundTextElement: true,
+ })
+ .some(isTextElement)
+ );
+ },
+ keywords: ["text", "clipboard", "copy"],
+});
diff --git a/packages/excalidraw/actions/actionCropEditor.tsx b/packages/excalidraw/actions/actionCropEditor.tsx
new file mode 100644
index 0000000..643f666
--- /dev/null
+++ b/packages/excalidraw/actions/actionCropEditor.tsx
@@ -0,0 +1,55 @@
+import { register } from "./register";
+import { cropIcon } from "../components/icons";
+import { CaptureUpdateAction } from "../store";
+import { ToolButton } from "../components/ToolButton";
+import { t } from "../i18n";
+import { isImageElement } from "../element/typeChecks";
+import type { ExcalidrawImageElement } from "../element/types";
+
+export const actionToggleCropEditor = register({
+ name: "cropEditor",
+ label: "helpDialog.cropStart",
+ icon: cropIcon,
+ viewMode: true,
+ trackEvent: { category: "menu" },
+ keywords: ["image", "crop"],
+ perform(elements, appState, _, app) {
+ const selectedElement = app.scene.getSelectedElements({
+ selectedElementIds: appState.selectedElementIds,
+ includeBoundTextElement: true,
+ })[0] as ExcalidrawImageElement;
+
+ return {
+ appState: {
+ ...appState,
+ isCropping: false,
+ croppingElementId: selectedElement.id,
+ },
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ predicate: (elements, appState, _, app) => {
+ const selectedElements = app.scene.getSelectedElements(appState);
+ if (
+ !appState.croppingElementId &&
+ selectedElements.length === 1 &&
+ isImageElement(selectedElements[0])
+ ) {
+ return true;
+ }
+ return false;
+ },
+ PanelComponent: ({ appState, updateData, app }) => {
+ const label = t("helpDialog.cropStart");
+
+ return (
+ <ToolButton
+ type="button"
+ icon={cropIcon}
+ title={label}
+ aria-label={label}
+ onClick={() => updateData(null)}
+ />
+ );
+ },
+});
diff --git a/packages/excalidraw/actions/actionDeleteSelected.test.tsx b/packages/excalidraw/actions/actionDeleteSelected.test.tsx
new file mode 100644
index 0000000..d48646f
--- /dev/null
+++ b/packages/excalidraw/actions/actionDeleteSelected.test.tsx
@@ -0,0 +1,211 @@
+import React from "react";
+import { Excalidraw, mutateElement } from "../index";
+import { act, assertElements, render } from "../tests/test-utils";
+import { API } from "../tests/helpers/api";
+import { actionDeleteSelected } from "./actionDeleteSelected";
+
+const { h } = window;
+
+describe("deleting selected elements when frame selected should keep children + select them", () => {
+ beforeEach(async () => {
+ await render(<Excalidraw />);
+ });
+
+ it("frame only", async () => {
+ const f1 = API.createElement({
+ type: "frame",
+ });
+
+ const r1 = API.createElement({
+ type: "rectangle",
+ frameId: f1.id,
+ });
+
+ API.setElements([f1, r1]);
+
+ API.setSelectedElements([f1]);
+
+ act(() => {
+ h.app.actionManager.executeAction(actionDeleteSelected);
+ });
+
+ assertElements(h.elements, [
+ { id: f1.id, isDeleted: true },
+ { id: r1.id, isDeleted: false, selected: true },
+ ]);
+ });
+
+ it("frame + text container (text's frameId set)", async () => {
+ const f1 = API.createElement({
+ type: "frame",
+ });
+
+ const r1 = API.createElement({
+ type: "rectangle",
+ frameId: f1.id,
+ });
+
+ const t1 = API.createElement({
+ type: "text",
+ width: 200,
+ height: 100,
+ fontSize: 20,
+ containerId: r1.id,
+ frameId: f1.id,
+ });
+
+ mutateElement(r1, {
+ boundElements: [{ type: "text", id: t1.id }],
+ });
+
+ API.setElements([f1, r1, t1]);
+
+ API.setSelectedElements([f1]);
+
+ act(() => {
+ h.app.actionManager.executeAction(actionDeleteSelected);
+ });
+
+ assertElements(h.elements, [
+ { id: f1.id, isDeleted: true },
+ { id: r1.id, isDeleted: false, selected: true },
+ { id: t1.id, isDeleted: false },
+ ]);
+ });
+
+ it("frame + text container (text's frameId not set)", async () => {
+ const f1 = API.createElement({
+ type: "frame",
+ });
+
+ const r1 = API.createElement({
+ type: "rectangle",
+ frameId: f1.id,
+ });
+
+ const t1 = API.createElement({
+ type: "text",
+ width: 200,
+ height: 100,
+ fontSize: 20,
+ containerId: r1.id,
+ frameId: null,
+ });
+
+ mutateElement(r1, {
+ boundElements: [{ type: "text", id: t1.id }],
+ });
+
+ API.setElements([f1, r1, t1]);
+
+ API.setSelectedElements([f1]);
+
+ act(() => {
+ h.app.actionManager.executeAction(actionDeleteSelected);
+ });
+
+ assertElements(h.elements, [
+ { id: f1.id, isDeleted: true },
+ { id: r1.id, isDeleted: false, selected: true },
+ { id: t1.id, isDeleted: false },
+ ]);
+ });
+
+ it("frame + text container (text selected too)", async () => {
+ const f1 = API.createElement({
+ type: "frame",
+ });
+
+ const r1 = API.createElement({
+ type: "rectangle",
+ frameId: f1.id,
+ });
+
+ const t1 = API.createElement({
+ type: "text",
+ width: 200,
+ height: 100,
+ fontSize: 20,
+ containerId: r1.id,
+ frameId: null,
+ });
+
+ mutateElement(r1, {
+ boundElements: [{ type: "text", id: t1.id }],
+ });
+
+ API.setElements([f1, r1, t1]);
+
+ API.setSelectedElements([f1, t1]);
+
+ act(() => {
+ h.app.actionManager.executeAction(actionDeleteSelected);
+ });
+
+ assertElements(h.elements, [
+ { id: f1.id, isDeleted: true },
+ { id: r1.id, isDeleted: false, selected: true },
+ { id: t1.id, isDeleted: false },
+ ]);
+ });
+
+ it("frame + labeled arrow", async () => {
+ const f1 = API.createElement({
+ type: "frame",
+ });
+
+ const a1 = API.createElement({
+ type: "arrow",
+ frameId: f1.id,
+ });
+
+ const t1 = API.createElement({
+ type: "text",
+ width: 200,
+ height: 100,
+ fontSize: 20,
+ containerId: a1.id,
+ frameId: null,
+ });
+
+ mutateElement(a1, {
+ boundElements: [{ type: "text", id: t1.id }],
+ });
+
+ API.setElements([f1, a1, t1]);
+
+ API.setSelectedElements([f1, t1]);
+
+ act(() => {
+ h.app.actionManager.executeAction(actionDeleteSelected);
+ });
+
+ assertElements(h.elements, [
+ { id: f1.id, isDeleted: true },
+ { id: a1.id, isDeleted: false, selected: true },
+ { id: t1.id, isDeleted: false },
+ ]);
+ });
+
+ it("frame + children selected", async () => {
+ const f1 = API.createElement({
+ type: "frame",
+ });
+ const r1 = API.createElement({
+ type: "rectangle",
+ frameId: f1.id,
+ });
+ API.setElements([f1, r1]);
+
+ API.setSelectedElements([f1, r1]);
+
+ act(() => {
+ h.app.actionManager.executeAction(actionDeleteSelected);
+ });
+
+ assertElements(h.elements, [
+ { id: f1.id, isDeleted: true },
+ { id: r1.id, isDeleted: false, selected: true },
+ ]);
+ });
+});
diff --git a/packages/excalidraw/actions/actionDeleteSelected.tsx b/packages/excalidraw/actions/actionDeleteSelected.tsx
new file mode 100644
index 0000000..c640f92
--- /dev/null
+++ b/packages/excalidraw/actions/actionDeleteSelected.tsx
@@ -0,0 +1,311 @@
+import { getSelectedElements, isSomeElementSelected } from "../scene";
+import { KEYS } from "../keys";
+import { ToolButton } from "../components/ToolButton";
+import { t } from "../i18n";
+import { register } from "./register";
+import { getNonDeletedElements } from "../element";
+import type { ExcalidrawElement } from "../element/types";
+import type { AppClassProperties, AppState } from "../types";
+import { mutateElement, newElementWith } from "../element/mutateElement";
+import { getElementsInGroup, selectGroupsForSelectedElements } from "../groups";
+import { LinearElementEditor } from "../element/linearElementEditor";
+import { fixBindingsAfterDeletion } from "../element/binding";
+import {
+ isBoundToContainer,
+ isElbowArrow,
+ isFrameLikeElement,
+} from "../element/typeChecks";
+import { updateActiveTool } from "../utils";
+import { TrashIcon } from "../components/icons";
+import { CaptureUpdateAction } from "../store";
+import { getContainerElement } from "../element/textElement";
+import { getFrameChildren } from "../frame";
+
+const deleteSelectedElements = (
+ elements: readonly ExcalidrawElement[],
+ appState: AppState,
+ app: AppClassProperties,
+) => {
+ const framesToBeDeleted = new Set(
+ getSelectedElements(
+ elements.filter((el) => isFrameLikeElement(el)),
+ appState,
+ ).map((el) => el.id),
+ );
+
+ const selectedElementIds: Record<ExcalidrawElement["id"], true> = {};
+
+ const elementsMap = app.scene.getNonDeletedElementsMap();
+
+ const processedElements = new Set<ExcalidrawElement["id"]>();
+
+ for (const frameId of framesToBeDeleted) {
+ const frameChildren = getFrameChildren(elements, frameId);
+ for (const el of frameChildren) {
+ if (processedElements.has(el.id)) {
+ continue;
+ }
+
+ if (isBoundToContainer(el)) {
+ const containerElement = getContainerElement(el, elementsMap);
+ if (containerElement) {
+ selectedElementIds[containerElement.id] = true;
+ }
+ } else {
+ selectedElementIds[el.id] = true;
+ }
+ processedElements.add(el.id);
+ }
+ }
+
+ let shouldSelectEditingGroup = true;
+
+ const nextElements = elements.map((el) => {
+ if (appState.selectedElementIds[el.id]) {
+ const boundElement = isBoundToContainer(el)
+ ? getContainerElement(el, elementsMap)
+ : null;
+
+ if (el.frameId && framesToBeDeleted.has(el.frameId)) {
+ shouldSelectEditingGroup = false;
+ selectedElementIds[el.id] = true;
+ return el;
+ }
+
+ if (
+ boundElement?.frameId &&
+ framesToBeDeleted.has(boundElement?.frameId)
+ ) {
+ return el;
+ }
+
+ if (el.boundElements) {
+ el.boundElements.forEach((candidate) => {
+ const bound = app.scene.getNonDeletedElementsMap().get(candidate.id);
+ if (bound && isElbowArrow(bound)) {
+ mutateElement(bound, {
+ startBinding:
+ el.id === bound.startBinding?.elementId
+ ? null
+ : bound.startBinding,
+ endBinding:
+ el.id === bound.endBinding?.elementId ? null : bound.endBinding,
+ });
+ mutateElement(bound, { points: bound.points });
+ }
+ });
+ }
+ return newElementWith(el, { isDeleted: true });
+ }
+
+ // if deleting a frame, remove the children from it and select them
+ if (el.frameId && framesToBeDeleted.has(el.frameId)) {
+ shouldSelectEditingGroup = false;
+ if (!isBoundToContainer(el)) {
+ selectedElementIds[el.id] = true;
+ }
+ return newElementWith(el, { frameId: null });
+ }
+
+ if (isBoundToContainer(el) && appState.selectedElementIds[el.containerId]) {
+ return newElementWith(el, { isDeleted: true });
+ }
+ return el;
+ });
+
+ let nextEditingGroupId = appState.editingGroupId;
+
+ // select next eligible element in currently editing group or supergroup
+ if (shouldSelectEditingGroup && appState.editingGroupId) {
+ const elems = getElementsInGroup(
+ nextElements,
+ appState.editingGroupId,
+ ).filter((el) => !el.isDeleted);
+ if (elems.length > 1) {
+ if (elems[0]) {
+ selectedElementIds[elems[0].id] = true;
+ }
+ } else {
+ nextEditingGroupId = null;
+ if (elems[0]) {
+ selectedElementIds[elems[0].id] = true;
+ }
+
+ const lastElementInGroup = elems[0];
+ if (lastElementInGroup) {
+ const editingGroupIdx = lastElementInGroup.groupIds.findIndex(
+ (groupId) => {
+ return groupId === appState.editingGroupId;
+ },
+ );
+ const superGroupId = lastElementInGroup.groupIds[editingGroupIdx + 1];
+ if (superGroupId) {
+ const elems = getElementsInGroup(nextElements, superGroupId).filter(
+ (el) => !el.isDeleted,
+ );
+ if (elems.length > 1) {
+ nextEditingGroupId = superGroupId;
+
+ elems.forEach((el) => {
+ selectedElementIds[el.id] = true;
+ });
+ }
+ }
+ }
+ }
+ }
+
+ return {
+ elements: nextElements,
+ appState: {
+ ...appState,
+ ...selectGroupsForSelectedElements(
+ {
+ selectedElementIds,
+ editingGroupId: nextEditingGroupId,
+ },
+ nextElements,
+ appState,
+ null,
+ ),
+ },
+ };
+};
+
+const handleGroupEditingState = (
+ appState: AppState,
+ elements: readonly ExcalidrawElement[],
+): AppState => {
+ if (appState.editingGroupId) {
+ const siblingElements = getElementsInGroup(
+ getNonDeletedElements(elements),
+ appState.editingGroupId!,
+ );
+ if (siblingElements.length) {
+ return {
+ ...appState,
+ selectedElementIds: { [siblingElements[0].id]: true },
+ };
+ }
+ }
+ return appState;
+};
+
+export const actionDeleteSelected = register({
+ name: "deleteSelectedElements",
+ label: "labels.delete",
+ icon: TrashIcon,
+ trackEvent: { category: "element", action: "delete" },
+ perform: (elements, appState, formData, app) => {
+ if (appState.editingLinearElement) {
+ const {
+ elementId,
+ selectedPointsIndices,
+ startBindingElement,
+ endBindingElement,
+ } = appState.editingLinearElement;
+ const elementsMap = app.scene.getNonDeletedElementsMap();
+ const element = LinearElementEditor.getElement(elementId, elementsMap);
+ if (!element) {
+ return false;
+ }
+ // case: no point selected → do nothing, as deleting the whole element
+ // is most likely a mistake, where you wanted to delete a specific point
+ // but failed to select it (or you thought it's selected, while it was
+ // only in a hover state)
+ if (selectedPointsIndices == null) {
+ return false;
+ }
+
+ // case: deleting last remaining point
+ if (element.points.length < 2) {
+ const nextElements = elements.map((el) => {
+ if (el.id === element.id) {
+ return newElementWith(el, { isDeleted: true });
+ }
+ return el;
+ });
+ const nextAppState = handleGroupEditingState(appState, nextElements);
+
+ return {
+ elements: nextElements,
+ appState: {
+ ...nextAppState,
+ editingLinearElement: null,
+ },
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ }
+
+ // We cannot do this inside `movePoint` because it is also called
+ // when deleting the uncommitted point (which hasn't caused any binding)
+ const binding = {
+ startBindingElement: selectedPointsIndices?.includes(0)
+ ? null
+ : startBindingElement,
+ endBindingElement: selectedPointsIndices?.includes(
+ element.points.length - 1,
+ )
+ ? null
+ : endBindingElement,
+ };
+
+ LinearElementEditor.deletePoints(element, selectedPointsIndices);
+
+ return {
+ elements,
+ appState: {
+ ...appState,
+ editingLinearElement: {
+ ...appState.editingLinearElement,
+ ...binding,
+ selectedPointsIndices:
+ selectedPointsIndices?.[0] > 0
+ ? [selectedPointsIndices[0] - 1]
+ : [0],
+ },
+ },
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ }
+
+ let { elements: nextElements, appState: nextAppState } =
+ deleteSelectedElements(elements, appState, app);
+
+ fixBindingsAfterDeletion(
+ nextElements,
+ nextElements.filter((el) => el.isDeleted),
+ );
+
+ nextAppState = handleGroupEditingState(nextAppState, nextElements);
+
+ return {
+ elements: nextElements,
+ appState: {
+ ...nextAppState,
+ activeTool: updateActiveTool(appState, { type: "selection" }),
+ multiElement: null,
+ activeEmbeddable: null,
+ },
+ captureUpdate: isSomeElementSelected(
+ getNonDeletedElements(elements),
+ appState,
+ )
+ ? CaptureUpdateAction.IMMEDIATELY
+ : CaptureUpdateAction.EVENTUALLY,
+ };
+ },
+ keyTest: (event, appState, elements) =>
+ (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) &&
+ !event[KEYS.CTRL_OR_CMD],
+ PanelComponent: ({ elements, appState, updateData }) => (
+ <ToolButton
+ type="button"
+ icon={TrashIcon}
+ title={t("labels.delete")}
+ aria-label={t("labels.delete")}
+ onClick={() => updateData(null)}
+ visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
+ />
+ ),
+});
diff --git a/packages/excalidraw/actions/actionDistribute.tsx b/packages/excalidraw/actions/actionDistribute.tsx
new file mode 100644
index 0000000..15d33b1
--- /dev/null
+++ b/packages/excalidraw/actions/actionDistribute.tsx
@@ -0,0 +1,110 @@
+import {
+ DistributeHorizontallyIcon,
+ DistributeVerticallyIcon,
+} from "../components/icons";
+import { ToolButton } from "../components/ToolButton";
+import type { Distribution } from "../distribute";
+import { distributeElements } from "../distribute";
+import { getNonDeletedElements } from "../element";
+import { isFrameLikeElement } from "../element/typeChecks";
+import type { ExcalidrawElement } from "../element/types";
+import { updateFrameMembershipOfSelectedElements } from "../frame";
+import { t } from "../i18n";
+import { CODES, KEYS } from "../keys";
+import { isSomeElementSelected } from "../scene";
+import { CaptureUpdateAction } from "../store";
+import type { AppClassProperties, AppState } from "../types";
+import { arrayToMap, getShortcutKey } from "../utils";
+import { register } from "./register";
+
+const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
+ const selectedElements = app.scene.getSelectedElements(appState);
+ return (
+ selectedElements.length > 1 &&
+ // TODO enable distributing frames when implemented properly
+ !selectedElements.some((el) => isFrameLikeElement(el))
+ );
+};
+
+const distributeSelectedElements = (
+ elements: readonly ExcalidrawElement[],
+ appState: Readonly<AppState>,
+ app: AppClassProperties,
+ distribution: Distribution,
+) => {
+ const selectedElements = app.scene.getSelectedElements(appState);
+
+ const updatedElements = distributeElements(
+ selectedElements,
+ app.scene.getNonDeletedElementsMap(),
+ distribution,
+ );
+
+ const updatedElementsMap = arrayToMap(updatedElements);
+
+ return updateFrameMembershipOfSelectedElements(
+ elements.map((element) => updatedElementsMap.get(element.id) || element),
+ appState,
+ app,
+ );
+};
+
+export const distributeHorizontally = register({
+ name: "distributeHorizontally",
+ label: "labels.distributeHorizontally",
+ trackEvent: { category: "element" },
+ perform: (elements, appState, _, app) => {
+ return {
+ appState,
+ elements: distributeSelectedElements(elements, appState, app, {
+ space: "between",
+ axis: "x",
+ }),
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ keyTest: (event) =>
+ !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.H,
+ PanelComponent: ({ elements, appState, updateData, app }) => (
+ <ToolButton
+ hidden={!enableActionGroup(appState, app)}
+ type="button"
+ icon={DistributeHorizontallyIcon}
+ onClick={() => updateData(null)}
+ title={`${t("labels.distributeHorizontally")} — ${getShortcutKey(
+ "Alt+H",
+ )}`}
+ aria-label={t("labels.distributeHorizontally")}
+ visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
+ />
+ ),
+});
+
+export const distributeVertically = register({
+ name: "distributeVertically",
+ label: "labels.distributeVertically",
+ trackEvent: { category: "element" },
+ perform: (elements, appState, _, app) => {
+ return {
+ appState,
+ elements: distributeSelectedElements(elements, appState, app, {
+ space: "between",
+ axis: "y",
+ }),
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ keyTest: (event) =>
+ !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
+ PanelComponent: ({ elements, appState, updateData, app }) => (
+ <ToolButton
+ hidden={!enableActionGroup(appState, app)}
+ type="button"
+ icon={DistributeVerticallyIcon}
+ onClick={() => updateData(null)}
+ title={`${t("labels.distributeVertically")} — ${getShortcutKey("Alt+V")}`}
+ aria-label={t("labels.distributeVertically")}
+ visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
+ />
+ ),
+});
diff --git a/packages/excalidraw/actions/actionDuplicateSelection.test.tsx b/packages/excalidraw/actions/actionDuplicateSelection.test.tsx
new file mode 100644
index 0000000..dc471df
--- /dev/null
+++ b/packages/excalidraw/actions/actionDuplicateSelection.test.tsx
@@ -0,0 +1,530 @@
+import { Excalidraw } from "../index";
+import {
+ act,
+ assertElements,
+ getCloneByOrigId,
+ render,
+} from "../tests/test-utils";
+import { API } from "../tests/helpers/api";
+import { actionDuplicateSelection } from "./actionDuplicateSelection";
+import React from "react";
+import { ORIG_ID } from "../constants";
+
+const { h } = window;
+
+describe("actionDuplicateSelection", () => {
+ beforeEach(async () => {
+ await render(<Excalidraw />);
+ });
+
+ describe("duplicating frames", () => {
+ it("frame selected only", async () => {
+ const frame = API.createElement({
+ type: "frame",
+ });
+
+ const rectangle = API.createElement({
+ type: "rectangle",
+ frameId: frame.id,
+ });
+
+ API.setElements([frame, rectangle]);
+ API.setSelectedElements([frame]);
+
+ act(() => {
+ h.app.actionManager.executeAction(actionDuplicateSelection);
+ });
+
+ assertElements(h.elements, [
+ { id: frame.id },
+ { id: rectangle.id, frameId: frame.id },
+ { [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id },
+ { [ORIG_ID]: frame.id, selected: true },
+ ]);
+ });
+
+ it("frame selected only (with text container)", async () => {
+ const frame = API.createElement({
+ type: "frame",
+ });
+
+ const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
+
+ API.setElements([frame, rectangle, text]);
+ API.setSelectedElements([frame]);
+
+ act(() => {
+ h.app.actionManager.executeAction(actionDuplicateSelection);
+ });
+
+ assertElements(h.elements, [
+ { id: frame.id },
+ { id: rectangle.id, frameId: frame.id },
+ { id: text.id, containerId: rectangle.id, frameId: frame.id },
+ { [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id },
+ {
+ [ORIG_ID]: text.id,
+ containerId: getCloneByOrigId(rectangle.id)?.id,
+ frameId: getCloneByOrigId(frame.id)?.id,
+ },
+ { [ORIG_ID]: frame.id, selected: true },
+ ]);
+ });
+
+ it("frame + text container selected (order A)", async () => {
+ const frame = API.createElement({
+ type: "frame",
+ });
+
+ const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
+
+ API.setElements([frame, rectangle, text]);
+ API.setSelectedElements([frame, rectangle]);
+
+ act(() => {
+ h.app.actionManager.executeAction(actionDuplicateSelection);
+ });
+
+ assertElements(h.elements, [
+ { id: frame.id },
+ { id: rectangle.id, frameId: frame.id },
+ { id: text.id, containerId: rectangle.id, frameId: frame.id },
+ {
+ [ORIG_ID]: rectangle.id,
+ frameId: getCloneByOrigId(frame.id)?.id,
+ },
+ {
+ [ORIG_ID]: text.id,
+ containerId: getCloneByOrigId(rectangle.id)?.id,
+ frameId: getCloneByOrigId(frame.id)?.id,
+ },
+ {
+ [ORIG_ID]: frame.id,
+ selected: true,
+ },
+ ]);
+ });
+
+ it("frame + text container selected (order B)", async () => {
+ const frame = API.createElement({
+ type: "frame",
+ });
+
+ const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
+
+ API.setElements([text, rectangle, frame]);
+ API.setSelectedElements([rectangle, frame]);
+
+ act(() => {
+ h.app.actionManager.executeAction(actionDuplicateSelection);
+ });
+
+ assertElements(h.elements, [
+ { id: rectangle.id, frameId: frame.id },
+ { id: text.id, containerId: rectangle.id, frameId: frame.id },
+ { id: frame.id },
+ {
+ type: "rectangle",
+ [ORIG_ID]: `${rectangle.id}`,
+ },
+ {
+ [ORIG_ID]: `${text.id}`,
+ type: "text",
+ containerId: getCloneByOrigId(rectangle.id)?.id,
+ frameId: getCloneByOrigId(frame.id)?.id,
+ },
+ { [ORIG_ID]: `${frame.id}`, type: "frame", selected: true },
+ ]);
+ });
+ });
+
+ describe("duplicating frame children", () => {
+ it("frame child selected", () => {
+ const frame = API.createElement({
+ type: "frame",
+ });
+
+ const rectangle = API.createElement({
+ type: "rectangle",
+ frameId: frame.id,
+ });
+
+ API.setElements([frame, rectangle]);
+ API.setSelectedElements([rectangle]);
+
+ act(() => {
+ h.app.actionManager.executeAction(actionDuplicateSelection);
+ });
+
+ assertElements(h.elements, [
+ { id: frame.id },
+ { id: rectangle.id, frameId: frame.id },
+ { [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
+ ]);
+ });
+
+ it("frame text container selected (rectangle selected)", () => {
+ const frame = API.createElement({
+ type: "frame",
+ });
+
+ const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
+
+ API.setElements([frame, rectangle, text]);
+ API.setSelectedElements([rectangle]);
+
+ act(() => {
+ h.app.actionManager.executeAction(actionDuplicateSelection);
+ });
+
+ assertElements(h.elements, [
+ { id: frame.id },
+ { id: rectangle.id, frameId: frame.id },
+ { id: text.id, containerId: rectangle.id, frameId: frame.id },
+ { [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
+ {
+ [ORIG_ID]: text.id,
+ containerId: getCloneByOrigId(rectangle.id).id,
+ frameId: frame.id,
+ },
+ ]);
+ });
+
+ it("frame bound text selected (container not selected)", () => {
+ const frame = API.createElement({
+ type: "frame",
+ });
+
+ const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
+
+ API.setElements([frame, rectangle, text]);
+ API.setSelectedElements([text]);
+
+ act(() => {
+ h.app.actionManager.executeAction(actionDuplicateSelection);
+ });
+
+ assertElements(h.elements, [
+ { id: frame.id },
+ { id: rectangle.id, frameId: frame.id },
+ { id: text.id, containerId: rectangle.id, frameId: frame.id },
+ { [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
+ {
+ [ORIG_ID]: text.id,
+ containerId: getCloneByOrigId(rectangle.id).id,
+ frameId: frame.id,
+ },
+ ]);
+ });
+
+ it("frame text container selected (text not exists)", () => {
+ const frame = API.createElement({
+ type: "frame",
+ });
+
+ const [rectangle] = API.createTextContainer({ frameId: frame.id });
+
+ API.setElements([frame, rectangle]);
+ API.setSelectedElements([rectangle]);
+
+ act(() => {
+ h.app.actionManager.executeAction(actionDuplicateSelection);
+ });
+
+ assertElements(h.elements, [
+ { id: frame.id },
+ { id: rectangle.id, frameId: frame.id },
+ { [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
+ ]);
+ });
+
+ // shouldn't happen
+ it("frame bound text selected (container not exists)", () => {
+ const frame = API.createElement({
+ type: "frame",
+ });
+
+ const [, text] = API.createTextContainer({ frameId: frame.id });
+
+ API.setElements([frame, text]);
+ API.setSelectedElements([text]);
+
+ act(() => {
+ h.app.actionManager.executeAction(actionDuplicateSelection);
+ });
+
+ assertElements(h.elements, [
+ { id: frame.id },
+ { id: text.id, frameId: frame.id },
+ { [ORIG_ID]: text.id, frameId: frame.id },
+ ]);
+ });
+
+ it("frame bound container selected (text has no frameId)", () => {
+ const frame = API.createElement({
+ type: "frame",
+ });
+
+ const [rectangle, text] = API.createTextContainer({
+ frameId: frame.id,
+ label: { frameId: null },
+ });
+
+ API.setElements([frame, rectangle, text]);
+ API.setSelectedElements([rectangle]);
+
+ act(() => {
+ h.app.actionManager.executeAction(actionDuplicateSelection);
+ });
+
+ assertElements(h.elements, [
+ { id: frame.id },
+ { id: rectangle.id, frameId: frame.id },
+ { id: text.id, containerId: rectangle.id },
+ { [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
+ {
+ [ORIG_ID]: text.id,
+ containerId: getCloneByOrigId(rectangle.id).id,
+ },
+ ]);
+ });
+ });
+
+ describe("duplicating multiple frames", () => {
+ it("multiple frames selected (no children)", () => {
+ const frame1 = API.createElement({
+ type: "frame",
+ });
+
+ const rect1 = API.createElement({
+ type: "rectangle",
+ frameId: frame1.id,
+ });
+
+ const frame2 = API.createElement({
+ type: "frame",
+ });
+
+ const rect2 = API.createElement({
+ type: "rectangle",
+ frameId: frame2.id,
+ });
+
+ const ellipse = API.createElement({
+ type: "ellipse",
+ });
+
+ API.setElements([rect1, frame1, ellipse, rect2, frame2]);
+ API.setSelectedElements([frame1, frame2]);
+
+ act(() => {
+ h.app.actionManager.executeAction(actionDuplicateSelection);
+ });
+
+ assertElements(h.elements, [
+ { id: rect1.id, frameId: frame1.id },
+ { id: frame1.id },
+ { [ORIG_ID]: rect1.id, frameId: getCloneByOrigId(frame1.id)?.id },
+ { [ORIG_ID]: frame1.id, selected: true },
+ { id: ellipse.id },
+ { id: rect2.id, frameId: frame2.id },
+ { id: frame2.id },
+ { [ORIG_ID]: rect2.id, frameId: getCloneByOrigId(frame2.id)?.id },
+ { [ORIG_ID]: frame2.id, selected: true },
+ ]);
+ });
+
+ it("multiple frames selected (no children) + unrelated element", () => {
+ const frame1 = API.createElement({
+ type: "frame",
+ });
+
+ const rect1 = API.createElement({
+ type: "rectangle",
+ frameId: frame1.id,
+ });
+
+ const frame2 = API.createElement({
+ type: "frame",
+ });
+
+ const rect2 = API.createElement({
+ type: "rectangle",
+ frameId: frame2.id,
+ });
+
+ const ellipse = API.createElement({
+ type: "ellipse",
+ });
+
+ API.setElements([rect1, frame1, ellipse, rect2, frame2]);
+ API.setSelectedElements([frame1, ellipse, frame2]);
+
+ act(() => {
+ h.app.actionManager.executeAction(actionDuplicateSelection);
+ });
+
+ assertElements(h.elements, [
+ { id: rect1.id, frameId: frame1.id },
+ { id: frame1.id },
+ { [ORIG_ID]: rect1.id, frameId: getCloneByOrigId(frame1.id)?.id },
+ { [ORIG_ID]: frame1.id, selected: true },
+ { id: ellipse.id },
+ { [ORIG_ID]: ellipse.id, selected: true },
+ { id: rect2.id, frameId: frame2.id },
+ { id: frame2.id },
+ { [ORIG_ID]: rect2.id, frameId: getCloneByOrigId(frame2.id)?.id },
+ { [ORIG_ID]: frame2.id, selected: true },
+ ]);
+ });
+ });
+
+ describe("duplicating containers/bound elements", () => {
+ it("labeled arrow (arrow selected)", () => {
+ const [arrow, text] = API.createLabeledArrow();
+
+ API.setElements([arrow, text]);
+ API.setSelectedElements([arrow]);
+
+ act(() => {
+ h.app.actionManager.executeAction(actionDuplicateSelection);
+ });
+
+ assertElements(h.elements, [
+ { id: arrow.id },
+ { id: text.id, containerId: arrow.id },
+ { [ORIG_ID]: arrow.id, selected: true },
+ { [ORIG_ID]: text.id, containerId: getCloneByOrigId(arrow.id)?.id },
+ ]);
+ });
+
+ // shouldn't happen
+ it("labeled arrow (text selected)", () => {
+ const [arrow, text] = API.createLabeledArrow();
+
+ API.setElements([arrow, text]);
+ API.setSelectedElements([text]);
+
+ act(() => {
+ h.app.actionManager.executeAction(actionDuplicateSelection);
+ });
+
+ assertElements(h.elements, [
+ { id: arrow.id },
+ { id: text.id, containerId: arrow.id },
+ { [ORIG_ID]: arrow.id, selected: true },
+ { [ORIG_ID]: text.id, containerId: getCloneByOrigId(arrow.id)?.id },
+ ]);
+ });
+ });
+
+ describe("duplicating groups", () => {
+ it("duplicate group containing frame (children don't have groupIds set)", () => {
+ const frame = API.createElement({
+ type: "frame",
+ groupIds: ["A"],
+ });
+
+ const [rectangle, text] = API.createTextContainer({
+ frameId: frame.id,
+ });
+
+ const ellipse = API.createElement({
+ type: "ellipse",
+ groupIds: ["A"],
+ });
+
+ API.setElements([rectangle, text, frame, ellipse]);
+ API.setSelectedElements([frame, ellipse]);
+
+ act(() => {
+ h.app.actionManager.executeAction(actionDuplicateSelection);
+ });
+
+ assertElements(h.elements, [
+ { id: rectangle.id, frameId: frame.id },
+ { id: text.id, frameId: frame.id },
+ { id: frame.id },
+ { id: ellipse.id },
+ { [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id },
+ { [ORIG_ID]: text.id, frameId: getCloneByOrigId(frame.id)?.id },
+ { [ORIG_ID]: frame.id, selected: true },
+ { [ORIG_ID]: ellipse.id, selected: true },
+ ]);
+ });
+
+ it("duplicate group containing frame (children have groupIds)", () => {
+ const frame = API.createElement({
+ type: "frame",
+ groupIds: ["A"],
+ });
+
+ const [rectangle, text] = API.createTextContainer({
+ frameId: frame.id,
+ groupIds: ["A"],
+ });
+
+ const ellipse = API.createElement({
+ type: "ellipse",
+ groupIds: ["A"],
+ });
+
+ API.setElements([rectangle, text, frame, ellipse]);
+ API.setSelectedElements([frame, ellipse]);
+
+ act(() => {
+ h.app.actionManager.executeAction(actionDuplicateSelection);
+ });
+
+ assertElements(h.elements, [
+ { id: rectangle.id, frameId: frame.id },
+ { id: text.id, frameId: frame.id },
+ { id: frame.id },
+ { id: ellipse.id },
+ {
+ [ORIG_ID]: rectangle.id,
+ frameId: getCloneByOrigId(frame.id)?.id,
+ // FIXME shouldn't be selected (in selectGroupsForSelectedElements)
+ selected: true,
+ },
+ {
+ [ORIG_ID]: text.id,
+ frameId: getCloneByOrigId(frame.id)?.id,
+ // FIXME shouldn't be selected (in selectGroupsForSelectedElements)
+ selected: true,
+ },
+ { [ORIG_ID]: frame.id, selected: true },
+ { [ORIG_ID]: ellipse.id, selected: true },
+ ]);
+ });
+
+ it("duplicating element nested in group", () => {
+ const ellipse = API.createElement({
+ type: "ellipse",
+ groupIds: ["B"],
+ });
+ const rect1 = API.createElement({
+ type: "rectangle",
+ groupIds: ["A", "B"],
+ });
+ const rect2 = API.createElement({
+ type: "rectangle",
+ groupIds: ["A", "B"],
+ });
+
+ API.setElements([ellipse, rect1, rect2]);
+ API.setSelectedElements([ellipse], "B");
+
+ act(() => {
+ h.app.actionManager.executeAction(actionDuplicateSelection);
+ });
+
+ assertElements(h.elements, [
+ { id: ellipse.id },
+ { [ORIG_ID]: ellipse.id, groupIds: ["B"], selected: true },
+ { id: rect1.id, groupIds: ["A", "B"] },
+ { id: rect2.id, groupIds: ["A", "B"] },
+ ]);
+ });
+ });
+});
diff --git a/packages/excalidraw/actions/actionDuplicateSelection.tsx b/packages/excalidraw/actions/actionDuplicateSelection.tsx
new file mode 100644
index 0000000..b28e831
--- /dev/null
+++ b/packages/excalidraw/actions/actionDuplicateSelection.tsx
@@ -0,0 +1,359 @@
+import { KEYS } from "../keys";
+import { register } from "./register";
+import type { ExcalidrawElement } from "../element/types";
+import { duplicateElement, getNonDeletedElements } from "../element";
+import { isSomeElementSelected } from "../scene";
+import { ToolButton } from "../components/ToolButton";
+import { t } from "../i18n";
+import {
+ arrayToMap,
+ castArray,
+ findLastIndex,
+ getShortcutKey,
+ invariant,
+} from "../utils";
+import { LinearElementEditor } from "../element/linearElementEditor";
+import {
+ selectGroupsForSelectedElements,
+ getSelectedGroupForElement,
+ getElementsInGroup,
+} from "../groups";
+import type { AppState } from "../types";
+import { fixBindingsAfterDuplication } from "../element/binding";
+import type { ActionResult } from "./types";
+import { DEFAULT_GRID_SIZE } from "../constants";
+import {
+ bindTextToShapeAfterDuplication,
+ getBoundTextElement,
+ getContainerElement,
+} from "../element/textElement";
+import {
+ hasBoundTextElement,
+ isBoundToContainer,
+ isFrameLikeElement,
+} from "../element/typeChecks";
+import { normalizeElementOrder } from "../element/sortElements";
+import { DuplicateIcon } from "../components/icons";
+import {
+ bindElementsToFramesAfterDuplication,
+ getFrameChildren,
+} from "../frame";
+import {
+ excludeElementsInFramesFromSelection,
+ getSelectedElements,
+} from "../scene/selection";
+import { CaptureUpdateAction } from "../store";
+
+export const actionDuplicateSelection = register({
+ name: "duplicateSelection",
+ label: "labels.duplicateSelection",
+ icon: DuplicateIcon,
+ trackEvent: { category: "element" },
+ perform: (elements, appState, formData, app) => {
+ // duplicate selected point(s) if editing a line
+ if (appState.editingLinearElement) {
+ // TODO: Invariants should be checked here instead of duplicateSelectedPoints()
+ try {
+ const newAppState = LinearElementEditor.duplicateSelectedPoints(
+ appState,
+ app.scene.getNonDeletedElementsMap(),
+ );
+
+ return {
+ elements,
+ appState: newAppState,
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ } catch {
+ return false;
+ }
+ }
+
+ const nextState = duplicateElements(elements, appState);
+
+ if (app.props.onDuplicate && nextState.elements) {
+ const mappedElements = app.props.onDuplicate(
+ nextState.elements,
+ elements,
+ );
+ if (mappedElements) {
+ nextState.elements = mappedElements;
+ }
+ }
+
+ return {
+ ...nextState,
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
+ PanelComponent: ({ elements, appState, updateData }) => (
+ <ToolButton
+ type="button"
+ icon={DuplicateIcon}
+ title={`${t("labels.duplicateSelection")} — ${getShortcutKey(
+ "CtrlOrCmd+D",
+ )}`}
+ aria-label={t("labels.duplicateSelection")}
+ onClick={() => updateData(null)}
+ visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
+ />
+ ),
+});
+
+const duplicateElements = (
+ elements: readonly ExcalidrawElement[],
+ appState: AppState,
+): Partial<Exclude<ActionResult, false>> => {
+ // ---------------------------------------------------------------------------
+
+ const groupIdMap = new Map();
+ const newElements: ExcalidrawElement[] = [];
+ const oldElements: ExcalidrawElement[] = [];
+ const oldIdToDuplicatedId = new Map();
+ const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
+
+ const elementsMap = arrayToMap(elements);
+
+ const duplicateAndOffsetElement = <
+ T extends ExcalidrawElement | ExcalidrawElement[],
+ >(
+ element: T,
+ ): T extends ExcalidrawElement[]
+ ? ExcalidrawElement[]
+ : ExcalidrawElement | null => {
+ const elements = castArray(element);
+
+ const _newElements = elements.reduce(
+ (acc: ExcalidrawElement[], element) => {
+ if (processedIds.has(element.id)) {
+ return acc;
+ }
+
+ processedIds.set(element.id, true);
+
+ const newElement = duplicateElement(
+ appState.editingGroupId,
+ groupIdMap,
+ element,
+ {
+ x: element.x + DEFAULT_GRID_SIZE / 2,
+ y: element.y + DEFAULT_GRID_SIZE / 2,
+ },
+ );
+
+ processedIds.set(newElement.id, true);
+
+ duplicatedElementsMap.set(newElement.id, newElement);
+ oldIdToDuplicatedId.set(element.id, newElement.id);
+
+ oldElements.push(element);
+ newElements.push(newElement);
+
+ acc.push(newElement);
+ return acc;
+ },
+ [],
+ );
+
+ return (
+ Array.isArray(element) ? _newElements : _newElements[0] || null
+ ) as T extends ExcalidrawElement[]
+ ? ExcalidrawElement[]
+ : ExcalidrawElement | null;
+ };
+
+ elements = normalizeElementOrder(elements);
+
+ const idsOfElementsToDuplicate = arrayToMap(
+ getSelectedElements(elements, appState, {
+ includeBoundTextElement: true,
+ includeElementsInFrames: true,
+ }),
+ );
+
+ // Ids of elements that have already been processed so we don't push them
+ // into the array twice if we end up backtracking when retrieving
+ // discontiguous group of elements (can happen due to a bug, or in edge
+ // cases such as a group containing deleted elements which were not selected).
+ //
+ // This is not enough to prevent duplicates, so we do a second loop afterwards
+ // to remove them.
+ //
+ // For convenience we mark even the newly created ones even though we don't
+ // loop over them.
+ const processedIds = new Map<ExcalidrawElement["id"], true>();
+
+ const elementsWithClones: ExcalidrawElement[] = elements.slice();
+
+ const insertAfterIndex = (
+ index: number,
+ elements: ExcalidrawElement | null | ExcalidrawElement[],
+ ) => {
+ invariant(index !== -1, "targetIndex === -1 ");
+
+ if (!Array.isArray(elements) && !elements) {
+ return;
+ }
+
+ elementsWithClones.splice(index + 1, 0, ...castArray(elements));
+ };
+
+ const frameIdsToDuplicate = new Set(
+ elements
+ .filter(
+ (el) => idsOfElementsToDuplicate.has(el.id) && isFrameLikeElement(el),
+ )
+ .map((el) => el.id),
+ );
+
+ for (const element of elements) {
+ if (processedIds.has(element.id)) {
+ continue;
+ }
+
+ if (!idsOfElementsToDuplicate.has(element.id)) {
+ continue;
+ }
+
+ // groups
+ // -------------------------------------------------------------------------
+
+ const groupId = getSelectedGroupForElement(appState, element);
+ if (groupId) {
+ const groupElements = getElementsInGroup(elements, groupId).flatMap(
+ (element) =>
+ isFrameLikeElement(element)
+ ? [...getFrameChildren(elements, element.id), element]
+ : [element],
+ );
+
+ const targetIndex = findLastIndex(elementsWithClones, (el) => {
+ return el.groupIds?.includes(groupId);
+ });
+
+ insertAfterIndex(targetIndex, duplicateAndOffsetElement(groupElements));
+ continue;
+ }
+
+ // frame duplication
+ // -------------------------------------------------------------------------
+
+ if (element.frameId && frameIdsToDuplicate.has(element.frameId)) {
+ continue;
+ }
+
+ if (isFrameLikeElement(element)) {
+ const frameId = element.id;
+
+ const frameChildren = getFrameChildren(elements, frameId);
+
+ const targetIndex = findLastIndex(elementsWithClones, (el) => {
+ return el.frameId === frameId || el.id === frameId;
+ });
+
+ insertAfterIndex(
+ targetIndex,
+ duplicateAndOffsetElement([...frameChildren, element]),
+ );
+ continue;
+ }
+
+ // text container
+ // -------------------------------------------------------------------------
+
+ if (hasBoundTextElement(element)) {
+ const boundTextElement = getBoundTextElement(element, elementsMap);
+
+ const targetIndex = findLastIndex(elementsWithClones, (el) => {
+ return (
+ el.id === element.id ||
+ ("containerId" in el && el.containerId === element.id)
+ );
+ });
+
+ if (boundTextElement) {
+ insertAfterIndex(
+ targetIndex,
+ duplicateAndOffsetElement([element, boundTextElement]),
+ );
+ } else {
+ insertAfterIndex(targetIndex, duplicateAndOffsetElement(element));
+ }
+
+ continue;
+ }
+
+ if (isBoundToContainer(element)) {
+ const container = getContainerElement(element, elementsMap);
+
+ const targetIndex = findLastIndex(elementsWithClones, (el) => {
+ return el.id === element.id || el.id === container?.id;
+ });
+
+ if (container) {
+ insertAfterIndex(
+ targetIndex,
+ duplicateAndOffsetElement([container, element]),
+ );
+ } else {
+ insertAfterIndex(targetIndex, duplicateAndOffsetElement(element));
+ }
+
+ continue;
+ }
+
+ // default duplication (regular elements)
+ // -------------------------------------------------------------------------
+
+ insertAfterIndex(
+ findLastIndex(elementsWithClones, (el) => el.id === element.id),
+ duplicateAndOffsetElement(element),
+ );
+ }
+
+ // ---------------------------------------------------------------------------
+
+ bindTextToShapeAfterDuplication(
+ elementsWithClones,
+ oldElements,
+ oldIdToDuplicatedId,
+ );
+ fixBindingsAfterDuplication(
+ elementsWithClones,
+ oldElements,
+ oldIdToDuplicatedId,
+ );
+ bindElementsToFramesAfterDuplication(
+ elementsWithClones,
+ oldElements,
+ oldIdToDuplicatedId,
+ );
+
+ const nextElementsToSelect =
+ excludeElementsInFramesFromSelection(newElements);
+
+ return {
+ elements: elementsWithClones,
+ appState: {
+ ...appState,
+ ...selectGroupsForSelectedElements(
+ {
+ editingGroupId: appState.editingGroupId,
+ selectedElementIds: nextElementsToSelect.reduce(
+ (acc: Record<ExcalidrawElement["id"], true>, element) => {
+ if (!isBoundToContainer(element)) {
+ acc[element.id] = true;
+ }
+ return acc;
+ },
+ {},
+ ),
+ },
+ getNonDeletedElements(elementsWithClones),
+ appState,
+ null,
+ ),
+ },
+ };
+};
diff --git a/packages/excalidraw/actions/actionElementLink.ts b/packages/excalidraw/actions/actionElementLink.ts
new file mode 100644
index 0000000..91469fd
--- /dev/null
+++ b/packages/excalidraw/actions/actionElementLink.ts
@@ -0,0 +1,110 @@
+import { copyTextToSystemClipboard } from "../clipboard";
+import { copyIcon, elementLinkIcon } from "../components/icons";
+import {
+ canCreateLinkFromElements,
+ defaultGetElementLinkFromSelection,
+ getLinkIdAndTypeFromSelection,
+} from "../element/elementLink";
+import { t } from "../i18n";
+import { getSelectedElements } from "../scene";
+import { CaptureUpdateAction } from "../store";
+import { register } from "./register";
+
+export const actionCopyElementLink = register({
+ name: "copyElementLink",
+ label: "labels.copyElementLink",
+ icon: copyIcon,
+ trackEvent: { category: "element" },
+ perform: async (elements, appState, _, app) => {
+ const selectedElements = getSelectedElements(elements, appState);
+
+ try {
+ if (window.location) {
+ const idAndType = getLinkIdAndTypeFromSelection(
+ selectedElements,
+ appState,
+ );
+
+ if (idAndType) {
+ await copyTextToSystemClipboard(
+ app.props.generateLinkForSelection
+ ? app.props.generateLinkForSelection(idAndType.id, idAndType.type)
+ : defaultGetElementLinkFromSelection(
+ idAndType.id,
+ idAndType.type,
+ ),
+ );
+
+ return {
+ appState: {
+ toast: {
+ message: t("toast.elementLinkCopied"),
+ closable: true,
+ },
+ },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ }
+ return {
+ appState,
+ elements,
+ app,
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ }
+ } catch (error: any) {
+ console.error(error);
+ }
+
+ return {
+ appState,
+ elements,
+ app,
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ },
+ predicate: (elements, appState) =>
+ canCreateLinkFromElements(getSelectedElements(elements, appState)),
+});
+
+export const actionLinkToElement = register({
+ name: "linkToElement",
+ label: "labels.linkToElement",
+ icon: elementLinkIcon,
+ perform: (elements, appState, _, app) => {
+ const selectedElements = getSelectedElements(elements, appState);
+
+ if (
+ selectedElements.length !== 1 ||
+ !canCreateLinkFromElements(selectedElements)
+ ) {
+ return {
+ elements,
+ appState,
+ app,
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ }
+
+ return {
+ appState: {
+ ...appState,
+ openDialog: {
+ name: "elementLinkSelector",
+ sourceElementId: getSelectedElements(elements, appState)[0].id,
+ },
+ },
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ predicate: (elements, appState, appProps, app) => {
+ const selectedElements = getSelectedElements(elements, appState);
+
+ return (
+ appState.openDialog?.name !== "elementLinkSelector" &&
+ selectedElements.length === 1 &&
+ canCreateLinkFromElements(selectedElements)
+ );
+ },
+ trackEvent: false,
+});
diff --git a/packages/excalidraw/actions/actionElementLock.test.tsx b/packages/excalidraw/actions/actionElementLock.test.tsx
new file mode 100644
index 0000000..d6dd3f7
--- /dev/null
+++ b/packages/excalidraw/actions/actionElementLock.test.tsx
@@ -0,0 +1,69 @@
+import React from "react";
+import { Excalidraw } from "../index";
+import { queryByTestId, fireEvent } from "@testing-library/react";
+import { render } from "../tests/test-utils";
+import { Pointer, UI } from "../tests/helpers/ui";
+import { API } from "../tests/helpers/api";
+
+const { h } = window;
+const mouse = new Pointer("mouse");
+
+describe("element locking", () => {
+ it("should not show unlockAllElements action in contextMenu if no elements locked", async () => {
+ await render(<Excalidraw />);
+
+ mouse.rightClickAt(0, 0);
+
+ const item = queryByTestId(UI.queryContextMenu()!, "unlockAllElements");
+ expect(item).toBe(null);
+ });
+
+ it("should unlock all elements and select them when using unlockAllElements action in contextMenu", async () => {
+ await render(
+ <Excalidraw
+ initialData={{
+ elements: [
+ API.createElement({
+ x: 100,
+ y: 100,
+ width: 100,
+ height: 100,
+ locked: true,
+ }),
+ API.createElement({
+ x: 100,
+ y: 100,
+ width: 100,
+ height: 100,
+ locked: true,
+ }),
+ API.createElement({
+ x: 100,
+ y: 100,
+ width: 100,
+ height: 100,
+ locked: false,
+ }),
+ ],
+ }}
+ />,
+ );
+
+ mouse.rightClickAt(0, 0);
+
+ expect(Object.keys(h.state.selectedElementIds).length).toBe(0);
+ expect(h.elements.map((el) => el.locked)).toEqual([true, true, false]);
+
+ const item = queryByTestId(UI.queryContextMenu()!, "unlockAllElements");
+ expect(item).not.toBe(null);
+
+ fireEvent.click(item!.querySelector("button")!);
+
+ expect(h.elements.map((el) => el.locked)).toEqual([false, false, false]);
+ // should select the unlocked elements
+ expect(h.state.selectedElementIds).toEqual({
+ [h.elements[0].id]: true,
+ [h.elements[1].id]: true,
+ });
+ });
+});
diff --git a/packages/excalidraw/actions/actionElementLock.ts b/packages/excalidraw/actions/actionElementLock.ts
new file mode 100644
index 0000000..eba21f2
--- /dev/null
+++ b/packages/excalidraw/actions/actionElementLock.ts
@@ -0,0 +1,119 @@
+import { LockedIcon, UnlockedIcon } from "../components/icons";
+import { newElementWith } from "../element/mutateElement";
+import { isFrameLikeElement } from "../element/typeChecks";
+import type { ExcalidrawElement } from "../element/types";
+import { KEYS } from "../keys";
+import { getSelectedElements } from "../scene";
+import { CaptureUpdateAction } from "../store";
+import { arrayToMap } from "../utils";
+import { register } from "./register";
+
+const shouldLock = (elements: readonly ExcalidrawElement[]) =>
+ elements.every((el) => !el.locked);
+
+export const actionToggleElementLock = register({
+ name: "toggleElementLock",
+ label: (elements, appState, app) => {
+ const selected = app.scene.getSelectedElements({
+ selectedElementIds: appState.selectedElementIds,
+ includeBoundTextElement: false,
+ });
+ if (selected.length === 1 && !isFrameLikeElement(selected[0])) {
+ return selected[0].locked
+ ? "labels.elementLock.unlock"
+ : "labels.elementLock.lock";
+ }
+
+ return shouldLock(selected)
+ ? "labels.elementLock.lockAll"
+ : "labels.elementLock.unlockAll";
+ },
+ icon: (appState, elements) => {
+ const selectedElements = getSelectedElements(elements, appState);
+ return shouldLock(selectedElements) ? LockedIcon : UnlockedIcon;
+ },
+ trackEvent: { category: "element" },
+ predicate: (elements, appState, _, app) => {
+ const selectedElements = app.scene.getSelectedElements(appState);
+ return (
+ selectedElements.length > 0 &&
+ !selectedElements.some((element) => element.locked && element.frameId)
+ );
+ },
+ perform: (elements, appState, _, app) => {
+ const selectedElements = app.scene.getSelectedElements({
+ selectedElementIds: appState.selectedElementIds,
+ includeBoundTextElement: true,
+ includeElementsInFrames: true,
+ });
+
+ if (!selectedElements.length) {
+ return false;
+ }
+
+ const nextLockState = shouldLock(selectedElements);
+ const selectedElementsMap = arrayToMap(selectedElements);
+ return {
+ elements: elements.map((element) => {
+ if (!selectedElementsMap.has(element.id)) {
+ return element;
+ }
+
+ return newElementWith(element, { locked: nextLockState });
+ }),
+ appState: {
+ ...appState,
+ selectedLinearElement: nextLockState
+ ? null
+ : appState.selectedLinearElement,
+ },
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ keyTest: (event, appState, elements, app) => {
+ return (
+ event.key.toLocaleLowerCase() === KEYS.L &&
+ event[KEYS.CTRL_OR_CMD] &&
+ event.shiftKey &&
+ app.scene.getSelectedElements({
+ selectedElementIds: appState.selectedElementIds,
+ includeBoundTextElement: false,
+ }).length > 0
+ );
+ },
+});
+
+export const actionUnlockAllElements = register({
+ name: "unlockAllElements",
+ paletteName: "Unlock all elements",
+ trackEvent: { category: "canvas" },
+ viewMode: false,
+ icon: UnlockedIcon,
+ predicate: (elements, appState) => {
+ const selectedElements = getSelectedElements(elements, appState);
+ return (
+ selectedElements.length === 0 &&
+ elements.some((element) => element.locked)
+ );
+ },
+ perform: (elements, appState) => {
+ const lockedElements = elements.filter((el) => el.locked);
+
+ return {
+ elements: elements.map((element) => {
+ if (element.locked) {
+ return newElementWith(element, { locked: false });
+ }
+ return element;
+ }),
+ appState: {
+ ...appState,
+ selectedElementIds: Object.fromEntries(
+ lockedElements.map((el) => [el.id, true]),
+ ),
+ },
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ label: "labels.elementLock.unlockAll",
+});
diff --git a/packages/excalidraw/actions/actionExport.tsx b/packages/excalidraw/actions/actionExport.tsx
new file mode 100644
index 0000000..8d18acd
--- /dev/null
+++ b/packages/excalidraw/actions/actionExport.tsx
@@ -0,0 +1,309 @@
+import { ExportIcon, questionCircle, saveAs } from "../components/icons";
+import { ProjectName } from "../components/ProjectName";
+import { ToolButton } from "../components/ToolButton";
+import { Tooltip } from "../components/Tooltip";
+import { DarkModeToggle } from "../components/DarkModeToggle";
+import { loadFromJSON, saveAsJSON } from "../data";
+import { resaveAsImageWithScene } from "../data/resave";
+import { t } from "../i18n";
+import { useDevice } from "../components/App";
+import { KEYS } from "../keys";
+import { register } from "./register";
+import { CheckboxItem } from "../components/CheckboxItem";
+import { getExportSize } from "../scene/export";
+import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants";
+import { getSelectedElements, isSomeElementSelected } from "../scene";
+import { getNonDeletedElements } from "../element";
+import { isImageFileHandle } from "../data/blob";
+import { nativeFileSystemSupported } from "../data/filesystem";
+import type { Theme } from "../element/types";
+
+import "../components/ToolIcon.scss";
+import { CaptureUpdateAction } from "../store";
+
+export const actionChangeProjectName = register({
+ name: "changeProjectName",
+ label: "labels.fileTitle",
+ trackEvent: false,
+ perform: (_elements, appState, value) => {
+ return {
+ appState: { ...appState, name: value },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ },
+ PanelComponent: ({ appState, updateData, appProps, data, app }) => (
+ <ProjectName
+ label={t("labels.fileTitle")}
+ value={app.getName()}
+ onChange={(name: string) => updateData(name)}
+ ignoreFocus={data?.ignoreFocus ?? false}
+ />
+ ),
+});
+
+export const actionChangeExportScale = register({
+ name: "changeExportScale",
+ label: "imageExportDialog.scale",
+ trackEvent: { category: "export", action: "scale" },
+ perform: (_elements, appState, value) => {
+ return {
+ appState: { ...appState, exportScale: value },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ },
+ PanelComponent: ({ elements: allElements, appState, updateData }) => {
+ const elements = getNonDeletedElements(allElements);
+ const exportSelected = isSomeElementSelected(elements, appState);
+ const exportedElements = exportSelected
+ ? getSelectedElements(elements, appState)
+ : elements;
+
+ return (
+ <>
+ {EXPORT_SCALES.map((s) => {
+ const [width, height] = getExportSize(
+ exportedElements,
+ DEFAULT_EXPORT_PADDING,
+ s,
+ );
+
+ const scaleButtonTitle = `${t(
+ "imageExportDialog.label.scale",
+ )} ${s}x (${width}x${height})`;
+
+ return (
+ <ToolButton
+ key={s}
+ size="small"
+ type="radio"
+ icon={`${s}x`}
+ name="export-canvas-scale"
+ title={scaleButtonTitle}
+ aria-label={scaleButtonTitle}
+ id="export-canvas-scale"
+ checked={s === appState.exportScale}
+ onChange={() => updateData(s)}
+ />
+ );
+ })}
+ </>
+ );
+ },
+});
+
+export const actionChangeExportBackground = register({
+ name: "changeExportBackground",
+ label: "imageExportDialog.label.withBackground",
+ trackEvent: { category: "export", action: "toggleBackground" },
+ perform: (_elements, appState, value) => {
+ return {
+ appState: { ...appState, exportBackground: value },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ },
+ PanelComponent: ({ appState, updateData }) => (
+ <CheckboxItem
+ checked={appState.exportBackground}
+ onChange={(checked) => updateData(checked)}
+ >
+ {t("imageExportDialog.label.withBackground")}
+ </CheckboxItem>
+ ),
+});
+
+export const actionChangeExportEmbedScene = register({
+ name: "changeExportEmbedScene",
+ label: "imageExportDialog.tooltip.embedScene",
+ trackEvent: { category: "export", action: "embedScene" },
+ perform: (_elements, appState, value) => {
+ return {
+ appState: { ...appState, exportEmbedScene: value },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ },
+ PanelComponent: ({ appState, updateData }) => (
+ <CheckboxItem
+ checked={appState.exportEmbedScene}
+ onChange={(checked) => updateData(checked)}
+ >
+ {t("imageExportDialog.label.embedScene")}
+ <Tooltip label={t("imageExportDialog.tooltip.embedScene")} long={true}>
+ <div className="excalidraw-tooltip-icon">{questionCircle}</div>
+ </Tooltip>
+ </CheckboxItem>
+ ),
+});
+
+export const actionSaveToActiveFile = register({
+ name: "saveToActiveFile",
+ label: "buttons.save",
+ icon: ExportIcon,
+ trackEvent: { category: "export" },
+ predicate: (elements, appState, props, app) => {
+ return (
+ !!app.props.UIOptions.canvasActions.saveToActiveFile &&
+ !!appState.fileHandle &&
+ !appState.viewModeEnabled
+ );
+ },
+ perform: async (elements, appState, value, app) => {
+ const fileHandleExists = !!appState.fileHandle;
+
+ try {
+ const { fileHandle } = isImageFileHandle(appState.fileHandle)
+ ? await resaveAsImageWithScene(
+ elements,
+ appState,
+ app.files,
+ app.getName(),
+ )
+ : await saveAsJSON(elements, appState, app.files, app.getName());
+
+ return {
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ appState: {
+ ...appState,
+ fileHandle,
+ toast: fileHandleExists
+ ? {
+ message: fileHandle?.name
+ ? t("toast.fileSavedToFilename").replace(
+ "{filename}",
+ `"${fileHandle.name}"`,
+ )
+ : t("toast.fileSaved"),
+ }
+ : null,
+ },
+ };
+ } catch (error: any) {
+ if (error?.name !== "AbortError") {
+ console.error(error);
+ } else {
+ console.warn(error);
+ }
+ return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
+ }
+ },
+ keyTest: (event) =>
+ event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
+});
+
+export const actionSaveFileToDisk = register({
+ name: "saveFileToDisk",
+ label: "exportDialog.disk_title",
+ icon: ExportIcon,
+ viewMode: true,
+ trackEvent: { category: "export" },
+ perform: async (elements, appState, value, app) => {
+ try {
+ const { fileHandle } = await saveAsJSON(
+ elements,
+ {
+ ...appState,
+ fileHandle: null,
+ },
+ app.files,
+ app.getName(),
+ );
+ return {
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ appState: {
+ ...appState,
+ openDialog: null,
+ fileHandle,
+ toast: { message: t("toast.fileSaved") },
+ },
+ };
+ } catch (error: any) {
+ if (error?.name !== "AbortError") {
+ console.error(error);
+ } else {
+ console.warn(error);
+ }
+ return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
+ }
+ },
+ keyTest: (event) =>
+ event.key === KEYS.S && event.shiftKey && event[KEYS.CTRL_OR_CMD],
+ PanelComponent: ({ updateData }) => (
+ <ToolButton
+ type="button"
+ icon={saveAs}
+ title={t("buttons.saveAs")}
+ aria-label={t("buttons.saveAs")}
+ showAriaLabel={useDevice().editor.isMobile}
+ hidden={!nativeFileSystemSupported}
+ onClick={() => updateData(null)}
+ data-testid="save-as-button"
+ />
+ ),
+});
+
+export const actionLoadScene = register({
+ name: "loadScene",
+ label: "buttons.load",
+ trackEvent: { category: "export" },
+ predicate: (elements, appState, props, app) => {
+ return (
+ !!app.props.UIOptions.canvasActions.loadScene && !appState.viewModeEnabled
+ );
+ },
+ perform: async (elements, appState, _, app) => {
+ try {
+ const {
+ elements: loadedElements,
+ appState: loadedAppState,
+ files,
+ } = await loadFromJSON(appState, elements);
+ return {
+ elements: loadedElements,
+ appState: loadedAppState,
+ files,
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ } catch (error: any) {
+ if (error?.name === "AbortError") {
+ console.warn(error);
+ return false;
+ }
+ return {
+ elements,
+ appState: { ...appState, errorMessage: error.message },
+ files: app.files,
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ }
+ },
+ keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
+});
+
+export const actionExportWithDarkMode = register({
+ name: "exportWithDarkMode",
+ label: "imageExportDialog.label.darkMode",
+ trackEvent: { category: "export", action: "toggleTheme" },
+ perform: (_elements, appState, value) => {
+ return {
+ appState: { ...appState, exportWithDarkMode: value },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ },
+ PanelComponent: ({ appState, updateData }) => (
+ <div
+ style={{
+ display: "flex",
+ justifyContent: "flex-end",
+ marginTop: "-45px",
+ marginBottom: "10px",
+ }}
+ >
+ <DarkModeToggle
+ value={appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT}
+ onChange={(theme: Theme) => {
+ updateData(theme === THEME.DARK);
+ }}
+ title={t("imageExportDialog.label.darkMode")}
+ />
+ </div>
+ ),
+});
diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx
new file mode 100644
index 0000000..f2d8c01
--- /dev/null
+++ b/packages/excalidraw/actions/actionFinalize.tsx
@@ -0,0 +1,223 @@
+import { KEYS } from "../keys";
+import { isInvisiblySmallElement } from "../element";
+import { arrayToMap, updateActiveTool } from "../utils";
+import { ToolButton } from "../components/ToolButton";
+import { done } from "../components/icons";
+import { t } from "../i18n";
+import { register } from "./register";
+import { mutateElement } from "../element/mutateElement";
+import { LinearElementEditor } from "../element/linearElementEditor";
+import {
+ maybeBindLinearElement,
+ bindOrUnbindLinearElement,
+} from "../element/binding";
+import { isBindingElement, isLinearElement } from "../element/typeChecks";
+import type { AppState } from "../types";
+import { resetCursor } from "../cursor";
+import { CaptureUpdateAction } from "../store";
+import { pointFrom } from "@excalidraw/math";
+import { isPathALoop } from "../shapes";
+
+export const actionFinalize = register({
+ name: "finalize",
+ label: "",
+ trackEvent: false,
+ perform: (elements, appState, _, app) => {
+ const { interactiveCanvas, focusContainer, scene } = app;
+
+ const elementsMap = scene.getNonDeletedElementsMap();
+
+ if (appState.editingLinearElement) {
+ const { elementId, startBindingElement, endBindingElement } =
+ appState.editingLinearElement;
+ const element = LinearElementEditor.getElement(elementId, elementsMap);
+
+ if (element) {
+ if (isBindingElement(element)) {
+ bindOrUnbindLinearElement(
+ element,
+ startBindingElement,
+ endBindingElement,
+ elementsMap,
+ scene,
+ );
+ }
+ return {
+ elements:
+ element.points.length < 2 || isInvisiblySmallElement(element)
+ ? elements.filter((el) => el.id !== element.id)
+ : undefined,
+ appState: {
+ ...appState,
+ cursorButton: "up",
+ editingLinearElement: null,
+ },
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ }
+ }
+
+ let newElements = elements;
+
+ const pendingImageElement =
+ appState.pendingImageElementId &&
+ scene.getElement(appState.pendingImageElementId);
+
+ if (pendingImageElement) {
+ mutateElement(pendingImageElement, { isDeleted: true }, false);
+ }
+
+ if (window.document.activeElement instanceof HTMLElement) {
+ focusContainer();
+ }
+
+ const multiPointElement = appState.multiElement
+ ? appState.multiElement
+ : appState.newElement?.type === "freedraw"
+ ? appState.newElement
+ : null;
+
+ if (multiPointElement) {
+ // pen and mouse have hover
+ if (
+ multiPointElement.type !== "freedraw" &&
+ appState.lastPointerDownWith !== "touch"
+ ) {
+ const { points, lastCommittedPoint } = multiPointElement;
+ if (
+ !lastCommittedPoint ||
+ points[points.length - 1] !== lastCommittedPoint
+ ) {
+ mutateElement(multiPointElement, {
+ points: multiPointElement.points.slice(0, -1),
+ });
+ }
+ }
+
+ if (isInvisiblySmallElement(multiPointElement)) {
+ // TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
+ newElements = newElements.filter(
+ (el) => el.id !== multiPointElement.id,
+ );
+ }
+
+ // If the multi point line closes the loop,
+ // set the last point to first point.
+ // This ensures that loop remains closed at different scales.
+ const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value);
+ if (
+ multiPointElement.type === "line" ||
+ multiPointElement.type === "freedraw"
+ ) {
+ if (isLoop) {
+ const linePoints = multiPointElement.points;
+ const firstPoint = linePoints[0];
+ mutateElement(multiPointElement, {
+ points: linePoints.map((p, index) =>
+ index === linePoints.length - 1
+ ? pointFrom(firstPoint[0], firstPoint[1])
+ : p,
+ ),
+ });
+ }
+ }
+
+ if (
+ isBindingElement(multiPointElement) &&
+ !isLoop &&
+ multiPointElement.points.length > 1
+ ) {
+ const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
+ multiPointElement,
+ -1,
+ arrayToMap(elements),
+ );
+ maybeBindLinearElement(
+ multiPointElement,
+ appState,
+ { x, y },
+ elementsMap,
+ elements,
+ );
+ }
+ }
+
+ if (
+ (!appState.activeTool.locked &&
+ appState.activeTool.type !== "freedraw") ||
+ !multiPointElement
+ ) {
+ resetCursor(interactiveCanvas);
+ }
+
+ let activeTool: AppState["activeTool"];
+ if (appState.activeTool.type === "eraser") {
+ activeTool = updateActiveTool(appState, {
+ ...(appState.activeTool.lastActiveTool || {
+ type: "selection",
+ }),
+ lastActiveToolBeforeEraser: null,
+ });
+ } else {
+ activeTool = updateActiveTool(appState, {
+ type: "selection",
+ });
+ }
+
+ return {
+ elements: newElements,
+ appState: {
+ ...appState,
+ cursorButton: "up",
+ activeTool:
+ (appState.activeTool.locked ||
+ appState.activeTool.type === "freedraw") &&
+ multiPointElement
+ ? appState.activeTool
+ : activeTool,
+ activeEmbeddable: null,
+ newElement: null,
+ selectionElement: null,
+ multiElement: null,
+ editingTextElement: null,
+ startBoundElement: null,
+ suggestedBindings: [],
+ selectedElementIds:
+ multiPointElement &&
+ !appState.activeTool.locked &&
+ appState.activeTool.type !== "freedraw"
+ ? {
+ ...appState.selectedElementIds,
+ [multiPointElement.id]: true,
+ }
+ : appState.selectedElementIds,
+ // To select the linear element when user has finished mutipoint editing
+ selectedLinearElement:
+ multiPointElement && isLinearElement(multiPointElement)
+ ? new LinearElementEditor(multiPointElement)
+ : appState.selectedLinearElement,
+ pendingImageElementId: null,
+ },
+ // TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ keyTest: (event, appState) =>
+ (event.key === KEYS.ESCAPE &&
+ (appState.editingLinearElement !== null ||
+ (!appState.newElement && appState.multiElement === null))) ||
+ ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
+ appState.multiElement !== null),
+ PanelComponent: ({ appState, updateData, data }) => (
+ <ToolButton
+ type="button"
+ icon={done}
+ title={t("buttons.done")}
+ aria-label={t("buttons.done")}
+ onClick={updateData}
+ visible={appState.multiElement != null}
+ size={data?.size || "medium"}
+ style={{ pointerEvents: "all" }}
+ />
+ ),
+});
diff --git a/packages/excalidraw/actions/actionFlip.test.tsx b/packages/excalidraw/actions/actionFlip.test.tsx
new file mode 100644
index 0000000..94e4721
--- /dev/null
+++ b/packages/excalidraw/actions/actionFlip.test.tsx
@@ -0,0 +1,212 @@
+import React from "react";
+import { Excalidraw } from "../index";
+import { render } from "../tests/test-utils";
+import { API } from "../tests/helpers/api";
+import { pointFrom } from "@excalidraw/math";
+import { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
+
+const { h } = window;
+
+describe("flipping re-centers selection", () => {
+ it("elbow arrow touches group selection side yet it remains in place after multiple moves", async () => {
+ const elements = [
+ API.createElement({
+ type: "rectangle",
+ id: "rec1",
+ x: 100,
+ y: 100,
+ width: 100,
+ height: 100,
+ boundElements: [{ id: "arr", type: "arrow" }],
+ }),
+ API.createElement({
+ type: "rectangle",
+ id: "rec2",
+ x: 220,
+ y: 250,
+ width: 100,
+ height: 100,
+ boundElements: [{ id: "arr", type: "arrow" }],
+ }),
+ API.createElement({
+ type: "arrow",
+ id: "arr",
+ x: 149.9,
+ y: 95,
+ width: 156,
+ height: 239.9,
+ startBinding: {
+ elementId: "rec1",
+ focus: 0,
+ gap: 5,
+ fixedPoint: [0.49, -0.05],
+ },
+ endBinding: {
+ elementId: "rec2",
+ focus: 0,
+ gap: 5,
+ fixedPoint: [-0.05, 0.49],
+ },
+ startArrowhead: null,
+ endArrowhead: "arrow",
+ fixedSegments: null,
+ points: [
+ pointFrom(0, 0),
+ pointFrom(0, -35),
+ pointFrom(-90, -35),
+ pointFrom(-90, 204),
+ pointFrom(66, 204),
+ ],
+ elbowed: true,
+ }),
+ ];
+ await render(<Excalidraw initialData={{ elements }} />);
+
+ API.setSelectedElements(elements);
+
+ expect(Object.keys(h.state.selectedElementIds).length).toBe(3);
+
+ API.executeAction(actionFlipHorizontal);
+ API.executeAction(actionFlipHorizontal);
+ API.executeAction(actionFlipHorizontal);
+ API.executeAction(actionFlipHorizontal);
+
+ const rec1 = h.elements.find((el) => el.id === "rec1")!;
+ expect(rec1.x).toBeCloseTo(100, 0);
+ expect(rec1.y).toBeCloseTo(100, 0);
+
+ const rec2 = h.elements.find((el) => el.id === "rec2")!;
+ expect(rec2.x).toBeCloseTo(220, 0);
+ expect(rec2.y).toBeCloseTo(250, 0);
+ });
+});
+
+describe("flipping arrowheads", () => {
+ beforeEach(async () => {
+ await render(<Excalidraw />);
+ });
+
+ it("flipping bound arrow should flip arrowheads only", () => {
+ const rect = API.createElement({
+ type: "rectangle",
+ boundElements: [{ type: "arrow", id: "arrow1" }],
+ });
+ const arrow = API.createElement({
+ type: "arrow",
+ id: "arrow1",
+ startArrowhead: "arrow",
+ endArrowhead: null,
+ endBinding: {
+ elementId: rect.id,
+ focus: 0.5,
+ gap: 5,
+ },
+ });
+
+ API.setElements([rect, arrow]);
+ API.setSelectedElements([arrow]);
+
+ expect(API.getElement(arrow).startArrowhead).toBe("arrow");
+ expect(API.getElement(arrow).endArrowhead).toBe(null);
+
+ API.executeAction(actionFlipHorizontal);
+ expect(API.getElement(arrow).startArrowhead).toBe(null);
+ expect(API.getElement(arrow).endArrowhead).toBe("arrow");
+
+ API.executeAction(actionFlipHorizontal);
+ expect(API.getElement(arrow).startArrowhead).toBe("arrow");
+ expect(API.getElement(arrow).endArrowhead).toBe(null);
+
+ API.executeAction(actionFlipVertical);
+ expect(API.getElement(arrow).startArrowhead).toBe(null);
+ expect(API.getElement(arrow).endArrowhead).toBe("arrow");
+ });
+
+ it("flipping bound arrow should flip arrowheads only 2", () => {
+ const rect = API.createElement({
+ type: "rectangle",
+ boundElements: [{ type: "arrow", id: "arrow1" }],
+ });
+ const rect2 = API.createElement({
+ type: "rectangle",
+ boundElements: [{ type: "arrow", id: "arrow1" }],
+ });
+ const arrow = API.createElement({
+ type: "arrow",
+ id: "arrow1",
+ startArrowhead: "arrow",
+ endArrowhead: "circle",
+ startBinding: {
+ elementId: rect.id,
+ focus: 0.5,
+ gap: 5,
+ },
+ endBinding: {
+ elementId: rect2.id,
+ focus: 0.5,
+ gap: 5,
+ },
+ });
+
+ API.setElements([rect, rect2, arrow]);
+ API.setSelectedElements([arrow]);
+
+ expect(API.getElement(arrow).startArrowhead).toBe("arrow");
+ expect(API.getElement(arrow).endArrowhead).toBe("circle");
+
+ API.executeAction(actionFlipHorizontal);
+ expect(API.getElement(arrow).startArrowhead).toBe("circle");
+ expect(API.getElement(arrow).endArrowhead).toBe("arrow");
+
+ API.executeAction(actionFlipVertical);
+ expect(API.getElement(arrow).startArrowhead).toBe("arrow");
+ expect(API.getElement(arrow).endArrowhead).toBe("circle");
+ });
+
+ it("flipping unbound arrow shouldn't flip arrowheads", () => {
+ const arrow = API.createElement({
+ type: "arrow",
+ id: "arrow1",
+ startArrowhead: "arrow",
+ endArrowhead: "circle",
+ });
+
+ API.setElements([arrow]);
+ API.setSelectedElements([arrow]);
+
+ expect(API.getElement(arrow).startArrowhead).toBe("arrow");
+ expect(API.getElement(arrow).endArrowhead).toBe("circle");
+
+ API.executeAction(actionFlipHorizontal);
+ expect(API.getElement(arrow).startArrowhead).toBe("arrow");
+ expect(API.getElement(arrow).endArrowhead).toBe("circle");
+ });
+
+ it("flipping bound arrow shouldn't flip arrowheads if selected alongside non-arrow eleemnt", () => {
+ const rect = API.createElement({
+ type: "rectangle",
+ boundElements: [{ type: "arrow", id: "arrow1" }],
+ });
+ const arrow = API.createElement({
+ type: "arrow",
+ id: "arrow1",
+ startArrowhead: "arrow",
+ endArrowhead: null,
+ endBinding: {
+ elementId: rect.id,
+ focus: 0.5,
+ gap: 5,
+ },
+ });
+
+ API.setElements([rect, arrow]);
+ API.setSelectedElements([rect, arrow]);
+
+ expect(API.getElement(arrow).startArrowhead).toBe("arrow");
+ expect(API.getElement(arrow).endArrowhead).toBe(null);
+
+ API.executeAction(actionFlipHorizontal);
+ expect(API.getElement(arrow).startArrowhead).toBe("arrow");
+ expect(API.getElement(arrow).endArrowhead).toBe(null);
+ });
+});
diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts
new file mode 100644
index 0000000..80149b8
--- /dev/null
+++ b/packages/excalidraw/actions/actionFlip.ts
@@ -0,0 +1,204 @@
+import { register } from "./register";
+import { getSelectedElements } from "../scene";
+import { getNonDeletedElements } from "../element";
+import type {
+ ExcalidrawArrowElement,
+ ExcalidrawElbowArrowElement,
+ ExcalidrawElement,
+ NonDeleted,
+ NonDeletedSceneElementsMap,
+} from "../element/types";
+import { resizeMultipleElements } from "../element/resizeElements";
+import type { AppClassProperties, AppState } from "../types";
+import { arrayToMap } from "../utils";
+import { CODES, KEYS } from "../keys";
+import {
+ bindOrUnbindLinearElements,
+ isBindingEnabled,
+} from "../element/binding";
+import { updateFrameMembershipOfSelectedElements } from "../frame";
+import { flipHorizontal, flipVertical } from "../components/icons";
+import { CaptureUpdateAction } from "../store";
+import {
+ isArrowElement,
+ isElbowArrow,
+ isLinearElement,
+} from "../element/typeChecks";
+import { mutateElement, newElementWith } from "../element/mutateElement";
+import { deepCopyElement } from "../element/newElement";
+import { getCommonBoundingBox } from "../element/bounds";
+
+export const actionFlipHorizontal = register({
+ name: "flipHorizontal",
+ label: "labels.flipHorizontal",
+ icon: flipHorizontal,
+ trackEvent: { category: "element" },
+ perform: (elements, appState, _, app) => {
+ return {
+ elements: updateFrameMembershipOfSelectedElements(
+ flipSelectedElements(
+ elements,
+ app.scene.getNonDeletedElementsMap(),
+ appState,
+ "horizontal",
+ app,
+ ),
+ appState,
+ app,
+ ),
+ appState,
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ keyTest: (event) => event.shiftKey && event.code === CODES.H,
+});
+
+export const actionFlipVertical = register({
+ name: "flipVertical",
+ label: "labels.flipVertical",
+ icon: flipVertical,
+ trackEvent: { category: "element" },
+ perform: (elements, appState, _, app) => {
+ return {
+ elements: updateFrameMembershipOfSelectedElements(
+ flipSelectedElements(
+ elements,
+ app.scene.getNonDeletedElementsMap(),
+ appState,
+ "vertical",
+ app,
+ ),
+ appState,
+ app,
+ ),
+ appState,
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ keyTest: (event) =>
+ event.shiftKey && event.code === CODES.V && !event[KEYS.CTRL_OR_CMD],
+});
+
+const flipSelectedElements = (
+ elements: readonly ExcalidrawElement[],
+ elementsMap: NonDeletedSceneElementsMap,
+ appState: Readonly<AppState>,
+ flipDirection: "horizontal" | "vertical",
+ app: AppClassProperties,
+) => {
+ const selectedElements = getSelectedElements(
+ getNonDeletedElements(elements),
+ appState,
+ {
+ includeBoundTextElement: true,
+ includeElementsInFrames: true,
+ },
+ );
+
+ const updatedElements = flipElements(
+ selectedElements,
+ elementsMap,
+ appState,
+ flipDirection,
+ app,
+ );
+
+ const updatedElementsMap = arrayToMap(updatedElements);
+
+ return elements.map(
+ (element) => updatedElementsMap.get(element.id) || element,
+ );
+};
+
+const flipElements = (
+ selectedElements: NonDeleted<ExcalidrawElement>[],
+ elementsMap: NonDeletedSceneElementsMap,
+ appState: AppState,
+ flipDirection: "horizontal" | "vertical",
+ app: AppClassProperties,
+): ExcalidrawElement[] => {
+ if (
+ selectedElements.every(
+ (element) =>
+ isArrowElement(element) && (element.startBinding || element.endBinding),
+ )
+ ) {
+ return selectedElements.map((element) => {
+ const _element = element as ExcalidrawArrowElement;
+ return newElementWith(_element, {
+ startArrowhead: _element.endArrowhead,
+ endArrowhead: _element.startArrowhead,
+ });
+ });
+ }
+
+ const { midX, midY } = getCommonBoundingBox(selectedElements);
+
+ resizeMultipleElements(
+ selectedElements,
+ elementsMap,
+ "nw",
+ app.scene,
+ new Map(
+ Array.from(elementsMap.values()).map((element) => [
+ element.id,
+ deepCopyElement(element),
+ ]),
+ ),
+ {
+ flipByX: flipDirection === "horizontal",
+ flipByY: flipDirection === "vertical",
+ shouldResizeFromCenter: true,
+ shouldMaintainAspectRatio: true,
+ },
+ );
+
+ bindOrUnbindLinearElements(
+ selectedElements.filter(isLinearElement),
+ elementsMap,
+ app.scene.getNonDeletedElements(),
+ app.scene,
+ isBindingEnabled(appState),
+ [],
+ appState.zoom,
+ );
+
+ // ---------------------------------------------------------------------------
+ // flipping arrow elements (and potentially other) makes the selection group
+ // "move" across the canvas because of how arrows can bump against the "wall"
+ // of the selection, so we need to center the group back to the original
+ // position so that repeated flips don't accumulate the offset
+
+ const { elbowArrows, otherElements } = selectedElements.reduce(
+ (
+ acc: {
+ elbowArrows: ExcalidrawElbowArrowElement[];
+ otherElements: ExcalidrawElement[];
+ },
+ element,
+ ) =>
+ isElbowArrow(element)
+ ? { ...acc, elbowArrows: acc.elbowArrows.concat(element) }
+ : { ...acc, otherElements: acc.otherElements.concat(element) },
+ { elbowArrows: [], otherElements: [] },
+ );
+
+ const { midX: newMidX, midY: newMidY } =
+ getCommonBoundingBox(selectedElements);
+ const [diffX, diffY] = [midX - newMidX, midY - newMidY];
+ otherElements.forEach((element) =>
+ mutateElement(element, {
+ x: element.x + diffX,
+ y: element.y + diffY,
+ }),
+ );
+ elbowArrows.forEach((element) =>
+ mutateElement(element, {
+ x: element.x + diffX,
+ y: element.y + diffY,
+ }),
+ );
+ // ---------------------------------------------------------------------------
+
+ return selectedElements;
+};
diff --git a/packages/excalidraw/actions/actionFrame.ts b/packages/excalidraw/actions/actionFrame.ts
new file mode 100644
index 0000000..ce05b53
--- /dev/null
+++ b/packages/excalidraw/actions/actionFrame.ts
@@ -0,0 +1,214 @@
+import { getCommonBounds, getNonDeletedElements } from "../element";
+import type { ExcalidrawElement } from "../element/types";
+import { addElementsToFrame, removeAllElementsFromFrame } from "../frame";
+import { getFrameChildren } from "../frame";
+import { KEYS } from "../keys";
+import type { AppClassProperties, AppState, UIAppState } from "../types";
+import { updateActiveTool } from "../utils";
+import { setCursorForShape } from "../cursor";
+import { register } from "./register";
+import { isFrameLikeElement } from "../element/typeChecks";
+import { frameToolIcon } from "../components/icons";
+import { CaptureUpdateAction } from "../store";
+import { getSelectedElements } from "../scene";
+import { newFrameElement } from "../element/newElement";
+import { getElementsInGroup } from "../groups";
+import { mutateElement } from "../element/mutateElement";
+
+const isSingleFrameSelected = (
+ appState: UIAppState,
+ app: AppClassProperties,
+) => {
+ const selectedElements = app.scene.getSelectedElements(appState);
+
+ return (
+ selectedElements.length === 1 && isFrameLikeElement(selectedElements[0])
+ );
+};
+
+export const actionSelectAllElementsInFrame = register({
+ name: "selectAllElementsInFrame",
+ label: "labels.selectAllElementsInFrame",
+ trackEvent: { category: "canvas" },
+ perform: (elements, appState, _, app) => {
+ const selectedElement =
+ app.scene.getSelectedElements(appState).at(0) || null;
+
+ if (isFrameLikeElement(selectedElement)) {
+ const elementsInFrame = getFrameChildren(
+ getNonDeletedElements(elements),
+ selectedElement.id,
+ ).filter((element) => !(element.type === "text" && element.containerId));
+
+ return {
+ elements,
+ appState: {
+ ...appState,
+ selectedElementIds: elementsInFrame.reduce((acc, element) => {
+ acc[element.id] = true;
+ return acc;
+ }, {} as Record<ExcalidrawElement["id"], true>),
+ },
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ }
+
+ return {
+ elements,
+ appState,
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ },
+ predicate: (elements, appState, _, app) =>
+ isSingleFrameSelected(appState, app),
+});
+
+export const actionRemoveAllElementsFromFrame = register({
+ name: "removeAllElementsFromFrame",
+ label: "labels.removeAllElementsFromFrame",
+ trackEvent: { category: "history" },
+ perform: (elements, appState, _, app) => {
+ const selectedElement =
+ app.scene.getSelectedElements(appState).at(0) || null;
+
+ if (isFrameLikeElement(selectedElement)) {
+ return {
+ elements: removeAllElementsFromFrame(elements, selectedElement),
+ appState: {
+ ...appState,
+ selectedElementIds: {
+ [selectedElement.id]: true,
+ },
+ },
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ }
+
+ return {
+ elements,
+ appState,
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ },
+ predicate: (elements, appState, _, app) =>
+ isSingleFrameSelected(appState, app),
+});
+
+export const actionupdateFrameRendering = register({
+ name: "updateFrameRendering",
+ label: "labels.updateFrameRendering",
+ viewMode: true,
+ trackEvent: { category: "canvas" },
+ perform: (elements, appState) => {
+ return {
+ elements,
+ appState: {
+ ...appState,
+ frameRendering: {
+ ...appState.frameRendering,
+ enabled: !appState.frameRendering.enabled,
+ },
+ },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ },
+ checked: (appState: AppState) => appState.frameRendering.enabled,
+});
+
+export const actionSetFrameAsActiveTool = register({
+ name: "setFrameAsActiveTool",
+ label: "toolBar.frame",
+ trackEvent: { category: "toolbar" },
+ icon: frameToolIcon,
+ viewMode: false,
+ perform: (elements, appState, _, app) => {
+ const nextActiveTool = updateActiveTool(appState, {
+ type: "frame",
+ });
+
+ setCursorForShape(app.interactiveCanvas, {
+ ...appState,
+ activeTool: nextActiveTool,
+ });
+
+ return {
+ elements,
+ appState: {
+ ...appState,
+ activeTool: updateActiveTool(appState, {
+ type: "frame",
+ }),
+ },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ },
+ keyTest: (event) =>
+ !event[KEYS.CTRL_OR_CMD] &&
+ !event.shiftKey &&
+ !event.altKey &&
+ event.key.toLocaleLowerCase() === KEYS.F,
+});
+
+export const actionWrapSelectionInFrame = register({
+ name: "wrapSelectionInFrame",
+ label: "labels.wrapSelectionInFrame",
+ trackEvent: { category: "element" },
+ predicate: (elements, appState, _, app) => {
+ const selectedElements = getSelectedElements(elements, appState);
+
+ return (
+ selectedElements.length > 0 &&
+ !selectedElements.some((element) => isFrameLikeElement(element))
+ );
+ },
+ perform: (elements, appState, _, app) => {
+ const selectedElements = getSelectedElements(elements, appState);
+
+ const [x1, y1, x2, y2] = getCommonBounds(
+ selectedElements,
+ app.scene.getNonDeletedElementsMap(),
+ );
+ const PADDING = 16;
+ const frame = newFrameElement({
+ x: x1 - PADDING,
+ y: y1 - PADDING,
+ width: x2 - x1 + PADDING * 2,
+ height: y2 - y1 + PADDING * 2,
+ });
+
+ // for a selected partial group, we want to remove it from the remainder of the group
+ if (appState.editingGroupId) {
+ const elementsInGroup = getElementsInGroup(
+ selectedElements,
+ appState.editingGroupId,
+ );
+
+ for (const elementInGroup of elementsInGroup) {
+ const index = elementInGroup.groupIds.indexOf(appState.editingGroupId);
+
+ mutateElement(
+ elementInGroup,
+ {
+ groupIds: elementInGroup.groupIds.slice(0, index),
+ },
+ false,
+ );
+ }
+ }
+
+ const nextElements = addElementsToFrame(
+ [...app.scene.getElementsIncludingDeleted(), frame],
+ selectedElements,
+ frame,
+ appState,
+ );
+
+ return {
+ elements: nextElements,
+ appState: {
+ selectedElementIds: { [frame.id]: true },
+ },
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+});
diff --git a/packages/excalidraw/actions/actionGroup.tsx b/packages/excalidraw/actions/actionGroup.tsx
new file mode 100644
index 0000000..4e98c59
--- /dev/null
+++ b/packages/excalidraw/actions/actionGroup.tsx
@@ -0,0 +1,308 @@
+import { KEYS } from "../keys";
+import { t } from "../i18n";
+import { arrayToMap, getShortcutKey } from "../utils";
+import { register } from "./register";
+import { UngroupIcon, GroupIcon } from "../components/icons";
+import { newElementWith } from "../element/mutateElement";
+import { isSomeElementSelected } from "../scene";
+import {
+ getSelectedGroupIds,
+ selectGroup,
+ selectGroupsForSelectedElements,
+ getElementsInGroup,
+ addToGroup,
+ removeFromSelectedGroups,
+ isElementInGroup,
+} from "../groups";
+import { getNonDeletedElements } from "../element";
+import { randomId } from "../random";
+import { ToolButton } from "../components/ToolButton";
+import type {
+ ExcalidrawElement,
+ ExcalidrawTextElement,
+ OrderedExcalidrawElement,
+} from "../element/types";
+import type { AppClassProperties, AppState } from "../types";
+import { isBoundToContainer } from "../element/typeChecks";
+import {
+ frameAndChildrenSelectedTogether,
+ getElementsInResizingFrame,
+ getFrameLikeElements,
+ getRootElements,
+ groupByFrameLikes,
+ removeElementsFromFrame,
+ replaceAllElementsInFrame,
+} from "../frame";
+import { syncMovedIndices } from "../fractionalIndex";
+import { CaptureUpdateAction } from "../store";
+
+const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
+ if (elements.length >= 2) {
+ const groupIds = elements[0].groupIds;
+ for (const groupId of groupIds) {
+ if (
+ elements.reduce(
+ (acc, element) => acc && isElementInGroup(element, groupId),
+ true,
+ )
+ ) {
+ return true;
+ }
+ }
+ }
+ return false;
+};
+
+const enableActionGroup = (
+ elements: readonly ExcalidrawElement[],
+ appState: AppState,
+ app: AppClassProperties,
+) => {
+ const selectedElements = app.scene.getSelectedElements({
+ selectedElementIds: appState.selectedElementIds,
+ includeBoundTextElement: true,
+ });
+
+ return (
+ selectedElements.length >= 2 &&
+ !allElementsInSameGroup(selectedElements) &&
+ !frameAndChildrenSelectedTogether(selectedElements)
+ );
+};
+
+export const actionGroup = register({
+ name: "group",
+ label: "labels.group",
+ icon: (appState) => <GroupIcon theme={appState.theme} />,
+ trackEvent: { category: "element" },
+ perform: (elements, appState, _, app) => {
+ const selectedElements = getRootElements(
+ app.scene.getSelectedElements({
+ selectedElementIds: appState.selectedElementIds,
+ includeBoundTextElement: true,
+ }),
+ );
+ if (selectedElements.length < 2) {
+ // nothing to group
+ return {
+ appState,
+ elements,
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ }
+ // if everything is already grouped into 1 group, there is nothing to do
+ const selectedGroupIds = getSelectedGroupIds(appState);
+ if (selectedGroupIds.length === 1) {
+ const selectedGroupId = selectedGroupIds[0];
+ const elementIdsInGroup = new Set(
+ getElementsInGroup(elements, selectedGroupId).map(
+ (element) => element.id,
+ ),
+ );
+ const selectedElementIds = new Set(
+ selectedElements.map((element) => element.id),
+ );
+ const combinedSet = new Set([
+ ...Array.from(elementIdsInGroup),
+ ...Array.from(selectedElementIds),
+ ]);
+ if (combinedSet.size === elementIdsInGroup.size) {
+ // no incremental ids in the selected ids
+ return {
+ appState,
+ elements,
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ }
+ }
+
+ let nextElements = [...elements];
+
+ // this includes the case where we are grouping elements inside a frame
+ // and elements outside that frame
+ const groupingElementsFromDifferentFrames =
+ new Set(selectedElements.map((element) => element.frameId)).size > 1;
+ // when it happens, we want to remove elements that are in the frame
+ // and are going to be grouped from the frame (mouthful, I know)
+ if (groupingElementsFromDifferentFrames) {
+ const frameElementsMap = groupByFrameLikes(selectedElements);
+
+ frameElementsMap.forEach((elementsInFrame, frameId) => {
+ removeElementsFromFrame(
+ elementsInFrame,
+ app.scene.getNonDeletedElementsMap(),
+ );
+ });
+ }
+
+ const newGroupId = randomId();
+ const selectElementIds = arrayToMap(selectedElements);
+
+ nextElements = nextElements.map((element) => {
+ if (!selectElementIds.get(element.id)) {
+ return element;
+ }
+ return newElementWith(element, {
+ groupIds: addToGroup(
+ element.groupIds,
+ newGroupId,
+ appState.editingGroupId,
+ ),
+ });
+ });
+ // keep the z order within the group the same, but move them
+ // to the z order of the highest element in the layer stack
+ const elementsInGroup = getElementsInGroup(nextElements, newGroupId);
+ const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
+ const lastGroupElementIndex = nextElements.lastIndexOf(
+ lastElementInGroup as OrderedExcalidrawElement,
+ );
+ const elementsAfterGroup = nextElements.slice(lastGroupElementIndex + 1);
+ const elementsBeforeGroup = nextElements
+ .slice(0, lastGroupElementIndex)
+ .filter(
+ (updatedElement) => !isElementInGroup(updatedElement, newGroupId),
+ );
+ const reorderedElements = syncMovedIndices(
+ [...elementsBeforeGroup, ...elementsInGroup, ...elementsAfterGroup],
+ arrayToMap(elementsInGroup),
+ );
+
+ return {
+ appState: {
+ ...appState,
+ ...selectGroup(
+ newGroupId,
+ { ...appState, selectedGroupIds: {} },
+ getNonDeletedElements(nextElements),
+ ),
+ },
+ elements: reorderedElements,
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ predicate: (elements, appState, _, app) =>
+ enableActionGroup(elements, appState, app),
+ keyTest: (event) =>
+ !event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G,
+ PanelComponent: ({ elements, appState, updateData, app }) => (
+ <ToolButton
+ hidden={!enableActionGroup(elements, appState, app)}
+ type="button"
+ icon={<GroupIcon theme={appState.theme} />}
+ onClick={() => updateData(null)}
+ title={`${t("labels.group")} — ${getShortcutKey("CtrlOrCmd+G")}`}
+ aria-label={t("labels.group")}
+ visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
+ ></ToolButton>
+ ),
+});
+
+export const actionUngroup = register({
+ name: "ungroup",
+ label: "labels.ungroup",
+ icon: (appState) => <UngroupIcon theme={appState.theme} />,
+ trackEvent: { category: "element" },
+ perform: (elements, appState, _, app) => {
+ const groupIds = getSelectedGroupIds(appState);
+ const elementsMap = arrayToMap(elements);
+
+ if (groupIds.length === 0) {
+ return {
+ appState,
+ elements,
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ }
+
+ let nextElements = [...elements];
+
+ const boundTextElementIds: ExcalidrawTextElement["id"][] = [];
+ nextElements = nextElements.map((element) => {
+ if (isBoundToContainer(element)) {
+ boundTextElementIds.push(element.id);
+ }
+ const nextGroupIds = removeFromSelectedGroups(
+ element.groupIds,
+ appState.selectedGroupIds,
+ );
+ if (nextGroupIds.length === element.groupIds.length) {
+ return element;
+ }
+ return newElementWith(element, {
+ groupIds: nextGroupIds,
+ });
+ });
+
+ const updateAppState = selectGroupsForSelectedElements(
+ appState,
+ getNonDeletedElements(nextElements),
+ appState,
+ null,
+ );
+
+ const selectedElements = app.scene.getSelectedElements(appState);
+
+ const selectedElementFrameIds = new Set(
+ selectedElements
+ .filter((element) => element.frameId)
+ .map((element) => element.frameId!),
+ );
+
+ const targetFrames = getFrameLikeElements(elements).filter((frame) =>
+ selectedElementFrameIds.has(frame.id),
+ );
+
+ targetFrames.forEach((frame) => {
+ if (frame) {
+ nextElements = replaceAllElementsInFrame(
+ nextElements,
+ getElementsInResizingFrame(
+ nextElements,
+ frame,
+ appState,
+ elementsMap,
+ ),
+ frame,
+ app,
+ );
+ }
+ });
+
+ // remove binded text elements from selection
+ updateAppState.selectedElementIds = Object.entries(
+ updateAppState.selectedElementIds,
+ ).reduce(
+ (acc: { [key: ExcalidrawElement["id"]]: true }, [id, selected]) => {
+ if (selected && !boundTextElementIds.includes(id)) {
+ acc[id] = true;
+ }
+ return acc;
+ },
+ {},
+ );
+
+ return {
+ appState: { ...appState, ...updateAppState },
+ elements: nextElements,
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ keyTest: (event) =>
+ event.shiftKey &&
+ event[KEYS.CTRL_OR_CMD] &&
+ event.key === KEYS.G.toUpperCase(),
+ predicate: (elements, appState) => getSelectedGroupIds(appState).length > 0,
+
+ PanelComponent: ({ elements, appState, updateData }) => (
+ <ToolButton
+ type="button"
+ hidden={getSelectedGroupIds(appState).length === 0}
+ icon={<UngroupIcon theme={appState.theme} />}
+ onClick={() => updateData(null)}
+ title={`${t("labels.ungroup")} — ${getShortcutKey("CtrlOrCmd+Shift+G")}`}
+ aria-label={t("labels.ungroup")}
+ visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
+ ></ToolButton>
+ ),
+});
diff --git a/packages/excalidraw/actions/actionHistory.tsx b/packages/excalidraw/actions/actionHistory.tsx
new file mode 100644
index 0000000..299a09f
--- /dev/null
+++ b/packages/excalidraw/actions/actionHistory.tsx
@@ -0,0 +1,128 @@
+import type { Action, ActionResult } from "./types";
+import { UndoIcon, RedoIcon } from "../components/icons";
+import { ToolButton } from "../components/ToolButton";
+import { t } from "../i18n";
+import type { History } from "../history";
+import { HistoryChangedEvent } from "../history";
+import type { AppClassProperties, AppState } from "../types";
+import { KEYS, matchKey } from "../keys";
+import { arrayToMap } from "../utils";
+import { isWindows } from "../constants";
+import type { SceneElementsMap } from "../element/types";
+import type { Store } from "../store";
+import { CaptureUpdateAction } from "../store";
+import { useEmitter } from "../hooks/useEmitter";
+
+const executeHistoryAction = (
+ app: AppClassProperties,
+ appState: Readonly<AppState>,
+ updater: () => [SceneElementsMap, AppState] | void,
+): ActionResult => {
+ if (
+ !appState.multiElement &&
+ !appState.resizingElement &&
+ !appState.editingTextElement &&
+ !appState.newElement &&
+ !appState.selectedElementsAreBeingDragged &&
+ !appState.selectionElement &&
+ !app.flowChartCreator.isCreatingChart
+ ) {
+ const result = updater();
+
+ if (!result) {
+ return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
+ }
+
+ const [nextElementsMap, nextAppState] = result;
+ const nextElements = Array.from(nextElementsMap.values());
+
+ return {
+ appState: nextAppState,
+ elements: nextElements,
+ captureUpdate: CaptureUpdateAction.NEVER,
+ };
+ }
+
+ return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
+};
+
+type ActionCreator = (history: History, store: Store) => Action;
+
+export const createUndoAction: ActionCreator = (history, store) => ({
+ name: "undo",
+ label: "buttons.undo",
+ icon: UndoIcon,
+ trackEvent: { category: "history" },
+ viewMode: false,
+ perform: (elements, appState, value, app) =>
+ executeHistoryAction(app, appState, () =>
+ history.undo(
+ arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
+ appState,
+ store.snapshot,
+ ),
+ ),
+ keyTest: (event) =>
+ event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
+ PanelComponent: ({ updateData, data }) => {
+ const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
+ history.onHistoryChangedEmitter,
+ new HistoryChangedEvent(
+ history.isUndoStackEmpty,
+ history.isRedoStackEmpty,
+ ),
+ );
+
+ return (
+ <ToolButton
+ type="button"
+ icon={UndoIcon}
+ aria-label={t("buttons.undo")}
+ onClick={updateData}
+ size={data?.size || "medium"}
+ disabled={isUndoStackEmpty}
+ data-testid="button-undo"
+ />
+ );
+ },
+});
+
+export const createRedoAction: ActionCreator = (history, store) => ({
+ name: "redo",
+ label: "buttons.redo",
+ icon: RedoIcon,
+ trackEvent: { category: "history" },
+ viewMode: false,
+ perform: (elements, appState, _, app) =>
+ executeHistoryAction(app, appState, () =>
+ history.redo(
+ arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
+ appState,
+ store.snapshot,
+ ),
+ ),
+ keyTest: (event) =>
+ (event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
+ (isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)),
+ PanelComponent: ({ updateData, data }) => {
+ const { isRedoStackEmpty } = useEmitter(
+ history.onHistoryChangedEmitter,
+ new HistoryChangedEvent(
+ history.isUndoStackEmpty,
+ history.isRedoStackEmpty,
+ ),
+ );
+
+ return (
+ <ToolButton
+ type="button"
+ icon={RedoIcon}
+ aria-label={t("buttons.redo")}
+ onClick={updateData}
+ size={data?.size || "medium"}
+ disabled={isRedoStackEmpty}
+ data-testid="button-redo"
+ />
+ );
+ },
+});
diff --git a/packages/excalidraw/actions/actionLinearEditor.tsx b/packages/excalidraw/actions/actionLinearEditor.tsx
new file mode 100644
index 0000000..1f05755
--- /dev/null
+++ b/packages/excalidraw/actions/actionLinearEditor.tsx
@@ -0,0 +1,77 @@
+import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
+import { LinearElementEditor } from "../element/linearElementEditor";
+import { isElbowArrow, isLinearElement } from "../element/typeChecks";
+import type { ExcalidrawLinearElement } from "../element/types";
+import { CaptureUpdateAction } from "../store";
+import { register } from "./register";
+import { ToolButton } from "../components/ToolButton";
+import { t } from "../i18n";
+import { lineEditorIcon } from "../components/icons";
+
+export const actionToggleLinearEditor = register({
+ name: "toggleLinearEditor",
+ category: DEFAULT_CATEGORIES.elements,
+ label: (elements, appState, app) => {
+ const selectedElement = app.scene.getSelectedElements({
+ selectedElementIds: appState.selectedElementIds,
+ })[0] as ExcalidrawLinearElement | undefined;
+
+ return selectedElement?.type === "arrow"
+ ? "labels.lineEditor.editArrow"
+ : "labels.lineEditor.edit";
+ },
+ keywords: ["line"],
+ trackEvent: {
+ category: "element",
+ },
+ predicate: (elements, appState, _, app) => {
+ const selectedElements = app.scene.getSelectedElements(appState);
+ if (
+ !appState.editingLinearElement &&
+ selectedElements.length === 1 &&
+ isLinearElement(selectedElements[0]) &&
+ !isElbowArrow(selectedElements[0])
+ ) {
+ return true;
+ }
+ return false;
+ },
+ perform(elements, appState, _, app) {
+ const selectedElement = app.scene.getSelectedElements({
+ selectedElementIds: appState.selectedElementIds,
+ includeBoundTextElement: true,
+ })[0] as ExcalidrawLinearElement;
+
+ const editingLinearElement =
+ appState.editingLinearElement?.elementId === selectedElement.id
+ ? null
+ : new LinearElementEditor(selectedElement);
+ return {
+ appState: {
+ ...appState,
+ editingLinearElement,
+ },
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ PanelComponent: ({ appState, updateData, app }) => {
+ const selectedElement = app.scene.getSelectedElements({
+ selectedElementIds: appState.selectedElementIds,
+ })[0] as ExcalidrawLinearElement;
+
+ const label = t(
+ selectedElement.type === "arrow"
+ ? "labels.lineEditor.editArrow"
+ : "labels.lineEditor.edit",
+ );
+ return (
+ <ToolButton
+ type="button"
+ icon={lineEditorIcon}
+ title={label}
+ aria-label={label}
+ onClick={() => updateData(null)}
+ />
+ );
+ },
+});
diff --git a/packages/excalidraw/actions/actionLink.tsx b/packages/excalidraw/actions/actionLink.tsx
new file mode 100644
index 0000000..beb95d2
--- /dev/null
+++ b/packages/excalidraw/actions/actionLink.tsx
@@ -0,0 +1,55 @@
+import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
+import { LinkIcon } from "../components/icons";
+import { ToolButton } from "../components/ToolButton";
+import { isEmbeddableElement } from "../element/typeChecks";
+import { t } from "../i18n";
+import { KEYS } from "../keys";
+import { getSelectedElements } from "../scene";
+import { CaptureUpdateAction } from "../store";
+import { getShortcutKey } from "../utils";
+import { register } from "./register";
+
+export const actionLink = register({
+ name: "hyperlink",
+ label: (elements, appState) => getContextMenuLabel(elements, appState),
+ icon: LinkIcon,
+ perform: (elements, appState) => {
+ if (appState.showHyperlinkPopup === "editor") {
+ return false;
+ }
+
+ return {
+ elements,
+ appState: {
+ ...appState,
+ showHyperlinkPopup: "editor",
+ openMenu: null,
+ },
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ trackEvent: { category: "hyperlink", action: "click" },
+ keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K,
+ predicate: (elements, appState) => {
+ const selectedElements = getSelectedElements(elements, appState);
+ return selectedElements.length === 1;
+ },
+ PanelComponent: ({ elements, appState, updateData }) => {
+ const selectedElements = getSelectedElements(elements, appState);
+
+ return (
+ <ToolButton
+ type="button"
+ icon={LinkIcon}
+ aria-label={t(getContextMenuLabel(elements, appState))}
+ title={`${
+ isEmbeddableElement(elements[0])
+ ? t("labels.link.labelEmbed")
+ : t("labels.link.label")
+ } - ${getShortcutKey("CtrlOrCmd+K")}`}
+ onClick={() => updateData(null)}
+ selected={selectedElements.length === 1 && !!selectedElements[0].link}
+ />
+ );
+ },
+});
diff --git a/packages/excalidraw/actions/actionMenu.tsx b/packages/excalidraw/actions/actionMenu.tsx
new file mode 100644
index 0000000..9e71fe2
--- /dev/null
+++ b/packages/excalidraw/actions/actionMenu.tsx
@@ -0,0 +1,81 @@
+import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons";
+import { ToolButton } from "../components/ToolButton";
+import { t } from "../i18n";
+import { showSelectedShapeActions, getNonDeletedElements } from "../element";
+import { register } from "./register";
+import { KEYS } from "../keys";
+import { CaptureUpdateAction } from "../store";
+
+export const actionToggleCanvasMenu = register({
+ name: "toggleCanvasMenu",
+ label: "buttons.menu",
+ trackEvent: { category: "menu" },
+ perform: (_, appState) => ({
+ appState: {
+ ...appState,
+ openMenu: appState.openMenu === "canvas" ? null : "canvas",
+ },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ }),
+ PanelComponent: ({ appState, updateData }) => (
+ <ToolButton
+ type="button"
+ icon={HamburgerMenuIcon}
+ aria-label={t("buttons.menu")}
+ onClick={updateData}
+ selected={appState.openMenu === "canvas"}
+ />
+ ),
+});
+
+export const actionToggleEditMenu = register({
+ name: "toggleEditMenu",
+ label: "buttons.edit",
+ trackEvent: { category: "menu" },
+ perform: (_elements, appState) => ({
+ appState: {
+ ...appState,
+ openMenu: appState.openMenu === "shape" ? null : "shape",
+ },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ }),
+ PanelComponent: ({ elements, appState, updateData }) => (
+ <ToolButton
+ visible={showSelectedShapeActions(
+ appState,
+ getNonDeletedElements(elements),
+ )}
+ type="button"
+ icon={palette}
+ aria-label={t("buttons.edit")}
+ onClick={updateData}
+ selected={appState.openMenu === "shape"}
+ />
+ ),
+});
+
+export const actionShortcuts = register({
+ name: "toggleShortcuts",
+ label: "welcomeScreen.defaults.helpHint",
+ icon: HelpIconThin,
+ viewMode: true,
+ trackEvent: { category: "menu", action: "toggleHelpDialog" },
+ perform: (_elements, appState, _, { focusContainer }) => {
+ if (appState.openDialog?.name === "help") {
+ focusContainer();
+ }
+ return {
+ appState: {
+ ...appState,
+ openDialog:
+ appState.openDialog?.name === "help"
+ ? null
+ : {
+ name: "help",
+ },
+ },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ },
+ keyTest: (event) => event.key === KEYS.QUESTION_MARK,
+});
diff --git a/packages/excalidraw/actions/actionNavigate.tsx b/packages/excalidraw/actions/actionNavigate.tsx
new file mode 100644
index 0000000..4803720
--- /dev/null
+++ b/packages/excalidraw/actions/actionNavigate.tsx
@@ -0,0 +1,133 @@
+import { getClientColor } from "../clients";
+import { Avatar } from "../components/Avatar";
+import type { GoToCollaboratorComponentProps } from "../components/UserList";
+import {
+ eyeIcon,
+ microphoneIcon,
+ microphoneMutedIcon,
+} from "../components/icons";
+import { t } from "../i18n";
+import { CaptureUpdateAction } from "../store";
+import type { Collaborator } from "../types";
+import { register } from "./register";
+import clsx from "clsx";
+
+export const actionGoToCollaborator = register({
+ name: "goToCollaborator",
+ label: "Go to a collaborator",
+ viewMode: true,
+ trackEvent: { category: "collab" },
+ perform: (_elements, appState, collaborator: Collaborator) => {
+ if (
+ !collaborator.socketId ||
+ appState.userToFollow?.socketId === collaborator.socketId ||
+ collaborator.isCurrentUser
+ ) {
+ return {
+ appState: {
+ ...appState,
+ userToFollow: null,
+ },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ }
+
+ return {
+ appState: {
+ ...appState,
+ userToFollow: {
+ socketId: collaborator.socketId,
+ username: collaborator.username || "",
+ },
+ // Close mobile menu
+ openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
+ },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ },
+ PanelComponent: ({ updateData, data, appState }) => {
+ const { socketId, collaborator, withName, isBeingFollowed } =
+ data as GoToCollaboratorComponentProps;
+
+ const background = getClientColor(socketId, collaborator);
+
+ const statusClassNames = clsx({
+ "is-followed": isBeingFollowed,
+ "is-current-user": collaborator.isCurrentUser === true,
+ "is-speaking": collaborator.isSpeaking,
+ "is-in-call": collaborator.isInCall,
+ "is-muted": collaborator.isMuted,
+ });
+
+ const statusIconJSX = collaborator.isInCall ? (
+ collaborator.isSpeaking ? (
+ <div
+ className="UserList__collaborator-status-icon-speaking-indicator"
+ title={t("userList.hint.isSpeaking")}
+ >
+ <div />
+ <div />
+ <div />
+ </div>
+ ) : collaborator.isMuted ? (
+ <div
+ className="UserList__collaborator-status-icon-microphone-muted"
+ title={t("userList.hint.micMuted")}
+ >
+ {microphoneMutedIcon}
+ </div>
+ ) : (
+ <div title={t("userList.hint.inCall")}>{microphoneIcon}</div>
+ )
+ ) : null;
+
+ return withName ? (
+ <div
+ className={`dropdown-menu-item dropdown-menu-item-base UserList__collaborator ${statusClassNames}`}
+ style={{ [`--avatar-size` as any]: "1.5rem" }}
+ onClick={() => updateData<Collaborator>(collaborator)}
+ >
+ <Avatar
+ color={background}
+ onClick={() => {}}
+ name={collaborator.username || ""}
+ src={collaborator.avatarUrl}
+ className={statusClassNames}
+ />
+ <div className="UserList__collaborator-name">
+ {collaborator.username}
+ </div>
+ <div className="UserList__collaborator-status-icons" aria-hidden>
+ {isBeingFollowed && (
+ <div
+ className="UserList__collaborator-status-icon-is-followed"
+ title={t("userList.hint.followStatus")}
+ >
+ {eyeIcon}
+ </div>
+ )}
+ {statusIconJSX}
+ </div>
+ </div>
+ ) : (
+ <div
+ className={`UserList__collaborator UserList__collaborator--avatar-only ${statusClassNames}`}
+ >
+ <Avatar
+ color={background}
+ onClick={() => {
+ updateData(collaborator);
+ }}
+ name={collaborator.username || ""}
+ src={collaborator.avatarUrl}
+ className={statusClassNames}
+ />
+ {statusIconJSX && (
+ <div className="UserList__collaborator-status-icon">
+ {statusIconJSX}
+ </div>
+ )}
+ </div>
+ );
+ },
+});
diff --git a/packages/excalidraw/actions/actionProperties.test.tsx b/packages/excalidraw/actions/actionProperties.test.tsx
new file mode 100644
index 0000000..80d2ca2
--- /dev/null
+++ b/packages/excalidraw/actions/actionProperties.test.tsx
@@ -0,0 +1,168 @@
+import React from "react";
+import { Excalidraw } from "../index";
+import { queryByTestId } from "@testing-library/react";
+import { render } from "../tests/test-utils";
+import { UI } from "../tests/helpers/ui";
+import { API } from "../tests/helpers/api";
+import { COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_PICKS } from "../colors";
+import { FONT_FAMILY, STROKE_WIDTH } from "../constants";
+
+describe("element locking", () => {
+ beforeEach(async () => {
+ await render(<Excalidraw />);
+ });
+
+ describe("properties when tool selected", () => {
+ it("should show active background top picks", () => {
+ UI.clickTool("rectangle");
+
+ const color = DEFAULT_ELEMENT_BACKGROUND_PICKS[1];
+
+ // just in case we change it in the future
+ expect(color).not.toBe(COLOR_PALETTE.transparent);
+
+ API.setAppState({
+ currentItemBackgroundColor: color,
+ });
+ const activeColor = queryByTestId(
+ document.body,
+ `color-top-pick-${color}`,
+ );
+ expect(activeColor).toHaveClass("active");
+ });
+
+ it("should show fill style when background non-transparent", () => {
+ UI.clickTool("rectangle");
+
+ const color = DEFAULT_ELEMENT_BACKGROUND_PICKS[1];
+
+ // just in case we change it in the future
+ expect(color).not.toBe(COLOR_PALETTE.transparent);
+
+ API.setAppState({
+ currentItemBackgroundColor: color,
+ currentItemFillStyle: "hachure",
+ });
+ const hachureFillButton = queryByTestId(document.body, `fill-hachure`);
+
+ expect(hachureFillButton).toHaveClass("active");
+ API.setAppState({
+ currentItemFillStyle: "solid",
+ });
+ const solidFillStyle = queryByTestId(document.body, `fill-solid`);
+ expect(solidFillStyle).toHaveClass("active");
+ });
+
+ it("should not show fill style when background transparent", () => {
+ UI.clickTool("rectangle");
+
+ API.setAppState({
+ currentItemBackgroundColor: COLOR_PALETTE.transparent,
+ currentItemFillStyle: "hachure",
+ });
+ const hachureFillButton = queryByTestId(document.body, `fill-hachure`);
+
+ expect(hachureFillButton).toBe(null);
+ });
+
+ it("should show horizontal text align for text tool", () => {
+ UI.clickTool("text");
+
+ API.setAppState({
+ currentItemTextAlign: "right",
+ });
+
+ const centerTextAlign = queryByTestId(document.body, `align-right`);
+ expect(centerTextAlign).toBeChecked();
+ });
+ });
+
+ describe("properties when elements selected", () => {
+ it("should show active styles when single element selected", () => {
+ const rect = API.createElement({
+ type: "rectangle",
+ backgroundColor: "red",
+ fillStyle: "cross-hatch",
+ });
+ API.setElements([rect]);
+ API.setSelectedElements([rect]);
+
+ const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
+ expect(crossHatchButton).toHaveClass("active");
+ });
+
+ it("should not show fill style selected element's background is transparent", () => {
+ const rect = API.createElement({
+ type: "rectangle",
+ backgroundColor: COLOR_PALETTE.transparent,
+ fillStyle: "cross-hatch",
+ });
+ API.setElements([rect]);
+ API.setSelectedElements([rect]);
+
+ const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
+ expect(crossHatchButton).toBe(null);
+ });
+
+ it("should highlight common stroke width of selected elements", () => {
+ const rect1 = API.createElement({
+ type: "rectangle",
+ strokeWidth: STROKE_WIDTH.thin,
+ });
+ const rect2 = API.createElement({
+ type: "rectangle",
+ strokeWidth: STROKE_WIDTH.thin,
+ });
+ API.setElements([rect1, rect2]);
+ API.setSelectedElements([rect1, rect2]);
+
+ const thinStrokeWidthButton = queryByTestId(
+ document.body,
+ `strokeWidth-thin`,
+ );
+ expect(thinStrokeWidthButton).toBeChecked();
+ });
+
+ it("should not highlight any stroke width button if no common style", () => {
+ const rect1 = API.createElement({
+ type: "rectangle",
+ strokeWidth: STROKE_WIDTH.thin,
+ });
+ const rect2 = API.createElement({
+ type: "rectangle",
+ strokeWidth: STROKE_WIDTH.bold,
+ });
+ API.setElements([rect1, rect2]);
+ API.setSelectedElements([rect1, rect2]);
+
+ expect(queryByTestId(document.body, `strokeWidth-thin`)).not.toBe(null);
+ expect(
+ queryByTestId(document.body, `strokeWidth-thin`),
+ ).not.toBeChecked();
+ expect(
+ queryByTestId(document.body, `strokeWidth-bold`),
+ ).not.toBeChecked();
+ expect(
+ queryByTestId(document.body, `strokeWidth-extraBold`),
+ ).not.toBeChecked();
+ });
+
+ it("should show properties of different element types when selected", () => {
+ const rect = API.createElement({
+ type: "rectangle",
+ strokeWidth: STROKE_WIDTH.bold,
+ });
+ const text = API.createElement({
+ type: "text",
+ fontFamily: FONT_FAMILY["Comic Shanns"],
+ });
+ API.setElements([rect, text]);
+ API.setSelectedElements([rect, text]);
+
+ expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();
+ expect(queryByTestId(document.body, `font-family-code`)).toHaveClass(
+ "active",
+ );
+ });
+ });
+});
diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx
new file mode 100644
index 0000000..0c2ad3b
--- /dev/null
+++ b/packages/excalidraw/actions/actionProperties.tsx
@@ -0,0 +1,1777 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import type { AppClassProperties, AppState, Primitive } from "../types";
+import type { CaptureUpdateActionType } from "../store";
+import {
+ DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
+ DEFAULT_ELEMENT_BACKGROUND_PICKS,
+ DEFAULT_ELEMENT_STROKE_COLOR_PALETTE,
+ DEFAULT_ELEMENT_STROKE_PICKS,
+} from "../colors";
+import { trackEvent } from "../analytics";
+import { ButtonIconSelect } from "../components/ButtonIconSelect";
+import { ColorPicker } from "../components/ColorPicker/ColorPicker";
+import { IconPicker } from "../components/IconPicker";
+import { FontPicker } from "../components/FontPicker/FontPicker";
+// TODO barnabasmolnar/editor-redesign
+// TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon,
+// ArrowHead icons
+import {
+ ArrowheadArrowIcon,
+ ArrowheadBarIcon,
+ ArrowheadCircleIcon,
+ ArrowheadTriangleIcon,
+ ArrowheadNoneIcon,
+ StrokeStyleDashedIcon,
+ StrokeStyleDottedIcon,
+ TextAlignTopIcon,
+ TextAlignBottomIcon,
+ TextAlignMiddleIcon,
+ FillHachureIcon,
+ FillCrossHatchIcon,
+ FillSolidIcon,
+ SloppinessArchitectIcon,
+ SloppinessArtistIcon,
+ SloppinessCartoonistIcon,
+ StrokeWidthBaseIcon,
+ StrokeWidthBoldIcon,
+ StrokeWidthExtraBoldIcon,
+ FontSizeSmallIcon,
+ FontSizeMediumIcon,
+ FontSizeLargeIcon,
+ FontSizeExtraLargeIcon,
+ EdgeSharpIcon,
+ EdgeRoundIcon,
+ TextAlignLeftIcon,
+ TextAlignCenterIcon,
+ TextAlignRightIcon,
+ FillZigZagIcon,
+ ArrowheadTriangleOutlineIcon,
+ ArrowheadCircleOutlineIcon,
+ ArrowheadDiamondIcon,
+ ArrowheadDiamondOutlineIcon,
+ fontSizeIcon,
+ sharpArrowIcon,
+ roundArrowIcon,
+ elbowArrowIcon,
+ ArrowheadCrowfootIcon,
+ ArrowheadCrowfootOneIcon,
+ ArrowheadCrowfootOneOrManyIcon,
+} from "../components/icons";
+import {
+ ARROW_TYPE,
+ DEFAULT_FONT_FAMILY,
+ DEFAULT_FONT_SIZE,
+ FONT_FAMILY,
+ ROUNDNESS,
+ STROKE_WIDTH,
+ VERTICAL_ALIGN,
+} from "../constants";
+import {
+ getNonDeletedElements,
+ isTextElement,
+ redrawTextBoundingBox,
+} from "../element";
+import { mutateElement, newElementWith } from "../element/mutateElement";
+import { getBoundTextElement } from "../element/textElement";
+import {
+ isArrowElement,
+ isBoundToContainer,
+ isElbowArrow,
+ isLinearElement,
+ isUsingAdaptiveRadius,
+} from "../element/typeChecks";
+import type {
+ Arrowhead,
+ ExcalidrawBindableElement,
+ ExcalidrawElement,
+ ExcalidrawLinearElement,
+ ExcalidrawTextElement,
+ FontFamilyValues,
+ TextAlign,
+ VerticalAlign,
+ NonDeletedSceneElementsMap,
+} from "../element/types";
+import { getLanguage, t } from "../i18n";
+import { KEYS } from "../keys";
+import { randomInteger } from "../random";
+import {
+ canHaveArrowheads,
+ getCommonAttributeOfSelectedElements,
+ getSelectedElements,
+ getTargetElements,
+ isSomeElementSelected,
+} from "../scene";
+import { hasStrokeColor } from "../scene/comparisons";
+import {
+ arrayToMap,
+ getFontFamilyString,
+ getShortcutKey,
+ tupleToCoors,
+} from "../utils";
+import { register } from "./register";
+import { CaptureUpdateAction } from "../store";
+import { Fonts, getLineHeight } from "../fonts";
+import {
+ bindLinearElement,
+ bindPointToSnapToElementOutline,
+ calculateFixedPointForElbowArrowBinding,
+ getHoveredElementForBinding,
+ updateBoundElements,
+} from "../element/binding";
+import { LinearElementEditor } from "../element/linearElementEditor";
+import type { LocalPoint } from "@excalidraw/math";
+import { pointFrom } from "@excalidraw/math";
+import { Range } from "../components/Range";
+
+const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
+
+export const changeProperty = (
+ elements: readonly ExcalidrawElement[],
+ appState: AppState,
+ callback: (element: ExcalidrawElement) => ExcalidrawElement,
+ includeBoundText = false,
+) => {
+ const selectedElementIds = arrayToMap(
+ getSelectedElements(elements, appState, {
+ includeBoundTextElement: includeBoundText,
+ }),
+ );
+
+ return elements.map((element) => {
+ if (
+ selectedElementIds.get(element.id) ||
+ element.id === appState.editingTextElement?.id
+ ) {
+ return callback(element);
+ }
+ return element;
+ });
+};
+
+export const getFormValue = function <T extends Primitive>(
+ elements: readonly ExcalidrawElement[],
+ appState: AppState,
+ getAttribute: (element: ExcalidrawElement) => T,
+ isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
+ defaultValue: T | ((isSomeElementSelected: boolean) => T),
+): T {
+ const editingTextElement = appState.editingTextElement;
+ const nonDeletedElements = getNonDeletedElements(elements);
+
+ let ret: T | null = null;
+
+ if (editingTextElement) {
+ ret = getAttribute(editingTextElement);
+ }
+
+ if (!ret) {
+ const hasSelection = isSomeElementSelected(nonDeletedElements, appState);
+
+ if (hasSelection) {
+ ret =
+ getCommonAttributeOfSelectedElements(
+ isRelevantElement === true
+ ? nonDeletedElements
+ : nonDeletedElements.filter((el) => isRelevantElement(el)),
+ appState,
+ getAttribute,
+ ) ??
+ (typeof defaultValue === "function"
+ ? defaultValue(true)
+ : defaultValue);
+ } else {
+ ret =
+ typeof defaultValue === "function" ? defaultValue(false) : defaultValue;
+ }
+ }
+
+ return ret;
+};
+
+const offsetElementAfterFontResize = (
+ prevElement: ExcalidrawTextElement,
+ nextElement: ExcalidrawTextElement,
+) => {
+ if (isBoundToContainer(nextElement) || !nextElement.autoResize) {
+ return nextElement;
+ }
+ return mutateElement(
+ nextElement,
+ {
+ x:
+ prevElement.textAlign === "left"
+ ? prevElement.x
+ : prevElement.x +
+ (prevElement.width - nextElement.width) /
+ (prevElement.textAlign === "center" ? 2 : 1),
+ // centering vertically is non-standard, but for Excalidraw I think
+ // it makes sense
+ y: prevElement.y + (prevElement.height - nextElement.height) / 2,
+ },
+ false,
+ );
+};
+
+const changeFontSize = (
+ elements: readonly ExcalidrawElement[],
+ appState: AppState,
+ app: AppClassProperties,
+ getNewFontSize: (element: ExcalidrawTextElement) => number,
+ fallbackValue?: ExcalidrawTextElement["fontSize"],
+) => {
+ const newFontSizes = new Set<number>();
+
+ const updatedElements = changeProperty(
+ elements,
+ appState,
+ (oldElement) => {
+ if (isTextElement(oldElement)) {
+ const newFontSize = getNewFontSize(oldElement);
+ newFontSizes.add(newFontSize);
+
+ let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
+ fontSize: newFontSize,
+ });
+ redrawTextBoundingBox(
+ newElement,
+ app.scene.getContainerElement(oldElement),
+ app.scene.getNonDeletedElementsMap(),
+ );
+
+ newElement = offsetElementAfterFontResize(oldElement, newElement);
+
+ return newElement;
+ }
+ return oldElement;
+ },
+ true,
+ );
+
+ // Update arrow elements after text elements have been updated
+ const updatedElementsMap = arrayToMap(updatedElements);
+ getSelectedElements(elements, appState, {
+ includeBoundTextElement: true,
+ }).forEach((element) => {
+ if (isTextElement(element)) {
+ updateBoundElements(
+ element,
+ updatedElementsMap as NonDeletedSceneElementsMap,
+ );
+ }
+ });
+
+ return {
+ elements: updatedElements,
+ appState: {
+ ...appState,
+ // update state only if we've set all select text elements to
+ // the same font size
+ currentItemFontSize:
+ newFontSizes.size === 1
+ ? [...newFontSizes][0]
+ : fallbackValue ?? appState.currentItemFontSize,
+ },
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+};
+
+// -----------------------------------------------------------------------------
+
+export const actionChangeStrokeColor = register({
+ name: "changeStrokeColor",
+ label: "labels.stroke",
+ trackEvent: false,
+ perform: (elements, appState, value) => {
+ return {
+ ...(value.currentItemStrokeColor && {
+ elements: changeProperty(
+ elements,
+ appState,
+ (el) => {
+ return hasStrokeColor(el.type)
+ ? newElementWith(el, {
+ strokeColor: value.currentItemStrokeColor,
+ })
+ : el;
+ },
+ true,
+ ),
+ }),
+ appState: {
+ ...appState,
+ ...value,
+ },
+ captureUpdate: !!value.currentItemStrokeColor
+ ? CaptureUpdateAction.IMMEDIATELY
+ : CaptureUpdateAction.EVENTUALLY,
+ };
+ },
+ PanelComponent: ({ elements, appState, updateData, appProps }) => (
+ <>
+ <h3 aria-hidden="true">{t("labels.stroke")}</h3>
+ <ColorPicker
+ topPicks={DEFAULT_ELEMENT_STROKE_PICKS}
+ palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE}
+ type="elementStroke"
+ label={t("labels.stroke")}
+ color={getFormValue(
+ elements,
+ appState,
+ (element) => element.strokeColor,
+ true,
+ appState.currentItemStrokeColor,
+ )}
+ onChange={(color) => updateData({ currentItemStrokeColor: color })}
+ elements={elements}
+ appState={appState}
+ updateData={updateData}
+ />
+ </>
+ ),
+});
+
+export const actionChangeBackgroundColor = register({
+ name: "changeBackgroundColor",
+ label: "labels.changeBackground",
+ trackEvent: false,
+ perform: (elements, appState, value) => {
+ return {
+ ...(value.currentItemBackgroundColor && {
+ elements: changeProperty(elements, appState, (el) =>
+ newElementWith(el, {
+ backgroundColor: value.currentItemBackgroundColor,
+ }),
+ ),
+ }),
+ appState: {
+ ...appState,
+ ...value,
+ },
+ captureUpdate: !!value.currentItemBackgroundColor
+ ? CaptureUpdateAction.IMMEDIATELY
+ : CaptureUpdateAction.EVENTUALLY,
+ };
+ },
+ PanelComponent: ({ elements, appState, updateData, appProps }) => (
+ <>
+ <h3 aria-hidden="true">{t("labels.background")}</h3>
+ <ColorPicker
+ topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS}
+ palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
+ type="elementBackground"
+ label={t("labels.background")}
+ color={getFormValue(
+ elements,
+ appState,
+ (element) => element.backgroundColor,
+ true,
+ appState.currentItemBackgroundColor,
+ )}
+ onChange={(color) => updateData({ currentItemBackgroundColor: color })}
+ elements={elements}
+ appState={appState}
+ updateData={updateData}
+ />
+ </>
+ ),
+});
+
+export const actionChangeFillStyle = register({
+ name: "changeFillStyle",
+ label: "labels.fill",
+ trackEvent: false,
+ perform: (elements, appState, value, app) => {
+ trackEvent(
+ "element",
+ "changeFillStyle",
+ `${value} (${app.device.editor.isMobile ? "mobile" : "desktop"})`,
+ );
+ return {
+ elements: changeProperty(elements, appState, (el) =>
+ newElementWith(el, {
+ fillStyle: value,
+ }),
+ ),
+ appState: { ...appState, currentItemFillStyle: value },
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ PanelComponent: ({ elements, appState, updateData }) => {
+ const selectedElements = getSelectedElements(elements, appState);
+ const allElementsZigZag =
+ selectedElements.length > 0 &&
+ selectedElements.every((el) => el.fillStyle === "zigzag");
+
+ return (
+ <fieldset>
+ <legend>{t("labels.fill")}</legend>
+ <ButtonIconSelect
+ type="button"
+ options={[
+ {
+ value: "hachure",
+ text: `${
+ allElementsZigZag ? t("labels.zigzag") : t("labels.hachure")
+ } (${getShortcutKey("Alt-Click")})`,
+ icon: allElementsZigZag ? FillZigZagIcon : FillHachureIcon,
+ active: allElementsZigZag ? true : undefined,
+ testId: `fill-hachure`,
+ },
+ {
+ value: "cross-hatch",
+ text: t("labels.crossHatch"),
+ icon: FillCrossHatchIcon,
+ testId: `fill-cross-hatch`,
+ },
+ {
+ value: "solid",
+ text: t("labels.solid"),
+ icon: FillSolidIcon,
+ testId: `fill-solid`,
+ },
+ ]}
+ value={getFormValue(
+ elements,
+ appState,
+ (element) => element.fillStyle,
+ (element) => element.hasOwnProperty("fillStyle"),
+ (hasSelection) =>
+ hasSelection ? null : appState.currentItemFillStyle,
+ )}
+ onClick={(value, event) => {
+ const nextValue =
+ event.altKey &&
+ value === "hachure" &&
+ selectedElements.every((el) => el.fillStyle === "hachure")
+ ? "zigzag"
+ : value;
+
+ updateData(nextValue);
+ }}
+ />
+ </fieldset>
+ );
+ },
+});
+
+export const actionChangeStrokeWidth = register({
+ name: "changeStrokeWidth",
+ label: "labels.strokeWidth",
+ trackEvent: false,
+ perform: (elements, appState, value) => {
+ return {
+ elements: changeProperty(elements, appState, (el) =>
+ newElementWith(el, {
+ strokeWidth: value,
+ }),
+ ),
+ appState: { ...appState, currentItemStrokeWidth: value },
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ PanelComponent: ({ elements, appState, updateData }) => (
+ <fieldset>
+ <legend>{t("labels.strokeWidth")}</legend>
+ <ButtonIconSelect
+ group="stroke-width"
+ options={[
+ {
+ value: STROKE_WIDTH.thin,
+ text: t("labels.thin"),
+ icon: StrokeWidthBaseIcon,
+ testId: "strokeWidth-thin",
+ },
+ {
+ value: STROKE_WIDTH.bold,
+ text: t("labels.bold"),
+ icon: StrokeWidthBoldIcon,
+ testId: "strokeWidth-bold",
+ },
+ {
+ value: STROKE_WIDTH.extraBold,
+ text: t("labels.extraBold"),
+ icon: StrokeWidthExtraBoldIcon,
+ testId: "strokeWidth-extraBold",
+ },
+ ]}
+ value={getFormValue(
+ elements,
+ appState,
+ (element) => element.strokeWidth,
+ (element) => element.hasOwnProperty("strokeWidth"),
+ (hasSelection) =>
+ hasSelection ? null : appState.currentItemStrokeWidth,
+ )}
+ onChange={(value) => updateData(value)}
+ />
+ </fieldset>
+ ),
+});
+
+export const actionChangeSloppiness = register({
+ name: "changeSloppiness",
+ label: "labels.sloppiness",
+ trackEvent: false,
+ perform: (elements, appState, value) => {
+ return {
+ elements: changeProperty(elements, appState, (el) =>
+ newElementWith(el, {
+ seed: randomInteger(),
+ roughness: value,
+ }),
+ ),
+ appState: { ...appState, currentItemRoughness: value },
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ PanelComponent: ({ elements, appState, updateData }) => (
+ <fieldset>
+ <legend>{t("labels.sloppiness")}</legend>
+ <ButtonIconSelect
+ group="sloppiness"
+ options={[
+ {
+ value: 0,
+ text: t("labels.architect"),
+ icon: SloppinessArchitectIcon,
+ },
+ {
+ value: 1,
+ text: t("labels.artist"),
+ icon: SloppinessArtistIcon,
+ },
+ {
+ value: 2,
+ text: t("labels.cartoonist"),
+ icon: SloppinessCartoonistIcon,
+ },
+ ]}
+ value={getFormValue(
+ elements,
+ appState,
+ (element) => element.roughness,
+ (element) => element.hasOwnProperty("roughness"),
+ (hasSelection) =>
+ hasSelection ? null : appState.currentItemRoughness,
+ )}
+ onChange={(value) => updateData(value)}
+ />
+ </fieldset>
+ ),
+});
+
+export const actionChangeStrokeStyle = register({
+ name: "changeStrokeStyle",
+ label: "labels.strokeStyle",
+ trackEvent: false,
+ perform: (elements, appState, value) => {
+ return {
+ elements: changeProperty(elements, appState, (el) =>
+ newElementWith(el, {
+ strokeStyle: value,
+ }),
+ ),
+ appState: { ...appState, currentItemStrokeStyle: value },
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ PanelComponent: ({ elements, appState, updateData }) => (
+ <fieldset>
+ <legend>{t("labels.strokeStyle")}</legend>
+ <ButtonIconSelect
+ group="strokeStyle"
+ options={[
+ {
+ value: "solid",
+ text: t("labels.strokeStyle_solid"),
+ icon: StrokeWidthBaseIcon,
+ },
+ {
+ value: "dashed",
+ text: t("labels.strokeStyle_dashed"),
+ icon: StrokeStyleDashedIcon,
+ },
+ {
+ value: "dotted",
+ text: t("labels.strokeStyle_dotted"),
+ icon: StrokeStyleDottedIcon,
+ },
+ ]}
+ value={getFormValue(
+ elements,
+ appState,
+ (element) => element.strokeStyle,
+ (element) => element.hasOwnProperty("strokeStyle"),
+ (hasSelection) =>
+ hasSelection ? null : appState.currentItemStrokeStyle,
+ )}
+ onChange={(value) => updateData(value)}
+ />
+ </fieldset>
+ ),
+});
+
+export const actionChangeOpacity = register({
+ name: "changeOpacity",
+ label: "labels.opacity",
+ trackEvent: false,
+ perform: (elements, appState, value) => {
+ return {
+ elements: changeProperty(
+ elements,
+ appState,
+ (el) =>
+ newElementWith(el, {
+ opacity: value,
+ }),
+ true,
+ ),
+ appState: { ...appState, currentItemOpacity: value },
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ PanelComponent: ({ elements, appState, updateData }) => (
+ <Range
+ updateData={updateData}
+ elements={elements}
+ appState={appState}
+ testId="opacity"
+ />
+ ),
+});
+
+export const actionChangeFontSize = register({
+ name: "changeFontSize",
+ label: "labels.fontSize",
+ trackEvent: false,
+ perform: (elements, appState, value, app) => {
+ return changeFontSize(elements, appState, app, () => value, value);
+ },
+ PanelComponent: ({ elements, appState, updateData, app }) => (
+ <fieldset>
+ <legend>{t("labels.fontSize")}</legend>
+ <ButtonIconSelect
+ group="font-size"
+ options={[
+ {
+ value: 16,
+ text: t("labels.small"),
+ icon: FontSizeSmallIcon,
+ testId: "fontSize-small",
+ },
+ {
+ value: 20,
+ text: t("labels.medium"),
+ icon: FontSizeMediumIcon,
+ testId: "fontSize-medium",
+ },
+ {
+ value: 28,
+ text: t("labels.large"),
+ icon: FontSizeLargeIcon,
+ testId: "fontSize-large",
+ },
+ {
+ value: 36,
+ text: t("labels.veryLarge"),
+ icon: FontSizeExtraLargeIcon,
+ testId: "fontSize-veryLarge",
+ },
+ ]}
+ value={getFormValue(
+ elements,
+ appState,
+ (element) => {
+ if (isTextElement(element)) {
+ return element.fontSize;
+ }
+ const boundTextElement = getBoundTextElement(
+ element,
+ app.scene.getNonDeletedElementsMap(),
+ );
+ if (boundTextElement) {
+ return boundTextElement.fontSize;
+ }
+ return null;
+ },
+ (element) =>
+ isTextElement(element) ||
+ getBoundTextElement(
+ element,
+ app.scene.getNonDeletedElementsMap(),
+ ) !== null,
+ (hasSelection) =>
+ hasSelection
+ ? null
+ : appState.currentItemFontSize || DEFAULT_FONT_SIZE,
+ )}
+ onChange={(value) => updateData(value)}
+ />
+ </fieldset>
+ ),
+});
+
+export const actionDecreaseFontSize = register({
+ name: "decreaseFontSize",
+ label: "labels.decreaseFontSize",
+ icon: fontSizeIcon,
+ trackEvent: false,
+ perform: (elements, appState, value, app) => {
+ return changeFontSize(elements, appState, app, (element) =>
+ Math.round(
+ // get previous value before relative increase (doesn't work fully
+ // due to rounding and float precision issues)
+ (1 / (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)) * element.fontSize,
+ ),
+ );
+ },
+ keyTest: (event) => {
+ return (
+ event[KEYS.CTRL_OR_CMD] &&
+ event.shiftKey &&
+ // KEYS.COMMA needed for MacOS
+ (event.key === KEYS.CHEVRON_LEFT || event.key === KEYS.COMMA)
+ );
+ },
+});
+
+export const actionIncreaseFontSize = register({
+ name: "increaseFontSize",
+ label: "labels.increaseFontSize",
+ icon: fontSizeIcon,
+ trackEvent: false,
+ perform: (elements, appState, value, app) => {
+ return changeFontSize(elements, appState, app, (element) =>
+ Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)),
+ );
+ },
+ keyTest: (event) => {
+ return (
+ event[KEYS.CTRL_OR_CMD] &&
+ event.shiftKey &&
+ // KEYS.PERIOD needed for MacOS
+ (event.key === KEYS.CHEVRON_RIGHT || event.key === KEYS.PERIOD)
+ );
+ },
+});
+
+type ChangeFontFamilyData = Partial<
+ Pick<
+ AppState,
+ "openPopup" | "currentItemFontFamily" | "currentHoveredFontFamily"
+ >
+> & {
+ /** cache of selected & editing elements populated on opened popup */
+ cachedElements?: Map<string, ExcalidrawElement>;
+ /** flag to reset all elements to their cached versions */
+ resetAll?: true;
+ /** flag to reset all containers to their cached versions */
+ resetContainers?: true;
+};
+
+export const actionChangeFontFamily = register({
+ name: "changeFontFamily",
+ label: "labels.fontFamily",
+ trackEvent: false,
+ perform: (elements, appState, value, app) => {
+ const { cachedElements, resetAll, resetContainers, ...nextAppState } =
+ value as ChangeFontFamilyData;
+
+ if (resetAll) {
+ const nextElements = changeProperty(
+ elements,
+ appState,
+ (element) => {
+ const cachedElement = cachedElements?.get(element.id);
+ if (cachedElement) {
+ const newElement = newElementWith(element, {
+ ...cachedElement,
+ });
+
+ return newElement;
+ }
+
+ return element;
+ },
+ true,
+ );
+
+ return {
+ elements: nextElements,
+ appState: {
+ ...appState,
+ ...nextAppState,
+ },
+ captureUpdate: CaptureUpdateAction.NEVER,
+ };
+ }
+
+ const { currentItemFontFamily, currentHoveredFontFamily } = value;
+
+ let nextCaptureUpdateAction: CaptureUpdateActionType =
+ CaptureUpdateAction.EVENTUALLY;
+ let nextFontFamily: FontFamilyValues | undefined;
+ let skipOnHoverRender = false;
+
+ if (currentItemFontFamily) {
+ nextFontFamily = currentItemFontFamily;
+ nextCaptureUpdateAction = CaptureUpdateAction.IMMEDIATELY;
+ } else if (currentHoveredFontFamily) {
+ nextFontFamily = currentHoveredFontFamily;
+ nextCaptureUpdateAction = CaptureUpdateAction.EVENTUALLY;
+
+ const selectedTextElements = getSelectedElements(elements, appState, {
+ includeBoundTextElement: true,
+ }).filter((element) => isTextElement(element));
+
+ // skip on hover re-render for more than 200 text elements or for text element with more than 5000 chars combined
+ if (selectedTextElements.length > 200) {
+ skipOnHoverRender = true;
+ } else {
+ let i = 0;
+ let textLengthAccumulator = 0;
+
+ while (
+ i < selectedTextElements.length &&
+ textLengthAccumulator < 5000
+ ) {
+ const textElement = selectedTextElements[i] as ExcalidrawTextElement;
+ textLengthAccumulator += textElement?.originalText.length || 0;
+ i++;
+ }
+
+ if (textLengthAccumulator > 5000) {
+ skipOnHoverRender = true;
+ }
+ }
+ }
+
+ const result = {
+ appState: {
+ ...appState,
+ ...nextAppState,
+ },
+ captureUpdate: nextCaptureUpdateAction,
+ };
+
+ if (nextFontFamily && !skipOnHoverRender) {
+ const elementContainerMapping = new Map<
+ ExcalidrawTextElement,
+ ExcalidrawElement | null
+ >();
+ let uniqueChars = new Set<string>();
+ let skipFontFaceCheck = false;
+
+ const fontsCache = Array.from(Fonts.loadedFontsCache.values());
+ const fontFamily = Object.entries(FONT_FAMILY).find(
+ ([_, value]) => value === nextFontFamily,
+ )?.[0];
+
+ // skip `document.font.check` check on hover, if at least one font family has loaded as it's super slow (could result in slightly different bbox, which is fine)
+ if (
+ currentHoveredFontFamily &&
+ fontFamily &&
+ fontsCache.some((sig) => sig.startsWith(fontFamily))
+ ) {
+ skipFontFaceCheck = true;
+ }
+
+ // following causes re-render so make sure we changed the family
+ // otherwise it could cause unexpected issues, such as preventing opening the popover when in wysiwyg
+ Object.assign(result, {
+ elements: changeProperty(
+ elements,
+ appState,
+ (oldElement) => {
+ if (
+ isTextElement(oldElement) &&
+ (oldElement.fontFamily !== nextFontFamily ||
+ currentItemFontFamily) // force update on selection
+ ) {
+ const newElement: ExcalidrawTextElement = newElementWith(
+ oldElement,
+ {
+ fontFamily: nextFontFamily,
+ lineHeight: getLineHeight(nextFontFamily!),
+ },
+ );
+
+ const cachedContainer =
+ cachedElements?.get(oldElement.containerId || "") || {};
+
+ const container = app.scene.getContainerElement(oldElement);
+
+ if (resetContainers && container && cachedContainer) {
+ // reset the container back to it's cached version
+ mutateElement(container, { ...cachedContainer }, false);
+ }
+
+ if (!skipFontFaceCheck) {
+ uniqueChars = new Set([
+ ...uniqueChars,
+ ...Array.from(newElement.originalText),
+ ]);
+ }
+
+ elementContainerMapping.set(newElement, container);
+
+ return newElement;
+ }
+
+ return oldElement;
+ },
+ true,
+ ),
+ });
+
+ // size is irrelevant, but necessary
+ const fontString = `10px ${getFontFamilyString({
+ fontFamily: nextFontFamily,
+ })}`;
+ const chars = Array.from(uniqueChars.values()).join();
+
+ if (skipFontFaceCheck || window.document.fonts.check(fontString, chars)) {
+ // we either skip the check (have at least one font face loaded) or do the check and find out all the font faces have loaded
+ for (const [element, container] of elementContainerMapping) {
+ // trigger synchronous redraw
+ redrawTextBoundingBox(
+ element,
+ container,
+ app.scene.getNonDeletedElementsMap(),
+ false,
+ );
+ }
+ } else {
+ // otherwise try to load all font faces for the given chars and redraw elements once our font faces loaded
+ window.document.fonts.load(fontString, chars).then((fontFaces) => {
+ for (const [element, container] of elementContainerMapping) {
+ // use latest element state to ensure we don't have closure over an old instance in order to avoid possible race conditions (i.e. font faces load out-of-order while rapidly switching fonts)
+ const latestElement = app.scene.getElement(element.id);
+ const latestContainer = container
+ ? app.scene.getElement(container.id)
+ : null;
+
+ if (latestElement) {
+ // trigger async redraw
+ redrawTextBoundingBox(
+ latestElement as ExcalidrawTextElement,
+ latestContainer,
+ app.scene.getNonDeletedElementsMap(),
+ false,
+ );
+ }
+ }
+
+ // trigger update once we've mutated all the elements, which also updates our cache
+ app.fonts.onLoaded(fontFaces);
+ });
+ }
+ }
+
+ return result;
+ },
+ PanelComponent: ({ elements, appState, app, updateData }) => {
+ const cachedElementsRef = useRef<Map<string, ExcalidrawElement>>(new Map());
+ const prevSelectedFontFamilyRef = useRef<number | null>(null);
+ // relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
+ const [batchedData, setBatchedData] = useState<ChangeFontFamilyData>({});
+ const isUnmounted = useRef(true);
+
+ const selectedFontFamily = useMemo(() => {
+ const getFontFamily = (
+ elementsArray: readonly ExcalidrawElement[],
+ elementsMap: Map<string, ExcalidrawElement>,
+ ) =>
+ getFormValue(
+ elementsArray,
+ appState,
+ (element) => {
+ if (isTextElement(element)) {
+ return element.fontFamily;
+ }
+ const boundTextElement = getBoundTextElement(element, elementsMap);
+ if (boundTextElement) {
+ return boundTextElement.fontFamily;
+ }
+ return null;
+ },
+ (element) =>
+ isTextElement(element) ||
+ getBoundTextElement(element, elementsMap) !== null,
+ (hasSelection) =>
+ hasSelection
+ ? null
+ : appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
+ );
+
+ // popup opened, use cached elements
+ if (
+ batchedData.openPopup === "fontFamily" &&
+ appState.openPopup === "fontFamily"
+ ) {
+ return getFontFamily(
+ Array.from(cachedElementsRef.current?.values() ?? []),
+ cachedElementsRef.current,
+ );
+ }
+
+ // popup closed, use all elements
+ if (!batchedData.openPopup && appState.openPopup !== "fontFamily") {
+ return getFontFamily(elements, app.scene.getNonDeletedElementsMap());
+ }
+
+ // popup props are not in sync, hence we are in the middle of an update, so keeping the previous value we've had
+ return prevSelectedFontFamilyRef.current;
+ }, [batchedData.openPopup, appState, elements, app.scene]);
+
+ useEffect(() => {
+ prevSelectedFontFamilyRef.current = selectedFontFamily;
+ }, [selectedFontFamily]);
+
+ useEffect(() => {
+ if (Object.keys(batchedData).length) {
+ updateData(batchedData);
+ // reset the data after we've used the data
+ setBatchedData({});
+ }
+ // call update only on internal state changes
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [batchedData]);
+
+ useEffect(() => {
+ isUnmounted.current = false;
+
+ return () => {
+ isUnmounted.current = true;
+ };
+ }, []);
+
+ return (
+ <fieldset>
+ <legend>{t("labels.fontFamily")}</legend>
+ <FontPicker
+ isOpened={appState.openPopup === "fontFamily"}
+ selectedFontFamily={selectedFontFamily}
+ hoveredFontFamily={appState.currentHoveredFontFamily}
+ onSelect={(fontFamily) => {
+ setBatchedData({
+ openPopup: null,
+ currentHoveredFontFamily: null,
+ currentItemFontFamily: fontFamily,
+ });
+
+ // defensive clear so immediate close won't abuse the cached elements
+ cachedElementsRef.current.clear();
+ }}
+ onHover={(fontFamily) => {
+ setBatchedData({
+ currentHoveredFontFamily: fontFamily,
+ cachedElements: new Map(cachedElementsRef.current),
+ resetContainers: true,
+ });
+ }}
+ onLeave={() => {
+ setBatchedData({
+ currentHoveredFontFamily: null,
+ cachedElements: new Map(cachedElementsRef.current),
+ resetAll: true,
+ });
+ }}
+ onPopupChange={(open) => {
+ if (open) {
+ // open, populate the cache from scratch
+ cachedElementsRef.current.clear();
+
+ const { editingTextElement } = appState;
+
+ // still check type to be safe
+ if (editingTextElement?.type === "text") {
+ // retrieve the latest version from the scene, as `editingTextElement` isn't mutated
+ const latesteditingTextElement = app.scene.getElement(
+ editingTextElement.id,
+ );
+
+ // inside the wysiwyg editor
+ cachedElementsRef.current.set(
+ editingTextElement.id,
+ newElementWith(
+ latesteditingTextElement || editingTextElement,
+ {},
+ true,
+ ),
+ );
+ } else {
+ const selectedElements = getSelectedElements(
+ elements,
+ appState,
+ {
+ includeBoundTextElement: true,
+ },
+ );
+
+ for (const element of selectedElements) {
+ cachedElementsRef.current.set(
+ element.id,
+ newElementWith(element, {}, true),
+ );
+ }
+ }
+
+ setBatchedData({
+ openPopup: "fontFamily",
+ });
+ } else {
+ // close, use the cache and clear it afterwards
+ const data = {
+ openPopup: null,
+ currentHoveredFontFamily: null,
+ cachedElements: new Map(cachedElementsRef.current),
+ resetAll: true,
+ } as ChangeFontFamilyData;
+
+ if (isUnmounted.current) {
+ // in case the component was unmounted by the parent, trigger the update directly
+ updateData({ ...batchedData, ...data });
+ } else {
+ setBatchedData(data);
+ }
+
+ cachedElementsRef.current.clear();
+ }
+ }}
+ />
+ </fieldset>
+ );
+ },
+});
+
+export const actionChangeTextAlign = register({
+ name: "changeTextAlign",
+ label: "Change text alignment",
+ trackEvent: false,
+ perform: (elements, appState, value, app) => {
+ return {
+ elements: changeProperty(
+ elements,
+ appState,
+ (oldElement) => {
+ if (isTextElement(oldElement)) {
+ const newElement: ExcalidrawTextElement = newElementWith(
+ oldElement,
+ { textAlign: value },
+ );
+ redrawTextBoundingBox(
+ newElement,
+ app.scene.getContainerElement(oldElement),
+ app.scene.getNonDeletedElementsMap(),
+ );
+ return newElement;
+ }
+
+ return oldElement;
+ },
+ true,
+ ),
+ appState: {
+ ...appState,
+ currentItemTextAlign: value,
+ },
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ PanelComponent: ({ elements, appState, updateData, app }) => {
+ const elementsMap = app.scene.getNonDeletedElementsMap();
+ return (
+ <fieldset>
+ <legend>{t("labels.textAlign")}</legend>
+ <ButtonIconSelect<TextAlign | false>
+ group="text-align"
+ options={[
+ {
+ value: "left",
+ text: t("labels.left"),
+ icon: TextAlignLeftIcon,
+ testId: "align-left",
+ },
+ {
+ value: "center",
+ text: t("labels.center"),
+ icon: TextAlignCenterIcon,
+ testId: "align-horizontal-center",
+ },
+ {
+ value: "right",
+ text: t("labels.right"),
+ icon: TextAlignRightIcon,
+ testId: "align-right",
+ },
+ ]}
+ value={getFormValue(
+ elements,
+ appState,
+ (element) => {
+ if (isTextElement(element)) {
+ return element.textAlign;
+ }
+ const boundTextElement = getBoundTextElement(
+ element,
+ elementsMap,
+ );
+ if (boundTextElement) {
+ return boundTextElement.textAlign;
+ }
+ return null;
+ },
+ (element) =>
+ isTextElement(element) ||
+ getBoundTextElement(element, elementsMap) !== null,
+ (hasSelection) =>
+ hasSelection ? null : appState.currentItemTextAlign,
+ )}
+ onChange={(value) => updateData(value)}
+ />
+ </fieldset>
+ );
+ },
+});
+
+export const actionChangeVerticalAlign = register({
+ name: "changeVerticalAlign",
+ label: "Change vertical alignment",
+ trackEvent: { category: "element" },
+ perform: (elements, appState, value, app) => {
+ return {
+ elements: changeProperty(
+ elements,
+ appState,
+ (oldElement) => {
+ if (isTextElement(oldElement)) {
+ const newElement: ExcalidrawTextElement = newElementWith(
+ oldElement,
+ { verticalAlign: value },
+ );
+
+ redrawTextBoundingBox(
+ newElement,
+ app.scene.getContainerElement(oldElement),
+ app.scene.getNonDeletedElementsMap(),
+ );
+ return newElement;
+ }
+
+ return oldElement;
+ },
+ true,
+ ),
+ appState: {
+ ...appState,
+ },
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ PanelComponent: ({ elements, appState, updateData, app }) => {
+ return (
+ <fieldset>
+ <ButtonIconSelect<VerticalAlign | false>
+ group="text-align"
+ options={[
+ {
+ value: VERTICAL_ALIGN.TOP,
+ text: t("labels.alignTop"),
+ icon: <TextAlignTopIcon theme={appState.theme} />,
+ testId: "align-top",
+ },
+ {
+ value: VERTICAL_ALIGN.MIDDLE,
+ text: t("labels.centerVertically"),
+ icon: <TextAlignMiddleIcon theme={appState.theme} />,
+ testId: "align-middle",
+ },
+ {
+ value: VERTICAL_ALIGN.BOTTOM,
+ text: t("labels.alignBottom"),
+ icon: <TextAlignBottomIcon theme={appState.theme} />,
+ testId: "align-bottom",
+ },
+ ]}
+ value={getFormValue(
+ elements,
+ appState,
+ (element) => {
+ if (isTextElement(element) && element.containerId) {
+ return element.verticalAlign;
+ }
+ const boundTextElement = getBoundTextElement(
+ element,
+ app.scene.getNonDeletedElementsMap(),
+ );
+ if (boundTextElement) {
+ return boundTextElement.verticalAlign;
+ }
+ return null;
+ },
+ (element) =>
+ isTextElement(element) ||
+ getBoundTextElement(
+ element,
+ app.scene.getNonDeletedElementsMap(),
+ ) !== null,
+ (hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
+ )}
+ onChange={(value) => updateData(value)}
+ />
+ </fieldset>
+ );
+ },
+});
+
+export const actionChangeRoundness = register({
+ name: "changeRoundness",
+ label: "Change edge roundness",
+ trackEvent: false,
+ perform: (elements, appState, value) => {
+ return {
+ elements: changeProperty(elements, appState, (el) => {
+ if (isElbowArrow(el)) {
+ return el;
+ }
+
+ return newElementWith(el, {
+ roundness:
+ value === "round"
+ ? {
+ type: isUsingAdaptiveRadius(el.type)
+ ? ROUNDNESS.ADAPTIVE_RADIUS
+ : ROUNDNESS.PROPORTIONAL_RADIUS,
+ }
+ : null,
+ });
+ }),
+ appState: {
+ ...appState,
+ currentItemRoundness: value,
+ },
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ PanelComponent: ({ elements, appState, updateData }) => {
+ const targetElements = getTargetElements(
+ getNonDeletedElements(elements),
+ appState,
+ );
+
+ const hasLegacyRoundness = targetElements.some(
+ (el) => el.roundness?.type === ROUNDNESS.LEGACY,
+ );
+
+ return (
+ <fieldset>
+ <legend>{t("labels.edges")}</legend>
+ <ButtonIconSelect
+ group="edges"
+ options={[
+ {
+ value: "sharp",
+ text: t("labels.sharp"),
+ icon: EdgeSharpIcon,
+ },
+ {
+ value: "round",
+ text: t("labels.round"),
+ icon: EdgeRoundIcon,
+ },
+ ]}
+ value={getFormValue(
+ elements,
+ appState,
+ (element) =>
+ hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
+ (element) =>
+ !isArrowElement(element) && element.hasOwnProperty("roundness"),
+ (hasSelection) =>
+ hasSelection ? null : appState.currentItemRoundness,
+ )}
+ onChange={(value) => updateData(value)}
+ />
+ </fieldset>
+ );
+ },
+});
+
+const getArrowheadOptions = (flip: boolean) => {
+ return [
+ {
+ value: null,
+ text: t("labels.arrowhead_none"),
+ keyBinding: "q",
+ icon: ArrowheadNoneIcon,
+ },
+ {
+ value: "arrow",
+ text: t("labels.arrowhead_arrow"),
+ keyBinding: "w",
+ icon: <ArrowheadArrowIcon flip={flip} />,
+ },
+ {
+ value: "triangle",
+ text: t("labels.arrowhead_triangle"),
+ icon: <ArrowheadTriangleIcon flip={flip} />,
+ keyBinding: "e",
+ },
+ {
+ value: "triangle_outline",
+ text: t("labels.arrowhead_triangle_outline"),
+ icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
+ keyBinding: "r",
+ },
+ {
+ value: "circle",
+ text: t("labels.arrowhead_circle"),
+ keyBinding: "a",
+ icon: <ArrowheadCircleIcon flip={flip} />,
+ },
+ {
+ value: "circle_outline",
+ text: t("labels.arrowhead_circle_outline"),
+ keyBinding: "s",
+ icon: <ArrowheadCircleOutlineIcon flip={flip} />,
+ },
+ {
+ value: "diamond",
+ text: t("labels.arrowhead_diamond"),
+ icon: <ArrowheadDiamondIcon flip={flip} />,
+ keyBinding: "d",
+ },
+ {
+ value: "diamond_outline",
+ text: t("labels.arrowhead_diamond_outline"),
+ icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
+ keyBinding: "f",
+ },
+ {
+ value: "bar",
+ text: t("labels.arrowhead_bar"),
+ keyBinding: "z",
+ icon: <ArrowheadBarIcon flip={flip} />,
+ },
+ {
+ value: "crowfoot_one",
+ text: t("labels.arrowhead_crowfoot_one"),
+ icon: <ArrowheadCrowfootOneIcon flip={flip} />,
+ keyBinding: "c",
+ },
+ {
+ value: "crowfoot_many",
+ text: t("labels.arrowhead_crowfoot_many"),
+ icon: <ArrowheadCrowfootIcon flip={flip} />,
+ keyBinding: "x",
+ },
+ {
+ value: "crowfoot_one_or_many",
+ text: t("labels.arrowhead_crowfoot_one_or_many"),
+ icon: <ArrowheadCrowfootOneOrManyIcon flip={flip} />,
+ keyBinding: "v",
+ },
+ ] as const;
+};
+
+export const actionChangeArrowhead = register({
+ name: "changeArrowhead",
+ label: "Change arrowheads",
+ trackEvent: false,
+ perform: (
+ elements,
+ appState,
+ value: { position: "start" | "end"; type: Arrowhead },
+ ) => {
+ return {
+ elements: changeProperty(elements, appState, (el) => {
+ if (isLinearElement(el)) {
+ const { position, type } = value;
+
+ if (position === "start") {
+ const element: ExcalidrawLinearElement = newElementWith(el, {
+ startArrowhead: type,
+ });
+ return element;
+ } else if (position === "end") {
+ const element: ExcalidrawLinearElement = newElementWith(el, {
+ endArrowhead: type,
+ });
+ return element;
+ }
+ }
+
+ return el;
+ }),
+ appState: {
+ ...appState,
+ [value.position === "start"
+ ? "currentItemStartArrowhead"
+ : "currentItemEndArrowhead"]: value.type,
+ },
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ PanelComponent: ({ elements, appState, updateData }) => {
+ const isRTL = getLanguage().rtl;
+
+ return (
+ <fieldset>
+ <legend>{t("labels.arrowheads")}</legend>
+ <div className="iconSelectList buttonList">
+ <IconPicker
+ label="arrowhead_start"
+ options={getArrowheadOptions(!isRTL)}
+ value={getFormValue<Arrowhead | null>(
+ elements,
+ appState,
+ (element) =>
+ isLinearElement(element) && canHaveArrowheads(element.type)
+ ? element.startArrowhead
+ : appState.currentItemStartArrowhead,
+ true,
+ appState.currentItemStartArrowhead,
+ )}
+ onChange={(value) => updateData({ position: "start", type: value })}
+ numberOfOptionsToAlwaysShow={4}
+ />
+ <IconPicker
+ label="arrowhead_end"
+ group="arrowheads"
+ options={getArrowheadOptions(!!isRTL)}
+ value={getFormValue<Arrowhead | null>(
+ elements,
+ appState,
+ (element) =>
+ isLinearElement(element) && canHaveArrowheads(element.type)
+ ? element.endArrowhead
+ : appState.currentItemEndArrowhead,
+ true,
+ appState.currentItemEndArrowhead,
+ )}
+ onChange={(value) => updateData({ position: "end", type: value })}
+ numberOfOptionsToAlwaysShow={4}
+ />
+ </div>
+ </fieldset>
+ );
+ },
+});
+
+export const actionChangeArrowType = register({
+ name: "changeArrowType",
+ label: "Change arrow types",
+ trackEvent: false,
+ perform: (elements, appState, value, app) => {
+ const newElements = changeProperty(elements, appState, (el) => {
+ if (!isArrowElement(el)) {
+ return el;
+ }
+ const newElement = newElementWith(el, {
+ roundness:
+ value === ARROW_TYPE.round
+ ? {
+ type: ROUNDNESS.PROPORTIONAL_RADIUS,
+ }
+ : null,
+ elbowed: value === ARROW_TYPE.elbow,
+ points:
+ value === ARROW_TYPE.elbow || el.elbowed
+ ? [el.points[0], el.points[el.points.length - 1]]
+ : el.points,
+ });
+
+ if (isElbowArrow(newElement)) {
+ const elementsMap = app.scene.getNonDeletedElementsMap();
+
+ app.dismissLinearEditor();
+
+ const startGlobalPoint =
+ LinearElementEditor.getPointAtIndexGlobalCoordinates(
+ newElement,
+ 0,
+ elementsMap,
+ );
+ const endGlobalPoint =
+ LinearElementEditor.getPointAtIndexGlobalCoordinates(
+ newElement,
+ -1,
+ elementsMap,
+ );
+ const startHoveredElement =
+ !newElement.startBinding &&
+ getHoveredElementForBinding(
+ tupleToCoors(startGlobalPoint),
+ elements,
+ elementsMap,
+ appState.zoom,
+ false,
+ true,
+ );
+ const endHoveredElement =
+ !newElement.endBinding &&
+ getHoveredElementForBinding(
+ tupleToCoors(endGlobalPoint),
+ elements,
+ elementsMap,
+ appState.zoom,
+ false,
+ true,
+ );
+ const startElement = startHoveredElement
+ ? startHoveredElement
+ : newElement.startBinding &&
+ (elementsMap.get(
+ newElement.startBinding.elementId,
+ ) as ExcalidrawBindableElement);
+ const endElement = endHoveredElement
+ ? endHoveredElement
+ : newElement.endBinding &&
+ (elementsMap.get(
+ newElement.endBinding.elementId,
+ ) as ExcalidrawBindableElement);
+
+ const finalStartPoint = startHoveredElement
+ ? bindPointToSnapToElementOutline(
+ newElement,
+ startHoveredElement,
+ "start",
+ )
+ : startGlobalPoint;
+ const finalEndPoint = endHoveredElement
+ ? bindPointToSnapToElementOutline(
+ newElement,
+ endHoveredElement,
+ "end",
+ )
+ : endGlobalPoint;
+
+ startHoveredElement &&
+ bindLinearElement(
+ newElement,
+ startHoveredElement,
+ "start",
+ elementsMap,
+ );
+ endHoveredElement &&
+ bindLinearElement(newElement, endHoveredElement, "end", elementsMap);
+
+ mutateElement(newElement, {
+ points: [finalStartPoint, finalEndPoint].map(
+ (p): LocalPoint =>
+ pointFrom(p[0] - newElement.x, p[1] - newElement.y),
+ ),
+ ...(startElement && newElement.startBinding
+ ? {
+ startBinding: {
+ // @ts-ignore TS cannot discern check above
+ ...newElement.startBinding!,
+ ...calculateFixedPointForElbowArrowBinding(
+ newElement,
+ startElement,
+ "start",
+ elementsMap,
+ ),
+ },
+ }
+ : {}),
+ ...(endElement && newElement.endBinding
+ ? {
+ endBinding: {
+ // @ts-ignore TS cannot discern check above
+ ...newElement.endBinding,
+ ...calculateFixedPointForElbowArrowBinding(
+ newElement,
+ endElement,
+ "end",
+ elementsMap,
+ ),
+ },
+ }
+ : {}),
+ });
+
+ LinearElementEditor.updateEditorMidPointsCache(
+ newElement,
+ elementsMap,
+ app.state,
+ );
+ }
+
+ return newElement;
+ });
+
+ const newState = {
+ ...appState,
+ currentItemArrowType: value,
+ };
+
+ // Change the arrow type and update any other state settings for
+ // the arrow.
+ const selectedId = appState.selectedLinearElement?.elementId;
+ if (selectedId) {
+ const selected = newElements.find((el) => el.id === selectedId);
+ if (selected) {
+ newState.selectedLinearElement = new LinearElementEditor(
+ selected as ExcalidrawLinearElement,
+ );
+ }
+ }
+
+ return {
+ elements: newElements,
+ appState: newState,
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ PanelComponent: ({ elements, appState, updateData }) => {
+ return (
+ <fieldset>
+ <legend>{t("labels.arrowtypes")}</legend>
+ <ButtonIconSelect
+ group="arrowtypes"
+ options={[
+ {
+ value: ARROW_TYPE.sharp,
+ text: t("labels.arrowtype_sharp"),
+ icon: sharpArrowIcon,
+ testId: "sharp-arrow",
+ },
+ {
+ value: ARROW_TYPE.round,
+ text: t("labels.arrowtype_round"),
+ icon: roundArrowIcon,
+ testId: "round-arrow",
+ },
+ {
+ value: ARROW_TYPE.elbow,
+ text: t("labels.arrowtype_elbowed"),
+ icon: elbowArrowIcon,
+ testId: "elbow-arrow",
+ },
+ ]}
+ value={getFormValue(
+ elements,
+ appState,
+ (element) => {
+ if (isArrowElement(element)) {
+ return element.elbowed
+ ? ARROW_TYPE.elbow
+ : element.roundness
+ ? ARROW_TYPE.round
+ : ARROW_TYPE.sharp;
+ }
+
+ return null;
+ },
+ (element) => isArrowElement(element),
+ (hasSelection) =>
+ hasSelection ? null : appState.currentItemArrowType,
+ )}
+ onChange={(value) => updateData(value)}
+ />
+ </fieldset>
+ );
+ },
+});
diff --git a/packages/excalidraw/actions/actionSelectAll.ts b/packages/excalidraw/actions/actionSelectAll.ts
new file mode 100644
index 0000000..61c2780
--- /dev/null
+++ b/packages/excalidraw/actions/actionSelectAll.ts
@@ -0,0 +1,57 @@
+import { KEYS } from "../keys";
+import { register } from "./register";
+import { selectGroupsForSelectedElements } from "../groups";
+import { getNonDeletedElements, isTextElement } from "../element";
+import type { ExcalidrawElement } from "../element/types";
+import { isLinearElement } from "../element/typeChecks";
+import { LinearElementEditor } from "../element/linearElementEditor";
+import { selectAllIcon } from "../components/icons";
+import { CaptureUpdateAction } from "../store";
+
+export const actionSelectAll = register({
+ name: "selectAll",
+ label: "labels.selectAll",
+ icon: selectAllIcon,
+ trackEvent: { category: "canvas" },
+ viewMode: false,
+ perform: (elements, appState, value, app) => {
+ if (appState.editingLinearElement) {
+ return false;
+ }
+
+ const selectedElementIds = elements
+ .filter(
+ (element) =>
+ !element.isDeleted &&
+ !(isTextElement(element) && element.containerId) &&
+ !element.locked,
+ )
+ .reduce((map: Record<ExcalidrawElement["id"], true>, element) => {
+ map[element.id] = true;
+ return map;
+ }, {});
+
+ return {
+ appState: {
+ ...appState,
+ ...selectGroupsForSelectedElements(
+ {
+ editingGroupId: null,
+ selectedElementIds,
+ },
+ getNonDeletedElements(elements),
+ appState,
+ app,
+ ),
+ selectedLinearElement:
+ // single linear element selected
+ Object.keys(selectedElementIds).length === 1 &&
+ isLinearElement(elements[0])
+ ? new LinearElementEditor(elements[0])
+ : null,
+ },
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A,
+});
diff --git a/packages/excalidraw/actions/actionStyles.ts b/packages/excalidraw/actions/actionStyles.ts
new file mode 100644
index 0000000..01f8040
--- /dev/null
+++ b/packages/excalidraw/actions/actionStyles.ts
@@ -0,0 +1,167 @@
+import {
+ isTextElement,
+ isExcalidrawElement,
+ redrawTextBoundingBox,
+} from "../element";
+import { CODES, KEYS } from "../keys";
+import { t } from "../i18n";
+import { register } from "./register";
+import { newElementWith } from "../element/mutateElement";
+import {
+ DEFAULT_FONT_SIZE,
+ DEFAULT_FONT_FAMILY,
+ DEFAULT_TEXT_ALIGN,
+} from "../constants";
+import { getBoundTextElement } from "../element/textElement";
+import {
+ hasBoundTextElement,
+ canApplyRoundnessTypeToElement,
+ getDefaultRoundnessTypeForElement,
+ isFrameLikeElement,
+ isArrowElement,
+} from "../element/typeChecks";
+import { getSelectedElements } from "../scene";
+import type { ExcalidrawTextElement } from "../element/types";
+import { paintIcon } from "../components/icons";
+import { CaptureUpdateAction } from "../store";
+import { getLineHeight } from "../fonts";
+
+// `copiedStyles` is exported only for tests.
+export let copiedStyles: string = "{}";
+
+export const actionCopyStyles = register({
+ name: "copyStyles",
+ label: "labels.copyStyles",
+ icon: paintIcon,
+ trackEvent: { category: "element" },
+ perform: (elements, appState, formData, app) => {
+ const elementsCopied = [];
+ const element = elements.find((el) => appState.selectedElementIds[el.id]);
+ elementsCopied.push(element);
+ if (element && hasBoundTextElement(element)) {
+ const boundTextElement = getBoundTextElement(
+ element,
+ app.scene.getNonDeletedElementsMap(),
+ );
+ elementsCopied.push(boundTextElement);
+ }
+ if (element) {
+ copiedStyles = JSON.stringify(elementsCopied);
+ }
+ return {
+ appState: {
+ ...appState,
+ toast: { message: t("toast.copyStyles") },
+ },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ },
+ keyTest: (event) =>
+ event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
+});
+
+export const actionPasteStyles = register({
+ name: "pasteStyles",
+ label: "labels.pasteStyles",
+ icon: paintIcon,
+ trackEvent: { category: "element" },
+ perform: (elements, appState, formData, app) => {
+ const elementsCopied = JSON.parse(copiedStyles);
+ const pastedElement = elementsCopied[0];
+ const boundTextElement = elementsCopied[1];
+ if (!isExcalidrawElement(pastedElement)) {
+ return { elements, captureUpdate: CaptureUpdateAction.EVENTUALLY };
+ }
+
+ const selectedElements = getSelectedElements(elements, appState, {
+ includeBoundTextElement: true,
+ });
+ const selectedElementIds = selectedElements.map((element) => element.id);
+ return {
+ elements: elements.map((element) => {
+ if (selectedElementIds.includes(element.id)) {
+ let elementStylesToCopyFrom = pastedElement;
+ if (isTextElement(element) && element.containerId) {
+ elementStylesToCopyFrom = boundTextElement;
+ }
+ if (!elementStylesToCopyFrom) {
+ return element;
+ }
+ let newElement = newElementWith(element, {
+ backgroundColor: elementStylesToCopyFrom?.backgroundColor,
+ strokeWidth: elementStylesToCopyFrom?.strokeWidth,
+ strokeColor: elementStylesToCopyFrom?.strokeColor,
+ strokeStyle: elementStylesToCopyFrom?.strokeStyle,
+ fillStyle: elementStylesToCopyFrom?.fillStyle,
+ opacity: elementStylesToCopyFrom?.opacity,
+ roughness: elementStylesToCopyFrom?.roughness,
+ roundness: elementStylesToCopyFrom.roundness
+ ? canApplyRoundnessTypeToElement(
+ elementStylesToCopyFrom.roundness.type,
+ element,
+ )
+ ? elementStylesToCopyFrom.roundness
+ : getDefaultRoundnessTypeForElement(element)
+ : null,
+ });
+
+ if (isTextElement(newElement)) {
+ const fontSize =
+ (elementStylesToCopyFrom as ExcalidrawTextElement).fontSize ||
+ DEFAULT_FONT_SIZE;
+ const fontFamily =
+ (elementStylesToCopyFrom as ExcalidrawTextElement).fontFamily ||
+ DEFAULT_FONT_FAMILY;
+ newElement = newElementWith(newElement, {
+ fontSize,
+ fontFamily,
+ textAlign:
+ (elementStylesToCopyFrom as ExcalidrawTextElement).textAlign ||
+ DEFAULT_TEXT_ALIGN,
+ lineHeight:
+ (elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight ||
+ getLineHeight(fontFamily),
+ });
+ let container = null;
+ if (newElement.containerId) {
+ container =
+ selectedElements.find(
+ (element) =>
+ isTextElement(newElement) &&
+ element.id === newElement.containerId,
+ ) || null;
+ }
+ redrawTextBoundingBox(
+ newElement,
+ container,
+ app.scene.getNonDeletedElementsMap(),
+ );
+ }
+
+ if (
+ newElement.type === "arrow" &&
+ isArrowElement(elementStylesToCopyFrom)
+ ) {
+ newElement = newElementWith(newElement, {
+ startArrowhead: elementStylesToCopyFrom.startArrowhead,
+ endArrowhead: elementStylesToCopyFrom.endArrowhead,
+ });
+ }
+
+ if (isFrameLikeElement(element)) {
+ newElement = newElementWith(newElement, {
+ roundness: null,
+ backgroundColor: "transparent",
+ });
+ }
+
+ return newElement;
+ }
+ return element;
+ }),
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ keyTest: (event) =>
+ event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
+});
diff --git a/packages/excalidraw/actions/actionTextAutoResize.ts b/packages/excalidraw/actions/actionTextAutoResize.ts
new file mode 100644
index 0000000..29f54c9
--- /dev/null
+++ b/packages/excalidraw/actions/actionTextAutoResize.ts
@@ -0,0 +1,48 @@
+import { isTextElement } from "../element";
+import { newElementWith } from "../element/mutateElement";
+import { measureText } from "../element/textMeasurements";
+import { getSelectedElements } from "../scene";
+import { CaptureUpdateAction } from "../store";
+import type { AppClassProperties } from "../types";
+import { getFontString } from "../utils";
+import { register } from "./register";
+
+export const actionTextAutoResize = register({
+ name: "autoResize",
+ label: "labels.autoResize",
+ icon: null,
+ trackEvent: { category: "element" },
+ predicate: (elements, appState, _: unknown, app: AppClassProperties) => {
+ const selectedElements = getSelectedElements(elements, appState);
+ return (
+ selectedElements.length === 1 &&
+ isTextElement(selectedElements[0]) &&
+ !selectedElements[0].autoResize
+ );
+ },
+ perform: (elements, appState, _, app) => {
+ const selectedElements = getSelectedElements(elements, appState);
+
+ return {
+ appState,
+ elements: elements.map((element) => {
+ if (element.id === selectedElements[0].id && isTextElement(element)) {
+ const metrics = measureText(
+ element.originalText,
+ getFontString(element),
+ element.lineHeight,
+ );
+
+ return newElementWith(element, {
+ autoResize: true,
+ width: metrics.width,
+ height: metrics.height,
+ text: element.originalText,
+ });
+ }
+ return element;
+ }),
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+});
diff --git a/packages/excalidraw/actions/actionToggleGridMode.tsx b/packages/excalidraw/actions/actionToggleGridMode.tsx
new file mode 100644
index 0000000..69d6570
--- /dev/null
+++ b/packages/excalidraw/actions/actionToggleGridMode.tsx
@@ -0,0 +1,32 @@
+import { CODES, KEYS } from "../keys";
+import { register } from "./register";
+import type { AppState } from "../types";
+import { gridIcon } from "../components/icons";
+import { CaptureUpdateAction } from "../store";
+
+export const actionToggleGridMode = register({
+ name: "gridMode",
+ icon: gridIcon,
+ keywords: ["snap"],
+ label: "labels.toggleGrid",
+ viewMode: true,
+ trackEvent: {
+ category: "canvas",
+ predicate: (appState) => appState.gridModeEnabled,
+ },
+ perform(elements, appState) {
+ return {
+ appState: {
+ ...appState,
+ gridModeEnabled: !this.checked!(appState),
+ objectsSnapModeEnabled: false,
+ },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ },
+ checked: (appState: AppState) => appState.gridModeEnabled,
+ predicate: (element, appState, props) => {
+ return props.gridModeEnabled === undefined;
+ },
+ keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
+});
diff --git a/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx b/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx
new file mode 100644
index 0000000..1ae3cbe
--- /dev/null
+++ b/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx
@@ -0,0 +1,31 @@
+import { magnetIcon } from "../components/icons";
+import { CODES, KEYS } from "../keys";
+import { CaptureUpdateAction } from "../store";
+import { register } from "./register";
+
+export const actionToggleObjectsSnapMode = register({
+ name: "objectsSnapMode",
+ label: "buttons.objectsSnapMode",
+ icon: magnetIcon,
+ viewMode: false,
+ trackEvent: {
+ category: "canvas",
+ predicate: (appState) => !appState.objectsSnapModeEnabled,
+ },
+ perform(elements, appState) {
+ return {
+ appState: {
+ ...appState,
+ objectsSnapModeEnabled: !this.checked!(appState),
+ gridModeEnabled: false,
+ },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ },
+ checked: (appState) => appState.objectsSnapModeEnabled,
+ predicate: (elements, appState, appProps) => {
+ return typeof appProps.objectsSnapModeEnabled === "undefined";
+ },
+ keyTest: (event) =>
+ !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.S,
+});
diff --git a/packages/excalidraw/actions/actionToggleSearchMenu.ts b/packages/excalidraw/actions/actionToggleSearchMenu.ts
new file mode 100644
index 0000000..9261e79
--- /dev/null
+++ b/packages/excalidraw/actions/actionToggleSearchMenu.ts
@@ -0,0 +1,55 @@
+import { KEYS } from "../keys";
+import { register } from "./register";
+import type { AppState } from "../types";
+import { searchIcon } from "../components/icons";
+import { CaptureUpdateAction } from "../store";
+import { CANVAS_SEARCH_TAB, CLASSES, DEFAULT_SIDEBAR } from "../constants";
+
+export const actionToggleSearchMenu = register({
+ name: "searchMenu",
+ icon: searchIcon,
+ keywords: ["search", "find"],
+ label: "search.title",
+ viewMode: true,
+ trackEvent: {
+ category: "search_menu",
+ action: "toggle",
+ predicate: (appState) => appState.gridModeEnabled,
+ },
+ perform(elements, appState, _, app) {
+ if (
+ appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
+ appState.openSidebar.tab === CANVAS_SEARCH_TAB
+ ) {
+ const searchInput =
+ app.excalidrawContainerValue.container?.querySelector<HTMLInputElement>(
+ `.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`,
+ );
+
+ if (searchInput?.matches(":focus")) {
+ return {
+ appState: { ...appState, openSidebar: null },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ }
+
+ searchInput?.focus();
+ searchInput?.select();
+ return false;
+ }
+
+ return {
+ appState: {
+ ...appState,
+ openSidebar: { name: DEFAULT_SIDEBAR.name, tab: CANVAS_SEARCH_TAB },
+ openDialog: null,
+ },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ },
+ checked: (appState: AppState) => appState.gridModeEnabled,
+ predicate: (element, appState, props) => {
+ return props.gridModeEnabled === undefined;
+ },
+ keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F,
+});
diff --git a/packages/excalidraw/actions/actionToggleStats.tsx b/packages/excalidraw/actions/actionToggleStats.tsx
new file mode 100644
index 0000000..e28d099
--- /dev/null
+++ b/packages/excalidraw/actions/actionToggleStats.tsx
@@ -0,0 +1,26 @@
+import { register } from "./register";
+import { CODES, KEYS } from "../keys";
+import { abacusIcon } from "../components/icons";
+import { CaptureUpdateAction } from "../store";
+
+export const actionToggleStats = register({
+ name: "stats",
+ label: "stats.fullTitle",
+ icon: abacusIcon,
+ paletteName: "Toggle stats",
+ viewMode: true,
+ trackEvent: { category: "menu" },
+ keywords: ["edit", "attributes", "customize"],
+ perform(elements, appState) {
+ return {
+ appState: {
+ ...appState,
+ stats: { ...appState.stats, open: !this.checked!(appState) },
+ },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ },
+ checked: (appState) => appState.stats.open,
+ keyTest: (event) =>
+ !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
+});
diff --git a/packages/excalidraw/actions/actionToggleViewMode.tsx b/packages/excalidraw/actions/actionToggleViewMode.tsx
new file mode 100644
index 0000000..cae3b09
--- /dev/null
+++ b/packages/excalidraw/actions/actionToggleViewMode.tsx
@@ -0,0 +1,31 @@
+import { eyeIcon } from "../components/icons";
+import { CODES, KEYS } from "../keys";
+import { CaptureUpdateAction } from "../store";
+import { register } from "./register";
+
+export const actionToggleViewMode = register({
+ name: "viewMode",
+ label: "labels.viewMode",
+ paletteName: "Toggle view mode",
+ icon: eyeIcon,
+ viewMode: true,
+ trackEvent: {
+ category: "canvas",
+ predicate: (appState) => !appState.viewModeEnabled,
+ },
+ perform(elements, appState) {
+ return {
+ appState: {
+ ...appState,
+ viewModeEnabled: !this.checked!(appState),
+ },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ },
+ checked: (appState) => appState.viewModeEnabled,
+ predicate: (elements, appState, appProps) => {
+ return typeof appProps.viewModeEnabled === "undefined";
+ },
+ keyTest: (event) =>
+ !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R,
+});
diff --git a/packages/excalidraw/actions/actionToggleZenMode.tsx b/packages/excalidraw/actions/actionToggleZenMode.tsx
new file mode 100644
index 0000000..31afd3f
--- /dev/null
+++ b/packages/excalidraw/actions/actionToggleZenMode.tsx
@@ -0,0 +1,31 @@
+import { coffeeIcon } from "../components/icons";
+import { CODES, KEYS } from "../keys";
+import { CaptureUpdateAction } from "../store";
+import { register } from "./register";
+
+export const actionToggleZenMode = register({
+ name: "zenMode",
+ label: "buttons.zenMode",
+ icon: coffeeIcon,
+ paletteName: "Toggle zen mode",
+ viewMode: true,
+ trackEvent: {
+ category: "canvas",
+ predicate: (appState) => !appState.zenModeEnabled,
+ },
+ perform(elements, appState) {
+ return {
+ appState: {
+ ...appState,
+ zenModeEnabled: !this.checked!(appState),
+ },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ },
+ checked: (appState) => appState.zenModeEnabled,
+ predicate: (elements, appState, appProps) => {
+ return typeof appProps.zenModeEnabled === "undefined";
+ },
+ keyTest: (event) =>
+ !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,
+});
diff --git a/packages/excalidraw/actions/actionZindex.tsx b/packages/excalidraw/actions/actionZindex.tsx
new file mode 100644
index 0000000..4097b2d
--- /dev/null
+++ b/packages/excalidraw/actions/actionZindex.tsx
@@ -0,0 +1,153 @@
+import {
+ moveOneLeft,
+ moveOneRight,
+ moveAllLeft,
+ moveAllRight,
+} from "../zindex";
+import { KEYS, CODES } from "../keys";
+import { t } from "../i18n";
+import { getShortcutKey } from "../utils";
+import { register } from "./register";
+import {
+ BringForwardIcon,
+ BringToFrontIcon,
+ SendBackwardIcon,
+ SendToBackIcon,
+} from "../components/icons";
+import { isDarwin } from "../constants";
+import { CaptureUpdateAction } from "../store";
+
+export const actionSendBackward = register({
+ name: "sendBackward",
+ label: "labels.sendBackward",
+ keywords: ["move down", "zindex", "layer"],
+ icon: SendBackwardIcon,
+ trackEvent: { category: "element" },
+ perform: (elements, appState) => {
+ return {
+ elements: moveOneLeft(elements, appState),
+ appState,
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ keyPriority: 40,
+ keyTest: (event) =>
+ event[KEYS.CTRL_OR_CMD] &&
+ !event.shiftKey &&
+ event.code === CODES.BRACKET_LEFT,
+ PanelComponent: ({ updateData, appState }) => (
+ <button
+ type="button"
+ className="zIndexButton"
+ onClick={() => updateData(null)}
+ title={`${t("labels.sendBackward")} — ${getShortcutKey("CtrlOrCmd+[")}`}
+ >
+ {SendBackwardIcon}
+ </button>
+ ),
+});
+
+export const actionBringForward = register({
+ name: "bringForward",
+ label: "labels.bringForward",
+ keywords: ["move up", "zindex", "layer"],
+ icon: BringForwardIcon,
+ trackEvent: { category: "element" },
+ perform: (elements, appState) => {
+ return {
+ elements: moveOneRight(elements, appState),
+ appState,
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ keyPriority: 40,
+ keyTest: (event) =>
+ event[KEYS.CTRL_OR_CMD] &&
+ !event.shiftKey &&
+ event.code === CODES.BRACKET_RIGHT,
+ PanelComponent: ({ updateData, appState }) => (
+ <button
+ type="button"
+ className="zIndexButton"
+ onClick={() => updateData(null)}
+ title={`${t("labels.bringForward")} — ${getShortcutKey("CtrlOrCmd+]")}`}
+ >
+ {BringForwardIcon}
+ </button>
+ ),
+});
+
+export const actionSendToBack = register({
+ name: "sendToBack",
+ label: "labels.sendToBack",
+ keywords: ["move down", "zindex", "layer"],
+ icon: SendToBackIcon,
+ trackEvent: { category: "element" },
+ perform: (elements, appState) => {
+ return {
+ elements: moveAllLeft(elements, appState),
+ appState,
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ keyTest: (event) =>
+ isDarwin
+ ? event[KEYS.CTRL_OR_CMD] &&
+ event.altKey &&
+ event.code === CODES.BRACKET_LEFT
+ : event[KEYS.CTRL_OR_CMD] &&
+ event.shiftKey &&
+ event.code === CODES.BRACKET_LEFT,
+ PanelComponent: ({ updateData, appState }) => (
+ <button
+ type="button"
+ className="zIndexButton"
+ onClick={() => updateData(null)}
+ title={`${t("labels.sendToBack")} — ${
+ isDarwin
+ ? getShortcutKey("CtrlOrCmd+Alt+[")
+ : getShortcutKey("CtrlOrCmd+Shift+[")
+ }`}
+ >
+ {SendToBackIcon}
+ </button>
+ ),
+});
+
+export const actionBringToFront = register({
+ name: "bringToFront",
+ label: "labels.bringToFront",
+ keywords: ["move up", "zindex", "layer"],
+ icon: BringToFrontIcon,
+ trackEvent: { category: "element" },
+
+ perform: (elements, appState) => {
+ return {
+ elements: moveAllRight(elements, appState),
+ appState,
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ keyTest: (event) =>
+ isDarwin
+ ? event[KEYS.CTRL_OR_CMD] &&
+ event.altKey &&
+ event.code === CODES.BRACKET_RIGHT
+ : event[KEYS.CTRL_OR_CMD] &&
+ event.shiftKey &&
+ event.code === CODES.BRACKET_RIGHT,
+ PanelComponent: ({ updateData, appState }) => (
+ <button
+ type="button"
+ className="zIndexButton"
+ onClick={(event) => updateData(null)}
+ title={`${t("labels.bringToFront")} — ${
+ isDarwin
+ ? getShortcutKey("CtrlOrCmd+Alt+]")
+ : getShortcutKey("CtrlOrCmd+Shift+]")
+ }`}
+ >
+ {BringToFrontIcon}
+ </button>
+ ),
+});
diff --git a/packages/excalidraw/actions/index.ts b/packages/excalidraw/actions/index.ts
new file mode 100644
index 0000000..a556bfb
--- /dev/null
+++ b/packages/excalidraw/actions/index.ts
@@ -0,0 +1,92 @@
+export { actionDeleteSelected } from "./actionDeleteSelected";
+export {
+ actionBringForward,
+ actionBringToFront,
+ actionSendBackward,
+ actionSendToBack,
+} from "./actionZindex";
+export { actionSelectAll } from "./actionSelectAll";
+export { actionDuplicateSelection } from "./actionDuplicateSelection";
+export {
+ actionChangeStrokeColor,
+ actionChangeBackgroundColor,
+ actionChangeStrokeWidth,
+ actionChangeFillStyle,
+ actionChangeSloppiness,
+ actionChangeOpacity,
+ actionChangeFontSize,
+ actionChangeFontFamily,
+ actionChangeTextAlign,
+ actionChangeVerticalAlign,
+} from "./actionProperties";
+
+export {
+ actionChangeViewBackgroundColor,
+ actionClearCanvas,
+ actionZoomIn,
+ actionZoomOut,
+ actionResetZoom,
+ actionZoomToFit,
+ actionToggleTheme,
+} from "./actionCanvas";
+
+export { actionFinalize } from "./actionFinalize";
+
+export {
+ actionChangeProjectName,
+ actionChangeExportBackground,
+ actionSaveToActiveFile,
+ actionSaveFileToDisk,
+ actionLoadScene,
+} from "./actionExport";
+
+export { actionCopyStyles, actionPasteStyles } from "./actionStyles";
+export {
+ actionToggleCanvasMenu,
+ actionToggleEditMenu,
+ actionShortcuts,
+} from "./actionMenu";
+
+export { actionGroup, actionUngroup } from "./actionGroup";
+
+export { actionGoToCollaborator } from "./actionNavigate";
+
+export { actionAddToLibrary } from "./actionAddToLibrary";
+
+export {
+ actionAlignTop,
+ actionAlignBottom,
+ actionAlignLeft,
+ actionAlignRight,
+ actionAlignVerticallyCentered,
+ actionAlignHorizontallyCentered,
+} from "./actionAlign";
+
+export {
+ distributeHorizontally,
+ distributeVertically,
+} from "./actionDistribute";
+
+export { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
+
+export {
+ actionCopy,
+ actionCut,
+ actionCopyAsPng,
+ actionCopyAsSvg,
+ copyText,
+} from "./actionClipboard";
+
+export { actionToggleGridMode } from "./actionToggleGridMode";
+export { actionToggleZenMode } from "./actionToggleZenMode";
+export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode";
+
+export { actionToggleStats } from "./actionToggleStats";
+export { actionUnbindText, actionBindText } from "./actionBoundText";
+export { actionLink } from "./actionLink";
+export { actionToggleElementLock } from "./actionElementLock";
+export { actionToggleLinearEditor } from "./actionLinearEditor";
+
+export { actionToggleSearchMenu } from "./actionToggleSearchMenu";
+
+export { actionToggleCropEditor } from "./actionCropEditor";
diff --git a/packages/excalidraw/actions/manager.tsx b/packages/excalidraw/actions/manager.tsx
new file mode 100644
index 0000000..f378438
--- /dev/null
+++ b/packages/excalidraw/actions/manager.tsx
@@ -0,0 +1,194 @@
+import React from "react";
+import type {
+ Action,
+ UpdaterFn,
+ ActionName,
+ ActionResult,
+ PanelComponentProps,
+ ActionSource,
+} from "./types";
+import type {
+ ExcalidrawElement,
+ OrderedExcalidrawElement,
+} from "../element/types";
+import type { AppClassProperties, AppState } from "../types";
+import { trackEvent } from "../analytics";
+import { isPromiseLike } from "../utils";
+
+const trackAction = (
+ action: Action,
+ source: ActionSource,
+ appState: Readonly<AppState>,
+ elements: readonly ExcalidrawElement[],
+ app: AppClassProperties,
+ value: any,
+) => {
+ if (action.trackEvent) {
+ try {
+ if (typeof action.trackEvent === "object") {
+ const shouldTrack = action.trackEvent.predicate
+ ? action.trackEvent.predicate(appState, elements, value)
+ : true;
+ if (shouldTrack) {
+ trackEvent(
+ action.trackEvent.category,
+ action.trackEvent.action || action.name,
+ `${source} (${app.device.editor.isMobile ? "mobile" : "desktop"})`,
+ );
+ }
+ }
+ } catch (error) {
+ console.error("error while logging action:", error);
+ }
+ }
+};
+
+export class ActionManager {
+ actions = {} as Record<ActionName, Action>;
+
+ updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
+
+ getAppState: () => Readonly<AppState>;
+ getElementsIncludingDeleted: () => readonly OrderedExcalidrawElement[];
+ app: AppClassProperties;
+
+ constructor(
+ updater: UpdaterFn,
+ getAppState: () => AppState,
+ getElementsIncludingDeleted: () => readonly OrderedExcalidrawElement[],
+ app: AppClassProperties,
+ ) {
+ this.updater = (actionResult) => {
+ if (isPromiseLike(actionResult)) {
+ actionResult.then((actionResult) => {
+ return updater(actionResult);
+ });
+ } else {
+ return updater(actionResult);
+ }
+ };
+ this.getAppState = getAppState;
+ this.getElementsIncludingDeleted = getElementsIncludingDeleted;
+ this.app = app;
+ }
+
+ registerAction(action: Action) {
+ this.actions[action.name] = action;
+ }
+
+ registerAll(actions: readonly Action[]) {
+ actions.forEach((action) => this.registerAction(action));
+ }
+
+ handleKeyDown(event: React.KeyboardEvent | KeyboardEvent) {
+ const canvasActions = this.app.props.UIOptions.canvasActions;
+ const data = Object.values(this.actions)
+ .sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0))
+ .filter(
+ (action) =>
+ (action.name in canvasActions
+ ? canvasActions[action.name as keyof typeof canvasActions]
+ : true) &&
+ action.keyTest &&
+ action.keyTest(
+ event,
+ this.getAppState(),
+ this.getElementsIncludingDeleted(),
+ this.app,
+ ),
+ );
+
+ if (data.length !== 1) {
+ if (data.length > 1) {
+ console.warn("Canceling as multiple actions match this shortcut", data);
+ }
+ return false;
+ }
+
+ const action = data[0];
+
+ if (this.getAppState().viewModeEnabled && action.viewMode !== true) {
+ return false;
+ }
+
+ const elements = this.getElementsIncludingDeleted();
+ const appState = this.getAppState();
+ const value = null;
+
+ trackAction(action, "keyboard", appState, elements, this.app, null);
+
+ event.preventDefault();
+ event.stopPropagation();
+ this.updater(data[0].perform(elements, appState, value, this.app));
+ return true;
+ }
+
+ executeAction<T extends Action>(
+ action: T,
+ source: ActionSource = "api",
+ value: Parameters<T["perform"]>[2] = null,
+ ) {
+ const elements = this.getElementsIncludingDeleted();
+ const appState = this.getAppState();
+
+ trackAction(action, source, appState, elements, this.app, value);
+
+ this.updater(action.perform(elements, appState, value, this.app));
+ }
+
+ /**
+ * @param data additional data sent to the PanelComponent
+ */
+ renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
+ const canvasActions = this.app.props.UIOptions.canvasActions;
+
+ if (
+ this.actions[name] &&
+ "PanelComponent" in this.actions[name] &&
+ (name in canvasActions
+ ? canvasActions[name as keyof typeof canvasActions]
+ : true)
+ ) {
+ const action = this.actions[name];
+ const PanelComponent = action.PanelComponent!;
+ PanelComponent.displayName = "PanelComponent";
+ const elements = this.getElementsIncludingDeleted();
+ const appState = this.getAppState();
+ const updateData = (formState?: any) => {
+ trackAction(action, "ui", appState, elements, this.app, formState);
+
+ this.updater(
+ action.perform(
+ this.getElementsIncludingDeleted(),
+ this.getAppState(),
+ formState,
+ this.app,
+ ),
+ );
+ };
+
+ return (
+ <PanelComponent
+ elements={this.getElementsIncludingDeleted()}
+ appState={this.getAppState()}
+ updateData={updateData}
+ appProps={this.app.props}
+ app={this.app}
+ data={data}
+ />
+ );
+ }
+
+ return null;
+ };
+
+ isActionEnabled = (action: Action) => {
+ const elements = this.getElementsIncludingDeleted();
+ const appState = this.getAppState();
+
+ return (
+ !action.predicate ||
+ action.predicate(elements, appState, this.app.props, this.app)
+ );
+ };
+}
diff --git a/packages/excalidraw/actions/register.ts b/packages/excalidraw/actions/register.ts
new file mode 100644
index 0000000..7c841e3
--- /dev/null
+++ b/packages/excalidraw/actions/register.ts
@@ -0,0 +1,10 @@
+import type { Action } from "./types";
+
+export let actions: readonly Action[] = [];
+
+export const register = <T extends Action>(action: T) => {
+ actions = actions.concat(action);
+ return action as T & {
+ keyTest?: unknown extends T["keyTest"] ? never : T["keyTest"];
+ };
+};
diff --git a/packages/excalidraw/actions/shortcuts.ts b/packages/excalidraw/actions/shortcuts.ts
new file mode 100644
index 0000000..451609d
--- /dev/null
+++ b/packages/excalidraw/actions/shortcuts.ts
@@ -0,0 +1,125 @@
+import { isDarwin } from "../constants";
+import { t } from "../i18n";
+import type { SubtypeOf } from "../utility-types";
+import { getShortcutKey } from "../utils";
+import type { ActionName } from "./types";
+
+export type ShortcutName =
+ | SubtypeOf<
+ ActionName,
+ | "toggleTheme"
+ | "loadScene"
+ | "clearCanvas"
+ | "cut"
+ | "copy"
+ | "paste"
+ | "copyStyles"
+ | "pasteStyles"
+ | "selectAll"
+ | "deleteSelectedElements"
+ | "duplicateSelection"
+ | "sendBackward"
+ | "bringForward"
+ | "sendToBack"
+ | "bringToFront"
+ | "copyAsPng"
+ | "group"
+ | "ungroup"
+ | "gridMode"
+ | "zenMode"
+ | "objectsSnapMode"
+ | "stats"
+ | "addToLibrary"
+ | "viewMode"
+ | "flipHorizontal"
+ | "flipVertical"
+ | "hyperlink"
+ | "toggleElementLock"
+ | "resetZoom"
+ | "zoomOut"
+ | "zoomIn"
+ | "zoomToFit"
+ | "zoomToFitSelectionInViewport"
+ | "zoomToFitSelection"
+ | "toggleEraserTool"
+ | "toggleHandTool"
+ | "setFrameAsActiveTool"
+ | "saveFileToDisk"
+ | "saveToActiveFile"
+ | "toggleShortcuts"
+ | "wrapSelectionInFrame"
+ >
+ | "saveScene"
+ | "imageExport"
+ | "commandPalette"
+ | "searchMenu";
+
+const shortcutMap: Record<ShortcutName, string[]> = {
+ toggleTheme: [getShortcutKey("Shift+Alt+D")],
+ saveScene: [getShortcutKey("CtrlOrCmd+S")],
+ loadScene: [getShortcutKey("CtrlOrCmd+O")],
+ clearCanvas: [getShortcutKey("CtrlOrCmd+Delete")],
+ imageExport: [getShortcutKey("CtrlOrCmd+Shift+E")],
+ commandPalette: [
+ getShortcutKey("CtrlOrCmd+/"),
+ getShortcutKey("CtrlOrCmd+Shift+P"),
+ ],
+ cut: [getShortcutKey("CtrlOrCmd+X")],
+ copy: [getShortcutKey("CtrlOrCmd+C")],
+ paste: [getShortcutKey("CtrlOrCmd+V")],
+ copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")],
+ pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")],
+ selectAll: [getShortcutKey("CtrlOrCmd+A")],
+ deleteSelectedElements: [getShortcutKey("Delete")],
+ duplicateSelection: [
+ getShortcutKey("CtrlOrCmd+D"),
+ getShortcutKey(`Alt+${t("helpDialog.drag")}`),
+ ],
+ sendBackward: [getShortcutKey("CtrlOrCmd+[")],
+ bringForward: [getShortcutKey("CtrlOrCmd+]")],
+ sendToBack: [
+ isDarwin
+ ? getShortcutKey("CtrlOrCmd+Alt+[")
+ : getShortcutKey("CtrlOrCmd+Shift+["),
+ ],
+ bringToFront: [
+ isDarwin
+ ? getShortcutKey("CtrlOrCmd+Alt+]")
+ : getShortcutKey("CtrlOrCmd+Shift+]"),
+ ],
+ copyAsPng: [getShortcutKey("Shift+Alt+C")],
+ group: [getShortcutKey("CtrlOrCmd+G")],
+ ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
+ gridMode: [getShortcutKey("CtrlOrCmd+'")],
+ zenMode: [getShortcutKey("Alt+Z")],
+ objectsSnapMode: [getShortcutKey("Alt+S")],
+ stats: [getShortcutKey("Alt+/")],
+ addToLibrary: [],
+ flipHorizontal: [getShortcutKey("Shift+H")],
+ flipVertical: [getShortcutKey("Shift+V")],
+ viewMode: [getShortcutKey("Alt+R")],
+ hyperlink: [getShortcutKey("CtrlOrCmd+K")],
+ toggleElementLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
+ resetZoom: [getShortcutKey("CtrlOrCmd+0")],
+ zoomOut: [getShortcutKey("CtrlOrCmd+-")],
+ zoomIn: [getShortcutKey("CtrlOrCmd++")],
+ zoomToFitSelection: [getShortcutKey("Shift+3")],
+ zoomToFit: [getShortcutKey("Shift+1")],
+ zoomToFitSelectionInViewport: [getShortcutKey("Shift+2")],
+ toggleEraserTool: [getShortcutKey("E")],
+ toggleHandTool: [getShortcutKey("H")],
+ setFrameAsActiveTool: [getShortcutKey("F")],
+ saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")],
+ saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
+ toggleShortcuts: [getShortcutKey("?")],
+ searchMenu: [getShortcutKey("CtrlOrCmd+F")],
+ wrapSelectionInFrame: [],
+};
+
+export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
+ const shortcuts = shortcutMap[name];
+ // if multiple shortcuts available, take the first one
+ return shortcuts && shortcuts.length > 0
+ ? shortcuts[idx] || shortcuts[0]
+ : "";
+};
diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts
new file mode 100644
index 0000000..71ac9f4
--- /dev/null
+++ b/packages/excalidraw/actions/types.ts
@@ -0,0 +1,207 @@
+import type React from "react";
+import type {
+ ExcalidrawElement,
+ OrderedExcalidrawElement,
+} from "../element/types";
+import type {
+ AppClassProperties,
+ AppState,
+ ExcalidrawProps,
+ BinaryFiles,
+ UIAppState,
+} from "../types";
+import type { CaptureUpdateActionType } from "../store";
+
+export type ActionSource =
+ | "ui"
+ | "keyboard"
+ | "contextMenu"
+ | "api"
+ | "commandPalette";
+
+/** if false, the action should be prevented */
+export type ActionResult =
+ | {
+ elements?: readonly ExcalidrawElement[] | null;
+ appState?: Partial<AppState> | null;
+ files?: BinaryFiles | null;
+ captureUpdate: CaptureUpdateActionType;
+ replaceFiles?: boolean;
+ }
+ | false;
+
+type ActionFn = (
+ elements: readonly OrderedExcalidrawElement[],
+ appState: Readonly<AppState>,
+ formData: any,
+ app: AppClassProperties,
+) => ActionResult | Promise<ActionResult>;
+
+export type UpdaterFn = (res: ActionResult) => void;
+export type ActionFilterFn = (action: Action) => void;
+
+export type ActionName =
+ | "copy"
+ | "cut"
+ | "paste"
+ | "copyAsPng"
+ | "copyAsSvg"
+ | "copyText"
+ | "sendBackward"
+ | "bringForward"
+ | "sendToBack"
+ | "bringToFront"
+ | "copyStyles"
+ | "selectAll"
+ | "pasteStyles"
+ | "gridMode"
+ | "zenMode"
+ | "objectsSnapMode"
+ | "stats"
+ | "changeStrokeColor"
+ | "changeBackgroundColor"
+ | "changeFillStyle"
+ | "changeStrokeWidth"
+ | "changeStrokeShape"
+ | "changeSloppiness"
+ | "changeStrokeStyle"
+ | "changeArrowhead"
+ | "changeArrowType"
+ | "changeOpacity"
+ | "changeFontSize"
+ | "toggleCanvasMenu"
+ | "toggleEditMenu"
+ | "undo"
+ | "redo"
+ | "finalize"
+ | "changeProjectName"
+ | "changeExportBackground"
+ | "changeExportEmbedScene"
+ | "changeExportScale"
+ | "saveToActiveFile"
+ | "saveFileToDisk"
+ | "loadScene"
+ | "duplicateSelection"
+ | "deleteSelectedElements"
+ | "changeViewBackgroundColor"
+ | "clearCanvas"
+ | "zoomIn"
+ | "zoomOut"
+ | "resetZoom"
+ | "zoomToFit"
+ | "zoomToFitSelection"
+ | "zoomToFitSelectionInViewport"
+ | "changeFontFamily"
+ | "changeTextAlign"
+ | "changeVerticalAlign"
+ | "toggleFullScreen"
+ | "toggleShortcuts"
+ | "group"
+ | "ungroup"
+ | "goToCollaborator"
+ | "addToLibrary"
+ | "changeRoundness"
+ | "alignTop"
+ | "alignBottom"
+ | "alignLeft"
+ | "alignRight"
+ | "alignVerticallyCentered"
+ | "alignHorizontallyCentered"
+ | "distributeHorizontally"
+ | "distributeVertically"
+ | "flipHorizontal"
+ | "flipVertical"
+ | "viewMode"
+ | "exportWithDarkMode"
+ | "toggleTheme"
+ | "increaseFontSize"
+ | "decreaseFontSize"
+ | "unbindText"
+ | "hyperlink"
+ | "bindText"
+ | "unlockAllElements"
+ | "toggleElementLock"
+ | "toggleLinearEditor"
+ | "toggleEraserTool"
+ | "toggleHandTool"
+ | "selectAllElementsInFrame"
+ | "removeAllElementsFromFrame"
+ | "updateFrameRendering"
+ | "setFrameAsActiveTool"
+ | "setEmbeddableAsActiveTool"
+ | "createContainerFromText"
+ | "wrapTextInContainer"
+ | "commandPalette"
+ | "autoResize"
+ | "elementStats"
+ | "searchMenu"
+ | "copyElementLink"
+ | "linkToElement"
+ | "cropEditor"
+ | "wrapSelectionInFrame";
+
+export type PanelComponentProps = {
+ elements: readonly ExcalidrawElement[];
+ appState: AppState;
+ updateData: <T = any>(formData?: T) => void;
+ appProps: ExcalidrawProps;
+ data?: Record<string, any>;
+ app: AppClassProperties;
+};
+
+export interface Action {
+ name: ActionName;
+ label:
+ | string
+ | ((
+ elements: readonly ExcalidrawElement[],
+ appState: Readonly<AppState>,
+ app: AppClassProperties,
+ ) => string);
+ keywords?: string[];
+ icon?:
+ | React.ReactNode
+ | ((
+ appState: UIAppState,
+ elements: readonly ExcalidrawElement[],
+ ) => React.ReactNode);
+ PanelComponent?: React.FC<PanelComponentProps>;
+ perform: ActionFn;
+ keyPriority?: number;
+ keyTest?: (
+ event: React.KeyboardEvent | KeyboardEvent,
+ appState: AppState,
+ elements: readonly ExcalidrawElement[],
+ app: AppClassProperties,
+ ) => boolean;
+ predicate?: (
+ elements: readonly ExcalidrawElement[],
+ appState: AppState,
+ appProps: ExcalidrawProps,
+ app: AppClassProperties,
+ ) => boolean;
+ checked?: (appState: Readonly<AppState>) => boolean;
+ trackEvent:
+ | false
+ | {
+ category:
+ | "toolbar"
+ | "element"
+ | "canvas"
+ | "export"
+ | "history"
+ | "menu"
+ | "collab"
+ | "hyperlink"
+ | "search_menu";
+ action?: string;
+ predicate?: (
+ appState: Readonly<AppState>,
+ elements: readonly ExcalidrawElement[],
+ value: any,
+ ) => boolean;
+ };
+ /** if set to `true`, allow action to be performed in viewMode.
+ * Defaults to `false` */
+ viewMode?: boolean;
+}