mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-04-23 03:00:31 -04:00
Merge branch 'main' into ryan/spandrel-upscale
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { IconButton } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { boardIdSelected } from 'features/gallery/store/gallerySlice';
|
||||
import { boardIdSelected, boardSearchTextChanged } from 'features/gallery/store/gallerySlice';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiPlusBold } from 'react-icons/pi';
|
||||
@@ -24,10 +24,12 @@ const AddBoardButton = ({ isPrivateBoard }: Props) => {
|
||||
}
|
||||
return t('boards.addSharedBoard');
|
||||
}, [allowPrivateBoards, isPrivateBoard, t]);
|
||||
|
||||
const handleCreateBoard = useCallback(async () => {
|
||||
try {
|
||||
const board = await createBoard({ board_name: t('boards.myBoard'), is_private: isPrivateBoard }).unwrap();
|
||||
dispatch(boardIdSelected({ boardId: board.board_id }));
|
||||
dispatch(boardSearchTextChanged(''));
|
||||
} catch {
|
||||
//no-op
|
||||
}
|
||||
@@ -42,7 +44,9 @@ const AddBoardButton = ({ isPrivateBoard }: Props) => {
|
||||
onClick={handleCreateBoard}
|
||||
size="md"
|
||||
data-testid="add-board-button"
|
||||
variant="ghost"
|
||||
variant="link"
|
||||
w={8}
|
||||
h={8}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import { Collapse, Flex, Icon, Text, useDisclosure } from '@invoke-ai/ui-library';
|
||||
import { Box, Flex, Text } from '@invoke-ai/ui-library';
|
||||
import { EMPTY_ARRAY } from 'app/store/constants';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { overlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants';
|
||||
import DeleteBoardModal from 'features/gallery/components/Boards/DeleteBoardModal';
|
||||
import GallerySettingsPopover from 'features/gallery/components/GallerySettingsPopover/GallerySettingsPopover';
|
||||
import { selectListBoardsQueryArgs } from 'features/gallery/store/gallerySelectors';
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { memo, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiCaretUpBold } from 'react-icons/pi';
|
||||
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
|
||||
import type { BoardDTO } from 'services/api/types';
|
||||
|
||||
import AddBoardButton from './AddBoardButton';
|
||||
import BoardsSearch from './BoardsSearch';
|
||||
import GalleryBoard from './GalleryBoard';
|
||||
import NoBoardBoard from './NoBoardBoard';
|
||||
|
||||
@@ -30,8 +27,6 @@ const BoardsList = () => {
|
||||
const queryArgs = useAppSelector(selectListBoardsQueryArgs);
|
||||
const { data: boards } = useListAllBoardsQuery(queryArgs);
|
||||
const [boardToDelete, setBoardToDelete] = useState<BoardDTO>();
|
||||
const privateBoardsDisclosure = useDisclosure({ defaultIsOpen: false });
|
||||
const sharedBoardsDisclosure = useDisclosure({ defaultIsOpen: false });
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { filteredPrivateBoards, filteredSharedBoards } = useMemo(() => {
|
||||
@@ -45,43 +40,30 @@ const BoardsList = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex layerStyle="first" flexDir="column" borderRadius="base">
|
||||
<Flex gap={2} alignItems="center" pb={2}>
|
||||
<BoardsSearch />
|
||||
<GallerySettingsPopover />
|
||||
</Flex>
|
||||
{allowPrivateBoards && (
|
||||
<>
|
||||
<Flex w="full" gap={2}>
|
||||
<Flex
|
||||
flexGrow={1}
|
||||
onClick={privateBoardsDisclosure.onToggle}
|
||||
gap={2}
|
||||
alignItems="center"
|
||||
cursor="pointer"
|
||||
>
|
||||
<Icon
|
||||
as={PiCaretUpBold}
|
||||
boxSize={4}
|
||||
transform={privateBoardsDisclosure.isOpen ? 'rotate(0deg)' : 'rotate(180deg)'}
|
||||
transitionProperty="common"
|
||||
transitionDuration="normal"
|
||||
color="base.400"
|
||||
/>
|
||||
<Text fontSize="md" fontWeight="medium" userSelect="none">
|
||||
{t('boards.private')}
|
||||
</Text>
|
||||
</Flex>
|
||||
<AddBoardButton isPrivateBoard={true} />
|
||||
</Flex>
|
||||
<Collapse in={privateBoardsDisclosure.isOpen} animateOpacity>
|
||||
<OverlayScrollbarsComponent
|
||||
defer
|
||||
style={overlayScrollbarsStyles}
|
||||
options={overlayScrollbarsParams.options}
|
||||
>
|
||||
<Flex direction="column" maxH={346} gap={1}>
|
||||
{allowPrivateBoards && <NoBoardBoard isSelected={selectedBoardId === 'none'} />}
|
||||
<Box position="relative" w="full" h="full">
|
||||
<Box position="absolute" top={0} right={0} bottom={0} left={0}>
|
||||
<OverlayScrollbarsComponent defer style={overlayScrollbarsStyles} options={overlayScrollbarsParams.options}>
|
||||
{allowPrivateBoards && (
|
||||
<Flex direction="column" gap={1}>
|
||||
<Flex
|
||||
position="sticky"
|
||||
w="full"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
ps={2}
|
||||
pb={1}
|
||||
pt={2}
|
||||
zIndex={1}
|
||||
top={0}
|
||||
bg="base.900"
|
||||
>
|
||||
<Text fontSize="md" fontWeight="semibold" userSelect="none">
|
||||
{t('boards.private')}
|
||||
</Text>
|
||||
<AddBoardButton isPrivateBoard={true} />
|
||||
</Flex>
|
||||
<Flex direction="column" gap={1}>
|
||||
<NoBoardBoard isSelected={selectedBoardId === 'none'} />
|
||||
{filteredPrivateBoards.map((board) => (
|
||||
<GalleryBoard
|
||||
board={board}
|
||||
@@ -91,42 +73,41 @@ const BoardsList = () => {
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</OverlayScrollbarsComponent>
|
||||
</Collapse>
|
||||
</>
|
||||
)}
|
||||
<Flex w="full" gap={2}>
|
||||
<Flex onClick={sharedBoardsDisclosure.onToggle} gap={2} alignItems="center" cursor="pointer" flexGrow={1}>
|
||||
<Icon
|
||||
as={PiCaretUpBold}
|
||||
boxSize={4}
|
||||
transform={sharedBoardsDisclosure.isOpen ? 'rotate(0deg)' : 'rotate(180deg)'}
|
||||
transitionProperty="common"
|
||||
transitionDuration="normal"
|
||||
color="base.400"
|
||||
/>
|
||||
<Text fontSize="md" fontWeight="medium" userSelect="none">
|
||||
{allowPrivateBoards ? t('boards.shared') : t('boards.boards')}
|
||||
</Text>
|
||||
</Flex>
|
||||
<AddBoardButton isPrivateBoard={false} />
|
||||
</Flex>
|
||||
<Collapse in={sharedBoardsDisclosure.isOpen} animateOpacity>
|
||||
<OverlayScrollbarsComponent defer style={overlayScrollbarsStyles} options={overlayScrollbarsParams.options}>
|
||||
<Flex direction="column" maxH={346} gap={1}>
|
||||
{!allowPrivateBoards && <NoBoardBoard isSelected={selectedBoardId === 'none'} />}
|
||||
{filteredSharedBoards.map((board) => (
|
||||
<GalleryBoard
|
||||
board={board}
|
||||
isSelected={selectedBoardId === board.board_id}
|
||||
setBoardToDelete={setBoardToDelete}
|
||||
key={board.board_id}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
<Flex direction="column" gap={1}>
|
||||
<Flex
|
||||
position="sticky"
|
||||
w="full"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
ps={2}
|
||||
pb={1}
|
||||
pt={2}
|
||||
zIndex={1}
|
||||
top={0}
|
||||
bg="base.900"
|
||||
>
|
||||
<Text fontSize="md" fontWeight="semibold" userSelect="none">
|
||||
{allowPrivateBoards ? t('boards.shared') : t('boards.boards')}
|
||||
</Text>
|
||||
<AddBoardButton isPrivateBoard={false} />
|
||||
</Flex>
|
||||
<Flex direction="column" gap={1}>
|
||||
{!allowPrivateBoards && <NoBoardBoard isSelected={selectedBoardId === 'none'} />}
|
||||
{filteredSharedBoards.map((board) => (
|
||||
<GalleryBoard
|
||||
board={board}
|
||||
isSelected={selectedBoardId === board.board_id}
|
||||
setBoardToDelete={setBoardToDelete}
|
||||
key={board.board_id}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</OverlayScrollbarsComponent>
|
||||
</Collapse>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Box>
|
||||
<DeleteBoardModal boardToDelete={boardToDelete} setBoardToDelete={setBoardToDelete} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -40,7 +40,7 @@ const BoardsSearch = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<InputGroup>
|
||||
<InputGroup pt={2}>
|
||||
<Input
|
||||
placeholder={t('boards.searchBoard')}
|
||||
value={boardSearchText}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Text,
|
||||
Tooltip,
|
||||
useDisclosure,
|
||||
useEditableControls,
|
||||
} from '@invoke-ai/ui-library';
|
||||
import { skipToken } from '@reduxjs/toolkit/query';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
@@ -18,7 +19,8 @@ import { AutoAddBadge } from 'features/gallery/components/Boards/AutoAddBadge';
|
||||
import BoardContextMenu from 'features/gallery/components/Boards/BoardContextMenu';
|
||||
import { BoardTotalsTooltip } from 'features/gallery/components/Boards/BoardsList/BoardTotalsTooltip';
|
||||
import { autoAddBoardIdChanged, boardIdSelected } from 'features/gallery/store/gallerySlice';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import type { MouseEvent, MouseEventHandler, MutableRefObject } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiArchiveBold, PiImageSquare } from 'react-icons/pi';
|
||||
import { useUpdateBoardMutation } from 'services/api/endpoints/boards';
|
||||
@@ -35,7 +37,7 @@ const editableInputStyles: SystemStyleObject = {
|
||||
};
|
||||
|
||||
const _hover: SystemStyleObject = {
|
||||
bg: 'base.800',
|
||||
bg: 'base.850',
|
||||
};
|
||||
|
||||
interface GalleryBoardProps {
|
||||
@@ -49,15 +51,19 @@ const GalleryBoard = ({ board, isSelected, setBoardToDelete }: GalleryBoardProps
|
||||
const { t } = useTranslation();
|
||||
const autoAddBoardId = useAppSelector((s) => s.gallery.autoAddBoardId);
|
||||
const autoAssignBoardOnClick = useAppSelector((s) => s.gallery.autoAssignBoardOnClick);
|
||||
const selectedBoardId = useAppSelector((s) => s.gallery.selectedBoardId);
|
||||
const editingDisclosure = useDisclosure();
|
||||
const [localBoardName, setLocalBoardName] = useState(board.board_name);
|
||||
const onStartEditingRef = useRef<MouseEventHandler | undefined>(undefined);
|
||||
|
||||
const handleSelectBoard = useCallback(() => {
|
||||
dispatch(boardIdSelected({ boardId: board.board_id }));
|
||||
if (autoAssignBoardOnClick) {
|
||||
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));
|
||||
}
|
||||
}, [dispatch, board.board_id, autoAssignBoardOnClick]);
|
||||
}, [selectedBoardId, board.board_id, autoAssignBoardOnClick, autoAddBoardId, dispatch]);
|
||||
|
||||
const [updateBoard, { isLoading: isUpdateBoardLoading }] = useUpdateBoardMutation();
|
||||
|
||||
@@ -70,7 +76,7 @@ const GalleryBoard = ({ board, isSelected, setBoardToDelete }: GalleryBoardProps
|
||||
[board.board_id]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
const onSubmit = useCallback(
|
||||
async (newBoardName: string) => {
|
||||
if (!newBoardName.trim()) {
|
||||
// empty strings are not allowed
|
||||
@@ -96,21 +102,30 @@ const GalleryBoard = ({ board, isSelected, setBoardToDelete }: GalleryBoardProps
|
||||
[board.board_id, board.board_name, editingDisclosure, updateBoard]
|
||||
);
|
||||
|
||||
const handleChange = useCallback((newBoardName: string) => {
|
||||
const onChange = useCallback((newBoardName: string) => {
|
||||
setLocalBoardName(newBoardName);
|
||||
}, []);
|
||||
|
||||
const onDoubleClick = useCallback((e: MouseEvent<HTMLDivElement>) => {
|
||||
if (onStartEditingRef.current) {
|
||||
onStartEditingRef.current(e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BoardContextMenu board={board} setBoardToDelete={setBoardToDelete}>
|
||||
{(ref) => (
|
||||
<Tooltip
|
||||
label={<BoardTotalsTooltip board_id={board.board_id} isArchived={Boolean(board.archived)} />}
|
||||
openDelay={1000}
|
||||
placement="left"
|
||||
closeOnScroll
|
||||
>
|
||||
<Flex
|
||||
position="relative"
|
||||
ref={ref}
|
||||
onClick={handleSelectBoard}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
w="full"
|
||||
alignItems="center"
|
||||
borderRadius="base"
|
||||
@@ -118,7 +133,7 @@ const GalleryBoard = ({ board, isSelected, setBoardToDelete }: GalleryBoardProps
|
||||
py={1}
|
||||
px={2}
|
||||
gap={2}
|
||||
bg={isSelected ? 'base.800' : undefined}
|
||||
bg={isSelected ? 'base.850' : undefined}
|
||||
_hover={_hover}
|
||||
>
|
||||
<CoverImage board={board} />
|
||||
@@ -131,10 +146,12 @@ const GalleryBoard = ({ board, isSelected, setBoardToDelete }: GalleryBoardProps
|
||||
value={localBoardName}
|
||||
isDisabled={isUpdateBoardLoading}
|
||||
submitOnBlur={true}
|
||||
onChange={handleChange}
|
||||
onSubmit={handleSubmit}
|
||||
onChange={onChange}
|
||||
onSubmit={onSubmit}
|
||||
isPreviewFocusable={false}
|
||||
>
|
||||
<EditablePreview
|
||||
cursor="pointer"
|
||||
p={0}
|
||||
fontSize="md"
|
||||
textOverflow="ellipsis"
|
||||
@@ -145,15 +162,10 @@ const GalleryBoard = ({ board, isSelected, setBoardToDelete }: GalleryBoardProps
|
||||
fontWeight={isSelected ? 'semibold' : 'normal'}
|
||||
/>
|
||||
<EditableInput sx={editableInputStyles} />
|
||||
<JankEditableHijack onStartEditingRef={onStartEditingRef} />
|
||||
</Editable>
|
||||
{autoAddBoardId === board.board_id && !editingDisclosure.isOpen && <AutoAddBadge />}
|
||||
{board.archived && !editingDisclosure.isOpen && (
|
||||
<Icon
|
||||
as={PiArchiveBold}
|
||||
fill="base.300"
|
||||
filter="drop-shadow(0px 0px 0.1rem var(--invoke-colors-base-800))"
|
||||
/>
|
||||
)}
|
||||
{board.archived && !editingDisclosure.isOpen && <Icon as={PiArchiveBold} fill="base.300" />}
|
||||
{!editingDisclosure.isOpen && <Text variant="subtext">{board.image_count}</Text>}
|
||||
|
||||
<IAIDroppable data={droppableData} dropLabel={<Text fontSize="md">{t('unifiedCanvas.move')}</Text>} />
|
||||
@@ -164,6 +176,16 @@ const GalleryBoard = ({ board, isSelected, setBoardToDelete }: GalleryBoardProps
|
||||
);
|
||||
};
|
||||
|
||||
const JankEditableHijack = memo((props: { onStartEditingRef: MutableRefObject<MouseEventHandler | undefined> }) => {
|
||||
const editableControls = useEditableControls();
|
||||
useEffect(() => {
|
||||
props.onStartEditingRef.current = editableControls.getEditButtonProps().onClick;
|
||||
}, [props, editableControls]);
|
||||
return null;
|
||||
});
|
||||
|
||||
JankEditableHijack.displayName = 'JankEditableHijack';
|
||||
|
||||
export default memo(GalleryBoard);
|
||||
|
||||
const CoverImage = ({ board }: { board: BoardDTO }) => {
|
||||
|
||||
@@ -17,7 +17,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const _hover: SystemStyleObject = {
|
||||
bg: 'base.800',
|
||||
bg: 'base.850',
|
||||
};
|
||||
|
||||
const NoBoardBoard = memo(({ isSelected }: Props) => {
|
||||
@@ -29,6 +29,7 @@ const NoBoardBoard = memo(({ isSelected }: Props) => {
|
||||
});
|
||||
const autoAddBoardId = useAppSelector((s) => s.gallery.autoAddBoardId);
|
||||
const autoAssignBoardOnClick = useAppSelector((s) => s.gallery.autoAssignBoardOnClick);
|
||||
const boardSearchText = useAppSelector((s) => s.gallery.boardSearchText);
|
||||
const boardName = useBoardName('none');
|
||||
const handleSelectBoard = useCallback(() => {
|
||||
dispatch(boardIdSelected({ boardId: 'none' }));
|
||||
@@ -44,11 +45,26 @@ const NoBoardBoard = memo(({ isSelected }: Props) => {
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const filteredOut = useMemo(() => {
|
||||
return boardSearchText ? !boardName.toLowerCase().includes(boardSearchText.toLowerCase()) : false;
|
||||
}, [boardName, boardSearchText]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (filteredOut) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<NoBoardBoardContextMenu>
|
||||
{(ref) => (
|
||||
<Tooltip label={<BoardTotalsTooltip board_id="none" isArchived={false} />} openDelay={1000}>
|
||||
<Tooltip
|
||||
label={<BoardTotalsTooltip board_id="none" isArchived={false} />}
|
||||
openDelay={1000}
|
||||
placement="left"
|
||||
closeOnScroll
|
||||
>
|
||||
<Flex
|
||||
position="relative"
|
||||
ref={ref}
|
||||
@@ -60,7 +76,7 @@ const NoBoardBoard = memo(({ isSelected }: Props) => {
|
||||
px={2}
|
||||
py={1}
|
||||
gap={2}
|
||||
bg={isSelected ? 'base.800' : undefined}
|
||||
bg={isSelected ? 'base.850' : undefined}
|
||||
_hover={_hover}
|
||||
>
|
||||
<Flex w={8} h={8} justifyContent="center" alignItems="center">
|
||||
|
||||
@@ -3,12 +3,26 @@ import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { memo } from 'react';
|
||||
import { useBoardName } from 'services/api/hooks/useBoardName';
|
||||
|
||||
const GalleryBoardName = () => {
|
||||
type Props = {
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const GalleryBoardName = (props: Props) => {
|
||||
const selectedBoardId = useAppSelector((s) => s.gallery.selectedBoardId);
|
||||
const boardName = useBoardName(selectedBoardId);
|
||||
|
||||
return (
|
||||
<Flex w="full" borderWidth={1} borderRadius="base" alignItems="center" justifyContent="center" px={2}>
|
||||
<Flex
|
||||
onClick={props.onClick}
|
||||
as="button"
|
||||
h="full"
|
||||
w="full"
|
||||
borderWidth={1}
|
||||
borderRadius="base"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
px={2}
|
||||
>
|
||||
<Text fontWeight="semibold" fontSize="md" noOfLines={1} wordBreak="break-all" color="base.200">
|
||||
{boardName}
|
||||
</Text>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Flex, Link, Text } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { $projectName, $projectUrl } from 'app/store/nanostores/projectId';
|
||||
import { memo } from 'react';
|
||||
|
||||
import GalleryBoardName from './GalleryBoardName';
|
||||
|
||||
type Props = {
|
||||
onClickBoardName: () => void;
|
||||
};
|
||||
|
||||
export const GalleryHeader = memo((props: Props) => {
|
||||
const projectName = useStore($projectName);
|
||||
const projectUrl = useStore($projectUrl);
|
||||
|
||||
if (projectName && projectUrl) {
|
||||
return (
|
||||
<Flex gap={2} w="full" alignItems="center" justifyContent="space-evenly" pe={2}>
|
||||
<Text fontSize="md" fontWeight="semibold" noOfLines={1} w="full" textAlign="center">
|
||||
<Link href={projectUrl}>{projectName}</Link>
|
||||
</Text>
|
||||
<GalleryBoardName onClick={props.onClickBoardName} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex w="full" pe={2}>
|
||||
<GalleryBoardName onClick={props.onClickBoardName} />
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
GalleryHeader.displayName = 'GalleryHeader';
|
||||
@@ -17,7 +17,7 @@ const GallerySettingsPopover = () => {
|
||||
return (
|
||||
<Popover isLazy>
|
||||
<PopoverTrigger>
|
||||
<IconButton aria-label={t('gallery.gallerySettings')} size="sm" icon={<RiSettings4Fill />} />
|
||||
<IconButton aria-label={t('gallery.gallerySettings')} icon={<RiSettings4Fill />} variant="link" h="full" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<PopoverBody>
|
||||
|
||||
@@ -1,24 +1,71 @@
|
||||
import { Button, ButtonGroup, Flex, Tab, TabList, Tabs } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { $galleryHeader } from 'app/store/nanostores/galleryHeader';
|
||||
import type { ChakraProps } from '@invoke-ai/ui-library';
|
||||
import {
|
||||
Box,
|
||||
Collapse,
|
||||
Divider,
|
||||
Flex,
|
||||
IconButton,
|
||||
Spacer,
|
||||
Tab,
|
||||
TabList,
|
||||
Tabs,
|
||||
useDisclosure,
|
||||
} from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { GalleryHeader } from 'features/gallery/components/GalleryHeader';
|
||||
import { galleryViewChanged } from 'features/gallery/store/gallerySlice';
|
||||
import { memo, useCallback } from 'react';
|
||||
import ResizeHandle from 'features/ui/components/tabs/ResizeHandle';
|
||||
import { usePanel, type UsePanelOptions } from 'features/ui/hooks/usePanel';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { memo, useCallback, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiImagesBold } from 'react-icons/pi';
|
||||
import { RiServerLine } from 'react-icons/ri';
|
||||
import { PiMagnifyingGlassBold } from 'react-icons/pi';
|
||||
import type { ImperativePanelGroupHandle } from 'react-resizable-panels';
|
||||
import { Panel, PanelGroup } from 'react-resizable-panels';
|
||||
|
||||
import BoardsList from './Boards/BoardsList/BoardsList';
|
||||
import GalleryBoardName from './GalleryBoardName';
|
||||
import BoardsSearch from './Boards/BoardsList/BoardsSearch';
|
||||
import GallerySettingsPopover from './GallerySettingsPopover/GallerySettingsPopover';
|
||||
import GalleryImageGrid from './ImageGrid/GalleryImageGrid';
|
||||
import { GalleryPagination } from './ImageGrid/GalleryPagination';
|
||||
import { GallerySearch } from './ImageGrid/GallerySearch';
|
||||
|
||||
const COLLAPSE_STYLES: CSSProperties = { flexShrink: 0, minHeight: 0 };
|
||||
|
||||
const BASE_STYLES: ChakraProps['sx'] = {
|
||||
fontWeight: 'semibold',
|
||||
fontSize: 'sm',
|
||||
color: 'base.300',
|
||||
};
|
||||
|
||||
const SELECTED_STYLES: ChakraProps['sx'] = {
|
||||
borderColor: 'base.800',
|
||||
borderBottomColor: 'base.900',
|
||||
color: 'invokeBlue.300',
|
||||
};
|
||||
|
||||
const ImageGalleryContent = () => {
|
||||
const { t } = useTranslation();
|
||||
const galleryView = useAppSelector((s) => s.gallery.galleryView);
|
||||
const searchTerm = useAppSelector((s) => s.gallery.searchTerm);
|
||||
const boardSearchText = useAppSelector((s) => s.gallery.boardSearchText);
|
||||
const dispatch = useAppDispatch();
|
||||
const galleryHeader = useStore($galleryHeader);
|
||||
const searchDisclosure = useDisclosure({ defaultIsOpen: false });
|
||||
const boardSearchDisclosure = useDisclosure({ defaultIsOpen: false });
|
||||
const panelGroupRef = useRef<ImperativePanelGroupHandle>(null);
|
||||
|
||||
const boardsListPanelOptions = useMemo<UsePanelOptions>(
|
||||
() => ({
|
||||
unit: 'pixels',
|
||||
minSize: 128,
|
||||
defaultSize: 256,
|
||||
fallbackMinSizePct: 20,
|
||||
panelGroupRef,
|
||||
panelGroupDirection: 'vertical',
|
||||
}),
|
||||
[]
|
||||
);
|
||||
const boardsListPanel = usePanel(boardsListPanelOptions);
|
||||
|
||||
const handleClickImages = useCallback(() => {
|
||||
dispatch(galleryViewChanged('images'));
|
||||
@@ -29,53 +76,103 @@ const ImageGalleryContent = () => {
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
layerStyle="first"
|
||||
position="relative"
|
||||
flexDirection="column"
|
||||
h="full"
|
||||
w="full"
|
||||
borderRadius="base"
|
||||
p={2}
|
||||
gap={2}
|
||||
>
|
||||
{galleryHeader}
|
||||
<BoardsList />
|
||||
<GalleryBoardName />
|
||||
<Flex alignItems="center" justifyContent="space-between" gap={2}>
|
||||
<Tabs index={galleryView === 'images' ? 0 : 1} variant="unstyled" size="sm" w="full">
|
||||
<TabList>
|
||||
<ButtonGroup w="full">
|
||||
<Tab
|
||||
as={Button}
|
||||
size="sm"
|
||||
isChecked={galleryView === 'images'}
|
||||
onClick={handleClickImages}
|
||||
w="full"
|
||||
leftIcon={<PiImagesBold size="16px" />}
|
||||
data-testid="images-tab"
|
||||
>
|
||||
{t('parameters.images')}
|
||||
</Tab>
|
||||
<Tab
|
||||
as={Button}
|
||||
size="sm"
|
||||
isChecked={galleryView === 'assets'}
|
||||
onClick={handleClickAssets}
|
||||
w="full"
|
||||
leftIcon={<RiServerLine size="16px" />}
|
||||
data-testid="assets-tab"
|
||||
>
|
||||
{t('gallery.assets')}
|
||||
</Tab>
|
||||
</ButtonGroup>
|
||||
</TabList>
|
||||
</Tabs>
|
||||
<Flex position="relative" flexDirection="column" h="full" w="full" pt={2}>
|
||||
<Flex alignItems="center" gap={2}>
|
||||
<GalleryHeader onClickBoardName={boardsListPanel.toggle} />
|
||||
<GallerySettingsPopover />
|
||||
<Box position="relative" h="full">
|
||||
<IconButton
|
||||
w="full"
|
||||
h="full"
|
||||
onClick={boardSearchDisclosure.onToggle}
|
||||
tooltip={`${t('gallery.displayBoardSearch')}`}
|
||||
aria-label={t('gallery.displayBoardSearch')}
|
||||
icon={<PiMagnifyingGlassBold />}
|
||||
variant="link"
|
||||
/>
|
||||
{boardSearchText && (
|
||||
<Box
|
||||
position="absolute"
|
||||
w={2}
|
||||
h={2}
|
||||
bg="invokeBlue.300"
|
||||
borderRadius="full"
|
||||
insetBlockStart={2}
|
||||
insetInlineEnd={2}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
<GallerySearch />
|
||||
<GalleryImageGrid />
|
||||
<GalleryPagination />
|
||||
<PanelGroup ref={panelGroupRef} direction="vertical">
|
||||
<Panel
|
||||
id="boards-list-panel"
|
||||
ref={boardsListPanel.ref}
|
||||
defaultSize={boardsListPanel.defaultSize}
|
||||
minSize={boardsListPanel.minSize}
|
||||
onCollapse={boardsListPanel.onCollapse}
|
||||
onExpand={boardsListPanel.onExpand}
|
||||
collapsible
|
||||
>
|
||||
<Flex flexDir="column" w="full" h="full">
|
||||
<Collapse in={boardSearchDisclosure.isOpen} style={COLLAPSE_STYLES}>
|
||||
<BoardsSearch />
|
||||
</Collapse>
|
||||
<Divider pt={2} />
|
||||
<BoardsList />
|
||||
</Flex>
|
||||
</Panel>
|
||||
<ResizeHandle
|
||||
id="gallery-panel-handle"
|
||||
orientation="horizontal"
|
||||
onDoubleClick={boardsListPanel.onDoubleClickHandle}
|
||||
/>
|
||||
<Panel id="gallery-wrapper-panel" minSize={20}>
|
||||
<Flex flexDirection="column" alignItems="center" justifyContent="space-between" h="full" w="full">
|
||||
<Tabs index={galleryView === 'images' ? 0 : 1} variant="enclosed" display="flex" flexDir="column" w="full">
|
||||
<TabList gap={2} fontSize="sm" borderColor="base.800">
|
||||
<Tab sx={BASE_STYLES} _selected={SELECTED_STYLES} onClick={handleClickImages} data-testid="images-tab">
|
||||
{t('parameters.images')}
|
||||
</Tab>
|
||||
<Tab sx={BASE_STYLES} _selected={SELECTED_STYLES} onClick={handleClickAssets} data-testid="assets-tab">
|
||||
{t('gallery.assets')}
|
||||
</Tab>
|
||||
<Spacer />
|
||||
<Box position="relative">
|
||||
<IconButton
|
||||
w="full"
|
||||
h="full"
|
||||
onClick={searchDisclosure.onToggle}
|
||||
tooltip={`${t('gallery.displaySearch')}`}
|
||||
aria-label={t('gallery.displaySearch')}
|
||||
icon={<PiMagnifyingGlassBold />}
|
||||
variant="link"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<Box
|
||||
position="absolute"
|
||||
w={2}
|
||||
h={2}
|
||||
bg="invokeBlue.300"
|
||||
borderRadius="full"
|
||||
insetBlockStart={2}
|
||||
insetInlineEnd={2}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</TabList>
|
||||
</Tabs>
|
||||
<Box w="full">
|
||||
<Collapse in={searchDisclosure.isOpen} style={COLLAPSE_STYLES}>
|
||||
<Box w="full" pt={2}>
|
||||
<GallerySearch />
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
<GalleryImageGrid />
|
||||
<GalleryPagination />
|
||||
</Flex>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -124,7 +124,7 @@ const Content = () => {
|
||||
}, [calculateNewLimit, container, dispatch]);
|
||||
|
||||
return (
|
||||
<Box position="relative" w="full" h="full">
|
||||
<Box position="relative" w="full" h="full" mt={2}>
|
||||
<Box
|
||||
ref={containerRef}
|
||||
position="absolute"
|
||||
|
||||
@@ -48,12 +48,12 @@ const sx: SystemStyleObject = {
|
||||
transitionDuration: 'normal',
|
||||
'.resize-handle-inner': {
|
||||
'&[data-orientation="horizontal"]': {
|
||||
w: 'calc(100% - 1rem)',
|
||||
w: '100%',
|
||||
h: '2px',
|
||||
},
|
||||
'&[data-orientation="vertical"]': {
|
||||
w: '2px',
|
||||
h: 'calc(100% - 1rem)',
|
||||
h: '100%',
|
||||
},
|
||||
borderRadius: 'base',
|
||||
transitionProperty: 'inherit',
|
||||
|
||||
@@ -16,6 +16,10 @@ export type UsePanelOptions =
|
||||
* The minimum size of the panel as a percentage.
|
||||
*/
|
||||
minSize: number;
|
||||
/**
|
||||
* The default size of the panel as a percentage.
|
||||
*/
|
||||
defaultSize?: number;
|
||||
/**
|
||||
* The unit of the minSize
|
||||
*/
|
||||
@@ -26,6 +30,10 @@ export type UsePanelOptions =
|
||||
* The minimum size of the panel in pixels.
|
||||
*/
|
||||
minSize: number;
|
||||
/**
|
||||
* The default size of the panel in pixels.
|
||||
*/
|
||||
defaultSize?: number;
|
||||
/**
|
||||
* The unit of the minSize.
|
||||
*/
|
||||
@@ -50,6 +58,10 @@ export type UsePanelReturn = {
|
||||
* The dynamically calculated minimum size of the panel.
|
||||
*/
|
||||
minSize: number;
|
||||
/**
|
||||
* The dynamically calculated default size of the panel.
|
||||
*/
|
||||
defaultSize: number;
|
||||
/**
|
||||
* Whether the panel is collapsed.
|
||||
*/
|
||||
@@ -94,6 +106,7 @@ export type UsePanelReturn = {
|
||||
export const usePanel = (arg: UsePanelOptions): UsePanelReturn => {
|
||||
const panelHandleRef = useRef<ImperativePanelHandle>(null);
|
||||
const [_minSize, _setMinSize] = useState<number>(arg.unit === 'percentages' ? arg.minSize : 0);
|
||||
const [_defaultSize, _setDefaultSize] = useState<number>(arg.defaultSize ?? arg.minSize);
|
||||
|
||||
// If the units are pixels, we need to calculate the min size as a percentage of the available space,
|
||||
// then resize the panel if it is too small.
|
||||
@@ -113,18 +126,16 @@ export const usePanel = (arg: UsePanelOptions): UsePanelReturn => {
|
||||
}
|
||||
|
||||
const minSizePct = getSizeAsPercentage(arg.minSize, arg.panelGroupRef, arg.panelGroupDirection);
|
||||
|
||||
_setMinSize(minSizePct);
|
||||
|
||||
/**
|
||||
* TODO(psyche): Ideally, we only resize the panel if there is not enough room for it in the
|
||||
* panel group. This is a bit tricky, though. We'd need to track the last known panel size
|
||||
* and compare it to the new size before resizing. This introduces some complexity that I'd
|
||||
* rather not need to maintain.
|
||||
*
|
||||
* For now, we'll just resize the panel to the min size every time the panel group is resized.
|
||||
*/
|
||||
if (!panelHandleRef.current.isCollapsed()) {
|
||||
const defaultSizePct = getSizeAsPercentage(
|
||||
arg.defaultSize ?? arg.minSize,
|
||||
arg.panelGroupRef,
|
||||
arg.panelGroupDirection
|
||||
);
|
||||
_setDefaultSize(defaultSizePct);
|
||||
|
||||
if (!panelHandleRef.current.isCollapsed() && panelHandleRef.current.getSize() < minSizePct && minSizePct > 0) {
|
||||
panelHandleRef.current.resize(minSizePct);
|
||||
}
|
||||
});
|
||||
@@ -133,8 +144,12 @@ export const usePanel = (arg: UsePanelOptions): UsePanelReturn => {
|
||||
panelGroupHandleElements.forEach((el) => resizeObserver.observe(el));
|
||||
|
||||
// Resize the panel to the min size once on startup
|
||||
const minSizePct = getSizeAsPercentage(arg.minSize, arg.panelGroupRef, arg.panelGroupDirection);
|
||||
panelHandleRef.current?.resize(minSizePct);
|
||||
const defaultSizePct = getSizeAsPercentage(
|
||||
arg.defaultSize ?? arg.minSize,
|
||||
arg.panelGroupRef,
|
||||
arg.panelGroupDirection
|
||||
);
|
||||
panelHandleRef.current?.resize(defaultSizePct);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
@@ -188,14 +203,14 @@ export const usePanel = (arg: UsePanelOptions): UsePanelReturn => {
|
||||
|
||||
const onDoubleClickHandle = useCallback(() => {
|
||||
// If the panel is really super close to the min size, collapse it
|
||||
if (Math.abs((panelHandleRef.current?.getSize() ?? 0) - _minSize) < 0.01) {
|
||||
if (Math.abs((panelHandleRef.current?.getSize() ?? 0) - _defaultSize) < 0.01) {
|
||||
collapse();
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, resize to the min size
|
||||
panelHandleRef.current?.resize(_minSize);
|
||||
}, [_minSize, collapse]);
|
||||
panelHandleRef.current?.resize(_defaultSize);
|
||||
}, [_defaultSize, collapse]);
|
||||
|
||||
return {
|
||||
ref: panelHandleRef,
|
||||
@@ -209,6 +224,7 @@ export const usePanel = (arg: UsePanelOptions): UsePanelReturn => {
|
||||
collapse,
|
||||
resize,
|
||||
onDoubleClickHandle,
|
||||
defaultSize: _defaultSize,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user