diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 58be5430a26..3264c7e5e5e 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -2891,6 +2891,8 @@ "previous": "Previous", "next": "Next", "saveToGallery": "Save To Gallery", + "hideThumbnails": "Hide Thumbnails", + "showThumbnails": "Show Thumbnails", "showResultsOn": "Showing Results", "showResultsOff": "Hiding Results" }, diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaItemsList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaItemsList.tsx index 962ad027ccf..b1507b9b489 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaItemsList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaItemsList.tsx @@ -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. */ @@ -88,11 +94,7 @@ const useScrollableStagingArea = (rootRef: RefObject) => { 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: { @@ -113,6 +115,9 @@ const useScrollableStagingArea = (rootRef: RefObject) => { 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: { @@ -131,7 +136,6 @@ const useScrollableStagingArea = (rootRef: RefObject) => { export const StagingAreaItemsList = memo(() => { const canvasManager = useCanvasManager(); - const ctx = useStagingAreaContext(); const virtuosoRef = useRef(null); const rangeRef = useRef({ startIndex: 0, endIndex: 0 }); @@ -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) => { diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbar.tsx index ff73a37fc94..b2f1c3776c1 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbar.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbar.tsx @@ -9,12 +9,18 @@ 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 }); @@ -22,6 +28,12 @@ export const StagingAreaToolbar = memo(() => { return ( + + + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarToggleThumbnailsButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarToggleThumbnailsButton.tsx new file mode 100644 index 00000000000..24d5cfbe4ed --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarToggleThumbnailsButton.tsx @@ -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 ( + : } + onClick={onToggle} + colorScheme="invokeBlue" + /> + ); +}); + +StagingAreaToolbarToggleThumbnailsButton.displayName = 'StagingAreaToolbarToggleThumbnailsButton'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/context.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/context.tsx index 6b8da8dc4da..4b5e1c438c6 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/context.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/context.tsx @@ -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 })); } diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/shared.ts b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/shared.ts index fe98408df58..fd294e2dcbf 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/shared.ts +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/shared.ts @@ -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]) => diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.test.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.test.ts new file mode 100644 index 00000000000..a93ce4e8daa --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.test.ts @@ -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; + type SchemaState = z.infer; + + 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>(); + }); + + 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, + }); + }); +}); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts index 694abcda1c6..9cf2efacd35 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts @@ -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; const getInitialState = (): CanvasStagingAreaState => ({ - _version: 1, + _version: 2, canvasSessionId: getPrefixedId('canvas'), canvasDiscardedQueueItems: [], + areThumbnailsVisible: true, }); const slice = createSlice({ @@ -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 { @@ -50,7 +56,7 @@ const slice = createSlice({ }, }); -export const { canvasSessionReset, canvasQueueItemDiscarded } = slice.actions; +export const { canvasSessionReset, canvasQueueItemDiscarded, canvasSessionThumbnailsVisibilityToggled } = slice.actions; export const canvasSessionSliceConfig: SliceConfig = { slice, @@ -62,6 +68,12 @@ export const canvasSessionSliceConfig: SliceConfig = { 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); @@ -71,6 +83,7 @@ export const canvasSessionSliceConfig: SliceConfig = { 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, diff --git a/invokeai/frontend/web/src/features/ui/layouts/StagingArea.tsx b/invokeai/frontend/web/src/features/ui/layouts/StagingArea.tsx index f262a25daa8..552b3298726 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/StagingArea.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/StagingArea.tsx @@ -1,11 +1,23 @@ 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; @@ -13,8 +25,18 @@ export const StagingArea = memo(() => { return ( - - + + + + ); });