Custom Controller
Custom Controller
If you want your own advanced controller to stay consistent with engine architecture, build it like built-ins:
- controller extends
InputControllerBase - behavior is split into modules (
IInputModule<TContext>) scene.inputManagerinjects one typed context into the whole controller
This is exactly how LayerWorldInputController is structured.
Target Architecture
Input (static state)
-> InputControllerBase
-> ModuleWorldZoom
-> ModuleWorldPan
Controller owns lifecycle and module orchestration.
Modules own concrete behavior.
Step 1. Define typed context
import type { LayerWorld } from '@flowscape-ui/core-sdk';
import type { WorldInputOptions } from '@flowscape-ui/core-sdk';
export type WorldInputContext = {
stage: IStage;
world: LayerWorld;
options: Required<WorldInputOptions>;
emitChange: () => void;
};
Why this matters:
stagegives screen-space dimensions and DOM rectworldgives camera accessoptionsconfigures behavior without hardcodingemitChangetriggers redraw after state changes
Step 2. Define module contract alias
import type { IInputModule } from '@flowscape-ui/core-sdk';
export type IWorldInputModule = IInputModule<WorldInputContext>;
Now every module in this controller is guaranteed to use the same context shape.
Step 3. Create controller class and compose modules
import {
InputControllerBase,
type IInputModule,
type WorldInputOptions,
} from '@flowscape-ui/core-sdk';
import { ModuleWorldZoom, ModuleWorldPan } from './modules';
export type WorldInputContext = {
stage: IStage;
world: LayerWorld;
options: Required<WorldInputOptions>;
emitChange: () => void;
};
export type IWorldInputModule = IInputModule<WorldInputContext>;
export class LayerWorldInputController extends InputControllerBase<WorldInputContext, IWorldInputModule> {
public readonly id = 0;
constructor() {
super();
this.addModule(new ModuleWorldZoom());
this.addModule(new ModuleWorldPan());
}
}
What base class gives you automatically:
attach(target)for controller and all modulesupdate()loop for all modulesdetach()anddestroy()cleanup for all modules- module replacement by
idviaaddModule(...)
Step 4. Implement one module (real example)
ModuleWorldZoom focuses only on zoom behavior.
import {
type IInputModule,
type Point,
Input,
KeyCode,
MouseButton,
} from '@flowscape-ui/core-sdk';
import type { WorldInputContext } from './LayerWorldInputController';
export class ModuleWorldZoom implements IInputModule<WorldInputContext> {
public readonly id = 'world-zoom';
private _context: WorldInputContext | null = null;
private _dragAccum = 0;
private _ctrlMouseZoomStartPoint: Point | null = null;
public attach(context: WorldInputContext): void {
this._context = context;
}
public detach(): void {
this._context = null;
this._dragAccum = 0;
this._ctrlMouseZoomStartPoint = null;
}
public destroy(): void {
this.detach();
}
public update(): void {
if (!this._context) return;
this._updateZoomFromKeyboard();
this._updateCtrlWheelZoom();
this._updateCtrlMouseZoom();
}
private _getCamera() {
return this._context!.world.camera;
}
private _emitChange(): void {
this._context!.emitChange();
}
private _updateCtrlWheelZoom(): void {
const { options } = this._context!;
if (!options.zoomEnabled) return;
Input.configure({ preventWheelDefault: true });
const scroll = Input.mouseScrollDelta;
if (scroll.x === 0 && scroll.y === 0) return;
if (!Input.scrollCtrl) return;
const point = this._toStagePoint(Input.mousePosition);
const factor = scroll.y > 0 ? 1 / options.zoomFactor : options.zoomFactor;
this._getCamera().zoomAtScreen(point, factor);
this._emitChange();
}
private _updateZoomFromKeyboard(): void {
const { options, stage } = this._context!;
if (!options.zoomEnabled) return;
const center: Point = {
x: stage.width() / 2,
y: stage.height() / 2,
};
if (
Input.getKeyDownCombo(KeyCode.LeftControl, KeyCode.Equals) ||
Input.getKeyDownCombo(KeyCode.RightControl, KeyCode.Equals)
) {
this._getCamera().zoomAtScreen(center, options.zoomFactor);
this._emitChange();
return;
}
if (
Input.getKeyDownCombo(KeyCode.LeftControl, KeyCode.Minus) ||
Input.getKeyDownCombo(KeyCode.RightControl, KeyCode.Minus)
) {
this._getCamera().zoomAtScreen(center, 1 / options.zoomFactor);
this._emitChange();
}
}
private _updateCtrlMouseZoom(): void {
const { options } = this._context!;
if (!options.zoomEnabled) return;
const isActive = Input.ctrlPressed && Input.getMouseButton(MouseButton.Right);
if (!isActive) {
this._ctrlMouseZoomStartPoint = null;
this._dragAccum = 0;
return;
}
if (this._ctrlMouseZoomStartPoint === null) {
const origin = Input.getMouseButtonPressOrigin(MouseButton.Right);
this._ctrlMouseZoomStartPoint = this._toStagePoint(origin);
}
const dy = Input.mousePositionDelta.y;
if (dy === 0) return;
this._dragAccum += dy;
const sensitivity = 4;
const point = this._ctrlMouseZoomStartPoint;
while (Math.abs(this._dragAccum) >= sensitivity) {
const factor = this._dragAccum > 0 ? 1 / options.zoomFactor : options.zoomFactor;
this._getCamera().zoomAtScreen(point, factor);
this._dragAccum -= Math.sign(this._dragAccum) * sensitivity;
}
this._emitChange();
}
private _toStagePoint(client: Point): Point {
const stage = this._context!.stage;
const rect = stage.container().getBoundingClientRect();
const stageWidth = stage.width() || 1;
const stageHeight = stage.height() || 1;
const scaleX = rect.width > 0 ? rect.width / stageWidth : 1;
const scaleY = rect.height > 0 ? rect.height / stageHeight : 1;
return {
x: (client.x - rect.left) / scaleX,
y: (client.y - rect.top) / scaleY,
};
}
}
Module responsibilities in this example
| Part | Responsibility |
|---|---|
attach(context) | Receives typed runtime context. |
detach() | Clears context and temporary zoom state. |
update() | Runs all zoom strategies per frame. |
_updateCtrlWheelZoom() | Ctrl+wheel zoom anchored at cursor position. |
_updateZoomFromKeyboard() | Ctrl++ and Ctrl+- zoom at stage center. |
_updateCtrlMouseZoom() | Ctrl+RMB drag zoom with sensitivity accumulator. |
_toStagePoint(...) | Converts client coordinates to stage coordinates. |
Step 5. Register controller in scene
const worldInputController = new LayerWorldInputController();
scene.inputManager.add(layerWorld, worldInputController, {
stage: host.getRenderNode(),
world: layerWorld,
options: {
enabled: true,
panMode: 'right',
zoomEnabled: true,
zoomFactor: 1.08,
preventWheelDefault: true,
keyboardPanSpeed: 900,
keyboardPanShiftMultiplier: 1.5,
},
emitChange: () => scene.invalidate(),
});
Step 6. Cleanup
scene.inputManager.remove(worldInputController.id);
Practical rules
- Keep one module for one interaction responsibility.
- Keep module ids stable and unique.
- Reset module temporary state in
detach(). - Use context options instead of hardcoded behavior flags.
- Call
emitChange()only when visible state changes.