From User to Contributor
From User to Contributor
Over the summer, I participated in the Summer Open Source Experience through Computing Talent Initiative and CodeDay Labs. The program pairs contributors with real issues in open-source projects.
Having previously completed one of their internships, I was given flexibility to choose a project aligned with my interests. With a background in graphic design and countless hours in vector tools, I’ve always been curious about how drawing applications work under the hood. That curiosity led me to Excalidraw.
What is Excalidraw?
Excalidraw is a free, open-source collaborative whiteboard built around simplicity and privacy. Its WYSIWYG interface makes sketching intuitive, and end-to-end encryption enables secure collaboration without accounts or subscriptions.
I was already an active user. I use it for note-taking and previously relied on it while building an MVP with a remote team. We mapped ideas in real time, saved boards weekly, and incorporated them into our final presentation. From students to startups, Excalidraw’s flexibility makes it powerful.
The Bug
The issue I tackled involved alignment and distribution tools failing in edit-group mode (Issue #9606).
To understand the bug, three core actions matter:
- Grouping – Links elements so they behave as a single unit.
- Alignment – Lines elements along a shared axis.
- Distribution – Evenly spaces elements based on bounding boxes.
Normally, when you double-click a group to enter edit-group mode, you can transform individual elements while preserving the group. However, alignment and distribution produced no visible changes in this mode. The tools appeared active but did nothing, indicating a bug in the logic.
Tracing the Problem
I began at App.tsx, where pointer interactions are handled, but the file exceeded 10,000 lines. To narrow the scope, I used AI to generate a preliminary Mermaid diagram of the double-click flow, then manually verified it line-by-line. This gave me a reliable mental model of the interaction loop.
Further tracing led me to alignElements and distributeElements in group.ts. Both relied on a helper function called getMaximumGroups, which organizes selected elements into groups using each element’s groupIds array.
Manual Code Tracing
Analyzing the code for alignElements, I noticed a function within that shared a commonality across all affected actions within the issue...
The Suspect...
Where GetMaximumGroups was in charge of grouping elements for the affected actions, determining their respective new positions based on their groupings.
Grouping Logic
In edit-group mode, all selected elements share the same final groupId, so getMaximumGroups returns a single group, this prevented alignment and distribution from functioning since they operate across groups.
export const alignElements = (selectedElements: ExcalidrawElement[],alignment: Alignment,scene: Scene): ExcalidrawElement[] => {const elementsMap = scene.getNonDeletedElementsMap();const groups: ExcalidrawElement[][] = getMaximumGroups(selectedElements, elementsMap);const selectionBoundingBox =getCommonBoundingBox(selectedElements);return groups.flatMap((group) => {const translation =calculateTranslation(group, selectionBoundingBox, alignment);return group.map((element) => {// update elementconst updatedEle = scene.mutateElement(element, {x: element.x + translation.x,y: element.y + translation.y,});// update bound elementsupdateBoundElements(element, scene, {simultaneouslyUpdated: group,});return updatedEle;});});};
Here’s where the issue surfaced.
Excalidraw does not model hierarchy with a tree structure. Instead, hierarchy is encoded in the ordering of the flat groupIds array. The last index represents the outermost group.
While in edit-group mode, all selected elements share the same final groupId. As a result, getMaximumGroups returns a single group containing all elements. Since alignment and distribution operate across groups, no translation occurs since every element within a group is treated as one unit.
The tools weren’t broken; the grouping logic prevented them from acting.
The Solution
My initial instinct was to modify getMaximumGroups, but it was coupled to other logic. To avoid regressions, I introduced a new function: getSelectedElementsByGroup.
To Prevent Regressions
Since a lot of the pre-existing logic in getMaximumGroups was still relevant, I considered making a copy and modifying it.
The Fix: getSelectedElementsByGroup
Required modifications required passing of an additional parameter appState,
incorporating appState.selectedGroupIds into the grouping logic, and refactoring dependencies to use the new function.
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 groupingconst boundTextElement =getBoundTextElement(element, elementsMap);if (boundTextElement) {currentGroupMembers.push(boundTextElement);}groups.set(groupId, [...currentGroupMembers, element]);});return Array.from(groups.values());};
The key difference was incorporating appState.selectedGroupIds. By passing this into existing helpers, the function could determine whether an element should be treated as:
- A standalone element
- A member of a selected group
- Or part of a nested group
This preserved grouping order while allowing alignment and distribution to function correctly in edit-group mode.
I updated:
align.tsanddistribute.tsactionAlign.tsxandactionDistribute.tsx
After validating UI edge cases and ensuring all existing tests passed, I submitted PR #9721.
Additional Feature: Respecting Hierarchy
During review, the maintainer requested extended behavior:

If a single group is selected, alignment and distribution should apply to that group’s direct children, not flatten the hierarchy.
This required slicing each element’s groupIds array at the selected group’s position and regrouping based on the derived structure. If the derived array was empty, the element was standalone; otherwise, it belonged to the last group in that slice.
Before the Review: Initial Implementation
Required modifications required passing of an additional parameter appState,
incorporating appState.selectedGroupIds into the grouping logic, and refactoring dependencies to use the new function.
After the Review: Clarifying Semantics
For clarity, @mrazator requested that I needed to distinguish between elements and groups, since semantically they represent different concepts within the codebase. and to compose two methods in which handle the cases of groups and elements separately.
After the Review: Additional Feature
The additional feature requested would allow for alignment and distribution to be applied to direct children of a selected group, a unique scenario for a drawing application with an infinite canvas and no artboards to align/distribute to.
After the Review: Core Logic
With the additional feature implemented, the core logic of the function was now to determine whether the selected elements were part of a single group, and if so, to respect hierarchy by grouping elements based on their position within the groupIds array relative to the selected group ID. If multiple groups were selected, it would default to grouping based on the selected group ID as before.
export const getSelectedElementsByGroup = (selectedElements: ExcalidrawElement[],elementsMap: ElementsMap,appState: Readonly<AppState>,): ExcalidrawElement[][] => {const groups: Map<String, ExcalidrawElement[]> =new Map<String, ExcalidrawElement[]>();selectedElements.forEach((element: ExcalidrawElement) => {const groupId =getSelectedGroupIdForElement(element, appState.selectedGroupIds) ||element.id;const currentGroupMembers = groups.get(groupId) || [];const boundTextElement =getBoundTextElement(element, elementsMap);if (boundTextElement) {currentGroupMembers.push(boundTextElement);}groups.set(groupId, [...currentGroupMembers, element]);});return Array.from(groups.values());};
After refactoring and adding new test coverage, I resubmitted. The PR was approved and merged on July 15.
I also added missing test coverage for distribution (PR #9756), and later helped confirm a release-timing issue related to the original fix (Issue #9790).
Closing Thoughts
This experience stretched me technically and professionally. What began as curiosity about design tools evolved into deeper lessons in state modeling, debugging legacy code, and communicating complex ideas clearly.
One highlight was being invited to share my workflow during a fireside chat with over 100 students alongside our coordinator, Utsab Saha. I spoke about using AI not to replace thinking, but to accelerate understanding—generating diagrams, mapping flows, and validating assumptions.
I’m deeply grateful to Computing Talent Initiative, CodeDay, the Excalidraw maintainers, and my mentor Swapna Nadakuditi for their guidance.
Fixing a small bug might seem minor—but in open source, small fixes compound. And this one helped me grow in ways I didn’t expect.