UNPKG

react-zoom-pan-pinch

Version:
705 lines (596 loc) 20.6 kB
import { BoundsType, LibrarySetup, PositionType, VelocityType, AnimationType, ReactZoomPanPinchProps, ReactZoomPanPinchState, ReactZoomPanPinchRef, DeviceType, } from "../models"; import { getContext, createSetup, createState, handleCallback, getTransformStyles, makePassiveEventOption, getCenterPosition, assignRef, } from "../utils"; import { handleCancelAnimation } from "./animations/animations.utils"; import { isWheelAllowed, isWheelPanningAllowed } from "./wheel/wheel.utils"; import { isPinchAllowed, isPinchStartAllowed } from "./pinch/pinch.utils"; import { handleCalculateBounds } from "./bounds/bounds.utils"; import { handleWheelStart, handleWheelZoom, handleWheelStop, handleWheelPanningStop, handleWheelPanningStart, } from "./wheel/wheel.logic"; import { getPaddingValue, handleNewPosition, isPanningAllowed, isPanningStartAllowed, } from "./pan/panning.utils"; import { handlePanning, handlePanningEnd, handlePanningStart, } from "./pan/panning.logic"; import { handlePinchStart, handlePinchStop, handlePinchZoom, } from "./pinch/pinch.logic"; import { handleDoubleClick, isDoubleClickAllowed, } from "./double-click/double-click.logic"; type StartCoordsType = { x: number; y: number } | null; export class ZoomPanPinch { public props: ReactZoomPanPinchProps; public mounted = true; public state: ReactZoomPanPinchState; public setup: LibrarySetup; public observer?: ResizeObserver; public onChangeCallbacks: Set<(ctx: ReactZoomPanPinchRef) => void> = new Set(); public onInitCallbacks: Set<(ctx: ReactZoomPanPinchRef) => void> = new Set(); public onTransformCallbacks: Set< (data: { scale: number; positionX: number; positionY: number; previousScale: number; ref: ReactZoomPanPinchRef; }) => void > = new Set(); // Components public wrapperComponent: HTMLDivElement | null = null; public contentComponent: HTMLDivElement | null = null; // Initialization public isInitialized = false; public bounds: BoundsType | null = null; // wheel helpers public previousWheelEvent: WheelEvent | null = null; public wheelStopEventTimer: ReturnType<typeof setTimeout> | null = null; public wheelAnimationTimer: ReturnType<typeof setTimeout> | null = null; // panning helpers public isPanning = false; public isWheelPanning = false; public startCoords: StartCoordsType = null; public panStartPosition: { x: number; y: number } | null = null; public lastTouch: number | null = null; // pinch helpers public isPinching = false; public distance: null | number = null; public lastDistance: null | number = null; public pinchStartDistance: null | number = null; public pinchStartScale: null | number = null; public pinchMidpoint: null | PositionType = null; public pinchPreviousCenter: null | PositionType = null; // double click helpers public doubleClickStopEventTimer: ReturnType<typeof setTimeout> | null = null; // velocity helpers public velocity: VelocityType | null = null; public velocityTime: number | null = null; public lastMousePosition: PositionType | null = null; // animations helpers public isAnimating = false; public animation: AnimationType | null = null; // key press public pressedKeys: { [key: string]: boolean } = {}; constructor(props: ReactZoomPanPinchProps) { this.props = props; this.setup = createSetup(this.props); this.state = createState(this.props); } mount = () => { this.initializeWindowEvents(); }; unmount = () => { this.cleanupWindowEvents(); }; update = (newProps: ReactZoomPanPinchProps) => { this.props = newProps; if (this.wrapperComponent && this.contentComponent) { handleCalculateBounds(this, this.state.scale); } this.setup = createSetup(newProps); }; initializeWindowEvents = (): void => { const passive = makePassiveEventOption(); const currentDocument = this.wrapperComponent?.ownerDocument; const currentWindow = currentDocument?.defaultView; this.wrapperComponent?.addEventListener( "wheel", this.onWheelPanning, passive, ); this.wrapperComponent?.addEventListener( "keyup", this.setKeyUnPressed, passive, ); this.wrapperComponent?.addEventListener( "keydown", this.setKeyPressed, passive, ); // Panning on window to allow panning when mouse is out of component wrapper currentWindow?.addEventListener("mousedown", this.onPanningStart, passive); currentWindow?.addEventListener("mousemove", this.onPanning, passive); currentWindow?.addEventListener("mouseup", this.onPanningStop, passive); currentDocument?.addEventListener("mouseleave", this.clearPanning, passive); currentWindow?.addEventListener("keyup", this.setKeyUnPressed, passive); currentWindow?.addEventListener("keydown", this.setKeyPressed, passive); currentWindow?.addEventListener("blur", this.handleWindowBlur); }; cleanupWindowEvents = (): void => { const passive = makePassiveEventOption(); const currentDocument = this.wrapperComponent?.ownerDocument; const currentWindow = currentDocument?.defaultView; currentWindow?.removeEventListener( "mousedown", this.onPanningStart, passive, ); currentWindow?.removeEventListener("mousemove", this.onPanning, passive); currentWindow?.removeEventListener("mouseup", this.onPanningStop, passive); currentDocument?.removeEventListener( "mouseleave", this.clearPanning, passive, ); currentWindow?.removeEventListener("keyup", this.setKeyUnPressed, passive); currentWindow?.removeEventListener("keydown", this.setKeyPressed, passive); currentWindow?.removeEventListener("blur", this.handleWindowBlur); document.removeEventListener("mouseleave", this.clearPanning, passive); this.wrapperComponent?.removeEventListener( "wheel", this.onWheelPanning, passive, ); this.wrapperComponent?.removeEventListener( "keyup", this.setKeyUnPressed, passive, ); this.wrapperComponent?.removeEventListener( "keydown", this.setKeyPressed, passive, ); handleCancelAnimation(this); this.observer?.disconnect(); }; handleInitializeWrapperEvents = (wrapper: HTMLDivElement): void => { // Zooming events on wrapper const passive = makePassiveEventOption(); wrapper.addEventListener("wheel", this.onWheelZoom, passive); wrapper.addEventListener("dblclick", this.onDoubleClick, passive); wrapper.addEventListener("touchstart", this.onTouchPanningStart, passive); wrapper.addEventListener("touchmove", this.onTouchPanning, passive); wrapper.addEventListener("touchend", this.onTouchPanningStop, passive); }; handleInitialize = (contentComponent: HTMLDivElement): void => { const { centerOnInit } = this.setup; this.applyTransformation(); this.onInitCallbacks.forEach((callback) => callback(getContext(this))); if (centerOnInit) { this.setCenter(); this.observer = new ResizeObserver(() => { const currentWidth = contentComponent.offsetWidth; const currentHeight = contentComponent.offsetHeight; if (currentWidth > 0 || currentHeight > 0) { this.onInitCallbacks.forEach((callback) => callback(getContext(this)), ); this.setCenter(); this.observer?.disconnect(); } }); // TODO: CHANGE to first interaction? // if nothing about the contentComponent has changed after 5 seconds, disconnect the observer setTimeout(() => { this.observer?.disconnect(); }, 5000); // Start observing the target node for configured mutations this.observer.observe(contentComponent); } }; /// /////// // Zoom /// /////// onWheelZoom = (event: WheelEvent): void => { const { disabled } = this.setup; if (disabled) return; this.syncModifierKeys(event); const isAllowed = isWheelAllowed(this, event); if (!isAllowed) return; handleWheelStart(this, event); handleWheelZoom(this, event); handleWheelStop(this, event); }; /// /////// // Track Pad Panning /// /////// onWheelPanning = (event: WheelEvent): void => { const { onPanning } = this.props; const { trackPadPanning } = this.setup; const { lockAxisX, lockAxisY } = trackPadPanning; this.syncModifierKeys(event); const isAllowed = isWheelPanningAllowed(this, event); if (!isAllowed) return; event.preventDefault(); event.stopPropagation(); const { positionX, positionY } = this.state; const mouseX = positionX - event.deltaX; const mouseY = positionY - event.deltaY; const newPositionX = lockAxisX ? positionX : mouseX; const newPositionY = lockAxisY ? positionY : mouseY; const { sizeX, sizeY } = this.setup.autoAlignment; const paddingValueX = getPaddingValue(this, sizeX); const paddingValueY = getPaddingValue(this, sizeY); if (newPositionX === positionX && newPositionY === positionY) return; handleWheelPanningStart(this, event); handleNewPosition( this, newPositionX, newPositionY, paddingValueX, paddingValueY, ); handleCallback(getContext(this), event, onPanning); handleWheelPanningStop(this, event); }; /// /////// // Pan /// /////// onPanningStart = (event: MouseEvent): void => { const { disabled } = this.setup; const { onPanningStart } = this.props; if (disabled) return; this.syncModifierKeys(event); const isAllowed = isPanningStartAllowed(this, event); if (!isAllowed) return; const keysPressed = this.isPressingKeys(this.setup.panning.activationKeys); if (!keysPressed) return; if (event.button === 0 && !this.setup.panning.allowLeftClickPan) return; if (event.button === 1 && !this.setup.panning.allowMiddleClickPan) return; if (event.button === 2 && !this.setup.panning.allowRightClickPan) return; event.preventDefault(); event.stopPropagation(); handleCancelAnimation(this); handlePanningStart(this, event); handleCallback(getContext(this), event, onPanningStart); }; onPanning = (event: MouseEvent): void => { const { disabled } = this.setup; const { onPanning } = this.props; if (disabled) return; this.syncModifierKeys(event); // Detect missed mouseup — e.g. when the mouse was released outside an // iframe boundary where the host frame swallows the mouseup event. if (this.isPanning && event.buttons === 0) { this.clearPanning(event); return; } const isAllowed = isPanningAllowed(this); if (!isAllowed) return; const keysPressed = this.isPressingKeys(this.setup.panning.activationKeys); if (!keysPressed) return; event.preventDefault(); event.stopPropagation(); handlePanning(this, event.clientX, event.clientY, DeviceType.MOUSE); handleCallback(getContext(this), event, onPanning); }; onPanningStop = (event: MouseEvent | TouchEvent): void => { const { velocityDisabled } = this.setup.panning; const { onPanningStop } = this.props; if (this.isPanning) { handlePanningEnd(this, velocityDisabled); handleCallback(getContext(this), event, onPanningStop); } }; /// /////// // Pinch /// /////// onPinchStart = (event: TouchEvent): void => { const { disabled } = this.setup; const { onPinchStart } = this.props; if (disabled) return; const isAllowed = isPinchStartAllowed(this, event); if (!isAllowed) return; handlePinchStart(this, event); handleCancelAnimation(this); handleCallback(getContext(this), event, onPinchStart); }; onPinch = (event: TouchEvent): void => { const { disabled } = this.setup; const { onPinch } = this.props; if (disabled) return; const isAllowed = isPinchAllowed(this); if (!isAllowed) return; event.preventDefault(); event.stopPropagation(); handlePinchZoom(this, event); handleCallback(getContext(this), event, onPinch); }; onPinchStop = (event: TouchEvent): void => { const { onPinchStop } = this.props; if (this.pinchStartScale) { handlePinchStop(this); handleCallback(getContext(this), event, onPinchStop); } }; /// /////// // Touch /// /////// onTouchPanningStart = (event: TouchEvent): void => { const { disabled, doubleClick } = this.setup; const { onPanningStart } = this.props; if (disabled) return; const isDoubleTapAllowed = !doubleClick?.disabled; const isDoubleTap = this.lastTouch && +new Date() - this.lastTouch < 200; if (isDoubleTapAllowed && isDoubleTap && event.touches.length === 1) { this.onDoubleClick(event); } else { this.lastTouch = +new Date(); handleCancelAnimation(this); const { touches } = event; const isPanningAction = touches.length === 1; const isPinchAction = touches.length === 2; const isAllowed = isPanningStartAllowed(this, event); if (isPanningAction) { if (!isAllowed) return; handleCancelAnimation(this); handlePanningStart(this, event); handleCallback(getContext(this), event, onPanningStart); } if (isPinchAction) { this.onPinchStart(event); } } }; onTouchPanning = (event: TouchEvent): void => { const { disabled } = this.setup; const { onPanning } = this.props; if (this.isPanning && event.touches.length === 1) { if (disabled) return; const isAllowed = isPanningAllowed(this); if (!isAllowed) return; if (event.cancelable) { event.preventDefault(); } event.stopPropagation(); const touch = event.touches[0]; handlePanning(this, touch.clientX, touch.clientY, DeviceType.TOUCH); handleCallback(getContext(this), event, onPanning); } else if (event.touches.length > 1) { this.onPinch(event); } }; onTouchPanningStop = (event: TouchEvent): void => { this.onPanningStop(event); this.onPinchStop(event); }; /// /////// // Double Click /// /////// onDoubleClick = (event: MouseEvent | TouchEvent): void => { const { disabled } = this.setup; if (disabled) return; const isAllowed = isDoubleClickAllowed(this, event); if (!isAllowed) return; handleDoubleClick(this, event); }; /// /////// // Helpers /// /////// clearPanning = (event: MouseEvent): void => { if (this.isPanning) { this.onPanningStop(event); } }; // When the window loses focus (e.g. user clicks outside an iframe), // keyup and mouseup events are swallowed by the parent frame. Clear all // tracked state to prevent stale activation keys or ghost panning. handleWindowBlur = (): void => { this.pressedKeys = {}; if (this.isPanning) { this.isPanning = false; this.startCoords = null; } }; // Iframe focus problem (e.g. Storybook): // // When the library runs inside an iframe, keyboard events (keydown/keyup) // only reach the iframe's window when it has focus. If the user navigates // via the host UI (e.g. Storybook sidebar) the iframe never receives // focus, so keydown never fires and activationKeys like Cmd/Ctrl are // invisible to pressedKeys. // // Mouse and wheel events, however, DO reach the iframe regardless of // focus — and they carry modifier flags (ctrlKey, metaKey, shiftKey, // altKey) that always reflect the real physical key state at event time. // // We sync those flags into pressedKeys on every interaction event so // that activationKeys checks work without requiring iframe focus. // Both pressed (true) AND released (false) states must be written; // writing only `true` would leave stale keys after release because // keyup never fires in an unfocused iframe. syncModifierKeys = (event: MouseEvent | WheelEvent | TouchEvent): void => { const { ctrlKey, metaKey, shiftKey, altKey } = event; if (typeof ctrlKey === "boolean") this.pressedKeys.Control = ctrlKey; if (typeof metaKey === "boolean") this.pressedKeys.Meta = metaKey; if (typeof shiftKey === "boolean") this.pressedKeys.Shift = shiftKey; if (typeof altKey === "boolean") this.pressedKeys.Alt = altKey; }; setKeyPressed = (e: KeyboardEvent): void => { this.pressedKeys[e.key] = true; }; setKeyUnPressed = (e: KeyboardEvent): void => { this.pressedKeys[e.key] = false; }; isPressingKeys = ( keys: string[] | ((keys: string[]) => boolean), ): boolean => { if (typeof keys === "function") { return keys( Object.entries(this.pressedKeys) .filter(([, pressed]) => pressed) .map(([key]) => key), ); } if (!keys.length) { return true; } return Boolean(keys.every((key) => this.pressedKeys[key])); }; setCenter = (): void => { if (this.wrapperComponent && this.contentComponent) { const targetState = getCenterPosition( this.state.scale, this.wrapperComponent, this.contentComponent, ); this.setState( targetState.scale, targetState.positionX, targetState.positionY, ); } }; handleTransformStyles = (x: number, y: number, scale: number) => { if (this.props.customTransform) { return this.props.customTransform(x, y, scale); } return getTransformStyles(x, y, scale); }; getContext = () => { return getContext(this); }; applyTransformation = (): void => { if (!this.mounted || !this.contentComponent) return; const { scale, positionX, positionY } = this.state; const transform = this.handleTransformStyles(positionX, positionY, scale); // Detached mode do not apply transformation directly to content component if (!this.props.detached) { this.contentComponent.style.transform = transform; } this.onTransformCallbacks.forEach((callback) => callback({ scale, positionX, positionY, previousScale: this.state.previousScale, ref: getContext(this), }), ); }; setState = (scale: number, positionX: number, positionY: number): void => { const { onTransform } = this.props; if ( !Number.isNaN(scale) && !Number.isNaN(positionX) && !Number.isNaN(positionY) ) { const safeScale = Math.max(scale, 1e-7); if (safeScale !== this.state.scale) { this.state.previousScale = this.state.scale; this.state.scale = safeScale; } this.state.positionX = positionX; this.state.positionY = positionY; this.applyTransformation(); const ctx = getContext(this); this.onChangeCallbacks.forEach((callback) => callback(ctx)); handleCallback( ctx, { scale: this.state.scale, positionX, positionY }, onTransform, ); } else { console.error("Detected NaN set state values"); } }; /** * Hooks */ onTransform = ( callback: (data: { scale: number; positionX: number; positionY: number; previousScale: number; ref: ReactZoomPanPinchRef; }) => void, ) => { if (!this.onTransformCallbacks.has(callback)) { this.onTransformCallbacks.add(callback); } return () => { this.onTransformCallbacks.delete(callback); }; }; onChange = (callback: (ref: ReactZoomPanPinchRef) => void) => { if (!this.onChangeCallbacks.has(callback)) { this.onChangeCallbacks.add(callback); } return () => { this.onChangeCallbacks.delete(callback); }; }; onInit = (callback: (ref: ReactZoomPanPinchRef) => void) => { if (!this.onInitCallbacks.has(callback)) { this.onInitCallbacks.add(callback); } return () => { this.onInitCallbacks.delete(callback); }; }; /** * Initialization */ init = ( wrapperComponent: HTMLDivElement, contentComponent: HTMLDivElement, ): void => { this.cleanupWindowEvents(); this.wrapperComponent = wrapperComponent; this.contentComponent = contentComponent; handleCalculateBounds(this, this.state.scale); this.handleInitializeWrapperEvents(wrapperComponent); this.handleInitialize(contentComponent); this.initializeWindowEvents(); this.isInitialized = true; const ctx = getContext(this); handleCallback(ctx, undefined, this.props.onInit); assignRef(this.props.ref, ctx); }; }