snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s
Some checks failed
Tests / test (push) Failing after 15s
This commit is contained in:
65
packages/excalidraw/actions/actionAddToLibrary.ts
Normal file
65
packages/excalidraw/actions/actionAddToLibrary.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { LIBRARY_DISABLED_TYPES, randomId } from "@excalidraw/common";
|
||||
import { deepCopyElement } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
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",
|
||||
});
|
||||
273
packages/excalidraw/actions/actionAlign.tsx
Normal file
273
packages/excalidraw/actions/actionAlign.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import { isFrameLikeElement } from "@excalidraw/element";
|
||||
|
||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
||||
|
||||
import { KEYS, arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import { alignElements } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { getSelectedElementsByGroup } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import type { Alignment } from "@excalidraw/element";
|
||||
|
||||
import { IconButton } from "../components/IconButton";
|
||||
import {
|
||||
AlignBottomIcon,
|
||||
AlignLeftIcon,
|
||||
AlignRightIcon,
|
||||
AlignTopIcon,
|
||||
CenterHorizontallyIcon,
|
||||
CenterVerticallyIcon,
|
||||
} from "../components/icons";
|
||||
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
|
||||
import { getShortcutKey } from "../shortcut";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppClassProperties, AppState, UIAppState } from "../types";
|
||||
|
||||
export const alignActionsPredicate = (
|
||||
appState: UIAppState,
|
||||
app: AppClassProperties,
|
||||
) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
return (
|
||||
getSelectedElementsByGroup(
|
||||
selectedElements,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
appState as Readonly<AppState>,
|
||||
).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 updatedElements = alignElements(
|
||||
selectedElements,
|
||||
alignment,
|
||||
app.scene,
|
||||
appState,
|
||||
);
|
||||
|
||||
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 }) => (
|
||||
<IconButton
|
||||
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 }) => (
|
||||
<IconButton
|
||||
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 }) => (
|
||||
<IconButton
|
||||
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 }) => (
|
||||
<IconButton
|
||||
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 }) => (
|
||||
<IconButton
|
||||
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 }) => (
|
||||
<IconButton
|
||||
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)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
377
packages/excalidraw/actions/actionBoundText.tsx
Normal file
377
packages/excalidraw/actions/actionBoundText.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
import {
|
||||
BOUND_TEXT_PADDING,
|
||||
ROUNDNESS,
|
||||
TEXT_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
arrayToMap,
|
||||
getFontString,
|
||||
getStrokeWidthByKey,
|
||||
isTransparent,
|
||||
} from "@excalidraw/common";
|
||||
import {
|
||||
getOriginalContainerHeightFromCache,
|
||||
isBoundToContainer,
|
||||
resetOriginalContainerCache,
|
||||
updateOriginalContainerCache,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
DEFAULT_BOUND_TEXT_LABEL_POSITION,
|
||||
computeBoundTextPosition,
|
||||
computeContainerDimensionForBoundText,
|
||||
getBoundTextElement,
|
||||
normalizeStickyNoteFontSize,
|
||||
redrawTextBoundingBox,
|
||||
updateStickyNoteLayout,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isArrowElement,
|
||||
isStickyNoteElement,
|
||||
isTextBindableContainer,
|
||||
isTextElement,
|
||||
isUsingAdaptiveRadius,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { measureText } from "@excalidraw/element";
|
||||
|
||||
import { syncMovedIndices } from "@excalidraw/element";
|
||||
|
||||
import { newElement } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawTextElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppState } from "../types";
|
||||
|
||||
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,
|
||||
);
|
||||
app.scene.mutateElement(boundTextElement as ExcalidrawTextElement, {
|
||||
containerId: null,
|
||||
width,
|
||||
height,
|
||||
text: boundTextElement.originalText,
|
||||
baseFontSize: null,
|
||||
x,
|
||||
y,
|
||||
labelPosition: null,
|
||||
});
|
||||
app.scene.mutateElement(element, {
|
||||
boundElements: element.boundElements?.filter(
|
||||
(ele) => ele.id !== boundTextElement.id,
|
||||
),
|
||||
});
|
||||
if (isStickyNoteElement(element)) {
|
||||
// an empty note sits at its base height; bound arrows follow
|
||||
updateStickyNoteLayout(element, app.scene);
|
||||
} else {
|
||||
app.scene.mutateElement(element, {
|
||||
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;
|
||||
}
|
||||
// a note and its label share one ink: the text the user styled wins,
|
||||
// unless it is transparent (a note's label never is)
|
||||
const stickyInk = isStickyNoteElement(container)
|
||||
? isTransparent(textElement.strokeColor)
|
||||
? container.strokeColor
|
||||
: textElement.strokeColor
|
||||
: null;
|
||||
app.scene.mutateElement(textElement, {
|
||||
containerId: container.id,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
textAlign: TEXT_ALIGN.CENTER,
|
||||
autoResize: true,
|
||||
angle: (isArrowElement(container) ? 0 : container?.angle ?? 0) as Radians,
|
||||
labelPosition: isArrowElement(container)
|
||||
? DEFAULT_BOUND_TEXT_LABEL_POSITION
|
||||
: null,
|
||||
...(stickyInk
|
||||
? {
|
||||
baseFontSize: normalizeStickyNoteFontSize(
|
||||
textElement.baseFontSize ?? textElement.fontSize,
|
||||
),
|
||||
strokeColor: stickyInk,
|
||||
}
|
||||
: null),
|
||||
});
|
||||
app.scene.mutateElement(container, {
|
||||
boundElements: (container.boundElements || []).concat({
|
||||
type: "text",
|
||||
id: textElement.id,
|
||||
}),
|
||||
// the footer paints with the note's ink
|
||||
...(stickyInk ? { strokeColor: stickyInk } : null),
|
||||
});
|
||||
const originalContainerHeight = container.height;
|
||||
redrawTextBoundingBox(textElement, container, app.scene);
|
||||
// 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 someTextElements = selectedElements.some(
|
||||
(el) => isTextElement(el) && !isBoundToContainer(el),
|
||||
);
|
||||
return selectedElements.length > 0 && someTextElements;
|
||||
},
|
||||
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) && !isBoundToContainer(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: getStrokeWidthByKey(
|
||||
"rectangle",
|
||||
appState.currentItemStrokeWidthKey,
|
||||
),
|
||||
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) {
|
||||
app.scene.mutateElement(ele, {
|
||||
startBinding,
|
||||
endBinding,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.scene.mutateElement(textElement, {
|
||||
containerId: container.id,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
boundElements: null,
|
||||
textAlign: TEXT_ALIGN.CENTER,
|
||||
autoResize: true,
|
||||
});
|
||||
|
||||
redrawTextBoundingBox(textElement, container, app.scene);
|
||||
|
||||
updatedElements = pushContainerBelowText(
|
||||
[...updatedElements, container],
|
||||
container,
|
||||
textElement,
|
||||
);
|
||||
|
||||
containerIds[container.id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
elements: updatedElements,
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: containerIds,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
});
|
||||
450
packages/excalidraw/actions/actionCanvas.tsx
Normal file
450
packages/excalidraw/actions/actionCanvas.tsx
Normal file
@@ -0,0 +1,450 @@
|
||||
import {
|
||||
DEFAULT_CANVAS_BACKGROUND_PICKS,
|
||||
MAX_ZOOM,
|
||||
MIN_ZOOM,
|
||||
THEME,
|
||||
ZOOM_STEP,
|
||||
CODES,
|
||||
KEYS,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { getCommonBounds } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type { Bounds } from "@excalidraw/common";
|
||||
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
||||
import { IconButton } from "../components/IconButton";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import {
|
||||
MoonIcon,
|
||||
SunIcon,
|
||||
TrashIcon,
|
||||
zoomAreaIcon,
|
||||
ZoomInIcon,
|
||||
ZoomOutIcon,
|
||||
ZoomResetIcon,
|
||||
} from "../components/icons";
|
||||
import { useAppStateValue } from "../hooks/useAppStateValue";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { getNormalizedZoom } from "../scene";
|
||||
import {
|
||||
constrainScrollState,
|
||||
getViewportForZoomWithScrollConstraints,
|
||||
zoomToFitBounds,
|
||||
} from "../viewport";
|
||||
import { getShortcutKey } from "../shortcut";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppState } from "../types";
|
||||
|
||||
export const actionChangeViewBackgroundColor = register<Partial<AppState>>({
|
||||
name: "changeViewBackgroundColor",
|
||||
label: "labels.canvasBackground",
|
||||
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, data }) => {
|
||||
// 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",
|
||||
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,
|
||||
colorTopPicks: appState.colorTopPicks,
|
||||
activeTool:
|
||||
appState.activeTool.type === "image"
|
||||
? {
|
||||
...appState.activeTool,
|
||||
type: app.state.preferredSelectionTool.type,
|
||||
}
|
||||
: appState.activeTool,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const actionZoomIn = register({
|
||||
name: "zoomIn",
|
||||
label: "buttons.zoomIn",
|
||||
viewMode: true,
|
||||
navigation: true,
|
||||
icon: ZoomInIcon,
|
||||
trackEvent: { category: "canvas" },
|
||||
predicate: (elements, appState, appProps, app) => app.isNavigationEnabled(),
|
||||
perform: (_elements, appState, _, app) => {
|
||||
app.requestUnfollow();
|
||||
const nextState = {
|
||||
...appState,
|
||||
...getViewportForZoomWithScrollConstraints(
|
||||
{
|
||||
viewportX: appState.width / 2 + appState.offsetLeft,
|
||||
viewportY: appState.height / 2 + appState.offsetTop,
|
||||
nextZoom: getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
|
||||
},
|
||||
appState,
|
||||
),
|
||||
};
|
||||
return {
|
||||
appState: nextState,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData }) => {
|
||||
const zoomValue = useAppStateValue((appState) => appState.zoom.value);
|
||||
return (
|
||||
<IconButton
|
||||
type="button"
|
||||
className="zoom-in-button zoom-button"
|
||||
icon={ZoomInIcon}
|
||||
title={`${t("buttons.zoomIn")} — ${getShortcutKey("CtrlOrCmd++")}`}
|
||||
aria-label={t("buttons.zoomIn")}
|
||||
disabled={zoomValue >= 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,
|
||||
navigation: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
predicate: (elements, appState, appProps, app) => app.isNavigationEnabled(),
|
||||
perform: (_elements, appState, _, app) => {
|
||||
app.requestUnfollow();
|
||||
const nextState = {
|
||||
...appState,
|
||||
...getViewportForZoomWithScrollConstraints(
|
||||
{
|
||||
viewportX: appState.width / 2 + appState.offsetLeft,
|
||||
viewportY: appState.height / 2 + appState.offsetTop,
|
||||
nextZoom: getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
|
||||
},
|
||||
appState,
|
||||
),
|
||||
};
|
||||
return {
|
||||
appState: nextState,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData }) => {
|
||||
const zoomValue = useAppStateValue((appState) => appState.zoom.value);
|
||||
return (
|
||||
<IconButton
|
||||
type="button"
|
||||
className="zoom-out-button zoom-button"
|
||||
icon={ZoomOutIcon}
|
||||
title={`${t("buttons.zoomOut")} — ${getShortcutKey("CtrlOrCmd+-")}`}
|
||||
aria-label={t("buttons.zoomOut")}
|
||||
disabled={zoomValue <= 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,
|
||||
navigation: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
predicate: (elements, appState, appProps, app) => app.isNavigationEnabled(),
|
||||
perform: (_elements, appState, _, app) => {
|
||||
app.requestUnfollow();
|
||||
// reset to 100%, unless a zoom lock floors the zoom higher — then reset to
|
||||
// the locked minimum zoom (the lock's resting zoom level)
|
||||
const nextZoom = appState.scrollConstraints?.lockZoom
|
||||
? appState.scrollConstraints.zoom
|
||||
: 1;
|
||||
const nextState = {
|
||||
...appState,
|
||||
...getViewportForZoomWithScrollConstraints(
|
||||
{
|
||||
viewportX: appState.width / 2 + appState.offsetLeft,
|
||||
viewportY: appState.height / 2 + appState.offsetTop,
|
||||
nextZoom: getNormalizedZoom(nextZoom),
|
||||
},
|
||||
appState,
|
||||
),
|
||||
};
|
||||
return {
|
||||
appState: nextState,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData }) => {
|
||||
const zoomValue = useAppStateValue((appState) => appState.zoom.value);
|
||||
return (
|
||||
<Tooltip label={t("buttons.resetZoom")} style={{ height: "100%" }}>
|
||||
<IconButton
|
||||
type="button"
|
||||
className="reset-zoom-button zoom-button"
|
||||
title={t("buttons.resetZoom")}
|
||||
aria-label={t("buttons.resetZoom")}
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
>
|
||||
{(zoomValue * 100).toFixed(0)}%
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
keyTest: (event) =>
|
||||
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
|
||||
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
|
||||
});
|
||||
|
||||
// under a viewport lock, zoom-to-fit targets the locked box rather than the
|
||||
// scene elements
|
||||
const getScrollConstraintsBounds = (
|
||||
scrollConstraints: NonNullable<AppState["scrollConstraints"]>,
|
||||
) => {
|
||||
return [
|
||||
scrollConstraints.x,
|
||||
scrollConstraints.y,
|
||||
scrollConstraints.x + scrollConstraints.width,
|
||||
scrollConstraints.y + scrollConstraints.height,
|
||||
] as Bounds;
|
||||
};
|
||||
|
||||
// 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,
|
||||
// with no selection (as is always the case in view mode & when
|
||||
// non-interactive), fits all elements, or the locked viewport box when
|
||||
// a viewport lock is active
|
||||
viewMode: true,
|
||||
navigation: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
predicate: (elements, appState, appProps, app) => app.isNavigationEnabled(),
|
||||
perform: (elements, appState, _, app) => {
|
||||
app.requestUnfollow();
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
const bounds = selectedElements.length
|
||||
? getCommonBounds(getNonDeletedElements(selectedElements))
|
||||
: appState.scrollConstraints
|
||||
? getScrollConstraintsBounds(appState.scrollConstraints)
|
||||
: getCommonBounds(getNonDeletedElements(elements));
|
||||
const result = zoomToFitBounds({
|
||||
bounds,
|
||||
appState,
|
||||
fit: "scale-down",
|
||||
canvasOffsets: app.viewport.getOffsets(),
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
// re-clamp so the fit can't escape an active scroll/zoom lock
|
||||
appState: {
|
||||
...result.appState,
|
||||
...constrainScrollState(result.appState),
|
||||
},
|
||||
};
|
||||
},
|
||||
// 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,
|
||||
// with no selection (as is always the case in view mode & when
|
||||
// non-interactive), fits all elements, or the locked viewport box when
|
||||
// a viewport lock is active
|
||||
viewMode: true,
|
||||
navigation: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
predicate: (elements, appState, appProps, app) => app.isNavigationEnabled(),
|
||||
perform: (elements, appState, _, app) => {
|
||||
app.requestUnfollow();
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
const bounds = selectedElements.length
|
||||
? getCommonBounds(getNonDeletedElements(selectedElements))
|
||||
: appState.scrollConstraints
|
||||
? getScrollConstraintsBounds(appState.scrollConstraints)
|
||||
: getCommonBounds(getNonDeletedElements(elements));
|
||||
const result = zoomToFitBounds({
|
||||
bounds,
|
||||
appState,
|
||||
fit: "contain",
|
||||
canvasOffsets: app.viewport.getOffsets(),
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
// re-clamp so the fit can't escape an active scroll/zoom lock
|
||||
appState: {
|
||||
...result.appState,
|
||||
...constrainScrollState(result.appState),
|
||||
},
|
||||
};
|
||||
},
|
||||
// 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,
|
||||
navigation: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
predicate: (elements, appState, appProps, app) => app.isNavigationEnabled(),
|
||||
perform: (elements, appState, _, app) => {
|
||||
app.requestUnfollow();
|
||||
// under a viewport lock, fits the locked box rather than the elements
|
||||
const bounds = appState.scrollConstraints
|
||||
? getScrollConstraintsBounds(appState.scrollConstraints)
|
||||
: getCommonBounds(getNonDeletedElements(elements));
|
||||
const result = zoomToFitBounds({
|
||||
bounds,
|
||||
appState,
|
||||
fit: "scale-down",
|
||||
canvasOffsets: app.viewport.getOffsets(),
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
// re-clamp so the fit can't escape an active scroll/zoom lock
|
||||
appState: {
|
||||
...result.appState,
|
||||
...constrainScrollState(result.appState),
|
||||
},
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event.code === CODES.ONE &&
|
||||
event.shiftKey &&
|
||||
!event.altKey &&
|
||||
!event[KEYS.CTRL_OR_CMD],
|
||||
});
|
||||
|
||||
export const actionToggleTheme = register<AppState["theme"]>({
|
||||
name: "toggleTheme",
|
||||
label: (_, appState) => {
|
||||
return appState.theme === THEME.DARK
|
||||
? "buttons.lightMode"
|
||||
: "buttons.darkMode";
|
||||
},
|
||||
keywords: ["toggle", "dark", "light", "mode", "theme"],
|
||||
icon: (appState, elements) =>
|
||||
appState.theme === THEME.LIGHT ? MoonIcon : SunIcon,
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_, appState, value, app) => {
|
||||
const nextTheme =
|
||||
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT);
|
||||
|
||||
if (app.props.onThemeChange) {
|
||||
app.props.onThemeChange(nextTheme);
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
theme: nextTheme,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] &&
|
||||
event.altKey &&
|
||||
event.shiftKey &&
|
||||
event.code === CODES.D,
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return !!app.props.UIOptions.canvasActions.toggleTheme;
|
||||
},
|
||||
});
|
||||
285
packages/excalidraw/actions/actionClipboard.tsx
Normal file
285
packages/excalidraw/actions/actionClipboard.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import { isTextElement } from "@excalidraw/element";
|
||||
import { getTextFromElements } from "@excalidraw/element";
|
||||
|
||||
import { CODES, KEYS, isFirefox } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
copyTextToSystemClipboard,
|
||||
copyToClipboard,
|
||||
createPasteEvent,
|
||||
probablySupportsClipboardBlob,
|
||||
probablySupportsClipboardWriteText,
|
||||
readSystemClipboard,
|
||||
} from "../clipboard";
|
||||
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
|
||||
import { exportCanvas, prepareElementsForExport } from "../data/index";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionCopy = register<ClipboardEvent | null>({
|
||||
name: "copy",
|
||||
label: "labels.copy",
|
||||
icon: DuplicateIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, event, 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<ClipboardEvent | null>({
|
||||
name: "cut",
|
||||
label: "labels.cut",
|
||||
icon: cutIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, event, 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"],
|
||||
});
|
||||
59
packages/excalidraw/actions/actionCropEditor.tsx
Normal file
59
packages/excalidraw/actions/actionCropEditor.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { isImageElement } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawImageElement } from "@excalidraw/element/types";
|
||||
|
||||
import { IconButton } from "../components/IconButton";
|
||||
import { cropIcon } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
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 (
|
||||
<IconButton
|
||||
type="button"
|
||||
icon={cropIcon}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
onClick={() => updateData(null)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
213
packages/excalidraw/actions/actionDeleteSelected.test.tsx
Normal file
213
packages/excalidraw/actions/actionDeleteSelected.test.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import React from "react";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { act, assertElements, render } from "../tests/test-utils";
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
h.app.scene.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,
|
||||
});
|
||||
|
||||
h.app.scene.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,
|
||||
});
|
||||
|
||||
h.app.scene.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,
|
||||
});
|
||||
|
||||
h.app.scene.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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
329
packages/excalidraw/actions/actionDeleteSelected.tsx
Normal file
329
packages/excalidraw/actions/actionDeleteSelected.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import {
|
||||
KEYS,
|
||||
MOBILE_ACTION_BUTTON_BG,
|
||||
updateActiveTool,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
import { fixBindingsAfterDeletion } from "@excalidraw/element";
|
||||
import { LinearElementEditor } from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { getContainerElement } from "@excalidraw/element";
|
||||
import {
|
||||
isBoundToContainer,
|
||||
isElbowArrow,
|
||||
isFrameLikeElement,
|
||||
} from "@excalidraw/element";
|
||||
import { getFrameChildren } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
getElementsInGroup,
|
||||
selectGroupsForSelectedElements,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { TrashIcon } from "../components/icons";
|
||||
import { IconButton } from "../components/IconButton";
|
||||
|
||||
import { useStylesPanelMode } from "../components/App";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
|
||||
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)) {
|
||||
app.scene.mutateElement(bound, {
|
||||
startBinding:
|
||||
el.id === bound.startBinding?.elementId
|
||||
? null
|
||||
: bound.startBinding,
|
||||
endBinding:
|
||||
el.id === bound.endBinding?.elementId ? null : bound.endBinding,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
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,
|
||||
},
|
||||
getNonDeletedElements(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.selectedLinearElement?.isEditing) {
|
||||
const { elementId, selectedPointsIndices } =
|
||||
appState.selectedLinearElement;
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const linearElement = LinearElementEditor.getElement(
|
||||
elementId,
|
||||
elementsMap,
|
||||
);
|
||||
if (!linearElement) {
|
||||
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 all points
|
||||
if (selectedPointsIndices.length >= linearElement.points.length) {
|
||||
const nextElements = elements.map((el) => {
|
||||
if (el.id === linearElement.id) {
|
||||
return newElementWith(el, { isDeleted: true });
|
||||
}
|
||||
return el;
|
||||
});
|
||||
const nextAppState = handleGroupEditingState(appState, nextElements);
|
||||
|
||||
return {
|
||||
elements: nextElements,
|
||||
appState: {
|
||||
...nextAppState,
|
||||
selectedLinearElement: null,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
}
|
||||
|
||||
LinearElementEditor.deletePoints(
|
||||
linearElement,
|
||||
app,
|
||||
selectedPointsIndices,
|
||||
);
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState: {
|
||||
...appState,
|
||||
selectedLinearElement: {
|
||||
...appState.selectedLinearElement,
|
||||
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: app.state.preferredSelectionTool.type,
|
||||
}),
|
||||
multiElement: null,
|
||||
newElement: null,
|
||||
activeEmbeddable: null,
|
||||
selectedLinearElement: 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, app }) => {
|
||||
const isMobile = useStylesPanelMode() === "mobile";
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
type="button"
|
||||
icon={TrashIcon}
|
||||
title={t("labels.delete")}
|
||||
aria-label={t("labels.delete")}
|
||||
onClick={() => updateData(null)}
|
||||
disabled={
|
||||
!isSomeElementSelected(getNonDeletedElements(elements), appState)
|
||||
}
|
||||
style={{
|
||||
...(isMobile && appState.openPopup !== "compactOtherProperties"
|
||||
? MOBILE_ACTION_BUTTON_BG
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
152
packages/excalidraw/actions/actionDeselect.ts
Normal file
152
packages/excalidraw/actions/actionDeselect.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
getElementsInGroup,
|
||||
isSomeElementSelected,
|
||||
makeNextSelectedElementIds,
|
||||
selectGroupsForSelectedElements,
|
||||
} from "@excalidraw/element";
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { KEYS, isWritableElement, updateActiveTool } from "@excalidraw/common";
|
||||
|
||||
import type { GroupId } from "@excalidraw/element/types";
|
||||
|
||||
import { TOGGLE_TOOLS } from "../components/Tools";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
|
||||
const getNextActiveTool = (
|
||||
appState: Readonly<AppState>,
|
||||
app: AppClassProperties,
|
||||
) => {
|
||||
if (TOGGLE_TOOLS.includes(appState.activeTool.type)) {
|
||||
return updateActiveTool(appState, {
|
||||
...(appState.activeTool.lastActiveTool || {
|
||||
type: app.state.preferredSelectionTool.type,
|
||||
}),
|
||||
lastActiveTool: null,
|
||||
});
|
||||
}
|
||||
|
||||
return updateActiveTool(appState, {
|
||||
type: app.state.preferredSelectionTool.type,
|
||||
});
|
||||
};
|
||||
|
||||
const getParentEditingGroupId = (
|
||||
appState: Readonly<AppState>,
|
||||
app: AppClassProperties,
|
||||
selectedElementIds: AppState["selectedElementIds"],
|
||||
): GroupId | null => {
|
||||
if (!appState.editingGroupId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nonDeletedElements = app.scene.getNonDeletedElements();
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds,
|
||||
elements: nonDeletedElements,
|
||||
});
|
||||
const candidateElements = selectedElements.length
|
||||
? selectedElements
|
||||
: getElementsInGroup(nonDeletedElements, appState.editingGroupId);
|
||||
|
||||
for (const element of candidateElements) {
|
||||
const editingGroupIndex = element.groupIds.indexOf(appState.editingGroupId);
|
||||
if (editingGroupIndex !== -1 && element.groupIds[editingGroupIndex + 1]) {
|
||||
return element.groupIds[editingGroupIndex + 1] as GroupId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const actionDeselect = register({
|
||||
name: "deselect",
|
||||
label: "",
|
||||
trackEvent: false,
|
||||
perform: (_elements, appState, _, app) => {
|
||||
const activeTool = getNextActiveTool(appState, app);
|
||||
app.cursor.applyForTool(activeTool);
|
||||
|
||||
if (appState.editingGroupId) {
|
||||
const nonDeletedElements = app.scene.getNonDeletedElements();
|
||||
const selectedElementIds =
|
||||
Object.keys(appState.selectedElementIds).length > 0
|
||||
? appState.selectedElementIds
|
||||
: getElementsInGroup(
|
||||
nonDeletedElements,
|
||||
appState.editingGroupId,
|
||||
).reduce((acc, element) => {
|
||||
acc[element.id] = true;
|
||||
return acc;
|
||||
}, {} as Record<string, true>);
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
...selectGroupsForSelectedElements(
|
||||
{
|
||||
editingGroupId: getParentEditingGroupId(
|
||||
appState,
|
||||
app,
|
||||
selectedElementIds,
|
||||
),
|
||||
selectedElementIds,
|
||||
},
|
||||
nonDeletedElements,
|
||||
appState,
|
||||
app,
|
||||
),
|
||||
activeEmbeddable: null,
|
||||
activeTool,
|
||||
selectedLinearElement: null,
|
||||
selectionElement: null,
|
||||
showHyperlinkPopup: false,
|
||||
suggestedBinding: null,
|
||||
hoveredArrowTextAnchor: null,
|
||||
frameToHighlight: null,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
activeEmbeddable: null,
|
||||
activeTool,
|
||||
editingGroupId: null,
|
||||
selectedElementIds: makeNextSelectedElementIds({}, appState),
|
||||
selectedGroupIds: {},
|
||||
selectedLinearElement: null,
|
||||
selectionElement: null,
|
||||
showHyperlinkPopup: false,
|
||||
suggestedBinding: null,
|
||||
hoveredArrowTextAnchor: null,
|
||||
frameToHighlight: null,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
keyTest: (event, appState, _, app) => {
|
||||
if (event.key !== KEYS.ESCAPE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isWritableElement(event.target)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
!appState.newElement &&
|
||||
appState.multiElement === null &&
|
||||
!appState.selectedLinearElement?.isEditing &&
|
||||
(appState.activeEmbeddable !== null ||
|
||||
appState.activeTool.type !== app.state.preferredSelectionTool.type ||
|
||||
!!appState.editingGroupId ||
|
||||
!!appState.selectedLinearElement ||
|
||||
isSomeElementSelected(app.scene.getNonDeletedElements(), appState))
|
||||
);
|
||||
},
|
||||
});
|
||||
131
packages/excalidraw/actions/actionDistribute.tsx
Normal file
131
packages/excalidraw/actions/actionDistribute.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import { isFrameLikeElement } from "@excalidraw/element";
|
||||
|
||||
import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
||||
|
||||
import { distributeElements } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { getSelectedElementsByGroup } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import type { Distribution } from "@excalidraw/element";
|
||||
|
||||
import { IconButton } from "../components/IconButton";
|
||||
import {
|
||||
DistributeHorizontallyIcon,
|
||||
DistributeVerticallyIcon,
|
||||
} from "../components/icons";
|
||||
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
|
||||
import { getShortcutKey } from "../shortcut";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppClassProperties, AppState, UIAppState } from "../types";
|
||||
|
||||
const enableActionGroup = (appState: UIAppState, app: AppClassProperties) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
return (
|
||||
getSelectedElementsByGroup(
|
||||
selectedElements,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
appState,
|
||||
).length > 2 &&
|
||||
// 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,
|
||||
appState,
|
||||
app.scene,
|
||||
);
|
||||
|
||||
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 }) => (
|
||||
<IconButton
|
||||
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 }) => (
|
||||
<IconButton
|
||||
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)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
531
packages/excalidraw/actions/actionDuplicateSelection.test.tsx
Normal file
531
packages/excalidraw/actions/actionDuplicateSelection.test.tsx
Normal file
@@ -0,0 +1,531 @@
|
||||
import { ORIG_ID } from "@excalidraw/common";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import {
|
||||
act,
|
||||
assertElements,
|
||||
getCloneByOrigId,
|
||||
render,
|
||||
} from "../tests/test-utils";
|
||||
|
||||
import { actionDuplicateSelection } from "./actionDuplicateSelection";
|
||||
|
||||
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, selected: true },
|
||||
]);
|
||||
});
|
||||
|
||||
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"] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
135
packages/excalidraw/actions/actionDuplicateSelection.tsx
Normal file
135
packages/excalidraw/actions/actionDuplicateSelection.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
DEFAULT_GRID_SIZE,
|
||||
KEYS,
|
||||
MOBILE_ACTION_BUTTON_BG,
|
||||
arrayToMap,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import { LinearElementEditor } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
getSelectedElements,
|
||||
getSelectionStateForElements,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { syncMovedIndices } from "@excalidraw/element";
|
||||
|
||||
import { duplicateElements } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { IconButton } from "../components/IconButton";
|
||||
import { DuplicateIcon } from "../components/icons";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { getShortcutKey } from "../shortcut";
|
||||
|
||||
import { useStylesPanelMode } from "../components/App";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionDuplicateSelection = register({
|
||||
name: "duplicateSelection",
|
||||
label: "labels.duplicateSelection",
|
||||
icon: DuplicateIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, formData, app) => {
|
||||
if (appState.selectedElementsAreBeingDragged) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// duplicate selected point(s) if editing a line
|
||||
if (appState.selectedLinearElement?.isEditing) {
|
||||
// TODO: Invariants should be checked here instead of duplicateSelectedPoints()
|
||||
try {
|
||||
const newAppState = LinearElementEditor.duplicateSelectedPoints(
|
||||
appState,
|
||||
app.scene,
|
||||
);
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState: newAppState,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let { duplicatedElements, elementsWithDuplicates } = duplicateElements({
|
||||
type: "in-place",
|
||||
elements,
|
||||
idsOfElementsToDuplicate: arrayToMap(
|
||||
getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
}),
|
||||
),
|
||||
appState,
|
||||
randomizeSeed: true,
|
||||
overrides: ({ origElement, origIdToDuplicateId }) => {
|
||||
const duplicateFrameId =
|
||||
origElement.frameId && origIdToDuplicateId.get(origElement.frameId);
|
||||
return {
|
||||
x: origElement.x + DEFAULT_GRID_SIZE / 2,
|
||||
y: origElement.y + DEFAULT_GRID_SIZE / 2,
|
||||
frameId: duplicateFrameId ?? origElement.frameId,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
if (app.props.onDuplicate && elementsWithDuplicates) {
|
||||
const mappedElements = app.props.onDuplicate(
|
||||
elementsWithDuplicates,
|
||||
elements,
|
||||
);
|
||||
if (mappedElements) {
|
||||
elementsWithDuplicates = mappedElements;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
elements: syncMovedIndices(
|
||||
elementsWithDuplicates,
|
||||
arrayToMap(duplicatedElements),
|
||||
),
|
||||
appState: {
|
||||
...appState,
|
||||
...getSelectionStateForElements(
|
||||
duplicatedElements,
|
||||
getNonDeletedElements(elementsWithDuplicates),
|
||||
appState,
|
||||
),
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
const isMobile = useStylesPanelMode() === "mobile";
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
type="button"
|
||||
icon={DuplicateIcon}
|
||||
title={`${t("labels.duplicateSelection")} — ${getShortcutKey(
|
||||
"CtrlOrCmd+D",
|
||||
)}`}
|
||||
aria-label={t("labels.duplicateSelection")}
|
||||
onClick={() => updateData(null)}
|
||||
disabled={
|
||||
!isSomeElementSelected(getNonDeletedElements(elements), appState)
|
||||
}
|
||||
style={{
|
||||
...(isMobile && appState.openPopup !== "compactOtherProperties"
|
||||
? MOBILE_ACTION_BUTTON_BG
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
113
packages/excalidraw/actions/actionElementLink.ts
Normal file
113
packages/excalidraw/actions/actionElementLink.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
canCreateLinkFromElements,
|
||||
defaultGetElementLinkFromSelection,
|
||||
getLinkIdAndTypeFromSelection,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { copyTextToSystemClipboard } from "../clipboard";
|
||||
import { copyIcon, elementLinkIcon } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
import { getSelectedElements } from "../scene";
|
||||
|
||||
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,
|
||||
});
|
||||
70
packages/excalidraw/actions/actionElementLock.test.tsx
Normal file
70
packages/excalidraw/actions/actionElementLock.test.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { queryByTestId, fireEvent } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { Pointer, UI } from "../tests/helpers/ui";
|
||||
import { render } from "../tests/test-utils";
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
219
packages/excalidraw/actions/actionElementLock.ts
Normal file
219
packages/excalidraw/actions/actionElementLock.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { KEYS, arrayToMap, randomId } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
elementsAreInSameGroup,
|
||||
getNonDeletedElements,
|
||||
newElementWith,
|
||||
selectGroupsFromGivenElements,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { LockedIcon, UnlockedIcon } from "../components/icons";
|
||||
|
||||
import { getSelectedElements } from "../scene";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppState } from "../types";
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
return shouldLock(selected)
|
||||
? "labels.elementLock.lock"
|
||||
: "labels.elementLock.unlock";
|
||||
},
|
||||
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);
|
||||
|
||||
const isAGroup =
|
||||
selectedElements.length > 1 && elementsAreInSameGroup(selectedElements);
|
||||
const isASingleUnit = selectedElements.length === 1 || isAGroup;
|
||||
const newGroupId = isASingleUnit ? null : randomId();
|
||||
|
||||
let nextLockedMultiSelections = { ...appState.lockedMultiSelections };
|
||||
|
||||
if (nextLockState) {
|
||||
nextLockedMultiSelections = {
|
||||
...appState.lockedMultiSelections,
|
||||
...(newGroupId ? { [newGroupId]: true } : {}),
|
||||
};
|
||||
} else if (isAGroup) {
|
||||
const groupId = selectedElements[0].groupIds.at(-1)!;
|
||||
delete nextLockedMultiSelections[groupId];
|
||||
}
|
||||
|
||||
const nextElements = elements.map((element) => {
|
||||
if (!selectedElementsMap.has(element.id)) {
|
||||
return element;
|
||||
}
|
||||
|
||||
let nextGroupIds = element.groupIds;
|
||||
|
||||
// if locking together, add to group
|
||||
// if unlocking, remove the temporary group
|
||||
if (nextLockState) {
|
||||
if (newGroupId) {
|
||||
nextGroupIds = [...nextGroupIds, newGroupId];
|
||||
}
|
||||
} else {
|
||||
nextGroupIds = nextGroupIds.filter(
|
||||
(groupId) => !appState.lockedMultiSelections[groupId],
|
||||
);
|
||||
}
|
||||
|
||||
return newElementWith(element, {
|
||||
locked: nextLockState,
|
||||
// do not recreate the array unncessarily
|
||||
groupIds:
|
||||
nextGroupIds.length !== element.groupIds.length
|
||||
? nextGroupIds
|
||||
: element.groupIds,
|
||||
});
|
||||
});
|
||||
|
||||
const nextElementsMap = arrayToMap(nextElements);
|
||||
const nextSelectedElementIds: AppState["selectedElementIds"] = nextLockState
|
||||
? {}
|
||||
: Object.fromEntries(selectedElements.map((el) => [el.id, true]));
|
||||
const unlockedSelectedElements = selectedElements.map(
|
||||
(el) => nextElementsMap.get(el.id) || el,
|
||||
);
|
||||
const nextSelectedGroupIds = nextLockState
|
||||
? {}
|
||||
: selectGroupsFromGivenElements(
|
||||
getNonDeletedElements(unlockedSelectedElements),
|
||||
appState,
|
||||
);
|
||||
|
||||
const activeLockedId = nextLockState
|
||||
? newGroupId
|
||||
? newGroupId
|
||||
: isAGroup
|
||||
? selectedElements[0].groupIds.at(-1)!
|
||||
: selectedElements[0].id
|
||||
: null;
|
||||
|
||||
return {
|
||||
elements: nextElements,
|
||||
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: nextSelectedElementIds,
|
||||
selectedGroupIds: nextSelectedGroupIds,
|
||||
selectedLinearElement: nextLockState
|
||||
? null
|
||||
: appState.selectedLinearElement,
|
||||
lockedMultiSelections: nextLockedMultiSelections,
|
||||
activeLockedId,
|
||||
},
|
||||
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",
|
||||
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);
|
||||
|
||||
const nextElements = elements.map((element) => {
|
||||
if (element.locked) {
|
||||
// remove the temporary groupId if it exists
|
||||
const nextGroupIds = element.groupIds.filter(
|
||||
(gid) => !appState.lockedMultiSelections[gid],
|
||||
);
|
||||
|
||||
return newElementWith(element, {
|
||||
locked: false,
|
||||
groupIds:
|
||||
// do not recreate the array unncessarily
|
||||
element.groupIds.length !== nextGroupIds.length
|
||||
? nextGroupIds
|
||||
: element.groupIds,
|
||||
});
|
||||
}
|
||||
return element;
|
||||
});
|
||||
|
||||
const nextElementsMap = arrayToMap(nextElements);
|
||||
|
||||
const unlockedElements = lockedElements.map(
|
||||
(el) => nextElementsMap.get(el.id) || el,
|
||||
);
|
||||
const unlockedNonDeletedElements = getNonDeletedElements(unlockedElements);
|
||||
|
||||
return {
|
||||
elements: nextElements,
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: Object.fromEntries(
|
||||
unlockedNonDeletedElements.map((el) => [el.id, true]),
|
||||
),
|
||||
selectedGroupIds: selectGroupsFromGivenElements(
|
||||
unlockedNonDeletedElements,
|
||||
appState,
|
||||
),
|
||||
lockedMultiSelections: {},
|
||||
activeLockedId: null,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
label: "labels.elementLock.unlockAll",
|
||||
});
|
||||
462
packages/excalidraw/actions/actionExport.tsx
Normal file
462
packages/excalidraw/actions/actionExport.tsx
Normal file
@@ -0,0 +1,462 @@
|
||||
import { KEYS, THEME } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement, Theme } from "@excalidraw/element/types";
|
||||
|
||||
import { useEditorInterface } from "../components/App";
|
||||
import { CheckboxItem } from "../components/CheckboxItem";
|
||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||
import { ProjectName } from "../components/ProjectName";
|
||||
import { Toast } from "../components/Toast";
|
||||
import { IconButton } from "../components/IconButton";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { ExportIcon, questionCircle, saveAs } from "../components/icons";
|
||||
import { loadFromJSON, saveAsJSON } from "../data";
|
||||
import { isImageFileHandle } from "../data/blob";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
|
||||
import { resaveAsImageWithScene } from "../data/resave";
|
||||
|
||||
import { t } from "../i18n";
|
||||
|
||||
import "../components/ToolIcon.scss";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { JSONExportData } from "../data/json";
|
||||
|
||||
import type {
|
||||
AppClassProperties,
|
||||
AppState,
|
||||
BinaryFiles,
|
||||
ExcalidrawProps,
|
||||
OnExportProgress,
|
||||
} from "../types";
|
||||
|
||||
export const actionChangeProjectName = register<AppState["name"]>({
|
||||
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<AppState["exportScale"]>({
|
||||
name: "changeExportScale",
|
||||
label: "imageExportDialog.scale",
|
||||
trackEvent: { category: "export", action: "scale" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportScale: value },
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const actionChangeExportBackground = register<
|
||||
AppState["exportBackground"]
|
||||
>({
|
||||
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<
|
||||
AppState["exportEmbedScene"]
|
||||
>({
|
||||
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>
|
||||
),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// onExport interception helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let onExportInProgress = false;
|
||||
|
||||
const onProgressToast = (
|
||||
app: AppClassProperties,
|
||||
progress: {
|
||||
message?: OnExportProgress["message"];
|
||||
progress?: number | null;
|
||||
},
|
||||
) => {
|
||||
const message = progress.message ?? t("progressDialog.defaultMessage");
|
||||
app.setAppState({
|
||||
toast: {
|
||||
message:
|
||||
progress.progress != null ? (
|
||||
<>
|
||||
{message}
|
||||
<Toast.ProgressBar progress={progress.progress} />
|
||||
</>
|
||||
) : (
|
||||
message
|
||||
),
|
||||
duration: Infinity,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** awaits host app's onExport result, and renders progress to the UI */
|
||||
async function handleOnExportResult(
|
||||
onExportResult: ReturnType<NonNullable<ExcalidrawProps["onExport"]>>,
|
||||
opts: {
|
||||
signal: AbortSignal;
|
||||
app: AppClassProperties;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (opts.app.state.isLoading) {
|
||||
onProgressToast(opts.app, { progress: null });
|
||||
await opts.app.onStateChange({ predicate: (state) => !state.isLoading });
|
||||
}
|
||||
|
||||
if (
|
||||
onExportResult != null &&
|
||||
typeof onExportResult === "object" &&
|
||||
Symbol.asyncIterator in onExportResult
|
||||
) {
|
||||
for await (const value of onExportResult) {
|
||||
if (opts.signal.aborted) {
|
||||
onExportResult.return();
|
||||
return;
|
||||
}
|
||||
if (value.type === "progress") {
|
||||
onProgressToast(opts.app, {
|
||||
message: value.message,
|
||||
progress: value.progress ?? null,
|
||||
});
|
||||
} else if (value.type === "done") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Generator completed without explicit "done" message
|
||||
return;
|
||||
}
|
||||
|
||||
if (onExportResult instanceof Promise) {
|
||||
onProgressToast(opts.app, { progress: null });
|
||||
await onExportResult;
|
||||
}
|
||||
}
|
||||
|
||||
function prepareDataForJSONExport(
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
app: AppClassProperties,
|
||||
): { abortController: AbortController; data: Promise<JSONExportData> } {
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
|
||||
const dataPromise = new Promise<JSONExportData>(async (resolve) => {
|
||||
try {
|
||||
if (app.props.onExport) {
|
||||
await handleOnExportResult(
|
||||
app.props.onExport(
|
||||
"json",
|
||||
{
|
||||
elements,
|
||||
appState,
|
||||
files,
|
||||
},
|
||||
{
|
||||
signal,
|
||||
},
|
||||
),
|
||||
{
|
||||
app,
|
||||
signal,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.name === "AbortError") {
|
||||
// if abort error, assume it's a reaction on the signal being aborted
|
||||
console.warn(
|
||||
`onExport() aborted by host app (signal aborted: ${signal.aborted})`,
|
||||
);
|
||||
} else {
|
||||
// non-abort error
|
||||
//
|
||||
console.error("Error during props.onExport() handling", error);
|
||||
}
|
||||
|
||||
// either way, we currently don't allow host apps to cancel save actions
|
||||
// so we resolve to orig data
|
||||
}
|
||||
|
||||
resolve({
|
||||
elements,
|
||||
appState,
|
||||
// return latest files in case they finished loading during onExport
|
||||
files: app.files,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
abortController,
|
||||
data: dataPromise,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Save actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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) => {
|
||||
if (onExportInProgress) {
|
||||
return false;
|
||||
}
|
||||
onExportInProgress = true;
|
||||
|
||||
const previousFileHandle = appState.fileHandle;
|
||||
const filename = app.getName();
|
||||
|
||||
const { abortController, data: exportedDataPromise } =
|
||||
prepareDataForJSONExport(elements, appState, app.files, app);
|
||||
|
||||
try {
|
||||
const { fileHandle } = isImageFileHandle(previousFileHandle)
|
||||
? await resaveAsImageWithScene(
|
||||
exportedDataPromise,
|
||||
previousFileHandle,
|
||||
filename,
|
||||
)
|
||||
: await saveAsJSON({
|
||||
data: exportedDataPromise,
|
||||
filename,
|
||||
fileHandle: previousFileHandle,
|
||||
});
|
||||
|
||||
return {
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
appState: {
|
||||
fileHandle,
|
||||
toast: {
|
||||
message:
|
||||
previousFileHandle && fileHandle?.name
|
||||
? t("toast.fileSavedToFilename").replace(
|
||||
"{filename}",
|
||||
`"${fileHandle.name}"`,
|
||||
)
|
||||
: t("toast.fileSaved"),
|
||||
duration: 1500,
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
abortController.abort();
|
||||
|
||||
if (error?.name !== "AbortError") {
|
||||
console.error(error);
|
||||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
return {
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
appState: {
|
||||
toast: null,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
onExportInProgress = false;
|
||||
}
|
||||
},
|
||||
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) => {
|
||||
if (onExportInProgress) {
|
||||
return false;
|
||||
}
|
||||
onExportInProgress = true;
|
||||
|
||||
const { abortController, data: exportedDataPromise } =
|
||||
prepareDataForJSONExport(elements, appState, app.files, app);
|
||||
|
||||
try {
|
||||
const { fileHandle: savedFileHandle } = await saveAsJSON({
|
||||
data: exportedDataPromise,
|
||||
filename: app.getName(),
|
||||
fileHandle: null,
|
||||
});
|
||||
|
||||
return {
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
appState: {
|
||||
openDialog: null,
|
||||
fileHandle: savedFileHandle,
|
||||
toast: { message: t("toast.fileSaved"), duration: 3000 },
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
abortController.abort();
|
||||
if (error?.name !== "AbortError") {
|
||||
console.error(error);
|
||||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
return {
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
appState: {
|
||||
toast: null,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
onExportInProgress = false;
|
||||
}
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event.key.toLowerCase() === KEYS.S &&
|
||||
event.shiftKey &&
|
||||
event[KEYS.CTRL_OR_CMD],
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<IconButton
|
||||
type="button"
|
||||
icon={saveAs}
|
||||
title={t("buttons.saveAs")}
|
||||
aria-label={t("buttons.saveAs")}
|
||||
showAriaLabel={useEditorInterface().formFactor === "phone"}
|
||||
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<
|
||||
AppState["exportWithDarkMode"]
|
||||
>({
|
||||
name: "exportWithDarkMode",
|
||||
label: "imageExportDialog.label.darkMode",
|
||||
trackEvent: { category: "export", action: "toggleTheme" },
|
||||
perform: (_elements, appState, value, app) => {
|
||||
app.sessionExportThemeOverride = value ? THEME.DARK : THEME.LIGHT;
|
||||
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>
|
||||
),
|
||||
});
|
||||
439
packages/excalidraw/actions/actionFinalize.tsx
Normal file
439
packages/excalidraw/actions/actionFinalize.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
|
||||
import { bindOrUnbindBindingElement } from "@excalidraw/element/binding";
|
||||
import {
|
||||
isValidPolygon,
|
||||
LinearElementEditor,
|
||||
newElementWith,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
isBindingElement,
|
||||
isFreeDrawElement,
|
||||
isLinearElement,
|
||||
isLineElement,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
KEYS,
|
||||
arrayToMap,
|
||||
invariant,
|
||||
shouldRotateWithDiscreteAngle,
|
||||
updateActiveTool,
|
||||
} from "@excalidraw/common";
|
||||
import { isPathALoop } from "@excalidraw/element";
|
||||
|
||||
import { isInvisiblySmallElement } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
NonDeleted,
|
||||
PointsPositionUpdates,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { done } from "../components/icons";
|
||||
import { TOGGLE_TOOLS } from "../components/Tools";
|
||||
import { IconButton } from "../components/IconButton";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppState } from "../types";
|
||||
|
||||
type FormData = {
|
||||
event: PointerEvent;
|
||||
sceneCoords: { x: number; y: number };
|
||||
hitBoundText?: boolean;
|
||||
};
|
||||
|
||||
export const actionFinalize = register<FormData>({
|
||||
name: "finalize",
|
||||
label: "",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, data, app) => {
|
||||
let shouldCommit = true;
|
||||
let newElements = elements;
|
||||
const { focusContainer, scene } = app;
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const isDrawShapeTool = appState.activeTool.type === "autoshape";
|
||||
|
||||
if (data && appState.selectedLinearElement) {
|
||||
const { event, sceneCoords } = data;
|
||||
const element = LinearElementEditor.getElement(
|
||||
appState.selectedLinearElement.elementId,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
invariant(
|
||||
element,
|
||||
"Arrow element should exist if selectedLinearElement is set",
|
||||
);
|
||||
|
||||
invariant(
|
||||
sceneCoords,
|
||||
"sceneCoords should be defined if actionFinalize is called with event",
|
||||
);
|
||||
|
||||
const linearElementEditor = LinearElementEditor.handlePointerUp(
|
||||
event,
|
||||
appState.selectedLinearElement,
|
||||
appState,
|
||||
app.scene,
|
||||
);
|
||||
|
||||
if (
|
||||
isBindingElement(element) &&
|
||||
!appState.selectedLinearElement.segmentMidPointHoveredCoords &&
|
||||
// a label drag along the arrow moves no endpoint, so it must not
|
||||
// rebind either of them
|
||||
!data.hitBoundText
|
||||
) {
|
||||
const newArrow = !!appState.newElement;
|
||||
|
||||
const selectedPointsIndices =
|
||||
newArrow || !appState.selectedLinearElement.selectedPointsIndices
|
||||
? [element.points.length - 1] // New arrow creation
|
||||
: appState.selectedLinearElement.selectedPointsIndices;
|
||||
|
||||
const angleLocked = shouldRotateWithDiscreteAngle(event);
|
||||
const effectiveGridSize = event[KEYS.CTRL_OR_CMD]
|
||||
? null
|
||||
: app.getEffectiveGridSize();
|
||||
|
||||
const draggedPoints: PointsPositionUpdates =
|
||||
selectedPointsIndices.reduce((map, index) => {
|
||||
map.set(index, {
|
||||
point: angleLocked
|
||||
? element.points[index]
|
||||
: LinearElementEditor.createPointAt(
|
||||
element,
|
||||
elementsMap,
|
||||
sceneCoords.x - linearElementEditor.pointerOffset.x,
|
||||
sceneCoords.y - linearElementEditor.pointerOffset.y,
|
||||
effectiveGridSize,
|
||||
),
|
||||
});
|
||||
|
||||
return map;
|
||||
}, new Map()) ?? new Map();
|
||||
|
||||
bindOrUnbindBindingElement(
|
||||
element,
|
||||
draggedPoints,
|
||||
sceneCoords.x,
|
||||
sceneCoords.y,
|
||||
scene,
|
||||
appState,
|
||||
{
|
||||
newArrow,
|
||||
altKey: event.altKey,
|
||||
angleLocked,
|
||||
gridSize: app.getEffectiveGridSize(),
|
||||
},
|
||||
);
|
||||
} else if (isLineElement(element)) {
|
||||
if (
|
||||
appState.selectedLinearElement?.isEditing &&
|
||||
!appState.newElement &&
|
||||
!isValidPolygon(element.points)
|
||||
) {
|
||||
scene.mutateElement(element, {
|
||||
polygon: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (linearElementEditor !== appState.selectedLinearElement) {
|
||||
// `handlePointerUp()` updated the linear element instance,
|
||||
// so filter out this element if it is too small,
|
||||
// but do an update to all new elements anyway for undo/redo purposes.
|
||||
|
||||
if (element && isInvisiblySmallElement(element)) {
|
||||
// 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.map((el) => {
|
||||
if (el.id === element.id) {
|
||||
return newElementWith(el, {
|
||||
isDeleted: true,
|
||||
});
|
||||
}
|
||||
return el;
|
||||
});
|
||||
}
|
||||
|
||||
const activeToolLocked = appState.activeTool?.locked;
|
||||
|
||||
return {
|
||||
elements:
|
||||
element.points.length < 2 || isInvisiblySmallElement(element)
|
||||
? elements.map((el) => {
|
||||
if (el.id === element.id) {
|
||||
return newElementWith(el, { isDeleted: true });
|
||||
}
|
||||
return el;
|
||||
})
|
||||
: newElements,
|
||||
appState: {
|
||||
...appState,
|
||||
cursorButton: "up",
|
||||
selectedElementIds: isDrawShapeTool
|
||||
? {}
|
||||
: appState.selectedElementIds,
|
||||
selectedLinearElement:
|
||||
activeToolLocked || isDrawShapeTool
|
||||
? null
|
||||
: {
|
||||
...linearElementEditor,
|
||||
selectedPointsIndices: null,
|
||||
isEditing: false,
|
||||
initialState: {
|
||||
...linearElementEditor.initialState,
|
||||
lastClickedPoint: -1,
|
||||
},
|
||||
pointerOffset: { x: 0, y: 0 },
|
||||
},
|
||||
selectionElement: null,
|
||||
suggestedBinding: null,
|
||||
hoveredArrowTextAnchor: null,
|
||||
newElement: null,
|
||||
multiElement: null,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
focusContainer();
|
||||
|
||||
// clean up pending gesture even if active tool is already not drawShape
|
||||
const hadPendingSketch = app.drawShape.hasPendingGesture();
|
||||
if (hadPendingSketch || isDrawShapeTool) {
|
||||
app.drawShape.finalize();
|
||||
if (hadPendingSketch) {
|
||||
// finalize() inserts the recognized element via app.insertNewElement
|
||||
// — re-read so the returned array includes it (replaceAllElements
|
||||
// would otherwise drop it)
|
||||
newElements = app.scene.getElementsIncludingDeleted();
|
||||
}
|
||||
}
|
||||
|
||||
let element: NonDeleted<ExcalidrawElement> | null = null;
|
||||
if (appState.multiElement) {
|
||||
element = appState.multiElement;
|
||||
} else if (
|
||||
// the drawShape preview in `newElement` is not a scene element and the
|
||||
// sketch was finalized by app.drawShape.finalize() above — never treat
|
||||
// the preview as an in-progress element here
|
||||
!isDrawShapeTool &&
|
||||
!hadPendingSketch &&
|
||||
(appState.newElement?.type === "freedraw" ||
|
||||
isBindingElement(appState.newElement))
|
||||
) {
|
||||
element = appState.newElement;
|
||||
} else if (Object.keys(appState.selectedElementIds).length === 1) {
|
||||
const candidate = elementsMap.get(
|
||||
Object.keys(appState.selectedElementIds)[0],
|
||||
) as NonDeleted<ExcalidrawLinearElement> | undefined;
|
||||
if (candidate) {
|
||||
element = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (element) {
|
||||
// pen and mouse have hover
|
||||
if (
|
||||
appState.selectedLinearElement &&
|
||||
appState.multiElement &&
|
||||
element.type !== "freedraw" &&
|
||||
appState.lastPointerDownWith !== "touch"
|
||||
) {
|
||||
const { points } = element;
|
||||
const { lastCommittedPoint } = appState.selectedLinearElement;
|
||||
if (
|
||||
!lastCommittedPoint ||
|
||||
points[points.length - 1] !== lastCommittedPoint
|
||||
) {
|
||||
shouldCommit = false;
|
||||
scene.mutateElement(element, {
|
||||
points: element.points.slice(0, -1),
|
||||
});
|
||||
if (
|
||||
isBindingElement(element) &&
|
||||
element.endBinding &&
|
||||
// after slicing the trailing point a <2-point arrow may be left
|
||||
element.points.length > 1
|
||||
) {
|
||||
const newArrow = !!appState.newElement;
|
||||
const draggedPoints: PointsPositionUpdates = new Map([
|
||||
[
|
||||
element.points.length - 1,
|
||||
{
|
||||
point: element.points[element.points.length - 1],
|
||||
isDragging: false,
|
||||
},
|
||||
],
|
||||
]);
|
||||
const globalPoint =
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
element,
|
||||
-1,
|
||||
elementsMap,
|
||||
);
|
||||
bindOrUnbindBindingElement(
|
||||
element,
|
||||
draggedPoints,
|
||||
globalPoint[0],
|
||||
globalPoint[1],
|
||||
scene,
|
||||
appState,
|
||||
{
|
||||
newArrow,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (element && isInvisiblySmallElement(element)) {
|
||||
// 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.map((el) => {
|
||||
if (el.id === element?.id) {
|
||||
return newElementWith(el, { isDeleted: true });
|
||||
}
|
||||
return el;
|
||||
});
|
||||
}
|
||||
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
// 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(element.points, appState.zoom.value);
|
||||
|
||||
if (isLoop && (isLineElement(element) || isFreeDrawElement(element))) {
|
||||
const linePoints = element.points;
|
||||
const firstPoint = linePoints[0];
|
||||
const points: LocalPoint[] = linePoints.map((p, index) =>
|
||||
index === linePoints.length - 1
|
||||
? pointFrom(firstPoint[0], firstPoint[1])
|
||||
: p,
|
||||
);
|
||||
if (isLineElement(element)) {
|
||||
scene.mutateElement(element, {
|
||||
points,
|
||||
polygon: true,
|
||||
});
|
||||
} else {
|
||||
scene.mutateElement(element, {
|
||||
points,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isLineElement(element) && !isValidPolygon(element.points)) {
|
||||
scene.mutateElement(element, {
|
||||
polygon: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let activeTool: AppState["activeTool"];
|
||||
if (TOGGLE_TOOLS.includes(appState.activeTool.type)) {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
...(appState.activeTool.lastActiveTool || {
|
||||
type: app.state.preferredSelectionTool.type,
|
||||
}),
|
||||
lastActiveTool: null,
|
||||
});
|
||||
} else {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
type: app.state.preferredSelectionTool.type,
|
||||
});
|
||||
}
|
||||
|
||||
// locked via the tool lock or host-forced (`props.activeTool`)
|
||||
const isToolLocked = app.isToolLocked();
|
||||
|
||||
// the drawShape flow finalizes without a `newElement` (the sketch is
|
||||
// converted separately), so it stays active regardless of `element`
|
||||
const keepActiveTool =
|
||||
appState.activeTool.type === "autoshape" ||
|
||||
((isToolLocked || appState.activeTool.type === "freedraw") && !!element);
|
||||
|
||||
if (!keepActiveTool) {
|
||||
// the active tool reverts (see the returned `appState.activeTool`) —
|
||||
// apply the reverted tool's cursor
|
||||
app.cursor.applyForTool(activeTool);
|
||||
}
|
||||
|
||||
let selectedLinearElement =
|
||||
element && isLinearElement(element)
|
||||
? new LinearElementEditor(element, arrayToMap(newElements)) // To select the linear element when user has finished mutipoint editing
|
||||
: appState.selectedLinearElement;
|
||||
|
||||
selectedLinearElement = selectedLinearElement
|
||||
? {
|
||||
...selectedLinearElement,
|
||||
isEditing: appState.newElement
|
||||
? false
|
||||
: selectedLinearElement.isEditing,
|
||||
initialState: {
|
||||
...selectedLinearElement.initialState,
|
||||
lastClickedPoint: -1,
|
||||
origin: null,
|
||||
},
|
||||
}
|
||||
: selectedLinearElement;
|
||||
|
||||
return {
|
||||
elements: newElements,
|
||||
appState: {
|
||||
...appState,
|
||||
cursorButton: "up",
|
||||
activeTool: keepActiveTool ? appState.activeTool : activeTool,
|
||||
activeEmbeddable: null,
|
||||
newElement: null,
|
||||
selectionElement: null,
|
||||
multiElement: null,
|
||||
editingTextElement: null,
|
||||
suggestedBinding: null,
|
||||
hoveredArrowTextAnchor: null,
|
||||
frameToHighlight: null,
|
||||
selectedElementIds: isDrawShapeTool
|
||||
? {}
|
||||
: element && !isToolLocked && appState.activeTool.type !== "freedraw"
|
||||
? {
|
||||
...appState.selectedElementIds,
|
||||
[element.id]: true,
|
||||
}
|
||||
: appState.selectedElementIds,
|
||||
|
||||
selectedLinearElement: isDrawShapeTool ? null : selectedLinearElement,
|
||||
},
|
||||
// TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit
|
||||
captureUpdate: shouldCommit
|
||||
? CaptureUpdateAction.IMMEDIATELY
|
||||
: CaptureUpdateAction.NEVER,
|
||||
};
|
||||
},
|
||||
keyTest: (event, appState) =>
|
||||
(event.key === KEYS.ESCAPE && appState.selectedLinearElement?.isEditing) ||
|
||||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
||||
appState.multiElement !== null),
|
||||
PanelComponent: ({ appState, updateData, data }) => (
|
||||
<IconButton
|
||||
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" }}
|
||||
/>
|
||||
),
|
||||
});
|
||||
211
packages/excalidraw/actions/actionFlip.test.tsx
Normal file
211
packages/excalidraw/actions/actionFlip.test.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { render } from "../tests/test-utils";
|
||||
|
||||
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",
|
||||
fixedPoint: [0.49, -0.05],
|
||||
mode: "orbit",
|
||||
},
|
||||
endBinding: {
|
||||
elementId: "rec2",
|
||||
fixedPoint: [-0.05, 0.49],
|
||||
mode: "orbit",
|
||||
},
|
||||
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(101, 0);
|
||||
|
||||
const rec2 = h.elements.find((el) => el.id === "rec2")!;
|
||||
expect(rec2.x).toBeCloseTo(220, 0);
|
||||
expect(rec2.y).toBeCloseTo(251, 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,
|
||||
fixedPoint: [0.5, 0.5],
|
||||
mode: "orbit",
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
fixedPoint: [0.5, 0.5],
|
||||
mode: "orbit",
|
||||
},
|
||||
endBinding: {
|
||||
elementId: rect2.id,
|
||||
fixedPoint: [0.5, 0.5],
|
||||
mode: "orbit",
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
fixedPoint: [0.5, 0.5],
|
||||
mode: "orbit",
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
196
packages/excalidraw/actions/actionFlip.ts
Normal file
196
packages/excalidraw/actions/actionFlip.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
import { bindOrUnbindBindingElements } from "@excalidraw/element";
|
||||
import { getCommonBoundingBox } from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { deepCopyElement } from "@excalidraw/element";
|
||||
import { resizeMultipleElements } from "@excalidraw/element";
|
||||
import { isArrowElement, isElbowArrow } from "@excalidraw/element";
|
||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
||||
import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawElement,
|
||||
NonDeleted,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { getSelectedElements } from "../scene";
|
||||
|
||||
import { flipHorizontal, flipVertical } from "../components/icons";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
|
||||
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,
|
||||
flipDirection,
|
||||
app,
|
||||
);
|
||||
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
|
||||
return elements.map(
|
||||
(element) => updatedElementsMap.get(element.id) || element,
|
||||
);
|
||||
};
|
||||
|
||||
const flipElements = (
|
||||
selectedElements: NonDeleted<ExcalidrawElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
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,
|
||||
},
|
||||
);
|
||||
|
||||
bindOrUnbindBindingElements(
|
||||
selectedElements.filter(isArrowElement),
|
||||
app.scene,
|
||||
app.state,
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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) =>
|
||||
app.scene.mutateElement(element, {
|
||||
x: element.x + diffX,
|
||||
y: element.y + diffY,
|
||||
}),
|
||||
);
|
||||
elbowArrows.forEach((element) =>
|
||||
app.scene.mutateElement(element, {
|
||||
x: element.x + diffX,
|
||||
y: element.y + diffY,
|
||||
}),
|
||||
);
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return selectedElements;
|
||||
};
|
||||
180
packages/excalidraw/actions/actionFrame.ts
Normal file
180
packages/excalidraw/actions/actionFrame.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
import { mutateElement } from "@excalidraw/element";
|
||||
import { newFrameElement } from "@excalidraw/element";
|
||||
import { isFrameLikeElement } from "@excalidraw/element";
|
||||
import {
|
||||
addElementsToFrame,
|
||||
removeAllElementsFromFrame,
|
||||
} from "@excalidraw/element";
|
||||
import { getFrameChildren } from "@excalidraw/element";
|
||||
|
||||
import { getElementsInGroup } from "@excalidraw/element";
|
||||
|
||||
import { getCommonBounds } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { getSelectedElements } from "../scene";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppClassProperties, UIAppState } from "../types";
|
||||
|
||||
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.frameRendering.enabled,
|
||||
});
|
||||
|
||||
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 elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
|
||||
const [x1, y1, x2, y2] = getCommonBounds(selectedElements, elementsMap);
|
||||
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, elementsMap, {
|
||||
groupIds: elementInGroup.groupIds.slice(0, index),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const nextElements = addElementsToFrame(
|
||||
[...app.scene.getElementsIncludingDeleted(), frame],
|
||||
selectedElements,
|
||||
frame,
|
||||
);
|
||||
|
||||
return {
|
||||
elements: nextElements,
|
||||
appState: {
|
||||
selectedElementIds: { [frame.id]: true },
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
});
|
||||
323
packages/excalidraw/actions/actionGroup.tsx
Normal file
323
packages/excalidraw/actions/actionGroup.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
|
||||
import { isBoundToContainer } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
frameAndChildrenSelectedTogether,
|
||||
getElementsInResizingFrame,
|
||||
getFrameLikeElements,
|
||||
getRootElements,
|
||||
groupByFrameLikes,
|
||||
removeElementsFromFrame,
|
||||
replaceAllElementsInFrame,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { KEYS, randomId, arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
getSelectedGroupIds,
|
||||
selectGroup,
|
||||
selectGroupsForSelectedElements,
|
||||
getElementsInGroup,
|
||||
addToGroup,
|
||||
removeFromSelectedGroups,
|
||||
isElementInGroup,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { syncMovedIndices } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
OrderedExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { IconButton } from "../components/IconButton";
|
||||
import { UngroupIcon, GroupIcon } from "../components/icons";
|
||||
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
|
||||
import { getShortcutKey } from "../shortcut";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppClassProperties, UIAppState } from "../types";
|
||||
|
||||
const allElementsInSameGroup = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
editingGroupId: UIAppState["editingGroupId"],
|
||||
) => {
|
||||
if (elements.length >= 2) {
|
||||
const editingGroupIndex = editingGroupId
|
||||
? elements[0].groupIds.indexOf(editingGroupId)
|
||||
: -1;
|
||||
const groupIds =
|
||||
editingGroupIndex > -1
|
||||
? elements[0].groupIds.slice(0, editingGroupIndex)
|
||||
: elements[0].groupIds;
|
||||
for (const groupId of groupIds) {
|
||||
if (elements.every((element) => isElementInGroup(element, groupId))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const enableActionGroup = (appState: UIAppState, app: AppClassProperties) => {
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: false,
|
||||
});
|
||||
|
||||
return (
|
||||
selectedElements.length >= 2 &&
|
||||
!allElementsInSameGroup(selectedElements, appState.editingGroupId) &&
|
||||
!frameAndChildrenSelectedTogether(selectedElements)
|
||||
);
|
||||
};
|
||||
|
||||
export const actionGroup = register({
|
||||
name: "group",
|
||||
label: "labels.group",
|
||||
icon: (appState) => <GroupIcon theme={appState.theme} />,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
if (!enableActionGroup(appState, app)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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(appState, app),
|
||||
keyTest: (event) =>
|
||||
!event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G,
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<IconButton
|
||||
hidden={!enableActionGroup(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)}
|
||||
></IconButton>
|
||||
),
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 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 }) => (
|
||||
<IconButton
|
||||
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)}
|
||||
></IconButton>
|
||||
),
|
||||
});
|
||||
146
packages/excalidraw/actions/actionHistory.tsx
Normal file
146
packages/excalidraw/actions/actionHistory.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
KEYS,
|
||||
matchKey,
|
||||
arrayToMap,
|
||||
MOBILE_ACTION_BUTTON_BG,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { orderByFractionalIndex } from "@excalidraw/element";
|
||||
|
||||
import type { SceneElementsMap } from "@excalidraw/element/types";
|
||||
|
||||
import { IconButton } from "../components/IconButton";
|
||||
import { UndoIcon, RedoIcon } from "../components/icons";
|
||||
import { HistoryChangedEvent } from "../history";
|
||||
import { useEmitter } from "../hooks/useEmitter";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { useStylesPanelMode } from "../components/App";
|
||||
|
||||
import type { History } from "../history";
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
import type { Action, ActionResult } from "./types";
|
||||
|
||||
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.flowchart.isCreatingChart &&
|
||||
// a drawShape sketch in progress isn't visible via `newElement` while
|
||||
// the stroke is still unrecognized — block history mid-gesture the same
|
||||
// way as for the other in-progress interactions above
|
||||
!app.drawShape.hasPendingGesture()
|
||||
) {
|
||||
const result = updater();
|
||||
|
||||
if (!result) {
|
||||
return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
|
||||
}
|
||||
|
||||
const [nextElementsMap, nextAppState] = result;
|
||||
|
||||
// order by fractional indices in case the map was accidently modified in the meantime
|
||||
const nextElements = orderByFractionalIndex(
|
||||
Array.from(nextElementsMap.values()),
|
||||
);
|
||||
|
||||
return {
|
||||
appState: nextAppState,
|
||||
elements: nextElements,
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
};
|
||||
}
|
||||
|
||||
return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
|
||||
};
|
||||
|
||||
type ActionCreator = (history: History) => Action;
|
||||
|
||||
export const createUndoAction: ActionCreator = (history) => ({
|
||||
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, appState),
|
||||
),
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
|
||||
PanelComponent: ({ appState, updateData, data, app }) => {
|
||||
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
|
||||
history.onHistoryChangedEmitter,
|
||||
new HistoryChangedEvent(
|
||||
history.isUndoStackEmpty,
|
||||
history.isRedoStackEmpty,
|
||||
),
|
||||
);
|
||||
const isMobile = useStylesPanelMode() === "mobile";
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
type="button"
|
||||
icon={UndoIcon}
|
||||
aria-label={t("buttons.undo")}
|
||||
onClick={updateData}
|
||||
size={data?.size || "medium"}
|
||||
disabled={isUndoStackEmpty}
|
||||
data-testid="button-undo"
|
||||
style={{
|
||||
...(isMobile ? MOBILE_ACTION_BUTTON_BG : {}),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const createRedoAction: ActionCreator = (history) => ({
|
||||
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, appState),
|
||||
),
|
||||
keyTest: (event) =>
|
||||
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
|
||||
(event[KEYS.CTRL_OR_CMD] && !event.shiftKey && matchKey(event, KEYS.Y)),
|
||||
PanelComponent: ({ appState, updateData, data, app }) => {
|
||||
const { isRedoStackEmpty } = useEmitter(
|
||||
history.onHistoryChangedEmitter,
|
||||
new HistoryChangedEvent(
|
||||
history.isUndoStackEmpty,
|
||||
history.isRedoStackEmpty,
|
||||
),
|
||||
);
|
||||
const isMobile = useStylesPanelMode() === "mobile";
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
type="button"
|
||||
icon={RedoIcon}
|
||||
aria-label={t("buttons.redo")}
|
||||
onClick={updateData}
|
||||
size={data?.size || "medium"}
|
||||
disabled={isRedoStackEmpty}
|
||||
data-testid="button-redo"
|
||||
style={{
|
||||
...(isMobile ? MOBILE_ACTION_BUTTON_BG : {}),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
212
packages/excalidraw/actions/actionLinearEditor.tsx
Normal file
212
packages/excalidraw/actions/actionLinearEditor.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import {
|
||||
isElbowArrow,
|
||||
isLinearElement,
|
||||
isLineElement,
|
||||
} from "@excalidraw/element";
|
||||
import { arrayToMap, invariant } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
toggleLinePolygonState,
|
||||
CaptureUpdateAction,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawLineElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
|
||||
import { IconButton } from "../components/IconButton";
|
||||
import { lineEditorIcon, polygonIcon } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { newElementWith } from "../../element/src/mutateElement";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
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.selectedLinearElement?.isEditing &&
|
||||
selectedElements.length === 1 &&
|
||||
isLinearElement(selectedElements[0]) &&
|
||||
!isElbowArrow(selectedElements[0])
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
perform(elements, appState, _, app) {
|
||||
invariant(
|
||||
appState.selectedLinearElement,
|
||||
"No selected linear element found",
|
||||
);
|
||||
|
||||
const selectedElement = app.scene.getElement(
|
||||
appState.selectedLinearElement.elementId,
|
||||
) as ExcalidrawLinearElement | null;
|
||||
|
||||
invariant(selectedElement, "No selected element found");
|
||||
|
||||
const selectedLinearElement = {
|
||||
...appState.selectedLinearElement,
|
||||
isEditing: !appState.selectedLinearElement.isEditing,
|
||||
};
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
selectedLinearElement,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData, app }) => {
|
||||
const selectedElement = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
})[0] as ExcalidrawLinearElement;
|
||||
|
||||
if (!selectedElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = t(
|
||||
selectedElement.type === "arrow"
|
||||
? "labels.lineEditor.editArrow"
|
||||
: "labels.lineEditor.edit",
|
||||
);
|
||||
return (
|
||||
<IconButton
|
||||
type="button"
|
||||
icon={lineEditorIcon}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
onClick={() => updateData(null)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const actionTogglePolygon = register({
|
||||
name: "togglePolygon",
|
||||
category: DEFAULT_CATEGORIES.elements,
|
||||
icon: polygonIcon,
|
||||
keywords: ["loop"],
|
||||
label: (elements, appState, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
});
|
||||
|
||||
const allPolygons = !selectedElements.some(
|
||||
(element) => !isLineElement(element) || !element.polygon,
|
||||
);
|
||||
|
||||
return allPolygons
|
||||
? "labels.polygon.breakPolygon"
|
||||
: "labels.polygon.convertToPolygon";
|
||||
},
|
||||
trackEvent: {
|
||||
category: "element",
|
||||
},
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
});
|
||||
|
||||
return (
|
||||
selectedElements.length > 0 &&
|
||||
selectedElements.every(
|
||||
(element) => isLineElement(element) && element.points.length >= 4,
|
||||
)
|
||||
);
|
||||
},
|
||||
perform(elements, appState, _, app) {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
|
||||
if (selectedElements.some((element) => !isLineElement(element))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetElements = selectedElements as ExcalidrawLineElement[];
|
||||
|
||||
// if one element not a polygon, convert all to polygon
|
||||
const nextPolygonState = targetElements.some((element) => !element.polygon);
|
||||
|
||||
const targetElementsMap = arrayToMap(targetElements);
|
||||
|
||||
return {
|
||||
elements: elements.map((element) => {
|
||||
if (!targetElementsMap.has(element.id) || !isLineElement(element)) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return newElementWith(element, {
|
||||
backgroundColor: nextPolygonState
|
||||
? element.backgroundColor
|
||||
: "transparent",
|
||||
...toggleLinePolygonState(element, nextPolygonState),
|
||||
});
|
||||
}),
|
||||
appState,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData, app }) => {
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
});
|
||||
|
||||
if (
|
||||
selectedElements.length === 0 ||
|
||||
selectedElements.some(
|
||||
(element) =>
|
||||
!isLineElement(element) ||
|
||||
// only show polygon button if every selected element is already
|
||||
// a polygon, effectively showing this button only to allow for
|
||||
// disabling the polygon state
|
||||
!element.polygon ||
|
||||
element.points.length < 3,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allPolygon = selectedElements.every(
|
||||
(element) => isLineElement(element) && element.polygon,
|
||||
);
|
||||
|
||||
const label = t(
|
||||
allPolygon
|
||||
? "labels.polygon.breakPolygon"
|
||||
: "labels.polygon.convertToPolygon",
|
||||
);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
type="toggle"
|
||||
icon={polygonIcon}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
checked={allPolygon}
|
||||
onSelect={() => updateData(null)}
|
||||
style={{ marginLeft: "auto" }}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
65
packages/excalidraw/actions/actionLink.tsx
Normal file
65
packages/excalidraw/actions/actionLink.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
getNonDeletedElements,
|
||||
isEmbeddableElement,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { KEYS } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { IconButton } from "../components/IconButton";
|
||||
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
|
||||
import { LinkIcon } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { getShortcutKey } from "../shortcut";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionLink = register({
|
||||
name: "hyperlink",
|
||||
label: (elements, appState) =>
|
||||
getContextMenuLabel(getNonDeletedElements(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 (
|
||||
<IconButton
|
||||
type="toggle"
|
||||
icon={LinkIcon}
|
||||
aria-label={t(
|
||||
getContextMenuLabel(getNonDeletedElements(elements), appState),
|
||||
)}
|
||||
title={`${
|
||||
isEmbeddableElement(elements[0])
|
||||
? t("labels.link.labelEmbed")
|
||||
: t("labels.link.label")
|
||||
} - ${getShortcutKey("CtrlOrCmd+K")}`}
|
||||
onSelect={() => updateData(null)}
|
||||
checked={selectedElements.length === 1 && !!selectedElements[0].link}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
35
packages/excalidraw/actions/actionMenu.tsx
Normal file
35
packages/excalidraw/actions/actionMenu.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { KEYS } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { HelpIconThin } from "../components/icons";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
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",
|
||||
},
|
||||
openMenu: null,
|
||||
openPopup: null,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.key === KEYS.QUESTION_MARK,
|
||||
});
|
||||
151
packages/excalidraw/actions/actionNavigate.tsx
Normal file
151
packages/excalidraw/actions/actionNavigate.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { invariant } from "@excalidraw/common";
|
||||
|
||||
import { getClientColor } from "../clients";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import {
|
||||
eyeIcon,
|
||||
microphoneIcon,
|
||||
microphoneMutedIcon,
|
||||
} from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { GoToCollaboratorComponentProps } from "../components/UserList";
|
||||
import type { Collaborator } from "../types";
|
||||
|
||||
export const actionGoToCollaborator = register<Collaborator>({
|
||||
name: "goToCollaborator",
|
||||
label: "Go to a collaborator",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "collab" },
|
||||
perform: (_elements, appState, collaborator, app) => {
|
||||
invariant(
|
||||
collaborator,
|
||||
"actionGoToCollaborator: collaborator should be defined when actionGoToCollaborator is called",
|
||||
);
|
||||
|
||||
if (
|
||||
!collaborator.socketId ||
|
||||
app.props.userToFollow?.socketId === collaborator.socketId ||
|
||||
collaborator.isCurrentUser
|
||||
) {
|
||||
app.requestUnfollow();
|
||||
return {
|
||||
appState,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
}
|
||||
|
||||
app.emitUserFollowIntent({
|
||||
userToFollow: {
|
||||
socketId: collaborator.socketId,
|
||||
username: collaborator.username || "",
|
||||
},
|
||||
action: "FOLLOW",
|
||||
});
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
// 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={
|
||||
collaborator.isCurrentUser
|
||||
? undefined
|
||||
: () => 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>
|
||||
)}
|
||||
{collaborator.isCurrentUser && ` (${t("labels.you")})`}
|
||||
{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>
|
||||
);
|
||||
},
|
||||
});
|
||||
255
packages/excalidraw/actions/actionProperties.test.tsx
Normal file
255
packages/excalidraw/actions/actionProperties.test.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import { fireEvent, queryByTestId } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
||||
FREEDRAW_STROKE_WIDTH,
|
||||
FONT_FAMILY,
|
||||
STROKE_WIDTH,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { UI } from "../tests/helpers/ui";
|
||||
import { render } from "../tests/test-utils";
|
||||
|
||||
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 not show fill style for sticky note tool", () => {
|
||||
UI.clickTool("stickynote");
|
||||
|
||||
API.setAppState({
|
||||
currentItemBackgroundColor: DEFAULT_ELEMENT_BACKGROUND_PICKS[1],
|
||||
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 not show fill style for selected sticky note", () => {
|
||||
const stickyNote = API.createElement({
|
||||
type: "stickynote",
|
||||
backgroundColor: DEFAULT_ELEMENT_BACKGROUND_PICKS[1],
|
||||
fillStyle: "hachure",
|
||||
});
|
||||
API.setElements([stickyNote]);
|
||||
API.setSelectedElements([stickyNote]);
|
||||
|
||||
const hachureFillButton = queryByTestId(document.body, `fill-hachure`);
|
||||
expect(hachureFillButton).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 highlight common stroke width key across freedraw and non-freedraw elements", () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
strokeWidth: STROKE_WIDTH.medium,
|
||||
});
|
||||
const freedraw = API.createElement({
|
||||
type: "freedraw",
|
||||
strokeWidth: FREEDRAW_STROKE_WIDTH.medium,
|
||||
});
|
||||
API.setElements([rect, freedraw]);
|
||||
API.setSelectedElements([rect, freedraw]);
|
||||
|
||||
expect(queryByTestId(document.body, `strokeWidth-medium`)).toBeChecked();
|
||||
});
|
||||
|
||||
it("should apply stroke width by element type", () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
strokeWidth: STROKE_WIDTH.thin,
|
||||
});
|
||||
const freedraw = API.createElement({
|
||||
type: "freedraw",
|
||||
strokeWidth: FREEDRAW_STROKE_WIDTH.thin,
|
||||
});
|
||||
API.setElements([rect, freedraw]);
|
||||
API.setSelectedElements([rect, freedraw]);
|
||||
|
||||
const boldStrokeWidthButton = queryByTestId(
|
||||
document.body,
|
||||
`strokeWidth-bold`,
|
||||
);
|
||||
expect(boldStrokeWidthButton).not.toBe(null);
|
||||
fireEvent.click(boldStrokeWidthButton!);
|
||||
|
||||
const selectedElements = API.getSelectedElements();
|
||||
const selectedRect = selectedElements.find(
|
||||
(element) => element.type === "rectangle",
|
||||
);
|
||||
const selectedFreedraw = selectedElements.find(
|
||||
(element) => element.type === "freedraw",
|
||||
);
|
||||
|
||||
expect(selectedRect?.strokeWidth).toBe(STROKE_WIDTH.bold);
|
||||
expect(selectedFreedraw?.strokeWidth).toBe(FREEDRAW_STROKE_WIDTH.bold);
|
||||
});
|
||||
|
||||
it("should create new elements with stroke width by element type", () => {
|
||||
API.setAppState({ currentItemStrokeWidthKey: "bold" });
|
||||
|
||||
const rect = API.createElement({ type: "rectangle" });
|
||||
const freedraw = API.createElement({ type: "freedraw" });
|
||||
|
||||
expect(rect.strokeWidth).toBe(STROKE_WIDTH.bold);
|
||||
expect(freedraw.strokeWidth).toBe(FREEDRAW_STROKE_WIDTH.bold);
|
||||
});
|
||||
|
||||
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.medium,
|
||||
});
|
||||
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-medium`),
|
||||
).not.toBeChecked();
|
||||
expect(
|
||||
queryByTestId(document.body, `strokeWidth-bold`),
|
||||
).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("should show properties of different element types when selected", () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
strokeWidth: STROKE_WIDTH.medium,
|
||||
});
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
fontFamily: FONT_FAMILY["Comic Shanns"],
|
||||
});
|
||||
API.setElements([rect, text]);
|
||||
API.setSelectedElements([rect, text]);
|
||||
|
||||
expect(queryByTestId(document.body, `strokeWidth-medium`)).toBeChecked();
|
||||
expect(queryByTestId(document.body, `font-family-code`)).toHaveClass(
|
||||
"active",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
2293
packages/excalidraw/actions/actionProperties.tsx
Normal file
2293
packages/excalidraw/actions/actionProperties.tsx
Normal file
File diff suppressed because it is too large
Load Diff
70
packages/excalidraw/actions/actionSelectAll.ts
Normal file
70
packages/excalidraw/actions/actionSelectAll.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
import { LinearElementEditor } from "@excalidraw/element";
|
||||
import { isLinearElement, isTextElement } from "@excalidraw/element";
|
||||
|
||||
import { arrayToMap, KEYS } from "@excalidraw/common";
|
||||
|
||||
import { selectGroupsForSelectedElements } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
NonDeleted,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { selectAllIcon } from "../components/icons";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionSelectAll = register({
|
||||
name: "selectAll",
|
||||
label: "labels.selectAll",
|
||||
icon: selectAllIcon,
|
||||
trackEvent: { category: "canvas" },
|
||||
viewMode: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
if (appState.selectedLinearElement?.isEditing) {
|
||||
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] as NonDeleted<ExcalidrawLinearElement>,
|
||||
arrayToMap(elements),
|
||||
)
|
||||
: null,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A,
|
||||
});
|
||||
231
packages/excalidraw/actions/actionStyles.ts
Normal file
231
packages/excalidraw/actions/actionStyles.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import {
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
CODES,
|
||||
KEYS,
|
||||
getLineHeight,
|
||||
arrayToMap,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { newElementWith, syncStickyNoteInk } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
normalizeStickyNote,
|
||||
hasBoundTextElement,
|
||||
canApplyRoundnessTypeToElement,
|
||||
getDefaultRoundnessTypeForElement,
|
||||
isFrameLikeElement,
|
||||
isArrowElement,
|
||||
isExcalidrawElement,
|
||||
getColorUpdate,
|
||||
isNonDeletedElement,
|
||||
isStickyNoteBoundText,
|
||||
isStickyNoteElement,
|
||||
isTextElement,
|
||||
getBaseFontSize,
|
||||
getBaseFontSizeUpdate,
|
||||
relayoutStickyNotes,
|
||||
updateBoundElements,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
getBoundTextElement,
|
||||
redrawTextBoundingBox,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawTextElement } from "@excalidraw/element/types";
|
||||
|
||||
import { paintIcon } from "../components/icons";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { getSelectedElements } from "../scene";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
// `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);
|
||||
const elementsMap = arrayToMap(elements);
|
||||
// whether the copied text was a sticky label is decided by the copied
|
||||
// snapshot — its container may be gone from the live scene by now
|
||||
const copiedElementsMap = arrayToMap(
|
||||
(elementsCopied as unknown[]).filter(isExcalidrawElement),
|
||||
);
|
||||
const nextElements = relayoutStickyNotes(
|
||||
// a restyled note and its label end up with one ink — the label's,
|
||||
// when the copied styles carry two colors
|
||||
syncStickyNoteInk(
|
||||
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 sourceText =
|
||||
elementStylesToCopyFrom as ExcalidrawTextElement;
|
||||
const fontSize =
|
||||
(isTextElement(elementStylesToCopyFrom)
|
||||
? getBaseFontSize(elementStylesToCopyFrom, copiedElementsMap)
|
||||
: sourceText.fontSize) || DEFAULT_FONT_SIZE;
|
||||
const fontFamily = sourceText.fontFamily || DEFAULT_FONT_FAMILY;
|
||||
let container = null;
|
||||
const containerId = newElement.containerId;
|
||||
if (containerId) {
|
||||
container =
|
||||
selectedElements.find(
|
||||
(element) => element.id === containerId,
|
||||
) || null;
|
||||
}
|
||||
const newTextElement = newElementWith(newElement, {
|
||||
...getBaseFontSizeUpdate(newElement, fontSize, elementsMap),
|
||||
fontFamily,
|
||||
textAlign: sourceText.textAlign || DEFAULT_TEXT_ALIGN,
|
||||
lineHeight: sourceText.lineHeight || getLineHeight(fontFamily),
|
||||
});
|
||||
newElement = newTextElement;
|
||||
|
||||
if (isStickyNoteBoundText(newTextElement, elementsMap)) {
|
||||
// the copied stroke may be transparent; a note's label never is
|
||||
newElement = newElementWith(
|
||||
newTextElement,
|
||||
getColorUpdate(
|
||||
newTextElement,
|
||||
"strokeColor",
|
||||
newTextElement.strokeColor,
|
||||
elementsMap,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// sticky labels are laid out together with their (possibly
|
||||
// also restyled) note in the post-pass below
|
||||
redrawTextBoundingBox(newTextElement, container, app.scene);
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
if (isStickyNoteElement(newElement)) {
|
||||
newElement = normalizeStickyNote(newElement);
|
||||
}
|
||||
|
||||
return newElement;
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
elementsMap,
|
||||
),
|
||||
new Set(selectedElementIds),
|
||||
{ prevElementsMap: elementsMap },
|
||||
);
|
||||
|
||||
// a restyled note may have grown or shrunk — arrows bound to it follow
|
||||
for (const element of nextElements) {
|
||||
const prev = elementsMap.get(element.id);
|
||||
if (
|
||||
isStickyNoteElement(element) &&
|
||||
isNonDeletedElement(element) &&
|
||||
prev &&
|
||||
(prev.height !== element.height ||
|
||||
prev.x !== element.x ||
|
||||
prev.y !== element.y)
|
||||
) {
|
||||
updateBoundElements(element, app.scene);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
elements: nextElements,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
|
||||
});
|
||||
88
packages/excalidraw/actions/actionTextAutoResize.ts
Normal file
88
packages/excalidraw/actions/actionTextAutoResize.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { getFontString } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
getTextAnchorRatios,
|
||||
isExcalidrawElement,
|
||||
isNonDeletedElement,
|
||||
newElementWith,
|
||||
updateBoundElements,
|
||||
} from "@excalidraw/element";
|
||||
import { measureText } from "@excalidraw/element";
|
||||
|
||||
import { isTextElement } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { getSelectedElements } from "../scene";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionTextAutoResize = register({
|
||||
name: "autoResize",
|
||||
label: "labels.autoResize",
|
||||
icon: null,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState, _: unknown) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
return (
|
||||
selectedElements.length === 1 &&
|
||||
isTextElement(selectedElements[0]) &&
|
||||
!selectedElements[0].autoResize
|
||||
);
|
||||
},
|
||||
perform: (elements, appState, targetElement, app) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
|
||||
const targetTextElement =
|
||||
isExcalidrawElement(targetElement) && isTextElement(targetElement)
|
||||
? targetElement
|
||||
: (selectedElements[0] as ExcalidrawElement | undefined);
|
||||
|
||||
const target = elements.find(
|
||||
(element) => element.id === targetTextElement?.id,
|
||||
);
|
||||
|
||||
if (!target || !isTextElement(target)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const metrics = measureText(
|
||||
target.originalText,
|
||||
getFontString(target),
|
||||
target.lineHeight,
|
||||
);
|
||||
|
||||
// unwrapping resizes the box, so keep the point the text's alignment pins
|
||||
// — otherwise a right-aligned or centred text slides sideways, and one
|
||||
// bound to an arrow endpoint drags the arrow with it
|
||||
const anchor = getTextAnchorRatios(target);
|
||||
|
||||
const resized = newElementWith(target, {
|
||||
autoResize: true,
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
x: target.x + (target.width - metrics.width) * anchor.x,
|
||||
y: target.y + (target.height - metrics.height) * anchor.y,
|
||||
text: target.originalText,
|
||||
});
|
||||
|
||||
// the anchor only pins one point of the box, so any other arrow bound to
|
||||
// this text still has to be re-routed to its new geometry. The scene holds
|
||||
// the pre-resize text, hence handing the new one over explicitly.
|
||||
if (isNonDeletedElement(resized)) {
|
||||
updateBoundElements(resized, app.scene, {
|
||||
changedElements: new Map([[resized.id, resized]]),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
appState,
|
||||
elements: elements.map((element) =>
|
||||
element.id === resized.id ? resized : element,
|
||||
),
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
});
|
||||
26
packages/excalidraw/actions/actionToggleArrowBinding.tsx
Normal file
26
packages/excalidraw/actions/actionToggleArrowBinding.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleArrowBinding = register({
|
||||
name: "arrowBinding",
|
||||
label: "labels.arrowBinding",
|
||||
viewMode: false,
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
predicate: (appState) => appState.bindingPreference === "disabled",
|
||||
},
|
||||
perform(elements, appState) {
|
||||
const newPreference =
|
||||
appState.bindingPreference === "enabled" ? "disabled" : "enabled";
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
bindingPreference: newPreference,
|
||||
isBindingEnabled: newPreference === "enabled",
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.bindingPreference === "enabled",
|
||||
});
|
||||
34
packages/excalidraw/actions/actionToggleGridMode.tsx
Normal file
34
packages/excalidraw/actions/actionToggleGridMode.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { CODES, KEYS } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { gridIcon } from "../components/icons";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
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.gridModeEnabled,
|
||||
predicate: (element, appState, props) => {
|
||||
return props.gridModeEnabled === undefined;
|
||||
},
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
|
||||
});
|
||||
23
packages/excalidraw/actions/actionToggleMidpointSnapping.tsx
Normal file
23
packages/excalidraw/actions/actionToggleMidpointSnapping.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleMidpointSnapping = register({
|
||||
name: "midpointSnapping",
|
||||
label: "labels.midpointSnapping",
|
||||
viewMode: false,
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
predicate: (appState) => !appState.isMidpointSnappingEnabled,
|
||||
},
|
||||
perform(elements, appState) {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
isMidpointSnappingEnabled: !this.checked!(appState),
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.isMidpointSnappingEnabled,
|
||||
});
|
||||
34
packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx
Normal file
34
packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { CODES, KEYS } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { magnetIcon } from "../components/icons";
|
||||
|
||||
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,
|
||||
});
|
||||
58
packages/excalidraw/actions/actionToggleSearchMenu.ts
Normal file
58
packages/excalidraw/actions/actionToggleSearchMenu.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
KEYS,
|
||||
CANVAS_SEARCH_TAB,
|
||||
CLASSES,
|
||||
DEFAULT_SIDEBAR,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { searchIcon } from "../components/icons";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
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.openDialog) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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`,
|
||||
);
|
||||
|
||||
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.gridModeEnabled,
|
||||
predicate: (element, appState, props) => {
|
||||
return props.gridModeEnabled === undefined;
|
||||
},
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F,
|
||||
});
|
||||
35
packages/excalidraw/actions/actionToggleShapeSwitch.tsx
Normal file
35
packages/excalidraw/actions/actionToggleShapeSwitch.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import {
|
||||
getConversionTypeFromElements,
|
||||
convertElementTypePopupAtom,
|
||||
} from "../components/ConvertElementTypePopup";
|
||||
import { editorJotaiStore } from "../editor-jotai";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleShapeSwitch = register({
|
||||
name: "toggleShapeSwitch",
|
||||
label: "labels.shapeSwitch",
|
||||
icon: () => null,
|
||||
viewMode: true,
|
||||
trackEvent: {
|
||||
category: "shape_switch",
|
||||
action: "toggle",
|
||||
},
|
||||
keywords: ["change", "switch", "swap"],
|
||||
perform(elements, appState, _, app) {
|
||||
editorJotaiStore.set(convertElementTypePopupAtom, {
|
||||
type: "panel",
|
||||
});
|
||||
|
||||
return {
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.gridModeEnabled,
|
||||
predicate: (elements, appState, props) =>
|
||||
getConversionTypeFromElements(elements as ExcalidrawElement[]) !== null,
|
||||
});
|
||||
28
packages/excalidraw/actions/actionToggleStats.tsx
Normal file
28
packages/excalidraw/actions/actionToggleStats.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { CODES, KEYS } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { abacusIcon } from "../components/icons";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleStats = register({
|
||||
name: "stats",
|
||||
label: "stats.fullTitle",
|
||||
icon: abacusIcon,
|
||||
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,
|
||||
});
|
||||
36
packages/excalidraw/actions/actionToggleViewMode.tsx
Normal file
36
packages/excalidraw/actions/actionToggleViewMode.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { CODES, KEYS } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { eyeIcon } from "../components/icons";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleViewMode = register({
|
||||
name: "viewMode",
|
||||
label: "labels.viewMode",
|
||||
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, app) => {
|
||||
return (
|
||||
typeof appProps.viewModeEnabled === "undefined" &&
|
||||
app.isInteractionEnabled()
|
||||
);
|
||||
},
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R,
|
||||
});
|
||||
36
packages/excalidraw/actions/actionToggleZenMode.tsx
Normal file
36
packages/excalidraw/actions/actionToggleZenMode.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { CODES, KEYS } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { coffeeIcon } from "../components/icons";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleZenMode = register({
|
||||
name: "zenMode",
|
||||
label: "buttons.zenMode",
|
||||
icon: coffeeIcon,
|
||||
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, app) => {
|
||||
return (
|
||||
app.editorInterface.formFactor !== "phone" &&
|
||||
typeof appProps.zenModeEnabled === "undefined"
|
||||
);
|
||||
},
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,
|
||||
});
|
||||
156
packages/excalidraw/actions/actionZindex.tsx
Normal file
156
packages/excalidraw/actions/actionZindex.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { KEYS, CODES, isDarwin } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
moveOneLeft,
|
||||
moveOneRight,
|
||||
moveAllLeft,
|
||||
moveAllRight,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
BringForwardIcon,
|
||||
BringToFrontIcon,
|
||||
SendBackwardIcon,
|
||||
SendToBackIcon,
|
||||
} from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
import { getShortcutKey } from "../shortcut";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionSendBackward = register({
|
||||
name: "sendBackward",
|
||||
label: "labels.sendBackward",
|
||||
keywords: ["move down", "zindex", "layer"],
|
||||
icon: SendBackwardIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, value, app) => {
|
||||
return {
|
||||
elements: moveOneLeft(elements, appState, app.scene),
|
||||
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, value, app) => {
|
||||
return {
|
||||
elements: moveOneRight(elements, appState, app.scene),
|
||||
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>
|
||||
),
|
||||
});
|
||||
192
packages/excalidraw/actions/colorTargets.ts
Normal file
192
packages/excalidraw/actions/colorTargets.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import {
|
||||
COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
||||
DEFAULT_ELEMENT_STROKE_COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_STROKE_PICKS,
|
||||
STICKY_NOTE_BACKGROUND_PICKS,
|
||||
STICKY_NOTE_STROKE_PICKS,
|
||||
arrayToMap,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
getColorTargetElement,
|
||||
hasBackground,
|
||||
hasStrokeColor,
|
||||
isStickyNoteBoundText,
|
||||
isStickyNoteElement,
|
||||
isTextElement,
|
||||
normalizeStickyNoteBackgroundColor,
|
||||
normalizeStickyNoteStrokeColor,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type { ColorPaletteCustom, ColorTuple } from "@excalidraw/common";
|
||||
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { getSelectedElements } from "../scene";
|
||||
|
||||
import type { AppState } from "../types";
|
||||
|
||||
export type ColorProperty = "strokeColor" | "backgroundColor";
|
||||
|
||||
/**
|
||||
* Sticky notes are their own color domain: own defaults, own top picks, no
|
||||
* transparent. A pick targets the regular domain, the sticky domain, or both
|
||||
* ("mixed" selections write both defaults and use the regular picker).
|
||||
*/
|
||||
export type ColorTargetKind = "regular" | "sticky" | "mixed";
|
||||
|
||||
export type ColorDefaultKey =
|
||||
| "currentItemStrokeColor"
|
||||
| "currentItemBackgroundColor"
|
||||
| "currentItemStickynoteStrokeColor"
|
||||
| "currentItemStickynoteBackgroundColor";
|
||||
|
||||
export type ColorTargetAppState = Pick<
|
||||
AppState,
|
||||
"selectedElementIds" | "editingTextElement" | "activeTool" | ColorDefaultKey
|
||||
>;
|
||||
|
||||
export type ColorTarget = {
|
||||
kind: ColorTargetKind;
|
||||
property: ColorProperty;
|
||||
/** the current-item defaults a pick is written to (normalized per domain) */
|
||||
appStateKeys: readonly ColorDefaultKey[];
|
||||
/** the default shown when no element is targeted */
|
||||
currentValue: string;
|
||||
palette: ColorPaletteCustom;
|
||||
topPicks: ColorTuple;
|
||||
customizableTopPicks: keyof AppState["colorTopPicks"];
|
||||
excludedColors: readonly string[] | undefined;
|
||||
};
|
||||
|
||||
const DEFAULT_KEYS: Record<
|
||||
ColorProperty,
|
||||
Record<"regular" | "sticky", ColorDefaultKey>
|
||||
> = {
|
||||
strokeColor: {
|
||||
regular: "currentItemStrokeColor",
|
||||
sticky: "currentItemStickynoteStrokeColor",
|
||||
},
|
||||
backgroundColor: {
|
||||
regular: "currentItemBackgroundColor",
|
||||
sticky: "currentItemStickynoteBackgroundColor",
|
||||
},
|
||||
};
|
||||
|
||||
// transparent is hidden rather than removed from the palette so the remaining
|
||||
// colors keep their usual hotkeys (same mechanism as the bucket fill picker);
|
||||
// a module constant so the memoized picker's identity comparison holds
|
||||
const STICKY_NOTE_EXCLUDED_COLORS: readonly string[] = [
|
||||
COLOR_PALETTE.transparent,
|
||||
];
|
||||
|
||||
const isStickyNoteColorTarget = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
) =>
|
||||
isStickyNoteElement(element) ||
|
||||
(isTextElement(element) && isStickyNoteBoundText(element, elementsMap));
|
||||
|
||||
/**
|
||||
* Who a stroke/background pick targets. Resolve it from the state an action
|
||||
* runs against, at execution time — never capture it in a render closure:
|
||||
* the memoized picker keeps a stale `onChange`, and the always-visible top
|
||||
* picks fire it without a re-render.
|
||||
*
|
||||
* Targets are the selected color-capable elements (for stroke incl. bound
|
||||
* labels, since a note's visible text is its label) plus the text being
|
||||
* edited (`handleTextWysiwyg` deselects while editing) — or, for a
|
||||
* background pick on a note's label, the note (see `getColorTargetElement`).
|
||||
* With no target, the active tool decides the domain.
|
||||
*/
|
||||
export const resolveColorTarget = (
|
||||
appState: ColorTargetAppState,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
property: ColorProperty,
|
||||
): ColorTarget => {
|
||||
const elementsMap = arrayToMap(elements);
|
||||
const supports = (element: ExcalidrawElement) =>
|
||||
property === "strokeColor"
|
||||
? hasStrokeColor(element.type)
|
||||
: hasBackground(element.type);
|
||||
|
||||
const targets: ExcalidrawElement[] = getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: property === "strokeColor",
|
||||
}).filter(supports);
|
||||
const editingText =
|
||||
appState.editingTextElement &&
|
||||
elementsMap.get(appState.editingTextElement.id);
|
||||
const editing =
|
||||
editingText && getColorTargetElement(editingText, property, elementsMap);
|
||||
if (
|
||||
editing &&
|
||||
!editing.isDeleted &&
|
||||
supports(editing) &&
|
||||
!targets.some((element) => element.id === editing.id)
|
||||
) {
|
||||
targets.push(editing);
|
||||
}
|
||||
|
||||
let kind: ColorTargetKind;
|
||||
if (!targets.length) {
|
||||
kind = appState.activeTool.type === "stickynote" ? "sticky" : "regular";
|
||||
} else {
|
||||
const stickyCount = targets.filter((element) =>
|
||||
isStickyNoteColorTarget(element, elementsMap),
|
||||
).length;
|
||||
kind =
|
||||
stickyCount === 0
|
||||
? "regular"
|
||||
: stickyCount === targets.length
|
||||
? "sticky"
|
||||
: "mixed";
|
||||
}
|
||||
|
||||
const keys = DEFAULT_KEYS[property];
|
||||
const isStroke = property === "strokeColor";
|
||||
|
||||
return {
|
||||
kind,
|
||||
property,
|
||||
appStateKeys: kind === "mixed" ? [keys.regular, keys.sticky] : [keys[kind]],
|
||||
currentValue: appState[kind === "sticky" ? keys.sticky : keys.regular],
|
||||
palette: isStroke
|
||||
? DEFAULT_ELEMENT_STROKE_COLOR_PALETTE
|
||||
: DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
||||
topPicks:
|
||||
kind === "sticky"
|
||||
? isStroke
|
||||
? STICKY_NOTE_STROKE_PICKS
|
||||
: STICKY_NOTE_BACKGROUND_PICKS
|
||||
: isStroke
|
||||
? DEFAULT_ELEMENT_STROKE_PICKS
|
||||
: DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
||||
customizableTopPicks:
|
||||
kind === "sticky"
|
||||
? isStroke
|
||||
? "stickyNoteStroke"
|
||||
: "stickyNoteBackground"
|
||||
: isStroke
|
||||
? "elementStroke"
|
||||
: "elementBackground",
|
||||
excludedColors: kind === "sticky" ? STICKY_NOTE_EXCLUDED_COLORS : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
/** the current-item default updates for a picked color */
|
||||
export const getColorTargetAppStateUpdates = (
|
||||
target: ColorTarget,
|
||||
color: string,
|
||||
): Partial<AppState> => {
|
||||
const updates: Partial<AppState> = {};
|
||||
for (const key of target.appStateKeys) {
|
||||
updates[key] =
|
||||
key === "currentItemStickynoteStrokeColor"
|
||||
? normalizeStickyNoteStrokeColor(color)
|
||||
: key === "currentItemStickynoteBackgroundColor"
|
||||
? normalizeStickyNoteBackgroundColor(color)
|
||||
: color;
|
||||
}
|
||||
return updates;
|
||||
};
|
||||
94
packages/excalidraw/actions/index.ts
Normal file
94
packages/excalidraw/actions/index.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
export { actionDeleteSelected } from "./actionDeleteSelected";
|
||||
export {
|
||||
actionBringForward,
|
||||
actionBringToFront,
|
||||
actionSendBackward,
|
||||
actionSendToBack,
|
||||
} from "./actionZindex";
|
||||
export { actionSelectAll } from "./actionSelectAll";
|
||||
export { actionDuplicateSelection } from "./actionDuplicateSelection";
|
||||
export {
|
||||
actionChangeStrokeColor,
|
||||
actionChangeBackgroundColor,
|
||||
actionChangeBucketFillBackgroundColor,
|
||||
actionChangeStrokeWidth,
|
||||
actionChangeFillStyle,
|
||||
actionChangeSloppiness,
|
||||
actionChangeFreedrawMode,
|
||||
actionChangeOpacity,
|
||||
actionChangeFontSize,
|
||||
actionChangeFontFamily,
|
||||
actionChangeTextAlign,
|
||||
actionChangeVerticalAlign,
|
||||
actionChangeArrowProperties,
|
||||
} from "./actionProperties";
|
||||
|
||||
export {
|
||||
actionChangeViewBackgroundColor,
|
||||
actionClearCanvas,
|
||||
actionZoomIn,
|
||||
actionZoomOut,
|
||||
actionResetZoom,
|
||||
actionZoomToFit,
|
||||
actionToggleTheme,
|
||||
} from "./actionCanvas";
|
||||
|
||||
export { actionFinalize } from "./actionFinalize";
|
||||
export { actionDeselect } from "./actionDeselect";
|
||||
|
||||
export {
|
||||
actionChangeProjectName,
|
||||
actionChangeExportBackground,
|
||||
actionSaveToActiveFile,
|
||||
actionSaveFileToDisk,
|
||||
actionLoadScene,
|
||||
} from "./actionExport";
|
||||
|
||||
export { actionCopyStyles, actionPasteStyles } from "./actionStyles";
|
||||
export { 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 { actionToggleArrowBinding } from "./actionToggleArrowBinding";
|
||||
export { actionToggleMidpointSnapping } from "./actionToggleMidpointSnapping";
|
||||
|
||||
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";
|
||||
246
packages/excalidraw/actions/manager.tsx
Normal file
246
packages/excalidraw/actions/manager.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import React from "react";
|
||||
|
||||
import { isPromiseLike } from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
OrderedExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { trackEvent } from "../analytics";
|
||||
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
import type {
|
||||
Action,
|
||||
UpdaterFn,
|
||||
ActionName,
|
||||
ActionResult,
|
||||
PanelComponentProps,
|
||||
ActionSource,
|
||||
} from "./types";
|
||||
|
||||
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.editorInterface.formFactor === "phone" ? "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));
|
||||
}
|
||||
|
||||
private isActionBlockedByViewportTransition = (action: Action) =>
|
||||
action.navigation === true && this.app.viewport.isLockedTransitionPending;
|
||||
|
||||
handleKeyDown(event: React.KeyboardEvent | KeyboardEvent) {
|
||||
if (!this.app.isInteractionEnabled() && !this.app.isNavigationEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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];
|
||||
|
||||
// in the non-interactive editor, only navigation actions are allowed
|
||||
// (when navigation itself is)
|
||||
if (!this.app.isInteractionEnabled() && action.navigation !== true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.getAppState().viewModeEnabled && action.viewMode !== true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isActionBlockedByViewportTransition(action)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return true;
|
||||
}
|
||||
|
||||
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,
|
||||
) {
|
||||
// the user must not be able to affect a non-interactive editor
|
||||
// (programmatic execution by the host remains allowed, as are
|
||||
// navigation actions when navigation is)
|
||||
if (
|
||||
source !== "api" &&
|
||||
!this.app.isInteractionEnabled() &&
|
||||
!(this.app.isNavigationEnabled() && action.navigation === true)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isActionBlockedByViewportTransition(action)) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 updateData = (formState?: any) => {
|
||||
if (this.isActionBlockedByViewportTransition(action)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// read fresh state at call time — memoized panel children may invoke
|
||||
// an `updateData` closure minted by an earlier render
|
||||
trackAction(
|
||||
action,
|
||||
"ui",
|
||||
this.getAppState(),
|
||||
this.getElementsIncludingDeleted(),
|
||||
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}
|
||||
renderAction={this.renderAction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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)
|
||||
);
|
||||
};
|
||||
}
|
||||
15
packages/excalidraw/actions/register.ts
Normal file
15
packages/excalidraw/actions/register.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Action } from "./types";
|
||||
|
||||
export let actions: readonly Action[] = [];
|
||||
|
||||
export const register = <
|
||||
TData extends any,
|
||||
T extends Action<TData> = Action<TData>,
|
||||
>(
|
||||
action: T,
|
||||
) => {
|
||||
actions = actions.concat(action);
|
||||
return action as T & {
|
||||
keyTest?: unknown extends T["keyTest"] ? never : T["keyTest"];
|
||||
};
|
||||
};
|
||||
124
packages/excalidraw/actions/shortcuts.ts
Normal file
124
packages/excalidraw/actions/shortcuts.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { isDarwin } from "@excalidraw/common";
|
||||
|
||||
import type { SubtypeOf } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { getShortcutKey } from "../shortcut";
|
||||
|
||||
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"
|
||||
| "saveFileToDisk"
|
||||
| "saveToActiveFile"
|
||||
| "toggleShortcuts"
|
||||
| "wrapSelectionInFrame"
|
||||
>
|
||||
| "saveScene"
|
||||
| "imageExport"
|
||||
| "commandPalette"
|
||||
| "searchMenu"
|
||||
| "toolLock";
|
||||
|
||||
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")],
|
||||
saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")],
|
||||
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
|
||||
toggleShortcuts: [getShortcutKey("?")],
|
||||
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
|
||||
wrapSelectionInFrame: [],
|
||||
toolLock: [getShortcutKey("Q")],
|
||||
};
|
||||
|
||||
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]
|
||||
: "";
|
||||
};
|
||||
222
packages/excalidraw/actions/types.ts
Normal file
222
packages/excalidraw/actions/types.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
OrderedExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type { CaptureUpdateActionType } from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
AppClassProperties,
|
||||
AppState,
|
||||
ExcalidrawProps,
|
||||
BinaryFiles,
|
||||
UIAppState,
|
||||
} from "../types";
|
||||
import type React from "react";
|
||||
|
||||
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<TData = any> = (
|
||||
elements: readonly OrderedExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
formData: TData | undefined,
|
||||
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"
|
||||
| "arrowBinding"
|
||||
| "midpointSnapping"
|
||||
| "stats"
|
||||
| "changeStrokeColor"
|
||||
| "changeBackgroundColor"
|
||||
| "changeBucketFillBackgroundColor"
|
||||
| "changeFillStyle"
|
||||
| "changeStrokeWidth"
|
||||
| "changeSloppiness"
|
||||
| "changeFreedrawMode"
|
||||
| "changeStrokeStyle"
|
||||
| "changeArrowhead"
|
||||
| "changeArrowType"
|
||||
| "changeArrowProperties"
|
||||
| "changeOpacity"
|
||||
| "changeFontSize"
|
||||
| "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"
|
||||
| "deselect"
|
||||
| "viewMode"
|
||||
| "exportWithDarkMode"
|
||||
| "toggleTheme"
|
||||
| "increaseFontSize"
|
||||
| "decreaseFontSize"
|
||||
| "unbindText"
|
||||
| "hyperlink"
|
||||
| "bindText"
|
||||
| "unlockAllElements"
|
||||
| "toggleElementLock"
|
||||
| "toggleLinearEditor"
|
||||
| "selectAllElementsInFrame"
|
||||
| "removeAllElementsFromFrame"
|
||||
| "updateFrameRendering"
|
||||
| "createContainerFromText"
|
||||
| "wrapTextInContainer"
|
||||
| "commandPalette"
|
||||
| "autoResize"
|
||||
| "elementStats"
|
||||
| "searchMenu"
|
||||
| "copyElementLink"
|
||||
| "linkToElement"
|
||||
| "cropEditor"
|
||||
| "wrapSelectionInFrame"
|
||||
| "toggleShapeSwitch"
|
||||
| "togglePolygon";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
// UIAppState (not AppState) because PanelComponents only re-render when
|
||||
// the UI does — reading UI-stripped props (zoom, scroll, …) would render
|
||||
// stale values; subscribe via useAppStateValue for those instead
|
||||
appState: UIAppState;
|
||||
updateData: <T = any>(formData?: T) => void;
|
||||
appProps: ExcalidrawProps;
|
||||
data?: Record<string, any>;
|
||||
app: AppClassProperties;
|
||||
renderAction: (
|
||||
name: ActionName,
|
||||
data?: PanelComponentProps["data"],
|
||||
) => React.JSX.Element | null;
|
||||
};
|
||||
|
||||
export interface Action<TData = any> {
|
||||
name: ActionName;
|
||||
label:
|
||||
| string
|
||||
| ((
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<UIAppState>,
|
||||
app: AppClassProperties,
|
||||
) => string);
|
||||
keywords?: string[];
|
||||
icon?:
|
||||
| React.ReactNode
|
||||
| ((
|
||||
appState: UIAppState,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => React.ReactNode);
|
||||
PanelComponent?: React.FC<PanelComponentProps>;
|
||||
perform: ActionFn<TData>;
|
||||
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<UIAppState>) => boolean;
|
||||
trackEvent:
|
||||
| false
|
||||
| {
|
||||
category:
|
||||
| "toolbar"
|
||||
| "element"
|
||||
| "canvas"
|
||||
| "export"
|
||||
| "history"
|
||||
| "menu"
|
||||
| "collab"
|
||||
| "hyperlink"
|
||||
| "search_menu"
|
||||
| "shape_switch";
|
||||
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;
|
||||
/** if set to `true`, the action counts as canvas navigation and remains
|
||||
* available in the non-interactive editor when
|
||||
* `interaction: { enabled: { navigation: true } }`. Defaults to `false` */
|
||||
navigation?: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user