A modifier key that means 'temporarily be that tool' should activate that tool
TLDR
When a held key means "temporarily behave like tool X," the usual implementation is a flag that every other tool checks. That is a duplicate of tool X spread across call sites, and it will drift from the original. Activate the real tool on keydown and restore the previous one on keyup.
The position
Hold Space in almost any canvas application and you get a pan cursor. Almost everyone implements it as a boolean:
if (e.key === ' ') this._spaceHeld = true;
Then that flag gets consulted wherever it might matter: in the selection tool, in the camera code, in hit-testing.
This is wrong on principle, not merely untidy. You already have a pan implementation. It is called the hand tool. The flag creates a second one, scattered across consumers, and the two will diverge.
What the industry does and why
The flag is the path of least resistance. One line to set, one to clear, and each consumer's check is a two-word condition that looks harmless in isolation. The duplication exists only in aggregate, so no single call site looks like a duplicate implementation.
There is also a real objection to the alternative. Switching tools has side effects: ours cancels in-progress drawing, updates toolbar state, and swaps the cursor. Pressing Space halfway through a polygon should not discard the polygon. That objection is correct, and it is why the flag feels safer.
Why it fails
Because the copies drift, invisibly.
Our pan logic existed twice. The hand tool, which panned from anywhere. And a branch inside the selection tool for the Space case, which had grown its own hand-maintained list of which elements count as background:
const isBackground = targetId === 'svgWrapper'
|| targetId === '_gridLayer'
|| targetId === 'svgContainer'
|| target.tagName.toLowerCase() === 'svg';
That list was missing the page background rect, which is most of the visible canvas. So holding Space and dragging on the page silently did nothing. The hand tool had no such bug, because it never needed the list.
That is the characteristic failure. Not a crash, just one copy quietly being worse than the other in a case nobody enumerated. Nobody files "the duplicate pan implementation has an incomplete background list." They say Space sometimes does not work.
The better approach
Make the modifier do the thing it says it does:
_beginSpacePan() {
this._spaceHeld = true;
if (this._drawState) return; // mid-draw: switching would cancel it
if (this.activeTool === 'hand') return;
this._toolBeforeSpace = this.activeTool;
this.setActiveTool('hand', { silent: true });
}
_endSpacePan() { if (!this._spaceHeld) return; this._spaceHeld = false; const prev = this._toolBeforeSpace; this._toolBeforeSpace = null; if (prev) this.setActiveTool(prev, { silent: true }); }
The side-effect objection is answered by one guard, not an architecture. If a draw is in progress, skip the swap.
The pan entry point then collapsed to two conditions, and the background list disappeared because the hand tool never needed one.
startDrag(event) {
if (this._textEditActive) return;
const middleMouse = event.button === 1;
if (this.activeTool !== 'hand' && !middleMouse) return;
// ...
}
The flag is still set, but only the two functions that own it read it. Every other consumer went away, along with the chance of them disagreeing. The toolbar button and cursor now update while Space is held, free, because the tool genuinely is active.
What you give up
Tool activation is heavier than setting a boolean. If your switch does layout or fires analytics, guard against key auto-repeat, which you want anyway.
You also inherit every side effect of tool switching, including ones added later by someone not thinking about the modifier path. A flag has no side effects precisely because it does nothing, which is the same reason it does not work.
And you need a blur handler, since a key released outside the window never fires keyup.
When the flag is right
When the modifier does not correspond to an existing mode.
Shift to constrain proportions, Alt to duplicate on drag, Ctrl to add to a selection: none of these are "temporarily be a different tool." There is no proportion tool. Those are genuine per-gesture modifications, and a flag is exactly right.
The test is simple. Ask whether you could name the tool the modifier is impersonating. If you can, activate it. If naming it produces something absurd, keep the flag.
Space is pan. Pan is the hand tool. Say so in code.