Modes and Meaning
Click. What happens?
Depends on the mode.
In view mode, click to pan. In select mode, click to select. In edit mode, click to add a vertex.
Same input, different behavior. This is the mode problem.
The First Disaster
We started with boolean flags.
const state = {
isSelecting: false,
isDragging: false,
isEditing: false,
isPanning: false,
isDrawing: false
};
Then the bugs began.
"User was selecting but also dragging?" isSelecting && isDragging. Possible.
"User was editing and panning at the same time?" Shouldn't be possible. But it was.
Five booleans = 32 possible states. Most are invalid. None are enforced.
The State Machine
A mode is an exclusive state. You're in exactly one mode at a time.
const Mode = {
VIEW: 'view',
SELECT: 'select',
EDIT_VECTOR: 'editVector',
DRAW_SHAPE: 'drawShape'
};
let currentMode = Mode.SELECT;
One variable. Four possibilities. Mutually exclusive by construction.
var cfg = {
"id": "mode1",
"type": "mode-demo",
"bindings": {
"mode": "editor.mode"
},
"width": 500,
"height": 250
}
Current mode: {{editor.mode}}
Click the canvas to see different behaviors per mode.
Mode-Specific Handlers
Each mode defines how it handles input:
const modeHandlers = {
[Mode.VIEW]: {
onMouseDown(e) { this.startPan(e); },
onMouseMove(e) { this.pan(e); },
onMouseUp(e) { this.endPan(); }
},
[Mode.SELECT]: {
onMouseDown(e) {
const hit = this.hitTest(e);
if (hit) this.selectObject(hit);
else this.startMarquee(e);
},
onMouseMove(e) { this.updateMarquee(e); },
onMouseUp(e) { this.endMarquee(); }
},
[Mode.EDIT_VECTOR]: {
onMouseDown(e) {
const hit = this.hitTestVertex(e);
if (hit) this.startDragVertex(hit);
else this.addVertex(e);
},
onMouseMove(e) { this.dragVertex(e); },
onMouseUp(e) { this.endDragVertex(); }
}
};
The dispatcher is simple:
function handleMouseDown(e) {
modeHandlers[currentMode].onMouseDown.call(editor, e);
}
Mode Transitions
Not all transitions are valid. View mode shouldn't jump directly to edit mode.
┌──────────┐
┌──>│ VIEW │<──┐
│ └────┬─────┘ │
│ │ │
│ ▼ │
│ ┌──────────┐ │
└───│ SELECT │───┘
└────┬─────┘
│ (double-click object)
▼
┌───────────┐
│EDIT_VECTOR│
└───────────┘
We encode this as a transition table:
const transitions = {
[Mode.VIEW]: [Mode.SELECT],
[Mode.SELECT]: [Mode.VIEW, Mode.EDIT_VECTOR, Mode.DRAW_SHAPE],
[Mode.EDIT_VECTOR]: [Mode.SELECT],
[Mode.DRAW_SHAPE]: [Mode.SELECT]
};
function canTransition(from, to) {
return transitions[from].includes(to);
}
function setMode(newMode) {
if (!canTransition(currentMode, newMode)) {
console.warn(`Invalid transition: ${currentMode} → ${newMode}`);
return;
}
modeHandlers[currentMode].onExit.call(editor);
currentMode = newMode;
modeHandlers[currentMode].onEnter.call(editor);
}
Illegal transitions get logged. State remains consistent.
Enter and Exit Hooks
Modes need setup and cleanup:
const modeHandlers = {
[Mode.EDIT_VECTOR]: {
onEnter() {
this.showVertexHandles();
this.showTangentControls();
this.setCursor('crosshair');
},
onExit() {
this.hideVertexHandles();
this.hideTangentControls();
this.setCursor('default');
},
// ... input handlers
}
};
onEnter prepares the UI. onExit cleans it up. The transition function calls both automatically.
Sub-States Within Modes
Select mode has its own complexity: idle, dragging, marquee selecting.
These are sub-states, not separate modes:
const SelectSubState = {
IDLE: 'idle',
DRAGGING: 'dragging',
MARQUEE: 'marquee'
};
const selectModeHandler = {
subState: SelectSubState.IDLE,
onMouseDown(e) {
const hit = this.hitTest(e);
if (hit) {
this.selectObject(hit);
this.subState = SelectSubState.DRAGGING;
this.dragStart = e;
} else {
this.subState = SelectSubState.MARQUEE;
this.marqueeStart = e;
}
},
onMouseMove(e) {
switch (this.subState) {
case SelectSubState.DRAGGING:
this.moveSelection(e);
break;
case SelectSubState.MARQUEE:
this.updateMarquee(e);
break;
}
},
onMouseUp(e) {
switch (this.subState) {
case SelectSubState.MARQUEE:
this.selectInMarquee();
break;
}
this.subState = SelectSubState.IDLE;
}
};
Sub-states reset to IDLE on mouse up. They don't persist across interactions.
Keyboard Modifiers
Modifiers change behavior without changing mode:
function onMouseDown(e) {
const hit = this.hitTest(e);
if (hit) {
if (e.shiftKey) {
this.toggleSelection(hit); // Add/remove from selection
} else {
this.selectObject(hit); // Replace selection
}
}
}
Shift, Ctrl, Alt modify the current mode's behavior. They're not modes themselves.
Tool Shortcuts
Users expect keyboard shortcuts to switch tools:
document.addEventListener('keydown', (e) => {
switch (e.key) {
case 'v': setMode(Mode.SELECT); break;
case 'h': setMode(Mode.VIEW); break;
case 'p': setMode(Mode.DRAW_SHAPE); break;
case 'Escape':
if (currentMode !== Mode.SELECT) {
setMode(Mode.SELECT);
}
break;
}
});
Escape always returns to select mode. It's the safe default.
Mode Indicator UI
Users need to know which mode they're in:
function updateModeUI() {
document.querySelectorAll('.tool-button').forEach(btn => {
btn.classList.toggle('active', btn.dataset.mode === currentMode);
});
canvas.style.cursor = modeCursors[currentMode];
}
Visual feedback: highlighted toolbar button, cursor change. No ambiguity.
Summary
- Modes are exclusive states. One at a time.
- Boolean flags multiply complexity. Avoid them.
- Transition tables enforce valid state changes.
- Enter/exit hooks manage UI setup and cleanup.
- Sub-states handle temporary conditions within modes.
- Modifiers change behavior, not mode.
The state machine pattern scales. Five modes? Twenty modes? Same architecture.
The alternative—scattered conditionals—doesn't scale. Trust me. We tried.
Next: Properties Panel →