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[], ): Pick< InteractiveCanvasAppState, "selectedGroupIds" | "selectedElementIds" | "editingGroupId" > => { const elementsInGroup = elements.reduce( (acc: Record, 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[] | null = null; let lastElements: readonly NonDeleted[] | null = null; let lastReturnValue: SelectGroupsReturnType | null = null; const _selectGroups = ( selectedElements: readonly NonDeleted[], elements: readonly NonDeleted[], appState: Pick, prevAppState: InteractiveCanvasAppState, ): SelectGroupsReturnType => { if ( lastReturnValue !== undefined && elements === lastElements && selectedElements === lastSelectedElements && appState.editingGroupId === lastReturnValue?.editingGroupId ) { return lastReturnValue; } const selectedGroupIds: Record = {}; // 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 = {}; const selectedElementIdsInGroups = elements.reduce( (acc: Record, 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, 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[], 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, ): 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 = 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(); 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(); 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, ): ExcalidrawElement[][] => { const selectedGroupIds = getSelectedGroupIds(appState); const unboundElements = selectedElements.filter( (element) => !isBoundToContainer(element), ); const groups: Map = new Map(); const elements: Map = 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())); };