Engineering Journal
Schema Editor
Schema Editor

We added an input mode to prevent a conflict our input model had already made impossible

2026-08-02

TLDR

We locked the camera whenever an object was selected, so drag and scroll would act on the selection instead of the canvas. The conflict this prevented could not occur, because the gesture vocabulary already separated the two. Selecting a single shape silently disabled zoom, fit and rotate for months.

The assumption that seemed reasonable

A canvas editor has two things a drag could plausibly mean. Move the camera, or move the thing under the cursor. Scroll could mean zoom the view, or scale the selection. This is a real design problem and every editor solves it somehow.

Our answer was a mode. When something is selected, you are editing, so the camera stands still and gestures belong to the selection. Hold a modifier to temporarily get the camera back.

Written down like that it sounds like careful interaction design. It reads well in a code review. The function was even honest about itself:

// When objects are selected the viewport is locked so
// drag / wheel / rotate act on the selection, not the
// canvas. Hold Space to temporarily unlock viewport.
_isViewportLocked() {
  return this._selection?.length > 0 && !this._spaceHeld;
}

That predicate spread to eight call sites: setZoom, setRotation, animateZoom, fitToView, two rotate-view functions, wheel handling, and pan.

When it failed

It failed constantly and quietly, which is why it lasted.

The report was not "the viewport lock is wrong." It was a list of things that felt broken. Clicking a component and then scrolling to zoom did nothing. Pressing the fit-to-screen button did nothing. The zoom slider snapped back to its old value when you dragged it.

Nobody connects those symptoms to a selection, because in every other editor they have used, selection does not disable zoom. So it presents as a flaky canvas rather than a rule. The user's actual question was "why did we lock them again?", which is the right question and one the code could not answer.

What was actually wrong

The premise. There was no ambiguity to resolve.

By the time the lock was written, panning already required Space or middle mouse, because a plain background drag had been given to marquee selection. So a plain drag on the canvas was never going to move the camera regardless of what was selected.

Resize and rotate only start from an explicit hit-test against overlay handles. A drag that begins on a handle is unambiguous. A drag that begins anywhere else is not a resize, again regardless of what is selected.

And scroll was never overloaded at all. Wheel meant zoom, always, in every mode. There was no competing meaning for it to be protected from.

So the guard defended three gestures, and all three had already been disambiguated by other means. It was pure cost. Worse, it was cost with a plausible justification attached, which is the most durable kind.

There is a structural tell here worth naming. The predicate was selection.length > 0. Selection is a state, not a gesture. A guard keyed on state disables a capability for as long as that state persists, whereas the thing it was worried about was momentary. That mismatch is visible in the signature alone, before you understand the domain at all.

What got deleted

All eight call sites. The function itself stayed, as a permanent false with a comment explaining why, because a touch-gesture handler in another module still probes it and deleting it outright would have meant touching code we were not otherwise changing.

//   Historically a selection locked the camera so drag/wheel/rotate acted on
//   the selection instead of the canvas. That was never needed: pan already
//   requires Space or middle-mouse, and resize/rotate only start from a
//   handle hit-test, so nothing about zoom/fit/rotate could ever be
//   ambiguous. All it did was make the view freeze whenever something was
//   selected.
_isViewportLocked() {
  return false;
}

Leaving the corpse in place with the autopsy attached is deliberate. The next person to wonder whether selection should lock the camera gets the answer instead of the idea.

What replaced it

Nothing. That is the whole point of the entry.

The removal was a net deletion of roughly twenty lines and the behaviour that replaced it is the absence of behaviour. Zoom zooms, fit fits, rotate rotates, and they do so whether or not something is selected, which is what every user already expected.

The one thing we did add came later and in the opposite direction. The modifier that used to "temporarily unlock the viewport" now simply activates the hand tool and restores the previous tool on release. Instead of a flag that other code consults to decide whether to behave differently, the modifier changes what tool is active and the existing tool handles it. One definition of panning rather than two.

The generalizable lesson

Before you add a mode to disambiguate two interactions, check whether your gesture vocabulary already separates them.

Modes are expensive in a way that is hard to see at the point of writing. They are cheap to add, they read as thoughtful, and their cost is paid later by users who cannot form a model of when the application will respond. A mode that prevents a real conflict earns that cost. A mode that prevents an imaginary one is invisible tax.

Three questions that would have caught this at review time:

What exactly is the ambiguous gesture? Name it as a specific input, not a category. "Drag" is a category. "Left-drag starting on empty canvas" is a gesture, and once you write it that precisely you can check whether it already has one meaning.

Is the guard keyed on a state or a gesture? State-keyed guards disable things for a duration. If the conflict is momentary, the guard is scoped wrong even when the conflict is real.

What breaks if I remove it? If you cannot name a specific broken interaction, you do not have a justification, you have a rationale. Ours could not survive that question, and it had never been asked.

The uncomfortable part is that this code was not written carelessly. It had a coherent story and a clear comment. It was wrong because the world it described had stopped being true, probably at the moment background-drag became marquee selection, and no one revisited the guard that assumption supported. Interaction guards should be re-audited whenever the gesture vocabulary around them changes, because they encode a snapshot of a conflict that may not outlive the next input change.

Read this post in the full Engineering Journal →