saagie-ui
Version:
Saagie UI from Saagie Design System
448 lines (366 loc) • 12 kB
JavaScript
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;