Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion invokeai/frontend/web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,10 @@
"move": "Move",
"useForPromptGeneration": "Use for Prompt Generation"
},
"stageViewer": {
"gridView": "Grid View",
"linearView": "Linear View"
},
"hotkeys": {
"hotkeys": "Hotkeys",
"searchHotkeys": "Search Hotkeys",
Expand Down Expand Up @@ -2886,7 +2890,8 @@
"launchpad": "Launchpad",
"workflowEditor": "Workflow Editor",
"imageViewer": "Viewer",
"canvas": "Canvas"
"canvas": "Canvas",
"stageViewer": "Stage"
},
"launchpad": {
"workflowsTitle": "Go deep with Workflows.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,10 @@ type PickerProps<T extends object> = {
* Whether the picker should be searchable. If true, renders a search input.
*/
searchable?: boolean;
/**
* An initial search term to populate the search input with.
*/
searchTerm?: string;
/**
* Initial state for group toggles. If provided, groups will start with these states instead of all being disabled.
*/
Expand Down Expand Up @@ -535,6 +539,7 @@ export const Picker = typedMemo(<T extends object>(props: PickerProps<T>) => {
OptionComponent = DefaultOptionComponent,
NextToSearchBar,
searchable,
searchTerm = '',
initialGroupStates,
} = props;
const rootRef = useRef<HTMLDivElement>(null);
Expand All @@ -553,7 +558,7 @@ export const Picker = typedMemo(<T extends object>(props: PickerProps<T>) => {
const $filteredOptionsCount = useComputed([$flattenedFilteredOptions], (options) => options.length);
const $hasFilteredOptions = useComputed([$filteredOptionsCount], (count) => count > 0);
const $selectedItem = useAtom<T | undefined>(undefined);
const $searchTerm = useAtom('');
const $searchTerm = useAtom(searchTerm);
const $selectedItemId = useComputed([$selectedItem], (item) => (item ? getOptionId(item) : undefined));

const selectIsCompactView = useMemo(() => buildSelectIsCompactView(pickerId), [pickerId]);
Expand Down
1 change: 1 addition & 0 deletions invokeai/frontend/web/src/common/hooks/focus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const log = logger('system');
const REGION_NAMES = [
'launchpad',
'viewer',
'stage',
'gallery',
'boards',
'layers',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import {
type BoxProps,
Button,
Flex,
Image,
Popover,
PopoverArrow,
PopoverBody,
PopoverContent,
PopoverTrigger,
Portal,
Spacer,
type SystemStyleObject,
Text,
} from '@invoke-ai/ui-library';
import { skipToken } from '@reduxjs/toolkit/query';
import { EMPTY_ARRAY } from 'app/store/constants';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { getRegex, Picker, usePickerContext } from 'common/components/Picker/Picker';
import { useDisclosure } from 'common/hooks/useBoolean';
import {
selectAutoAddBoardId,
selectAutoAssignBoardOnClick,
selectBoardSearchText,
selectListBoardsQueryArgs,
selectSelectedBoardId,
} from 'features/gallery/store/gallerySelectors';
import { autoAddBoardIdChanged, boardIdSelected } from 'features/gallery/store/gallerySlice';
import { memo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCaretDownBold } from 'react-icons/pi';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import type { BoardDTO } from 'services/api/types';

const isMatch = (board: BoardDTO, searchTerm: string) => {
const regex = getRegex(searchTerm);
const testString = `${board.board_name}`.toLowerCase();

if (testString.includes(searchTerm) || regex.test(testString)) {
return true;
}

return false;
};

export const BoardsDropdown = memo(() => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const popover = useDisclosure(false);
const pickerRef = useRef(null);

const autoAddBoardId = useAppSelector(selectAutoAddBoardId);
const autoAssignBoardOnClick = useAppSelector(selectAutoAssignBoardOnClick);

const boardSearchText = useAppSelector(selectBoardSearchText);
const selectedBoardId = useAppSelector(selectSelectedBoardId);
const queryArgs = useAppSelector(selectListBoardsQueryArgs);

const { data: boards } = useListAllBoardsQuery(queryArgs);

const selectedBoardData = boards?.find((board) => board.board_id === selectedBoardId);

const onClose = useCallback(() => {
popover.close();
}, [popover]);

const getOptionId = useCallback((option: BoardDTO) => option.board_id, []);

const onOptionSelect = useCallback(
(option: BoardDTO) => {
if (selectedBoardId !== option.board_id) {
dispatch(boardIdSelected({ boardId: option.board_id }));
}
if (autoAssignBoardOnClick && autoAddBoardId !== option.board_id) {
dispatch(autoAddBoardIdChanged(option.board_id));
}
},
[selectedBoardId, autoAssignBoardOnClick, autoAddBoardId, dispatch]
);

return (
// TODO: Add 'Uncategorized' Board Option
<Popover
isOpen={popover.isOpen}
onOpen={popover.open}
onClose={onClose}
// initialFocusRef={switcherRef.current?.inputRef}
>
<PopoverTrigger>
<Button size="sm" variant="outline" isDisabled={!boards || boards.length === 0} width="100%" maxWidth="200px">
{selectedBoardData?.board_name ?? t('boards.uncategorized')}
<Spacer />
<PiCaretDownBold />
</Button>
</PopoverTrigger>
<Portal appendToParentPortal={false}>
<PopoverContent p={0} w={400} h={400}>
<PopoverArrow />
<PopoverBody p={0} w="full" h="full" borderWidth={1} borderColor="base.700" borderRadius="base">
<Picker
pickerId="boards-picker"
handleRef={pickerRef}
optionsOrGroups={boards ?? EMPTY_ARRAY}
getOptionId={getOptionId}
isMatch={isMatch}
OptionComponent={BoardsSwitcherOptionComponent}
onSelect={onOptionSelect}
selectedOption={selectedBoardData}
NextToSearchBar={undefined}
searchable
searchTerm={boardSearchText}
/>
</PopoverBody>
</PopoverContent>
</Portal>
</Popover>
);
});

BoardsDropdown.displayName = 'BoardsDropdown';

const optionSx: SystemStyleObject = {
p: 1,
gap: 2,
alignItems: 'center',
cursor: 'pointer',
borderRadius: 'base',
'&[data-selected="true"]': {
bg: 'invokeBlue.300',
color: 'base.900',
'.extra-info': {
color: 'base.700',
},
'.picker-option': {
fontWeight: 'bold',
'&[data-is-compact="true"]': {
fontWeight: 'semibold',
},
},
'&[data-active="true"]': {
bg: 'invokeBlue.250',
},
},
'&[data-active="true"]': {
bg: 'base.750',
},
'&[data-disabled="true"]': {
cursor: 'not-allowed',
opacity: 0.5,
},
'&[data-is-compact="true"]': {
px: 1,
py: 0.5,

'& img': {
w: 8,
h: 8,
},
},
scrollMarginTop: '24px', // magic number, this is the height of the header
};

const BoardsSwitcherOptionComponent = memo(
({
option,
...rest
}: {
option: BoardDTO;
} & BoxProps) => {
const { board_name, cover_image_name } = option;
const { currentData: coverImage } = useGetImageDTOQuery(cover_image_name ?? skipToken);
const { isCompactView } = usePickerContext();

return (
<Flex {...rest} sx={optionSx} data-is-compact={isCompactView}>
<Image
src={coverImage?.thumbnail_url}
draggable={false}
w={10}
h={10}
borderRadius="base"
borderBottomRadius="lg"
objectFit="cover"
/>
<Flex flexDir="column" gap={1} flex={1}>
<Text className="picker-option">{board_name}</Text>
</Flex>
</Flex>
);
}
);

BoardsSwitcherOptionComponent.displayName = 'BoardsSwitcherOptionComponent';
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Divider, Flex, type SystemStyleObject } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { useStageFeed } from 'features/gallery/hooks/useStageFeed';
import { selectStageViewerMode } from 'features/ui/store/uiSelectors';
import { memo } from 'react';

import { StageViewerGridView } from './StageViewerGridView';
import { StageViewerLinearView } from './StageViewerLinearView';
import { StageViewerToolbar } from './StageViewerToolbar';

const StageViewerSx: SystemStyleObject = {
flexDir: 'column',
w: 'full',
h: 'full',
overflow: 'hidden',
gap: 2,
position: 'relative',
};

export const StageViewer = memo(() => {
const viewMode = useAppSelector(selectStageViewerMode);
const {feed} = useStageFeed();

return (
<Flex sx={StageViewerSx}>
<StageViewerToolbar />
<Divider />
<Flex flex={1} overflow="hidden">
{viewMode === 'grid' ? <StageViewerGridView feed={feed} /> : viewMode === 'linear' ? <StageViewerLinearView feed={feed} /> : null}
</Flex>
</Flex>
);
});

StageViewer.displayName = 'StageViewer';
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Flex, Image, Spinner } from "@invoke-ai/ui-library";
import { memo } from "react";
import { imagesApi } from "services/api/endpoints/images";

import type { StageFeedBoardItem, StageFeedQueueItem } from "./common";

export const StageViewerGridBoardItem = memo(({ item }: { item: StageFeedBoardItem }) => {
const imageName = item.id;
const { currentData: imageDTO, isUninitialized } = imagesApi.endpoints.getImageDTO.useQueryState(imageName);
imagesApi.endpoints.getImageDTO.useQuerySubscription(imageName, { skip: isUninitialized });

const aspectRatio = imageDTO ? imageDTO.width / imageDTO.height : 1;

return (
<Flex
flexDir="column"
>
<Flex
width="100%"
aspectRatio={aspectRatio}
bg="gray.700"
alignItems="center"
justifyContent="center"
>
{imageDTO && (
<Image
src={imageDTO.image_url}
alt={imageDTO.image_name}
objectFit="cover"
width="100%"
height="100%"
/>
)}
</Flex>

<Flex>
{imageDTO ? imageDTO.image_name : 'Loading...'}
</Flex>
</Flex>
);
});

StageViewerGridBoardItem.displayName = 'StageViewerGridBoardItem';

export const StageViewerGridQueueItem = memo(({ item }: {item: StageFeedQueueItem}) => {
const queueItemId = item.id;

return (
<Flex flexDir="column">
<Flex width="100%" aspectRatio={1} bg="gray.700" alignItems="center" justifyContent="center">
<Spinner size="lg" />
</Flex>
<Flex>
Queue Item: {queueItemId}
</Flex>
</Flex>
);
});

StageViewerGridQueueItem.displayName = 'StageViewerGridQueueItem';
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Box, Button, Flex } from '@invoke-ai/ui-library';
import { useStageFeedPagination } from 'features/gallery/hooks/useStageFeed';
import { memo } from 'react';

import type { StageViewProps } from './common';
import { StageViewerGridBoardItem, StageViewerGridQueueItem } from './StageViewerGridItem'

/**
* TODO:
*
* - Use Colcade for masonry layout
*/

export const StageViewerGridView = memo(({ feed }: StageViewProps) => {
const { visibleFeed, loadMore, hasMore, isLoading } = useStageFeedPagination(feed);

return (
<Flex flexDir="column" w="full" h="full" overflowY="auto">
<Box w="full" display="grid" gridTemplateColumns="repeat(auto-fill, minmax(200px, 1fr))" gap={4} p={2}>
{visibleFeed.map((item, index) => (
item.type === 'board_item' ? <StageViewerGridBoardItem key={index} item={item} /> : <StageViewerGridQueueItem key={index} item={item} />
))}
</Box>

{/* Pagination */}
{hasMore && (
<Flex w="full" justifyContent="center" p={4}>
<Button isLoading={isLoading} onClick={loadMore}>Load More</Button>
</Flex>
)}
</Flex>
);
});

StageViewerGridView.displayName = 'StageViewerGridView';
Loading
Loading