mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-04-23 03:00:31 -04:00
feat(ui): workflow library infinite scrolling
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Flex, Grid, GridItem, Spinner } from '@invoke-ai/ui-library';
|
||||
import { Button, Flex, Grid, GridItem, Spacer, Spinner } from '@invoke-ai/ui-library';
|
||||
import { EMPTY_ARRAY } from 'app/store/constants';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
||||
import {
|
||||
@@ -7,53 +8,65 @@ import {
|
||||
selectWorkflowOrderDirection,
|
||||
selectWorkflowSearchTerm,
|
||||
} from 'features/nodes/store/workflowSlice';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { memo, useCallback, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useListWorkflowsQuery } from 'services/api/endpoints/workflows';
|
||||
import type { useListWorkflowsQuery } from 'services/api/endpoints/workflows';
|
||||
import { useListWorkflowsInfiniteInfiniteQuery } from 'services/api/endpoints/workflows';
|
||||
import type { S } from 'services/api/types';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
|
||||
import { WorkflowLibraryPagination } from './WorkflowLibraryPagination';
|
||||
import { WorkflowListItem } from './WorkflowListItem';
|
||||
|
||||
const PER_PAGE = 6;
|
||||
const PER_PAGE = 3;
|
||||
|
||||
export const WorkflowList = () => {
|
||||
const searchTerm = useAppSelector(selectWorkflowSearchTerm);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [page, setPage] = useState(0);
|
||||
const useInfiniteQueryAry = () => {
|
||||
const categories = useAppSelector(selectWorkflowCategories);
|
||||
const orderBy = useAppSelector(selectWorkflowOrderBy);
|
||||
const direction = useAppSelector(selectWorkflowOrderDirection);
|
||||
const query = useAppSelector(selectWorkflowSearchTerm);
|
||||
const [debouncedQuery] = useDebounce(query, 500);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
}, [categories, query]);
|
||||
|
||||
const queryArg = useMemo<Parameters<typeof useListWorkflowsQuery>[0]>(() => {
|
||||
const queryArg = useMemo(() => {
|
||||
return {
|
||||
page,
|
||||
page: 0,
|
||||
per_page: PER_PAGE,
|
||||
order_by: orderBy,
|
||||
direction,
|
||||
categories,
|
||||
query: debouncedQuery,
|
||||
};
|
||||
}, [direction, orderBy, page, categories, debouncedQuery]);
|
||||
} satisfies Parameters<typeof useListWorkflowsQuery>[0];
|
||||
}, [orderBy, direction, categories, debouncedQuery]);
|
||||
|
||||
const { data, isLoading } = useListWorkflowsQuery(queryArg);
|
||||
return queryArg;
|
||||
};
|
||||
|
||||
const queryOptions = {
|
||||
selectFromResult: ({ data, ...rest }) => {
|
||||
return {
|
||||
items: data?.pages.map(({ items }) => items).flat() ?? EMPTY_ARRAY,
|
||||
...rest,
|
||||
} as const;
|
||||
},
|
||||
} satisfies Parameters<typeof useListWorkflowsInfiniteInfiniteQuery>[1];
|
||||
|
||||
export const WorkflowList = () => {
|
||||
const searchTerm = useAppSelector(selectWorkflowSearchTerm);
|
||||
const { t } = useTranslation();
|
||||
const queryArg = useInfiniteQueryAry();
|
||||
const { items, isFetching, isLoading, fetchNextPage, hasNextPage } = useListWorkflowsInfiniteInfiniteQuery(
|
||||
queryArg,
|
||||
queryOptions
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Flex alignItems="center" justifyContent="center" p={20}>
|
||||
<Flex alignItems="center" justifyContent="center" w="full" h="full">
|
||||
<Spinner />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data?.items.length) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<IAINoContentFallback
|
||||
fontSize="sm"
|
||||
@@ -65,15 +78,96 @@ export const WorkflowList = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex flexDir="column" gap={6}>
|
||||
<Grid templateColumns="repeat(2, minmax(200px, 3fr))" templateRows="1fr 1fr 1fr" gap={4}>
|
||||
{data?.items.map((workflow) => (
|
||||
<GridItem key={workflow.workflow_id}>
|
||||
<WorkflowListItem workflow={workflow} key={workflow.workflow_id} />
|
||||
</GridItem>
|
||||
))}
|
||||
</Grid>
|
||||
<WorkflowLibraryPagination page={page} setPage={setPage} data={data} />
|
||||
</Flex>
|
||||
<WorkflowListContent
|
||||
items={items}
|
||||
hasNextPage={hasNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
isFetching={isFetching}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const WorkflowListContent = memo(
|
||||
({
|
||||
items,
|
||||
hasNextPage,
|
||||
isFetching,
|
||||
fetchNextPage,
|
||||
}: {
|
||||
items: S['WorkflowRecordListItemWithThumbnailDTO'][];
|
||||
hasNextPage: boolean;
|
||||
isFetching: boolean;
|
||||
fetchNextPage: ReturnType<typeof useListWorkflowsInfiniteInfiniteQuery>['fetchNextPage'];
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const onScroll = useCallback(() => {
|
||||
if (!hasNextPage || isFetching) {
|
||||
return;
|
||||
}
|
||||
const el = ref.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
const { scrollTop, scrollHeight, clientHeight } = el;
|
||||
if (Math.abs(scrollHeight - (scrollTop + clientHeight)) <= 1) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [hasNextPage, isFetching, fetchNextPage]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!hasNextPage || isFetching) {
|
||||
return;
|
||||
}
|
||||
const el = ref.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
fetchNextPage();
|
||||
}, [hasNextPage, isFetching, fetchNextPage]);
|
||||
|
||||
// // TODO(psyche): this causes an infinite loop, the scrollIntoView triggers the onScroll which triggers the
|
||||
// // fetchNextPage which triggers the scrollIntoView again...
|
||||
// useEffect(() => {
|
||||
// const el = ref.current;
|
||||
// if (!el) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// const observer = new MutationObserver(() => {
|
||||
// el.querySelector(':scope > :last-child')?.scrollIntoView({ behavior: 'smooth' });
|
||||
// });
|
||||
|
||||
// observer.observe(el, { childList: true });
|
||||
|
||||
// return () => {
|
||||
// observer.disconnect();
|
||||
// };
|
||||
// }, []);
|
||||
|
||||
return (
|
||||
<Flex flexDir="column" gap={4} flex={1} minH={0}>
|
||||
<Grid
|
||||
ref={ref}
|
||||
templateColumns="repeat(auto-fit, minmax(340px, 3fr))"
|
||||
gridAutoFlow="dense"
|
||||
gap={4}
|
||||
overflow="scroll"
|
||||
onScroll={onScroll}
|
||||
>
|
||||
{items.map((workflow) => (
|
||||
<GridItem id={`grid-${workflow.workflow_id}`} key={workflow.workflow_id}>
|
||||
<WorkflowListItem workflow={workflow} key={workflow.workflow_id} />
|
||||
</GridItem>
|
||||
))}
|
||||
</Grid>
|
||||
<Spacer />
|
||||
<Button onClick={loadMore} isDisabled={!hasNextPage} isLoading={isFetching}>
|
||||
{t('nodes.loadMore')}
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
);
|
||||
WorkflowListContent.displayName = 'WorkflowListContent';
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Badge, Flex, Icon, Image, Spacer, Text } from '@invoke-ai/ui-library';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { selectWorkflowId } from 'features/nodes/store/workflowSlice';
|
||||
import { useLoadWorkflow } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
|
||||
import InvokeLogo from 'public/assets/images/invoke-symbol-wht-lrg.svg';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiImageBold, PiUsersBold } from 'react-icons/pi';
|
||||
import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types';
|
||||
@@ -17,17 +18,19 @@ import { ViewWorkflow } from './WorkflowLibraryListItemActions/ViewWorkflow';
|
||||
const IMAGE_THUMBNAIL_SIZE = '80px';
|
||||
const FALLBACK_ICON_SIZE = '24px';
|
||||
|
||||
const WORKFLOW_ACTION_BUTTONS_CN = 'workflow-action-buttons';
|
||||
|
||||
const sx: SystemStyleObject = {
|
||||
_hover: {
|
||||
bg: 'base.700',
|
||||
[`& .${WORKFLOW_ACTION_BUTTONS_CN}`]: {
|
||||
display: 'flex',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListItemWithThumbnailDTO }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const handleMouseOver = useCallback(() => {
|
||||
setIsHovered(true);
|
||||
}, []);
|
||||
|
||||
const handleMouseOut = useCallback(() => {
|
||||
setIsHovered(false);
|
||||
}, []);
|
||||
|
||||
const workflowId = useAppSelector(selectWorkflowId);
|
||||
const loadWorkflow = useLoadWorkflow();
|
||||
@@ -37,24 +40,22 @@ export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListIte
|
||||
}, [workflowId, workflow.workflow_id]);
|
||||
|
||||
const handleClickLoad = useCallback(() => {
|
||||
setIsHovered(false);
|
||||
loadWorkflow.loadWithDialog(workflow.workflow_id, 'view');
|
||||
}, [loadWorkflow, workflow.workflow_id]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
role="button"
|
||||
gap={4}
|
||||
onClick={handleClickLoad}
|
||||
cursor="pointer"
|
||||
bg="base.750"
|
||||
_hover={{ backgroundColor: 'base.700' }}
|
||||
p={2}
|
||||
ps={3}
|
||||
borderRadius="base"
|
||||
w="full"
|
||||
onMouseOver={handleMouseOver}
|
||||
onMouseOut={handleMouseOut}
|
||||
alignItems="stretch"
|
||||
sx={sx}
|
||||
>
|
||||
<Image
|
||||
src={workflow.thumbnail_url ?? undefined}
|
||||
@@ -95,7 +96,7 @@ export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListIte
|
||||
</Flex>
|
||||
|
||||
<Spacer />
|
||||
<Flex flexDir="column" gap={1} justifyContent="space-between">
|
||||
<Flex flexDir="column" gap={1} justifyContent="space-between" position="relative">
|
||||
<Flex gap={1} justifyContent="flex-end" w="full" p={2}>
|
||||
{workflow.category === 'project' && <Icon as={PiUsersBold} color="base.200" />}
|
||||
{workflow.category === 'default' && (
|
||||
@@ -103,7 +104,15 @@ export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListIte
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<Flex alignItems="center" gap={1} opacity={isHovered ? 1 : 0}>
|
||||
<Flex
|
||||
alignItems="center"
|
||||
gap={1}
|
||||
display="none"
|
||||
className={WORKFLOW_ACTION_BUTTONS_CN}
|
||||
position="absolute"
|
||||
right={0}
|
||||
bottom={0}
|
||||
>
|
||||
{workflow.category === 'default' && (
|
||||
<>
|
||||
{/* need to consider what is useful here and which icons show that. idea is to "try it out"/"view" or "clone for your own changes" */}
|
||||
|
||||
Reference in New Issue
Block a user