aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/groups.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/excalidraw/groups.ts')
-rw-r--r--packages/excalidraw/groups.ts399
1 files changed, 399 insertions, 0 deletions
diff --git a/packages/excalidraw/groups.ts b/packages/excalidraw/groups.ts
new file mode 100644
index 0000000..5a4e606
--- /dev/null
+++ b/packages/excalidraw/groups.ts
@@ -0,0 +1,399 @@
+import type {
+ GroupId,
+ ExcalidrawElement,
+ NonDeleted,
+ NonDeletedExcalidrawElement,
+ ElementsMapOrArray,
+ ElementsMap,
+} from "./element/types";
+import type {
+ AppClassProperties,
+ AppState,
+ InteractiveCanvasAppState,
+} from "./types";
+import { getSelectedElements } from "./scene";
+import { getBoundTextElement } from "./element/textElement";
+import { makeNextSelectedElementIds } from "./scene/selection";
+import type { Mutable } from "./utility-types";
+
+export const selectGroup = (
+ groupId: GroupId,
+ appState: InteractiveCanvasAppState,
+ elements: readonly NonDeleted<ExcalidrawElement>[],
+): Pick<
+ InteractiveCanvasAppState,
+ "selectedGroupIds" | "selectedElementIds" | "editingGroupId"
+> => {
+ const elementsInGroup = elements.reduce(
+ (acc: Record<string, true>, element) => {
+ if (element.groupIds.includes(groupId)) {
+ acc[element.id] = true;
+ }
+ return acc;
+ },
+ {},
+ );
+
+ if (Object.keys(elementsInGroup).length < 2) {
+ if (
+ appState.selectedGroupIds[groupId] ||
+ appState.editingGroupId === groupId
+ ) {
+ return {
+ selectedElementIds: appState.selectedElementIds,
+ selectedGroupIds: { ...appState.selectedGroupIds, [groupId]: false },
+ editingGroupId: null,
+ };
+ }
+ return appState;
+ }
+
+ return {
+ editingGroupId: appState.editingGroupId,
+ selectedGroupIds: { ...appState.selectedGroupIds, [groupId]: true },
+ selectedElementIds: {
+ ...appState.selectedElementIds,
+ ...elementsInGroup,
+ },
+ };
+};
+
+export const selectGroupsForSelectedElements = (function () {
+ type SelectGroupsReturnType = Pick<
+ InteractiveCanvasAppState,
+ "selectedGroupIds" | "editingGroupId" | "selectedElementIds"
+ >;
+
+ let lastSelectedElements: readonly NonDeleted<ExcalidrawElement>[] | null =
+ null;
+ let lastElements: readonly NonDeleted<ExcalidrawElement>[] | null = null;
+ let lastReturnValue: SelectGroupsReturnType | null = null;
+
+ const _selectGroups = (
+ selectedElements: readonly NonDeleted<ExcalidrawElement>[],
+ elements: readonly NonDeleted<ExcalidrawElement>[],
+ appState: Pick<AppState, "selectedElementIds" | "editingGroupId">,
+ prevAppState: InteractiveCanvasAppState,
+ ): SelectGroupsReturnType => {
+ if (
+ lastReturnValue !== undefined &&
+ elements === lastElements &&
+ selectedElements === lastSelectedElements &&
+ appState.editingGroupId === lastReturnValue?.editingGroupId
+ ) {
+ return lastReturnValue;
+ }
+
+ const selectedGroupIds: Record<GroupId, boolean> = {};
+ // Gather all the groups withing selected elements
+ for (const selectedElement of selectedElements) {
+ let groupIds = selectedElement.groupIds;
+ if (appState.editingGroupId) {
+ // handle the case where a group is nested within a group
+ const indexOfEditingGroup = groupIds.indexOf(appState.editingGroupId);
+ if (indexOfEditingGroup > -1) {
+ groupIds = groupIds.slice(0, indexOfEditingGroup);
+ }
+ }
+ if (groupIds.length > 0) {
+ const lastSelectedGroup = groupIds[groupIds.length - 1];
+ selectedGroupIds[lastSelectedGroup] = true;
+ }
+ }
+
+ // Gather all the elements within selected groups
+ const groupElementsIndex: Record<GroupId, string[]> = {};
+ const selectedElementIdsInGroups = elements.reduce(
+ (acc: Record<string, true>, element) => {
+ if (element.isDeleted) {
+ return acc;
+ }
+
+ const groupId = element.groupIds.find((id) => selectedGroupIds[id]);
+
+ if (groupId) {
+ acc[element.id] = true;
+
+ // Populate the index
+ if (!Array.isArray(groupElementsIndex[groupId])) {
+ groupElementsIndex[groupId] = [element.id];
+ } else {
+ groupElementsIndex[groupId].push(element.id);
+ }
+ }
+ return acc;
+ },
+ {},
+ );
+
+ for (const groupId of Object.keys(groupElementsIndex)) {
+ // If there is one element in the group, and the group is selected or it's being edited, it's not a group
+ if (groupElementsIndex[groupId].length < 2) {
+ if (selectedGroupIds[groupId]) {
+ selectedGroupIds[groupId] = false;
+ }
+ }
+ }
+
+ lastElements = elements;
+ lastSelectedElements = selectedElements;
+
+ lastReturnValue = {
+ editingGroupId: appState.editingGroupId,
+ selectedGroupIds,
+ selectedElementIds: makeNextSelectedElementIds(
+ {
+ ...appState.selectedElementIds,
+ ...selectedElementIdsInGroups,
+ },
+ prevAppState,
+ ),
+ };
+
+ return lastReturnValue;
+ };
+
+ /**
+ * When you select an element, you often want to actually select the whole group it's in, unless
+ * you're currently editing that group.
+ */
+ const selectGroupsForSelectedElements = (
+ appState: Pick<AppState, "selectedElementIds" | "editingGroupId">,
+ elements: readonly NonDeletedExcalidrawElement[],
+ prevAppState: InteractiveCanvasAppState,
+ /**
+ * supply null in cases where you don't have access to App instance and
+ * you don't care about optimizing selectElements retrieval
+ */
+ app: AppClassProperties | null,
+ ): Mutable<
+ Pick<
+ InteractiveCanvasAppState,
+ "selectedGroupIds" | "editingGroupId" | "selectedElementIds"
+ >
+ > => {
+ const selectedElements = app
+ ? app.scene.getSelectedElements({
+ selectedElementIds: appState.selectedElementIds,
+ // supplying elements explicitly in case we're passed non-state elements
+ elements,
+ })
+ : getSelectedElements(elements, appState);
+
+ if (!selectedElements.length) {
+ return {
+ selectedGroupIds: {},
+ editingGroupId: null,
+ selectedElementIds: makeNextSelectedElementIds(
+ appState.selectedElementIds,
+ prevAppState,
+ ),
+ };
+ }
+
+ return _selectGroups(selectedElements, elements, appState, prevAppState);
+ };
+
+ selectGroupsForSelectedElements.clearCache = () => {
+ lastElements = null;
+ lastSelectedElements = null;
+ lastReturnValue = null;
+ };
+
+ return selectGroupsForSelectedElements;
+})();
+
+/**
+ * If the element's group is selected, don't render an individual
+ * selection border around it.
+ */
+export const isSelectedViaGroup = (
+ appState: InteractiveCanvasAppState,
+ element: ExcalidrawElement,
+) => getSelectedGroupForElement(appState, element) != null;
+
+export const getSelectedGroupForElement = (
+ appState: InteractiveCanvasAppState,
+ element: ExcalidrawElement,
+) =>
+ element.groupIds
+ .filter((groupId) => groupId !== appState.editingGroupId)
+ .find((groupId) => appState.selectedGroupIds[groupId]);
+
+export const getSelectedGroupIds = (
+ appState: InteractiveCanvasAppState,
+): GroupId[] =>
+ Object.entries(appState.selectedGroupIds)
+ .filter(([groupId, isSelected]) => isSelected)
+ .map(([groupId, isSelected]) => groupId);
+
+// given a list of elements, return the the actual group ids that should be selected
+// or used to update the elements
+export const selectGroupsFromGivenElements = (
+ elements: readonly NonDeleted<ExcalidrawElement>[],
+ appState: InteractiveCanvasAppState,
+) => {
+ let nextAppState: InteractiveCanvasAppState = {
+ ...appState,
+ selectedGroupIds: {},
+ };
+
+ for (const element of elements) {
+ let groupIds = element.groupIds;
+ if (appState.editingGroupId) {
+ const indexOfEditingGroup = groupIds.indexOf(appState.editingGroupId);
+ if (indexOfEditingGroup > -1) {
+ groupIds = groupIds.slice(0, indexOfEditingGroup);
+ }
+ }
+ if (groupIds.length > 0) {
+ const groupId = groupIds[groupIds.length - 1];
+ nextAppState = {
+ ...nextAppState,
+ ...selectGroup(groupId, nextAppState, elements),
+ };
+ }
+ }
+
+ return nextAppState.selectedGroupIds;
+};
+
+export const editGroupForSelectedElement = (
+ appState: AppState,
+ element: NonDeleted<ExcalidrawElement>,
+): AppState => {
+ return {
+ ...appState,
+ editingGroupId: element.groupIds.length ? element.groupIds[0] : null,
+ selectedGroupIds: {},
+ selectedElementIds: {
+ [element.id]: true,
+ },
+ };
+};
+
+export const isElementInGroup = (element: ExcalidrawElement, groupId: string) =>
+ element.groupIds.includes(groupId);
+
+export const getElementsInGroup = (
+ elements: ElementsMapOrArray,
+ groupId: string,
+) => {
+ const elementsInGroup: ExcalidrawElement[] = [];
+ for (const element of elements.values()) {
+ if (isElementInGroup(element, groupId)) {
+ elementsInGroup.push(element);
+ }
+ }
+ return elementsInGroup;
+};
+
+export const getSelectedGroupIdForElement = (
+ element: ExcalidrawElement,
+ selectedGroupIds: { [groupId: string]: boolean },
+) => element.groupIds.find((groupId) => selectedGroupIds[groupId]);
+
+export const getNewGroupIdsForDuplication = (
+ groupIds: ExcalidrawElement["groupIds"],
+ editingGroupId: AppState["editingGroupId"],
+ mapper: (groupId: GroupId) => GroupId,
+) => {
+ const copy = [...groupIds];
+ const positionOfEditingGroupId = editingGroupId
+ ? groupIds.indexOf(editingGroupId)
+ : -1;
+ const endIndex =
+ positionOfEditingGroupId > -1 ? positionOfEditingGroupId : groupIds.length;
+ for (let index = 0; index < endIndex; index++) {
+ copy[index] = mapper(copy[index]);
+ }
+
+ return copy;
+};
+
+export const addToGroup = (
+ prevGroupIds: ExcalidrawElement["groupIds"],
+ newGroupId: GroupId,
+ editingGroupId: AppState["editingGroupId"],
+) => {
+ // insert before the editingGroupId, or push to the end.
+ const groupIds = [...prevGroupIds];
+ const positionOfEditingGroupId = editingGroupId
+ ? groupIds.indexOf(editingGroupId)
+ : -1;
+ const positionToInsert =
+ positionOfEditingGroupId > -1 ? positionOfEditingGroupId : groupIds.length;
+ groupIds.splice(positionToInsert, 0, newGroupId);
+ return groupIds;
+};
+
+export const removeFromSelectedGroups = (
+ groupIds: ExcalidrawElement["groupIds"],
+ selectedGroupIds: { [groupId: string]: boolean },
+) => groupIds.filter((groupId) => !selectedGroupIds[groupId]);
+
+export const getMaximumGroups = (
+ elements: ExcalidrawElement[],
+ elementsMap: ElementsMap,
+): ExcalidrawElement[][] => {
+ const groups: Map<String, ExcalidrawElement[]> = new Map<
+ String,
+ ExcalidrawElement[]
+ >();
+ elements.forEach((element: ExcalidrawElement) => {
+ const groupId =
+ element.groupIds.length === 0
+ ? element.id
+ : element.groupIds[element.groupIds.length - 1];
+
+ const currentGroupMembers = groups.get(groupId) || [];
+
+ // Include bound text if present when grouping
+ const boundTextElement = getBoundTextElement(element, elementsMap);
+ if (boundTextElement) {
+ currentGroupMembers.push(boundTextElement);
+ }
+ groups.set(groupId, [...currentGroupMembers, element]);
+ });
+
+ return Array.from(groups.values());
+};
+
+export const getNonDeletedGroupIds = (elements: ElementsMap) => {
+ const nonDeletedGroupIds = new Set<string>();
+
+ for (const [, element] of elements) {
+ // defensive check
+ if (element.isDeleted) {
+ continue;
+ }
+
+ // defensive fallback
+ for (const groupId of element.groupIds ?? []) {
+ nonDeletedGroupIds.add(groupId);
+ }
+ }
+
+ return nonDeletedGroupIds;
+};
+
+export const elementsAreInSameGroup = (
+ elements: readonly ExcalidrawElement[],
+) => {
+ const allGroups = elements.flatMap((element) => element.groupIds);
+ const groupCount = new Map<string, number>();
+ let maxGroup = 0;
+
+ for (const group of allGroups) {
+ groupCount.set(group, (groupCount.get(group) ?? 0) + 1);
+ if (groupCount.get(group)! > maxGroup) {
+ maxGroup = groupCount.get(group)!;
+ }
+ }
+
+ return maxGroup === elements.length;
+};
+
+export const isInGroup = (element: NonDeletedExcalidrawElement) => {
+ return element.groupIds.length > 0;
+};