From 50c4ac98af8d07cbccfbbf66c3bfdd83c121cd49 Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Fri, 8 Sep 2023 03:33:16 +0200 Subject: [PATCH] Thumbnail scrubber improvements (#4081) * Remove deps from useDebounce hook * Add useThrottle hook * Throttle preview scrubber * Scrubber improvements --- ui/v2.5/.eslintrc.json | 4 - .../List/Filters/SelectableFilter.tsx | 4 +- ui/v2.5/src/components/List/ListFilter.tsx | 16 ++-- .../PerformerDetails/PerformerScrapeModal.tsx | 4 +- .../PerformerStashBoxModal.tsx | 4 +- .../src/components/Scenes/PreviewScrubber.tsx | 71 +++++++++--------- ui/v2.5/src/components/Settings/context.tsx | 74 ++++++++----------- .../src/components/Shared/FilterSelect.tsx | 10 +-- .../Shared/FolderSelect/FolderSelect.tsx | 4 +- ui/v2.5/src/components/Shared/Select.tsx | 8 +- .../src/components/Shared/TruncatedText.tsx | 6 +- ui/v2.5/src/hooks/Lightbox/Lightbox.tsx | 1 - ui/v2.5/src/hooks/debounce.ts | 28 +++---- ui/v2.5/src/hooks/throttle.ts | 23 ++++++ 14 files changed, 127 insertions(+), 130 deletions(-) create mode 100644 ui/v2.5/src/hooks/throttle.ts diff --git a/ui/v2.5/.eslintrc.json b/ui/v2.5/.eslintrc.json index edce4355156..55e4d902dd8 100644 --- a/ui/v2.5/.eslintrc.json +++ b/ui/v2.5/.eslintrc.json @@ -53,10 +53,6 @@ "import/namespace": "off", "import/no-unresolved": "off", "react/display-name": "off", - "react-hooks/exhaustive-deps": [ - "error", - { "additionalHooks": "^(useDebounce)$" } - ], "react/prop-types": "off", "react/style-prop-object": [ "error", diff --git a/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx b/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx index 08f5a251462..3ce31bec761 100644 --- a/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx @@ -22,7 +22,7 @@ import { import { defineMessages, MessageDescriptor, useIntl } from "react-intl"; import { CriterionModifier } from "src/core/generated-graphql"; import { keyboardClickHandler } from "src/utils/keyboard"; -import { useDebouncedSetState } from "src/hooks/debounce"; +import { useDebounce } from "src/hooks/debounce"; import useFocus from "src/utils/focus"; interface ISelectedItem { @@ -192,7 +192,7 @@ export const ObjectsFilter = < const [query, setQuery] = useState(""); const [displayQuery, setDisplayQuery] = useState(query); - const debouncedSetQuery = useDebouncedSetState(setQuery, 250); + const debouncedSetQuery = useDebounce(setQuery, 250); const onQueryChange = useCallback( (input: string) => { setDisplayQuery(input); diff --git a/ui/v2.5/src/components/List/ListFilter.tsx b/ui/v2.5/src/components/List/ListFilter.tsx index 93b227828ad..cb692f96dfa 100644 --- a/ui/v2.5/src/components/List/ListFilter.tsx +++ b/ui/v2.5/src/components/List/ListFilter.tsx @@ -75,16 +75,12 @@ export const ListFilter: React.FC = ({ [filter, onFilterUpdate] ); - const searchCallback = useDebounce( - (value: string) => { - const newFilter = cloneDeep(filter); - newFilter.searchTerm = value; - newFilter.currentPage = 1; - onFilterUpdate(newFilter); - }, - [filter, onFilterUpdate], - 500 - ); + const searchCallback = useDebounce((value: string) => { + const newFilter = cloneDeep(filter); + newFilter.searchTerm = value; + newFilter.currentPage = 1; + onFilterUpdate(newFilter); + }, 500); const intl = useIntl(); diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeModal.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeModal.tsx index 31e093afc71..95874006e1c 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeModal.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeModal.tsx @@ -6,7 +6,7 @@ import * as GQL from "src/core/generated-graphql"; import { ModalComponent } from "src/components/Shared/Modal"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { useScrapePerformerList } from "src/core/StashService"; -import { useDebouncedSetState } from "src/hooks/debounce"; +import { useDebounce } from "src/hooks/debounce"; const CLASSNAME = "PerformerScrapeModal"; const CLASSNAME_LIST = `${CLASSNAME}-list`; @@ -33,7 +33,7 @@ const PerformerScrapeModal: React.FC = ({ const performers = data?.scrapeSinglePerformer ?? []; - const onInputChange = useDebouncedSetState(setQuery, 500); + const onInputChange = useDebounce(setQuery, 500); useEffect(() => inputRef.current?.focus(), []); diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerStashBoxModal.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerStashBoxModal.tsx index b171e00f663..773cd62d28d 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerStashBoxModal.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerStashBoxModal.tsx @@ -6,7 +6,7 @@ import * as GQL from "src/core/generated-graphql"; import { ModalComponent } from "src/components/Shared/Modal"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { stashboxDisplayName } from "src/utils/stashbox"; -import { useDebouncedSetState } from "src/hooks/debounce"; +import { useDebounce } from "src/hooks/debounce"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { stringToGender } from "src/utils/gender"; @@ -171,7 +171,7 @@ const PerformerStashBoxModal: React.FC = ({ const performers = data?.scrapeSinglePerformer ?? []; - const onInputChange = useDebouncedSetState(setQuery, 500); + const onInputChange = useDebounce(setQuery, 500); useEffect(() => inputRef.current?.focus(), []); diff --git a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx index 201d0af210e..3155f2009e1 100644 --- a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx +++ b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx @@ -1,13 +1,13 @@ -import React, { useMemo } from "react"; -import { useDebounce } from "src/hooks/debounce"; +import React, { useRef, useMemo, useState, useLayoutEffect } from "react"; import { useSpriteInfo } from "src/hooks/sprite"; +import { useThrottle } from "src/hooks/throttle"; import TextUtils from "src/utils/text"; interface IHoverScrubber { totalSprites: number; activeIndex: number | undefined; setActiveIndex: (index: number | undefined) => void; - onClick?: (index: number) => void; + onClick?: () => void; } const HoverScrubber: React.FC = ({ @@ -20,7 +20,12 @@ const HoverScrubber: React.FC = ({ const { width } = e.currentTarget.getBoundingClientRect(); const x = e.nativeEvent.offsetX; - return Math.floor((x / width) * (totalSprites - 1)); + const i = Math.floor((x / width) * totalSprites); + + // clamp to [0, totalSprites) + if (i < 0) return 0; + if (i >= totalSprites) return totalSprites - 1; + return i; } function onMouseMove(e: React.MouseEvent) { @@ -43,11 +48,11 @@ const HoverScrubber: React.FC = ({ if (relatedTarget !== e.target) return; e.preventDefault(); - onClick(getActiveIndex(e)); + onClick(); } const indicatorStyle = useMemo(() => { - if (activeIndex === undefined) return {}; + if (activeIndex === undefined || !totalSprites) return {}; const width = (activeIndex / totalSprites) * 100; @@ -97,56 +102,54 @@ export const PreviewScrubber: React.FC = ({ vttPath, onClick, }) => { - const imageParentRef = React.useRef(null); + const imageParentRef = useRef(null); + const [style, setStyle] = useState({}); - const [activeIndex, setActiveIndex] = React.useState(); + const [activeIndex, setActiveIndex] = useState(); - const debounceSetActiveIndex = useDebounce( - setActiveIndex, - [setActiveIndex], - 1 - ); + const debounceSetActiveIndex = useThrottle(setActiveIndex, 50); const spriteInfo = useSpriteInfo(vttPath); - const style = useMemo(() => { - if (!spriteInfo || activeIndex === undefined || !imageParentRef.current) { - return {}; + const sprite = useMemo(() => { + if (!spriteInfo || activeIndex === undefined) { + return undefined; } + return spriteInfo[activeIndex]; + }, [activeIndex, spriteInfo]); + + useLayoutEffect(() => { + const imageParent = imageParentRef.current; - const sprite = spriteInfo[activeIndex]; + if (!sprite || !imageParent) { + return setStyle({}); + } - const clientRect = imageParentRef.current?.getBoundingClientRect(); - const scale = clientRect ? scaleToFit(sprite, clientRect) : 1; + const clientRect = imageParent.getBoundingClientRect(); + const scale = scaleToFit(sprite, clientRect); - return { + setStyle({ backgroundPosition: `${-sprite.x}px ${-sprite.y}px`, backgroundImage: `url(${sprite.url})`, width: `${sprite.w}px`, height: `${sprite.h}px`, transform: `scale(${scale})`, - }; - }, [spriteInfo, activeIndex, imageParentRef]); + }); + }, [sprite]); const currentTime = useMemo(() => { - if (!spriteInfo || activeIndex === undefined) { - return undefined; - } - - const sprite = spriteInfo[activeIndex]; + if (!sprite) return undefined; const start = TextUtils.secondsToTimestamp(sprite.start); return start; - }, [activeIndex, spriteInfo]); + }, [sprite]); - function onScrubberClick(index: number) { - if (!spriteInfo || !onClick) { + function onScrubberClick() { + if (!sprite || !onClick) { return; } - const sprite = spriteInfo[index]; - onClick(sprite.start); } @@ -154,7 +157,7 @@ export const PreviewScrubber: React.FC = ({ return (
- {activeIndex !== undefined && spriteInfo && ( + {sprite && (
{currentTime !== undefined && ( @@ -163,7 +166,7 @@ export const PreviewScrubber: React.FC = ({
)} debounceSetActiveIndex(i)} onClick={onScrubberClick} diff --git a/ui/v2.5/src/components/Settings/context.tsx b/ui/v2.5/src/components/Settings/context.tsx index cbb353a6bdb..eae1bd4040f 100644 --- a/ui/v2.5/src/components/Settings/context.tsx +++ b/ui/v2.5/src/components/Settings/context.tsx @@ -134,7 +134,7 @@ export const SettingsContext: React.FC = ({ children }) => { setUI(data.configuration.ui); }, [data, error]); - const resetSuccess = useDebounce(() => setUpdateSuccess(undefined), [], 4000); + const resetSuccess = useDebounce(() => setUpdateSuccess(undefined), 4000); const onSuccess = useCallback(() => { setUpdateSuccess(true); @@ -158,7 +158,6 @@ export const SettingsContext: React.FC = ({ children }) => { setSaveError(e); } }, - [updateGeneralConfig, onSuccess], 500 ); @@ -208,7 +207,6 @@ export const SettingsContext: React.FC = ({ children }) => { setSaveError(e); } }, - [updateInterfaceConfig, onSuccess], 500 ); @@ -258,7 +256,6 @@ export const SettingsContext: React.FC = ({ children }) => { setSaveError(e); } }, - [updateDefaultsConfig, onSuccess], 500 ); @@ -308,7 +305,6 @@ export const SettingsContext: React.FC = ({ children }) => { setSaveError(e); } }, - [updateScrapingConfig, onSuccess], 500 ); @@ -342,25 +338,21 @@ export const SettingsContext: React.FC = ({ children }) => { } // saves the configuration if no further changes are made after a half second - const saveDLNAConfig = useDebounce( - async (input: GQL.ConfigDlnaInput) => { - try { - setUpdateSuccess(undefined); - await updateDLNAConfig({ - variables: { - input, - }, - }); - - setPendingDLNA(undefined); - onSuccess(); - } catch (e) { - setSaveError(e); - } - }, - [updateDLNAConfig, onSuccess], - 500 - ); + const saveDLNAConfig = useDebounce(async (input: GQL.ConfigDlnaInput) => { + try { + setUpdateSuccess(undefined); + await updateDLNAConfig({ + variables: { + input, + }, + }); + + setPendingDLNA(undefined); + onSuccess(); + } catch (e) { + setSaveError(e); + } + }, 500); useEffect(() => { if (!pendingDLNA) { @@ -392,25 +384,21 @@ export const SettingsContext: React.FC = ({ children }) => { } // saves the configuration if no further changes are made after a half second - const saveUIConfig = useDebounce( - async (input: IUIConfig) => { - try { - setUpdateSuccess(undefined); - await updateUIConfig({ - variables: { - input, - }, - }); - - setPendingUI(undefined); - onSuccess(); - } catch (e) { - setSaveError(e); - } - }, - [updateUIConfig, onSuccess], - 500 - ); + const saveUIConfig = useDebounce(async (input: IUIConfig) => { + try { + setUpdateSuccess(undefined); + await updateUIConfig({ + variables: { + input, + }, + }); + + setPendingUI(undefined); + onSuccess(); + } catch (e) { + setSaveError(e); + } + }, 500); useEffect(() => { if (!pendingUI) { diff --git a/ui/v2.5/src/components/Shared/FilterSelect.tsx b/ui/v2.5/src/components/Shared/FilterSelect.tsx index faf14a7fc28..3489f1851d4 100644 --- a/ui/v2.5/src/components/Shared/FilterSelect.tsx +++ b/ui/v2.5/src/components/Shared/FilterSelect.tsx @@ -229,13 +229,9 @@ export const FilterSelectComponent = < }; const debounceDelay = 100; - const debounceLoadOptions = useDebounce( - (inputValue, callback) => { - loadOptions(inputValue).then(callback); - }, - [loadOptions], - debounceDelay - ); + const debounceLoadOptions = useDebounce((inputValue, callback) => { + loadOptions(inputValue).then(callback); + }, debounceDelay); return ( diff --git a/ui/v2.5/src/components/Shared/FolderSelect/FolderSelect.tsx b/ui/v2.5/src/components/Shared/FolderSelect/FolderSelect.tsx index 4c7bdafa36f..50b7e45b805 100644 --- a/ui/v2.5/src/components/Shared/FolderSelect/FolderSelect.tsx +++ b/ui/v2.5/src/components/Shared/FolderSelect/FolderSelect.tsx @@ -5,7 +5,7 @@ import { Icon } from "../Icon"; import { LoadingIndicator } from "../LoadingIndicator"; import { useDirectory } from "src/core/StashService"; import { faEllipsis, faTimes } from "@fortawesome/free-solid-svg-icons"; -import { useDebouncedSetState } from "src/hooks/debounce"; +import { useDebounce } from "src/hooks/debounce"; interface IProps { currentDirectory: string; @@ -44,7 +44,7 @@ export const FolderSelect: React.FC = ({ (error && hideError ? [] : defaultDirectoriesOrEmpty) : defaultDirectoriesOrEmpty; - const debouncedSetDirectory = useDebouncedSetState(setDirectory, 250); + const debouncedSetDirectory = useDebounce(setDirectory, 250); useEffect(() => { if (currentDirectory !== directory) { diff --git a/ui/v2.5/src/components/Shared/Select.tsx b/ui/v2.5/src/components/Shared/Select.tsx index 495df4f5e2d..5a1c5b2fecc 100644 --- a/ui/v2.5/src/components/Shared/Select.tsx +++ b/ui/v2.5/src/components/Shared/Select.tsx @@ -29,7 +29,7 @@ import { objectTitle } from "src/core/files"; import { galleryTitle } from "src/core/galleries"; import { TagPopover } from "../Tags/TagPopover"; import { defaultMaxOptionsShown, IUIConfig } from "src/core/config"; -import { useDebouncedSetState } from "src/hooks/debounce"; +import { useDebounce } from "src/hooks/debounce"; import { Placement } from "react-bootstrap/esm/Overlay"; import { PerformerIDSelect } from "../Performers/PerformerSelect"; @@ -352,7 +352,7 @@ export const GallerySelect: React.FC = (props) => { value: g.id, })); - const onInputChange = useDebouncedSetState(setQuery, 500); + const onInputChange = useDebounce(setQuery, 500); const onChange = (selectedItems: OnChangeValue) => { const selected = getSelectedItems(selectedItems); @@ -403,7 +403,7 @@ export const SceneSelect: React.FC = (props) => { value: s.id, })); - const onInputChange = useDebouncedSetState(setQuery, 500); + const onInputChange = useDebounce(setQuery, 500); const onChange = (selectedItems: OnChangeValue) => { const selected = getSelectedItems(selectedItems); @@ -453,7 +453,7 @@ export const ImageSelect: React.FC = (props) => { value: s.id, })); - const onInputChange = useDebouncedSetState(setQuery, 500); + const onInputChange = useDebounce(setQuery, 500); const onChange = (selectedItems: OnChangeValue) => { const selected = getSelectedItems(selectedItems); diff --git a/ui/v2.5/src/components/Shared/TruncatedText.tsx b/ui/v2.5/src/components/Shared/TruncatedText.tsx index 99f4af1f527..6ffe9d78e1a 100644 --- a/ui/v2.5/src/components/Shared/TruncatedText.tsx +++ b/ui/v2.5/src/components/Shared/TruncatedText.tsx @@ -25,11 +25,7 @@ export const TruncatedText: React.FC = ({ const [showTooltip, setShowTooltip] = useState(false); const target = useRef(null); - const startShowingTooltip = useDebounce( - () => setShowTooltip(true), - [], - delay - ); + const startShowingTooltip = useDebounce(() => setShowTooltip(true), delay); if (!text) return <>; diff --git a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx index cfe1d5db34d..8218253f706 100644 --- a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx +++ b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx @@ -212,7 +212,6 @@ export const LightboxComponent: React.FC = ({ const disableInstantTransition = useDebounce( () => setInstantTransition(false), - [], 400 ); diff --git a/ui/v2.5/src/hooks/debounce.ts b/ui/v2.5/src/hooks/debounce.ts index 236cbf35b1f..9baf3d1d472 100644 --- a/ui/v2.5/src/hooks/debounce.ts +++ b/ui/v2.5/src/hooks/debounce.ts @@ -1,23 +1,23 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable react-hooks/exhaustive-deps */ -import { DebounceSettings } from "lodash-es"; -import debounce, { DebouncedFunc } from "lodash-es/debounce"; -import React, { useCallback } from "react"; +import { debounce, DebouncedFunc, DebounceSettings } from "lodash-es"; +import { useCallback, useRef } from "react"; export function useDebounce any>( fn: T, - deps: React.DependencyList, wait?: number, options?: DebounceSettings ): DebouncedFunc { - return useCallback(debounce(fn, wait, options), [...deps, wait, options]); -} - -// Convenience hook for use with state setters -export function useDebouncedSetState( - fn: React.Dispatch>, - wait?: number, - options?: DebounceSettings -): DebouncedFunc>> { - return useDebounce(fn, [], wait, options); + const func = useRef(fn); + func.current = fn; + return useCallback( + debounce( + function (this: any) { + return func.current.apply(this, arguments as any); + }, + wait, + options + ), + [wait, options?.leading, options?.trailing, options?.maxWait] + ); } diff --git a/ui/v2.5/src/hooks/throttle.ts b/ui/v2.5/src/hooks/throttle.ts new file mode 100644 index 00000000000..358f68fc621 --- /dev/null +++ b/ui/v2.5/src/hooks/throttle.ts @@ -0,0 +1,23 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable react-hooks/exhaustive-deps */ +import { DebouncedFunc, DebounceSettings, throttle } from "lodash-es"; +import { useCallback, useRef } from "react"; + +export function useThrottle any>( + fn: T, + wait?: number, + options?: DebounceSettings +): DebouncedFunc { + const func = useRef(fn); + func.current = fn; + return useCallback( + throttle( + function (this: any) { + return func.current.apply(this, arguments as any); + }, + wait, + options + ), + [wait, options?.leading, options?.trailing, options?.maxWait] + ); +}