mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-11-04 12:54:23 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			482 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			482 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
import type {
 | 
						|
  AppClassProperties,
 | 
						|
  AppState,
 | 
						|
  InteractiveCanvasAppState,
 | 
						|
} from "@excalidraw/excalidraw/types";
 | 
						|
import type { Mutable } from "@excalidraw/common/utility-types";
 | 
						|
 | 
						|
import { getBoundTextElement } from "./textElement";
 | 
						|
 | 
						|
import { isBoundToContainer } from "./typeChecks";
 | 
						|
 | 
						|
import { makeNextSelectedElementIds, getSelectedElements } from "./selection";
 | 
						|
 | 
						|
import type {
 | 
						|
  GroupId,
 | 
						|
  ExcalidrawElement,
 | 
						|
  NonDeleted,
 | 
						|
  NonDeletedExcalidrawElement,
 | 
						|
  ElementsMapOrArray,
 | 
						|
  ElementsMap,
 | 
						|
} from "./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: Pick<
 | 
						|
    InteractiveCanvasAppState,
 | 
						|
    "editingGroupId" | "selectedGroupIds"
 | 
						|
  >,
 | 
						|
  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 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;
 | 
						|
};
 | 
						|
 | 
						|
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;
 | 
						|
};
 | 
						|
 | 
						|
// given a list of selected elements, return the element grouped by their immediate group selected state
 | 
						|
// in the case if only one group is selected and all elements selected are within the group, it will respect group hierarchy in accordance to their nested grouping order
 | 
						|
export const getSelectedElementsByGroup = (
 | 
						|
  selectedElements: ExcalidrawElement[],
 | 
						|
  elementsMap: ElementsMap,
 | 
						|
  appState: Readonly<AppState>,
 | 
						|
): ExcalidrawElement[][] => {
 | 
						|
  const selectedGroupIds = getSelectedGroupIds(appState);
 | 
						|
  const unboundElements = selectedElements.filter(
 | 
						|
    (element) => !isBoundToContainer(element),
 | 
						|
  );
 | 
						|
  const groups: Map<string, ExcalidrawElement[]> = new Map();
 | 
						|
  const elements: Map<string, ExcalidrawElement[]> = new Map();
 | 
						|
 | 
						|
  // helper function to add an element to the elements map
 | 
						|
  const addToElementsMap = (element: ExcalidrawElement) => {
 | 
						|
    // elements
 | 
						|
    const currentElementMembers = elements.get(element.id) || [];
 | 
						|
    const boundTextElement = getBoundTextElement(element, elementsMap);
 | 
						|
 | 
						|
    if (boundTextElement) {
 | 
						|
      currentElementMembers.push(boundTextElement);
 | 
						|
    }
 | 
						|
    elements.set(element.id, [...currentElementMembers, element]);
 | 
						|
  };
 | 
						|
 | 
						|
  // helper function to add an element to the groups map
 | 
						|
  const addToGroupsMap = (element: ExcalidrawElement, groupId: string) => {
 | 
						|
    // groups
 | 
						|
    const currentGroupMembers = groups.get(groupId) || [];
 | 
						|
    const boundTextElement = getBoundTextElement(element, elementsMap);
 | 
						|
 | 
						|
    if (boundTextElement) {
 | 
						|
      currentGroupMembers.push(boundTextElement);
 | 
						|
    }
 | 
						|
    groups.set(groupId, [...currentGroupMembers, element]);
 | 
						|
  };
 | 
						|
 | 
						|
  // helper function to handle the case where a single group is selected
 | 
						|
  // and all elements selected are within the group, it will respect group hierarchy in accordance to
 | 
						|
  // their nested grouping order
 | 
						|
  const handleSingleSelectedGroupCase = (
 | 
						|
    element: ExcalidrawElement,
 | 
						|
    selectedGroupId: GroupId,
 | 
						|
  ) => {
 | 
						|
    const indexOfSelectedGroupId = element.groupIds.indexOf(selectedGroupId, 0);
 | 
						|
    const nestedGroupCount = element.groupIds.slice(
 | 
						|
      0,
 | 
						|
      indexOfSelectedGroupId,
 | 
						|
    ).length;
 | 
						|
    return nestedGroupCount > 0
 | 
						|
      ? addToGroupsMap(element, element.groupIds[indexOfSelectedGroupId - 1])
 | 
						|
      : addToElementsMap(element);
 | 
						|
  };
 | 
						|
 | 
						|
  const isAllInSameGroup = selectedElements.every((element) =>
 | 
						|
    isSelectedViaGroup(appState, element),
 | 
						|
  );
 | 
						|
 | 
						|
  unboundElements.forEach((element) => {
 | 
						|
    const selectedGroupId = getSelectedGroupIdForElement(
 | 
						|
      element,
 | 
						|
      appState.selectedGroupIds,
 | 
						|
    );
 | 
						|
    if (!selectedGroupId) {
 | 
						|
      addToElementsMap(element);
 | 
						|
    } else if (selectedGroupIds.length === 1 && isAllInSameGroup) {
 | 
						|
      handleSingleSelectedGroupCase(element, selectedGroupId);
 | 
						|
    } else {
 | 
						|
      addToGroupsMap(element, selectedGroupId);
 | 
						|
    }
 | 
						|
  });
 | 
						|
  return Array.from(groups.values()).concat(Array.from(elements.values()));
 | 
						|
};
 |