@julio-soto/react-compare-slider
Version:
A slider component to compare any two React components in landscape or portrait orientation. It supports custom images, videos... and everything else.
483 lines (426 loc) • 15.9 kB
JavaScript
;
Object.defineProperty(exports, '__esModule', { value: true });
function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; }
var React = require('react');
var React__default = _interopDefault(React);
const ThisArrow = ({
flip
}) => {
const style = {
width: 0,
height: 0,
borderTop: '8px solid transparent',
borderRight: '10px solid',
borderBottom: '8px solid transparent',
transform: flip ? 'rotate(180deg)' : undefined
};
return React__default.createElement("div", {
style: style
});
};
/** Default `handle`. */
const ReactCompareSliderHandle = ({
portrait,
buttonStyle,
linesStyle,
style,
...props
}) => {
const _style = {
display: 'flex',
flexDirection: portrait ? 'row' : 'column',
placeItems: 'center',
height: '100%',
cursor: portrait ? 'ns-resize' : 'ew-resize',
pointerEvents: 'none',
color: '#fff',
...style
};
const _linesStyle = {
flexGrow: 1,
height: portrait ? 2 : '100%',
width: portrait ? '100%' : 2,
backgroundColor: 'currentColor',
pointerEvents: 'auto',
boxShadow: '0 0 7px rgba(0,0,0,.35)',
...linesStyle
};
const _buttonStyle = {
display: 'grid',
gridAutoFlow: 'column',
gap: 8,
placeContent: 'center',
flexShrink: 0,
width: 56,
height: 56,
borderRadius: '50%',
borderStyle: 'solid',
borderWidth: 2,
pointerEvents: 'auto',
backdropFilter: 'blur(7px)',
WebkitBackdropFilter: 'blur(7px)',
boxShadow: '0 0 7px rgba(0,0,0,.35)',
transform: portrait ? 'rotate(90deg)' : undefined,
...buttonStyle
};
return React__default.createElement("div", Object.assign({
className: "__rcs-handle-root"
}, props, {
style: _style
}), React__default.createElement("div", {
className: "__rcs-handle-line",
style: _linesStyle
}), React__default.createElement("div", {
className: "__rcs-handle-button",
style: _buttonStyle
}, React__default.createElement(ThisArrow, null), React__default.createElement(ThisArrow, {
flip: true
})), React__default.createElement("div", {
className: "__rcs-handle-line",
style: _linesStyle
}));
};
/**
* Stand-alone CSS utility to make replaced elements (`img`, `video`, etc.) fit their
* container.
*/
const styleFitContainer = ({
boxSizing = 'border-box',
objectFit = 'cover',
objectPosition = 'center',
...props
} = {}) => ({
display: 'block',
width: '100%',
height: '100%',
maxWidth: '100%',
boxSizing,
objectFit,
objectPosition,
...props
});
/** Store the previous supplied value. */
const usePrevious = value => {
const ref = React.useRef(value);
React.useEffect(() => {
ref.current = value;
});
return ref.current;
};
/**
* Event listener binding hook.
* @param eventName - Event to bind to.
* @param handler - Callback handler.
* @param element - Element to bind to.
* @param handlerOptions - Event handler options.
*/
const useEventListener = (eventName, handler, element, handlerOptions) => {
const savedHandler = React.useRef();
React.useEffect(() => {
savedHandler.current = handler;
}, [handler]);
React.useEffect(() => {
// Make sure element supports addEventListener.
if (!(element && element.addEventListener)) return; // Create event listener that calls handler function stored in ref.
const eventListener = event => savedHandler.current && savedHandler.current(event);
element.addEventListener(eventName, eventListener, handlerOptions);
return () => {
element.removeEventListener(eventName, eventListener, handlerOptions);
};
}, [eventName, element, handlerOptions]);
};
/**
* Conditionally use `useLayoutEffect` for client *or* `useEffect` for SSR.
* @see <https://github.com/reduxjs/react-redux/blob/c581d480dd675f2645851fb006bef91aeb6ac24d/src/utils/useIsomorphicLayoutEffect.js>
*/
const useIsomorphicLayoutEffect = typeof window !== 'undefined' && window.document && window.document.createElement ? React.useLayoutEffect : React.useEffect;
/**
* Bind resize observer callback to element.
* @param ref - Element to bind to.
* @param handler - Callback for handling entry's bounding rect.
*/
const useResizeObserver = (ref, handler) => {
const observer = React.useRef();
const observe = React.useCallback(() => {
if (ref.current && observer.current) observer.current.observe(ref.current);
}, [ref]); // Bind/rebind observer when `handler` changes.
useIsomorphicLayoutEffect(() => {
observer.current = new ResizeObserver(([entry]) => handler(entry.contentRect));
observe();
return () => {
if (observer.current) observer.current.disconnect();
};
}, [handler, observe]);
};
/** Container for clipped item. */
const ThisClipContainer = /*#__PURE__*/React.forwardRef((props, ref) => {
const style = {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
willChange: 'clip',
userSelect: 'none',
KhtmlUserSelect: 'none',
MozUserSelect: 'none',
WebkitUserSelect: 'none'
};
return React__default.createElement("div", Object.assign({}, props, {
style: style,
"data-rcs": "clip-item",
ref: ref
}));
});
ThisClipContainer.displayName = 'ThisClipContainer';
/** Handle container to control position. */
const ThisHandleContainer = /*#__PURE__*/React.forwardRef(({
children,
portrait
}, ref) => {
const style = {
position: 'absolute',
top: 0,
width: '100%',
height: '100%',
pointerEvents: 'none'
};
const innerStyle = {
position: 'absolute',
width: portrait ? '100%' : undefined,
height: portrait ? undefined : '100%',
transform: portrait ? 'translateY(-50%)' : 'translateX(-50%)',
pointerEvents: 'all'
};
return React__default.createElement("div", {
style: style,
"data-rcs": "handle-container",
ref: ref
}, React__default.createElement("div", {
style: innerStyle
}, children));
});
ThisHandleContainer.displayName = 'ThisHandleContainer';
const EVENT_PASSIVE_PARAMS = {
passive: true
};
const EVENT_CAPTURE_PARAMS = {
capture: true,
passive: false
};
/** Root Comparison slider. */
const ReactCompareSlider = ({
handle,
itemOne,
itemTwo,
onlyHandleDraggable = false,
onPositionChange,
portrait = false,
position = 50,
boundsPadding = 0,
changePositionOnHover = false,
style,
...props
}) => {
/** DOM node of the root element. */
const rootContainerRef = React.useRef(null);
/** DOM node of the item that is clipped. */
const clipContainerRef = React.useRef(null);
/** DOM node of the handle container. */
const handleContainerRef = React.useRef(null);
/** Current position as a percentage value (initially negative to sync bounds on mount). */
const internalPositionPc = React.useRef(position);
/** Previous `position` prop value. */
const prevPropPosition = usePrevious(position);
/** Whether user is currently dragging. */
const [isDragging, setIsDragging] = React.useState(false);
/** Whether component has a `window` event binding. */
const hasWindowBinding = React.useRef(false);
/** Target container for pointer events. */
const [interactiveTarget, setInteractiveTarget] = React.useState();
/** Whether the bounds of the container element have been synchronised. */
const [didSyncBounds, setDidSyncBounds] = React.useState(false); // Set target container for pointer events.
React.useEffect(() => {
setInteractiveTarget(onlyHandleDraggable ? handleContainerRef.current : rootContainerRef.current);
}, [onlyHandleDraggable]);
/** Update internal position value. */
const updateInternalPosition = React.useCallback(function updateInternalCall({
x,
y,
isOffset,
portrait: _portrait,
boundsPadding: _boundsPadding
}) {
const {
top,
left,
width,
height
} = rootContainerRef.current.getBoundingClientRect(); // Early out if width or height are zero, can't calculate values
// from zeros.
if (width === 0 || height === 0) return;
/**
* Pixel position clamped within the container's bounds.
* @NOTE This does *not* take `boundsPadding` into account because we need
* the full coords to correctly position the handle.
*/
const positionPx = Math.min(Math.max( // Determine bounds based on orientation
_portrait ? isOffset ? y - top - window.pageYOffset : y : isOffset ? x - left - window.pageXOffset : x, // Min value
0), // Max value
_portrait ? height : width);
/**
* Internal position percentage *without* bounds.
* @NOTE This uses the entire container bounds **without** `boundsPadding`
* to get the *real* bounds.
*/
const nextInternalPositionPc = positionPx / (_portrait ? height : width) * 100;
/** Whether the current pixel position meets the min/max bounds. */
const positionMeetsBounds = _portrait ? positionPx === 0 || positionPx === height : positionPx === 0 || positionPx === width;
const canSkipPositionPc = nextInternalPositionPc === internalPositionPc.current && (internalPositionPc.current === 0 || internalPositionPc.current === 100); // Early out if pixel and percentage positions are already at the min/max
// to prevent update spamming when the user is sliding outside of the
// container.
if (didSyncBounds && canSkipPositionPc && positionMeetsBounds) {
return;
} else {
setDidSyncBounds(true);
} // Set new internal position.
internalPositionPc.current = nextInternalPositionPc;
/** Pixel position clamped to extremities *with* bounds padding. */
const clampedPx = Math.min( // Get largest from pixel position *or* bounds padding.
Math.max(positionPx, 0 + _boundsPadding), // Use height *or* width based on orientation.
(_portrait ? height : width) - _boundsPadding); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
clipContainerRef.current.style.clip = _portrait ? `rect(auto,auto,${clampedPx}px,auto)` : `rect(auto,${clampedPx}px,auto,auto)`; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
handleContainerRef.current.style.transform = _portrait ? `translate3d(0,${clampedPx}px,0)` : `translate3d(${clampedPx}px,0,0)`;
if (onPositionChange) onPositionChange(internalPositionPc.current);
}, [didSyncBounds, onPositionChange]); // Update internal position when other user controllable props change.
React.useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const {
width,
height
} = rootContainerRef.current.getBoundingClientRect(); // Use current internal position if `position` hasn't changed.
const nextPosition = position === prevPropPosition ? internalPositionPc.current : position;
updateInternalPosition({
portrait,
boundsPadding,
x: width / 100 * nextPosition,
y: height / 100 * nextPosition
});
}, [portrait, position, prevPropPosition, boundsPadding, updateInternalPosition]);
/** Handle mouse/touch down. */
const handlePointerDown = React.useCallback(ev => {
ev.preventDefault();
updateInternalPosition({
portrait,
boundsPadding,
isOffset: true,
x: ev instanceof MouseEvent ? ev.pageX : ev.touches[0].pageX,
y: ev instanceof MouseEvent ? ev.pageY : ev.touches[0].pageY
});
setIsDragging(true);
}, [portrait, boundsPadding, updateInternalPosition]);
/** Handle mouse/touch move. */
const handlePointerMove = React.useCallback(function moveCall(ev) {
updateInternalPosition({
portrait,
boundsPadding,
isOffset: true,
x: ev instanceof MouseEvent ? ev.pageX : ev.touches[0].pageX,
y: ev instanceof MouseEvent ? ev.pageY : ev.touches[0].pageY
});
}, [portrait, boundsPadding, updateInternalPosition]);
/** Handle mouse/touch up. */
const handlePointerUp = React.useCallback(() => {
setIsDragging(false);
}, []);
/** Resync internal position on resize. */
const handleResize = React.useCallback(({
width,
height
}) => {
updateInternalPosition({
portrait,
boundsPadding,
x: width / 100 * internalPositionPc.current,
y: height / 100 * internalPositionPc.current
});
}, [portrait, boundsPadding, updateInternalPosition]); // Allow drag outside of container while pointer is still down.
React.useEffect(() => {
if (isDragging && !hasWindowBinding.current) {
window.addEventListener('mousemove', handlePointerMove, EVENT_PASSIVE_PARAMS);
window.addEventListener('mouseup', handlePointerUp, EVENT_PASSIVE_PARAMS);
window.addEventListener('touchmove', handlePointerMove, EVENT_PASSIVE_PARAMS);
window.addEventListener('touchend', handlePointerUp, EVENT_PASSIVE_PARAMS);
hasWindowBinding.current = true;
}
return () => {
if (hasWindowBinding.current) {
window.removeEventListener('mousemove', handlePointerMove);
window.removeEventListener('mouseup', handlePointerUp);
window.removeEventListener('touchmove', handlePointerMove);
window.removeEventListener('touchend', handlePointerUp);
hasWindowBinding.current = false;
}
};
}, [handlePointerMove, handlePointerUp, isDragging]); // Bind resize observer to container.
useResizeObserver(rootContainerRef, handleResize); // Handle hover events on the container.
React.useEffect(() => {
const containerRef = rootContainerRef.current;
const handleMouseLeave = () => {
if (isDragging) return;
handlePointerUp();
};
if (changePositionOnHover) {
containerRef.addEventListener('mousemove', handlePointerMove, EVENT_PASSIVE_PARAMS);
containerRef.addEventListener('mouseleave', handleMouseLeave, EVENT_PASSIVE_PARAMS);
}
return () => {
containerRef.removeEventListener('mousemove', handlePointerMove);
containerRef.removeEventListener('mouseleave', handleMouseLeave);
};
}, [changePositionOnHover, handlePointerMove, handlePointerUp, isDragging]);
useEventListener('mousedown', handlePointerDown, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
interactiveTarget, EVENT_CAPTURE_PARAMS);
useEventListener('touchstart', handlePointerDown, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
interactiveTarget, EVENT_CAPTURE_PARAMS); // Use custom handle if requested.
const Handle = handle || React__default.createElement(ReactCompareSliderHandle, {
portrait: portrait
});
const rootStyle = {
position: 'relative',
overflow: 'hidden',
cursor: isDragging ? portrait ? 'ns-resize' : 'ew-resize' : undefined,
userSelect: 'none',
KhtmlUserSelect: 'none',
msUserSelect: 'none',
MozUserSelect: 'none',
WebkitUserSelect: 'none',
...style
};
return React__default.createElement("div", Object.assign({}, props, {
ref: rootContainerRef,
style: rootStyle,
"data-rcs": "root"
}), itemTwo, React__default.createElement(ThisClipContainer, {
ref: clipContainerRef
}, itemOne), React__default.createElement(ThisHandleContainer, {
portrait: portrait,
ref: handleContainerRef
}, Handle));
};
/** Image with defaults from `styleFitContainer` applied. */
const ReactCompareSliderImage = ({
style,
...props
}) => {
const rootStyle = styleFitContainer(style);
return React__default.createElement("img", Object.assign({}, props, {
style: rootStyle,
"data-rcs": "image"
}));
};
exports.ReactCompareSlider = ReactCompareSlider;
exports.ReactCompareSliderHandle = ReactCompareSliderHandle;
exports.ReactCompareSliderImage = ReactCompareSliderImage;
exports.styleFitContainer = styleFitContainer;
//# sourceMappingURL=react-compare-slider.cjs.development.js.map