<div id="root"></div>
body{
margin: 0;
}
const { useState, useRef, useCallback, useEffect } = React;
const clamp = (value, min, max) => {
if (value < min) return min;
else if (value > max) return max;
return value;
};
const useResizeObserver = (elements, callback) => {
useEffect(() => {
const resizeObserver = new ResizeObserver((entries) => callback(entries));
for (const elem of elements) {
console.log(elem);
elem.current && resizeObserver.observe(elem.current);
}
return () => resizeObserver.disconnect();
}, []);
};
const useMutationObserver = (elements, callback, config) => {
useEffect(() => {
const mutationObserver = new MutationObserver((mutations) => {
console.log("ita: ", mutations);
mutationObserver.disconnect();
callback(mutations);
for (const elem of elements) {
elem.current && mutationObserver.observe(elem.current, config);
}
});
for (const elem of elements) {
elem.current && mutationObserver.observe(elem.current, config);
}
return () => mutationObserver.disconnect();
}, []);
};
const useDragAndDrop = () => {
const [elementPosition, setElementPosition] = useState({ top: 0, left: 0 });
const [elementOffset, setElementOffset] = useState({ x: 0, y: 0 });
const pointerStartPosition = useRef({ x: null, y: null });
const pointerMovePosition = useRef({ x: null, y: null });
const currentDragElement = useRef(null);
const prevElementOffset = useRef({ x: 0, y: 0 });
const getCurrentPosition = (elem) => {
const { top, left } = elem.getBoundingClientRect();
return { top, left };
};
const moveDistance = (distance) =>
setElementOffset({
x: prevElementOffset.current.x + distance.x,
y: prevElementOffset.current.y + distance.y
});
const resetElementOffset = () => {
setElementOffset({
x: 0,
y: 0
});
prevElementOffset.current = {
x: 0,
y: 0
};
};
const resetPointerStartPosition = () => {
if (
pointerStartPosition.current.x === null ||
pointerStartPosition.current.y === null
)
return;
pointerStartPosition.current.x = null;
pointerStartPosition.current.y = null;
};
const handleMouseDown = (e) => {
e.preventDefault();
pointerStartPosition.current.x = e.clientX;
pointerStartPosition.current.y = e.clientY;
currentDragElement.current = e.target;
const elementCurrentPosition = getCurrentPosition(currentDragElement.current);
setElementPosition({
top: elementCurrentPosition.top,
left: elementCurrentPosition.left
});
};
const handleMouseMove = (e) => {
e.preventDefault();
if (!currentDragElement.current) return;
console.log('suga move: ', currentDragElement.current)
if (
pointerStartPosition.current.x === null ||
pointerStartPosition.current.y === null
)
return;
pointerMovePosition.current.x = e.clientX;
pointerMovePosition.current.y = e.clientY;
const pointerMoveDistance = {
x: pointerMovePosition.current.x - pointerStartPosition.current.x,
y: pointerMovePosition.current.y - pointerStartPosition.current.y
};
moveDistance(pointerMoveDistance);
};
const handleMouseUp = (e) => {
e.preventDefault();
if (!currentDragElement.current) return;
console.log('suga up: ', currentDragElement.current)
resetPointerStartPosition();
const elementCurrentPosition = getCurrentPosition(currentDragElement.current);
setElementPosition({
top: elementCurrentPosition.top,
left: elementCurrentPosition.left
});
currentDragElement.current = null;
};
useEffect(() => {
document.body.addEventListener("mousemove", handleMouseMove);
document.body.addEventListener("mouseup", handleMouseUp);
document.body.addEventListener("mouseleave", handleMouseUp);
return () => {
document.body.removeEventListener("mousemove", handleMouseMove);
document.body.removeEventListener("mouseup", handleMouseUp);
document.body.removeEventListener("mouseleave", handleMouseUp);
};
}, []);
useEffect(() => {
prevElementOffset.current = {
x: elementOffset.x,
y: elementOffset.y
};
}, [elementPosition.left, elementPosition.top]);
return [
{
currentDragElement,
elementPosition,
elementOffset,
},
{
pointerStartPosition,
pointerMovePosition
},
handleMouseDown,
setElementOffset,
resetElementOffset,
setElementPosition
];
};
const useRangeSlider = () => {
const [
{
currentDragElement,
elementPosition,
elementOffset
},
{
pointerStartPosition,
pointerMovePosition
},
handleMouseDown,
setElementOffset,
resetElementOffset,
setElementPosition
] = useDragAndDrop();
const [rangeSliderHandleOffsetX, setRangeSliderHandleOffsetX] = useState(
0
);
const [rangeSliderHandleWidth, setRangeSliderHandleWidth] = useState(0);
const [rangeSliderBarWidth, setRangeSliderBarWidth] = useState(0);
const [rangeSliderBarPosition, setRangeSliderBarPosition] = useState({
left: 0,
top: 0
});
const [rangeSliderHandlePosition, setRangeSliderHandlePosition] = useState(0);
const rangeSliderBarScale = useRef(1)
const rangeSliderHandleElement = useRef(null);
const rangeSliderBarElement = useRef(null);
const previousRangeSliderBarWidth = useRef(0)
useResizeObserver(
[rangeSliderBarElement],
(entries) => {
const entry = entries[0]
const width = entry.contentRect.width;
const height = entry.contentRect.height;
//if(width === 0 || height === 0) return
// useFrontBackRatioフックだとresizeObserverの検知の速さに対応できないため、ここでスケールを取得
/*rangeSliderBarScale.current =
previousRangeSliderBarWidth.current === 0
? 1
: width / previousRangeSliderBarWidth.current;*/
setRangeSliderBarWidth(width);
//previousRangeSliderBarWidth.current = width
//setRangeSliderHandleOffsetX((p) => p * rangeSliderBarScale.current)
const rect = entry.target.getBoundingClientRect();
setRangeSliderBarPosition({
left: rect.left,
top: rect.top
});
}
);
useResizeObserver(
[rangeSliderHandleElement],
(entries) => {
const entry = entries[0]
const width = entry.contentRect.width;
const height = entry.contentRect.height;
if(width === 0 || height === 0) return
setRangeSliderHandleWidth(width);
rangeSliderBarScale.current =
previousRangeSliderBarWidth.current === 0
? 1
: width / previousRangeSliderBarWidth.current;
previousRangeSliderBarWidth.current = width
setRangeSliderHandleOffsetX((p) => p * rangeSliderBarScale.current)
const left = entry.target.getBoundingClientRect().left;
setRangeSliderHandlePosition(left);
}
)
useMutationObserver(
[rangeSliderHandleElement],
(mutations) => {
const left = mutations[0].target.getBoundingClientRect().left;
setRangeSliderHandlePosition(left);
},
{
attributes: true,
subtree: false,
childList: false,
attributeFilter: ["class"]
}
);
useEffect(() => {
if (!currentDragElement.current) return;
// rangeSliderBarPosition.leftだと、バーの位置が途中から変化しなくなったときに、そこから先の変更値が取得できなくなるため、押し込まれたときのバーの位置を取得する
const rect = rangeSliderBarElement.current.getBoundingClientRect()
/*setRangeSliderBarPosition({
left: rect.left,
top: rect.top
});*/
const startX = pointerStartPosition.current.x - rect.left//rangeSliderBarPosition.left;
setRangeSliderHandleOffsetX(clamp(startX, 0, rangeSliderBarWidth));
}, [elementPosition]);
useEffect(() => {
const rect = rangeSliderBarElement.current.getBoundingClientRect()
/*setRangeSliderBarPosition({
left: rect.left,
top: rect.top
});*/
const moveX = pointerMovePosition.current.x - rect.left//rangeSliderBarPosition.left;
setRangeSliderHandleOffsetX((p) => clamp(moveX, 0, rangeSliderBarWidth));
}, [elementOffset]);
return [
{
handle: {
element: rangeSliderHandleElement,
offsetX: rangeSliderHandleOffsetX,
position: elementPosition,
width: rangeSliderHandleWidth
},
bar: {
element: rangeSliderBarElement,
position: rangeSliderBarPosition,
width: rangeSliderBarWidth,
scale: rangeSliderBarScale
},
handleMouseDown
},
setRangeSliderHandleOffsetX
];
};
const StyledRangeSlider = styled.div`
display: flex;
align-items: center;
height: 20vmin;
position: relative;
width: 100%;
`;
const Bar = styled.div`
background-color: #f0f0f0;
border-radius: 10vmin;
cursor: pointer;
height: 10vmin;
position: absolute;
width: 100%;
`;
const Handle = styled.div`
background-color: #ffa44a;
border-radius: 100%;
cursor: pointer;
height: 16vmin;
position: absolute;
left: -8vmin;
transform: translate3d(
${({ offsetX }) => offsetX}px,
0,
0
);
width: 16vmin;
`;
const RangeSlider = ({ rangeSlider }) => (
<StyledRangeSlider className="range-slider">
<Bar
className="range-slider-bar"
ref={rangeSlider.bar.element}
onMouseDown={rangeSlider.handleMouseDown}
/>
<Handle
className="range-slider-handle"
ref={rangeSlider.handle.element}
onMouseDown={rangeSlider.handleMouseDown}
offsetX={rangeSlider.handle.offsetX}
/>
</StyledRangeSlider>
);
const StyledApp = styled.div`
background-color: #8e82bf;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
width: 100%;
`;
const Container = styled.div`
width: 80vmin;
`;
const App = () => {
const [
rangeSlider,
setRangeSliderHandleOffsetX
] = useRangeSlider();
return (
<StyledApp>
<Container>
<RangeSlider rangeSlider={rangeSlider} />
</Container>
</StyledApp>
);
};
ReactDOM.render(<App />, document.getElementById("root"));
View Compiled
This Pen doesn't use any external CSS resources.