diff options
Diffstat (limited to 'packages/excalidraw/actions/actionGroup.tsx')
| -rw-r--r-- | packages/excalidraw/actions/actionGroup.tsx | 308 |
1 files changed, 308 insertions, 0 deletions
diff --git a/packages/excalidraw/actions/actionGroup.tsx b/packages/excalidraw/actions/actionGroup.tsx new file mode 100644 index 0000000..4e98c59 --- /dev/null +++ b/packages/excalidraw/actions/actionGroup.tsx @@ -0,0 +1,308 @@ +import { KEYS } from "../keys"; +import { t } from "../i18n"; +import { arrayToMap, getShortcutKey } from "../utils"; +import { register } from "./register"; +import { UngroupIcon, GroupIcon } from "../components/icons"; +import { newElementWith } from "../element/mutateElement"; +import { isSomeElementSelected } from "../scene"; +import { + getSelectedGroupIds, + selectGroup, + selectGroupsForSelectedElements, + getElementsInGroup, + addToGroup, + removeFromSelectedGroups, + isElementInGroup, +} from "../groups"; +import { getNonDeletedElements } from "../element"; +import { randomId } from "../random"; +import { ToolButton } from "../components/ToolButton"; +import type { + ExcalidrawElement, + ExcalidrawTextElement, + OrderedExcalidrawElement, +} from "../element/types"; +import type { AppClassProperties, AppState } from "../types"; +import { isBoundToContainer } from "../element/typeChecks"; +import { + frameAndChildrenSelectedTogether, + getElementsInResizingFrame, + getFrameLikeElements, + getRootElements, + groupByFrameLikes, + removeElementsFromFrame, + replaceAllElementsInFrame, +} from "../frame"; +import { syncMovedIndices } from "../fractionalIndex"; +import { CaptureUpdateAction } from "../store"; + +const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => { + if (elements.length >= 2) { + const groupIds = elements[0].groupIds; + for (const groupId of groupIds) { + if ( + elements.reduce( + (acc, element) => acc && isElementInGroup(element, groupId), + true, + ) + ) { + return true; + } + } + } + return false; +}; + +const enableActionGroup = ( + elements: readonly ExcalidrawElement[], + appState: AppState, + app: AppClassProperties, +) => { + const selectedElements = app.scene.getSelectedElements({ + selectedElementIds: appState.selectedElementIds, + includeBoundTextElement: true, + }); + + return ( + selectedElements.length >= 2 && + !allElementsInSameGroup(selectedElements) && + !frameAndChildrenSelectedTogether(selectedElements) + ); +}; + +export const actionGroup = register({ + name: "group", + label: "labels.group", + icon: (appState) => <GroupIcon theme={appState.theme} />, + trackEvent: { category: "element" }, + perform: (elements, appState, _, app) => { + const selectedElements = getRootElements( + app.scene.getSelectedElements({ + selectedElementIds: appState.selectedElementIds, + includeBoundTextElement: true, + }), + ); + if (selectedElements.length < 2) { + // nothing to group + return { + appState, + elements, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + } + // if everything is already grouped into 1 group, there is nothing to do + const selectedGroupIds = getSelectedGroupIds(appState); + if (selectedGroupIds.length === 1) { + const selectedGroupId = selectedGroupIds[0]; + const elementIdsInGroup = new Set( + getElementsInGroup(elements, selectedGroupId).map( + (element) => element.id, + ), + ); + const selectedElementIds = new Set( + selectedElements.map((element) => element.id), + ); + const combinedSet = new Set([ + ...Array.from(elementIdsInGroup), + ...Array.from(selectedElementIds), + ]); + if (combinedSet.size === elementIdsInGroup.size) { + // no incremental ids in the selected ids + return { + appState, + elements, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + } + } + + let nextElements = [...elements]; + + // this includes the case where we are grouping elements inside a frame + // and elements outside that frame + const groupingElementsFromDifferentFrames = + new Set(selectedElements.map((element) => element.frameId)).size > 1; + // when it happens, we want to remove elements that are in the frame + // and are going to be grouped from the frame (mouthful, I know) + if (groupingElementsFromDifferentFrames) { + const frameElementsMap = groupByFrameLikes(selectedElements); + + frameElementsMap.forEach((elementsInFrame, frameId) => { + removeElementsFromFrame( + elementsInFrame, + app.scene.getNonDeletedElementsMap(), + ); + }); + } + + const newGroupId = randomId(); + const selectElementIds = arrayToMap(selectedElements); + + nextElements = nextElements.map((element) => { + if (!selectElementIds.get(element.id)) { + return element; + } + return newElementWith(element, { + groupIds: addToGroup( + element.groupIds, + newGroupId, + appState.editingGroupId, + ), + }); + }); + // keep the z order within the group the same, but move them + // to the z order of the highest element in the layer stack + const elementsInGroup = getElementsInGroup(nextElements, newGroupId); + const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1]; + const lastGroupElementIndex = nextElements.lastIndexOf( + lastElementInGroup as OrderedExcalidrawElement, + ); + const elementsAfterGroup = nextElements.slice(lastGroupElementIndex + 1); + const elementsBeforeGroup = nextElements + .slice(0, lastGroupElementIndex) + .filter( + (updatedElement) => !isElementInGroup(updatedElement, newGroupId), + ); + const reorderedElements = syncMovedIndices( + [...elementsBeforeGroup, ...elementsInGroup, ...elementsAfterGroup], + arrayToMap(elementsInGroup), + ); + + return { + appState: { + ...appState, + ...selectGroup( + newGroupId, + { ...appState, selectedGroupIds: {} }, + getNonDeletedElements(nextElements), + ), + }, + elements: reorderedElements, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + predicate: (elements, appState, _, app) => + enableActionGroup(elements, appState, app), + keyTest: (event) => + !event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G, + PanelComponent: ({ elements, appState, updateData, app }) => ( + <ToolButton + hidden={!enableActionGroup(elements, appState, app)} + type="button" + icon={<GroupIcon theme={appState.theme} />} + onClick={() => updateData(null)} + title={`${t("labels.group")} — ${getShortcutKey("CtrlOrCmd+G")}`} + aria-label={t("labels.group")} + visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} + ></ToolButton> + ), +}); + +export const actionUngroup = register({ + name: "ungroup", + label: "labels.ungroup", + icon: (appState) => <UngroupIcon theme={appState.theme} />, + trackEvent: { category: "element" }, + perform: (elements, appState, _, app) => { + const groupIds = getSelectedGroupIds(appState); + const elementsMap = arrayToMap(elements); + + if (groupIds.length === 0) { + return { + appState, + elements, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + } + + let nextElements = [...elements]; + + const boundTextElementIds: ExcalidrawTextElement["id"][] = []; + nextElements = nextElements.map((element) => { + if (isBoundToContainer(element)) { + boundTextElementIds.push(element.id); + } + const nextGroupIds = removeFromSelectedGroups( + element.groupIds, + appState.selectedGroupIds, + ); + if (nextGroupIds.length === element.groupIds.length) { + return element; + } + return newElementWith(element, { + groupIds: nextGroupIds, + }); + }); + + const updateAppState = selectGroupsForSelectedElements( + appState, + getNonDeletedElements(nextElements), + appState, + null, + ); + + const selectedElements = app.scene.getSelectedElements(appState); + + const selectedElementFrameIds = new Set( + selectedElements + .filter((element) => element.frameId) + .map((element) => element.frameId!), + ); + + const targetFrames = getFrameLikeElements(elements).filter((frame) => + selectedElementFrameIds.has(frame.id), + ); + + targetFrames.forEach((frame) => { + if (frame) { + nextElements = replaceAllElementsInFrame( + nextElements, + getElementsInResizingFrame( + nextElements, + frame, + appState, + elementsMap, + ), + frame, + app, + ); + } + }); + + // remove binded text elements from selection + updateAppState.selectedElementIds = Object.entries( + updateAppState.selectedElementIds, + ).reduce( + (acc: { [key: ExcalidrawElement["id"]]: true }, [id, selected]) => { + if (selected && !boundTextElementIds.includes(id)) { + acc[id] = true; + } + return acc; + }, + {}, + ); + + return { + appState: { ...appState, ...updateAppState }, + elements: nextElements, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + keyTest: (event) => + event.shiftKey && + event[KEYS.CTRL_OR_CMD] && + event.key === KEYS.G.toUpperCase(), + predicate: (elements, appState) => getSelectedGroupIds(appState).length > 0, + + PanelComponent: ({ elements, appState, updateData }) => ( + <ToolButton + type="button" + hidden={getSelectedGroupIds(appState).length === 0} + icon={<UngroupIcon theme={appState.theme} />} + onClick={() => updateData(null)} + title={`${t("labels.ungroup")} — ${getShortcutKey("CtrlOrCmd+Shift+G")}`} + aria-label={t("labels.ungroup")} + visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} + ></ToolButton> + ), +}); |
