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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions invokeai/frontend/web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2891,6 +2891,8 @@
"previous": "Previous",
"next": "Next",
"saveToGallery": "Save To Gallery",
"hideThumbnails": "Hide Thumbnails",
"showThumbnails": "Show Thumbnails",
"showResultsOn": "Showing Results",
"showResultsOff": "Hiding Results"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,21 @@ import { Virtuoso } from 'react-virtuoso';
import type { S } from 'services/api/types';

import { useStagingAreaContext } from './context';
import { getQueueItemElementId } from './shared';
import { getQueueItemElementId, STAGING_AREA_THUMBNAIL_STRIP_HEIGHT } from './shared';

const log = logger('system');

const virtuosoStyles = {
width: '100%',
height: '72px',
height: STAGING_AREA_THUMBNAIL_STRIP_HEIGHT,
} satisfies CSSProperties;

const applyViewportStyles = (viewport: HTMLElement) => {
viewport.style.overflowX = `var(--os-viewport-overflow-x)`;
viewport.style.overflowY = `var(--os-viewport-overflow-y)`;
viewport.style.textAlign = 'center';
};

/**
* Scroll the item at the given index into view if it is not currently visible.
*/
Expand Down Expand Up @@ -88,11 +94,7 @@ const useScrollableStagingArea = (rootRef: RefObject<HTMLDivElement>) => {
defer: true,
events: {
initialized(osInstance) {
// force overflow styles
const { viewport } = osInstance.elements();
viewport.style.overflowX = `var(--os-viewport-overflow-x)`;
viewport.style.overflowY = `var(--os-viewport-overflow-y)`;
viewport.style.textAlign = 'center';
applyViewportStyles(osInstance.elements().viewport);
},
},
options: {
Expand All @@ -113,6 +115,9 @@ const useScrollableStagingArea = (rootRef: RefObject<HTMLDivElement>) => {
const { current: root } = rootRef;

if (scroller && root) {
// Apply the viewport layout styles before overlayscrollbars initializes to avoid a left-aligned first paint.
applyViewportStyles(scroller);

initialize({
target: root,
elements: {
Expand All @@ -131,7 +136,6 @@ const useScrollableStagingArea = (rootRef: RefObject<HTMLDivElement>) => {

export const StagingAreaItemsList = memo(() => {
const canvasManager = useCanvasManager();

const ctx = useStagingAreaContext();
const virtuosoRef = useRef<VirtuosoHandle>(null);
const rangeRef = useRef<ListRange>({ startIndex: 0, endIndex: 0 });
Expand All @@ -143,7 +147,7 @@ export const StagingAreaItemsList = memo(() => {

useEffect(() => {
return canvasManager.stagingArea.connectToSession(ctx.$items, ctx.$selectedItem);
}, [canvasManager, ctx.$progressData, ctx.$items, ctx.$selectedItem]);
}, [canvasManager, ctx.$items, ctx.$selectedItem]);

useEffect(() => {
return ctx.$selectedItemIndex.listen((selectedItemIndex) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,31 @@ import { StagingAreaToolbarNextButton } from 'features/controlLayers/components/
import { StagingAreaToolbarPrevButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarPrevButton';
import { StagingAreaToolbarSaveSelectedToGalleryButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarSaveSelectedToGalleryButton';
import { StagingAreaToolbarToggleShowResultsButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarToggleShowResultsButton';
import { StagingAreaToolbarToggleThumbnailsButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarToggleThumbnailsButton';
import { memo } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';

import { StagingAreaAutoSwitchButtons } from './StagingAreaAutoSwitchButtons';

export const StagingAreaToolbar = memo(() => {
type Props = {
areThumbnailsVisible: boolean;
onToggleThumbnails: () => void;
};

export const StagingAreaToolbar = memo(({ areThumbnailsVisible, onToggleThumbnails }: Props) => {
const ctx = useStagingAreaContext();

useHotkeys('meta+left', ctx.selectFirst, { preventDefault: true });
useHotkeys('meta+right', ctx.selectLast, { preventDefault: true });

return (
<Flex gap={2}>
<ButtonGroup borderRadius="base" shadow="dark-lg">
<StagingAreaToolbarToggleThumbnailsButton
areThumbnailsVisible={areThumbnailsVisible}
onToggle={onToggleThumbnails}
/>
</ButtonGroup>
<ButtonGroup borderRadius="base" shadow="dark-lg">
<StagingAreaToolbarPrevButton />
<StagingAreaToolbarImageCountButton />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { IconButton } from '@invoke-ai/ui-library';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCaretLineDownBold, PiCaretLineUpBold } from 'react-icons/pi';

type Props = {
areThumbnailsVisible: boolean;
onToggle: () => void;
};

export const StagingAreaToolbarToggleThumbnailsButton = memo(({ areThumbnailsVisible, onToggle }: Props) => {
const { t } = useTranslation();

const label = areThumbnailsVisible
? t('controlLayers.stagingArea.hideThumbnails', { defaultValue: 'Hide Thumbnails' })
: t('controlLayers.stagingArea.showThumbnails', { defaultValue: 'Show Thumbnails' });

return (
<IconButton
tooltip={label}
aria-label={label}
icon={areThumbnailsVisible ? <PiCaretLineDownBold /> : <PiCaretLineUpBold />}
onClick={onToggle}
colorScheme="invokeBlue"
/>
);
});

StagingAreaToolbarToggleThumbnailsButton.displayName = 'StagingAreaToolbarToggleThumbnailsButton';
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ export const StagingAreaContextProvider = memo(({ children, sessionId }: PropsWi
},
onDiscard: ({ item_id, status }) => {
store.dispatch(canvasQueueItemDiscarded({ itemId: item_id }));
if (selectQueueItems(store.getState()).length === 0) {
store.dispatch(canvasSessionReset());
}
if (status === 'in_progress' || status === 'pending') {
store.dispatch(queueApi.endpoints.cancelQueueItem.initiate({ item_id }, { track: false }));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const getProgressMessage = (data?: S['InvocationProgressEvent'] | null) =
export const DROP_SHADOW = 'drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0px 0px 4px rgba(0, 0, 0, 0.3))';

export const getQueueItemElementId = (index: number) => `queue-item-preview-${index}`;
export const STAGING_AREA_THUMBNAIL_STRIP_HEIGHT = '72px';

export const getOutputImageName = (item: S['SessionQueueItem']) => {
const nodeId = Object.entries(item.session.source_prepared_mapping).find(([nodeId]) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { Equals } from 'tsafe';
import { assert } from 'tsafe';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { z } from 'zod';

const { getPrefixedIdMock } = vi.hoisted(() => ({
getPrefixedIdMock: vi.fn((prefix: string) => `${prefix}-generated`),
}));

vi.mock('features/controlLayers/konva/util', () => ({
getPrefixedId: getPrefixedIdMock,
}));

import {
canvasSessionReset,
canvasSessionSliceConfig,
canvasSessionThumbnailsVisibilityToggled,
} from './canvasStagingAreaSlice';

describe('canvasStagingAreaSlice', () => {
type InitialState = ReturnType<typeof canvasSessionSliceConfig.getInitialState>;
type SchemaState = z.infer<typeof canvasSessionSliceConfig.schema>;

const { reducer } = canvasSessionSliceConfig.slice;
const migrate = canvasSessionSliceConfig.persistConfig?.migrate;

beforeEach(() => {
getPrefixedIdMock.mockReset();
getPrefixedIdMock.mockImplementation((prefix: string) => `${prefix}-generated`);
});

it('keeps the initial state aligned with the persisted schema', () => {
assert<Equals<InitialState, SchemaState>>();
});

it('toggles thumbnail visibility', () => {
const state = canvasSessionSliceConfig.getInitialState();

const hidden = reducer(state, canvasSessionThumbnailsVisibilityToggled());
const shown = reducer(hidden, canvasSessionThumbnailsVisibilityToggled());

expect(hidden.areThumbnailsVisible).toBe(false);
expect(shown.areThumbnailsVisible).toBe(true);
});

it('resets thumbnails visibility and discarded items on session reset', () => {
const state = {
_version: 2 as const,
canvasSessionId: 'canvas-existing',
canvasDiscardedQueueItems: [1, 2],
areThumbnailsVisible: false,
};

getPrefixedIdMock.mockReturnValueOnce('canvas-reset');

const result = reducer(state, canvasSessionReset());

expect(result).toEqual({
_version: 2,
canvasSessionId: 'canvas-reset',
canvasDiscardedQueueItems: [],
areThumbnailsVisible: true,
});
});

it('migrates legacy persisted state without a version to v2 defaults', () => {
expect(migrate).toBeDefined();

const result = migrate?.({});

expect(result).toEqual({
_version: 2,
canvasSessionId: 'canvas-generated',
canvasDiscardedQueueItems: [],
areThumbnailsVisible: true,
});
});

it('migrates v1 persisted state while preserving existing session data', () => {
expect(migrate).toBeDefined();

const result = migrate?.({
_version: 1,
canvasSessionId: 'canvas-v1',
canvasDiscardedQueueItems: [3, 5],
});

expect(result).toEqual({
_version: 2,
canvasSessionId: 'canvas-v1',
canvasDiscardedQueueItems: [3, 5],
areThumbnailsVisible: true,
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,18 @@ import { assert } from 'tsafe';
import z from 'zod';

const zCanvasStagingAreaState = z.object({
_version: z.literal(1),
_version: z.literal(2),
canvasSessionId: z.string(),
canvasDiscardedQueueItems: z.array(z.number().int()),
areThumbnailsVisible: z.boolean(),
});
type CanvasStagingAreaState = z.infer<typeof zCanvasStagingAreaState>;

const getInitialState = (): CanvasStagingAreaState => ({
_version: 1,
_version: 2,
canvasSessionId: getPrefixedId('canvas'),
canvasDiscardedQueueItems: [],
areThumbnailsVisible: true,
});

const slice = createSlice({
Expand All @@ -33,11 +35,15 @@ const slice = createSlice({
state.canvasDiscardedQueueItems.push(itemId);
}
},
canvasSessionThumbnailsVisibilityToggled: (state) => {
state.areThumbnailsVisible = !state.areThumbnailsVisible;
},
canvasSessionReset: {
reducer: (state, action: PayloadAction<{ canvasSessionId: string }>) => {
const { canvasSessionId } = action.payload;
state.canvasSessionId = canvasSessionId;
state.canvasDiscardedQueueItems = [];
state.areThumbnailsVisible = true;
},
prepare: () => {
return {
Expand All @@ -50,7 +56,7 @@ const slice = createSlice({
},
});

export const { canvasSessionReset, canvasQueueItemDiscarded } = slice.actions;
export const { canvasSessionReset, canvasQueueItemDiscarded, canvasSessionThumbnailsVisibilityToggled } = slice.actions;

export const canvasSessionSliceConfig: SliceConfig<typeof slice> = {
slice,
Expand All @@ -62,6 +68,12 @@ export const canvasSessionSliceConfig: SliceConfig<typeof slice> = {
if (!('_version' in state)) {
state._version = 1;
state.canvasSessionId = state.canvasSessionId ?? getPrefixedId('canvas');
state.canvasDiscardedQueueItems = state.canvasDiscardedQueueItems ?? [];
}

if (state._version === 1) {
state._version = 2;
state.areThumbnailsVisible = true;
}

return zCanvasStagingAreaState.parse(state);
Expand All @@ -71,6 +83,7 @@ export const canvasSessionSliceConfig: SliceConfig<typeof slice> = {

export const selectCanvasSessionSlice = (s: RootState) => s[slice.name];
export const selectCanvasSessionId = createSelector(selectCanvasSessionSlice, ({ canvasSessionId }) => canvasSessionId);
export const selectCanvasSessionAreThumbnailsVisible = (s: RootState) => s[slice.name].areThumbnailsVisible;

const selectDiscardedItems = createSelector(
selectCanvasSessionSlice,
Expand Down
30 changes: 26 additions & 4 deletions invokeai/frontend/web/src/features/ui/layouts/StagingArea.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,42 @@
import { Flex } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { STAGING_AREA_THUMBNAIL_STRIP_HEIGHT } from 'features/controlLayers/components/StagingArea/shared';
import { StagingAreaItemsList } from 'features/controlLayers/components/StagingArea/StagingAreaItemsList';
import { StagingAreaToolbar } from 'features/controlLayers/components/StagingArea/StagingAreaToolbar';
import { useCanvasIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { memo } from 'react';
import {
canvasSessionThumbnailsVisibilityToggled,
selectCanvasSessionAreThumbnailsVisible,
useCanvasIsStaging,
} from 'features/controlLayers/store/canvasStagingAreaSlice';
import { memo, useCallback } from 'react';

export const StagingArea = memo(() => {
const dispatch = useAppDispatch();
const isStaging = useCanvasIsStaging();
const areThumbnailsVisible = useAppSelector(selectCanvasSessionAreThumbnailsVisible);

const onToggleThumbnails = useCallback(() => {
dispatch(canvasSessionThumbnailsVisibilityToggled());
}, [dispatch]);

if (!isStaging) {
return null;
}

return (
<Flex position="absolute" flexDir="column" bottom={2} gap={2} align="center" justify="center" left={2} right={2}>
<StagingAreaItemsList />
<StagingAreaToolbar />
<Flex
w="full"
h={areThumbnailsVisible ? STAGING_AREA_THUMBNAIL_STRIP_HEIGHT : 0}
opacity={areThumbnailsVisible ? 1 : 0}
overflow="hidden"
pointerEvents={areThumbnailsVisible ? 'auto' : 'none'}
transitionProperty="height, opacity"
transitionDuration="normal"
>
<StagingAreaItemsList />
</Flex>
<StagingAreaToolbar areThumbnailsVisible={areThumbnailsVisible} onToggleThumbnails={onToggleThumbnails} />
</Flex>
);
});
Expand Down
Loading