mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 20:34:37 +03:00
Thumbnail scrubber improvements (#4081)
* Remove deps from useDebounce hook * Add useThrottle hook * Throttle preview scrubber * Scrubber improvements
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -75,16 +75,12 @@ export const ListFilter: React.FC<IListFilterProps> = ({
|
||||
[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();
|
||||
|
||||
|
||||
@@ -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<IProps> = ({
|
||||
|
||||
const performers = data?.scrapeSinglePerformer ?? [];
|
||||
|
||||
const onInputChange = useDebouncedSetState(setQuery, 500);
|
||||
const onInputChange = useDebounce(setQuery, 500);
|
||||
|
||||
useEffect(() => inputRef.current?.focus(), []);
|
||||
|
||||
|
||||
@@ -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<IProps> = ({
|
||||
|
||||
const performers = data?.scrapeSinglePerformer ?? [];
|
||||
|
||||
const onInputChange = useDebouncedSetState(setQuery, 500);
|
||||
const onInputChange = useDebounce(setQuery, 500);
|
||||
|
||||
useEffect(() => inputRef.current?.focus(), []);
|
||||
|
||||
|
||||
@@ -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<IHoverScrubber> = ({
|
||||
@@ -20,7 +20,12 @@ const HoverScrubber: React.FC<IHoverScrubber> = ({
|
||||
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<HTMLDivElement, MouseEvent>) {
|
||||
@@ -43,11 +48,11 @@ const HoverScrubber: React.FC<IHoverScrubber> = ({
|
||||
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<IScenePreviewProps> = ({
|
||||
vttPath,
|
||||
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(
|
||||
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;
|
||||
|
||||
if (!sprite || !imageParent) {
|
||||
return setStyle({});
|
||||
}
|
||||
|
||||
const sprite = spriteInfo[activeIndex];
|
||||
const clientRect = imageParent.getBoundingClientRect();
|
||||
const scale = scaleToFit(sprite, clientRect);
|
||||
|
||||
const clientRect = imageParentRef.current?.getBoundingClientRect();
|
||||
const scale = clientRect ? scaleToFit(sprite, clientRect) : 1;
|
||||
|
||||
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<IScenePreviewProps> = ({
|
||||
|
||||
return (
|
||||
<div className="preview-scrubber">
|
||||
{activeIndex !== undefined && spriteInfo && (
|
||||
{sprite && (
|
||||
<div className="scene-card-preview-image" ref={imageParentRef}>
|
||||
<div className="scrubber-image" style={style}></div>
|
||||
{currentTime !== undefined && (
|
||||
@@ -163,7 +166,7 @@ export const PreviewScrubber: React.FC<IScenePreviewProps> = ({
|
||||
</div>
|
||||
)}
|
||||
<HoverScrubber
|
||||
totalSprites={81}
|
||||
totalSprites={spriteInfo.length}
|
||||
activeIndex={activeIndex}
|
||||
setActiveIndex={(i) => debounceSetActiveIndex(i)}
|
||||
onClick={onScrubberClick}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
const saveDLNAConfig = useDebounce(async (input: GQL.ConfigDlnaInput) => {
|
||||
try {
|
||||
setUpdateSuccess(undefined);
|
||||
await updateDLNAConfig({
|
||||
variables: {
|
||||
input,
|
||||
},
|
||||
});
|
||||
|
||||
setPendingDLNA(undefined);
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
setSaveError(e);
|
||||
}
|
||||
},
|
||||
[updateDLNAConfig, onSuccess],
|
||||
500
|
||||
);
|
||||
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,
|
||||
},
|
||||
});
|
||||
const saveUIConfig = useDebounce(async (input: IUIConfig) => {
|
||||
try {
|
||||
setUpdateSuccess(undefined);
|
||||
await updateUIConfig({
|
||||
variables: {
|
||||
input,
|
||||
},
|
||||
});
|
||||
|
||||
setPendingUI(undefined);
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
setSaveError(e);
|
||||
}
|
||||
},
|
||||
[updateUIConfig, onSuccess],
|
||||
500
|
||||
);
|
||||
setPendingUI(undefined);
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
setSaveError(e);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingUI) {
|
||||
|
||||
@@ -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 (
|
||||
<SelectComponent<T, IsMulti>
|
||||
|
||||
@@ -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<IProps> = ({
|
||||
(error && hideError ? [] : defaultDirectoriesOrEmpty)
|
||||
: defaultDirectoriesOrEmpty;
|
||||
|
||||
const debouncedSetDirectory = useDebouncedSetState(setDirectory, 250);
|
||||
const debouncedSetDirectory = useDebounce(setDirectory, 250);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentDirectory !== directory) {
|
||||
|
||||
@@ -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<ITitledSelect> = (props) => {
|
||||
value: g.id,
|
||||
}));
|
||||
|
||||
const onInputChange = useDebouncedSetState(setQuery, 500);
|
||||
const onInputChange = useDebounce(setQuery, 500);
|
||||
|
||||
const onChange = (selectedItems: OnChangeValue<Option, boolean>) => {
|
||||
const selected = getSelectedItems(selectedItems);
|
||||
@@ -403,7 +403,7 @@ export const SceneSelect: React.FC<ITitledSelect> = (props) => {
|
||||
value: s.id,
|
||||
}));
|
||||
|
||||
const onInputChange = useDebouncedSetState(setQuery, 500);
|
||||
const onInputChange = useDebounce(setQuery, 500);
|
||||
|
||||
const onChange = (selectedItems: OnChangeValue<Option, boolean>) => {
|
||||
const selected = getSelectedItems(selectedItems);
|
||||
@@ -453,7 +453,7 @@ export const ImageSelect: React.FC<ITitledSelect> = (props) => {
|
||||
value: s.id,
|
||||
}));
|
||||
|
||||
const onInputChange = useDebouncedSetState(setQuery, 500);
|
||||
const onInputChange = useDebounce(setQuery, 500);
|
||||
|
||||
const onChange = (selectedItems: OnChangeValue<Option, boolean>) => {
|
||||
const selected = getSelectedItems(selectedItems);
|
||||
|
||||
@@ -25,11 +25,7 @@ export const TruncatedText: React.FC<ITruncatedTextProps> = ({
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const target = useRef(null);
|
||||
|
||||
const startShowingTooltip = useDebounce(
|
||||
() => setShowTooltip(true),
|
||||
[],
|
||||
delay
|
||||
);
|
||||
const startShowingTooltip = useDebounce(() => setShowTooltip(true), delay);
|
||||
|
||||
if (!text) return <></>;
|
||||
|
||||
|
||||
@@ -212,7 +212,6 @@ export const LightboxComponent: React.FC<IProps> = ({
|
||||
|
||||
const disableInstantTransition = useDebounce(
|
||||
() => setInstantTransition(false),
|
||||
[],
|
||||
400
|
||||
);
|
||||
|
||||
|
||||
@@ -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<T extends (...args: any) => any>(
|
||||
fn: T,
|
||||
deps: React.DependencyList,
|
||||
wait?: number,
|
||||
options?: DebounceSettings
|
||||
): DebouncedFunc<T> {
|
||||
return useCallback(debounce(fn, wait, options), [...deps, wait, options]);
|
||||
}
|
||||
|
||||
// Convenience hook for use with state setters
|
||||
export function useDebouncedSetState<S>(
|
||||
fn: React.Dispatch<React.SetStateAction<S>>,
|
||||
wait?: number,
|
||||
options?: DebounceSettings
|
||||
): DebouncedFunc<React.Dispatch<React.SetStateAction<S>>> {
|
||||
return useDebounce(fn, [], wait, options);
|
||||
const func = useRef<T>(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]
|
||||
);
|
||||
}
|
||||
|
||||
23
ui/v2.5/src/hooks/throttle.ts
Normal file
23
ui/v2.5/src/hooks/throttle.ts
Normal 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]
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user