Thumbnail scrubber improvements (#4081)

* Remove deps from useDebounce hook
* Add useThrottle hook
* Throttle preview scrubber
* Scrubber improvements
This commit is contained in:
DingDongSoLong4
2023-09-08 03:33:16 +02:00
committed by GitHub
parent 7a9214375b
commit 50c4ac98af
14 changed files with 126 additions and 129 deletions

View File

@@ -53,10 +53,6 @@
"import/namespace": "off", "import/namespace": "off",
"import/no-unresolved": "off", "import/no-unresolved": "off",
"react/display-name": "off", "react/display-name": "off",
"react-hooks/exhaustive-deps": [
"error",
{ "additionalHooks": "^(useDebounce)$" }
],
"react/prop-types": "off", "react/prop-types": "off",
"react/style-prop-object": [ "react/style-prop-object": [
"error", "error",

View File

@@ -22,7 +22,7 @@ import {
import { defineMessages, MessageDescriptor, useIntl } from "react-intl"; import { defineMessages, MessageDescriptor, useIntl } from "react-intl";
import { CriterionModifier } from "src/core/generated-graphql"; import { CriterionModifier } from "src/core/generated-graphql";
import { keyboardClickHandler } from "src/utils/keyboard"; import { keyboardClickHandler } from "src/utils/keyboard";
import { useDebouncedSetState } from "src/hooks/debounce"; import { useDebounce } from "src/hooks/debounce";
import useFocus from "src/utils/focus"; import useFocus from "src/utils/focus";
interface ISelectedItem { interface ISelectedItem {
@@ -192,7 +192,7 @@ export const ObjectsFilter = <
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [displayQuery, setDisplayQuery] = useState(query); const [displayQuery, setDisplayQuery] = useState(query);
const debouncedSetQuery = useDebouncedSetState(setQuery, 250); const debouncedSetQuery = useDebounce(setQuery, 250);
const onQueryChange = useCallback( const onQueryChange = useCallback(
(input: string) => { (input: string) => {
setDisplayQuery(input); setDisplayQuery(input);

View File

@@ -75,16 +75,12 @@ export const ListFilter: React.FC<IListFilterProps> = ({
[filter, onFilterUpdate] [filter, onFilterUpdate]
); );
const searchCallback = useDebounce( const searchCallback = useDebounce((value: string) => {
(value: string) => {
const newFilter = cloneDeep(filter); const newFilter = cloneDeep(filter);
newFilter.searchTerm = value; newFilter.searchTerm = value;
newFilter.currentPage = 1; newFilter.currentPage = 1;
onFilterUpdate(newFilter); onFilterUpdate(newFilter);
}, }, 500);
[filter, onFilterUpdate],
500
);
const intl = useIntl(); const intl = useIntl();

View File

@@ -6,7 +6,7 @@ import * as GQL from "src/core/generated-graphql";
import { ModalComponent } from "src/components/Shared/Modal"; import { ModalComponent } from "src/components/Shared/Modal";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { useScrapePerformerList } from "src/core/StashService"; import { useScrapePerformerList } from "src/core/StashService";
import { useDebouncedSetState } from "src/hooks/debounce"; import { useDebounce } from "src/hooks/debounce";
const CLASSNAME = "PerformerScrapeModal"; const CLASSNAME = "PerformerScrapeModal";
const CLASSNAME_LIST = `${CLASSNAME}-list`; const CLASSNAME_LIST = `${CLASSNAME}-list`;
@@ -33,7 +33,7 @@ const PerformerScrapeModal: React.FC<IProps> = ({
const performers = data?.scrapeSinglePerformer ?? []; const performers = data?.scrapeSinglePerformer ?? [];
const onInputChange = useDebouncedSetState(setQuery, 500); const onInputChange = useDebounce(setQuery, 500);
useEffect(() => inputRef.current?.focus(), []); useEffect(() => inputRef.current?.focus(), []);

View File

@@ -6,7 +6,7 @@ import * as GQL from "src/core/generated-graphql";
import { ModalComponent } from "src/components/Shared/Modal"; import { ModalComponent } from "src/components/Shared/Modal";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { stashboxDisplayName } from "src/utils/stashbox"; 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 { TruncatedText } from "src/components/Shared/TruncatedText";
import { stringToGender } from "src/utils/gender"; import { stringToGender } from "src/utils/gender";
@@ -171,7 +171,7 @@ const PerformerStashBoxModal: React.FC<IProps> = ({
const performers = data?.scrapeSinglePerformer ?? []; const performers = data?.scrapeSinglePerformer ?? [];
const onInputChange = useDebouncedSetState(setQuery, 500); const onInputChange = useDebounce(setQuery, 500);
useEffect(() => inputRef.current?.focus(), []); useEffect(() => inputRef.current?.focus(), []);

View File

@@ -1,13 +1,13 @@
import React, { useMemo } from "react"; import React, { useRef, useMemo, useState, useLayoutEffect } from "react";
import { useDebounce } from "src/hooks/debounce";
import { useSpriteInfo } from "src/hooks/sprite"; import { useSpriteInfo } from "src/hooks/sprite";
import { useThrottle } from "src/hooks/throttle";
import TextUtils from "src/utils/text"; import TextUtils from "src/utils/text";
interface IHoverScrubber { interface IHoverScrubber {
totalSprites: number; totalSprites: number;
activeIndex: number | undefined; activeIndex: number | undefined;
setActiveIndex: (index: number | undefined) => void; setActiveIndex: (index: number | undefined) => void;
onClick?: (index: number) => void; onClick?: () => void;
} }
const HoverScrubber: React.FC<IHoverScrubber> = ({ const HoverScrubber: React.FC<IHoverScrubber> = ({
@@ -20,7 +20,12 @@ const HoverScrubber: React.FC<IHoverScrubber> = ({
const { width } = e.currentTarget.getBoundingClientRect(); const { width } = e.currentTarget.getBoundingClientRect();
const x = e.nativeEvent.offsetX; 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<HTMLDivElement, MouseEvent>) { function onMouseMove(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
@@ -43,11 +48,11 @@ const HoverScrubber: React.FC<IHoverScrubber> = ({
if (relatedTarget !== e.target) return; if (relatedTarget !== e.target) return;
e.preventDefault(); e.preventDefault();
onClick(getActiveIndex(e)); onClick();
} }
const indicatorStyle = useMemo(() => { const indicatorStyle = useMemo(() => {
if (activeIndex === undefined) return {}; if (activeIndex === undefined || !totalSprites) return {};
const width = (activeIndex / totalSprites) * 100; const width = (activeIndex / totalSprites) * 100;
@@ -97,56 +102,54 @@ export const PreviewScrubber: React.FC<IScenePreviewProps> = ({
vttPath, vttPath,
onClick, onClick,
}) => { }) => {
const imageParentRef = React.useRef<HTMLDivElement>(null); const imageParentRef = useRef<HTMLDivElement>(null);
const [style, setStyle] = useState({});
const [activeIndex, setActiveIndex] = React.useState<number | undefined>(); const [activeIndex, setActiveIndex] = useState<number>();
const debounceSetActiveIndex = useDebounce( const debounceSetActiveIndex = useThrottle(setActiveIndex, 50);
setActiveIndex,
[setActiveIndex],
1
);
const spriteInfo = useSpriteInfo(vttPath); const spriteInfo = useSpriteInfo(vttPath);
const style = useMemo(() => { const sprite = useMemo(() => {
if (!spriteInfo || activeIndex === undefined || !imageParentRef.current) { if (!spriteInfo || activeIndex === undefined) {
return {}; return undefined;
}
return spriteInfo[activeIndex];
}, [activeIndex, spriteInfo]);
useLayoutEffect(() => {
const imageParent = imageParentRef.current;
if (!sprite || !imageParent) {
return setStyle({});
} }
const sprite = spriteInfo[activeIndex]; const clientRect = imageParent.getBoundingClientRect();
const scale = scaleToFit(sprite, clientRect);
const clientRect = imageParentRef.current?.getBoundingClientRect(); setStyle({
const scale = clientRect ? scaleToFit(sprite, clientRect) : 1;
return {
backgroundPosition: `${-sprite.x}px ${-sprite.y}px`, backgroundPosition: `${-sprite.x}px ${-sprite.y}px`,
backgroundImage: `url(${sprite.url})`, backgroundImage: `url(${sprite.url})`,
width: `${sprite.w}px`, width: `${sprite.w}px`,
height: `${sprite.h}px`, height: `${sprite.h}px`,
transform: `scale(${scale})`, transform: `scale(${scale})`,
}; });
}, [spriteInfo, activeIndex, imageParentRef]); }, [sprite]);
const currentTime = useMemo(() => { const currentTime = useMemo(() => {
if (!spriteInfo || activeIndex === undefined) { if (!sprite) return undefined;
return undefined;
}
const sprite = spriteInfo[activeIndex];
const start = TextUtils.secondsToTimestamp(sprite.start); const start = TextUtils.secondsToTimestamp(sprite.start);
return start; return start;
}, [activeIndex, spriteInfo]); }, [sprite]);
function onScrubberClick(index: number) { function onScrubberClick() {
if (!spriteInfo || !onClick) { if (!sprite || !onClick) {
return; return;
} }
const sprite = spriteInfo[index];
onClick(sprite.start); onClick(sprite.start);
} }
@@ -154,7 +157,7 @@ export const PreviewScrubber: React.FC<IScenePreviewProps> = ({
return ( return (
<div className="preview-scrubber"> <div className="preview-scrubber">
{activeIndex !== undefined && spriteInfo && ( {sprite && (
<div className="scene-card-preview-image" ref={imageParentRef}> <div className="scene-card-preview-image" ref={imageParentRef}>
<div className="scrubber-image" style={style}></div> <div className="scrubber-image" style={style}></div>
{currentTime !== undefined && ( {currentTime !== undefined && (
@@ -163,7 +166,7 @@ export const PreviewScrubber: React.FC<IScenePreviewProps> = ({
</div> </div>
)} )}
<HoverScrubber <HoverScrubber
totalSprites={81} totalSprites={spriteInfo.length}
activeIndex={activeIndex} activeIndex={activeIndex}
setActiveIndex={(i) => debounceSetActiveIndex(i)} setActiveIndex={(i) => debounceSetActiveIndex(i)}
onClick={onScrubberClick} onClick={onScrubberClick}

View File

@@ -134,7 +134,7 @@ export const SettingsContext: React.FC = ({ children }) => {
setUI(data.configuration.ui); setUI(data.configuration.ui);
}, [data, error]); }, [data, error]);
const resetSuccess = useDebounce(() => setUpdateSuccess(undefined), [], 4000); const resetSuccess = useDebounce(() => setUpdateSuccess(undefined), 4000);
const onSuccess = useCallback(() => { const onSuccess = useCallback(() => {
setUpdateSuccess(true); setUpdateSuccess(true);
@@ -158,7 +158,6 @@ export const SettingsContext: React.FC = ({ children }) => {
setSaveError(e); setSaveError(e);
} }
}, },
[updateGeneralConfig, onSuccess],
500 500
); );
@@ -208,7 +207,6 @@ export const SettingsContext: React.FC = ({ children }) => {
setSaveError(e); setSaveError(e);
} }
}, },
[updateInterfaceConfig, onSuccess],
500 500
); );
@@ -258,7 +256,6 @@ export const SettingsContext: React.FC = ({ children }) => {
setSaveError(e); setSaveError(e);
} }
}, },
[updateDefaultsConfig, onSuccess],
500 500
); );
@@ -308,7 +305,6 @@ export const SettingsContext: React.FC = ({ children }) => {
setSaveError(e); setSaveError(e);
} }
}, },
[updateScrapingConfig, onSuccess],
500 500
); );
@@ -342,8 +338,7 @@ export const SettingsContext: React.FC = ({ children }) => {
} }
// saves the configuration if no further changes are made after a half second // saves the configuration if no further changes are made after a half second
const saveDLNAConfig = useDebounce( const saveDLNAConfig = useDebounce(async (input: GQL.ConfigDlnaInput) => {
async (input: GQL.ConfigDlnaInput) => {
try { try {
setUpdateSuccess(undefined); setUpdateSuccess(undefined);
await updateDLNAConfig({ await updateDLNAConfig({
@@ -357,10 +352,7 @@ export const SettingsContext: React.FC = ({ children }) => {
} catch (e) { } catch (e) {
setSaveError(e); setSaveError(e);
} }
}, }, 500);
[updateDLNAConfig, onSuccess],
500
);
useEffect(() => { useEffect(() => {
if (!pendingDLNA) { if (!pendingDLNA) {
@@ -392,8 +384,7 @@ export const SettingsContext: React.FC = ({ children }) => {
} }
// saves the configuration if no further changes are made after a half second // saves the configuration if no further changes are made after a half second
const saveUIConfig = useDebounce( const saveUIConfig = useDebounce(async (input: IUIConfig) => {
async (input: IUIConfig) => {
try { try {
setUpdateSuccess(undefined); setUpdateSuccess(undefined);
await updateUIConfig({ await updateUIConfig({
@@ -407,10 +398,7 @@ export const SettingsContext: React.FC = ({ children }) => {
} catch (e) { } catch (e) {
setSaveError(e); setSaveError(e);
} }
}, }, 500);
[updateUIConfig, onSuccess],
500
);
useEffect(() => { useEffect(() => {
if (!pendingUI) { if (!pendingUI) {

View File

@@ -229,13 +229,9 @@ export const FilterSelectComponent = <
}; };
const debounceDelay = 100; const debounceDelay = 100;
const debounceLoadOptions = useDebounce( const debounceLoadOptions = useDebounce((inputValue, callback) => {
(inputValue, callback) => {
loadOptions(inputValue).then(callback); loadOptions(inputValue).then(callback);
}, }, debounceDelay);
[loadOptions],
debounceDelay
);
return ( return (
<SelectComponent<T, IsMulti> <SelectComponent<T, IsMulti>

View File

@@ -5,7 +5,7 @@ import { Icon } from "../Icon";
import { LoadingIndicator } from "../LoadingIndicator"; import { LoadingIndicator } from "../LoadingIndicator";
import { useDirectory } from "src/core/StashService"; import { useDirectory } from "src/core/StashService";
import { faEllipsis, faTimes } from "@fortawesome/free-solid-svg-icons"; import { faEllipsis, faTimes } from "@fortawesome/free-solid-svg-icons";
import { useDebouncedSetState } from "src/hooks/debounce"; import { useDebounce } from "src/hooks/debounce";
interface IProps { interface IProps {
currentDirectory: string; currentDirectory: string;
@@ -44,7 +44,7 @@ export const FolderSelect: React.FC<IProps> = ({
(error && hideError ? [] : defaultDirectoriesOrEmpty) (error && hideError ? [] : defaultDirectoriesOrEmpty)
: defaultDirectoriesOrEmpty; : defaultDirectoriesOrEmpty;
const debouncedSetDirectory = useDebouncedSetState(setDirectory, 250); const debouncedSetDirectory = useDebounce(setDirectory, 250);
useEffect(() => { useEffect(() => {
if (currentDirectory !== directory) { if (currentDirectory !== directory) {

View File

@@ -29,7 +29,7 @@ import { objectTitle } from "src/core/files";
import { galleryTitle } from "src/core/galleries"; import { galleryTitle } from "src/core/galleries";
import { TagPopover } from "../Tags/TagPopover"; import { TagPopover } from "../Tags/TagPopover";
import { defaultMaxOptionsShown, IUIConfig } from "src/core/config"; 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 { Placement } from "react-bootstrap/esm/Overlay";
import { PerformerIDSelect } from "../Performers/PerformerSelect"; import { PerformerIDSelect } from "../Performers/PerformerSelect";
@@ -352,7 +352,7 @@ export const GallerySelect: React.FC<ITitledSelect> = (props) => {
value: g.id, value: g.id,
})); }));
const onInputChange = useDebouncedSetState(setQuery, 500); const onInputChange = useDebounce(setQuery, 500);
const onChange = (selectedItems: OnChangeValue<Option, boolean>) => { const onChange = (selectedItems: OnChangeValue<Option, boolean>) => {
const selected = getSelectedItems(selectedItems); const selected = getSelectedItems(selectedItems);
@@ -403,7 +403,7 @@ export const SceneSelect: React.FC<ITitledSelect> = (props) => {
value: s.id, value: s.id,
})); }));
const onInputChange = useDebouncedSetState(setQuery, 500); const onInputChange = useDebounce(setQuery, 500);
const onChange = (selectedItems: OnChangeValue<Option, boolean>) => { const onChange = (selectedItems: OnChangeValue<Option, boolean>) => {
const selected = getSelectedItems(selectedItems); const selected = getSelectedItems(selectedItems);
@@ -453,7 +453,7 @@ export const ImageSelect: React.FC<ITitledSelect> = (props) => {
value: s.id, value: s.id,
})); }));
const onInputChange = useDebouncedSetState(setQuery, 500); const onInputChange = useDebounce(setQuery, 500);
const onChange = (selectedItems: OnChangeValue<Option, boolean>) => { const onChange = (selectedItems: OnChangeValue<Option, boolean>) => {
const selected = getSelectedItems(selectedItems); const selected = getSelectedItems(selectedItems);

View File

@@ -25,11 +25,7 @@ export const TruncatedText: React.FC<ITruncatedTextProps> = ({
const [showTooltip, setShowTooltip] = useState(false); const [showTooltip, setShowTooltip] = useState(false);
const target = useRef(null); const target = useRef(null);
const startShowingTooltip = useDebounce( const startShowingTooltip = useDebounce(() => setShowTooltip(true), delay);
() => setShowTooltip(true),
[],
delay
);
if (!text) return <></>; if (!text) return <></>;

View File

@@ -212,7 +212,6 @@ export const LightboxComponent: React.FC<IProps> = ({
const disableInstantTransition = useDebounce( const disableInstantTransition = useDebounce(
() => setInstantTransition(false), () => setInstantTransition(false),
[],
400 400
); );

View File

@@ -1,23 +1,23 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
import { DebounceSettings } from "lodash-es"; import { debounce, DebouncedFunc, DebounceSettings } from "lodash-es";
import debounce, { DebouncedFunc } from "lodash-es/debounce"; import { useCallback, useRef } from "react";
import React, { useCallback } from "react";
export function useDebounce<T extends (...args: any) => any>( export function useDebounce<T extends (...args: any) => any>(
fn: T, fn: T,
deps: React.DependencyList,
wait?: number, wait?: number,
options?: DebounceSettings options?: DebounceSettings
): DebouncedFunc<T> { ): DebouncedFunc<T> {
return useCallback(debounce(fn, wait, options), [...deps, wait, options]); const func = useRef<T>(fn);
} func.current = fn;
return useCallback(
// Convenience hook for use with state setters debounce(
export function useDebouncedSetState<S>( function (this: any) {
fn: React.Dispatch<React.SetStateAction<S>>, return func.current.apply(this, arguments as any);
wait?: number, },
options?: DebounceSettings wait,
): DebouncedFunc<React.Dispatch<React.SetStateAction<S>>> { options
return useDebounce(fn, [], wait, options); ),
[wait, options?.leading, options?.trailing, options?.maxWait]
);
} }

View File

@@ -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<T extends (...args: any) => any>(
fn: T,
wait?: number,
options?: DebounceSettings
): DebouncedFunc<T> {
const func = useRef<T>(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]
);
}