@niivue/niivue
Version:
minimal webgl2 nifti image viewer
299 lines (261 loc) • 9.33 kB
text/typescript
/**
* Event controller helper functions for canvas interaction management.
* This module provides pure functions for event handling and position calculations.
*
* Related to: Event registration, mouse position calculation, resize handling
*/
/**
* Mouse position relative to an element
*/
export interface RelativePosition {
x: number
y: number
}
/**
* Parameters for calculating device pixel ratio
*/
export interface CalculateDprParams {
forceDevicePixelRatio: number
}
/**
* Parameters for resize handling
*/
export interface ResizeParams {
canvas: HTMLCanvasElement
gl: WebGL2RenderingContext
isResizeCanvas: boolean
forceDevicePixelRatio: number
}
/**
* Result of resize calculation
*/
export interface ResizeResult {
dpr: number
width: number
height: number
shouldDraw: boolean
}
/**
* Event listener configuration for registration
*/
export interface EventListenerConfig {
type: string
handler: EventListener
options?: AddEventListenerOptions
}
/**
* Get the relative mouse position within a target element.
* @param event - The mouse event
* @param target - Optional target element (defaults to event.target)
* @returns The position relative to the target, or undefined if no target
*/
export function getRelativeMousePosition(event: MouseEvent, target?: EventTarget | null): RelativePosition | undefined {
const resolvedTarget = target || event.target
if (!resolvedTarget) {
return undefined
}
// Cast to Element to access getBoundingClientRect
const element = resolvedTarget as Element
if (typeof element.getBoundingClientRect !== 'function') {
return undefined
}
const rect = element.getBoundingClientRect()
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top
}
}
/**
* Get mouse position relative to canvas, excluding padding and borders.
* Currently this is a pass-through to getRelativeMousePosition, but provides
* a hook for future padding/border adjustments if needed.
* @param event - The mouse event
* @param target - The target element
* @returns The position relative to the canvas, or undefined if no target
*/
export function getNoPaddingNoBorderCanvasRelativeMousePosition(event: MouseEvent, target: EventTarget): RelativePosition | undefined {
const resolvedTarget = target || event.target
return getRelativeMousePosition(event, resolvedTarget)
}
/**
* Calculate the device pixel ratio based on configuration.
* @param params - Parameters containing forceDevicePixelRatio setting
* @returns The calculated device pixel ratio
*/
export function calculateDpr(params: CalculateDprParams): number {
const { forceDevicePixelRatio } = params
if (forceDevicePixelRatio === 0) {
return window.devicePixelRatio || 1
} else if (forceDevicePixelRatio < 0) {
return 1
}
return forceDevicePixelRatio
}
/**
* Calculate resize dimensions for a canvas.
* @param params - Parameters containing canvas, gl context, and resize settings
* @returns The calculated resize result with dpr, dimensions, and whether to draw
*/
export function calculateResizeDimensions(params: ResizeParams): ResizeResult {
const { canvas, isResizeCanvas, forceDevicePixelRatio } = params
if (!isResizeCanvas) {
return {
dpr: 1,
width: canvas.width,
height: canvas.height,
shouldDraw: true
}
}
const dpr = calculateDpr({ forceDevicePixelRatio })
let width: number
let height: number
const parent = canvas.parentElement
if (parent && 'width' in parent) {
width = (parent.width as number) * dpr
// @ts-expect-error height may not be defined on all HTMLElement types
height = parent.height * dpr
} else {
width = canvas.offsetWidth * dpr
height = canvas.offsetHeight * dpr
}
return {
dpr,
width,
height,
shouldDraw: true
}
}
/**
* Create a debounced resize handler that uses requestAnimationFrame.
* @param callback - The callback to execute on resize
* @returns A function suitable for use as a resize event listener
*/
export function createResizeHandler(callback: () => void): () => void {
return (): void => {
requestAnimationFrame(() => {
callback()
})
}
}
/**
* Create a ResizeObserver that calls the provided callback on resize.
* @param callback - The callback to execute when resize is observed
* @returns A configured ResizeObserver instance
*/
export function createResizeObserver(callback: () => void): ResizeObserver {
return new ResizeObserver(() => {
requestAnimationFrame(() => {
callback()
})
})
}
/**
* Create a MutationObserver that watches for child list changes.
* @param callback - The callback to execute when mutations are observed
* @returns A configured MutationObserver instance
*/
export function createCanvasObserver(callback: (mutations: MutationRecord[]) => void): MutationObserver {
return new MutationObserver(callback)
}
/**
* Apply canvas resize styles for full-size display.
* @param canvas - The canvas element to style
*/
export function applyCanvasResizeStyles(canvas: HTMLCanvasElement): void {
canvas.style.width = '100%'
canvas.style.height = '100%'
canvas.style.display = 'block'
}
/**
* Check if canvas and GL context are valid for resize operations.
* @param canvas - The canvas element
* @param gl - The WebGL context
* @returns True if both are valid
*/
export function isValidForResize(canvas: HTMLCanvasElement | null, gl: WebGL2RenderingContext | null): canvas is HTMLCanvasElement {
return canvas !== null && gl !== null
}
/**
* Parameters for creating an AbortController-based event registration
*/
export interface EventRegistrationParams {
canvas: HTMLCanvasElement
handlers: {
mousedown: (e: MouseEvent) => void
mouseup: (e: MouseEvent) => void
mousemove: (e: MouseEvent) => void
mouseleave: (e: MouseEvent) => void
touchstart: (e: TouchEvent) => void
touchend: (e: TouchEvent) => void
touchmove: (e: TouchEvent) => void
wheel: (e: WheelEvent) => void
contextmenu: (e: MouseEvent) => void
dblclick: (e: MouseEvent) => void
dragenter: (e: DragEvent) => void
dragover: (e: DragEvent) => void
drop: (e: DragEvent) => void
keyup: (e: KeyboardEvent) => void
keydown: (e: KeyboardEvent) => void
}
}
/**
* Register all interaction event listeners on a canvas element.
* Uses an AbortController for easy cleanup of all listeners.
* @param params - Parameters containing canvas and event handlers
* @returns The AbortController that can be used to remove all listeners
*/
export function registerEventListeners(params: EventRegistrationParams): AbortController {
const { canvas, handlers } = params
const controller = new AbortController()
const { signal } = controller
// Mouse events
canvas.addEventListener('mousedown', handlers.mousedown, { signal })
canvas.addEventListener('mouseup', handlers.mouseup, { signal })
canvas.addEventListener('mousemove', handlers.mousemove, { signal })
canvas.addEventListener('mouseleave', handlers.mouseleave, { signal })
// Touch events
canvas.addEventListener('touchstart', handlers.touchstart, { signal })
canvas.addEventListener('touchend', handlers.touchend, { signal })
canvas.addEventListener('touchmove', handlers.touchmove, { signal })
// Wheel event
canvas.addEventListener('wheel', handlers.wheel, { signal })
// Context menu
canvas.addEventListener('contextmenu', handlers.contextmenu, { signal })
// Double click
canvas.addEventListener('dblclick', handlers.dblclick, { signal })
// Drag and drop
canvas.addEventListener('dragenter', handlers.dragenter, { signal })
canvas.addEventListener('dragover', handlers.dragover, { signal })
canvas.addEventListener('drop', handlers.drop, { signal })
// Keyboard events (canvas needs tabindex for focus)
canvas.setAttribute('tabindex', '0')
canvas.addEventListener('keyup', handlers.keyup, { signal })
canvas.addEventListener('keydown', handlers.keydown, { signal })
return controller
}
/**
* Clean up resize-related observers and listeners.
* @param resizeObserver - The ResizeObserver to disconnect
* @param canvasObserver - The MutationObserver to disconnect
* @param resizeEventListener - The resize event listener to remove
*/
export function cleanupResizeObservers(resizeObserver: ResizeObserver | null, canvasObserver: MutationObserver | null, resizeEventListener: (() => void) | null): void {
if (resizeEventListener) {
window.removeEventListener('resize', resizeEventListener)
}
if (resizeObserver) {
resizeObserver.disconnect()
}
if (canvasObserver) {
canvasObserver.disconnect()
}
}
/**
* Clean up event controller by aborting all registered listeners.
* @param controller - The AbortController to abort
*/
export function cleanupEventController(controller: AbortController | null): void {
if (controller) {
controller.abort()
}
}