Skip to content

Instantly share code, notes, and snippets.

@relliv
Created March 27, 2026 13:07
Show Gist options
  • Select an option

  • Save relliv/ef0ea898f46cca94ab30e9ffbc2c0b5e to your computer and use it in GitHub Desktop.

Select an option

Save relliv/ef0ea898f46cca94ab30e9ffbc2c0b5e to your computer and use it in GitHub Desktop.
Foblex flow touch gesture utils example
import { PointExtensions } from '@foblex/2d';
import type { IPoint } from '@foblex/2d';
import type {
FCanvasChangeEvent,
FCanvasComponent,
FFlowComponent,
} from '@foblex/flow';
/**
* Result of viewport bounds checking
*/
export interface ViewportBoundsCheckResult {
/** All nodes are fully within the viewport (including padding) */
isFullyVisible: boolean;
/** At least some part of nodes are visible */
isPartiallyVisible: boolean;
/** How much nodes overflow each edge (negative = within bounds) */
overflow: {
left: number; // pixels overflow on left edge
right: number; // pixels overflow on right edge
top: number; // pixels overflow on top edge
bottom: number; // pixels overflow on bottom edge
};
/** Suggested canvas position to bring nodes back into view (null if already visible) */
constrainedPosition: IPoint | null;
}
export class FoblexUtils {
/**
* Simple wrapper to constrain canvas position to keep nodes in viewport
* Use this in your onCanvasChange handler to prevent nodes from going out of view
*
* @param fFlowComponent - Your f-flow component reference
* @param canvasEvent - The FCanvasChangeEvent from onCanvasChange
* @param maxPadding - Maximum allowed overflow (default: 100 canvas units)
* @returns Constrained position that keeps nodes visible
*/
public static getConstrainedCanvasPosition(
fFlowComponent: FFlowComponent,
canvasEvent: FCanvasChangeEvent,
maxPadding = 100
): IPoint {
const nodesBoundingBox = fFlowComponent.getNodesBoundingBox();
const result = this.checkNodesInViewport(
fFlowComponent,
nodesBoundingBox,
canvasEvent.position,
canvasEvent.scale,
maxPadding
);
// Return constrained position if needed, otherwise return current position
return result.constrainedPosition || canvasEvent.position;
}
public static zoomToPositionWithWheel(
fCanvas: FCanvasComponent,
wheelEvent: WheelEvent,
zoomStep: number,
zoomMin: number,
zoomMax: number
): void {
const currentScale = fCanvas.getScale();
// Calculate zoom direction and new scale
const zoomDirection = wheelEvent.deltaY > 0 ? -1 : 1; // negative deltaY = zoom in
// Calculate step based on wheel intensity (optional)
const intensity = Math.abs(wheelEvent.deltaY) / 100;
const normalizedIntensity = Math.max(0.1, Math.min(intensity, 1));
const step = zoomStep * normalizedIntensity;
// Calculate new scale
let newScale = currentScale + step * zoomDirection;
// Clamp to min/max
newScale = Math.max(zoomMin, Math.min(zoomMax, newScale));
// Get the parent container's bounding rect to calculate relative position
// This is necessary when the canvas is inside a modal or smaller container
const containerRect =
fCanvas.hostElement.parentElement?.getBoundingClientRect() ??
fCanvas.hostElement.getBoundingClientRect();
// Calculate mouse position relative to the container, not the screen
const zoomCenter: IPoint = PointExtensions.initialize(
wheelEvent.clientX - containerRect.left,
wheelEvent.clientY - containerRect.top
);
// Apply zoom
fCanvas.setScale(newScale, zoomCenter);
fCanvas.redraw();
}
public static panToPositionWithWheel(
fCanvas: FCanvasComponent,
deltaX: number,
deltaY: number
): void {
// Get current canvas position
const currentPosition = fCanvas.getPosition();
// Calculate new position with deltaX and deltaY
// Negate deltas for natural scrolling direction
const newX = currentPosition.x - deltaX;
const newY = currentPosition.y - deltaY;
// Apply new position directly to canvas
fCanvas.transform.position.x = newX;
fCanvas.transform.position.y = newY;
fCanvas.redraw();
}
}
import { Injectable, inject, type ElementRef } from '@angular/core';
import {
MeasurePerformance,
DeviceInputType,
InputDeviceDetector,
type IDetectionResult,
} from '@ngeenx/ngn-core-utils';
import { FoblexUtils } from '../../../../shared/utils/foblex-utils';
import { BoardBodyState } from '../state';
import type { FCanvasComponent } from '@foblex/flow';
const PERFORMANCE_THRESHOLD = 15;
/**
* Handler for gesture events (wheel/trackpad) in the board.
* Manages zoom and pan via mouse wheel and trackpad gestures.
*/
@Injectable()
export class GestureHandler {
private readonly state = inject(BoardBodyState);
private lastWheelDeviceInfo: IDetectionResult | null = null;
private lastWheelInteractionTime = 0;
private readonly debouncePeriod = 1000;
private containerRef: ElementRef | undefined;
private fCanvas: () => FCanvasComponent | undefined = () => undefined;
/**
* Initialize gesture event listeners
*/
public initGestureEventListeners(
containerRef: ElementRef | undefined,
getCanvas: () => FCanvasComponent | undefined
): void {
this.containerRef = containerRef;
this.fCanvas = getCanvas;
if (containerRef) {
containerRef.nativeElement.addEventListener(
'wheel',
this.handleWheel.bind(this),
{ passive: false }
);
}
}
/**
* Cleanup gesture event listeners
*/
public destroyGestureEventListeners(): void {
if (this.containerRef) {
this.containerRef.nativeElement.removeEventListener(
'wheel',
this.handleWheel.bind(this)
);
}
}
/**
* Handle wheel event for zoom/pan
*/
@MeasurePerformance({ threshold: PERFORMANCE_THRESHOLD })
private handleWheel(event: Event): void {
const wheelEvent = event as WheelEvent;
const targetElement = event.target as HTMLElement;
// Allow scrolling within scrollable elements
if (
targetElement.classList.contains('kt-scrollable') ||
targetElement.closest('.kt-scrollable')
) {
return;
}
// Prevent page scrolling when scrolling over the board canvas
wheelEvent.preventDefault();
const { deltaX, deltaY, ctrlKey } = wheelEvent;
const canvas = this.fCanvas();
if (!canvas) return;
const inputDevice = InputDeviceDetector.detect(wheelEvent);
const { zoomStep, zoomMin, zoomMax } = this.state;
// Touch: Pinch Zoom & Mouse: Ctrl + Scroll Zoom
if (ctrlKey) {
FoblexUtils.zoomToPositionWithWheel(
canvas,
wheelEvent,
zoomStep,
zoomMin,
zoomMax
);
}
// Touch: Pan & Mouse: Scroll Zoom
else {
if (inputDevice.deviceType === DeviceInputType.TOUCHPAD) {
FoblexUtils.panToPositionWithWheel(canvas, deltaX, deltaY);
} else {
if (
this.lastWheelDeviceInfo?.deviceType === DeviceInputType.TOUCHPAD &&
this.lastWheelInteractionTime + this.debouncePeriod > Date.now()
) {
// Ignore small changes to prevent flickering
if (deltaX > 1 || deltaX < -1) {
this.lastWheelDeviceInfo = inputDevice;
}
this.lastWheelInteractionTime = Date.now();
return;
}
FoblexUtils.zoomToPositionWithWheel(
canvas,
wheelEvent,
zoomStep,
zoomMin,
zoomMax
);
}
}
this.lastWheelDeviceInfo = inputDevice;
this.lastWheelInteractionTime = Date.now();
}
}
export enum ScrollDirection {
HORIZONTAL = 'horizontal',
VERTICAL = 'vertical',
}
export enum ZoomDirection {
IN = 'in',
OUT = 'out',
}
export enum DeviceInputType {
MOUSE = 'mouse',
TOUCHPAD = 'touchpad',
UNKNOWN = 'unknown',
}
export interface IScrollDirectionResult {
scrollDirection: ScrollDirection;
zoomDirection: ZoomDirection;
}
export interface IDetectionResult {
deviceType: DeviceInputType;
scrollDirections: IScrollDirectionResult[];
}
export class InputDeviceDetector {
public static detect(event: WheelEvent): IDetectionResult {
const scrollDirections = [];
if (event.deltaX !== 0) {
scrollDirections.push({
scrollDirection: ScrollDirection.HORIZONTAL,
zoomDirection: event.deltaX > 0 ? ZoomDirection.OUT : ZoomDirection.IN,
});
}
if (event.deltaY !== 0) {
scrollDirections.push({
scrollDirection: ScrollDirection.VERTICAL,
zoomDirection: event.deltaY > 0 ? ZoomDirection.OUT : ZoomDirection.IN,
});
}
let deviceType = DeviceInputType.UNKNOWN;
if (scrollDirections.length === 1) {
if (
scrollDirections.find(
item =>
item.scrollDirection === ScrollDirection.HORIZONTAL ||
item.scrollDirection === ScrollDirection.VERTICAL
)
) {
deviceType = DeviceInputType.MOUSE;
}
} else if (scrollDirections.length === 2) {
deviceType = DeviceInputType.TOUCHPAD;
}
return { deviceType, scrollDirections };
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment