Created
March 27, 2026 13:07
-
-
Save relliv/ef0ea898f46cca94ab30e9ffbc2c0b5e to your computer and use it in GitHub Desktop.
Foblex flow touch gesture utils example
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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