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.

align.ts
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 element
const updatedEle = scene.mutateElement(element, {
x: element.x + translation.x,
y: element.y + translation.y,
});
// update bound elements
updateBoundElements(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.

groups.ts
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());
};

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.ts and distribute.ts
  • actionAlign.tsx and actionDistribute.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:

additional feature for implementation

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.

groups.ts
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.