Excalidraw Onboarding LabChapter I — End to end: a user drags on the canvas to draw a rectangle0/6Contents
Chapter I

End to end: a user drags on the canvas to draw a rectangle

This module follows one concrete user action — dragging out a rectangle on the canvas — through every layer it touches: App's pointer-down handler, the newElement() factory, Scene's authoritative element store, the Store's independently-scheduled undo capture, and the Renderer's throttled repaint. By the end you can see why the same plain element object passes unchanged through factory, Scene, Store, and Renderer, and why undo bookkeeping and on-screen painting are two separate subscriptions to that same committed state rather than one path doing both.


walkthrough

handleCanvasPointerDown: where a browser event becomes an editor action

packages/excalidraw/components/App.tsx · lines 79067933
lines 7906–7908

The signature takes a React.PointerEvent<HTMLElement> — React's wrapper around the browser's native pointerdown event, which fires the instant the mouse button (or a touch, or a stylus) goes down over the canvas. This one function is wired to the canvas's onPointerDown prop, so it is the single front door every rectangle-drag, click-to-select, and shape tool interaction passes through.

lines 7919–7924

viewportCoordsToSceneCoords immediately converts the event's pixel position — relative to the browser viewport, and therefore dependent on however much the canvas is currently scrolled or zoomed — into scene coordinates, the fixed coordinate space every ExcalidrawElement's x/y is stored in. This is the first translation from 'raw event' to 'editor data': everything downstream, from the element factory to the renderer, works in scene space, never viewport pixels.

lines 7926–7931

target.setPointerCapture(event.pointerId) locks all subsequent pointermove/pointerup events for this pointerId onto the element that received the pointerdown — like a walkie-talkie channel that stays locked to whoever keyed it first. Without it, dragging fast enough to slide the cursor over a toolbar button mid-drag would start delivering move events to that button instead of the canvas. Because of the capture, every event for the rest of this drag keeps arriving at the same handler no matter where the cursor physically wanders.

  private handleCanvasPointerDown = (    event: React.PointerEvent<HTMLElement>,  ) => {    const selectedElements = this.scene.getSelectedElements(this.state);    // If Ctrl is not held, ensure isBindingEnabled reflects the user preference.    if (!event.ctrlKey) {      const preferenceEnabled = this.state.bindingPreference === "enabled";      if (this.state.isBindingEnabled !== preferenceEnabled) {        this.setState({ isBindingEnabled: preferenceEnabled });      }    }    const scenePointer = viewportCoordsToSceneCoords(event, this.state);    const { x: scenePointerX, y: scenePointerY } = scenePointer;    this.lastPointerMoveCoords = {      x: scenePointerX,      y: scenePointerY,    };    const target = event.target as HTMLElement;    // capture subsequent pointer events to the canvas    // this makes other elements non-interactive until pointer up    if (target.setPointerCapture) {      target.setPointerCapture(event.pointerId);    }    this.maybeCleanupAfterMissingPointerUp(event.nativeEvent);
step 1 of 3
walkthrough

createGenericElementOnPointerDown: from tool state to a factory call

packages/excalidraw/components/App.tsx · lines 97749830
lines 9778–9784

pointerDownState.origin.x/y — the scene coordinates captured at the moment of pointer-down — get snapped to the nearest grid point via getGridPoint, unless the user holds Ctrl/Cmd to bypass snapping. This is where the exact pixel the user clicked turns into the coordinate the new element is actually born at.

lines 9791–9804

baseElementAttributes gathers every 'current tool' style setting living in App's own state — stroke color, background, fill style, roughness, opacity — plus the snapped x/y, into one plain options object. Bundling the app-specific state here means the factory function itself stays generic and never needs to know anything about App.

lines 9812–9817

This is the actual factory call. For any non-embeddable tool — rectangle included — newElement({ type: elementType, ...baseElementAttributes }) calls into newElement.ts, which returns a plain JavaScript object: an id, x/y/width/height, style fields, a version and seed for later diffing — no class, no methods, just data. That plainness is exactly why the same value can be handed to the Scene, mutated at a low level, diffed by the Store, and read by the Renderer without any of those layers needing to know about each other's types.

lines 9819–9829

If the active tool is 'selection', the element is only ever kept as an ephemeral this.state.selectionElement — a rubber-band box that's never committed anywhere. For a real shape like our rectangle, this.insertNewElement(element) runs instead: this is the handoff out of App's tool logic and into the Scene, where the element actually becomes part of the drawing. The following setState({ newElement: element }) is just bookkeeping that lets the live in-progress rectangle keep redrawing as the user drags.

  private createGenericElementOnPointerDown = (    elementType: ExcalidrawGenericElement["type"] | "embeddable",    pointerDownState: PointerDownState,  ): void => {    const [gridX, gridY] = getGridPoint(      pointerDownState.origin.x,      pointerDownState.origin.y,      this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]        ? null        : this.getEffectiveGridSize(),    );    const topLayerFrame = this.getTopLayerFrameAtSceneCoords({      x: gridX,      y: gridY,    });    const baseElementAttributes = {      x: gridX,      y: gridY,      strokeColor: this.state.currentItemStrokeColor,      backgroundColor: this.state.currentItemBackgroundColor,      fillStyle: this.state.currentItemFillStyle,      strokeWidth: this.getCurrentItemStrokeWidth(elementType),      strokeStyle: this.state.currentItemStrokeStyle,      roughness: this.state.currentItemRoughness,      opacity: this.state.currentItemOpacity,      roundness: this.getCurrentItemRoundness(elementType),      locked: false,      frameId: topLayerFrame ? topLayerFrame.id : null,    } as const;    let element;    if (elementType === "embeddable") {      element = newEmbeddableElement({        type: "embeddable",        ...baseElementAttributes,      });    } else {      element = newElement({        type: elementType,        ...baseElementAttributes,      });    }    if (element.type === "selection") {      this.setState({        selectionElement: element,      });    } else {      this.insertNewElement(element);      this.setState({        multiElement: null,        newElement: element,      });    }  };
step 1 of 4
walkthrough

Renderer.getVisibleCanvasElements: culling before the throttled paint

packages/excalidraw/scene/Renderer.ts · lines 4787
lines 47–65

getVisibleCanvasElements takes the full elementsMap plus the viewport-describing slice of AppState — zoom, scroll offsets, canvas width/height — everything needed to know what's actually on screen right now, as opposed to what merely exists in the Scene.

lines 66–83

It loops over every element and keeps only the ones isElementInViewport reports as intersecting the current viewport — like a bouncer at a door, checking each element's bounds against the camera frame before letting it through to be drawn. This runs on every render, including every pointer-move while the rectangle is still being dragged, so keeping it a cheap loop over plain data (remember: elements are just plain objects) matters — there's no virtual-DOM diffing per element here.

lines 86–87

The filtered list comes back up through Renderer.getRenderableElements, which App.render() calls on every React render and passes down as the visibleElements prop to <StaticCanvas>. StaticCanvas's effect calls renderStaticScene, which — since render throttling is enabled by default — delegates to renderStaticSceneThrottled, a requestAnimationFrame-throttled function that collapses any number of calls within one frame into a single actual paint. So the chain from Scene.replaceAllElements's triggerUpdate to pixels on screen is: Scene notifies → React re-renders → Renderer recomputes visible elements → StaticCanvas schedules, at most once per frame, the pass that paints the rectangle.

  private getVisibleCanvasElements({    elementsMap,    zoom,    offsetLeft,    offsetTop,    scrollX,    scrollY,    height,    width,  }: {    elementsMap: NonDeletedElementsMap;    zoom: AppState["zoom"];    offsetLeft: AppState["offsetLeft"];    offsetTop: AppState["offsetTop"];    scrollX: AppState["scrollX"];    scrollY: AppState["scrollY"];    height: AppState["height"];    width: AppState["width"];  }): readonly NonDeletedExcalidrawElement[] {    const visibleElements: NonDeletedExcalidrawElement[] = [];    for (const element of elementsMap.values()) {      if (        isElementInViewport(          element,          width,          height,          {            zoom,            offsetLeft,            offsetTop,            scrollX,            scrollY,          },          elementsMap,        )      ) {        visibleElements.push(element);      }    }    return visibleElements;  }
step 1 of 3
Recap: the full trip from pointer-down to painted rectangle
Browser pointerdown…ges/excalidraw/components/App.tsx
App.handleCanvasPointerDown…ges/excalidraw/components/App.tsx
createGenericElementOnPointerDown…ges/excalidraw/components/App.tsx
newElement() factorypackages/element/src/newElement.ts
App.insertNewElement…ges/excalidraw/components/App.tsx
Scene.replaceAllElementspackages/element/src/Scene.ts
Store (scheduleCapture → commit)…ges/excalidraw/components/App.tsx
Renderer.getVisibleCanvasElements…ages/excalidraw/scene/Renderer.ts
renderStaticScene (throttled)…xcalidraw/renderer/staticScene.ts
Figure I · Click a node to see what it does, where it lives in the code, and its labeled connections — the annotation opens beside it.
checkpoint

Checkpoint: drawing a rectangle

Answered in place — nothing is graded, everything is explained. 0 / 3 passed

Inside createGenericElementOnPointerDown, what actually constructs the new rectangle as a plain data object?

Why does handleCanvasPointerDown call target.setPointerCapture(event.pointerId)?

Why can a freshly-drawn rectangle already be visible on screen before its undo entry is captured?