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

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 →