UNPKG

saagie-ui

Version:

Saagie UI from Saagie Design System

448 lines (366 loc) 12 kB
import React, { useState, useRef, useEffect, useLayoutEffect, } from 'react'; import PropTypes from 'prop-types'; import { MapInteractionContext } from './MapInteractionContext'; import { MapInteractionControls } from './MapInteractionControls'; const propTypes = { children: PropTypes.node.isRequired, transform: PropTypes.shape({ scale: PropTypes.number, x: PropTypes.number, y: PropTypes.number, }), onChange: PropTypes.func, minScale: PropTypes.number, maxScale: PropTypes.number, showControls: PropTypes.bool, isDragBlocked: PropTypes.bool, /** * Disable drag on map on specific element (CSS selector, e.g. ".my-element") */ preventDragSelector: PropTypes.string, preventScrollSelector: PropTypes.string, }; const defaultProps = { minScale: 0.2, maxScale: 1, transform: { scale: 1, x: 20, y: 20 }, showControls: false, onChange: () => {}, isDragBlocked: false, preventDragSelector: '', preventScrollSelector: '', }; const isTouch = (e) => e.touches && e.touches[0]; const isDualTouch = (e) => e.touches && e.touches.length === 2; const getPointerPosition = (e) => { const pointerX = isTouch(e) ? e.touches[0].clientX : e.clientX; const pointerY = isTouch(e) ? e.touches[0].clientY : e.clientY; return { x: pointerX, y: pointerY }; }; const getPointerDelta = (e, from = { x: 0, y: 0 }) => { const pointer = getPointerPosition(e); const deltaX = pointer.x - from.x; const deltaY = pointer.y - from.y; return { x: deltaX, y: deltaY }; }; const scaleFromDeltaTranslation = ({ scale, delta, transform }) => { const scaleRatio = scale / (transform.scale || 1); const vector = { x: (scaleRatio * delta.x) - delta.x, y: (scaleRatio * delta.y) - delta.y, }; const newTranslation = { x: transform.x - vector.x, y: transform.y - vector.y, }; return { ...newTranslation, scale, }; }; const getDeltaFromEventOriginToTranslationOrigin = ({ origin, translation, containerNode }) => { const containerOffset = containerNode.getBoundingClientRect(); const translationOrigin = { x: containerOffset.left + translation.x, y: containerOffset.top + translation.y, }; return { x: origin.x - translationOrigin.x, y: origin.y - translationOrigin.y, }; }; const getPinchScaleDelta = (prevTouches, currTouches) => { const [A, B] = prevTouches; const [C, D] = currTouches; const diff1 = { x: getPointerPosition(B).x - getPointerPosition(A).x, y: getPointerPosition(B).y - getPointerPosition(A).y, }; const diff2 = { x: getPointerPosition(D).x - getPointerPosition(C).x, y: getPointerPosition(D).y - getPointerPosition(C).y, }; const distance1 = Math.sqrt((diff1.x ** 2) + (diff1.y ** 2)); const distance2 = Math.sqrt((diff2.x ** 2) + (diff2.y ** 2)); if (!distance1 || !distance2) { return 1; } const scaleDelta = distance1 / distance2; return scaleDelta; }; const getPinchMidPoint = (touches) => { const [A, B] = touches; const midPoint = { x: (getPointerPosition(A).x + getPointerPosition(B).x) / 2, y: (getPointerPosition(A).y + getPointerPosition(B).y) / 2, }; return midPoint; }; export const MapInteraction = React.forwardRef(({ children, transform: transformProp, onChange, minScale, maxScale, showControls, isDragBlocked, preventDragSelector, preventScrollSelector, }, ref) => { const containerNodeRef = useRef(); const contentNodeRef = useRef(); const preventDragSelectorRef = useRef(); preventDragSelectorRef.current = preventDragSelector; const preventScrollSelectorRef = useRef(); preventScrollSelectorRef.current = preventScrollSelector; const containerWidth = containerNodeRef.current ? containerNodeRef.current.clientWidth : 1000; const contentWidth = contentNodeRef.current ? contentNodeRef.current.clientWidth : 1000; const containerHeight = containerNodeRef.current ? containerNodeRef.current.clientHeight : 1000; const contentHeight = contentNodeRef.current ? contentNodeRef.current.clientHeight : 1000; const getTransformInRange = ({ scale, x, y }) => { const newScale = Math.max(minScale, Math.min(scale, maxScale)); return { scale: newScale, x: Math.max( contentWidth * newScale * -1, Math.min(x, containerWidth) ), y: Math.max( contentHeight * newScale * -1, Math.min(y, containerHeight) ), }; }; const [transformState, setTransformState] = useState( getTransformInRange(transformProp) ); useLayoutEffect(() => { setTransformState(getTransformInRange({ x: transformProp.x, y: transformProp.y, scale: transformProp.scale, })); }, [transformProp.x, transformProp.y, transformProp.scale]); useLayoutEffect(() => { onChange(transformState); }, [transformState]); const startPointerPositionRef = useRef(); const startTouchesRef = useRef(null); const [isDragging, setIsDragging] = useState(false); const [isScrolling, setIsScrolling] = useState(false); const [isPinching, setIsPinching] = useState(false); useEffect(() => { const timeout = setTimeout(() => { setIsScrolling(false); }, 100); return () => clearTimeout(timeout); }, [isScrolling]); const handleStartDrag = (e) => { if (isDragBlocked) { return; } if (preventDragSelectorRef.current && e.target.closest(preventDragSelectorRef.current)) { return; } e.preventDefault(); setIsDragging(true); startPointerPositionRef.current = getPointerPosition(e); }; const handleDrag = (e) => { e.preventDefault(); if (isDualTouch(e)) { return; } const delta = getPointerDelta(e, startPointerPositionRef.current); startPointerPositionRef.current = getPointerPosition(e); setTransformState(({ x, y, scale }) => getTransformInRange({ x: x + delta.x, y: y + delta.y, scale, })); }; const handleStopDrag = () => { setIsDragging(false); }; const scaleFromOrigin = (newScaleDelta, origin, prevTransform) => { const newScale = prevTransform.scale + (1 - newScaleDelta); const deltaTranslation = getDeltaFromEventOriginToTranslationOrigin({ origin, translation: { x: prevTransform.x, y: prevTransform.y, }, containerNode: containerNodeRef.current, }); return scaleFromDeltaTranslation({ scale: getTransformInRange({ ...transformState, scale: newScale }).scale, delta: deltaTranslation, transform: prevTransform, }); }; const handleScrollScale = (e) => { if (preventScrollSelectorRef.current && e.target.closest(preventScrollSelectorRef.current)) { return; } e.preventDefault(); setIsScrolling(true); const pointerPosition = getPointerPosition(e); setTransformState((prevTransform) => getTransformInRange( scaleFromOrigin(2 ** (e.deltaY * 0.002), pointerPosition, prevTransform) )); }; const handlePinchStart = (e) => { if (preventScrollSelectorRef.current && e.target.closest(preventScrollSelectorRef.current)) { return; } if (!isDualTouch(e)) { return; } e.preventDefault(); startTouchesRef.current = e.touches; setIsPinching(true); }; const handlePinchEnd = (e) => { e.preventDefault(); startTouchesRef.current = null; setIsPinching(false); }; const handlePinch = (e) => { if (!isDualTouch(e) || !startTouchesRef.current || startTouchesRef.current.length !== 2) { return; } e.preventDefault(); const scaleDelta = getPinchScaleDelta(startTouchesRef.current, e.touches); const midPoint = getPinchMidPoint(e.touches); setTransformState((prevTransform) => getTransformInRange( scaleFromOrigin(scaleDelta, midPoint, prevTransform) )); startTouchesRef.current = e.touches; }; const handleScale = (scale) => { setTransformState((s) => getTransformInRange({ ...s, scale })); }; const handleFitContent = () => { const ratioX = (containerWidth / contentWidth); const ratioY = (containerHeight / contentHeight); const newScale = ratioX < ratioY ? ratioX : ratioY; setTransformState(getTransformInRange({ x: (containerWidth - (contentWidth * newScale)) / 2, y: (containerHeight - (contentHeight * newScale)) / 2, scale: newScale, })); }; const addEvent = (eventName, callback, node = containerNodeRef.current) => { node.addEventListener(eventName, callback, { passive: false }); }; const removeEvent = (eventName, callback, node = containerNodeRef.current) => { node.removeEventListener(eventName, callback, { passive: false }); }; // Scroll events useEffect(() => { if (!containerNodeRef.current) { return () => {}; } addEvent('mousewheel', handleScrollScale); addEvent('wheel', handleScrollScale); return () => { removeEvent('mousewheel', handleScrollScale); removeEvent('wheel', handleScrollScale); }; }, [containerNodeRef.current]); // On Pinch Start events useEffect(() => { if (!containerNodeRef.current) { return () => {}; } addEvent('touchstart', handlePinchStart); return () => { removeEvent('touchstart', handlePinchStart); }; }, [containerNodeRef.current]); // On Pinch events useEffect(() => { if (!containerNodeRef.current || !isPinching) { return () => {}; } addEvent('touchmove', handlePinch); addEvent('touchend', handlePinchEnd, window); return () => { removeEvent('touchmove', handlePinch); removeEvent('touchend', handlePinchEnd, window); }; }, [containerNodeRef.current, isPinching]); // Start Drag events useEffect(() => { if (!containerNodeRef.current) { return () => {}; } addEvent('mousedown', handleStartDrag); addEvent('touchstart', handleStartDrag); return () => { removeEvent('mousedown', handleStartDrag); removeEvent('touchstart', handleStartDrag); }; }, [containerNodeRef.current]); // On Drag events useEffect(() => { if (!containerNodeRef.current || !isDragging) { return () => {}; } addEvent('touchmove', handleDrag); addEvent('mousemove', handleDrag); addEvent('mouseup', handleStopDrag, window); addEvent('touchend', handleStopDrag, window); return () => { removeEvent('touchmove', handleDrag); removeEvent('mousemove', handleDrag); removeEvent('mouseup', handleStopDrag, window); removeEvent('touchend', handleStopDrag, window); }; }, [containerNodeRef.current, isDragging]); return ( <MapInteractionContext.Provider value={{ scale: transformState.scale, isDragging, }} > <div className={`sui-prj-map-interaction ${isDragging ? 'as--dragging' : ''}`} ref={ref} > <div ref={containerNodeRef} className="sui-prj-map-interaction__field" > <div className="sui-prj-map-interaction__board" style={{ transform: `translate(${transformState.x}px, ${transformState.y}px) scale(${transformState.scale})`, transition: !(isDragging || isScrolling || isPinching) ? '0.2s' : null, }} > <div className="sui-prj-map-interaction__layer" ref={contentNodeRef}> {children} </div> <div className={`sui-prj-map-interaction__background ${isDragging ? 'as--dragging' : ''}`} /> </div> </div> {showControls && ( <MapInteractionControls scale={transformState.scale} minScale={minScale} maxScale={maxScale} onScale={handleScale} onFitScale={handleFitContent} /> )} </div> </MapInteractionContext.Provider> ); }); export default MapInteraction; MapInteraction.propTypes = propTypes; MapInteraction.defaultProps = defaultProps;