From 44a6233494892b5333bd34d4c0c420eed10b28fd Mon Sep 17 00:00:00 2001 From: erutobusiness Date: Sat, 27 Apr 2024 01:44:33 +0900 Subject: [PATCH] =?UTF-8?q?=E3=83=89=E3=83=A9=E3=83=83=E3=82=B0=E3=81=AB?= =?UTF-8?q?=E3=82=88=E3=82=8B=E3=82=B9=E3=82=AF=E3=83=AD=E3=83=BC=E3=83=AB?= =?UTF-8?q?=E3=81=8C=E5=8F=AF=E8=83=BD=E3=81=AADiv=E3=82=92=E5=AE=9F?= =?UTF-8?q?=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/div/DivScrollable.tsx | 472 +++++++-------------------- src/components/div/DivScrollbar.tsx | 406 +++++++++++++++++++++++ src/hooks/useEventKeydown.ts | 47 +++ src/hooks/useEventWheel.ts | 36 ++ src/pages/scroll/Scroll.tsx | 3 + src/pages/scroll/ScrollDiv.tsx | 10 +- src/pages/scroll/Ultimate.tsx | 62 ++++ src/utils/easeInOutQuad.ts | 4 + src/utils/getChildLengthes.ts | 7 + src/utils/getChildOffsets.ts | 13 + src/utils/getClosestCoordinate.ts | 15 + src/utils/scrollAnimate.ts | 49 +++ src/utils/snapPointer.ts | 40 +++ src/utils/snapWheel.ts | 22 ++ 14 files changed, 819 insertions(+), 367 deletions(-) create mode 100644 src/components/div/DivScrollbar.tsx create mode 100644 src/hooks/useEventKeydown.ts create mode 100644 src/hooks/useEventWheel.ts create mode 100644 src/pages/scroll/Ultimate.tsx create mode 100644 src/utils/easeInOutQuad.ts create mode 100644 src/utils/getChildLengthes.ts create mode 100644 src/utils/getChildOffsets.ts create mode 100644 src/utils/getClosestCoordinate.ts create mode 100644 src/utils/scrollAnimate.ts create mode 100644 src/utils/snapPointer.ts create mode 100644 src/utils/snapWheel.ts diff --git a/src/components/div/DivScrollable.tsx b/src/components/div/DivScrollable.tsx index 6f2a9f2..55a5f23 100644 --- a/src/components/div/DivScrollable.tsx +++ b/src/components/div/DivScrollable.tsx @@ -6,397 +6,145 @@ import { type PointerEvent, forwardRef, memo, - useCallback, useEffect, - useImperativeHandle, - useMemo, useRef, - useState, } from 'react'; -import type { RefHandle } from '../../@types/scrollable'; -import { useObserverMutationChild } from '../../hooks/useObserverMutationChild'; -import { useScrollSmooth } from '../../hooks/useScrollSmooth'; - +import type { Coordinate } from '../../@types/index'; +import { useEventKeydown } from '../../hooks/useEventKeydown'; +import { useEventWheel } from '../../hooks/useEventWheel'; +import { scrollAnimate } from '../../utils/scrollAnimate'; +import { snapPointer } from '../../utils/snapPointer'; +import { snapWheel } from '../../utils/snapWheel'; interface Props extends HTMLAttributes { - id: string; + isSnap?: boolean; + isAnimate?: boolean; stylesParent?: StyleXStyles; - stylesChild?: StyleXStyles; - hasButton?: boolean; + stylesScroll?: StyleXStyles; } -const SCROLLBAR_WIDTH = 12; -const SCROLLBAR_MARGIN = 1; -const SCROLL_SUB = 300; - const styles = stylex.create({ - container: { - position: 'relative', - display: 'flex', - borderRadius: 4, - borderStyle: 'solid', - borderWidth: 2, - borderColor: 'lightgray', - textAlign: 'center', - width: '40svh', - height: '40svh', - margin: '1em', - overflow: 'auto', - '::-webkit-scrollbar': { - display: 'none', - }, - '::-webkit-scrollbar-thumb': { - display: 'none', - }, - }, - target: { - position: 'absolute', - top: 0, - left: 0, - width: 4, - height: 4, - backgroundColor: 'black', - }, - wrap: { - position: 'sticky', - right: 0, - top: 0, - height: '100%', - }, - wrapNotScrollable: { - display: 'none', + parent: { + overflow: 'hidden', }, - bar: { + scroll: { + overflow: 'auto', + touchAction: 'none', + cursor: 'grab', position: 'relative', - width: SCROLLBAR_WIDTH, + width: '100%', height: '100%', - backgroundColor: 'darkred', - cursor: 'pointer', - }, - barHasButton: { - height: `calc(100% - ${SCROLLBAR_WIDTH}px * 2)`, }, - button: (enable: boolean) => ({ - position: 'relative', - width: SCROLLBAR_WIDTH, - height: SCROLLBAR_WIDTH, - backgroundColor: 'darkgoldenrod', - cursor: 'pointer', - pointerEvents: !enable && 'none', - '::after': { - content: '', - position: 'absolute', - top: 0, - left: 0, - width: `calc(${SCROLLBAR_WIDTH}px - ${SCROLLBAR_MARGIN}px * 2)`, - height: `calc(${SCROLLBAR_WIDTH}px - ${SCROLLBAR_MARGIN}px * 2)`, - margin: SCROLLBAR_MARGIN, - backgroundColor: enable && 'yellow', - }, - }), - thumb: (height: number, top: number) => ({ - position: 'absolute', - left: 0, - top, - width: `calc(${SCROLLBAR_WIDTH}px - ${SCROLLBAR_MARGIN}px * 2)`, - height, - backgroundColor: 'red', - cursor: 'pointer', - marginLeft: SCROLLBAR_MARGIN, - }), }); -const Component = forwardRef( +const initCoordinate = { x: 0, y: 0 }; +let isDragging = false; + +const Component = forwardRef( ( - { - id, - stylesParent, - stylesChild, - hasButton = false, - children, - ...attrs - }, + { isSnap, isAnimate, stylesParent, stylesScroll, children, ...attrs }, ref, ) => { - const [thumbHeight, setThumbHeight] = useState(0); - const [thumbTop, setThumbTop] = useState(0); - - const isScrollable: boolean = useMemo( - () => thumbHeight > 0, - [thumbHeight], - ); - - const refScrollbar = useRef(null); - - const refTarget = useRef(null); - const refParent = useRef(null); - useImperativeHandle(ref, () => ({ - refTarget: refTarget.current, - refParent: refParent.current, - })); - - const { refChild, heightChild } = useObserverMutationChild(); - - const handleLoad = useCallback(() => { - // 親要素がないならキャンセル - if (!refParent?.current) return; - // 親要素の高さを取得 - const { clientHeight: heightParent } = refParent.current; - // 親要素の高さをもとにボタンの有無でスクロールバーの高さを取得 - const heightScrollbar = hasButton - ? heightParent - SCROLLBAR_WIDTH * 2 - : heightParent; - // 親要素と子要素の高さの比を取得 - const ratio = heightParent / heightChild; - // 子要素が親要素より小さくないならキャンセル - if (ratio >= 1) return; - // 比をもとにスクロールバーの高さを算出 - const newScrollbarHeight = Math.max(heightScrollbar * ratio, 32); - // スクロールバーの高さを設定 - setThumbHeight(newScrollbarHeight); - }, [heightChild, hasButton]); + // Ref: スクロール要素 + const refScroll = useRef(null); + // スクロール要素Refの受取 useEffect(() => { - handleLoad(); - }, [handleLoad]); - - const handleScrollParent = useCallback(() => { - if (!(refParent?.current && refScrollbar.current)) return; - - const { clientHeight: heightParent, scrollTop } = refParent.current; - const { clientHeight: heightScrollbar } = refScrollbar.current; - - const scrollRatio = scrollTop / (heightChild - heightParent); - const newScrollbarPosY = - scrollRatio * (heightScrollbar - thumbHeight); - - setThumbTop(newScrollbarPosY); - }, [heightChild, thumbHeight]); - - const [isDraggingThumb, setIsDraggingThumb] = useState(false); - const [thumbDragOffsetY, setThumbDragOffsetY] = useState(0); - - // Thumb - const handleThumbPointerDown = useCallback( - (e: PointerEvent) => { - e.stopPropagation(); - setIsDraggingThumb(true); - e.currentTarget.setPointerCapture(e.pointerId); - - const rect = e.currentTarget.getBoundingClientRect(); - const offsetY = e.clientY - rect.top; - setThumbDragOffsetY(offsetY); - }, - [], - ); - const handleThumbPointerUp = useCallback( - (e: PointerEvent) => { - setIsDraggingThumb(false); - e.currentTarget.releasePointerCapture(e.pointerId); - }, - [], - ); - const handleThumbPointerMove = useCallback( - (e: PointerEvent) => { - if ( - !( - isDraggingThumb && - refParent?.current && - refScrollbar.current - ) - ) - return; - - const { clientHeight: heightParent } = refParent.current; - const { clientHeight: heightScrollbar } = refScrollbar.current; - - const rect = refScrollbar.current.getBoundingClientRect(); - const offsetY = e.clientY - rect.top; - - const maxScrollbarPosY = heightScrollbar - thumbHeight; - const newScrollbarPosY = Math.min( - Math.max(offsetY - thumbDragOffsetY, 0), - maxScrollbarPosY, + if (!ref) return; + if (typeof ref === 'function') { + ref(refScroll.current); + } else { + ref.current = refScroll.current; + } + }, [ref]); + + // Ref: ドラッグ関連 + const dragOffset = useRef(initCoordinate); // ドラッグ開始時のポインター位置 + const dragScroll = useRef(initCoordinate); // ドラッグ開始時のスクロール位置 + const dragTimestamp = useRef(0); // ドラッグ中のタイムスタンプ + + const { cancelAnimateScroll } = scrollAnimate(); + + const handlePointerDown = (e: PointerEvent) => { + // 複数指をキャンセル + if (!refScroll.current || isDragging) return; + // ポインターを追跡 + refScroll.current.setPointerCapture(e.pointerId); + // アニメーションをキャンセル + cancelAnimateScroll(); + // ドラッグフラグを有効化 + isDragging = true; + // スクロール位置計算用変数を更新 + dragOffset.current = { + x: e.clientX - refScroll.current.offsetLeft, + y: e.clientY - refScroll.current.offsetTop, + }; + dragScroll.current = { + x: refScroll.current.scrollLeft, + y: refScroll.current.scrollTop, + }; + // カーソルを更新 + refScroll.current.style.cursor = 'grabbing'; + document.body.style.cursor = 'grabbing'; + }; + + const handlePointerMove = (e: PointerEvent) => { + // ドラッグ中でないならキャンセル + if (!(refScroll.current && isDragging)) return; + // ドラッグ位置からスクロール位置を算出してスクロール + const { offsetLeft, offsetTop } = refScroll.current; + const x = e.clientX - offsetLeft - dragOffset.current.x; + const y = e.clientY - offsetTop - dragOffset.current.y; + refScroll.current.scrollLeft = dragScroll.current.x - x; + refScroll.current.scrollTop = dragScroll.current.y - y; + // タイムスタンプを更新 + dragTimestamp.current = e.timeStamp; + }; + + const handlePointerUp = (e: PointerEvent) => { + // ドラッグ中でないならキャンセル + if (!(refScroll.current && isDragging)) return; + // ドラッグフラグを初期化 + isDragging = false; + // 吸着 + if (isSnap) + snapPointer( + e, + dragScroll.current, + dragTimestamp.current, + isAnimate, ); - - const scrollRatio = newScrollbarPosY / maxScrollbarPosY; - const newScrollTop = scrollRatio * (heightChild - heightParent); - - setThumbTop(newScrollbarPosY); - refParent.current.scrollTop = newScrollTop; - }, - [heightChild, isDraggingThumb, thumbHeight, thumbDragOffsetY], - ); - - // スクロールバー - const [isDraggingScrollbar, setIsDraggingScrollbar] = - useState(false); - - const handleScrollbarPointerMove = useCallback( - (e: PointerEvent) => { - if ( - !( - isDraggingScrollbar && - refParent?.current && - refScrollbar.current - ) - ) - return; - - const { clientHeight: heightParent } = refParent.current; - const { clientHeight: heightScrollbar } = refScrollbar.current; - - const rect = refScrollbar.current.getBoundingClientRect(); - const offsetY = e.clientY - rect.top; - - const maxScrollbarPosY = heightScrollbar - thumbHeight; - const newScrollbarPosY = Math.min( - Math.max(offsetY - thumbHeight / 2, 0), - maxScrollbarPosY, - ); - - const scrollRatio = newScrollbarPosY / maxScrollbarPosY; - const newScrollTop = scrollRatio * (heightChild - heightParent); - - setThumbTop(newScrollbarPosY); - refParent.current.scrollTop = newScrollTop; - }, - [heightChild, isDraggingScrollbar, thumbHeight], - ); - const handleScrollbarPointerDown = useCallback( - (e: PointerEvent) => { - e.currentTarget.setPointerCapture(e.pointerId); - setIsDraggingScrollbar(true); - if (!(refParent?.current && refScrollbar.current)) return; - - const { clientHeight: heightParent } = refParent.current; - const { clientHeight: heightScrollbar } = refScrollbar.current; - - const rect = refScrollbar.current.getBoundingClientRect(); - const offsetY = e.clientY - rect.top; - - const maxScrollbarPosY = heightScrollbar - thumbHeight; - const newScrollbarPosY = Math.min( - Math.max(offsetY - thumbHeight / 2, 0), - maxScrollbarPosY, - ); - - const scrollRatio = newScrollbarPosY / maxScrollbarPosY; - const newScrollTop = scrollRatio * (heightChild - heightParent); - - setThumbTop(newScrollbarPosY); - refParent.current.scrollTop = newScrollTop; + // カーソルを初期化 + refScroll.current.style.cursor = ''; + document.body.style.cursor = ''; + }; + + useEventWheel( + refScroll, + () => { + cancelAnimateScroll(); }, - [heightChild, thumbHeight], - ); - const handleScrollbarPointerUp = useCallback( - (e: PointerEvent) => { - setIsDraggingScrollbar(false); - e.currentTarget.releasePointerCapture(e.pointerId); - }, - [], - ); - - const { scrollSmooth } = useScrollSmooth(); - - const scrollSmoothCommon = useCallback( - (newTop: number) => { - // - if (!refParent?.current) return; - // - scrollSmooth(refTarget.current, newTop, refParent.current.id); + () => { + if (!refScroll.current) return; + snapWheel(refScroll.current, isAnimate); }, - [scrollSmooth], ); - const handleClickButtonUp = useCallback(() => { - // - if (!refParent?.current) return; - // - const newTop = Math.max( - refParent.current.scrollTop - SCROLL_SUB, - 0, - ); - // - scrollSmoothCommon(newTop); - }, [scrollSmoothCommon]); - const handleClickButtonDown = useCallback(() => { - // - if (!refParent?.current) return; - // - const newTop = Math.min( - refParent.current.scrollTop + SCROLL_SUB, - refParent.current.clientHeight, - ); - // - scrollSmoothCommon(newTop); - }, [scrollSmoothCommon]); - - const [canUp, setCanUp] = useState(false); - const [canDown, setCanDown] = useState(true); - useEffect(() => { - if (!refScrollbar.current) return; - const { clientHeight: heightScrollbar } = refScrollbar.current; - setCanUp(thumbTop > 0); - setCanDown(thumbTop < heightScrollbar - thumbHeight - 1); - }, [thumbHeight, thumbTop]); + useEventKeydown([ + { key: 'ArrowLeft', callback: () => console.log('!') }, + ]); return ( -
+
{/* スクロール対象 */} -
- {/* ターゲット */} -
- {/* 子要素 */} - {children} -
- {/* スクロールバー */}
- {/* 上ボタン */} -
- {/* バー本体 */} -
- {/* thumb */} -
-
- {/* 下ボタン */} -
+ {/* 子要素 */} + {children}
); diff --git a/src/components/div/DivScrollbar.tsx b/src/components/div/DivScrollbar.tsx new file mode 100644 index 0000000..ad185a2 --- /dev/null +++ b/src/components/div/DivScrollbar.tsx @@ -0,0 +1,406 @@ +import stylex from '@stylexjs/stylex'; +import type { StyleXStyles } from '@stylexjs/stylex'; +import type { UserAuthoredStyles } from '@stylexjs/stylex/lib/StyleXTypes'; +import { + type HTMLAttributes, + type PointerEvent, + forwardRef, + memo, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; +import type { RefHandle } from '../../@types/scrollable'; +import { useObserverMutationChild } from '../../hooks/useObserverMutationChild'; +import { useScrollSmooth } from '../../hooks/useScrollSmooth'; + +interface Props extends HTMLAttributes { + id: string; + stylesParent?: StyleXStyles; + stylesChild?: StyleXStyles; + hasButton?: boolean; +} + +const SCROLLBAR_WIDTH = 12; +const SCROLLBAR_MARGIN = 1; +const SCROLL_SUB = 300; + +const styles = stylex.create({ + container: { + position: 'relative', + display: 'flex', + borderRadius: 4, + borderStyle: 'solid', + borderWidth: 2, + borderColor: 'lightgray', + textAlign: 'center', + width: '40svh', + height: '40svh', + margin: '1em', + overflow: 'auto', + '::-webkit-scrollbar': { + display: 'none', + }, + '::-webkit-scrollbar-thumb': { + display: 'none', + }, + }, + target: { + position: 'absolute', + top: 0, + left: 0, + width: 4, + height: 4, + backgroundColor: 'black', + }, + wrap: { + position: 'sticky', + right: 0, + top: 0, + height: '100%', + }, + wrapNotScrollable: { + display: 'none', + }, + bar: { + position: 'relative', + width: SCROLLBAR_WIDTH, + height: '100%', + backgroundColor: 'darkred', + cursor: 'pointer', + }, + barHasButton: { + height: `calc(100% - ${SCROLLBAR_WIDTH}px * 2)`, + }, + button: (enable: boolean) => ({ + position: 'relative', + width: SCROLLBAR_WIDTH, + height: SCROLLBAR_WIDTH, + backgroundColor: 'darkgoldenrod', + cursor: 'pointer', + pointerEvents: !enable && 'none', + '::after': { + content: '', + position: 'absolute', + top: 0, + left: 0, + width: `calc(${SCROLLBAR_WIDTH}px - ${SCROLLBAR_MARGIN}px * 2)`, + height: `calc(${SCROLLBAR_WIDTH}px - ${SCROLLBAR_MARGIN}px * 2)`, + margin: SCROLLBAR_MARGIN, + backgroundColor: enable && 'yellow', + }, + }), + thumb: (height: number, top: number) => ({ + position: 'absolute', + left: 0, + top, + width: `calc(${SCROLLBAR_WIDTH}px - ${SCROLLBAR_MARGIN}px * 2)`, + height, + backgroundColor: 'red', + cursor: 'pointer', + marginLeft: SCROLLBAR_MARGIN, + }), +}); + +const Component = forwardRef( + ( + { + id, + stylesParent, + stylesChild, + hasButton = false, + children, + ...attrs + }, + ref, + ) => { + const [thumbHeight, setThumbHeight] = useState(0); + const [thumbTop, setThumbTop] = useState(0); + + const isScrollable: boolean = useMemo( + () => thumbHeight > 0, + [thumbHeight], + ); + + const refScrollbar = useRef(null); + + const refTarget = useRef(null); + const refParent = useRef(null); + useImperativeHandle(ref, () => ({ + refTarget: refTarget.current, + refParent: refParent.current, + })); + + const { refChild, heightChild } = useObserverMutationChild(); + + const handleLoad = useCallback(() => { + // 親要素がないならキャンセル + if (!refParent?.current) return; + // 親要素の高さを取得 + const { clientHeight: heightParent } = refParent.current; + // 親要素の高さをもとにボタンの有無でスクロールバーの高さを取得 + const heightScrollbar = hasButton + ? heightParent - SCROLLBAR_WIDTH * 2 + : heightParent; + // 親要素と子要素の高さの比を取得 + const ratio = heightParent / heightChild; + // 子要素が親要素より小さくないならキャンセル + if (ratio >= 1) return; + // 比をもとにスクロールバーの高さを算出 + const newScrollbarHeight = Math.max(heightScrollbar * ratio, 32); + // スクロールバーの高さを設定 + setThumbHeight(newScrollbarHeight); + }, [heightChild, hasButton]); + + useEffect(() => { + handleLoad(); + }, [handleLoad]); + + const handleScrollParent = useCallback(() => { + if (!(refParent?.current && refScrollbar.current)) return; + + const { clientHeight: heightParent, scrollTop } = refParent.current; + const { clientHeight: heightScrollbar } = refScrollbar.current; + + const scrollRatio = scrollTop / (heightChild - heightParent); + const newScrollbarPosY = + scrollRatio * (heightScrollbar - thumbHeight); + + setThumbTop(newScrollbarPosY); + }, [heightChild, thumbHeight]); + + const [isDraggingThumb, setIsDraggingThumb] = useState(false); + const [thumbDragOffsetY, setThumbDragOffsetY] = useState(0); + + // Thumb + const handleThumbPointerDown = useCallback( + (e: PointerEvent) => { + e.stopPropagation(); + setIsDraggingThumb(true); + e.currentTarget.setPointerCapture(e.pointerId); + + const rect = e.currentTarget.getBoundingClientRect(); + const offsetY = e.clientY - rect.top; + setThumbDragOffsetY(offsetY); + }, + [], + ); + const handleThumbPointerUp = useCallback( + (e: PointerEvent) => { + setIsDraggingThumb(false); + e.currentTarget.releasePointerCapture(e.pointerId); + }, + [], + ); + const handleThumbPointerMove = useCallback( + (e: PointerEvent) => { + if ( + !( + isDraggingThumb && + refParent?.current && + refScrollbar.current + ) + ) + return; + + const { clientHeight: heightParent } = refParent.current; + const { clientHeight: heightScrollbar } = refScrollbar.current; + + const rect = refScrollbar.current.getBoundingClientRect(); + const offsetY = e.clientY - rect.top; + + const maxScrollbarPosY = heightScrollbar - thumbHeight; + const newScrollbarPosY = Math.min( + Math.max(offsetY - thumbDragOffsetY, 0), + maxScrollbarPosY, + ); + + const scrollRatio = newScrollbarPosY / maxScrollbarPosY; + const newScrollTop = scrollRatio * (heightChild - heightParent); + + setThumbTop(newScrollbarPosY); + refParent.current.scrollTop = newScrollTop; + }, + [heightChild, isDraggingThumb, thumbHeight, thumbDragOffsetY], + ); + + // スクロールバー + const [isDraggingScrollbar, setIsDraggingScrollbar] = + useState(false); + + const handleScrollbarPointerMove = useCallback( + (e: PointerEvent) => { + if ( + !( + isDraggingScrollbar && + refParent?.current && + refScrollbar.current + ) + ) + return; + + const { clientHeight: heightParent } = refParent.current; + const { clientHeight: heightScrollbar } = refScrollbar.current; + + const rect = refScrollbar.current.getBoundingClientRect(); + const offsetY = e.clientY - rect.top; + + const maxScrollbarPosY = heightScrollbar - thumbHeight; + const newScrollbarPosY = Math.min( + Math.max(offsetY - thumbHeight / 2, 0), + maxScrollbarPosY, + ); + + const scrollRatio = newScrollbarPosY / maxScrollbarPosY; + const newScrollTop = scrollRatio * (heightChild - heightParent); + + setThumbTop(newScrollbarPosY); + refParent.current.scrollTop = newScrollTop; + }, + [heightChild, isDraggingScrollbar, thumbHeight], + ); + const handleScrollbarPointerDown = useCallback( + (e: PointerEvent) => { + e.currentTarget.setPointerCapture(e.pointerId); + setIsDraggingScrollbar(true); + if (!(refParent?.current && refScrollbar.current)) return; + + const { clientHeight: heightParent } = refParent.current; + const { clientHeight: heightScrollbar } = refScrollbar.current; + + const rect = refScrollbar.current.getBoundingClientRect(); + const offsetY = e.clientY - rect.top; + + const maxScrollbarPosY = heightScrollbar - thumbHeight; + const newScrollbarPosY = Math.min( + Math.max(offsetY - thumbHeight / 2, 0), + maxScrollbarPosY, + ); + + const scrollRatio = newScrollbarPosY / maxScrollbarPosY; + const newScrollTop = scrollRatio * (heightChild - heightParent); + + setThumbTop(newScrollbarPosY); + refParent.current.scrollTop = newScrollTop; + }, + [heightChild, thumbHeight], + ); + const handleScrollbarPointerUp = useCallback( + (e: PointerEvent) => { + setIsDraggingScrollbar(false); + e.currentTarget.releasePointerCapture(e.pointerId); + }, + [], + ); + + const { scrollSmooth } = useScrollSmooth(); + + const scrollSmoothCommon = useCallback( + (newTop: number) => { + // + if (!refParent?.current) return; + // + scrollSmooth(refTarget.current, newTop, refParent.current.id); + }, + [scrollSmooth], + ); + const handleClickButtonUp = useCallback(() => { + // + if (!refParent?.current) return; + // + const newTop = Math.max( + refParent.current.scrollTop - SCROLL_SUB, + 0, + ); + // + scrollSmoothCommon(newTop); + }, [scrollSmoothCommon]); + const handleClickButtonDown = useCallback(() => { + // + if (!refParent?.current) return; + // + const newTop = Math.min( + refParent.current.scrollTop + SCROLL_SUB, + refParent.current.clientHeight, + ); + // + scrollSmoothCommon(newTop); + }, [scrollSmoothCommon]); + + const [canUp, setCanUp] = useState(false); + const [canDown, setCanDown] = useState(true); + + useEffect(() => { + if (!refScrollbar.current) return; + const { clientHeight: heightScrollbar } = refScrollbar.current; + setCanUp(thumbTop > 0); + setCanDown(thumbTop < heightScrollbar - thumbHeight - 1); + }, [thumbHeight, thumbTop]); + + return ( +
+ {/* スクロール対象 */} +
+ {/* ターゲット */} +
+ {/* 子要素 */} + {children} +
+ {/* スクロールバー */} +
+ {/* 上ボタン */} +
+ {/* バー本体 */} +
+ {/* thumb */} +
+
+ {/* 下ボタン */} +
+
+
+ ); + }, +); + +export const DivScrollbar = memo(Component); diff --git a/src/hooks/useEventKeydown.ts b/src/hooks/useEventKeydown.ts new file mode 100644 index 0000000..5fd8ef9 --- /dev/null +++ b/src/hooks/useEventKeydown.ts @@ -0,0 +1,47 @@ +import { type RefObject, useEffect } from 'react'; + +const KEYDOWN_DURATION = 300; +const KEYDOWN_IGNORE_TAGNAMES = ['INPUT', 'TEXTAREA']; +let keydownTimestamp = 0; + +interface Trigger { + key: string; + callback: () => void; +} + +export const useEventKeydown = ( + triggers: Trigger[], + ref?: RefObject, +) => { + // + const handleKeydown = (e: KeyboardEvent) => { + if (e.isComposing) return; // 変換中ならキャンセル + const tagName = (e.target as HTMLElement).tagName; // 入力対象のタグ名を取得 + if (KEYDOWN_IGNORE_TAGNAMES.includes(tagName)) return; // タグ名が無視リストにあるならキャンセル + if (triggers.map(t => t.key).includes(e.key)) e.preventDefault(); // デフォルトの動作を無効化 + const sub = e.timeStamp - keydownTimestamp; // 時間間隔を取得 + if (sub < KEYDOWN_DURATION) return; // 間隔が一定以下ならキャンセル + keydownTimestamp = e.timeStamp; // 最終実行時間を更新 + // 入力に応じたコールバックを実行 + for (const trigger of triggers) { + if (trigger.key === e.key) { + trigger.callback(); + } + } + }; + // + useEffect(() => { + if (ref?.current) { + ref.current.addEventListener('keydown', handleKeydown, true); + } else { + window.addEventListener('keydown', handleKeydown, true); + } + return () => { + if (ref?.current) { + ref.current.removeEventListener('keydown', handleKeydown, true); + } else { + window.removeEventListener('keydown', handleKeydown, true); + } + }; + }, [ref, handleKeydown]); +}; diff --git a/src/hooks/useEventWheel.ts b/src/hooks/useEventWheel.ts new file mode 100644 index 0000000..59af462 --- /dev/null +++ b/src/hooks/useEventWheel.ts @@ -0,0 +1,36 @@ +import { type RefObject, useEffect } from 'react'; + +const WHEEL_DURATION = 300; +let wheelTimeoutid: number | null = null; + +export const useEventWheel = ( + ref: RefObject, + callbackWheel: () => void, + callbackEnd: () => void, +) => { + // + const handleWheel = (e: WheelEvent) => { + // 要素がないならキャンセル + if (!ref.current) return; + // 実行中の処理を実行 + callbackWheel(); + // デフォルトのスクロールを無効化 + e.preventDefault(); + // + ref.current.scrollLeft += e.deltaX; + ref.current.scrollTop += e.deltaY; + // タイムアウトを設定済みならクリア + if (wheelTimeoutid) clearTimeout(wheelTimeoutid); + // タイムアウトを設定 + wheelTimeoutid = setTimeout(callbackEnd, WHEEL_DURATION); + }; + // + useEffect(() => { + ref.current?.addEventListener('wheel', handleWheel, { + passive: false, + }); + return () => { + ref.current?.removeEventListener('wheel', handleWheel); + }; + }, [ref, handleWheel]); +}; diff --git a/src/pages/scroll/Scroll.tsx b/src/pages/scroll/Scroll.tsx index ba2d6e7..e825ff6 100644 --- a/src/pages/scroll/Scroll.tsx +++ b/src/pages/scroll/Scroll.tsx @@ -6,6 +6,7 @@ import { H1 } from '../../components/heading/H1'; import { useScrollSmooth } from '../../hooks/useScrollSmooth'; import { ScrollCss } from './ScrollCss'; import { ScrollDiv } from './ScrollDiv'; +import { Ultimate } from './Ultimate'; const styles = stylex.create({ wrap: { @@ -57,6 +58,8 @@ const Component: FC = () => { {/* タイトル */}

Scroll

+ {/* Ultimate */} + {/* Divスクロール */} {/* CSSスクロール */} diff --git a/src/pages/scroll/ScrollDiv.tsx b/src/pages/scroll/ScrollDiv.tsx index c3d5bba..9ec8a3e 100644 --- a/src/pages/scroll/ScrollDiv.tsx +++ b/src/pages/scroll/ScrollDiv.tsx @@ -3,7 +3,7 @@ import { type FC, memo, useCallback, useRef } from 'react'; import type { RefHandle } from '../../@types/scrollable'; import { ButtonVite } from '../../components/button/ButtonVite'; import { DivCustom } from '../../components/div/DivCustom'; -import { DivScrollable } from '../../components/div/DivScrollable'; +import { DivScrollbar } from '../../components/div/DivScrollbar'; import { H2 } from '../../components/heading/H2'; import { useScrollSmooth } from '../../hooks/useScrollSmooth'; import { stylesCommon } from './styles'; @@ -73,7 +73,7 @@ const Component: FC = () => {

With JS

{/* 通常スクロール */} - { Normal Scroll - + {/* スムーススクロール */} - { Smooth Scroll - +
); }; diff --git a/src/pages/scroll/Ultimate.tsx b/src/pages/scroll/Ultimate.tsx new file mode 100644 index 0000000..cbd6cc4 --- /dev/null +++ b/src/pages/scroll/Ultimate.tsx @@ -0,0 +1,62 @@ +import stylex from '@stylexjs/stylex'; +import { type FC, memo } from 'react'; +import { DivCustom } from '../../components/div/DivCustom'; +import { DivScrollable } from '../../components/div/DivScrollable'; +import { H2 } from '../../components/heading/H2'; +import { stylesCommon } from './styles'; + +const styles = stylex.create({ + wrapper: { + width: '100dvw', + height: '100dvh', + position: 'relative', + }, + parent: { + position: 'absolute', + left: '50%', + top: '50%', + transform: 'translate(-50%, -50%)', + width: '80dvw', + height: '80dvh', + }, + scroll: { + display: 'grid', + gridTemplateColumns: 'repeat(3, 1fr)', + gridTemplateRows: 'repeat(3, 1fr)', + '::-webkit-scrollbar': { + display: 'none', + }, + }, + child: { + width: '80dvw', + height: '80dvh', + background: 'radial-gradient(#ff000033, #0000ff33)', + }, +}); + +const Component: FC = () => { + return ( + +

Ultimate

+ {/* */} + +
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
9
+
+
+ ); +}; + +export const Ultimate = memo(Component); diff --git a/src/utils/easeInOutQuad.ts b/src/utils/easeInOutQuad.ts new file mode 100644 index 0000000..5f51d02 --- /dev/null +++ b/src/utils/easeInOutQuad.ts @@ -0,0 +1,4 @@ +type Type = (t: number) => number; +export const easeInOutQuad: Type = t => { + return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; +}; diff --git a/src/utils/getChildLengthes.ts b/src/utils/getChildLengthes.ts new file mode 100644 index 0000000..3564044 --- /dev/null +++ b/src/utils/getChildLengthes.ts @@ -0,0 +1,7 @@ +export const getChildWidthes = (elem: HTMLDivElement) => { + const children = Array.from(elem.children); + return { + childWidthes: children.map(child => child.clientWidth), + childHeights: children.map(child => child.clientHeight), + }; +}; diff --git a/src/utils/getChildOffsets.ts b/src/utils/getChildOffsets.ts new file mode 100644 index 0000000..5869295 --- /dev/null +++ b/src/utils/getChildOffsets.ts @@ -0,0 +1,13 @@ +import type { Coordinate } from '../@types'; + +type Type = (elem: HTMLDivElement) => Coordinate[]; + +export const getChildOffsets: Type = (elem: HTMLDivElement) => { + const rectParent = elem.getBoundingClientRect(); + const children = Array.from(elem.children); + const rectChildren = children.map(c => c.getBoundingClientRect()); + return rectChildren.map(c => ({ + x: c.left - rectParent.left + elem.scrollLeft, + y: c.top - rectParent.top + +elem.scrollTop, + })); +}; diff --git a/src/utils/getClosestCoordinate.ts b/src/utils/getClosestCoordinate.ts new file mode 100644 index 0000000..cb03ff1 --- /dev/null +++ b/src/utils/getClosestCoordinate.ts @@ -0,0 +1,15 @@ +import type { Coordinate } from '../@types'; + +export const getClosestCoordinate = ( + target: Coordinate, + samples: Coordinate[], +) => + samples.reduce((acc, curr) => { + const currDistance = Math.sqrt( + (curr.x - target.x) ** 2 + (curr.y - target.y) ** 2, + ); + const accDistance = Math.sqrt( + (acc.x - target.x) ** 2 + (acc.y - target.y) ** 2, + ); + return currDistance < accDistance ? curr : acc; + }, samples[0]); diff --git a/src/utils/scrollAnimate.ts b/src/utils/scrollAnimate.ts new file mode 100644 index 0000000..05d420a --- /dev/null +++ b/src/utils/scrollAnimate.ts @@ -0,0 +1,49 @@ +import type { Coordinate } from '../@types'; +import { easeInOutQuad } from './easeInOutQuad'; + +const SCROLL_ANIMATE_DURATION = 250; + +type Callback = () => void; + +export const scrollAnimate = () => { + // アニメーションのフレームID + let animationFrameId: number | null = null; + + const cancelAnimateScroll = (callback?: Callback) => { + if (animationFrameId === null) return; + cancelAnimationFrame(animationFrameId); + animationFrameId === null; + callback?.(); + }; + + const animateScroll = ( + elem: HTMLElement, + target: Coordinate, + callback?: Callback, + ) => { + const start = { x: elem.scrollLeft, y: elem.scrollTop }; + const distance = { x: target.x - start.x, y: target.y - start.y }; + if (distance.x === 0 && distance.y === 0) return callback?.(); + // タイムスタンプを定義 + let t: number | null = null; + // アニメーションを定義 + const step = (timestamp: number) => { + if (!t) t = timestamp; + const progress = timestamp - t; + const percentage = Math.min(progress / SCROLL_ANIMATE_DURATION, 1); + const easePercentage = easeInOutQuad(percentage); + elem.scrollLeft = start.x + distance.x * easePercentage; + elem.scrollTop = start.y + distance.y * easePercentage; + // 進捗率で分岐 + if (progress < SCROLL_ANIMATE_DURATION) { + animationFrameId = window.requestAnimationFrame(step); + } else { + cancelAnimateScroll(callback); + } + }; + // アニメーションを実行 + window.requestAnimationFrame(step); + }; + + return { cancelAnimateScroll, animateScroll }; +}; diff --git a/src/utils/snapPointer.ts b/src/utils/snapPointer.ts new file mode 100644 index 0000000..2db1f2e --- /dev/null +++ b/src/utils/snapPointer.ts @@ -0,0 +1,40 @@ +import type { PointerEvent } from 'react'; +import type { Coordinate } from '../@types'; +import { getChildOffsets } from './getChildOffsets'; +import { getClosestCoordinate } from './getClosestCoordinate'; +import { scrollAnimate } from './scrollAnimate'; + +export const snapPointer = ( + e: PointerEvent, + dragScroll: Coordinate, + dragTimestamp: number, + isAnimate: boolean | undefined, +) => { + // + const target = e.target as HTMLDivElement; + // + const { scrollLeft, scrollTop, clientWidth, clientHeight } = target; + const subTimestamp = e.timeStamp - dragTimestamp; + const subCoordinate: Coordinate = { + x: scrollLeft - dragScroll.x, + y: scrollTop - dragScroll.y, + }; + const adjuster: Coordinate = { + x: ((subCoordinate.x / subTimestamp) * clientWidth) / 5000, + y: ((subCoordinate.y / subTimestamp) * clientHeight) / 5000, + }; + const adjusted: Coordinate = { + x: scrollLeft + adjuster.x, + y: scrollTop + adjuster.y, + }; + const offsets: Coordinate[] = getChildOffsets(target); + const closest = getClosestCoordinate(adjusted, offsets); + // フラグによりアニメーションか瞬間移動か分岐 + if (isAnimate) { + const { animateScroll } = scrollAnimate(); + animateScroll(target, closest); + } else { + target.scrollLeft = closest.x; + target.scrollTop = closest.y; + } +}; diff --git a/src/utils/snapWheel.ts b/src/utils/snapWheel.ts new file mode 100644 index 0000000..abc92e7 --- /dev/null +++ b/src/utils/snapWheel.ts @@ -0,0 +1,22 @@ +import type { Coordinate } from '../@types'; +import { getChildOffsets } from './getChildOffsets'; +import { getClosestCoordinate } from './getClosestCoordinate'; +import { scrollAnimate } from './scrollAnimate'; + +export const snapWheel = ( + target: HTMLDivElement, + isAnimate: boolean | undefined, +) => { + // + const { scrollLeft: x, scrollTop: y } = target; + const offsets: Coordinate[] = getChildOffsets(target); + const closest = getClosestCoordinate({ x, y }, offsets); + // フラグによりアニメーションか瞬間移動か分岐 + if (isAnimate) { + const { animateScroll } = scrollAnimate(); + animateScroll(target, closest); + } else { + target.scrollLeft = closest.x; + target.scrollTop = closest.y; + } +};