Move "core" folder out of "features" into "base"
This commit is contained in:
203
src/base/hooks/useAutomaticScrolling.ts
Normal file
203
src/base/hooks/useAutomaticScrolling.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ScrollDirection } from '@/base/Base.types.ts';
|
||||
import { useResizeObserver } from '@/base/hooks/useResizeObserver.tsx';
|
||||
|
||||
// "scrollBy" and "scrollTo" both require at least a change of 1px, otherwise, nothing happens
|
||||
const MIN_SCROLL_AMOUNT_PX = 1;
|
||||
const getScrollAmount = (
|
||||
amountPerMs: number,
|
||||
speedMs: number,
|
||||
isRTL: boolean = false,
|
||||
invert: boolean = false,
|
||||
): number => {
|
||||
const pxPerMs = Math.max(amountPerMs * speedMs, MIN_SCROLL_AMOUNT_PX);
|
||||
const pxPerMsReadingMode = isRTL ? -pxPerMs : pxPerMs;
|
||||
const pxPerMsInverted = invert ? pxPerMsReadingMode * -1 : pxPerMsReadingMode;
|
||||
|
||||
return pxPerMsInverted;
|
||||
};
|
||||
|
||||
const getPxPerMs = (size: number, scrollAmountPercentage: number, scrollSpeedMs: number): number =>
|
||||
(size * (scrollAmountPercentage / 100)) / scrollSpeedMs;
|
||||
|
||||
function handleScrolling(
|
||||
smooth: boolean,
|
||||
scrollSpeedMs: number,
|
||||
setScrollTriggerId: (id: NodeJS.Timeout | number, type: 'interval' | 'animationFrame') => void,
|
||||
performScroll: (elapsedTime: number) => void,
|
||||
clearScrollTriggers: () => void,
|
||||
): void {
|
||||
clearScrollTriggers();
|
||||
|
||||
if (!smooth) {
|
||||
setScrollTriggerId(
|
||||
setInterval(() => {
|
||||
performScroll(scrollSpeedMs);
|
||||
}, scrollSpeedMs),
|
||||
'interval',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let startTime: number;
|
||||
const triggerScroll = (timestamp: DOMHighResTimeStamp) => {
|
||||
const elapsedTime = timestamp - (startTime ?? timestamp);
|
||||
startTime = timestamp;
|
||||
|
||||
performScroll(elapsedTime);
|
||||
setScrollTriggerId(requestAnimationFrame(triggerScroll), 'animationFrame');
|
||||
};
|
||||
|
||||
setScrollTriggerId(requestAnimationFrame(triggerScroll), 'animationFrame');
|
||||
}
|
||||
|
||||
export const useAutomaticScrolling = (
|
||||
refOrCallback: MutableRefObject<HTMLElement | null> | (() => void) | undefined,
|
||||
scrollPerSecond: number,
|
||||
scrollDirection: Exclude<ScrollDirection, ScrollDirection.XY> = ScrollDirection.Y,
|
||||
scrollAmountPercentage: number = 100,
|
||||
invert: boolean = false,
|
||||
smooth: boolean = false,
|
||||
): {
|
||||
isActive: boolean;
|
||||
isPaused: boolean;
|
||||
start: () => void;
|
||||
cancel: () => void;
|
||||
toggleActive: () => void;
|
||||
pause: () => void;
|
||||
resume: () => void;
|
||||
} => {
|
||||
const isCallback = typeof refOrCallback === 'function';
|
||||
|
||||
const elementStyle = useRef<CSSStyleDeclaration>(undefined);
|
||||
const scrollTriggerTimer = useRef<NodeJS.Timeout>(undefined);
|
||||
const scrollTriggerAnimationFrameId = useRef(-1);
|
||||
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
|
||||
// scroll amount is based on the screen dimensions, thus, the hook needs to update in case they change
|
||||
const [screenDimensions, setScreenDimensions] = useState({ width: window.innerWidth, height: window.innerHeight });
|
||||
useResizeObserver(
|
||||
document.documentElement,
|
||||
useCallback(() => {
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
|
||||
if (screenDimensions.width !== width || screenDimensions.height !== height) {
|
||||
setScreenDimensions({ width, height });
|
||||
}
|
||||
}, []),
|
||||
);
|
||||
|
||||
const clearScrollTriggers = useCallback(() => {
|
||||
clearTimeout(scrollTriggerTimer.current);
|
||||
cancelAnimationFrame(scrollTriggerAnimationFrameId.current);
|
||||
}, []);
|
||||
|
||||
const start = useCallback(() => {
|
||||
setIsActive(true);
|
||||
}, []);
|
||||
const cancel = useCallback(() => {
|
||||
setIsActive(false);
|
||||
setIsPaused(false);
|
||||
clearScrollTriggers();
|
||||
}, []);
|
||||
const toggleActive = useCallback(() => {
|
||||
if (isActive) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
start();
|
||||
}, [isActive]);
|
||||
const pause = useCallback(() => {
|
||||
setIsPaused(true);
|
||||
}, []);
|
||||
const resume = useCallback(() => {
|
||||
setIsPaused(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!refOrCallback || !isActive) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (isPaused) {
|
||||
clearScrollTriggers();
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const scrollSpeedMs = scrollPerSecond * 1000;
|
||||
|
||||
if (isCallback) {
|
||||
handleScrolling(
|
||||
false,
|
||||
scrollSpeedMs,
|
||||
(id) => {
|
||||
scrollTriggerTimer.current = id as NodeJS.Timeout;
|
||||
},
|
||||
refOrCallback,
|
||||
clearScrollTriggers,
|
||||
);
|
||||
return () => clearScrollTriggers();
|
||||
}
|
||||
|
||||
const element = refOrCallback.current;
|
||||
if (!element) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (!elementStyle.current) {
|
||||
elementStyle.current = getComputedStyle(element);
|
||||
}
|
||||
|
||||
const isRTL = elementStyle.current.direction === 'rtl';
|
||||
const handleScrollX = scrollDirection !== ScrollDirection.Y;
|
||||
const handleScrollY = scrollDirection !== ScrollDirection.X;
|
||||
|
||||
const pxPerMsX = getPxPerMs(window.innerWidth, scrollAmountPercentage, scrollSpeedMs);
|
||||
const pxPerMsY = getPxPerMs(window.innerHeight, scrollAmountPercentage, scrollSpeedMs);
|
||||
|
||||
handleScrolling(
|
||||
smooth,
|
||||
scrollSpeedMs,
|
||||
(id, type) => {
|
||||
switch (type) {
|
||||
case 'interval':
|
||||
scrollTriggerTimer.current = id as NodeJS.Timeout;
|
||||
break;
|
||||
case 'animationFrame':
|
||||
scrollTriggerAnimationFrameId.current = id as number;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unexpected "type" (${type})`);
|
||||
}
|
||||
},
|
||||
(elapsedTime) => {
|
||||
element.scrollBy({
|
||||
top: Number(handleScrollY) * getScrollAmount(pxPerMsY, elapsedTime, false, invert),
|
||||
left: Number(handleScrollX) * getScrollAmount(pxPerMsX, elapsedTime, isRTL, invert),
|
||||
// arg "smooth" triggers the interval so fast that using "behavior smooth" doesn't look smooth and also slows down the scrolling
|
||||
behavior: smooth ? undefined : 'smooth',
|
||||
});
|
||||
},
|
||||
clearScrollTriggers,
|
||||
);
|
||||
|
||||
return () => clearScrollTriggers();
|
||||
}, [refOrCallback, scrollPerSecond, scrollAmountPercentage, screenDimensions, isActive, isPaused, invert, smooth]);
|
||||
|
||||
return useMemo(
|
||||
() => ({ isActive, isPaused, start, cancel, toggleActive, pause, resume }),
|
||||
[isActive, isPaused, start, cancel, toggleActive, pause, resume],
|
||||
);
|
||||
};
|
||||
34
src/base/hooks/useBackButton.ts
Normal file
34
src/base/hooks/useBackButton.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useCallback } from 'react';
|
||||
import { AppRoutes } from '@/base/AppRoute.constants.ts';
|
||||
import { useAppPageHistoryContext } from '@/base/contexts/AppPageHistoryContext.tsx';
|
||||
|
||||
const PAGES_TO_IGNORE: readonly RegExp[] = [/\/manga\/[0-9]+\/chapter\/[0-9]+/g];
|
||||
|
||||
export const useBackButton = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const history = useAppPageHistoryContext();
|
||||
|
||||
return useCallback(() => {
|
||||
const isHistoryEmpty = !history.length;
|
||||
const isLastPageInHistoryCurrentPage = history.length === 1 && history[0] === location.pathname;
|
||||
const ignorePreviousPage = history.length && PAGES_TO_IGNORE.some((page) => !!history.slice(-2)[0].match(page));
|
||||
|
||||
const canNavigateBack = !ignorePreviousPage && !isHistoryEmpty && !isLastPageInHistoryCurrentPage;
|
||||
if (canNavigateBack) {
|
||||
navigate(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(AppRoutes.library.path());
|
||||
}, [history, location.pathname]);
|
||||
};
|
||||
25
src/base/hooks/useDebounce.ts
Normal file
25
src/base/hooks/useDebounce.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export const useDebounce = <Value>(value: Value, delay: number): Value => {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
};
|
||||
53
src/base/hooks/useHistory.ts
Normal file
53
src/base/hooks/useHistory.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { NavigationType, useLocation, useNavigationType } from 'react-router-dom';
|
||||
|
||||
const MAX_DEPTH = 50;
|
||||
|
||||
export const useHistory = () => {
|
||||
const location = useLocation();
|
||||
const navigationType = useNavigationType();
|
||||
|
||||
const [history, setHistory] = useState<string[]>([location.pathname]);
|
||||
|
||||
const updateHistory = useCallback((newHistory: string[]) => {
|
||||
// prevent the history from getting too large (only relevant in case the app never gets reloaded (e.g. browser F5,
|
||||
// electron window gets closed))
|
||||
// theoretically the history should be empty for the "base" pages (e.g. library, updates, ...), but since the browser
|
||||
// navigation is used, opening another base page pushes this page to this history, as if it had a different depth
|
||||
// than the current page (expected history: library -> manga -> reader,
|
||||
// possible history: library -> updates -> settings -> library -> manga -> reader)
|
||||
setHistory(newHistory.slice(-MAX_DEPTH));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const isLastPageInHistory = location.key === 'default';
|
||||
const ignoreInitialPop = isLastPageInHistory && history.length === 1;
|
||||
if (ignoreInitialPop) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (navigationType) {
|
||||
case NavigationType.Pop:
|
||||
updateHistory([...history.slice(0, -1)]);
|
||||
break;
|
||||
case NavigationType.Push:
|
||||
updateHistory([...history, location.pathname + location.search]);
|
||||
break;
|
||||
case NavigationType.Replace:
|
||||
updateHistory([...history.slice(0, -1), location.pathname + location.search]);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unexpected NavigationType "${navigationType}"`);
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
return history;
|
||||
};
|
||||
51
src/base/hooks/useIntersectionObserver.tsx
Normal file
51
src/base/hooks/useIntersectionObserver.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { RefObject, useLayoutEffect, useState } from 'react';
|
||||
|
||||
export const useIntersectionObserver = (
|
||||
ref: RefObject<HTMLElement | null> | HTMLElement | undefined | null,
|
||||
callback: IntersectionObserverCallback,
|
||||
{
|
||||
ignoreInitialObserve = false,
|
||||
root,
|
||||
rootMargin,
|
||||
threshold,
|
||||
}: IntersectionObserverInit & { ignoreInitialObserve?: boolean } = {},
|
||||
): (() => void) => {
|
||||
const [disconnect, setDisconnect] = useState<() => void>(() => {});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = ref instanceof HTMLElement ? ref : ref?.current;
|
||||
|
||||
if (!element) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
// gets immediately observed once on initial render
|
||||
let isInitialObserve = true;
|
||||
const intersectionObserver = new IntersectionObserver(
|
||||
(...args) => {
|
||||
if (ignoreInitialObserve && isInitialObserve) {
|
||||
isInitialObserve = false;
|
||||
return;
|
||||
}
|
||||
|
||||
callback(...args);
|
||||
},
|
||||
{ root, rootMargin, threshold },
|
||||
);
|
||||
intersectionObserver.observe(element);
|
||||
|
||||
setDisconnect(() => () => intersectionObserver.disconnect());
|
||||
|
||||
return () => intersectionObserver.disconnect();
|
||||
}, [ref, callback, ignoreInitialObserve, root, rootMargin, threshold]);
|
||||
|
||||
return disconnect;
|
||||
};
|
||||
228
src/base/hooks/useMouseDragScroll.tsx
Normal file
228
src/base/hooks/useMouseDragScroll.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* credit: (06.12.2024 - 00:11)
|
||||
* MobileLikeScroller
|
||||
* https://github.com/utsb-fmm/MobileLikeScroller/blob/main/mobilelikescroller.js
|
||||
*/
|
||||
|
||||
import { MutableRefObject, useEffect, useRef, useState } from 'react';
|
||||
import { ScrollDirection } from '@/base/Base.types.ts';
|
||||
import { coerceIn } from '@/lib/HelperFunctions.ts';
|
||||
|
||||
type Positions = [OldestPos: number, SecondOldestPos: number, LatestPos: number];
|
||||
type ClickTimes = [OldestTime: number, SecondOldestTime: number, LatestTime: number];
|
||||
|
||||
const OLDEST = 2;
|
||||
const SECOND_OLDEST = 1;
|
||||
const LATEST = 0;
|
||||
|
||||
const X = 0;
|
||||
const Y = 1;
|
||||
|
||||
export const useMouseDragScroll = (
|
||||
ref?: MutableRefObject<HTMLElement | null>,
|
||||
scrollDirection: ScrollDirection = ScrollDirection.XY,
|
||||
) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const elementStyle = useRef<CSSStyleDeclaration>(undefined);
|
||||
const previousClickPosX = useRef<Positions>([0, 0, 0]);
|
||||
const previousClickPosY = useRef<Positions>([0, 0, 0]);
|
||||
const previousClickTime = useRef<ClickTimes>([0, 0, 0]);
|
||||
const scrollAtT0 = useRef<[ScrollLeft: number, ScrollTop: number]>([0, 0]);
|
||||
const inertiaTimeInterval = useRef<NodeJS.Timeout>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref?.current;
|
||||
|
||||
if (!element) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const isRTL = () => {
|
||||
if (!elementStyle.current) {
|
||||
elementStyle.current = getComputedStyle(element);
|
||||
}
|
||||
|
||||
const isRTLDirection = elementStyle.current.direction === 'rtl';
|
||||
const isFlexDirectionReversed = elementStyle.current.flexDirection === 'row-reverse';
|
||||
|
||||
return isRTLDirection || (!isRTLDirection && isFlexDirectionReversed);
|
||||
};
|
||||
|
||||
const isTopReversed = () => {
|
||||
if (!elementStyle.current) {
|
||||
elementStyle.current = getComputedStyle(element);
|
||||
}
|
||||
|
||||
return elementStyle.current.flexDirection === 'column-reverse';
|
||||
};
|
||||
|
||||
const handleScrollX = scrollDirection !== ScrollDirection.Y;
|
||||
const handleScrollY = scrollDirection !== ScrollDirection.X;
|
||||
|
||||
const clearInertiaInterval = () => clearInterval(inertiaTimeInterval.current);
|
||||
|
||||
const inertiaMove = () => {
|
||||
const calcVelocity = (positions: Positions, clickTimes: ClickTimes, size: number): number =>
|
||||
(((positions[LATEST] - positions[OLDEST]) / (clickTimes[LATEST] - clickTimes[OLDEST])) * 1000) / size;
|
||||
|
||||
const v0 = [
|
||||
handleScrollX
|
||||
? calcVelocity(previousClickPosX.current, previousClickTime.current, element.clientWidth)
|
||||
: 0,
|
||||
handleScrollY
|
||||
? calcVelocity(previousClickPosY.current, previousClickTime.current, element.clientHeight)
|
||||
: 0,
|
||||
];
|
||||
|
||||
const a0V = (() => {
|
||||
if (handleScrollX && handleScrollY) {
|
||||
return Math.sqrt(v0[X] ** 2 + v0[Y] ** 2);
|
||||
}
|
||||
|
||||
if (handleScrollY) {
|
||||
return Math.abs(v0[Y]);
|
||||
}
|
||||
|
||||
return Math.abs(v0[X]);
|
||||
})();
|
||||
const unitVector = [v0[X] / a0V, v0[Y] / a0V];
|
||||
const a0VCoerced = coerceIn(1.2 * a0V, -12, 12);
|
||||
|
||||
const t = (Date.now() - previousClickTime.current[LATEST]) / 1000;
|
||||
const v =
|
||||
a0VCoerced - 14.278 * t + (75.24 * t ** 2) / a0VCoerced - (149.72 * t ** 3) / a0VCoerced / a0VCoerced;
|
||||
|
||||
const isValidVelocity = a0VCoerced !== 0 && v > 0 && !Number.isNaN(a0VCoerced);
|
||||
if (!isValidVelocity) {
|
||||
clearInertiaInterval();
|
||||
return;
|
||||
}
|
||||
|
||||
const calcDelta = (size: number, unit: number): number =>
|
||||
size *
|
||||
unit *
|
||||
(a0VCoerced * t -
|
||||
7.1397 * t ** 2 +
|
||||
(25.08 * t ** 3) / a0VCoerced -
|
||||
(37.43 * t ** 4) / a0VCoerced / a0VCoerced);
|
||||
|
||||
const delta = [
|
||||
calcDelta(element.clientWidth, unitVector[X]),
|
||||
calcDelta(element.clientHeight, unitVector[Y]),
|
||||
];
|
||||
const maxScrollPos = [
|
||||
element.scrollWidth - element.clientWidth,
|
||||
element.scrollHeight - element.clientHeight,
|
||||
];
|
||||
const newScrollPos = [
|
||||
coerceIn(scrollAtT0.current[X] - delta[X], isRTL() ? -maxScrollPos[X] : 0, maxScrollPos[X]),
|
||||
coerceIn(scrollAtT0.current[Y] - delta[Y], isTopReversed() ? -maxScrollPos[Y] : 0, maxScrollPos[Y]),
|
||||
];
|
||||
|
||||
const isScrollXPossible = newScrollPos[X] !== 0 || newScrollPos[X] !== maxScrollPos[X];
|
||||
const isScrollYPossible = newScrollPos[Y] !== 0 || newScrollPos[Y] !== maxScrollPos[Y];
|
||||
const isScrollPossible = isScrollXPossible || isScrollYPossible;
|
||||
if (!isScrollPossible) {
|
||||
clearInertiaInterval();
|
||||
}
|
||||
|
||||
if (handleScrollX) {
|
||||
element.scrollLeft = newScrollPos[X];
|
||||
}
|
||||
|
||||
if (handleScrollY) {
|
||||
element.scrollTop = newScrollPos[Y];
|
||||
}
|
||||
};
|
||||
|
||||
let isHandlingMouseMoveEvents = false;
|
||||
const shouldStartHandlingMouseMoveEvents = (e: MouseEvent) => {
|
||||
if (isHandlingMouseMoveEvents) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasScrollBar = [
|
||||
element.clientHeight < element.scrollHeight,
|
||||
element.clientWidth < element.scrollWidth,
|
||||
];
|
||||
const didPosChange = [
|
||||
Math.abs(previousClickPosX.current[LATEST] - e.pageX) > 0,
|
||||
Math.abs(previousClickPosY.current[LATEST] - e.pageY) > 0,
|
||||
];
|
||||
|
||||
return (hasScrollBar[X] && didPosChange[X]) || (hasScrollBar[Y] && didPosChange[Y]);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!shouldStartHandlingMouseMoveEvents(e)) {
|
||||
return;
|
||||
}
|
||||
|
||||
isHandlingMouseMoveEvents = true;
|
||||
setIsDragging(true);
|
||||
|
||||
previousClickPosX.current = [...(previousClickPosX.current.slice(1) as [number, number]), e.pageX];
|
||||
previousClickPosY.current = [...(previousClickPosY.current.slice(1) as [number, number]), e.pageY];
|
||||
previousClickTime.current = [...(previousClickTime.current.slice(1) as [number, number]), Date.now()];
|
||||
|
||||
if (handleScrollX) {
|
||||
element.scrollLeft += previousClickPosX.current[LATEST] - previousClickPosX.current[SECOND_OLDEST];
|
||||
}
|
||||
|
||||
if (handleScrollY) {
|
||||
element.scrollTop += previousClickPosY.current[LATEST] - previousClickPosY.current[SECOND_OLDEST];
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
element.removeEventListener('mousemove', handleMouseMove);
|
||||
element.removeEventListener('mouseup', handleMouseUp);
|
||||
|
||||
// move disabling drag handling to next event loop cycle so that e.g. the resulting mouse click at the end of the dragging can be ignored
|
||||
setTimeout(() => {
|
||||
isHandlingMouseMoveEvents = false;
|
||||
setIsDragging(false);
|
||||
}, 0);
|
||||
|
||||
scrollAtT0.current = [element.scrollLeft, element.scrollTop];
|
||||
inertiaTimeInterval.current = setInterval(inertiaMove, 16);
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
const isLeftMouseButton = e.button === 0;
|
||||
if (!isLeftMouseButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
previousClickPosX.current = [e.pageX, e.pageX, e.pageX];
|
||||
previousClickPosY.current = [e.pageY, e.pageY, e.pageY];
|
||||
previousClickTime.current = [Date.now() - 2, Date.now() - 1, Date.now()];
|
||||
|
||||
element.addEventListener('mousemove', handleMouseMove);
|
||||
element.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
clearInertiaInterval();
|
||||
};
|
||||
|
||||
element.addEventListener('mousedown', handleMouseDown);
|
||||
element.addEventListener('wheel', clearInertiaInterval);
|
||||
return () => {
|
||||
element.removeEventListener('mousedown', handleMouseDown);
|
||||
element.removeEventListener('wheel', clearInertiaInterval);
|
||||
clearInertiaInterval();
|
||||
};
|
||||
}, [ref, scrollDirection]);
|
||||
|
||||
return isDragging;
|
||||
};
|
||||
31
src/base/hooks/usePersistedValue.tsx
Normal file
31
src/base/hooks/usePersistedValue.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { useLocalStorage } from '@/base/hooks/useStorage.tsx';
|
||||
|
||||
export const getPersistedServerSetting = <T,>(serverValue: T | undefined, lastValue: T): T => {
|
||||
const isDisabled = serverValue === 0;
|
||||
if (isDisabled) {
|
||||
return lastValue;
|
||||
}
|
||||
|
||||
return serverValue ?? lastValue;
|
||||
};
|
||||
|
||||
export const usePersistedValue = <T,>(
|
||||
key: string,
|
||||
defaultValue: T,
|
||||
currentValue: T | undefined,
|
||||
getCurrentValue: (currentValue: T | undefined, persistedValue: T) => T,
|
||||
): [T, (value: T) => void] => {
|
||||
const [persistedValue, setPersistedValue] = useLocalStorage(key, defaultValue);
|
||||
|
||||
const value = getCurrentValue(currentValue, persistedValue);
|
||||
|
||||
return [value, setPersistedValue];
|
||||
};
|
||||
33
src/base/hooks/useResizeObserver.tsx
Normal file
33
src/base/hooks/useResizeObserver.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { RefObject, useLayoutEffect, useState } from 'react';
|
||||
|
||||
export const useResizeObserver = (
|
||||
ref: RefObject<HTMLElement | null> | HTMLElement | undefined | null,
|
||||
callback: ResizeObserverCallback,
|
||||
): (() => void) => {
|
||||
const [disconnect, setDisconnect] = useState<() => void>(() => {});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = ref instanceof HTMLElement ? ref : ref?.current;
|
||||
|
||||
if (!element) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(callback);
|
||||
resizeObserver.observe(element);
|
||||
|
||||
setDisconnect(() => () => resizeObserver.disconnect());
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [ref, callback]);
|
||||
|
||||
return disconnect;
|
||||
};
|
||||
101
src/base/hooks/useStorage.tsx
Normal file
101
src/base/hooks/useStorage.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Dispatch, Reducer, SetStateAction, useCallback, useMemo, useReducer, useSyncExternalStore } from 'react';
|
||||
import { AppStorage, Storage } from '@/lib/storage/AppStorage.ts';
|
||||
import { jsonSaveParse } from '@/lib/HelperFunctions.ts';
|
||||
|
||||
const subscribeToStorageUpdates = (callback: () => void) => {
|
||||
window.addEventListener('storage', callback);
|
||||
return () => window.removeEventListener('storage', callback);
|
||||
};
|
||||
|
||||
function useStorage<T>(storage: Storage, key: string, defaultValue: T | (() => T)): [T, Dispatch<SetStateAction<T>>];
|
||||
function useStorage<T = undefined>(
|
||||
storage: Storage,
|
||||
key: string,
|
||||
): [T | undefined, Dispatch<SetStateAction<T | undefined>>];
|
||||
|
||||
function useStorage<T>(
|
||||
storage: Storage,
|
||||
key: string,
|
||||
defaultValue?: T | (() => T) | undefined,
|
||||
): [T | undefined, Dispatch<SetStateAction<T | undefined>>] {
|
||||
const initialState = defaultValue instanceof Function ? defaultValue() : defaultValue;
|
||||
const storedValueRaw = useSyncExternalStore(subscribeToStorageUpdates, () => storage.getItem(key));
|
||||
|
||||
const setValue = useCallback<React.Dispatch<React.SetStateAction<T | undefined>>>(
|
||||
(value) => {
|
||||
// Allow value to be a function so we have same API as useState
|
||||
const valueToStore = (() => {
|
||||
if (value instanceof Function) {
|
||||
const previousValue = storage.getItemParsed(key, initialState);
|
||||
|
||||
return value(previousValue);
|
||||
}
|
||||
|
||||
return value;
|
||||
})();
|
||||
|
||||
storage.setItem(key, valueToStore);
|
||||
},
|
||||
[key],
|
||||
);
|
||||
|
||||
const storedValue = useMemo(() => {
|
||||
if (storedValueRaw === null) {
|
||||
return initialState;
|
||||
}
|
||||
|
||||
return jsonSaveParse(storedValueRaw) ?? storedValueRaw;
|
||||
}, [storedValueRaw, key]);
|
||||
|
||||
return [storedValue, setValue];
|
||||
}
|
||||
|
||||
const useReducerStorage = <S, A>(
|
||||
storage: Storage,
|
||||
reducer: Reducer<S, A>,
|
||||
key: string,
|
||||
defaultState: S | (() => S),
|
||||
) => {
|
||||
const [storedValue, setValue] = useStorage(storage, key, defaultState);
|
||||
return useReducer((state: S, action: A): S => {
|
||||
const newState = reducer(state, action);
|
||||
setValue(newState);
|
||||
return newState;
|
||||
}, storedValue);
|
||||
};
|
||||
|
||||
export function useLocalStorage<T>(key: string, defaultValue: T | (() => T)): [T, Dispatch<SetStateAction<T>>];
|
||||
export function useLocalStorage<T = undefined>(key: string): [T | undefined, Dispatch<SetStateAction<T | undefined>>];
|
||||
|
||||
export function useLocalStorage<T>(
|
||||
key: string,
|
||||
defaultValue?: T | undefined | (() => T | undefined),
|
||||
): [T | undefined, Dispatch<SetStateAction<T | undefined>>] {
|
||||
return useStorage(AppStorage.local, key, defaultValue);
|
||||
}
|
||||
|
||||
export function useReducerLocalStorage<S, A>(reducer: Reducer<S, A>, key: string, defaultState: S | (() => S)) {
|
||||
return useReducerStorage(AppStorage.local, reducer, key, defaultState);
|
||||
}
|
||||
|
||||
export function useSessionStorage<T>(key: string, defaultValue: T | (() => T)): [T, Dispatch<SetStateAction<T>>];
|
||||
export function useSessionStorage<T = undefined>(key: string): [T | undefined, Dispatch<SetStateAction<T | undefined>>];
|
||||
|
||||
export function useSessionStorage<T>(
|
||||
key: string,
|
||||
defaultValue?: T | (() => T),
|
||||
): [T | undefined, Dispatch<SetStateAction<T | undefined>>] {
|
||||
return useStorage(AppStorage.session, key, defaultValue);
|
||||
}
|
||||
|
||||
export function useReducerSessionStorage<S, A>(reducer: Reducer<S, A>, key: string, defaultState: S | (() => S)) {
|
||||
return useReducerStorage(AppStorage.session, reducer, key, defaultState);
|
||||
}
|
||||
Reference in New Issue
Block a user