diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 617c4341574..bf7dd721155 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -475,10 +475,13 @@ "displayBoardSearch": "Board Search", "displaySearch": "Image Search", "download": "Download", + "expandGallery": "Show Gallery", + "collapseGallery": "Hide Gallery", "exitBoardSearch": "Exit Board Search", "exitSearch": "Exit Image Search", "featuresWillReset": "If you delete this image, those features will immediately be reset.", "galleryImageSize": "Image Size", + "columns": "Columns", "gallerySettings": "Gallery Settings", "go": "Go", "image": "image", @@ -523,7 +526,12 @@ "openViewer": "Open Viewer", "closeViewer": "Close Viewer", "move": "Move", - "useForPromptGeneration": "Use for Prompt Generation" + "useForPromptGeneration": "Use for Prompt Generation", + "showBoardsSidebar": "Expand Boards", + "hideBoardsSidebar": "Minimize Boards", + "showSquareThumbnails": "Show Square Thumbnails", + "showAspectRatioThumbnails": "Show Aspect Ratio Thumbnails", + "toggleAspectRatio": "Toggle Aspect Ratio" }, "hotkeys": { "hotkeys": "Hotkeys", diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/batchEnqueued.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/batchEnqueued.ts index fae7436d2e1..0b8c2ff7bca 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/batchEnqueued.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/batchEnqueued.ts @@ -1,6 +1,8 @@ import { logger } from 'app/logging/logger'; import type { AppStartListening } from 'app/store/store'; import { truncate } from 'es-toolkit/compat'; +import { addGalleryProgressItems } from 'features/gallery/store/galleryProgressStore'; +import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors'; import { zPydanticValidationError } from 'features/system/store/zodSchemas'; import { toast } from 'features/toast/toast'; import { t } from 'i18next'; @@ -14,7 +16,7 @@ export const addBatchEnqueuedListener = (startAppListening: AppStartListening) = // success startAppListening({ matcher: queueApi.endpoints.enqueueBatch.matchFulfilled, - effect: (action) => { + effect: (action, listenerApi) => { const enqueueResult = action.payload; const arg = action.meta.arg.originalArgs; log.debug({ enqueueResult } as JsonObject, 'Batch enqueued'); @@ -28,6 +30,14 @@ export const addBatchEnqueuedListener = (startAppListening: AppStartListening) = direction: arg.prepend ? t('queue.front') : t('queue.back'), }), }); + + // Track generate-destination batches as progress items in the gallery + const destination = enqueueResult.batch.destination; + if (destination === 'generate') { + const state = listenerApi.getState(); + const targetBoardId = selectAutoAddBoardId(state); + addGalleryProgressItems(enqueueResult.item_ids, enqueueResult.batch.batch_id ?? '', targetBoardId); + } }, }); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasLayersPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasLayersPanelContent.tsx index 4dd08edcd58..7164de3cf30 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasLayersPanelContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasLayersPanelContent.tsx @@ -15,7 +15,7 @@ export const CanvasLayersPanel = memo(() => { return ( - + diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/AutoAddBadge.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/AutoAddBadge.tsx deleted file mode 100644 index a8b1f9f4fbf..00000000000 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/AutoAddBadge.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Badge } from '@invoke-ai/ui-library'; -import { memo } from 'react'; -import { useTranslation } from 'react-i18next'; - -export const AutoAddBadge = memo(() => { - const { t } = useTranslation(); - return ( - - {t('common.auto')} - - ); -}); - -AutoAddBadge.displayName = 'AutoAddBadge'; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/AutoAddIndicator.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/AutoAddIndicator.tsx new file mode 100644 index 00000000000..13ea2b573bc --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/AutoAddIndicator.tsx @@ -0,0 +1,16 @@ +import { Flex } from '@invoke-ai/ui-library'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiCircleFill } from 'react-icons/pi'; + +export const AutoAddIndicator = memo(() => { + const { t } = useTranslation(); + + return ( + + + + ); +}); + +AutoAddIndicator.displayName = 'AutoAddIndicator'; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardAutoAddSelect.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardAutoAddSelect.tsx deleted file mode 100644 index 1f95c7378ba..00000000000 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardAutoAddSelect.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library'; -import { Combobox, FormControl, FormLabel } from '@invoke-ai/ui-library'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { selectAutoAddBoardId, selectAutoAssignBoardOnClick } from 'features/gallery/store/gallerySelectors'; -import { autoAddBoardIdChanged } from 'features/gallery/store/gallerySlice'; -import { memo, useCallback, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; - -const BoardAutoAddSelect = () => { - const dispatch = useAppDispatch(); - const { t } = useTranslation(); - const autoAddBoardId = useAppSelector(selectAutoAddBoardId); - const autoAssignBoardOnClick = useAppSelector(selectAutoAssignBoardOnClick); - const { options, hasBoards } = useListAllBoardsQuery( - {}, - { - selectFromResult: ({ data }) => { - const options: ComboboxOption[] = [ - { - label: t('common.none'), - value: 'none', - }, - ].concat( - (data ?? []).map(({ board_id, board_name }) => ({ - label: board_name, - value: board_id, - })) - ); - return { - options, - hasBoards: options.length > 1, - }; - }, - } - ); - - const onChange = useCallback( - (v) => { - if (!v) { - return; - } - dispatch(autoAddBoardIdChanged(v.value)); - }, - [dispatch] - ); - - const value = useMemo(() => options.find((o) => o.value === autoAddBoardId), [options, autoAddBoardId]); - - const noOptionsMessage = useCallback(() => t('boards.noMatching'), [t]); - - return ( - - {t('boards.autoAddBoard')} - - - ); -}; -export default memo(BoardAutoAddSelect); diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/AddBoardButton.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/AddBoardButton.tsx index 9f59e60fee8..2a9179afa3b 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/AddBoardButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/AddBoardButton.tsx @@ -1,4 +1,5 @@ -import { IconButton } from '@invoke-ai/ui-library'; +import type { IconButtonProps } from '@invoke-ai/ui-library'; +import { Button, IconButton } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { boardIdSelected, boardSearchTextChanged } from 'features/gallery/store/gallerySlice'; import { memo, useCallback } from 'react'; @@ -6,7 +7,7 @@ import { useTranslation } from 'react-i18next'; import { PiPlusBold } from 'react-icons/pi'; import { useCreateBoardMutation } from 'services/api/endpoints/boards'; -const AddBoardButton = () => { +const useAddBoard = () => { const { t } = useTranslation(); const dispatch = useAppDispatch(); const [createBoard, { isLoading }] = useCreateBoardMutation(); @@ -21,20 +22,47 @@ const AddBoardButton = () => { } }, [t, createBoard, dispatch]); + return { handleCreateBoard, isLoading, t }; +}; + +export const AddBoardButton = memo(() => { + const { handleCreateBoard, isLoading, t } = useAddBoard(); + return ( - } + + ); +}); + +AddBoardButton.displayName = 'AddBoardButton'; + +export const AddBoardIconButton = memo((props: Partial) => { + const { handleCreateBoard, isLoading, t } = useAddBoard(); + const { 'aria-label': ariaLabel = t('boards.addBoard'), ...rest } = props; + + return ( + } + isLoading={isLoading} + onClick={handleCreateBoard} + data-testid="add-board-icon-button" + {...rest} /> ); -}; +}); -export default memo(AddBoardButton); +AddBoardIconButton.displayName = 'AddBoardIconButton'; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardEditableTitle.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardEditableTitle.tsx index a78f5706e10..565f6b3ed15 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardEditableTitle.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardEditableTitle.tsx @@ -45,7 +45,7 @@ export const BoardEditableTitle = memo(({ board, isSelected }: Props) => { if (!editable.isEditing) { return ( - + { aria-label="edit name" icon={} size="sm" - variant="ghost" + variant="link" onClick={editable.startEditing} /> )} @@ -76,6 +76,8 @@ export const BoardEditableTitle = memo(({ board, isSelected }: Props) => { {...editable.inputProps} variant="outline" isDisabled={updateBoardResult.isLoading} + h={7} + px={2} _focusVisible={{ borderWidth: 1, borderColor: 'invokeBlueAlpha.400', borderRadius: 'base' }} /> ); diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardItem.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardItem.tsx new file mode 100644 index 00000000000..e0296d37c13 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardItem.tsx @@ -0,0 +1,226 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import { Box, Flex, Icon, Image, Text, Tooltip } from '@invoke-ai/ui-library'; +import { skipToken } from '@reduxjs/toolkit/query'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { selectCurrentUser } from 'features/auth/store/authSlice'; +import type { AddImageToBoardDndTargetData, RemoveImageFromBoardDndTargetData } from 'features/dnd/dnd'; +import { addImageToBoardDndTarget, removeImageFromBoardDndTarget } from 'features/dnd/dnd'; +import { DndDropTarget } from 'features/dnd/DndDropTarget'; +import { AutoAddIndicator } from 'features/gallery/components/Boards/AutoAddIndicator'; +import BoardContextMenu from 'features/gallery/components/Boards/BoardContextMenu'; +import { BoardEditableTitle } from 'features/gallery/components/Boards/BoardsList/BoardEditableTitle'; +import { BoardTooltip } from 'features/gallery/components/Boards/BoardsList/BoardTooltip'; +import NoBoardBoardContextMenu from 'features/gallery/components/Boards/NoBoardBoardContextMenu'; +import { + selectAutoAddBoardId, + selectAutoAssignBoardOnClick, + selectSelectedBoardId, +} from 'features/gallery/store/gallerySelectors'; +import { autoAddBoardIdChanged, boardIdSelected } from 'features/gallery/store/gallerySlice'; +import type { ReactNode, RefObject } from 'react'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiArchiveBold, PiImageSquare } from 'react-icons/pi'; +import { useGetBoardAssetsTotalQuery, useGetBoardImagesTotalQuery } from 'services/api/endpoints/boards'; +import { useGetImageDTOQuery } from 'services/api/endpoints/images'; +import { useBoardName } from 'services/api/hooks/useBoardName'; +import type { BoardDTO } from 'services/api/types'; + +const _hover: SystemStyleObject = { + bg: 'base.850', +}; + +type BoardItemProps = { + /** Pass a BoardDTO for a regular board, or null for the "Uncategorized" board. */ + board: BoardDTO | null; + isSelected: boolean; + isCollapsed?: boolean; +}; + +const BoardItem = ({ board, isSelected, isCollapsed = false }: BoardItemProps) => { + const dispatch = useAppDispatch(); + const { t } = useTranslation(); + const autoAddBoardId = useAppSelector(selectAutoAddBoardId); + const autoAssignBoardOnClick = useAppSelector(selectAutoAssignBoardOnClick); + const selectedBoardId = useAppSelector(selectSelectedBoardId); + const currentUser = useAppSelector(selectCurrentUser); + + const boardId = board?.board_id ?? 'none'; + + const onClick = useCallback(() => { + if (board) { + if (selectedBoardId !== board.board_id) { + dispatch(boardIdSelected({ boardId: board.board_id })); + } + if (autoAssignBoardOnClick && autoAddBoardId !== board.board_id) { + dispatch(autoAddBoardIdChanged(board.board_id)); + } + } else { + dispatch(boardIdSelected({ boardId: 'none' })); + if (autoAssignBoardOnClick) { + dispatch(autoAddBoardIdChanged('none')); + } + } + }, [board, selectedBoardId, autoAssignBoardOnClick, autoAddBoardId, dispatch]); + + const dndTargetData = useMemo(() => { + if (board) { + return addImageToBoardDndTarget.getData({ boardId: board.board_id }); + } + return removeImageFromBoardDndTarget.getData(); + }, [board]); + + const dndTarget = board ? addImageToBoardDndTarget : removeImageFromBoardDndTarget; + + // For the uncategorized board (board === null), counts must be fetched via separate queries. + // For regular boards, counts are available directly on the BoardDTO. + const { imagesTotal: noBoardImagesTotal } = useGetBoardImagesTotalQuery(board ? skipToken : 'none', { + selectFromResult: ({ data }) => ({ imagesTotal: data?.total ?? 0 }), + }); + const { assetsTotal: noBoardAssetsTotal } = useGetBoardAssetsTotalQuery(board ? skipToken : 'none', { + selectFromResult: ({ data }) => ({ assetsTotal: data?.total ?? 0 }), + }); + + const boardCounts = useMemo( + () => ({ + image_count: board ? board.image_count : noBoardImagesTotal, + asset_count: board ? board.asset_count : noBoardAssetsTotal, + }), + [board, noBoardImagesTotal, noBoardAssetsTotal] + ); + + const showOwner = currentUser?.is_admin && board?.owner_username; + + const contextMenuContent = (ref: RefObject, tooltipLabel: ReactNode, innerContent: ReactNode) => ( + + + {innerContent} + + + ); + + const titleContent = board ? ( + + + + ) : ( + + ); + + const countsContent = ( + + + {boardCounts.image_count} | {boardCounts.asset_count} + + + ); + + const tooltipLabel = ; + + const expandedInnerContent = ( + <> + + + {titleContent} + {showOwner && ( + + {board.owner_username} + + )} + + {autoAddBoardId === boardId && } + {board?.archived && } + {countsContent} + + ); + + const collapsedInnerContent = ( + <> + + + ); + + return ( + + {board ? ( + + {(ref) => contextMenuContent(ref, tooltipLabel, isCollapsed ? collapsedInnerContent : expandedInnerContent)} + + ) : ( + + {(ref) => contextMenuContent(ref, tooltipLabel, isCollapsed ? collapsedInnerContent : expandedInnerContent)} + + )} + + + ); +}; + +export default memo(BoardItem); + +/** + * Thumbnail for a board item. Shows the cover image for regular boards, + * or the Invoke logo SVG for the "Uncategorized" board. + */ +const BoardThumbnail = memo(({ board }: { board: BoardDTO | null }) => { + const { currentData: coverImage } = useGetImageDTOQuery(board?.cover_image_name ?? skipToken); + + if (board && coverImage) { + return ( + + ); + } + + if (board) { + return ( + + + + ); + } + + // Uncategorized board - show Invoke logo + return ( + + + + + + ); +}); + +BoardThumbnail.displayName = 'BoardThumbnail'; + +/** + * Static title for the "Uncategorized" board (not editable). + */ +const NoBoardTitle = memo(({ isSelected }: { isSelected: boolean }) => { + const boardName = useBoardName('none'); + return ( + + {boardName} + + ); +}); + +NoBoardTitle.displayName = 'NoBoardTitle'; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx index 2d37a03f69f..8b05f0134e9 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx @@ -1,4 +1,4 @@ -import { Collapse, Flex, Text, useDisclosure } from '@invoke-ai/ui-library'; +import { Flex, IconButton, Text } from '@invoke-ai/ui-library'; import { EMPTY_ARRAY } from 'app/store/constants'; import { useAppSelector } from 'app/store/storeHooks'; import { fixTooltipCloseOnScrollStyles } from 'common/util/fixTooltipCloseOnScrollStyles'; @@ -7,21 +7,30 @@ import { selectListBoardsQueryArgs, selectSelectedBoardId, } from 'features/gallery/store/gallerySelectors'; -import { memo, useMemo } from 'react'; +import { memo, useCallback, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; +import { PiMagnifyingGlassBold, PiSidebarSimpleBold } from 'react-icons/pi'; import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; -import AddBoardButton from './AddBoardButton'; -import GalleryBoard from './GalleryBoard'; -import NoBoardBoard from './NoBoardBoard'; +import { AddBoardIconButton } from './AddBoardButton'; +import BoardItem from './BoardItem'; +import { BoardsSearch } from './BoardsSearch'; +import { BoardsSettingsPopover } from './BoardsSettingsPopover'; -export const BoardsList = memo(() => { +type BoardsListProps = { + isCollapsed?: boolean; + onCollapseBoards?: () => void; + onExpandBoards?: () => void; + showHeaderAddButton?: boolean; +}; + +export const BoardsList = memo(({ onCollapseBoards, onExpandBoards, isCollapsed }: BoardsListProps) => { const { t } = useTranslation(); const selectedBoardId = useAppSelector(selectSelectedBoardId); const boardSearchText = useAppSelector(selectBoardSearchText); const queryArgs = useAppSelector(selectListBoardsQueryArgs); const { data: boards } = useListAllBoardsQuery(queryArgs); - const { isOpen } = useDisclosure({ defaultIsOpen: true }); + const searchInputRef = useRef(null); const filteredBoards = useMemo(() => { if (!boards) { @@ -39,47 +48,107 @@ export const BoardsList = memo(() => { const elements = []; if (!boardSearchText.length) { - elements.push(); + elements.push( + + ); } filteredBoards.forEach((board) => { elements.push( - + ); }); return elements; - }, [boardSearchText.length, filteredBoards, selectedBoardId]); + }, [boardSearchText.length, filteredBoards, selectedBoardId, isCollapsed]); + + const onFocusSearch = useCallback(() => { + if (onExpandBoards) { + onExpandBoards(); + } + setTimeout(() => { + searchInputRef.current?.focus(); + }, 0); + }, [onExpandBoards]); return ( - - - {t('boards.boards')} - - - - - - {boardElements.length ? ( - boardElements - ) : ( - - {t('boards.noBoards', { boardType: boardSearchText.length ? 'Matching' : '' })} + {isCollapsed ? ( + <> + + } + onClick={onExpandBoards} + tooltip={t('gallery.showBoardsSidebar')} + aria-label={t('gallery.showBoardsSidebar')} + variant="ghost" + /> + } + onClick={onFocusSearch} + tooltip={t('boards.searchBoard')} + aria-label={t('boards.searchBoard')} + variant="ghost" + /> + + + {boardElements.length ? ( + boardElements + ) : ( + + {t('boards.noBoards', { boardType: boardSearchText.length ? 'Matching' : '' })} + + )} + + + ) : ( + <> + + + {t('boards.boards')} - )} - - + + } + onClick={onCollapseBoards} + tooltip={t('gallery.hideBoardsSidebar')} + aria-label={t('gallery.toggleBoardsSidebar')} + /> + + + + + + + + + {boardElements.length ? ( + boardElements + ) : ( + + {t('boards.noBoards', { boardType: boardSearchText.length ? 'Matching' : '' })} + + )} + + + )} ); }); diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsListWrapper.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsListWrapper.tsx deleted file mode 100644 index e7fa512d067..00000000000 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsListWrapper.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; -import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; -import { autoScrollForExternal } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/external'; -import { Box } from '@invoke-ai/ui-library'; -import { overlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants'; -import type { OverlayScrollbarsComponentRef } from 'overlayscrollbars-react'; -import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; -import type { CSSProperties } from 'react'; -import { memo, useEffect, useState } from 'react'; - -import { BoardsList } from './BoardsList'; - -const overlayScrollbarsStyles: CSSProperties = { - height: '100%', - width: '100%', -}; - -export const BoardsListWrapper = memo(() => { - const [os, osRef] = useState(null); - useEffect(() => { - const osInstance = os?.osInstance(); - - if (!osInstance) { - return; - } - - const element = osInstance.elements().viewport; - - // `pragmatic-drag-and-drop-auto-scroll` requires the element to have `overflow-y: scroll` or `overflow-y: auto` - // else it logs an ugly warning. In our case, using a custom scrollbar library, it will be 'hidden' by default. - // To prevent the erroneous warning, we temporarily set the overflow-y to 'scroll' and then revert it back. - const overflowY = element.style.overflowY; // starts 'hidden' - element.style.setProperty('overflow-y', 'scroll', 'important'); - const cleanup = combine(autoScrollForElements({ element }), autoScrollForExternal({ element })); - element.style.setProperty('overflow-y', overflowY); - - return cleanup; - }, [os]); - - return ( - - - - - - - - ); -}); - -BoardsListWrapper.displayName = 'BoardsListWrapper'; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsSearch.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsSearch.tsx index 5c578a55398..f8444f64b4c 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsSearch.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsSearch.tsx @@ -3,64 +3,69 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { selectBoardSearchText } from 'features/gallery/store/gallerySelectors'; import { boardSearchTextChanged } from 'features/gallery/store/gallerySlice'; import type { ChangeEvent, KeyboardEvent } from 'react'; -import { memo, useCallback } from 'react'; +import { forwardRef, memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiXBold } from 'react-icons/pi'; -export const BoardsSearch = memo(() => { - const dispatch = useAppDispatch(); - const boardSearchText = useAppSelector(selectBoardSearchText); - const { t } = useTranslation(); +export const BoardsSearch = memo( + forwardRef((_, ref) => { + const dispatch = useAppDispatch(); + const boardSearchText = useAppSelector(selectBoardSearchText); + const { t } = useTranslation(); - const handleBoardSearch = useCallback( - (searchTerm: string) => { - dispatch(boardSearchTextChanged(searchTerm)); - }, - [dispatch] - ); + const handleBoardSearch = useCallback( + (searchTerm: string) => { + dispatch(boardSearchTextChanged(searchTerm)); + }, + [dispatch] + ); - const clearBoardSearch = useCallback(() => { - dispatch(boardSearchTextChanged('')); - }, [dispatch]); + const clearBoardSearch = useCallback(() => { + dispatch(boardSearchTextChanged('')); + }, [dispatch]); - const handleKeydown = useCallback( - (e: KeyboardEvent) => { - // exit search mode on escape - if (e.key === 'Escape') { - clearBoardSearch(); - } - }, - [clearBoardSearch] - ); + const handleKeydown = useCallback( + (e: KeyboardEvent) => { + // exit search mode on escape + if (e.key === 'Escape') { + clearBoardSearch(); + } + }, + [clearBoardSearch] + ); - const handleChange = useCallback( - (e: ChangeEvent) => { - handleBoardSearch(e.target.value); - }, - [handleBoardSearch] - ); + const handleChange = useCallback( + (e: ChangeEvent) => { + handleBoardSearch(e.target.value); + }, + [handleBoardSearch] + ); + + return ( + + + {boardSearchText && boardSearchText.length && ( + + } + /> + + )} + + ); + }) +); - return ( - - - {boardSearchText && boardSearchText.length && ( - - } - /> - - )} - - ); -}); BoardsSearch.displayName = 'BoardsSearch'; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsSettingsPopover.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsSettingsPopover.tsx new file mode 100644 index 00000000000..73066153b3a --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsSettingsPopover.tsx @@ -0,0 +1,155 @@ +import type { ComboboxOption, IconButtonProps } from '@invoke-ai/ui-library'; +import { + Combobox, + Flex, + FormControl, + FormLabel, + IconButton, + Popover, + PopoverArrow, + PopoverBody, + PopoverContent, + PopoverTrigger, + Portal, + Switch, +} from '@invoke-ai/ui-library'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import type { SingleValue } from 'chakra-react-select'; +import { + autoAssignBoardOnClickChanged, + boardsListOrderByChanged, + boardsListOrderDirChanged, + selectGallerySlice, + shouldShowArchivedBoardsChanged, +} from 'features/gallery/store/gallerySlice'; +import type { ChangeEvent } from 'react'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiGearSixFill } from 'react-icons/pi'; +import { assert } from 'tsafe'; + +const selectBoardsSettings = createSelector(selectGallerySlice, (gallery) => ({ + autoAssignBoardOnClick: gallery.autoAssignBoardOnClick, + shouldShowArchivedBoards: gallery.shouldShowArchivedBoards, + boardsListOrderBy: gallery.boardsListOrderBy, + boardsListOrderDir: gallery.boardsListOrderDir, +})); + +export const BoardsSettingsPopover = memo((iconButtonProps: Partial) => { + const dispatch = useAppDispatch(); + const { t } = useTranslation(); + const { autoAssignBoardOnClick, shouldShowArchivedBoards, boardsListOrderBy, boardsListOrderDir } = + useAppSelector(selectBoardsSettings); + + const orderByOptions = useMemo( + () => [ + { value: 'created_at', label: t('common.created') }, + { value: 'board_name', label: t('common.name', { defaultValue: 'Name' }) }, + ], + [t] + ); + + const orderDirOptions = useMemo( + () => [ + { value: 'ASC', label: t('queue.sortOrderAscending') }, + { value: 'DESC', label: t('queue.sortOrderDescending') }, + ], + [t] + ); + + const onChangeShowArchivedBoards = useCallback( + (e: ChangeEvent) => dispatch(shouldShowArchivedBoardsChanged(e.target.checked)), + [dispatch] + ); + + const onChangeAutoAssignBoardOnClick = useCallback( + (e: ChangeEvent) => dispatch(autoAssignBoardOnClickChanged(e.target.checked)), + [dispatch] + ); + + const onChangeOrderBy = useCallback( + (v: SingleValue) => { + assert(v?.value === 'created_at' || v?.value === 'board_name'); + dispatch(boardsListOrderByChanged(v.value)); + }, + [dispatch] + ); + + const onChangeOrderDir = useCallback( + (v: SingleValue) => { + assert(v?.value === 'ASC' || v?.value === 'DESC'); + dispatch(boardsListOrderDirChanged(v.value)); + }, + [dispatch] + ); + + const orderByValue = useMemo( + () => orderByOptions.find((opt) => opt.value === boardsListOrderBy), + [boardsListOrderBy, orderByOptions] + ); + const orderDirValue = useMemo( + () => orderDirOptions.find((opt) => opt.value === boardsListOrderDir), + [boardsListOrderDir, orderDirOptions] + ); + + return ( + + + } + aria-label={t('gallery.boardsSettings')} + tooltip={t('gallery.boardsSettings')} + {...iconButtonProps} + /> + + + + + + + + + {t('gallery.showArchivedBoards')} + + + + + + {t('gallery.autoAssignBoardOnClick')} + + + + + + {t('common.orderBy')} + + + + + + {t('common.direction')} + + + + + + + + + ); +}); + +BoardsSettingsPopover.displayName = 'BoardsSettingsPopover'; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx deleted file mode 100644 index 4d821f819c6..00000000000 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import type { SystemStyleObject } from '@invoke-ai/ui-library'; -import { Box, Flex, Icon, Image, Text, Tooltip } from '@invoke-ai/ui-library'; -import { skipToken } from '@reduxjs/toolkit/query'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { selectCurrentUser } from 'features/auth/store/authSlice'; -import type { AddImageToBoardDndTargetData } from 'features/dnd/dnd'; -import { addImageToBoardDndTarget } from 'features/dnd/dnd'; -import { DndDropTarget } from 'features/dnd/DndDropTarget'; -import { AutoAddBadge } from 'features/gallery/components/Boards/AutoAddBadge'; -import BoardContextMenu from 'features/gallery/components/Boards/BoardContextMenu'; -import { BoardEditableTitle } from 'features/gallery/components/Boards/BoardsList/BoardEditableTitle'; -import { BoardTooltip } from 'features/gallery/components/Boards/BoardsList/BoardTooltip'; -import { - selectAutoAddBoardId, - selectAutoAssignBoardOnClick, - selectSelectedBoardId, -} from 'features/gallery/store/gallerySelectors'; -import { autoAddBoardIdChanged, boardIdSelected } from 'features/gallery/store/gallerySlice'; -import { memo, useCallback, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { PiArchiveBold, PiImageSquare } from 'react-icons/pi'; -import { useGetImageDTOQuery } from 'services/api/endpoints/images'; -import type { BoardDTO } from 'services/api/types'; - -const _hover: SystemStyleObject = { - bg: 'base.850', -}; - -interface GalleryBoardProps { - board: BoardDTO; - isSelected: boolean; -} - -const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => { - const dispatch = useAppDispatch(); - const { t } = useTranslation(); - const autoAddBoardId = useAppSelector(selectAutoAddBoardId); - const autoAssignBoardOnClick = useAppSelector(selectAutoAssignBoardOnClick); - const selectedBoardId = useAppSelector(selectSelectedBoardId); - const currentUser = useAppSelector(selectCurrentUser); - const onClick = useCallback(() => { - if (selectedBoardId !== board.board_id) { - dispatch(boardIdSelected({ boardId: board.board_id })); - } - if (autoAssignBoardOnClick && autoAddBoardId !== board.board_id) { - dispatch(autoAddBoardIdChanged(board.board_id)); - } - }, [selectedBoardId, board.board_id, autoAssignBoardOnClick, autoAddBoardId, dispatch]); - - const dndTargetData = useMemo( - () => addImageToBoardDndTarget.getData({ boardId: board.board_id }), - [board.board_id] - ); - - const boardCounts = useMemo( - () => ({ - image_count: board.image_count, - asset_count: board.asset_count, - }), - [board] - ); - - const showOwner = currentUser?.is_admin && board.owner_username; - - return ( - - - {(ref) => ( - } - openDelay={1000} - placement="right" - closeOnScroll - p={2} - > - - - - - {showOwner && ( - - {board.owner_username} - - )} - - {autoAddBoardId === board.board_id && } - {board.archived && } - - - {board.image_count} | {board.asset_count} - - - - - )} - - - - ); -}; - -export default memo(GalleryBoard); - -const CoverImage = ({ board }: { board: BoardDTO }) => { - const { currentData: coverImage } = useGetImageDTOQuery(board.cover_image_name ?? skipToken); - - if (coverImage) { - return ( - - ); - } - - return ( - - - - ); -}; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx deleted file mode 100644 index 900799f8ed7..00000000000 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import type { SystemStyleObject } from '@invoke-ai/ui-library'; -import { Box, Flex, Icon, Text, Tooltip } from '@invoke-ai/ui-library'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import type { RemoveImageFromBoardDndTargetData } from 'features/dnd/dnd'; -import { removeImageFromBoardDndTarget } from 'features/dnd/dnd'; -import { DndDropTarget } from 'features/dnd/DndDropTarget'; -import { AutoAddBadge } from 'features/gallery/components/Boards/AutoAddBadge'; -import { BoardTooltip } from 'features/gallery/components/Boards/BoardsList/BoardTooltip'; -import NoBoardBoardContextMenu from 'features/gallery/components/Boards/NoBoardBoardContextMenu'; -import { - selectAutoAddBoardId, - selectAutoAssignBoardOnClick, - selectBoardSearchText, -} from 'features/gallery/store/gallerySelectors'; -import { autoAddBoardIdChanged, boardIdSelected } from 'features/gallery/store/gallerySlice'; -import { memo, useCallback, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useGetBoardAssetsTotalQuery, useGetBoardImagesTotalQuery } from 'services/api/endpoints/boards'; -import { useBoardName } from 'services/api/hooks/useBoardName'; - -interface Props { - isSelected: boolean; -} - -const _hover: SystemStyleObject = { - bg: 'base.850', -}; - -const NoBoardBoard = memo(({ isSelected }: Props) => { - const dispatch = useAppDispatch(); - const { imagesTotal } = useGetBoardImagesTotalQuery('none', { - selectFromResult: ({ data }) => { - return { imagesTotal: data?.total ?? 0 }; - }, - }); - const { assetsTotal } = useGetBoardAssetsTotalQuery('none', { - selectFromResult: ({ data }) => { - return { assetsTotal: data?.total ?? 0 }; - }, - }); - const autoAddBoardId = useAppSelector(selectAutoAddBoardId); - const autoAssignBoardOnClick = useAppSelector(selectAutoAssignBoardOnClick); - const boardSearchText = useAppSelector(selectBoardSearchText); - const boardName = useBoardName('none'); - const handleSelectBoard = useCallback(() => { - dispatch(boardIdSelected({ boardId: 'none' })); - if (autoAssignBoardOnClick) { - dispatch(autoAddBoardIdChanged('none')); - } - }, [dispatch, autoAssignBoardOnClick]); - - const dndTargetData = useMemo(() => removeImageFromBoardDndTarget.getData(), []); - - const { t } = useTranslation(); - - if (boardSearchText.length) { - return null; - } - - return ( - - - {(ref) => ( - } - openDelay={1000} - placement="right" - closeOnScroll - > - - - {/* iconified from public/assets/images/invoke-symbol-wht-lrg.svg */} - - - - - - - {boardName} - - {autoAddBoardId === 'none' && } - - {imagesTotal} | {assetsTotal} - - - - )} - - - - ); -}); - -NoBoardBoard.displayName = 'HoverableBoard'; - -export default memo(NoBoardBoard); diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsListSortControls.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsListSortControls.tsx deleted file mode 100644 index e4ed79189b6..00000000000 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsListSortControls.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library'; -import { Combobox, Flex, FormControl, FormLabel } from '@invoke-ai/ui-library'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { selectBoardsListOrderBy, selectBoardsListOrderDir } from 'features/gallery/store/gallerySelectors'; -import { boardsListOrderByChanged, boardsListOrderDirChanged } from 'features/gallery/store/gallerySlice'; -import { memo, useCallback, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { z } from 'zod'; - -const zOrderBy = z.enum(['created_at', 'board_name']); -type OrderBy = z.infer; -const isOrderBy = (v: unknown): v is OrderBy => zOrderBy.safeParse(v).success; - -const zDirection = z.enum(['ASC', 'DESC']); -type Direction = z.infer; -const isDirection = (v: unknown): v is Direction => zDirection.safeParse(v).success; - -export const BoardsListSortControls = memo(() => { - const { t } = useTranslation(); - - const orderBy = useAppSelector(selectBoardsListOrderBy); - const direction = useAppSelector(selectBoardsListOrderDir); - - const ORDER_BY_OPTIONS: ComboboxOption[] = useMemo( - () => [ - { value: 'created_at', label: t('workflows.created') }, - { value: 'board_name', label: t('workflows.name') }, - ], - [t] - ); - - const DIRECTION_OPTIONS: ComboboxOption[] = useMemo( - () => [ - { value: 'ASC', label: t('workflows.ascending') }, - { value: 'DESC', label: t('workflows.descending') }, - ], - [t] - ); - - const dispatch = useAppDispatch(); - - const onChangeOrderBy = useCallback( - (v) => { - if (!isOrderBy(v?.value) || v.value === orderBy) { - return; - } - dispatch(boardsListOrderByChanged(v.value)); - }, - [orderBy, dispatch] - ); - const valueOrderBy = useMemo(() => { - return ORDER_BY_OPTIONS.find((o) => o.value === orderBy) || ORDER_BY_OPTIONS[0]; - }, [orderBy, ORDER_BY_OPTIONS]); - - const onChangeDirection = useCallback( - (v) => { - if (!isDirection(v?.value) || v.value === direction) { - return; - } - dispatch(boardsListOrderDirChanged(v.value)); - }, - [direction, dispatch] - ); - const valueDirection = useMemo( - () => DIRECTION_OPTIONS.find((o) => o.value === direction), - [direction, DIRECTION_OPTIONS] - ); - - return ( - - - {t('common.orderBy')} - - - - {t('common.direction')} - - - - ); -}); -BoardsListSortControls.displayName = 'BoardsListSortControls'; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsSettingsPopover.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsSettingsPopover.tsx deleted file mode 100644 index 3fef611f99b..00000000000 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsSettingsPopover.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { - Divider, - Flex, - IconButton, - Popover, - PopoverArrow, - PopoverBody, - PopoverContent, - PopoverTrigger, - Portal, - Text, -} from '@invoke-ai/ui-library'; -import BoardAutoAddSelect from 'features/gallery/components/Boards/BoardAutoAddSelect'; -import AutoAssignBoardCheckbox from 'features/gallery/components/GallerySettingsPopover/AutoAssignBoardCheckbox'; -import ShowArchivedBoardsCheckbox from 'features/gallery/components/GallerySettingsPopover/ShowArchivedBoardsCheckbox'; -import { memo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { PiGearSixFill } from 'react-icons/pi'; - -import { BoardsListSortControls } from './BoardsListSortControls'; - -export const BoardsSettingsPopover = memo(() => { - const { t } = useTranslation(); - - return ( - - - } - tooltip={t('gallery.boardsSettings')} - /> - - - - - - - - Boards Settings - - - - - - - - - - - - - - - - - ); -}); -BoardsSettingsPopover.displayName = 'BoardsSettingsPopover'; diff --git a/invokeai/frontend/web/src/features/gallery/components/BoardsListPanelContent.tsx b/invokeai/frontend/web/src/features/gallery/components/BoardsListPanelContent.tsx deleted file mode 100644 index fa93c597f3e..00000000000 --- a/invokeai/frontend/web/src/features/gallery/components/BoardsListPanelContent.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { Box, Button, Collapse, Divider, Flex, IconButton } from '@invoke-ai/ui-library'; -import { useStore } from '@nanostores/react'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { useDisclosure } from 'common/hooks/useBoolean'; -import { BoardsListWrapper } from 'features/gallery/components/Boards/BoardsList/BoardsListWrapper'; -import { BoardsSearch } from 'features/gallery/components/Boards/BoardsList/BoardsSearch'; -import { BoardsSettingsPopover } from 'features/gallery/components/Boards/BoardsSettingsPopover'; -import { selectBoardSearchText } from 'features/gallery/store/gallerySelectors'; -import { boardSearchTextChanged } from 'features/gallery/store/gallerySlice'; -import { useAutoLayoutContext } from 'features/ui/layouts/auto-layout-context'; -import { - BOARD_PANEL_DEFAULT_HEIGHT_PX, - BOARD_PANEL_MIN_EXPANDED_HEIGHT_PX, - BOARD_PANEL_MIN_HEIGHT_PX, - BOARDS_PANEL_ID, -} from 'features/ui/layouts/shared'; -import { useCollapsibleGridviewPanel } from 'features/ui/layouts/use-collapsible-gridview-panel'; -import type { CSSProperties } from 'react'; -import { memo, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { PiCaretDownBold, PiCaretUpBold, PiMagnifyingGlassBold } from 'react-icons/pi'; - -const COLLAPSE_STYLES: CSSProperties = { flexShrink: 0, minHeight: 0 }; - -export const BoardsPanel = memo(() => { - const boardSearchText = useAppSelector(selectBoardSearchText); - const searchDisclosure = useDisclosure(!!boardSearchText); - const { tab } = useAutoLayoutContext(); - const collapsibleApi = useCollapsibleGridviewPanel( - tab, - BOARDS_PANEL_ID, - 'vertical', - BOARD_PANEL_DEFAULT_HEIGHT_PX, - BOARD_PANEL_MIN_HEIGHT_PX, - BOARD_PANEL_MIN_EXPANDED_HEIGHT_PX - ); - const isCollapsed = useStore(collapsibleApi.$isCollapsed); - const { t } = useTranslation(); - const dispatch = useAppDispatch(); - - const onClickBoardSearch = useCallback(() => { - if (boardSearchText.length) { - dispatch(boardSearchTextChanged('')); - } - if (!searchDisclosure.isOpen && collapsibleApi.$isCollapsed.get()) { - collapsibleApi.expand(); - } - searchDisclosure.toggle(); - }, [boardSearchText.length, searchDisclosure, collapsibleApi, dispatch]); - - return ( - - - - - - - - } - colorScheme={searchDisclosure.isOpen ? 'invokeBlue' : 'base'} - /> - - - - - - - - - - - ); -}); -BoardsPanel.displayName = 'BoardsPanel'; diff --git a/invokeai/frontend/web/src/features/gallery/components/BottomGalleryPanel.tsx b/invokeai/frontend/web/src/features/gallery/components/BottomGalleryPanel.tsx new file mode 100644 index 00000000000..ddec570e522 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/BottomGalleryPanel.tsx @@ -0,0 +1,228 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import { Box, Button, Flex } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { overlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants'; +import type { GridviewPanel } from 'dockview'; +import { AddBoardButton, AddBoardIconButton } from 'features/gallery/components/Boards/BoardsList/AddBoardButton'; +import { BoardsList } from 'features/gallery/components/Boards/BoardsList/BoardsList'; +import { GalleryImageGrid } from 'features/gallery/components/GalleryImageGrid'; +import { GalleryImageGridPaged } from 'features/gallery/components/GalleryImageGridPaged'; +import { selectSelectedBoardId } from 'features/gallery/store/gallerySelectors'; +import { useAutoLayoutContext } from 'features/ui/layouts/auto-layout-context'; +import { navigationApi } from 'features/ui/layouts/navigation-api'; +import { + BOARDS_SIDEBAR_DEFAULT_WIDTH_PX, + BOARDS_SIDEBAR_MAX_WIDTH_PX, + BOARDS_SIDEBAR_MIN_WIDTH_PX, + BOTTOM_GALLERY_MIN_HEIGHT_PX, + BOTTOM_GALLERY_PANEL_ID, +} from 'features/ui/layouts/shared'; +import { selectShouldUsePagedGalleryView } from 'features/ui/store/uiSelectors'; +import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; +import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react'; +import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiCaretDownBold, PiCaretUpBold } from 'react-icons/pi'; +import { useBoardName } from 'services/api/hooks/useBoardName'; + +import { GalleryHeader } from './GalleryHeader'; + +const HEADER_STYLES_SX: SystemStyleObject = { + gap: 2, + p: 2, + alignItems: 'center', + w: 'full', + flexShrink: 0, + borderColor: 'base.700', + bg: 'base.850', + h: 12, +}; + +const overlayScrollbarsStyles: CSSProperties = { + height: '100%', + width: '100%', +}; + +const BOARDS_SIDEBAR_STYLES_SX: SystemStyleObject = { + h: 'full', + flexShrink: 0, + borderEndWidth: 1, + borderColor: 'base.700', + position: 'relative', +}; + +const BOARDS_SIDEBAR_EXPANDED_STYLES_SX: SystemStyleObject = { + ...BOARDS_SIDEBAR_STYLES_SX, + minW: `${BOARDS_SIDEBAR_MIN_WIDTH_PX}px`, +}; + +const COLLAPSED_SIDEBAR_WIDTH = 16; + +const BOARDS_SIDEBAR_COLLAPSED_STYLES_SX: SystemStyleObject = { + ...BOARDS_SIDEBAR_STYLES_SX, + w: COLLAPSED_SIDEBAR_WIDTH, + minW: COLLAPSED_SIDEBAR_WIDTH, +}; + +const BOARDS_SIDEBAR_RESIZE_HANDLE_STYLES_SX: SystemStyleObject = { + position: 'absolute', + insetY: 0, + right: '-2px', + w: '4px', + cursor: 'col-resize', + _hover: { bg: 'base.600' }, + transition: 'background 0.15s', +}; + +/** + * Hook that derives the gallery panel's collapsed state from dockview's panel dimensions. + * Subscribes to the panel's onDidDimensionsChange event so the UI stays in sync + * regardless of how the panel is collapsed/expanded (toggle button, hotkey, or external API call). + */ +const useIsGalleryPanelCollapsed = (): boolean => { + const { tab } = useAutoLayoutContext(); + const [isCollapsed, setIsCollapsed] = useState(false); + + useEffect(() => { + const panel = navigationApi.getPanel(tab, BOTTOM_GALLERY_PANEL_ID) as GridviewPanel | undefined; + if (!panel) { + return; + } + + // Sync initial state + setIsCollapsed(panel.height <= BOTTOM_GALLERY_MIN_HEIGHT_PX); + + // Subscribe to dimension changes + const { dispose } = panel.api.onDidDimensionsChange((event) => { + setIsCollapsed(event.height <= BOTTOM_GALLERY_MIN_HEIGHT_PX); + }); + + return dispose; + }, [tab]); + + return isCollapsed; +}; + +/** + * The bottom gallery panel that contains the boards sidebar and image grid. + * This component is placed at the bottom of the layout spanning the full width. + */ +export const BottomGalleryPanel = memo(() => { + const { t } = useTranslation(); + const shouldUsePagedGalleryView = useAppSelector(selectShouldUsePagedGalleryView); + const selectedBoardId = useAppSelector(selectSelectedBoardId); + const boardName = useBoardName(selectedBoardId); + const [isBoardsSidebarCollapsed, setIsBoardsSidebarCollapsed] = useState(false); + const [boardsSidebarWidth, setBoardsSidebarWidth] = useState(BOARDS_SIDEBAR_DEFAULT_WIDTH_PX); + const isResizingRef = useRef(false); + const isGalleryPanelCollapsed = useIsGalleryPanelCollapsed(); + + const handleToggleGalleryPanel = useCallback(() => { + navigationApi.toggleBottomPanel(); + }, []); + + const handleToggleBoardsSidebar = useCallback(() => { + setIsBoardsSidebarCollapsed((prev) => !prev); + }, []); + + // Resize handler for the boards sidebar + const handleResizeStart = useCallback( + (e: ReactMouseEvent) => { + if (isBoardsSidebarCollapsed) { + return; + } + e.preventDefault(); + isResizingRef.current = true; + const startX = e.clientX; + const startWidth = boardsSidebarWidth; + + const onMouseMove = (moveEvent: MouseEvent) => { + if (!isResizingRef.current) { + return; + } + const delta = moveEvent.clientX - startX; + const newWidth = Math.min( + BOARDS_SIDEBAR_MAX_WIDTH_PX, + Math.max(BOARDS_SIDEBAR_MIN_WIDTH_PX, startWidth + delta) + ); + setBoardsSidebarWidth(newWidth); + }; + + const onMouseUp = () => { + isResizingRef.current = false; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }, + [boardsSidebarWidth, isBoardsSidebarCollapsed] + ); + + return ( + + {/* Top header bar */} + + + + + {/* Main content area - hidden when collapsed */} + + {/* Boards Sidebar */} + + + + + + + + + + + {isBoardsSidebarCollapsed ? : } + + + {!isBoardsSidebarCollapsed && ( + + )} + + + {/* Image grid area */} + + {/* Gallery controls, settings + search */} + + + {/* Image grid */} + + {shouldUsePagedGalleryView ? : } + + + + + ); +}); + +BottomGalleryPanel.displayName = 'BottomGalleryPanel'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemLocateInGalery.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemLocateInGalery.tsx index 0a557710975..0db48bf5e13 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemLocateInGalery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemLocateInGalery.tsx @@ -1,11 +1,9 @@ import { MenuItem } from '@invoke-ai/ui-library'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useAppDispatch } from 'app/store/storeHooks'; import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; import { boardIdSelected } from 'features/gallery/store/gallerySlice'; import { IMAGE_CATEGORIES } from 'features/gallery/store/types'; import { navigationApi } from 'features/ui/layouts/navigation-api'; -import { useGalleryPanel } from 'features/ui/layouts/use-gallery-panel'; -import { selectActiveTab } from 'features/ui/store/uiSelectors'; import { memo, useCallback, useMemo } from 'react'; import { flushSync } from 'react-dom'; import { useTranslation } from 'react-i18next'; @@ -15,16 +13,13 @@ export const ContextMenuItemLocateInGalery = memo(() => { const { t } = useTranslation(); const dispatch = useAppDispatch(); const imageDTO = useImageDTOContext(); - const activeTab = useAppSelector(selectActiveTab); - const galleryPanel = useGalleryPanel(activeTab); const isGalleryImage = useMemo(() => { return !imageDTO.is_intermediate; }, [imageDTO]); const onClick = useCallback(() => { - navigationApi.expandRightPanel(); - galleryPanel.expand(); + navigationApi.expandBottomPanel(); flushSync(() => { dispatch( boardIdSelected({ @@ -36,7 +31,7 @@ export const ContextMenuItemLocateInGalery = memo(() => { }) ); }); - }, [dispatch, galleryPanel, imageDTO]); + }, [dispatch, imageDTO]); return ( } onClickCapture={onClick} isDisabled={!isGalleryImage}> diff --git a/invokeai/frontend/web/src/features/gallery/components/GalleryHeader.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryHeader.tsx new file mode 100644 index 00000000000..668e695a314 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/GalleryHeader.tsx @@ -0,0 +1,101 @@ +import { Button, ButtonGroup, Flex, IconButton, Spacer, Text } from '@invoke-ai/ui-library'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { GalleryUploadButton } from 'features/gallery/components/GalleryUploadButton'; +import { GallerySearch } from 'features/gallery/components/ImageGrid/GallerySearch'; +import { useGallerySearchTerm } from 'features/gallery/components/ImageGrid/useGallerySearchTerm'; +import { useGalleryImageNames } from 'features/gallery/components/use-gallery-image-names'; +import { selectSelectedBoardId, selectShowAspectRatioThumbnails } from 'features/gallery/store/gallerySelectors'; +import { + galleryViewChanged, + selectGallerySlice, + showAspectRatioThumbnailsChanged, +} from 'features/gallery/store/gallerySlice'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiArchiveBold, PiGridNineBold, PiImageSquareBold, PiSquaresFourBold } from 'react-icons/pi'; +import { useBoardName } from 'services/api/hooks/useBoardName'; + +import { GallerySettingsPopover } from './GallerySettingsPopover/GallerySettingsPopover'; + +const selectGalleryView = createSelector(selectGallerySlice, (gallery) => gallery.galleryView); +const selectSearchTerm = createSelector(selectGallerySlice, (gallery) => gallery.searchTerm); + +export const GalleryHeader = memo(() => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const selectedBoardId = useAppSelector(selectSelectedBoardId); + const boardName = useBoardName(selectedBoardId); + const showAspectRatio = useAppSelector(selectShowAspectRatioThumbnails); + const galleryView = useAppSelector(selectGalleryView); + const { imageNames } = useGalleryImageNames(); + const hasImages = imageNames.length > 0; + + const initialSearchTerm = useAppSelector(selectSearchTerm); + const [searchTerm, onChangeSearchTerm, onResetSearchTerm] = useGallerySearchTerm(); + + const handleToggleAspectRatio = useCallback(() => { + dispatch(showAspectRatioThumbnailsChanged(!showAspectRatio)); + }, [dispatch, showAspectRatio]); + + const handleClickImages = useCallback(() => { + dispatch(galleryViewChanged('images')); + }, [dispatch]); + + const handleClickAssets = useCallback(() => { + dispatch(galleryViewChanged('assets')); + }, [dispatch]); + + return ( + + + {boardName || t('gallery.allImages')} + + + + + + + + + + + : } + colorScheme={showAspectRatio ? 'invokeBlue' : 'base'} + /> + + + + + ); +}); + +GalleryHeader.displayName = 'GalleryHeader'; diff --git a/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx index b4443b87897..057563567ac 100644 --- a/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx @@ -1,15 +1,20 @@ import { Box, Flex, forwardRef, Grid, GridItem, Spinner, Text } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; import { createSelector } from '@reduxjs/toolkit'; import { useAppSelector, useAppStore } from 'app/store/storeHooks'; import { getFocusedRegion, useIsRegionFocused } from 'common/hooks/focus'; import { useRangeBasedImageFetching } from 'features/gallery/hooks/useRangeBasedImageFetching'; +import { $galleryProgressItems, selectFilteredGalleryProgressItems } from 'features/gallery/store/galleryProgressStore'; import type { selectGetImageNamesQueryArgs } from 'features/gallery/store/gallerySelectors'; import { - selectGalleryImageMinimumWidth, + selectGalleryColumns, + selectGalleryView, selectImageToCompare, selectLastSelectedItem, + selectSelectedBoardId, selectSelection, selectSelectionCount, + selectShowAspectRatioThumbnails, } from 'features/gallery/store/gallerySelectors'; import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; @@ -33,6 +38,7 @@ import { useDebounce } from 'use-debounce'; import { getItemIndex } from './getItemIndex'; import { getItemsPerRow } from './getItemsPerRow'; import { GalleryImage, GalleryImagePlaceholder } from './ImageGrid/GalleryImage'; +import { GalleryProgressTile } from './ImageGrid/GalleryProgressTile'; import { GallerySelectionCountTag } from './ImageGrid/GallerySelectionCountTag'; import { scrollIntoView } from './scrollIntoView'; import { useGalleryImageNames } from './use-gallery-image-names'; @@ -40,9 +46,18 @@ import { useScrollableGallery } from './useScrollableGallery'; type ListImageNamesQueryArgs = ReturnType; +/** + * A gallery grid item is either a progress item ID (number) or an image name (string). + * Progress items are prepended at the top of the grid. + */ +type GalleryGridItem = number | string; + +const EMPTY_PROGRESS_IDS: number[] = []; + type GridContext = { queryArgs: ListImageNamesQueryArgs; imageNames: string[]; + showAspectRatioThumbnails: boolean; }; /** @@ -79,8 +94,11 @@ const ImageAtPosition = memo(({ imageName }: { index: number; imageName: string }); ImageAtPosition.displayName = 'ImageAtPosition'; -const computeItemKey: GridComputeItemKey = (index, imageName, { queryArgs }) => { - return `${JSON.stringify(queryArgs)}-${imageName ?? index}`; +const computeItemKey: GridComputeItemKey = (index, item, { queryArgs }) => { + if (typeof item === 'number') { + return `progress-${item}`; + } + return `${JSON.stringify(queryArgs)}-${item ?? index}`; }; const canHandleGridArrowNavigation = ( @@ -281,7 +299,8 @@ const useKeepSelectedImageInView = ( imageNames: string[], virtuosoRef: React.RefObject, rootRef: React.RefObject, - rangeRef: MutableRefObject + rangeRef: MutableRefObject, + progressOffset: number ) => { const selection = useAppSelector(selectSelection); @@ -300,9 +319,9 @@ const useKeepSelectedImageInView = ( } setTimeout(() => { - scrollIntoView(targetImageName, imageNames, rootEl, virtuosoGridHandle, range); + scrollIntoView(targetImageName, imageNames, rootEl, virtuosoGridHandle, range, progressOffset); }, 0); - }, [imageNames, rangeRef, rootRef, virtuosoRef, selection]); + }, [imageNames, rangeRef, rootRef, virtuosoRef, selection, progressOffset]); }; const useStarImageHotkey = () => { @@ -342,39 +361,80 @@ type GalleryImageGridContentProps = { isLoading: boolean; queryArgs: ListImageNamesQueryArgs; rootRef?: React.RefObject; + showProgressTiles?: boolean; }; export const GalleryImageGridContent = memo( - ({ imageNames, navigationImageNames, isLoading, queryArgs, rootRef: rootRefProp }: GalleryImageGridContentProps) => { + ({ + imageNames, + navigationImageNames, + isLoading, + queryArgs, + rootRef: rootRefProp, + showProgressTiles = true, + }: GalleryImageGridContentProps) => { const virtuosoRef = useRef(null); const rangeRef = useRef({ startIndex: 0, endIndex: 0 }); const internalRootRef = useRef(null); const rootRef = rootRefProp ?? internalRootRef; + const showAspectRatioThumbnails = useAppSelector(selectShowAspectRatioThumbnails); + + // Read progress items from the nanostore, filtered by selected board and gallery view + const selectedBoardId = useAppSelector(selectSelectedBoardId); + const galleryView = useAppSelector(selectGalleryView); + const allProgressItems = useStore($galleryProgressItems); + + const progressItemIds = useMemo(() => { + if (!showProgressTiles || galleryView !== 'images') { + return EMPTY_PROGRESS_IDS; + } + const filtered = selectFilteredGalleryProgressItems(allProgressItems, selectedBoardId); + if (filtered.length === 0) { + return EMPTY_PROGRESS_IDS; + } + return filtered.map((item) => item.itemId); + }, [showProgressTiles, allProgressItems, selectedBoardId, galleryView]); + + // Combined data: progress item IDs (numbers) followed by image names (strings) + const gridData = useMemo( + () => (progressItemIds.length > 0 ? [...progressItemIds, ...imageNames] : imageNames), + [progressItemIds, imageNames] + ); - // Use range-based fetching for bulk loading image DTOs into cache based on the visible range + // Use range-based fetching for bulk loading image DTOs into cache based on the visible range. + // We offset the range by the number of progress items since they are prepended to the grid data. const { onRangeChanged } = useRangeBasedImageFetching({ imageNames, enabled: !isLoading, }); useStarImageHotkey(); - useKeepSelectedImageInView(imageNames, virtuosoRef, rootRef, rangeRef); + useKeepSelectedImageInView(imageNames, virtuosoRef, rootRef, rangeRef, progressItemIds.length); useKeyboardNavigation(navigationImageNames ?? imageNames, virtuosoRef, rootRef); const scrollerRef = useScrollableGallery(rootRef); /* * We have to keep track of the visible range for keep-selected-image-in-view functionality and push the range to - * the range-based image fetching hook. + * the range-based image fetching hook. The range from Virtuoso includes progress items at the front, so we offset + * it before passing to the image fetching hook. */ const handleRangeChanged = useCallback( (range: ListRange) => { rangeRef.current = range; - onRangeChanged(range); + const offset = progressItemIds.length; + const adjustedRange: ListRange = { + startIndex: Math.max(0, range.startIndex - offset), + endIndex: Math.max(0, range.endIndex - offset), + }; + onRangeChanged(adjustedRange); }, - [onRangeChanged] + [onRangeChanged, progressItemIds.length] ); - const context = useMemo(() => ({ imageNames, queryArgs }), [imageNames, queryArgs]); + const context = useMemo( + () => ({ imageNames, queryArgs, showAspectRatioThumbnails }), + [imageNames, queryArgs, showAspectRatioThumbnails] + ); if (isLoading) { return ( @@ -385,7 +445,7 @@ export const GalleryImageGridContent = memo( ); } - if (imageNames.length === 0) { + if (gridData.length === 0) { return ( No images found @@ -396,10 +456,10 @@ export const GalleryImageGridContent = memo( return ( // This wrapper component is necessary to initialize the overlay scrollbars! - + ref={virtuosoRef} context={context} - data={imageNames} + data={gridData} increaseViewportBy={4096} itemContent={itemContent} computeItemKey={computeItemKey} @@ -437,8 +497,8 @@ const scrollSeekConfiguration: ScrollSeekConfiguration = { const style = { height: '100%', width: '100%' }; const selectGridTemplateColumns = createSelector( - selectGalleryImageMinimumWidth, - (galleryImageMinimumWidth) => `repeat(auto-fill, minmax(${galleryImageMinimumWidth}px, 1fr))` + selectGalleryColumns, + (galleryColumns) => `repeat(${galleryColumns}, 1fr)` ); // Grid components @@ -450,20 +510,27 @@ const ListComponent: GridComponents['List'] = forwardRef(({ context }); ListComponent.displayName = 'ListComponent'; -const itemContent: GridItemContent = (index, imageName) => { - return ; +const itemContent: GridItemContent = (index, item) => { + if (typeof item === 'number') { + return ; + } + return ; }; -const ItemComponent: GridComponents['Item'] = forwardRef(({ context: _, ...rest }, ref) => ( - +const ItemComponent: GridComponents['Item'] = forwardRef(({ context, ...rest }, ref) => ( + )); ItemComponent.displayName = 'ItemComponent'; -const ScrollSeekPlaceholderComponent: GridComponents['ScrollSeekPlaceholder'] = (props) => ( - - - -); +const ScrollSeekPlaceholderComponent: GridComponents['ScrollSeekPlaceholder'] = (props) => { + // eslint-disable-next-line react/prop-types + const aspectRatio = props.context?.showAspectRatioThumbnails ? undefined : '1/1'; + return ( + + + + ); +}; ScrollSeekPlaceholderComponent.displayName = 'ScrollSeekPlaceholderComponent'; diff --git a/invokeai/frontend/web/src/features/gallery/components/GalleryImageGridPaged.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryImageGridPaged.tsx index c5b4fc405de..c6d52e50eb9 100644 --- a/invokeai/frontend/web/src/features/gallery/components/GalleryImageGridPaged.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/GalleryImageGridPaged.tsx @@ -3,7 +3,7 @@ import { useAppSelector } from 'app/store/storeHooks'; import { GalleryImageGridContent } from 'features/gallery/components/GalleryImageGrid'; import { GalleryPaginationPaged } from 'features/gallery/components/ImageGrid/GalleryPaginationPaged'; import { useGalleryImageNames } from 'features/gallery/components/use-gallery-image-names'; -import { selectGalleryImageMinimumWidth, selectLastSelectedItem } from 'features/gallery/store/gallerySelectors'; +import { selectGalleryColumns, selectLastSelectedItem } from 'features/gallery/store/gallerySelectors'; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { getItemsPerPage } from './getItemsPerPage'; @@ -13,7 +13,7 @@ const FALLBACK_PAGE_SIZE = 200; export const GalleryImageGridPaged = memo(() => { const { queryArgs, imageNames, isLoading } = useGalleryImageNames(); const lastSelectedItem = useAppSelector(selectLastSelectedItem); - const galleryImageMinimumWidth = useAppSelector(selectGalleryImageMinimumWidth); + const galleryColumns = useAppSelector(selectGalleryColumns); const [pageIndex, setPageIndex] = useState(0); const [pageSize, setPageSize] = useState(FALLBACK_PAGE_SIZE); const gridRootRef = useRef(null); @@ -101,7 +101,7 @@ export const GalleryImageGridPaged = memo(() => { }; frame = requestAnimationFrame(tick); return () => cancelAnimationFrame(frame); - }, [galleryImageMinimumWidth, imageNames.length, isLoading, pageIndex, pageSize]); + }, [galleryColumns, imageNames.length, isLoading, pageIndex, pageSize]); useEffect(() => { if (isLoading) { @@ -112,7 +112,7 @@ export const GalleryImageGridPaged = memo(() => { requestAnimationFrame(recalculatePageSize); }, 350); return () => clearTimeout(timeout); - }, [galleryImageMinimumWidth, isLoading, recalculatePageSize]); + }, [galleryColumns, isLoading, recalculatePageSize]); useEffect(() => { const rootEl = gridRootRef.current; @@ -124,7 +124,7 @@ export const GalleryImageGridPaged = memo(() => { }); observer.observe(rootEl); return () => observer.disconnect(); - }, [galleryImageMinimumWidth, recalculatePageSize]); + }, [galleryColumns, recalculatePageSize]); useEffect(() => { const rootEl = gridRootRef.current; @@ -178,13 +178,14 @@ export const GalleryImageGridPaged = memo(() => { onGoToPage={handleTabChange} onPageInputChange={handlePageInputChange} /> - + diff --git a/invokeai/frontend/web/src/features/gallery/components/GalleryPanel.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryPanel.tsx deleted file mode 100644 index 7c57aa08497..00000000000 --- a/invokeai/frontend/web/src/features/gallery/components/GalleryPanel.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { Box, Button, ButtonGroup, Collapse, Divider, Flex, IconButton, Spacer } from '@invoke-ai/ui-library'; -import { useStore } from '@nanostores/react'; -import { createSelector } from '@reduxjs/toolkit'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { useDisclosure } from 'common/hooks/useBoolean'; -import { useGallerySearchTerm } from 'features/gallery/components/ImageGrid/useGallerySearchTerm'; -import { selectSelectedBoardId } from 'features/gallery/store/gallerySelectors'; -import { galleryViewChanged, selectGallerySlice } from 'features/gallery/store/gallerySlice'; -import { useAutoLayoutContext } from 'features/ui/layouts/auto-layout-context'; -import { useGalleryPanel } from 'features/ui/layouts/use-gallery-panel'; -import { selectShouldUsePagedGalleryView } from 'features/ui/store/uiSelectors'; -import type { CSSProperties } from 'react'; -import { memo, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { PiCaretDownBold, PiCaretUpBold, PiMagnifyingGlassBold } from 'react-icons/pi'; -import { useBoardName } from 'services/api/hooks/useBoardName'; - -import { GalleryImageGrid } from './GalleryImageGrid'; -import { GalleryImageGridPaged } from './GalleryImageGridPaged'; -import { GallerySettingsPopover } from './GallerySettingsPopover/GallerySettingsPopover'; -import { GalleryUploadButton } from './GalleryUploadButton'; -import { GallerySearch } from './ImageGrid/GallerySearch'; - -const COLLAPSE_STYLES: CSSProperties = { flexShrink: 0, minHeight: 0, width: '100%' }; - -const selectGalleryView = createSelector(selectGallerySlice, (gallery) => gallery.galleryView); -const selectSearchTerm = createSelector(selectGallerySlice, (gallery) => gallery.searchTerm); - -export const GalleryPanel = memo(() => { - const { t } = useTranslation(); - const dispatch = useAppDispatch(); - const { tab } = useAutoLayoutContext(); - const galleryPanel = useGalleryPanel(tab); - const isCollapsed = useStore(galleryPanel.$isCollapsed); - const galleryView = useAppSelector(selectGalleryView); - const initialSearchTerm = useAppSelector(selectSearchTerm); - const shouldUsePagedGalleryView = useAppSelector(selectShouldUsePagedGalleryView); - const searchDisclosure = useDisclosure(!!initialSearchTerm); - const [searchTerm, onChangeSearchTerm, onResetSearchTerm] = useGallerySearchTerm(); - const handleClickImages = useCallback(() => { - dispatch(galleryViewChanged('images')); - }, [dispatch]); - - const handleClickAssets = useCallback(() => { - dispatch(galleryViewChanged('assets')); - }, [dispatch]); - - const handleClickSearch = useCallback(() => { - onResetSearchTerm(); - if (!searchDisclosure.isOpen && galleryPanel.$isCollapsed.get()) { - galleryPanel.expand(); - } - searchDisclosure.toggle(); - }, [galleryPanel, onResetSearchTerm, searchDisclosure]); - - const selectedBoardId = useAppSelector(selectSelectedBoardId); - const boardName = useBoardName(selectedBoardId); - - return ( - - - - - - - - - - - - - } - /> - - - - - - - - - - {shouldUsePagedGalleryView ? : } - - - ); -}); -GalleryPanel.displayName = 'GalleryPanel'; diff --git a/invokeai/frontend/web/src/features/gallery/components/GallerySettingsPopover/AutoAssignBoardCheckbox.tsx b/invokeai/frontend/web/src/features/gallery/components/GallerySettingsPopover/AutoAssignBoardCheckbox.tsx deleted file mode 100644 index b735c2d94db..00000000000 --- a/invokeai/frontend/web/src/features/gallery/components/GallerySettingsPopover/AutoAssignBoardCheckbox.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Checkbox, FormControl, FormLabel } from '@invoke-ai/ui-library'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { selectAutoAssignBoardOnClick } from 'features/gallery/store/gallerySelectors'; -import { autoAssignBoardOnClickChanged } from 'features/gallery/store/gallerySlice'; -import type { ChangeEvent } from 'react'; -import { memo, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; - -const GallerySettingsPopover = () => { - const dispatch = useAppDispatch(); - const { t } = useTranslation(); - const autoAssignBoardOnClick = useAppSelector(selectAutoAssignBoardOnClick); - - const onChange = useCallback( - (e: ChangeEvent) => dispatch(autoAssignBoardOnClickChanged(e.target.checked)), - [dispatch] - ); - - return ( - - {t('gallery.autoAssignBoardOnClick')} - - - ); -}; - -export default memo(GallerySettingsPopover); diff --git a/invokeai/frontend/web/src/features/gallery/components/GallerySettingsPopover/ImageMinimumWidthSlider.tsx b/invokeai/frontend/web/src/features/gallery/components/GallerySettingsPopover/ImageMinimumWidthSlider.tsx index 0185dc82c41..25df0ba889c 100644 --- a/invokeai/frontend/web/src/features/gallery/components/GallerySettingsPopover/ImageMinimumWidthSlider.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/GallerySettingsPopover/ImageMinimumWidthSlider.tsx @@ -1,27 +1,27 @@ import { CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { selectGalleryImageMinimumWidth } from 'features/gallery/store/gallerySelectors'; -import { setGalleryImageMinimumWidth } from 'features/gallery/store/gallerySlice'; +import { selectGalleryColumns } from 'features/gallery/store/gallerySelectors'; +import { setGalleryColumns } from 'features/gallery/store/gallerySlice'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -const GallerySettingsPopover = () => { +const GalleryColumnsSlider = () => { const dispatch = useAppDispatch(); const { t } = useTranslation(); - const galleryImageMinimumWidth = useAppSelector(selectGalleryImageMinimumWidth); + const galleryColumns = useAppSelector(selectGalleryColumns); const onChange = useCallback( (v: number) => { - dispatch(setGalleryImageMinimumWidth(v)); + dispatch(setGalleryColumns(v)); }, [dispatch] ); return ( - {t('gallery.galleryImageSize')} - + {t('gallery.columns')} + ); }; -export default memo(GallerySettingsPopover); +export default memo(GalleryColumnsSlider); diff --git a/invokeai/frontend/web/src/features/gallery/components/GallerySettingsPopover/ShowArchivedBoardsCheckbox.tsx b/invokeai/frontend/web/src/features/gallery/components/GallerySettingsPopover/ShowArchivedBoardsCheckbox.tsx deleted file mode 100644 index 135b1830382..00000000000 --- a/invokeai/frontend/web/src/features/gallery/components/GallerySettingsPopover/ShowArchivedBoardsCheckbox.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Checkbox, FormControl, FormLabel } from '@invoke-ai/ui-library'; -import { createSelector } from '@reduxjs/toolkit'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { selectGallerySlice, shouldShowArchivedBoardsChanged } from 'features/gallery/store/gallerySlice'; -import type { ChangeEvent } from 'react'; -import { memo, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; - -const selectShouldShowArchivedBoards = createSelector( - selectGallerySlice, - (gallery) => gallery.shouldShowArchivedBoards -); - -const GallerySettingsPopover = () => { - const dispatch = useAppDispatch(); - const { t } = useTranslation(); - const shouldShowArchivedBoards = useAppSelector(selectShouldShowArchivedBoards); - - const onChange = useCallback( - (e: ChangeEvent) => { - dispatch(shouldShowArchivedBoardsChanged(e.target.checked)); - }, - [dispatch] - ); - - return ( - - {t('gallery.showArchivedBoards')} - - - ); -}; - -export default memo(GallerySettingsPopover); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx index ccd58992ef6..9f0df9d2ed2 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx @@ -1,6 +1,6 @@ import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; import { draggable, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; -import type { FlexProps } from '@invoke-ai/ui-library'; +import type { FlexProps, SystemStyleObject } from '@invoke-ai/ui-library'; import { Flex, Icon, Image } from '@invoke-ai/ui-library'; import { createSelector } from '@reduxjs/toolkit'; import type { AppDispatch, AppGetState } from 'app/store/store'; @@ -18,6 +18,7 @@ import { selectGetImageNamesQueryArgs, selectSelectedBoardId, selectSelection, + selectShowAspectRatioThumbnails, } from 'features/gallery/store/gallerySelectors'; import { imageToCompareChanged, selectGallerySlice, selectionChanged } from 'features/gallery/store/gallerySlice'; import { navigationApi } from 'features/ui/layouts/navigation-api'; @@ -102,6 +103,25 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { [imageDTO.image_name] ); const isSelected = useAppSelector(selectIsSelected); + const showAspectRatio = useAppSelector(selectShowAspectRatioThumbnails); + + const galleryItemImageSx = useMemo(() => { + const baseSx: SystemStyleObject = { + w: 'full', + h: 'full', + borderRadius: 'base', + }; + if (showAspectRatio) { + return { + ...baseSx, + objectFit: 'cover', + }; + } + return { + ...baseSx, + objectFit: 'contain', + }; + }, [showAspectRatio]); useEffect(() => { const element = ref.current; @@ -210,12 +230,8 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { } - objectFit="contain" - maxW="full" - maxH="full" - borderRadius="base" + sx={galleryItemImageSx} /> diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryPaginationPaged.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryPaginationPaged.tsx index a32b10e3ee9..b74bd25b7bd 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryPaginationPaged.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryPaginationPaged.tsx @@ -1,4 +1,4 @@ -import { Button, Flex, IconButton, Spacer } from '@invoke-ai/ui-library'; +import { Button, Divider, Flex, IconButton } from '@invoke-ai/ui-library'; import { memo, useCallback, useMemo } from 'react'; import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi'; @@ -75,7 +75,7 @@ export const GalleryPaginationPaged = memo( } return ( - + - {pageButtons.map((page, i) => ( ))} - = pageCount - 1} variant="ghost" /> + ); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryProgressTile.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryProgressTile.tsx new file mode 100644 index 00000000000..cd5e92dc931 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryProgressTile.tsx @@ -0,0 +1,122 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import { CircularProgress, Flex, Icon, Image, Text } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppSelector } from 'app/store/storeHooks'; +import type { GalleryProgressItem } from 'features/gallery/store/galleryProgressStore'; +import { $galleryProgressItems } from 'features/gallery/store/galleryProgressStore'; +import { selectSystemSlice } from 'features/system/store/systemSlice'; +import { memo, useMemo } from 'react'; +import { PiClockBold } from 'react-icons/pi'; + +const selectShouldAntialiasProgressImage = createSelector( + selectSystemSlice, + (system) => system.shouldAntialiasProgressImage +); + +const circleStyles: SystemStyleObject = { + circle: { + transitionProperty: 'none', + transitionDuration: '0s', + }, +}; + +interface Props { + itemId: number; +} + +export const GalleryProgressTile = memo(({ itemId }: Props) => { + const allItems = useStore($galleryProgressItems); + const item = allItems[itemId]; + + if (!item) { + return null; + } + + if (item.status === 'in_progress' && item.progressImage) { + return ; + } + + return ; +}); + +GalleryProgressTile.displayName = 'GalleryProgressTile'; + +const PendingTile = memo((_props: { item: GalleryProgressItem }) => { + return ( + + + + Queued + + + ); +}); + +PendingTile.displayName = 'PendingTile'; + +const InProgressTile = memo(({ item }: { item: GalleryProgressItem }) => { + const shouldAntialiasProgressImage = useAppSelector(selectShouldAntialiasProgressImage); + + const imageSx = useMemo( + () => ({ + imageRendering: shouldAntialiasProgressImage ? 'auto' : 'pixelated', + }), + [shouldAntialiasProgressImage] + ); + + return ( + + {item.progressImage && ( + + )} + + + + + + {item.message ?? 'Generating...'} + + + + ); +}); + +InProgressTile.displayName = 'InProgressTile'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySearch.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySearch.tsx index 3c329c90e82..f91e3f5c014 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySearch.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySearch.tsx @@ -9,9 +9,10 @@ type Props = { searchTerm: string; onChangeSearchTerm: (value: string) => void; onResetSearchTerm: () => void; + isDisabled?: boolean; }; -export const GallerySearch = memo(({ searchTerm, onChangeSearchTerm, onResetSearchTerm }: Props) => { +export const GallerySearch = memo(({ searchTerm, onChangeSearchTerm, onResetSearchTerm, isDisabled }: Props) => { const { t } = useTranslation(); const { isFetching } = useGalleryImageNames(); @@ -40,6 +41,7 @@ export const GallerySearch = memo(({ searchTerm, onChangeSearchTerm, onResetSear onChange={handleChangeInput} data-testid="image-search-input" onKeyDown={handleKeydown} + isDisabled={isDisabled} /> {isFetching && ( diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataViewer.tsx index ffd3360fe15..478bae7722d 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataViewer.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageMetadataViewer/ImageMetadataViewer.tsx @@ -82,13 +82,12 @@ const ImageMetadataViewer = ({ image }: ImageMetadataViewerProps) => { diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx index bd9dc31a570..075de0d393a 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx @@ -14,7 +14,6 @@ import { boardIdSelected } from 'features/gallery/store/gallerySlice'; import { IMAGE_CATEGORIES } from 'features/gallery/store/types'; import { PostProcessingPopover } from 'features/parameters/components/PostProcessing/PostProcessingPopover'; import { navigationApi } from 'features/ui/layouts/navigation-api'; -import { useGalleryPanel } from 'features/ui/layouts/use-gallery-panel'; import { selectActiveTab } from 'features/ui/store/uiSelectors'; import { memo, useCallback, useMemo } from 'react'; import { flushSync } from 'react-dom'; @@ -36,16 +35,13 @@ export const CurrentImageButtons = memo(({ imageDTO }: { imageDTO: ImageDTO }) = const { t } = useTranslation(); const tab = useAppSelector(selectActiveTab); const dispatch = useAppDispatch(); - const activeTab = useAppSelector(selectActiveTab); - const galleryPanel = useGalleryPanel(activeTab); const isGalleryImage = useMemo(() => { return !imageDTO.is_intermediate; }, [imageDTO]); const locateInGallery = useCallback(() => { - navigationApi.expandRightPanel(); - galleryPanel.expand(); + navigationApi.expandBottomPanel(); flushSync(() => { dispatch( boardIdSelected({ @@ -57,7 +53,7 @@ export const CurrentImageButtons = memo(({ imageDTO }: { imageDTO: ImageDTO }) = }) ); }); - }, [dispatch, galleryPanel, imageDTO]); + }, [dispatch, imageDTO]); const isCanvasOrGenerateTab = tab === 'canvas' || tab === 'generate'; const isCanvasOrGenerateOrUpscalingTab = tab === 'canvas' || tab === 'generate' || tab === 'upscaling'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx index a39cf9be514..7d059f91633 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx @@ -162,9 +162,9 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu - {shouldShowItemDetails && imageToRender && !withProgress && ( + {shouldShowItemDetails && imageDTO && !withProgress && ( - + )} diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerPanel.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerPanel.tsx index 9e829ea8dcf..16d43bba25c 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerPanel.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerPanel.tsx @@ -1,3 +1,4 @@ +import { Box } from '@invoke-ai/ui-library'; import { createSelector } from '@reduxjs/toolkit'; import { useAppSelector } from 'app/store/storeHooks'; import { selectImageToCompare, selectLastSelectedItem } from 'features/gallery/store/gallerySelectors'; @@ -14,16 +15,13 @@ const selectIsComparing = createSelector( export const ImageViewerPanel = memo(() => { const isComparing = useAppSelector(selectIsComparing); - const lastSelectedItem = useAppSelector(selectLastSelectedItem); return ( - { - // The image viewer renders progress images - if no image is selected, show the image viewer anyway - !isComparing && !lastSelectedItem && - } - {!isComparing && } - {isComparing && } + + {!isComparing && } + {isComparing && } + ); }); diff --git a/invokeai/frontend/web/src/features/gallery/components/scrollIntoView.tsx b/invokeai/frontend/web/src/features/gallery/components/scrollIntoView.tsx index ca34427a575..43f585699c6 100644 --- a/invokeai/frontend/web/src/features/gallery/components/scrollIntoView.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/scrollIntoView.tsx @@ -5,13 +5,16 @@ const log = logger('gallery'); /** * Scroll the item at the given index into view if it is not currently visible. + * @param progressOffset - The number of progress items prepended before image names in the grid data. + * This offset is added to the image index to get the correct Virtuoso grid index. */ export const scrollIntoView = ( targetItemId: string, itemIds: string[], rootEl: HTMLDivElement, virtuosoGridHandle: VirtuosoGridHandle, - range: ListRange + range: ListRange, + progressOffset: number = 0 ) => { if (range.endIndex === 0) { // No range is rendered; no need to scroll to anything. @@ -27,40 +30,43 @@ export const scrollIntoView = ( return; } + // The grid index accounts for progress items prepended before image names + const gridIndex = targetIndex + progressOffset; + const targetItem = rootEl.querySelector(`.virtuoso-grid-item:has([data-item-id="${targetItemId}"])`) as HTMLElement; if (!targetItem) { - if (targetIndex > range.endIndex) { + if (gridIndex > range.endIndex) { log.trace( { - index: targetIndex, + index: gridIndex, behavior: 'auto', align: 'start', }, 'Scrolling into view: not in DOM' ); virtuosoGridHandle.scrollToIndex({ - index: targetIndex, + index: gridIndex, behavior: 'auto', align: 'start', }); - } else if (targetIndex < range.startIndex) { + } else if (gridIndex < range.startIndex) { log.trace( { - index: targetIndex, + index: gridIndex, behavior: 'auto', align: 'end', }, 'Scrolling into view: not in DOM' ); virtuosoGridHandle.scrollToIndex({ - index: targetIndex, + index: gridIndex, behavior: 'auto', align: 'end', }); } else { log.debug( - `Unable to find image ${targetItemId} at index ${targetIndex} but it is in the rendered range ${range.startIndex}-${range.endIndex}` + `Unable to find image ${targetItemId} at index ${gridIndex} but it is in the rendered range ${range.startIndex}-${range.endIndex}` ); } return; @@ -75,28 +81,28 @@ export const scrollIntoView = ( if (itemRect.top < rootRect.top) { log.trace( { - index: targetIndex, + index: gridIndex, behavior: 'auto', align: 'start', }, 'Scrolling into view: in overscan' ); virtuosoGridHandle.scrollToIndex({ - index: targetIndex, + index: gridIndex, behavior: 'auto', align: 'start', }); } else if (itemRect.bottom > rootRect.bottom) { log.trace( { - index: targetIndex, + index: gridIndex, behavior: 'auto', align: 'end', }, 'Scrolling into view: in overscan' ); virtuosoGridHandle.scrollToIndex({ - index: targetIndex, + index: gridIndex, behavior: 'auto', align: 'end', }); diff --git a/invokeai/frontend/web/src/features/gallery/store/galleryProgressStore.test.ts b/invokeai/frontend/web/src/features/gallery/store/galleryProgressStore.test.ts new file mode 100644 index 00000000000..9de653b0063 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/store/galleryProgressStore.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + $galleryProgressItems, + addGalleryProgressItems, + clearGalleryProgressItems, + removeGalleryProgressItem, + selectFilteredGalleryProgressItems, + setGalleryProgressItemInProgress, + updateGalleryProgressItemProgress, +} from './galleryProgressStore'; + +describe('galleryProgressStore', () => { + beforeEach(() => { + clearGalleryProgressItems(); + }); + + describe('addGalleryProgressItems', () => { + it('should add items with pending status', () => { + addGalleryProgressItems([1, 2, 3], 'batch-1', 'none'); + const items = $galleryProgressItems.get(); + expect(Object.keys(items)).toHaveLength(3); + expect(items[1]?.status).toBe('pending'); + expect(items[1]?.batchId).toBe('batch-1'); + expect(items[1]?.targetBoardId).toBe('none'); + expect(items[1]?.progressImage).toBeNull(); + }); + + it('should preserve existing items when adding new ones', () => { + addGalleryProgressItems([1], 'batch-1', 'none'); + addGalleryProgressItems([2], 'batch-2', 'board-1'); + const items = $galleryProgressItems.get(); + expect(Object.keys(items)).toHaveLength(2); + expect(items[1]?.batchId).toBe('batch-1'); + expect(items[2]?.batchId).toBe('batch-2'); + }); + }); + + describe('setGalleryProgressItemInProgress', () => { + it('should update status to in_progress', () => { + addGalleryProgressItems([1], 'batch-1', 'none'); + setGalleryProgressItemInProgress(1); + expect($galleryProgressItems.get()[1]?.status).toBe('in_progress'); + }); + + it('should no-op for non-existent items', () => { + setGalleryProgressItemInProgress(999); + expect(Object.keys($galleryProgressItems.get())).toHaveLength(0); + }); + }); + + describe('updateGalleryProgressItemProgress', () => { + it('should update progress image and percentage', () => { + addGalleryProgressItems([1], 'batch-1', 'none'); + const progressImage = { dataURL: 'data:image/png;base64,abc', width: 512, height: 512 }; + updateGalleryProgressItemProgress(1, progressImage, 0.5, 'Denoising'); + const item = $galleryProgressItems.get()[1]; + expect(item?.progressImage).toEqual(progressImage); + expect(item?.percentage).toBe(0.5); + expect(item?.message).toBe('Denoising'); + }); + + it('should no-op for non-existent items', () => { + updateGalleryProgressItemProgress(999, null, null, null); + expect(Object.keys($galleryProgressItems.get())).toHaveLength(0); + }); + }); + + describe('removeGalleryProgressItem', () => { + it('should remove the item', () => { + addGalleryProgressItems([1, 2], 'batch-1', 'none'); + removeGalleryProgressItem(1); + const items = $galleryProgressItems.get(); + expect(Object.keys(items)).toHaveLength(1); + expect(items[1]).toBeUndefined(); + expect(items[2]).toBeDefined(); + }); + + it('should no-op for non-existent items', () => { + addGalleryProgressItems([1], 'batch-1', 'none'); + removeGalleryProgressItem(999); + expect(Object.keys($galleryProgressItems.get())).toHaveLength(1); + }); + }); + + describe('clearGalleryProgressItems', () => { + it('should clear all items', () => { + addGalleryProgressItems([1, 2, 3], 'batch-1', 'none'); + clearGalleryProgressItems(); + expect(Object.keys($galleryProgressItems.get())).toHaveLength(0); + }); + }); + + describe('selectFilteredGalleryProgressItems', () => { + it('should filter by board ID', () => { + addGalleryProgressItems([1], 'batch-1', 'none'); + addGalleryProgressItems([2], 'batch-2', 'board-1'); + addGalleryProgressItems([3], 'batch-3', 'none'); + const items = $galleryProgressItems.get(); + const filtered = selectFilteredGalleryProgressItems(items, 'none'); + expect(filtered).toHaveLength(2); + expect(filtered.map((i) => i.itemId)).toEqual([1, 3]); + }); + + it('should sort by enqueuedAt ascending', () => { + addGalleryProgressItems([1], 'batch-1', 'none'); + addGalleryProgressItems([2], 'batch-2', 'none'); + const items = $galleryProgressItems.get(); + const filtered = selectFilteredGalleryProgressItems(items, 'none'); + expect(filtered[0]?.itemId).toBe(1); + expect(filtered[1]?.itemId).toBe(2); + }); + + it('should return empty array when no items match', () => { + addGalleryProgressItems([1], 'batch-1', 'none'); + const items = $galleryProgressItems.get(); + const filtered = selectFilteredGalleryProgressItems(items, 'board-xyz'); + expect(filtered).toHaveLength(0); + }); + }); +}); diff --git a/invokeai/frontend/web/src/features/gallery/store/galleryProgressStore.ts b/invokeai/frontend/web/src/features/gallery/store/galleryProgressStore.ts new file mode 100644 index 00000000000..f91e0b2e4c3 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/store/galleryProgressStore.ts @@ -0,0 +1,110 @@ +import type { BoardId } from 'features/gallery/store/types'; +import type { ProgressImage } from 'features/nodes/types/common'; +import { atom } from 'nanostores'; + +type GalleryProgressItemStatus = 'pending' | 'in_progress'; + +export type GalleryProgressItem = { + itemId: number; + batchId: string; + status: GalleryProgressItemStatus; + progressImage: ProgressImage | null; + percentage: number | null; + message: string | null; + targetBoardId: BoardId; + enqueuedAt: number; +}; + +/** + * Stores all active (pending/in_progress) queue items that should show in the gallery. + * Keyed by item ID. + */ +export const $galleryProgressItems = atom>({}); + +/** + * Adds multiple progress items from an enqueue batch result. + */ +export const addGalleryProgressItems = (itemIds: number[], batchId: string, targetBoardId: BoardId) => { + const current = $galleryProgressItems.get(); + const now = Date.now(); + const newItems: Record = {}; + for (const itemId of itemIds) { + newItems[itemId] = { + itemId, + batchId, + status: 'pending', + progressImage: null, + percentage: null, + message: null, + targetBoardId, + enqueuedAt: now, + }; + } + $galleryProgressItems.set({ ...current, ...newItems }); +}; + +/** + * Updates a progress item's status to in_progress. + */ +export const setGalleryProgressItemInProgress = (itemId: number) => { + const current = $galleryProgressItems.get(); + const item = current[itemId]; + if (!item) { + return; + } + $galleryProgressItems.set({ + ...current, + [itemId]: { ...item, status: 'in_progress' }, + }); +}; + +/** + * Updates a progress item's image and percentage. + */ +export const updateGalleryProgressItemProgress = ( + itemId: number, + progressImage: ProgressImage | null, + percentage: number | null, + message: string | null +) => { + const current = $galleryProgressItems.get(); + const item = current[itemId]; + if (!item) { + return; + } + $galleryProgressItems.set({ + ...current, + [itemId]: { ...item, progressImage, percentage, message }, + }); +}; + +/** + * Removes a progress item (on completion, failure, or cancellation). + */ +export const removeGalleryProgressItem = (itemId: number) => { + const current = $galleryProgressItems.get(); + if (!(itemId in current)) { + return; + } + const { [itemId]: _, ...rest } = current; + $galleryProgressItems.set(rest); +}; + +/** + * Clears all progress items (on disconnect/reconnect). + */ +export const clearGalleryProgressItems = () => { + $galleryProgressItems.set({}); +}; + +/** + * Returns progress items filtered by board ID, sorted by enqueuedAt ascending. + */ +export const selectFilteredGalleryProgressItems = ( + items: Record, + boardId: BoardId +): GalleryProgressItem[] => { + return Object.values(items) + .filter((item) => item.targetBoardId === boardId) + .sort((a, b) => a.enqueuedAt - b.enqueuedAt); +}; diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts index aad849fdb59..bc710eb9775 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts @@ -54,15 +54,10 @@ export const selectAutoAssignBoardOnClick = createSelector( ); export const selectBoardSearchText = createSelector(selectGallerySlice, (gallery) => gallery.boardSearchText); export const selectSearchTerm = createSelector(selectGallerySlice, (gallery) => gallery.searchTerm); -export const selectBoardsListOrderBy = createSelector(selectGallerySlice, (gallery) => gallery.boardsListOrderBy); -export const selectBoardsListOrderDir = createSelector(selectGallerySlice, (gallery) => gallery.boardsListOrderDir); export const selectSelectionCount = createSelector(selectGallerySlice, (gallery) => gallery.selection.length); export const selectSelection = createSelector(selectGallerySlice, (gallery) => gallery.selection); -export const selectGalleryImageMinimumWidth = createSelector( - selectGallerySlice, - (gallery) => gallery.galleryImageMinimumWidth -); +export const selectGalleryColumns = createSelector(selectGallerySlice, (gallery) => gallery.galleryColumns); export const selectComparisonMode = createSelector(selectGallerySlice, (gallery) => gallery.comparisonMode); export const selectComparisonFit = createSelector(selectGallerySlice, (gallery) => gallery.comparisonFit); @@ -71,3 +66,7 @@ export const selectAlwaysShouldImageSizeBadge = createSelector( selectGallerySlice, (gallery) => gallery.alwaysShowImageSizeBadge ); +export const selectShowAspectRatioThumbnails = createSelector( + selectGallerySlice, + (gallery) => gallery.showAspectRatioThumbnails +); diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts index 6a25caadce4..ecc11c6575b 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts @@ -21,7 +21,7 @@ const getInitialState = (): GalleryState => ({ shouldAutoSwitch: true, autoAssignBoardOnClick: true, autoAddBoardId: 'none', - galleryImageMinimumWidth: 90, + galleryColumns: 8, alwaysShowImageSizeBadge: false, selectedBoardId: 'none', galleryView: 'images', @@ -35,6 +35,7 @@ const getInitialState = (): GalleryState => ({ shouldShowArchivedBoards: false, boardsListOrderBy: 'created_at', boardsListOrderDir: 'DESC', + showAspectRatioThumbnails: false, }); const slice = createSlice({ @@ -75,8 +76,8 @@ const slice = createSlice({ shouldAutoSwitchChanged: (state, action: PayloadAction) => { state.shouldAutoSwitch = action.payload; }, - setGalleryImageMinimumWidth: (state, action: PayloadAction) => { - state.galleryImageMinimumWidth = action.payload; + setGalleryColumns: (state, action: PayloadAction) => { + state.galleryColumns = action.payload; }, autoAssignBoardOnClickChanged: (state, action: PayloadAction) => { state.autoAssignBoardOnClick = action.payload; @@ -142,6 +143,9 @@ const slice = createSlice({ boardsListOrderDirChanged: (state, action: PayloadAction) => { state.boardsListOrderDir = action.payload; }, + showAspectRatioThumbnailsChanged: (state, action: PayloadAction) => { + state.showAspectRatioThumbnails = action.payload; + }, }, extraReducers(builder) { // Clear board-related state on logout to prevent stale data when switching users @@ -156,8 +160,8 @@ const slice = createSlice({ export const { imageSelected, shouldAutoSwitchChanged, + setGalleryColumns, autoAssignBoardOnClickChanged, - setGalleryImageMinimumWidth, boardIdSelected, autoAddBoardIdChanged, galleryViewChanged, @@ -172,9 +176,10 @@ export const { orderDirChanged, starredFirstChanged, shouldShowArchivedBoardsChanged, - searchTermChanged, boardsListOrderByChanged, boardsListOrderDirChanged, + searchTermChanged, + showAspectRatioThumbnailsChanged, } = slice.actions; export const selectGallerySlice = (state: RootState) => state.gallery; diff --git a/invokeai/frontend/web/src/features/gallery/store/types.ts b/invokeai/frontend/web/src/features/gallery/store/types.ts index addeefe870f..d1a6a96d4aa 100644 --- a/invokeai/frontend/web/src/features/gallery/store/types.ts +++ b/invokeai/frontend/web/src/features/gallery/store/types.ts @@ -23,7 +23,7 @@ export const zGalleryState = z.object({ shouldAutoSwitch: z.boolean(), autoAssignBoardOnClick: z.boolean(), autoAddBoardId: zBoardId, - galleryImageMinimumWidth: z.number(), + galleryColumns: z.number(), selectedBoardId: zBoardId, galleryView: zGalleryView, boardSearchText: z.string(), @@ -37,6 +37,7 @@ export const zGalleryState = z.object({ shouldShowArchivedBoards: z.boolean(), boardsListOrderBy: zBoardRecordOrderBy, boardsListOrderDir: zOrderDir, + showAspectRatioThumbnails: z.boolean(), }); export type GalleryState = z.infer; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowsTabLeftPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowsTabLeftPanel.tsx index a31b71a4d44..102a2ec16ec 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowsTabLeftPanel.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowsTabLeftPanel.tsx @@ -13,7 +13,7 @@ const WorkflowsTabLeftPanel = () => { const mode = useAppSelector(selectWorkflowMode); return ( - + diff --git a/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts index c6a0bd6705b..ca6b158ca09 100644 --- a/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts +++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts @@ -107,6 +107,8 @@ export const useHotkeyData = (): HotkeysData => { addHotkey('app', 'focusPrompt', ['alt+a']); addHotkey('app', 'toggleLeftPanel', ['t', 'o']); addHotkey('app', 'toggleRightPanel', ['g']); + // addHotkey('app', 'toggleGalleryPanel', ['g']); + // addHotkey('app', 'toggleLayersPanel', ['l']); addHotkey('app', 'resetPanelLayout', ['shift+r']); addHotkey('app', 'togglePanels', ['f']); diff --git a/invokeai/frontend/web/src/features/ui/components/FloatingRightPanelButtons.tsx b/invokeai/frontend/web/src/features/ui/components/FloatingLayerPanelButtons.tsx similarity index 52% rename from invokeai/frontend/web/src/features/ui/components/FloatingRightPanelButtons.tsx rename to invokeai/frontend/web/src/features/ui/components/FloatingLayerPanelButtons.tsx index dc0d8025fad..402ef29fe94 100644 --- a/invokeai/frontend/web/src/features/ui/components/FloatingRightPanelButtons.tsx +++ b/invokeai/frontend/web/src/features/ui/components/FloatingLayerPanelButtons.tsx @@ -1,30 +1,34 @@ import { Flex, IconButton, Tooltip } from '@invoke-ai/ui-library'; import { navigationApi } from 'features/ui/layouts/navigation-api'; -import { memo } from 'react'; +import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { PiImagesSquareBold } from 'react-icons/pi'; +import { PiSidebarSimpleBold } from 'react-icons/pi'; -export const FloatingRightPanelButtons = memo(() => { +export const FloatingLayerPanelButtons = memo(() => { return ( - + ); }); -FloatingRightPanelButtons.displayName = 'FloatingRightPanelButtons'; +FloatingLayerPanelButtons.displayName = 'FloatingLayerPanelButtons'; -const ToggleRightPanelButton = memo(() => { +const ToggleLayerPanelButton = memo(() => { const { t } = useTranslation(); + const handleClick = useCallback(() => { + navigationApi.toggleRightPanel(); + }, []); + return ( } + onClick={handleClick} + icon={} h={48} /> ); }); -ToggleRightPanelButton.displayName = 'ToggleRightPanelButton'; +ToggleLayerPanelButton.displayName = 'ToggleLayerPanelButton'; diff --git a/invokeai/frontend/web/src/features/ui/components/VerticalNavBar.tsx b/invokeai/frontend/web/src/features/ui/components/VerticalNavBar.tsx index be3fa3e6898..1b8e5c23358 100644 --- a/invokeai/frontend/web/src/features/ui/components/VerticalNavBar.tsx +++ b/invokeai/frontend/web/src/features/ui/components/VerticalNavBar.tsx @@ -22,7 +22,17 @@ export const VerticalNavBar = memo(() => { const { t } = useTranslation(); return ( - + diff --git a/invokeai/frontend/web/src/features/ui/layouts/AutoLayoutPanelContainer.tsx b/invokeai/frontend/web/src/features/ui/layouts/AutoLayoutPanelContainer.tsx index 88e66f425b3..281d49afd2c 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/AutoLayoutPanelContainer.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/AutoLayoutPanelContainer.tsx @@ -11,7 +11,6 @@ const sx: SystemStyleObject = { position: 'relative', w: 'full', h: 'full', - p: 2, '&[data-highlighted="true"]::after': { borderColor: 'invokeBlue.300', }, diff --git a/invokeai/frontend/web/src/features/ui/layouts/CanvasTabLeftPanel.tsx b/invokeai/frontend/web/src/features/ui/layouts/CanvasTabLeftPanel.tsx index f9937e05a31..766045e2aaf 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/CanvasTabLeftPanel.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/CanvasTabLeftPanel.tsx @@ -5,7 +5,7 @@ import { memo } from 'react'; export const CanvasTabLeftPanel = memo(() => { return ( - + diff --git a/invokeai/frontend/web/src/features/ui/layouts/GenerateTabLeftPanel.tsx b/invokeai/frontend/web/src/features/ui/layouts/GenerateTabLeftPanel.tsx index d7f0c254526..9c3d495fa59 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/GenerateTabLeftPanel.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/GenerateTabLeftPanel.tsx @@ -5,7 +5,7 @@ import { memo } from 'react'; export const GenerateTabLeftPanel = memo(() => { return ( - + diff --git a/invokeai/frontend/web/src/features/ui/layouts/PanelHotkeysLogical.tsx b/invokeai/frontend/web/src/features/ui/layouts/PanelHotkeysLogical.tsx index 5e0b35fd551..8d63e75445c 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/PanelHotkeysLogical.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/PanelHotkeysLogical.tsx @@ -11,7 +11,7 @@ export const PanelHotkeysLogical = memo(() => { useRegisteredHotkeys({ category: 'app', id: 'toggleRightPanel', - callback: navigationApi.toggleRightPanel, + callback: navigationApi.toggleBottomPanel, }); useRegisteredHotkeys({ category: 'app', diff --git a/invokeai/frontend/web/src/features/ui/layouts/UpscalingTabLeftPanel.tsx b/invokeai/frontend/web/src/features/ui/layouts/UpscalingTabLeftPanel.tsx index f76d848a529..d772e2bc769 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/UpscalingTabLeftPanel.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/UpscalingTabLeftPanel.tsx @@ -5,7 +5,7 @@ import { memo } from 'react'; export const UpscalingTabLeftPanel = memo(() => { return ( - + diff --git a/invokeai/frontend/web/src/features/ui/layouts/canvas-tab-auto-layout.tsx b/invokeai/frontend/web/src/features/ui/layouts/canvas-tab-auto-layout.tsx index e2cbfe2c5d2..dce05dcf39d 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/canvas-tab-auto-layout.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/canvas-tab-auto-layout.tsx @@ -1,11 +1,10 @@ import type { DockviewApi, GridviewApi, IDockviewReactProps, IGridviewReactProps } from 'dockview'; import { DockviewReact, GridviewReact, LayoutPriority, Orientation } from 'dockview'; import { CanvasLayersPanel } from 'features/controlLayers/components/CanvasLayersPanelContent'; -import { BoardsPanel } from 'features/gallery/components/BoardsListPanelContent'; -import { GalleryPanel } from 'features/gallery/components/GalleryPanel'; +import { BottomGalleryPanel } from 'features/gallery/components/BottomGalleryPanel'; import { ImageViewerPanel } from 'features/gallery/components/ImageViewer/ImageViewerPanel'; +import { FloatingLayerPanelButtons } from 'features/ui/components/FloatingLayerPanelButtons'; import { FloatingCanvasLeftPanelButtons } from 'features/ui/components/FloatingLeftPanelButtons'; -import { FloatingRightPanelButtons } from 'features/ui/components/FloatingRightPanelButtons'; import type { AutoLayoutDockviewComponents, AutoLayoutGridviewComponents, @@ -28,15 +27,12 @@ import { DockviewTabLaunchpad } from './DockviewTabLaunchpad'; import { navigationApi } from './navigation-api'; import { PanelHotkeysLogical } from './PanelHotkeysLogical'; import { - BOARD_PANEL_MIN_HEIGHT_PX, - BOARDS_PANEL_ID, - CANVAS_BOARD_PANEL_DEFAULT_HEIGHT_PX, + BOTTOM_GALLERY_DEFAULT_HEIGHT_PX, + BOTTOM_GALLERY_MIN_HEIGHT_PX, + BOTTOM_GALLERY_PANEL_ID, DOCKVIEW_TAB_CANVAS_VIEWER_ID, DOCKVIEW_TAB_CANVAS_WORKSPACE_ID, DOCKVIEW_TAB_LAUNCHPAD_ID, - GALLERY_PANEL_DEFAULT_HEIGHT_PX, - GALLERY_PANEL_ID, - GALLERY_PANEL_MIN_HEIGHT_PX, LAUNCHPAD_PANEL_ID, LAYERS_PANEL_ID, LAYERS_PANEL_MIN_HEIGHT_PX, @@ -46,6 +42,7 @@ import { RIGHT_PANEL_ID, RIGHT_PANEL_MIN_SIZE_PX, SETTINGS_PANEL_ID, + TOP_AREA_ID, VIEWER_PANEL_ID, WORKSPACE_PANEL_ID, } from './shared'; @@ -134,46 +131,22 @@ const MainPanel = memo(() => { tabComponents={tabComponents} /> - + ); }); MainPanel.displayName = 'MainPanel'; +/** + * Right panel on canvas tab contains only the layers panel (no gallery/boards). + */ const rightPanelComponents: AutoLayoutGridviewComponents = { - [BOARDS_PANEL_ID]: withPanelContainer(BoardsPanel), - [GALLERY_PANEL_ID]: withPanelContainer(GalleryPanel), [LAYERS_PANEL_ID]: withPanelContainer(CanvasLayersPanel), }; const initializeRightPanelLayout = (tab: TabName, api: GridviewApi) => { navigationApi.registerContainer(tab, 'right', api, () => { - const gallery = api.addPanel({ - id: GALLERY_PANEL_ID, - component: GALLERY_PANEL_ID, - minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, - minimumHeight: GALLERY_PANEL_MIN_HEIGHT_PX, - params: { - tab, - focusRegion: 'gallery', - }, - }); - - const boards = api.addPanel({ - id: BOARDS_PANEL_ID, - component: BOARDS_PANEL_ID, - minimumHeight: BOARD_PANEL_MIN_HEIGHT_PX, - params: { - tab, - focusRegion: 'boards', - }, - position: { - direction: 'above', - referencePanel: gallery.id, - }, - }); - api.addPanel({ id: LAYERS_PANEL_ID, component: LAYERS_PANEL_ID, @@ -182,14 +155,7 @@ const initializeRightPanelLayout = (tab: TabName, api: GridviewApi) => { tab, focusRegion: 'layers', }, - position: { - direction: 'below', - referencePanel: gallery.id, - }, }); - - gallery.api.setSize({ height: GALLERY_PANEL_DEFAULT_HEIGHT_PX }); - boards.api.setSize({ height: CANVAS_BOARD_PANEL_DEFAULT_HEIGHT_PX }); }); }; @@ -250,14 +216,17 @@ const LeftPanel = memo(() => { }); LeftPanel.displayName = 'LeftPanel'; -const rootPanelComponents: RootLayoutGridviewComponents = { +/** + * Top area for canvas: left panel + main panel + right panel (layers only) side by side. + */ +const topAreaComponents: RootLayoutGridviewComponents = { [LEFT_PANEL_ID]: LeftPanel, [MAIN_PANEL_ID]: MainPanel, [RIGHT_PANEL_ID]: RightPanel, }; -const initializeRootPanelLayout = (tab: TabName, api: GridviewApi) => { - navigationApi.registerContainer(tab, 'root', api, () => { +const initializeTopAreaLayout = (tab: TabName, api: GridviewApi) => { + navigationApi.registerContainer(tab, 'top-area', api, () => { const main = api.addPanel({ id: MAIN_PANEL_ID, component: MAIN_PANEL_ID, @@ -291,6 +260,61 @@ const initializeRootPanelLayout = (tab: TabName, api: GridviewApi) => { }); }; +const TopArea = memo(() => { + const { tab } = useAutoLayoutContext(); + + const onReady = useCallback( + ({ api }) => { + initializeTopAreaLayout(tab, api); + }, + [tab] + ); + return ( + + ); +}); +TopArea.displayName = 'TopArea'; + +const bottomGalleryComponents: AutoLayoutGridviewComponents = { + [BOTTOM_GALLERY_PANEL_ID]: withPanelContainer(BottomGalleryPanel), +}; + +const rootPanelComponents: RootLayoutGridviewComponents = { + [TOP_AREA_ID]: TopArea, + [BOTTOM_GALLERY_PANEL_ID]: bottomGalleryComponents[BOTTOM_GALLERY_PANEL_ID]!, +}; + +const initializeRootPanelLayout = (tab: TabName, api: GridviewApi) => { + navigationApi.registerContainer(tab, 'root', api, () => { + const topArea = api.addPanel({ + id: TOP_AREA_ID, + component: TOP_AREA_ID, + priority: LayoutPriority.High, + }); + + const bottomGallery = api.addPanel({ + id: BOTTOM_GALLERY_PANEL_ID, + component: BOTTOM_GALLERY_PANEL_ID, + minimumHeight: BOTTOM_GALLERY_MIN_HEIGHT_PX, + params: { + tab, + focusRegion: 'gallery', + }, + position: { + direction: 'below', + referencePanel: topArea.id, + }, + }); + + bottomGallery.api.setSize({ height: BOTTOM_GALLERY_DEFAULT_HEIGHT_PX }); + }); +}; + export const CanvasTabAutoLayout = memo(() => { const onReady = useCallback(({ api }) => { initializeRootPanelLayout('canvas', api); @@ -309,7 +333,7 @@ export const CanvasTabAutoLayout = memo(() => { className="dockview-theme-invoke" components={rootPanelComponents} onReady={onReady} - orientation={Orientation.VERTICAL} + orientation={Orientation.HORIZONTAL} /> ); diff --git a/invokeai/frontend/web/src/features/ui/layouts/generate-tab-auto-layout.tsx b/invokeai/frontend/web/src/features/ui/layouts/generate-tab-auto-layout.tsx index e60c15b5da3..575da089fda 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/generate-tab-auto-layout.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/generate-tab-auto-layout.tsx @@ -1,10 +1,8 @@ import type { DockviewApi, GridviewApi, IDockviewReactProps, IGridviewReactProps } from 'dockview'; import { DockviewReact, GridviewReact, LayoutPriority, Orientation } from 'dockview'; -import { BoardsPanel } from 'features/gallery/components/BoardsListPanelContent'; -import { GalleryPanel } from 'features/gallery/components/GalleryPanel'; +import { BottomGalleryPanel } from 'features/gallery/components/BottomGalleryPanel'; import { ImageViewerPanel } from 'features/gallery/components/ImageViewer/ImageViewerPanel'; import { FloatingLeftPanelButtons } from 'features/ui/components/FloatingLeftPanelButtons'; -import { FloatingRightPanelButtons } from 'features/ui/components/FloatingRightPanelButtons'; import type { AutoLayoutDockviewComponents, AutoLayoutGridviewComponents, @@ -26,22 +24,18 @@ import { GenerateTabLeftPanel } from './GenerateTabLeftPanel'; import { navigationApi } from './navigation-api'; import { PanelHotkeysLogical } from './PanelHotkeysLogical'; import { - BOARD_PANEL_DEFAULT_HEIGHT_PX, - BOARD_PANEL_MIN_HEIGHT_PX, - BOARDS_PANEL_ID, + BOTTOM_GALLERY_DEFAULT_HEIGHT_PX, + BOTTOM_GALLERY_MIN_HEIGHT_PX, + BOTTOM_GALLERY_PANEL_ID, DOCKVIEW_TAB_ID, DOCKVIEW_TAB_LAUNCHPAD_ID, DOCKVIEW_TAB_PROGRESS_ID, - GALLERY_PANEL_DEFAULT_HEIGHT_PX, - GALLERY_PANEL_ID, - GALLERY_PANEL_MIN_HEIGHT_PX, LAUNCHPAD_PANEL_ID, LEFT_PANEL_ID, LEFT_PANEL_MIN_SIZE_PX, MAIN_PANEL_ID, - RIGHT_PANEL_ID, - RIGHT_PANEL_MIN_SIZE_PX, SETTINGS_PANEL_ID, + TOP_AREA_ID, VIEWER_PANEL_ID, } from './shared'; @@ -112,70 +106,12 @@ const MainPanel = memo(() => { theme={dockviewTheme} /> - ); }); MainPanel.displayName = 'MainPanel'; -const rightPanelComponents: AutoLayoutGridviewComponents = { - [BOARDS_PANEL_ID]: withPanelContainer(BoardsPanel), - [GALLERY_PANEL_ID]: withPanelContainer(GalleryPanel), -}; - -const initializeRightPanelLayout = (tab: TabName, api: GridviewApi) => { - navigationApi.registerContainer(tab, 'right', api, () => { - const gallery = api.addPanel({ - id: GALLERY_PANEL_ID, - component: GALLERY_PANEL_ID, - minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, - minimumHeight: GALLERY_PANEL_MIN_HEIGHT_PX, - params: { - tab, - focusRegion: 'gallery', - }, - }); - - const boards = api.addPanel({ - id: BOARDS_PANEL_ID, - component: BOARDS_PANEL_ID, - minimumHeight: BOARD_PANEL_MIN_HEIGHT_PX, - params: { - tab, - focusRegion: 'boards', - }, - position: { - direction: 'above', - referencePanel: gallery.id, - }, - }); - - gallery.api.setSize({ height: GALLERY_PANEL_DEFAULT_HEIGHT_PX }); - boards.api.setSize({ height: BOARD_PANEL_DEFAULT_HEIGHT_PX }); - }); -}; - -const RightPanel = memo(() => { - const { tab } = useAutoLayoutContext(); - - const onReady = useCallback( - ({ api }) => { - initializeRightPanelLayout(tab, api); - }, - [tab] - ); - return ( - - ); -}); -RightPanel.displayName = 'RightPanel'; - const leftPanelComponents: AutoLayoutGridviewComponents = { [SETTINGS_PANEL_ID]: withPanelContainer(GenerateTabLeftPanel), }; @@ -213,14 +149,17 @@ const LeftPanel = memo(() => { }); LeftPanel.displayName = 'LeftPanel'; -const rootPanelComponents: RootLayoutGridviewComponents = { +/** + * The top area contains the left panel and main panel side by side. + * This is a nested GridviewReact with VERTICAL orientation (which arranges panels horizontally). + */ +const topAreaComponents: RootLayoutGridviewComponents = { [LEFT_PANEL_ID]: LeftPanel, [MAIN_PANEL_ID]: MainPanel, - [RIGHT_PANEL_ID]: RightPanel, }; -const initializeRootPanelLayout = (tab: TabName, api: GridviewApi) => { - navigationApi.registerContainer(tab, 'root', api, () => { +const initializeTopAreaLayout = (tab: TabName, api: GridviewApi) => { + navigationApi.registerContainer(tab, 'top-area', api, () => { const main = api.addPanel({ id: MAIN_PANEL_ID, component: MAIN_PANEL_ID, @@ -237,18 +176,69 @@ const initializeRootPanelLayout = (tab: TabName, api: GridviewApi) => { }, }); - const right = api.addPanel({ - id: RIGHT_PANEL_ID, - component: RIGHT_PANEL_ID, - minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, + left.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX }); + }); +}; + +const TopArea = memo(() => { + const { tab } = useAutoLayoutContext(); + + const onReady = useCallback( + ({ api }) => { + initializeTopAreaLayout(tab, api); + }, + [tab] + ); + return ( + + ); +}); +TopArea.displayName = 'TopArea'; + +/** + * Bottom gallery panel wrapper for use in GridviewReact. + */ +const bottomGalleryComponents: AutoLayoutGridviewComponents = { + [BOTTOM_GALLERY_PANEL_ID]: withPanelContainer(BottomGalleryPanel), +}; + +/** + * Root layout: vertical split with top area and bottom gallery. + * Orientation.HORIZONTAL arranges panels vertically (top/bottom). + */ +const rootPanelComponents: RootLayoutGridviewComponents = { + [TOP_AREA_ID]: TopArea, + [BOTTOM_GALLERY_PANEL_ID]: bottomGalleryComponents[BOTTOM_GALLERY_PANEL_ID]!, +}; + +const initializeRootPanelLayout = (tab: TabName, api: GridviewApi) => { + navigationApi.registerContainer(tab, 'root', api, () => { + const topArea = api.addPanel({ + id: TOP_AREA_ID, + component: TOP_AREA_ID, + priority: LayoutPriority.High, + }); + + const bottomGallery = api.addPanel({ + id: BOTTOM_GALLERY_PANEL_ID, + component: BOTTOM_GALLERY_PANEL_ID, + minimumHeight: BOTTOM_GALLERY_MIN_HEIGHT_PX, + params: { + tab, + focusRegion: 'gallery', + }, position: { - direction: 'right', - referencePanel: main.id, + direction: 'below', + referencePanel: topArea.id, }, }); - left.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX }); - right.api.setSize({ width: RIGHT_PANEL_MIN_SIZE_PX }); + bottomGallery.api.setSize({ height: BOTTOM_GALLERY_DEFAULT_HEIGHT_PX }); }); }; @@ -270,7 +260,7 @@ export const GenerateTabAutoLayout = memo(() => { className="dockview-theme-invoke" components={rootPanelComponents} onReady={onReady} - orientation={Orientation.VERTICAL} + orientation={Orientation.HORIZONTAL} /> ); diff --git a/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts b/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts index a1ae782ab01..7a4622e906b 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts +++ b/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts @@ -10,7 +10,9 @@ import type { Atom } from 'nanostores'; import { atom } from 'nanostores'; import { - GALLERY_PANEL_ID, + BOTTOM_GALLERY_MIN_EXPANDED_HEIGHT_PX, + BOTTOM_GALLERY_MIN_HEIGHT_PX, + BOTTOM_GALLERY_PANEL_ID, LAUNCHPAD_PANEL_ID, LEFT_PANEL_ID, LEFT_PANEL_MIN_SIZE_PX, @@ -77,6 +79,12 @@ export class NavigationApi { private _$isLoading = atom(false); $isLoading: Atom = this._$isLoading; + /** + * Track the last expanded height of the bottom gallery panel for each tab. + * Used to restore the panel to its previous height when re-expanding after a collapse. + */ + _lastBottomPanelHeight: Map = new Map(); + /** * Track the _previous_ active dockview panel for each tab. */ @@ -610,10 +618,10 @@ export class NavigationApi { }; /** - * Toggle the left and right panels in the currently active tab. + * Toggle the left and bottom gallery panels in the currently active tab. + * On the canvas tab, also toggles the right panel if it exists. * - * This method will not wait for the panels to be registered. If either panel is not found, it will not toggle - * either panel. + * This method will not wait for the panels to be registered. * * @returns True if the panels were toggled, false if they were not found or an error occurred */ @@ -624,67 +632,213 @@ export class NavigationApi { return false; } const leftPanel = this.getPanel(activeTab, LEFT_PANEL_ID); - const rightPanel = this.getPanel(activeTab, RIGHT_PANEL_ID); + const bottomPanel = this.getPanel(activeTab, BOTTOM_GALLERY_PANEL_ID); - if (!rightPanel || !leftPanel) { - log.warn(`Right and/or left panel not found in tab "${activeTab}"`); + if (!leftPanel) { + log.warn(`Left panel not found in tab "${activeTab}"`); return false; } - if (!(leftPanel instanceof GridviewPanel) || !(rightPanel instanceof GridviewPanel)) { - log.error(`Left and right panels must be instances of GridviewPanel`); + if (!(leftPanel instanceof GridviewPanel)) { + log.error(`Left panel must be an instance of GridviewPanel`); return false; } const isLeftCollapsed = leftPanel.width === 0; - const isRightCollapsed = rightPanel.width === 0; - if (isLeftCollapsed || isRightCollapsed) { + // Check for bottom panel (only counts toward collapse state if it exists) + let hasBottomPanel = false; + let isBottomCollapsed = false; + if (bottomPanel && bottomPanel instanceof GridviewPanel) { + hasBottomPanel = true; + isBottomCollapsed = this._isBottomPanelCollapsedByHeight(bottomPanel); + } + + // Also check for right panel (canvas tab has one) + const rightPanel = this.getPanel(activeTab, RIGHT_PANEL_ID); + let hasRightPanel = false; + let isRightCollapsed = false; + if (rightPanel && rightPanel instanceof GridviewPanel) { + hasRightPanel = true; + isRightCollapsed = rightPanel.width === 0; + } + + const anyCollapsed = + isLeftCollapsed || (hasBottomPanel && isBottomCollapsed) || (hasRightPanel && isRightCollapsed); + + if (anyCollapsed) { this._expandPanel(leftPanel, LEFT_PANEL_MIN_SIZE_PX); - this._expandPanel(rightPanel, RIGHT_PANEL_MIN_SIZE_PX); + if (bottomPanel && bottomPanel instanceof GridviewPanel) { + const savedHeight = this._lastBottomPanelHeight.get(activeTab) ?? BOTTOM_GALLERY_MIN_EXPANDED_HEIGHT_PX; + this._expandPanelVertical(bottomPanel, savedHeight, BOTTOM_GALLERY_MIN_EXPANDED_HEIGHT_PX); + } + if (rightPanel && rightPanel instanceof GridviewPanel) { + this._expandPanel(rightPanel, RIGHT_PANEL_MIN_SIZE_PX); + } } else { this._collapsePanel(leftPanel); - this._collapsePanel(rightPanel); + if (bottomPanel && bottomPanel instanceof GridviewPanel) { + // Save current height before collapsing + this._lastBottomPanelHeight.set(activeTab, bottomPanel.height); + this._collapsePanelVertical(bottomPanel, BOTTOM_GALLERY_MIN_HEIGHT_PX); + } + if (rightPanel && rightPanel instanceof GridviewPanel) { + this._collapsePanel(rightPanel); + } } return true; }; /** - * Reset both left and right panels in the currently active tab to their minimum sizes. + * Reset left, right (if exists), and bottom gallery panels in the currently active tab to their default sizes. * - * This method will not wait for the panels to be registered. If either panel is not found, it will not reset - * either panel. + * This method will not wait for the panels to be registered. * * @returns True if the panels were reset, false if they were not found or an error occurred */ resetLeftAndRightPanels = (): boolean => { const activeTab = this._app?.activeTab.get() ?? null; if (!activeTab) { - log.warn('No active tab found to toggle right panel'); + log.warn('No active tab found to reset panels'); return false; } const leftPanel = this.getPanel(activeTab, LEFT_PANEL_ID); - const rightPanel = this.getPanel(activeTab, RIGHT_PANEL_ID); - if (!rightPanel || !leftPanel) { - log.warn(`Right and/or left panel not found in tab "${activeTab}"`); + if (!leftPanel) { + log.warn(`Left panel not found in tab "${activeTab}"`); return false; } - if (!(leftPanel instanceof GridviewPanel) || !(rightPanel instanceof GridviewPanel)) { - log.error(`Left and right panels must be instances of GridviewPanel`); + if (!(leftPanel instanceof GridviewPanel)) { + log.error(`Left panel must be an instance of GridviewPanel`); return false; } leftPanel.api.setConstraints({ maximumWidth: Number.MAX_SAFE_INTEGER, minimumWidth: LEFT_PANEL_MIN_SIZE_PX }); leftPanel.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX }); - rightPanel.api.setConstraints({ maximumWidth: Number.MAX_SAFE_INTEGER, minimumWidth: RIGHT_PANEL_MIN_SIZE_PX }); - rightPanel.api.setSize({ width: RIGHT_PANEL_MIN_SIZE_PX }); + // Reset right panel if it exists (canvas tab) + const rightPanel = this.getPanel(activeTab, RIGHT_PANEL_ID); + if (rightPanel && rightPanel instanceof GridviewPanel) { + rightPanel.api.setConstraints({ maximumWidth: Number.MAX_SAFE_INTEGER, minimumWidth: RIGHT_PANEL_MIN_SIZE_PX }); + rightPanel.api.setSize({ width: RIGHT_PANEL_MIN_SIZE_PX }); + } + + // Reset bottom gallery panel + const bottomPanel = this.getPanel(activeTab, BOTTOM_GALLERY_PANEL_ID); + if (bottomPanel && bottomPanel instanceof GridviewPanel) { + bottomPanel.api.setConstraints({ + maximumHeight: Number.MAX_SAFE_INTEGER, + minimumHeight: BOTTOM_GALLERY_MIN_EXPANDED_HEIGHT_PX, + }); + bottomPanel.api.setSize({ height: BOTTOM_GALLERY_MIN_EXPANDED_HEIGHT_PX }); + } return true; }; + /** + * Expand a panel vertically to a specified height. + * @param panel - The panel to expand + * @param height - The target height to set + * @param minHeight - The minimum height constraint (defaults to height if not specified) + */ + _expandPanelVertical = (panel: IGridviewPanel, height: number, minHeight?: number) => { + panel.api.setConstraints({ maximumHeight: Number.MAX_SAFE_INTEGER, minimumHeight: minHeight ?? height }); + panel.api.setSize({ height: height }); + }; + + /** + * Collapse a panel vertically by setting its height to 0. + */ + _collapsePanelVertical = (panel: IGridviewPanel, collapsedHeight: number = 0) => { + panel.api.setConstraints({ maximumHeight: collapsedHeight, minimumHeight: collapsedHeight }); + panel.api.setSize({ height: collapsedHeight }); + }; + + /** + * Check if a bottom gallery panel is collapsed based on its height. + * A panel is considered collapsed when its height is at or below the minimum (header-only) height. + */ + _isBottomPanelCollapsedByHeight = (panel: IGridviewPanel): boolean => { + return panel.height <= BOTTOM_GALLERY_MIN_HEIGHT_PX; + }; + + /** + * Toggle the bottom gallery panel in the currently active tab. + */ + toggleBottomPanel = (): boolean => { + const activeTab = this._app?.activeTab.get() ?? null; + if (!activeTab) { + log.warn('No active tab found to toggle bottom panel'); + return false; + } + const bottomPanel = this.getPanel(activeTab, BOTTOM_GALLERY_PANEL_ID); + if (!bottomPanel) { + log.warn(`Bottom gallery panel not found in active tab "${activeTab}"`); + return false; + } + + if (!(bottomPanel instanceof GridviewPanel)) { + log.error(`Bottom gallery panel must be an instance of GridviewPanel`); + return false; + } + + // Panel is collapsed if its height is at the collapsed min (header bar only) + const isCollapsed = this._isBottomPanelCollapsedByHeight(bottomPanel); + if (isCollapsed) { + const savedHeight = this._lastBottomPanelHeight.get(activeTab) ?? BOTTOM_GALLERY_MIN_EXPANDED_HEIGHT_PX; + this._expandPanelVertical(bottomPanel, savedHeight, BOTTOM_GALLERY_MIN_EXPANDED_HEIGHT_PX); + } else { + // Save current height before collapsing + this._lastBottomPanelHeight.set(activeTab, bottomPanel.height); + this._collapsePanelVertical(bottomPanel, BOTTOM_GALLERY_MIN_HEIGHT_PX); + } + return true; + }; + + /** + * Expand the bottom gallery panel in the currently active tab. + */ + expandBottomPanel = (): boolean => { + const activeTab = this._app?.activeTab.get() ?? null; + if (!activeTab) { + log.warn('No active tab found to expand bottom panel'); + return false; + } + const bottomPanel = this.getPanel(activeTab, BOTTOM_GALLERY_PANEL_ID); + if (!bottomPanel) { + log.warn(`Bottom gallery panel not found in active tab "${activeTab}"`); + return false; + } + + if (!(bottomPanel instanceof GridviewPanel)) { + log.error(`Bottom gallery panel must be an instance of GridviewPanel`); + return false; + } + + const savedHeight = this._lastBottomPanelHeight.get(activeTab) ?? BOTTOM_GALLERY_MIN_EXPANDED_HEIGHT_PX; + this._expandPanelVertical(bottomPanel, savedHeight, BOTTOM_GALLERY_MIN_EXPANDED_HEIGHT_PX); + return true; + }; + + /** + * Check if the bottom gallery panel in the currently active tab is collapsed. + * + * @returns True if the bottom panel is collapsed (height <= collapsed min), false if expanded or not found + */ + isBottomPanelCollapsed = (): boolean => { + const activeTab = this._app?.activeTab.get() ?? null; + if (!activeTab) { + return false; + } + const bottomPanel = this.getPanel(activeTab, BOTTOM_GALLERY_PANEL_ID); + if (!bottomPanel || !(bottomPanel instanceof GridviewPanel)) { + return false; + } + return this._isBottomPanelCollapsedByHeight(bottomPanel); + }; + /** * Toggle between the viewer panel and the previously focused dockview panel in the current tab. * If currently on viewer and a previous panel exists, switch to the previous panel. @@ -769,11 +923,11 @@ export class NavigationApi { * Returns true when the gallery panel is collapsed in the provided tab. */ isGalleryPanelCollapsed = (tab: TabName): boolean => { - const galleryPanel = this.getPanel(tab, GALLERY_PANEL_ID); + const galleryPanel = this.getPanel(tab, BOTTOM_GALLERY_PANEL_ID); if (!(galleryPanel instanceof GridviewPanel)) { return false; } - return galleryPanel.height <= (galleryPanel.minimumHeight ?? 0); + return this._isBottomPanelCollapsedByHeight(galleryPanel); }; /** @@ -822,6 +976,7 @@ export class NavigationApi { // Clear previous panel tracking for this tab this._prevActiveDockviewPanel.delete(tab); this._currentActiveDockviewPanel.delete(tab); + this._lastBottomPanelHeight.delete(tab); this._disposablesForTab.get(tab)?.forEach((disposeFn) => { try { disposeFn(); diff --git a/invokeai/frontend/web/src/features/ui/layouts/shared.ts b/invokeai/frontend/web/src/features/ui/layouts/shared.ts index efb17037ee8..e5844a80030 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/shared.ts +++ b/invokeai/frontend/web/src/features/ui/layouts/shared.ts @@ -1,13 +1,13 @@ export const LEFT_PANEL_ID = 'left'; export const MAIN_PANEL_ID = 'main'; export const RIGHT_PANEL_ID = 'right'; +export const TOP_AREA_ID = 'top-area'; +export const BOTTOM_GALLERY_PANEL_ID = 'bottom-gallery'; export const LAUNCHPAD_PANEL_ID = 'launchpad'; export const WORKSPACE_PANEL_ID = 'workspace'; export const VIEWER_PANEL_ID = 'viewer'; -export const BOARDS_PANEL_ID = 'boards'; -export const GALLERY_PANEL_ID = 'gallery'; export const LAYERS_PANEL_ID = 'layers'; export const SETTINGS_PANEL_ID = 'settings'; @@ -24,16 +24,14 @@ export const DOCKVIEW_TAB_CANVAS_WORKSPACE_ID = 'tab-canvas-workspace'; export const LEFT_PANEL_MIN_SIZE_PX = 420; export const RIGHT_PANEL_MIN_SIZE_PX = 420; -export const BOARD_PANEL_MIN_HEIGHT_PX = 36; -export const BOARD_PANEL_MIN_EXPANDED_HEIGHT_PX = 128; -export const BOARD_PANEL_DEFAULT_HEIGHT_PX = 232; - -export const GALLERY_PANEL_MIN_HEIGHT_PX = 36; -export const GALLERY_PANEL_MIN_EXPANDED_HEIGHT_PX = 128; -export const GALLERY_PANEL_DEFAULT_HEIGHT_PX = 232; - export const LAYERS_PANEL_MIN_HEIGHT_PX = 36; -export const CANVAS_BOARD_PANEL_DEFAULT_HEIGHT_PX = 36; // Collapsed by default on Canvas +// Bottom gallery panel constants +export const BOTTOM_GALLERY_MIN_HEIGHT_PX = 36; +export const BOTTOM_GALLERY_MIN_EXPANDED_HEIGHT_PX = 180; +export const BOTTOM_GALLERY_DEFAULT_HEIGHT_PX = 280; +export const BOARDS_SIDEBAR_MIN_WIDTH_PX = 250; +export const BOARDS_SIDEBAR_DEFAULT_WIDTH_PX = 250; +export const BOARDS_SIDEBAR_MAX_WIDTH_PX = 420; export const SWITCH_TABS_FAKE_DELAY_MS = 300; diff --git a/invokeai/frontend/web/src/features/ui/layouts/upscaling-tab-auto-layout.tsx b/invokeai/frontend/web/src/features/ui/layouts/upscaling-tab-auto-layout.tsx index e4f443148ff..a292887f856 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/upscaling-tab-auto-layout.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/upscaling-tab-auto-layout.tsx @@ -1,10 +1,8 @@ import type { DockviewApi, GridviewApi, IDockviewReactProps, IGridviewReactProps } from 'dockview'; import { DockviewReact, GridviewReact, LayoutPriority, Orientation } from 'dockview'; -import { BoardsPanel } from 'features/gallery/components/BoardsListPanelContent'; -import { GalleryPanel } from 'features/gallery/components/GalleryPanel'; +import { BottomGalleryPanel } from 'features/gallery/components/BottomGalleryPanel'; import { ImageViewerPanel } from 'features/gallery/components/ImageViewer/ImageViewerPanel'; import { FloatingLeftPanelButtons } from 'features/ui/components/FloatingLeftPanelButtons'; -import { FloatingRightPanelButtons } from 'features/ui/components/FloatingRightPanelButtons'; import type { AutoLayoutDockviewComponents, AutoLayoutGridviewComponents, @@ -24,22 +22,18 @@ import { DockviewTabProgress } from './DockviewTabProgress'; import { navigationApi } from './navigation-api'; import { PanelHotkeysLogical } from './PanelHotkeysLogical'; import { - BOARD_PANEL_DEFAULT_HEIGHT_PX, - BOARD_PANEL_MIN_HEIGHT_PX, - BOARDS_PANEL_ID, + BOTTOM_GALLERY_DEFAULT_HEIGHT_PX, + BOTTOM_GALLERY_MIN_HEIGHT_PX, + BOTTOM_GALLERY_PANEL_ID, DOCKVIEW_TAB_ID, DOCKVIEW_TAB_LAUNCHPAD_ID, DOCKVIEW_TAB_PROGRESS_ID, - GALLERY_PANEL_DEFAULT_HEIGHT_PX, - GALLERY_PANEL_ID, - GALLERY_PANEL_MIN_HEIGHT_PX, LAUNCHPAD_PANEL_ID, LEFT_PANEL_ID, LEFT_PANEL_MIN_SIZE_PX, MAIN_PANEL_ID, - RIGHT_PANEL_ID, - RIGHT_PANEL_MIN_SIZE_PX, SETTINGS_PANEL_ID, + TOP_AREA_ID, VIEWER_PANEL_ID, } from './shared'; import { UpscalingLaunchpadPanel } from './UpscalingLaunchpadPanel'; @@ -110,70 +104,12 @@ const MainPanel = memo(() => { theme={dockviewTheme} /> - ); }); MainPanel.displayName = 'MainPanel'; -const rightPanelComponents: AutoLayoutGridviewComponents = { - [BOARDS_PANEL_ID]: withPanelContainer(BoardsPanel), - [GALLERY_PANEL_ID]: withPanelContainer(GalleryPanel), -}; - -const initializeRightPanelLayout = (tab: TabName, api: GridviewApi) => { - navigationApi.registerContainer(tab, 'right', api, () => { - const gallery = api.addPanel({ - id: GALLERY_PANEL_ID, - component: GALLERY_PANEL_ID, - minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, - minimumHeight: GALLERY_PANEL_MIN_HEIGHT_PX, - params: { - tab, - focusRegion: 'gallery', - }, - }); - - const boards = api.addPanel({ - id: BOARDS_PANEL_ID, - component: BOARDS_PANEL_ID, - minimumHeight: BOARD_PANEL_MIN_HEIGHT_PX, - params: { - tab, - focusRegion: 'boards', - }, - position: { - direction: 'above', - referencePanel: gallery.id, - }, - }); - - gallery.api.setSize({ height: GALLERY_PANEL_DEFAULT_HEIGHT_PX }); - boards.api.setSize({ height: BOARD_PANEL_DEFAULT_HEIGHT_PX }); - }); -}; - -const RightPanel = memo(() => { - const { tab } = useAutoLayoutContext(); - - const onReady = useCallback( - ({ api }) => { - initializeRightPanelLayout(tab, api); - }, - [tab] - ); - return ( - - ); -}); -RightPanel.displayName = 'RightPanel'; - const leftPanelComponents: AutoLayoutGridviewComponents = { [SETTINGS_PANEL_ID]: withPanelContainer(UpscalingTabLeftPanel), }; @@ -212,21 +148,23 @@ const LeftPanel = memo(() => { }); LeftPanel.displayName = 'LeftPanel'; -const rootPanelComponents: RootLayoutGridviewComponents = { +/** + * The top area contains the left panel and main panel side by side. + */ +const topAreaComponents: RootLayoutGridviewComponents = { [LEFT_PANEL_ID]: LeftPanel, [MAIN_PANEL_ID]: MainPanel, - [RIGHT_PANEL_ID]: RightPanel, }; -const initializeRootPanelLayout = (tab: TabName, api: GridviewApi) => { - navigationApi.registerContainer(tab, 'root', api, () => { - const main = api.addPanel({ +const initializeTopAreaLayout = (tab: TabName, api: GridviewApi) => { + navigationApi.registerContainer(tab, 'top-area', api, () => { + const main = api.addPanel({ id: MAIN_PANEL_ID, component: MAIN_PANEL_ID, priority: LayoutPriority.High, }); - const left = api.addPanel({ + const left = api.addPanel({ id: LEFT_PANEL_ID, component: LEFT_PANEL_ID, minimumWidth: LEFT_PANEL_MIN_SIZE_PX, @@ -236,18 +174,62 @@ const initializeRootPanelLayout = (tab: TabName, api: GridviewApi) => { }, }); - const right = api.addPanel({ - id: RIGHT_PANEL_ID, - component: RIGHT_PANEL_ID, - minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, + left.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX }); + }); +}; + +const TopArea = memo(() => { + const { tab } = useAutoLayoutContext(); + + const onReady = useCallback( + ({ api }) => { + initializeTopAreaLayout(tab, api); + }, + [tab] + ); + return ( + + ); +}); +TopArea.displayName = 'TopArea'; + +const bottomGalleryComponents: AutoLayoutGridviewComponents = { + [BOTTOM_GALLERY_PANEL_ID]: withPanelContainer(BottomGalleryPanel), +}; + +const rootPanelComponents: RootLayoutGridviewComponents = { + [TOP_AREA_ID]: TopArea, + [BOTTOM_GALLERY_PANEL_ID]: bottomGalleryComponents[BOTTOM_GALLERY_PANEL_ID]!, +}; + +const initializeRootPanelLayout = (tab: TabName, api: GridviewApi) => { + navigationApi.registerContainer(tab, 'root', api, () => { + const topArea = api.addPanel({ + id: TOP_AREA_ID, + component: TOP_AREA_ID, + priority: LayoutPriority.High, + }); + + const bottomGallery = api.addPanel({ + id: BOTTOM_GALLERY_PANEL_ID, + component: BOTTOM_GALLERY_PANEL_ID, + minimumHeight: BOTTOM_GALLERY_MIN_HEIGHT_PX, + params: { + tab, + focusRegion: 'gallery', + }, position: { - direction: 'right', - referencePanel: main.id, + direction: 'below', + referencePanel: topArea.id, }, }); - left.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX }); - right.api.setSize({ width: RIGHT_PANEL_MIN_SIZE_PX }); + bottomGallery.api.setSize({ height: BOTTOM_GALLERY_DEFAULT_HEIGHT_PX }); }); }; @@ -269,7 +251,7 @@ export const UpscalingTabAutoLayout = memo(() => { className="dockview-theme-invoke" components={rootPanelComponents} onReady={onReady} - orientation={Orientation.VERTICAL} + orientation={Orientation.HORIZONTAL} /> ); diff --git a/invokeai/frontend/web/src/features/ui/layouts/use-collapsible-gridview-panel.ts b/invokeai/frontend/web/src/features/ui/layouts/use-collapsible-gridview-panel.ts deleted file mode 100644 index aa730a54a8c..00000000000 --- a/invokeai/frontend/web/src/features/ui/layouts/use-collapsible-gridview-panel.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { GridviewPanel, type GridviewPanelApi, type IGridviewPanel } from 'dockview'; -import type { TabName } from 'features/ui/store/uiTypes'; -import { atom } from 'nanostores'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; - -import { navigationApi } from './navigation-api'; - -const getIsCollapsed = ( - panel: IGridviewPanel, - orientation: 'vertical' | 'horizontal', - collapsedSize?: number -) => { - if (orientation === 'vertical') { - return panel.height <= (collapsedSize ?? panel.minimumHeight ?? 0); - } - return panel.width <= (collapsedSize ?? panel.minimumWidth ?? 0); -}; - -export const useCollapsibleGridviewPanel = ( - tab: TabName, - panelId: string, - orientation: 'horizontal' | 'vertical', - defaultSize: number, - collapsedSize?: number, - minExpandedSize?: number -) => { - const $isCollapsed = useState(() => atom(false))[0]; - const lastExpandedSizeRef = useRef(0); - const collapse = useCallback(() => { - const panel = navigationApi.getPanel(tab, panelId); - if (!panel || !(panel instanceof GridviewPanel)) { - return; - } - - const isCollapsed = getIsCollapsed(panel, orientation, collapsedSize); - if (isCollapsed) { - return; - } - - lastExpandedSizeRef.current = orientation === 'vertical' ? panel.height : panel.width; - - if (orientation === 'vertical') { - panel.api.setSize({ height: collapsedSize ?? panel.minimumHeight ?? 0 }); - } else { - panel.api.setSize({ width: collapsedSize ?? panel.minimumWidth ?? 0 }); - } - }, [collapsedSize, orientation, panelId, tab]); - - const expand = useCallback(() => { - const panel = navigationApi.getPanel(tab, panelId); - if (!panel || !(panel instanceof GridviewPanel)) { - return; - } - - const isCollapsed = getIsCollapsed(panel, orientation, collapsedSize); - if (!isCollapsed) { - return; - } - - let newSize = lastExpandedSizeRef.current || defaultSize; - if (minExpandedSize && newSize < minExpandedSize) { - newSize = minExpandedSize; - } - - if (orientation === 'vertical') { - panel.api.setSize({ height: newSize }); - } else { - panel.api.setSize({ width: newSize }); - } - }, [defaultSize, minExpandedSize, orientation, collapsedSize, panelId, tab]); - - const toggle = useCallback(() => { - const panel = navigationApi.getPanel(tab, panelId); - if (!panel || !(panel instanceof GridviewPanel)) { - return; - } - const isCollapsed = getIsCollapsed(panel, orientation, collapsedSize); - - if (isCollapsed) { - expand(); - } else { - collapse(); - } - }, [tab, panelId, orientation, collapsedSize, expand, collapse]); - - useEffect(() => { - const panel = navigationApi.getPanel(tab, panelId); - if (!panel || !(panel instanceof GridviewPanel)) { - return; - } - - const disposable = panel.api.onDidDimensionsChange(() => { - const isCollapsed = getIsCollapsed(panel, orientation, collapsedSize); - $isCollapsed.set(isCollapsed); - }); - - return () => { - disposable.dispose(); - }; - }, [$isCollapsed, collapsedSize, orientation, panelId, tab]); - - return useMemo( - () => ({ - $isCollapsed, - expand, - collapse, - toggle, - }), - [$isCollapsed, collapse, expand, toggle] - ); -}; diff --git a/invokeai/frontend/web/src/features/ui/layouts/use-gallery-panel.ts b/invokeai/frontend/web/src/features/ui/layouts/use-gallery-panel.ts deleted file mode 100644 index 274f4bd5954..00000000000 --- a/invokeai/frontend/web/src/features/ui/layouts/use-gallery-panel.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { TabName } from 'features/ui/store/uiTypes'; - -import { - GALLERY_PANEL_DEFAULT_HEIGHT_PX, - GALLERY_PANEL_ID, - GALLERY_PANEL_MIN_EXPANDED_HEIGHT_PX, - GALLERY_PANEL_MIN_HEIGHT_PX, -} from './shared'; -import { useCollapsibleGridviewPanel } from './use-collapsible-gridview-panel'; - -export const useGalleryPanel = (tab: TabName) => { - return useCollapsibleGridviewPanel( - tab, - GALLERY_PANEL_ID, - 'vertical', - GALLERY_PANEL_DEFAULT_HEIGHT_PX, - GALLERY_PANEL_MIN_HEIGHT_PX, - GALLERY_PANEL_MIN_EXPANDED_HEIGHT_PX - ); -}; diff --git a/invokeai/frontend/web/src/features/ui/layouts/workflows-tab-auto-layout.tsx b/invokeai/frontend/web/src/features/ui/layouts/workflows-tab-auto-layout.tsx index 026b7897283..8254c46b80f 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/workflows-tab-auto-layout.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/workflows-tab-auto-layout.tsx @@ -1,12 +1,10 @@ import type { DockviewApi, GridviewApi, IDockviewReactProps, IGridviewReactProps } from 'dockview'; import { DockviewReact, GridviewReact, LayoutPriority, Orientation } from 'dockview'; -import { BoardsPanel } from 'features/gallery/components/BoardsListPanelContent'; -import { GalleryPanel } from 'features/gallery/components/GalleryPanel'; +import { BottomGalleryPanel } from 'features/gallery/components/BottomGalleryPanel'; import { ImageViewerPanel } from 'features/gallery/components/ImageViewer/ImageViewerPanel'; import NodeEditor from 'features/nodes/components/NodeEditor'; import WorkflowsTabLeftPanel from 'features/nodes/components/sidePanel/WorkflowsTabLeftPanel'; import { FloatingLeftPanelButtons } from 'features/ui/components/FloatingLeftPanelButtons'; -import { FloatingRightPanelButtons } from 'features/ui/components/FloatingRightPanelButtons'; import type { AutoLayoutDockviewComponents, AutoLayoutGridviewComponents, @@ -26,22 +24,18 @@ import { DockviewTabProgress } from './DockviewTabProgress'; import { navigationApi } from './navigation-api'; import { PanelHotkeysLogical } from './PanelHotkeysLogical'; import { - BOARD_PANEL_DEFAULT_HEIGHT_PX, - BOARD_PANEL_MIN_HEIGHT_PX, - BOARDS_PANEL_ID, + BOTTOM_GALLERY_DEFAULT_HEIGHT_PX, + BOTTOM_GALLERY_MIN_HEIGHT_PX, + BOTTOM_GALLERY_PANEL_ID, DOCKVIEW_TAB_ID, DOCKVIEW_TAB_LAUNCHPAD_ID, DOCKVIEW_TAB_PROGRESS_ID, - GALLERY_PANEL_DEFAULT_HEIGHT_PX, - GALLERY_PANEL_ID, - GALLERY_PANEL_MIN_HEIGHT_PX, LAUNCHPAD_PANEL_ID, LEFT_PANEL_ID, LEFT_PANEL_MIN_SIZE_PX, MAIN_PANEL_ID, - RIGHT_PANEL_ID, - RIGHT_PANEL_MIN_SIZE_PX, SETTINGS_PANEL_ID, + TOP_AREA_ID, VIEWER_PANEL_ID, WORKSPACE_PANEL_ID, } from './shared'; @@ -131,56 +125,35 @@ const MainPanel = memo(() => { theme={dockviewTheme} /> - ); }); MainPanel.displayName = 'MainPanel'; -const rightPanelComponents: AutoLayoutGridviewComponents = { - [BOARDS_PANEL_ID]: withPanelContainer(BoardsPanel), - [GALLERY_PANEL_ID]: withPanelContainer(GalleryPanel), +const leftPanelComponents: AutoLayoutGridviewComponents = { + [SETTINGS_PANEL_ID]: withPanelContainer(WorkflowsTabLeftPanel), }; -const initializeRightPanelLayout = (tab: TabName, api: GridviewApi) => { - navigationApi.registerContainer(tab, 'right', api, () => { - const gallery = api.addPanel({ - id: GALLERY_PANEL_ID, - component: GALLERY_PANEL_ID, - minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, - minimumHeight: GALLERY_PANEL_MIN_HEIGHT_PX, - params: { - tab, - focusRegion: 'gallery', - }, - }); - - const boards = api.addPanel({ - id: BOARDS_PANEL_ID, - component: BOARDS_PANEL_ID, - minimumHeight: BOARD_PANEL_MIN_HEIGHT_PX, +const initializeLeftPanelLayout = (tab: TabName, api: GridviewApi) => { + navigationApi.registerContainer(tab, 'left', api, () => { + api.addPanel({ + id: SETTINGS_PANEL_ID, + component: SETTINGS_PANEL_ID, params: { tab, - focusRegion: 'boards', - }, - position: { - direction: 'above', - referencePanel: gallery.id, + focusRegion: 'settings', }, }); - - gallery.api.setSize({ height: GALLERY_PANEL_DEFAULT_HEIGHT_PX }); - boards.api.setSize({ height: BOARD_PANEL_DEFAULT_HEIGHT_PX }); }); }; -const RightPanel = memo(() => { +const LeftPanel = memo(() => { const { tab } = useAutoLayoutContext(); const onReady = useCallback( ({ api }) => { - initializeRightPanelLayout(tab, api); + initializeLeftPanelLayout(tab, api); }, [tab] ); @@ -188,86 +161,92 @@ const RightPanel = memo(() => { ); }); -RightPanel.displayName = 'RightPanel'; +LeftPanel.displayName = 'LeftPanel'; -const leftPanelComponents: AutoLayoutGridviewComponents = { - [SETTINGS_PANEL_ID]: withPanelContainer(WorkflowsTabLeftPanel), +const topAreaComponents: RootLayoutGridviewComponents = { + [LEFT_PANEL_ID]: LeftPanel, + [MAIN_PANEL_ID]: MainPanel, }; -const initializeLeftPanelLayout = (tab: TabName, api: GridviewApi) => { - navigationApi.registerContainer(tab, 'left', api, () => { - api.addPanel({ - id: SETTINGS_PANEL_ID, - component: SETTINGS_PANEL_ID, - params: { - tab, - focusRegion: 'settings', +const initializeTopAreaLayout = (tab: TabName, api: GridviewApi) => { + navigationApi.registerContainer(tab, 'top-area', api, () => { + const main = api.addPanel({ + id: MAIN_PANEL_ID, + component: MAIN_PANEL_ID, + priority: LayoutPriority.High, + }); + + const left = api.addPanel({ + id: LEFT_PANEL_ID, + component: LEFT_PANEL_ID, + minimumWidth: LEFT_PANEL_MIN_SIZE_PX, + position: { + direction: 'left', + referencePanel: main.id, }, }); + + left.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX }); }); }; -const LeftPanel = memo(() => { +const TopArea = memo(() => { const { tab } = useAutoLayoutContext(); const onReady = useCallback( ({ api }) => { - initializeLeftPanelLayout(tab, api); + initializeTopAreaLayout(tab, api); }, [tab] ); return ( ); }); -LeftPanel.displayName = 'LeftPanel'; +TopArea.displayName = 'TopArea'; + +const bottomGalleryComponents: AutoLayoutGridviewComponents = { + [BOTTOM_GALLERY_PANEL_ID]: withPanelContainer(BottomGalleryPanel), +}; const rootPanelComponents: RootLayoutGridviewComponents = { - [LEFT_PANEL_ID]: LeftPanel, - [MAIN_PANEL_ID]: MainPanel, - [RIGHT_PANEL_ID]: RightPanel, + [TOP_AREA_ID]: TopArea, + [BOTTOM_GALLERY_PANEL_ID]: bottomGalleryComponents[BOTTOM_GALLERY_PANEL_ID]!, }; const initializeRootPanelLayout = (tab: TabName, api: GridviewApi) => { navigationApi.registerContainer(tab, 'root', api, () => { - const main = api.addPanel({ - id: MAIN_PANEL_ID, - component: MAIN_PANEL_ID, + const topArea = api.addPanel({ + id: TOP_AREA_ID, + component: TOP_AREA_ID, priority: LayoutPriority.High, }); - const left = api.addPanel({ - id: LEFT_PANEL_ID, - component: LEFT_PANEL_ID, - minimumWidth: LEFT_PANEL_MIN_SIZE_PX, - position: { - direction: 'left', - referencePanel: main.id, + const bottomGallery = api.addPanel({ + id: BOTTOM_GALLERY_PANEL_ID, + component: BOTTOM_GALLERY_PANEL_ID, + minimumHeight: BOTTOM_GALLERY_MIN_HEIGHT_PX, + params: { + tab, + focusRegion: 'gallery', }, - }); - - const right = api.addPanel({ - id: RIGHT_PANEL_ID, - component: RIGHT_PANEL_ID, - minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, position: { - direction: 'right', - referencePanel: main.id, + direction: 'below', + referencePanel: topArea.id, }, }); - left.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX }); - right.api.setSize({ width: RIGHT_PANEL_MIN_SIZE_PX }); + bottomGallery.api.setSize({ height: BOTTOM_GALLERY_DEFAULT_HEIGHT_PX }); }); }; @@ -289,7 +268,7 @@ export const WorkflowsTabAutoLayout = memo(() => { className="dockview-theme-invoke" components={rootPanelComponents} onReady={onReady} - orientation={Orientation.VERTICAL} + orientation={Orientation.HORIZONTAL} /> ); diff --git a/invokeai/frontend/web/src/services/events/setEventListeners.tsx b/invokeai/frontend/web/src/services/events/setEventListeners.tsx index 6fb50e4902d..8c4ca7b9eed 100644 --- a/invokeai/frontend/web/src/services/events/setEventListeners.tsx +++ b/invokeai/frontend/web/src/services/events/setEventListeners.tsx @@ -23,6 +23,12 @@ import type { RefImageState, } from 'features/controlLayers/store/types'; import { getControlLayerState, getReferenceImageState } from 'features/controlLayers/store/util'; +import { + clearGalleryProgressItems, + removeGalleryProgressItem, + setGalleryProgressItemInProgress, + updateGalleryProgressItemProgress, +} from 'features/gallery/store/galleryProgressStore'; import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useNodeExecutionState'; import { zNodeStatus } from 'features/nodes/types/invocation'; import { modelSelected } from 'features/parameters/store/actions'; @@ -72,12 +78,14 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis socket.emit('subscribe_queue', { queue_id: 'default' }); socket.emit('subscribe_bulk_download', { bulk_download_id: 'default' }); $lastProgressEvent.set(null); + clearGalleryProgressItems(); }); socket.on('connect_error', (error) => { log.debug('Connect error'); setIsConnected(false); $lastProgressEvent.set(null); + clearGalleryProgressItems(); if (error && error.message) { const data: string | undefined = (error as unknown as { data: string | undefined }).data; if (data === 'ERR_UNAUTHENTICATED') { @@ -94,6 +102,7 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis socket.on('disconnect', () => { log.debug('Disconnected'); $lastProgressEvent.set(null); + clearGalleryProgressItems(); setIsConnected(false); }); @@ -130,6 +139,9 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis $lastProgressEvent.set(data); + // Update gallery progress tile with the latest progress image and percentage + updateGalleryProgressItemProgress(data.item_id, image ?? null, percentage ?? null, message ?? null); + if (origin === 'workflows') { const nes = deepClone($nodeExecutionStates.get()[invocation_source_id]); if (nes) { @@ -426,6 +438,7 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis dispatch(queueApi.util.invalidateTags(tagsToInvalidate)); if (status === 'in_progress') { + setGalleryProgressItemInProgress(item_id); forEach($nodeExecutionStates.get(), (nes) => { if (!nes) { return; @@ -440,6 +453,7 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis }); } else if (status === 'completed' || status === 'failed' || status === 'canceled') { finishedQueueItemIds.set(item_id, true); + removeGalleryProgressItem(item_id); if (status === 'failed' && error_type) { toast({ id: `INVOCATION_ERROR_${error_type}`, @@ -470,6 +484,7 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis { type: 'SessionQueueItem', id: LIST_ALL_TAG }, ]) ); + clearGalleryProgressItems(); }); socket.on('batch_enqueued', (data) => {