Erasing shapes · tldraw.dev<br>PricingShowcaseBlogDocs<br>48.4KQuick Start
When you scrub the eraser across the canvas in tldraw, every shape you cross gets erased. That sounds like the bare minimum for an eraser, but it's easy to build one that fails at it.<br>Well-made FigJam’s eraser misses lines when erasing quickly:<br>FigJam's eraser tunneling through lines.While glorious tldraw’s eraser does not:<br>Manly tldraw's eraser properly erases all crossed shapes, even when moving quickly.The difference is how applications treat input.<br>In FigJam (and the many other apps where this bug is found) the eraser is a point in space and shapes near the point get erased. In tldraw, the eraser is a line segment and shapes near the segment get erased. It’s a little detail that biggens the user experience.<br>Never skip a shape<br>Pointer input isn't continuous. The browser reports the pointer's position as a series of discrete samples, and when you move fast, those samples can land far apart. Flick your wrist and two consecutive pointer events might be a hundred pixels from each other, with three small shapes sitting untouched in the gap between them.<br>A naive eraser asks "what's under the pointer right now?" on every pointer event. At slow speeds that works fine. At high speeds it tunnels straight through anything that happens to fall between two samples, which is exactly when people use the eraser most aggressively: big, fast, careless swipes to clear a region.<br>Game developers know this problem as tunneling, where a fast-moving bullet passes through a wall because no frame ever caught it inside the wall. In tldraw, an eraser is a bullet you drag around with your hand.<br>Here's how we solve it.<br>Erase the line, not the point<br>The fix is to stop thinking of eraser input as a sequence of points and start thinking of it as a sequence of line segments. Each pointer event gives us a new position, and the editor remembers the previous one. Together they define a segment, and anything that segment touches gets erased. The samples may be sparse, but the segments between them are continuous: chain them together and you've covered the full path of the pointer, no matter how fast it moved.<br>The eraser tool is a state machine with three states: `idle`, `pointing`, and `erasing`. A quick click runs a single point test in the `pointing` state. The interesting work happens in the `erasing` state, which runs an `update` method on every pointer move:<br>const segmentToTest = [<br>previousPagePoint,<br>currentPagePoint<br>These two points are the segment we test. But testing every shape on the page against it would be wasteful, so we narrow the field first:<br>// the margin is in screen pixels, so convert it to page space<br>const margin = hitTestMargin / zoomLevel
const bounds = Box.FromPoints(segmentToTest).expandBy(margin)<br>const candidates = editor.getShapeIdsInsideBounds(bounds)<br>We wrap the segment in a bounding box, pad it by the hit test margin, and ask the editor's spatial index for the shapes inside. If there are none, we return early without testing a single shape.<br>Note the division by `zoomLevel`: the margin is defined in screen pixels, so when you're zoomed out we make it larger in page space. The eraser feels the same size on your screen no matter how far you've zoomed.<br>For each candidate, we transform the segment into the shape's local coordinate space:<br>const toLocalSpace = editor.getShapePageTransform(shape).clone().invert()<br>const [A, B] = segmentToTest.map((point) => toLocalSpace.applyToPoint(point))
Instead of teaching every hit test about rotated and scaled shapes, we move the two endpoints into the shape's own space, where the shape is axis-aligned and unscaled. A rotated rectangle is just a rectangle once you're standing inside its transform. After a quick bounding-box rejection, we hand the segment to the shape's geometry:<br>const geometry = editor.getShapeGeometry(shape)<br>if (geometry.hitTestLineSegment(A, B, margin)) {<br>erasing.add(shape.id)<br>One hit test, many geometries<br>That `geometry` object is where the second half of the story lives. Once we’ve identified shapes that could potentially be erased, we next need to test each shape more narrowly to see which to actually erase.<br>Every shape in tldraw exposes its outline through the geometry system: a tree of `Geometry2d` primitives like `Rectangle2d`, `Circle2d`, `Polyline2d`, and `CubicBezier2d`. The same geometry powers selection, arrow binding, snapping, and hit testing across the editor, so the eraser doesn't need to know anything about what it's erasing. A freehand draw stroke and a perfect ellipse both answer the same question: does this segment come within the margin of you?<br>The hit test itself is just a distance check: measure how close the segment gets to the geometry, and compare that against the margin. The base implementation walks the geometry's outline:<br>distanceToLineSegment(A, B) {<br>const { vertices } = this<br>let distance = Infinity<br>let nearest
for (let i = 0; i vertices.length; i++) {<br>const vertex...