This commit is contained in:
Mary Hipp 2023-06-14 16:53:01 -04:00 committed by psychedelicious
parent c009f46b00
commit e06c43adc8
11 changed files with 392 additions and 45 deletions

View File

@ -4,6 +4,7 @@ import { appSocketConnected, socketConnected } from 'services/events/actions';
import { receivedPageOfImages } from 'services/thunks/image';
import { receivedModels } from 'services/thunks/model';
import { receivedOpenAPISchema } from 'services/thunks/schema';
import { receivedBoards } from '../../../../../../services/thunks/board';
const moduleLog = log.child({ namespace: 'socketio' });
@ -19,6 +20,8 @@ export const addSocketConnectedEventListener = () => {
const { disabledTabs } = config;
dispatch(receivedBoards());
if (!images.ids.length) {
dispatch(receivedPageOfImages());
}

View File

@ -22,6 +22,7 @@ import uiReducer from 'features/ui/store/uiSlice';
import hotkeysReducer from 'features/ui/store/hotkeysSlice';
import modelsReducer from 'features/system/store/modelSlice';
import nodesReducer from 'features/nodes/store/nodesSlice';
import boardsReducer from 'features/gallery/store/boardSlice';
import { listenerMiddleware } from './middleware/listenerMiddleware';
@ -47,6 +48,7 @@ const allReducers = {
hotkeys: hotkeysReducer,
images: imagesReducer,
controlNet: controlNetReducer,
boards: boardsReducer,
// session: sessionReducer,
};
@ -65,6 +67,7 @@ const rememberedKeys: (keyof typeof allReducers)[] = [
'system',
'ui',
'controlNet',
'boards',
// 'hotkeys',
// 'config',
];

View File

@ -0,0 +1,101 @@
import { Box, Image, MenuItem, MenuList, Text } from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { memo, useCallback, useState } from 'react';
import { FaImage } from 'react-icons/fa';
import { ContextMenu } from 'chakra-ui-contextmenu';
import { useTranslation } from 'react-i18next';
import { ExternalLinkIcon } from '@chakra-ui/icons';
import { useAppToaster } from 'app/components/Toaster';
import { BoardRecord } from 'services/api';
import { EntityId, createSelector } from '@reduxjs/toolkit';
import {
selectFilteredImagesIds,
selectImagesById,
} from '../store/imagesSlice';
import { RootState } from '../../../app/store/store';
import { defaultSelectorOptions } from '../../../app/store/util/defaultMemoizeOptions';
import { useSelector } from 'react-redux';
interface HoverableBoardProps {
board: BoardRecord;
}
/**
* Gallery image component with delete/use all/use seed buttons on hover.
*/
const HoverableBoard = memo(({ board }: HoverableBoardProps) => {
const dispatch = useAppDispatch();
const { board_name, board_id, cover_image_name } = board;
const coverImage = useAppSelector((state) =>
selectImagesById(state, cover_image_name as EntityId)
);
const { t } = useTranslation();
const handleSelectBoard = useCallback(() => {
// dispatch(imageSelected(board_id));
}, []);
return (
<Box sx={{ w: 'full', h: 'full', touchAction: 'none' }}>
<ContextMenu<HTMLDivElement>
menuProps={{ size: 'sm', isLazy: true }}
renderMenu={() => (
<MenuList sx={{ visibility: 'visible !important' }}>
<MenuItem
icon={<ExternalLinkIcon />}
// onClickCapture={handleOpenInNewTab}
>
Sample Menu Item
</MenuItem>
</MenuList>
)}
>
{(ref) => (
<Box
position="relative"
key={board_id}
userSelect="none"
onClick={handleSelectBoard}
ref={ref}
sx={{
display: 'flex',
flexDir: 'column',
justifyContent: 'center',
alignItems: 'center',
w: 'full',
h: 'full',
transition: 'transform 0.2s ease-out',
aspectRatio: '1/1',
cursor: 'pointer',
}}
>
<Image
loading="lazy"
// objectFit={
// shouldUseSingleGalleryColumn ? 'contain' : galleryImageObjectFit
// }
draggable={false}
rounded="md"
src={coverImage ? coverImage.thumbnail_url : undefined}
fallback={<FaImage />}
sx={{
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
}}
/>
<Text textAlign="center">{board_name}</Text>
</Box>
)}
</ContextMenu>
</Box>
);
});
HoverableBoard.displayName = 'HoverableBoard';
export default HoverableBoard;

View File

@ -2,7 +2,14 @@ import { Box, Flex, Icon, Image, MenuItem, MenuList } from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { memo, useCallback, useContext, useState } from 'react';
import { FaCheck, FaExpand, FaImage, FaShare, FaTrash } from 'react-icons/fa';
import {
FaCheck,
FaExpand,
FaFolder,
FaImage,
FaShare,
FaTrash,
} from 'react-icons/fa';
import { ContextMenu } from 'chakra-ui-contextmenu';
import {
resizeAndScaleCanvas,
@ -168,6 +175,10 @@ const HoverableImage = memo((props: HoverableImageProps) => {
// dispatch(setIsLightboxOpen(true));
};
const handleAddToFolder = useCallback(() => {
// dispatch(addImageToFolder(image));
}, []);
const handleOpenInNewTab = () => {
window.open(image.image_url, '_blank');
};
@ -244,6 +255,9 @@ const HoverableImage = memo((props: HoverableImageProps) => {
{t('parameters.sendToUnifiedCanvas')}
</MenuItem>
)}
<MenuItem icon={<FaFolder />} onClickCapture={handleAddToFolder}>
Add to Folder
</MenuItem>
<MenuItem
sx={{ color: 'error.300' }}
icon={<FaTrash />}

View File

@ -20,6 +20,7 @@ import {
setGalleryImageObjectFit,
setShouldAutoSwitchToNewImages,
setShouldUseSingleGalleryColumn,
setGalleryView,
} from 'features/gallery/store/gallerySlice';
import { togglePinGalleryPanel } from 'features/ui/store/uiSlice';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
@ -36,7 +37,7 @@ import {
} from 'react';
import { useTranslation } from 'react-i18next';
import { BsPinAngle, BsPinAngleFill } from 'react-icons/bs';
import { FaImage, FaServer, FaWrench } from 'react-icons/fa';
import { FaFolder, FaImage, FaPlus, FaServer, FaWrench } from 'react-icons/fa';
import { MdPhotoLibrary } from 'react-icons/md';
import HoverableImage from './HoverableImage';
@ -53,22 +54,39 @@ import {
selectImagesAll,
} from '../store/imagesSlice';
import { receivedPageOfImages } from 'services/thunks/image';
import { boardSelector } from '../store/boardSelectors';
import { BoardRecord, ImageDTO } from '../../../services/api';
import { isBoardRecord, isImageDTO } from '../../../services/types/guards';
import HoverableBoard from './HoverableBoard';
import IAIInput from '../../../common/components/IAIInput';
import { boardCreated } from '../../../services/thunks/board';
const categorySelector = createSelector(
const itemSelector = createSelector(
[(state: RootState) => state],
(state) => {
const { images } = state;
const { categories } = images;
const { images, boards, gallery } = state;
const allImages = selectImagesAll(state);
const filteredImages = allImages.filter((i) =>
categories.includes(i.image_category)
);
let items: Array<ImageDTO | BoardRecord> = [];
let areMoreAvailable = false;
let isLoading = true;
if (gallery.galleryView === 'images' || gallery.galleryView === 'assets') {
const { categories } = images;
const allImages = selectImagesAll(state);
items = allImages.filter((i) => categories.includes(i.image_category));
areMoreAvailable = items.length < images.total;
isLoading = images.isLoading;
} else if (gallery.galleryView === 'boards') {
items = Object.values(boards.entities) as BoardRecord[];
areMoreAvailable = items.length < boards.total;
isLoading = boards.isLoading;
}
return {
images: filteredImages,
isLoading: images.isLoading,
areMoreImagesAvailable: filteredImages.length < images.total,
items,
isLoading,
areMoreAvailable,
categories: images.categories,
};
},
@ -76,18 +94,21 @@ const categorySelector = createSelector(
);
const mainSelector = createSelector(
[gallerySelector, uiSelector],
(gallery, ui) => {
[gallerySelector, uiSelector, boardSelector],
(gallery, ui, boardState) => {
const {
galleryImageMinimumWidth,
galleryImageObjectFit,
shouldAutoSwitchToNewImages,
shouldUseSingleGalleryColumn,
selectedImage,
galleryView,
} = gallery;
const { shouldPinGallery } = ui;
const { entities: boards } = boardState;
return {
shouldPinGallery,
galleryImageMinimumWidth,
@ -95,6 +116,8 @@ const mainSelector = createSelector(
shouldAutoSwitchToNewImages,
shouldUseSingleGalleryColumn,
selectedImage,
galleryView,
boards,
};
},
defaultSelectorOptions
@ -126,21 +149,23 @@ const ImageGalleryContent = () => {
shouldAutoSwitchToNewImages,
shouldUseSingleGalleryColumn,
selectedImage,
galleryView,
boards,
} = useAppSelector(mainSelector);
const { images, areMoreImagesAvailable, isLoading, categories } =
useAppSelector(categorySelector);
const { items, areMoreAvailable, isLoading, categories } =
useAppSelector(itemSelector);
const handleLoadMoreImages = useCallback(() => {
dispatch(receivedPageOfImages());
}, [dispatch]);
const handleEndReached = useMemo(() => {
if (areMoreImagesAvailable && !isLoading) {
if (areMoreAvailable && !isLoading) {
return handleLoadMoreImages;
}
return undefined;
}, [areMoreImagesAvailable, handleLoadMoreImages, isLoading]);
}, [areMoreAvailable, handleLoadMoreImages, isLoading]);
const handleChangeGalleryImageMinimumWidth = (v: number) => {
dispatch(setGalleryImageMinimumWidth(v));
@ -172,12 +197,24 @@ const ImageGalleryContent = () => {
const handleClickImagesCategory = useCallback(() => {
dispatch(imageCategoriesChanged(IMAGE_CATEGORIES));
dispatch(setGalleryView('images'));
}, [dispatch]);
const handleClickAssetsCategory = useCallback(() => {
dispatch(imageCategoriesChanged(ASSETS_CATEGORIES));
dispatch(setGalleryView('assets'));
}, [dispatch]);
const handleClickBoardsView = useCallback(() => {
dispatch(setGalleryView('boards'));
}, [dispatch]);
const [newBoardName, setNewBoardName] = useState('');
const handleCreateNewBoard = () => {
dispatch(boardCreated({ requestBody: newBoardName }));
};
return (
<Flex
sx={{
@ -198,7 +235,7 @@ const ImageGalleryContent = () => {
tooltip={t('gallery.images')}
aria-label={t('gallery.images')}
onClick={handleClickImagesCategory}
isChecked={categories === IMAGE_CATEGORIES}
isChecked={galleryView === 'images'}
size="sm"
icon={<FaImage />}
/>
@ -206,12 +243,47 @@ const ImageGalleryContent = () => {
tooltip={t('gallery.assets')}
aria-label={t('gallery.assets')}
onClick={handleClickAssetsCategory}
isChecked={categories === ASSETS_CATEGORIES}
isChecked={galleryView === 'assets'}
size="sm"
icon={<FaServer />}
/>
<IAIIconButton
tooltip={t('gallery.boards')}
aria-label={t('gallery.boards')}
onClick={handleClickBoardsView}
isChecked={galleryView === 'boards'}
size="sm"
icon={<FaFolder />}
/>
</ButtonGroup>
<Flex gap={2}>
<IAIPopover
triggerComponent={
<IAIIconButton
tooltip="Add Board"
aria-label="Add Board"
size="sm"
icon={<FaPlus />}
/>
}
>
<Flex direction="column" gap={2}>
<IAIInput
label="Board Name"
placeholder="Board Name"
value={newBoardName}
onChange={(e) => setNewBoardName(e.target.value)}
/>
<IAIButton
size="sm"
onClick={handleCreateNewBoard}
disabled={true}
isLoading={false}
>
Create
</IAIButton>
</Flex>
</IAIPopover>
<IAIPopover
triggerComponent={
<IAIIconButton
@ -271,57 +343,75 @@ const ImageGalleryContent = () => {
</Flex>
</Flex>
<Flex direction="column" gap={2} h="full">
{images.length || areMoreImagesAvailable ? (
{items.length || areMoreAvailable ? (
<>
<Box ref={rootRef} data-overlayscrollbars="" h="100%">
{shouldUseSingleGalleryColumn ? (
<Virtuoso
style={{ height: '100%' }}
data={images}
data={items}
endReached={handleEndReached}
scrollerRef={(ref) => setScrollerRef(ref)}
itemContent={(index, image) => (
<Flex sx={{ pb: 2 }}>
<HoverableImage
key={`${image.image_name}-${image.thumbnail_url}`}
image={image}
isSelected={
selectedImage?.image_name === image?.image_name
}
/>
</Flex>
)}
itemContent={(index, item) => {
if (isImageDTO(item)) {
return (
<Flex sx={{ pb: 2 }}>
<HoverableImage
key={`${item.image_name}-${item.thumbnail_url}`}
image={item}
isSelected={
selectedImage?.image_name === item?.image_name
}
/>
</Flex>
);
} else if (isBoardRecord(item)) {
return (
<Flex sx={{ pb: 2 }}>
<HoverableBoard key={item.board_id} board={item} />
</Flex>
);
}
}}
/>
) : (
<VirtuosoGrid
style={{ height: '100%' }}
data={images}
data={items}
endReached={handleEndReached}
components={{
Item: ItemContainer,
List: ListContainer,
}}
scrollerRef={setScroller}
itemContent={(index, image) => (
<HoverableImage
key={`${image.image_name}-${image.thumbnail_url}`}
image={image}
isSelected={
selectedImage?.image_name === image?.image_name
}
/>
)}
itemContent={(index, item) => {
if (isImageDTO(item)) {
return (
<HoverableImage
key={`${item.image_name}-${item.thumbnail_url}`}
image={item}
isSelected={
selectedImage?.image_name === item?.image_name
}
/>
);
} else if (isBoardRecord(item)) {
return (
<HoverableBoard key={item.board_id} board={item} />
);
}
}}
/>
)}
</Box>
<IAIButton
onClick={handleLoadMoreImages}
isDisabled={!areMoreImagesAvailable}
isDisabled={!areMoreAvailable}
isLoading={isLoading}
loadingText="Loading"
flexShrink={0}
>
{areMoreImagesAvailable
{areMoreAvailable
? t('gallery.loadMore')
: t('gallery.allImagesLoaded')}
</IAIButton>

View File

@ -0,0 +1,3 @@
import { RootState } from 'app/store/store';
export const boardSelector = (state: RootState) => state.boards;

View File

@ -0,0 +1,77 @@
import {
PayloadAction,
Update,
createEntityAdapter,
createSlice,
} from '@reduxjs/toolkit';
import { RootState } from 'app/store/store';
import { BoardRecord } from 'services/api';
import { dateComparator } from 'common/util/dateComparator';
import { receivedBoards } from '../../../services/thunks/board';
export const boardsAdapter = createEntityAdapter<BoardRecord>({
selectId: (board) => board.board_id,
sortComparer: (a, b) => dateComparator(b.updated_at, a.updated_at),
});
type AdditionalBoardsState = {
offset: number;
limit: number;
total: number;
isLoading: boolean;
};
export const initialBoardsState =
boardsAdapter.getInitialState<AdditionalBoardsState>({
offset: 0,
limit: 0,
total: 0,
isLoading: false,
});
export type BoardsState = typeof initialBoardsState;
const boardsSlice = createSlice({
name: 'boards',
initialState: initialBoardsState,
reducers: {
boardUpserted: (state, action: PayloadAction<BoardRecord>) => {
boardsAdapter.upsertOne(state, action.payload);
},
boardUpdatedOne: (state, action: PayloadAction<Update<BoardRecord>>) => {
boardsAdapter.updateOne(state, action.payload);
},
boardRemoved: (state, action: PayloadAction<string>) => {
boardsAdapter.removeOne(state, action.payload);
},
},
extraReducers: (builder) => {
builder.addCase(receivedBoards.pending, (state) => {
state.isLoading = true;
});
builder.addCase(receivedBoards.rejected, (state) => {
state.isLoading = false;
});
builder.addCase(receivedBoards.fulfilled, (state, action) => {
state.isLoading = false;
const { items, offset, limit, total } = action.payload;
state.offset = offset;
state.limit = limit;
state.total = total;
boardsAdapter.upsertMany(state, items);
});
},
});
export const {
selectAll: selectBoardsAll,
selectById: selectBoardsById,
selectEntities: selectBoardsEntities,
selectIds: selectBoardsIds,
selectTotal: selectBoardsTotal,
} = boardsAdapter.getSelectors<RootState>((state) => state.boards);
export const { boardUpserted, boardUpdatedOne, boardRemoved } =
boardsSlice.actions;
export default boardsSlice.reducer;

View File

@ -12,6 +12,7 @@ export interface GalleryState {
galleryImageObjectFit: GalleryImageObjectFitType;
shouldAutoSwitchToNewImages: boolean;
shouldUseSingleGalleryColumn: boolean;
galleryView: 'images' | 'assets' | 'boards';
}
export const initialGalleryState: GalleryState = {
@ -19,6 +20,7 @@ export const initialGalleryState: GalleryState = {
galleryImageObjectFit: 'cover',
shouldAutoSwitchToNewImages: true,
shouldUseSingleGalleryColumn: false,
galleryView: 'images',
};
export const gallerySlice = createSlice({
@ -48,6 +50,12 @@ export const gallerySlice = createSlice({
) => {
state.shouldUseSingleGalleryColumn = action.payload;
},
setGalleryView: (
state,
action: PayloadAction<'images' | 'assets' | 'boards'>
) => {
state.galleryView = action.payload;
},
},
extraReducers: (builder) => {
builder.addCase(imageUpserted, (state, action) => {
@ -75,6 +83,7 @@ export const {
setGalleryImageObjectFit,
setShouldAutoSwitchToNewImages,
setShouldUseSingleGalleryColumn,
setGalleryView,
} = gallerySlice.actions;
export default gallerySlice.reducer;

View File

@ -154,3 +154,16 @@ export const selectFilteredImagesIds = createSelector(
.map((i) => i.image_name);
}
);
// export const selectImageById = createSelector(
// (state: RootState, imageId) => state,
// (state) => {
// const {
// images: { categories },
// } = state;
// return selectImagesAll(state)
// .filter((i) => categories.includes(i.image_category))
// .map((i) => i.image_name);
// }
// );

View File

@ -0,0 +1,23 @@
import { createAppAsyncThunk } from '../../app/store/storeUtils';
import { BoardsService } from '../api';
/**
* `BoardsService.listBoards()` thunk
*/
export const receivedBoards = createAppAsyncThunk(
'api/receivedBoards',
async (_, { getState }) => {
const response = await BoardsService.listBoards({});
return response;
}
);
type BoardCreatedArg = Parameters<(typeof BoardsService)['createBoard']>[0];
export const boardCreated = createAppAsyncThunk(
'api/boardCreated',
async (arg: BoardCreatedArg) => {
const response = await BoardsService.createBoard(arg);
return response;
}
);

View File

@ -11,6 +11,7 @@ import {
LatentsOutput,
ResourceOrigin,
ImageDTO,
BoardRecord,
} from 'services/api';
export const isImageDTO = (obj: unknown): obj is ImageDTO => {
@ -29,6 +30,16 @@ export const isImageDTO = (obj: unknown): obj is ImageDTO => {
);
};
export const isBoardRecord = (obj: unknown): obj is BoardRecord => {
return (
isObject(obj) &&
'board_id' in obj &&
isString(obj?.board_id) &&
'board_name' in obj &&
isString(obj?.board_name)
);
};
export const isImageOutput = (
output: GraphExecutionState['results'][string]
): output is ImageOutput => output.type === 'image_output';