Actions: the general mechanism behind every state change
Every visible edit in Excalidraw — delete, duplicate, flip, lock, change a color — is implemented as an Action object: a plain record with a name, a perform() function, an optional keyboard test, and an optional panel widget, declared once via register() and collected into a single array. ActionManager is the dispatcher that turns any trigger (keydown, panel click, context-menu item, command-palette pick, or a direct API call) into a lookup-by-name and a call to that action's perform(), then hands the returned ActionResult to App.syncActionResult, which commits elements and AppState through the same Scene/Store primitives you saw in updateScene. This module generalizes the one hard-coded 'draw a rectangle' path into the architecture behind essentially the whole editor.
One concrete Action: actionFlipHorizontal
packages/excalidraw/actions/actionFlip.ts · lines 29–52name is the ActionName key ActionManager will store this under ('flipHorizontal' in the union defined in types.ts); label is an i18n key the UI resolves wherever this action is rendered as a menu item or shortcut-help row; icon and trackEvent feed that same UI/analytics regardless of what triggered the action. None of this is special to flipping — every registered action carries this same metadata shape.
perform matches the ActionFn signature: (elements, appState, formData, app) in, an ActionResult out. It computes the flipped elements, hands the (unchanged) appState back alongside them, and sets captureUpdate to IMMEDIATELY — telling Store this edit should land on the undo stack right away, reusing the CaptureUpdateAction mechanism from the drawing trace.
keyTest is the shortcut binding itself, not a lookup into some separate keymap file: it is a plain boolean check on the raw key event. ActionManager runs every registered action's keyTest against each keydown and fires whichever one matches — the action defines its own shortcut inline.
export const actionFlipHorizontal = register({ name: "flipHorizontal", label: "labels.flipHorizontal", icon: flipHorizontal, trackEvent: { category: "element" }, perform: (elements, appState, _, app) => { return { elements: updateFrameMembershipOfSelectedElements( flipSelectedElements( elements, app.scene.getNonDeletedElementsMap(), appState, "horizontal", app, ), appState, app, ), appState, captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; }, keyTest: (event) => event.shiftKey && event.code === CODES.H,});
executeAction: the one function every trigger funnels through
packages/excalidraw/actions/manager.tsx · lines 132–143source defaults to "api" — a test or a host app calling executeAction(actionDeleteSelected) directly needs to pass nothing extra. ContextMenu.tsx passes "contextMenu", CommandPalette.tsx passes "commandPalette": same method, only this one string differs, and it's used purely for analytics tagging (see trackAction below), never to change what runs.
It re-reads elements and appState fresh through the getters App handed ActionManager at construction — the exact same getters handleKeyDown uses for keyboard shortcuts — so an action never operates on stale state, regardless of how long ago the manager was built.
This is the whole mechanism in one line: call action.perform(...) and hand its return value straight to this.updater. ActionManager never inspects what changed — it doesn't know or care whether flipHorizontal, deleteSelectedElements, or changeStrokeColor just ran. That's what makes it a general dispatcher instead of a pile of if-else branches per command.
executeAction<T extends Action>( action: T, source: ActionSource = "api", value: Parameters<T["perform"]>[2] = null, ) { const elements = this.getElementsIncludingDeleted(); const appState = this.getAppState(); trackAction(action, source, appState, elements, this.app, value); this.updater(action.perform(elements, appState, value, this.app)); }
return { elements: nextElements, appState: { ...nextAppState, activeTool: updateActiveTool(appState, { type: app.state.preferredSelectionTool.type, }), multiElement: null, newElement: null, activeEmbeddable: null, selectedLinearElement: null, }, captureUpdate: isSomeElementSelected( getNonDeletedElements(elements), appState, ) ? CaptureUpdateAction.IMMEDIATELY : CaptureUpdateAction.EVENTUALLY, };
packages/excalidraw/actions/actionDeleteSelected.tsx · lines 285–303it("frame only", async () => { const f1 = API.createElement({ type: "frame", }); const r1 = API.createElement({ type: "rectangle", frameId: f1.id, }); API.setElements([f1, r1]); API.setSelectedElements([f1]); act(() => { h.app.actionManager.executeAction(actionDeleteSelected); }); assertElements(h.elements, [ { id: f1.id, isDeleted: true }, { id: r1.id, isDeleted: false, selected: true }, ]); });
packages/excalidraw/actions/actionDeleteSelected.test.tsx · lines 16–38h.app.actionManager.executeAction(actionDeleteSelected) directly, the exact same 'api'-source path a host application's imperative API would use. The assertion afterward reads h.elements, the live Scene contents from the drawing-trace module: proof that perform()'s ActionResult really did travel ActionManager → syncActionResult → Scene, with no UI involved at all.Check your understanding
Answered in place — nothing is graded, everything is explained. 0 / 3 passed
Where is the actionFlipHorizontal command actually declared as an Action object (name, perform, keyTest)?
In ActionManager.executeAction, once action.perform(...) returns an ActionResult, what happens to it?
What is an Action's keyTest function used for?