diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx index 17e23ff26f..6692ee1c9e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx @@ -313,13 +313,31 @@ const GenerateWithStartingImageAndInpaintMask = memo(() => { }); GenerateWithStartingImageAndInpaintMask.displayName = 'GenerateWithStartingImageAndInpaintMask'; -const scrollIndicatorSx = { +const scrollIndicatorBaseSx = { opacity: 0, + position: 'absolute', + w: 16, + h: 'full', + transitionProperty: 'opacity', + transitionDuration: '0.3s', + pointerEvents: 'none', '&[data-visible="true"]': { opacity: 1, }, } satisfies SystemStyleObject; +const scrollIndicatorLeftSx = { + ...scrollIndicatorBaseSx, + left: 0, + bg: 'linear-gradient(to right, var(--invoke-colors-base-900), transparent)', +} satisfies SystemStyleObject; + +const scrollIndicatorRightSx = { + ...scrollIndicatorBaseSx, + right: 0, + bg: 'linear-gradient(to left, var(--invoke-colors-base-900), transparent)', +} satisfies SystemStyleObject; + type StagingContextValue = { session: | { @@ -341,72 +359,11 @@ const useStagingContext = () => { return ctx; }; -const StagingArea = memo(() => { - const ctx = useStagingContext(); - const dispatch = useAppDispatch(); - const [selectedItemId, setSelectedItemId] = useState(null); - const [autoSwitch, setAutoSwitch] = useState(true); - const [canScrollLeft, setCanScrollLeft] = useState(false); - const [canScrollRight, setCanScrollRight] = useState(false); - const scrollableRef = useRef(null); - const { data } = useListAllQueueItemsQuery({ destination: ctx.session.id }); - const items = useMemo(() => data?.filter(({ status }) => status !== 'canceled') ?? EMPTY_ARRAY, [data]); - const selectedItem = useMemo( - () => - items.length > 0 && selectedItemId !== null ? items.find(({ item_id }) => item_id === selectedItemId) : null, - [items, selectedItemId] - ); - const selectedItemIndex = useMemo( - () => - items.length > 0 && selectedItemId !== null ? items.findIndex(({ item_id }) => item_id === selectedItemId) : null, - [items, selectedItemId] - ); - - const startOver = useCallback(() => { - dispatch(canvasSessionStarted({ sessionType: 'simple' })); - }, [dispatch]); - - useEffect(() => { - const el = scrollableRef.current; - if (!el) { - return; - } - const onScroll = () => { - const { scrollLeft, scrollWidth, clientWidth } = el; - setCanScrollLeft(scrollLeft > 0); - setCanScrollRight(scrollLeft + clientWidth < scrollWidth); - }; - el.addEventListener('scroll', onScroll); - const observer = new ResizeObserver(onScroll); - observer.observe(el); - return () => { - el.removeEventListener('scroll', onScroll); - observer.disconnect(); - }; - }, []); - - const onSelectItemId = useCallback((item_id: number | null) => { - setSelectedItemId(item_id); - if (item_id !== null) { - document.getElementById(getCardId(item_id))?.scrollIntoView(); - } - }, []); - - const onChangeAutoSwitch = useCallback((autoSwitch: boolean) => { - setAutoSwitch(autoSwitch); - }, []); - - useEffect(() => { - if (items.length === 0) { - onSelectItemId(null); - return; - } - if (selectedItemId === null && items.length > 0) { - onSelectItemId(items[0]?.item_id ?? null); - return; - } - }, [items, onSelectItemId, selectedItem, selectedItemId]); - +const useStagingAreaKeyboardNav = ( + items: S['SessionQueueItem'][], + selectedItemId: number | null, + onSelectItemId: (item_id: number) => void +) => { const onNext = useCallback(() => { if (selectedItemId === null) { return; @@ -451,6 +408,78 @@ const StagingArea = memo(() => { useHotkeys('right', onNext, { preventDefault: true }); useHotkeys('meta+left', onFirst, { preventDefault: true }); useHotkeys('meta+right', onLast, { preventDefault: true }); +}; + +const LIST_ALL_OPTIONS = { + selectFromResult: ({ data }) => { + if (!data) { + return { items: EMPTY_ARRAY }; + } + return { items: data.filter(({ status }) => status !== 'canceled') }; + }, +} satisfies Parameters[1]; + +const StagingArea = memo(() => { + const ctx = useStagingContext(); + const [selectedItemId, setSelectedItemId] = useState(null); + const [autoSwitch, setAutoSwitch] = useState(true); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + const scrollableRef = useRef(null); + const { items } = useListAllQueueItemsQuery({ destination: ctx.session.id }, LIST_ALL_OPTIONS); + const selectedItem = useMemo( + () => + items.length > 0 && selectedItemId !== null ? items.find(({ item_id }) => item_id === selectedItemId) : null, + [items, selectedItemId] + ); + const selectedItemIndex = useMemo( + () => + items.length > 0 && selectedItemId !== null ? items.findIndex(({ item_id }) => item_id === selectedItemId) : null, + [items, selectedItemId] + ); + + useEffect(() => { + const el = scrollableRef.current; + if (!el) { + return; + } + const onScroll = () => { + const { scrollLeft, scrollWidth, clientWidth } = el; + setCanScrollLeft(scrollLeft > 0); + setCanScrollRight(scrollLeft + clientWidth < scrollWidth); + }; + el.addEventListener('scroll', onScroll); + const observer = new ResizeObserver(onScroll); + observer.observe(el); + return () => { + el.removeEventListener('scroll', onScroll); + observer.disconnect(); + }; + }, []); + + const onSelectItemId = useCallback((item_id: number | null) => { + setSelectedItemId(item_id); + if (item_id !== null) { + document.getElementById(getCardId(item_id))?.scrollIntoView(); + } + }, []); + + useStagingAreaKeyboardNav(items, selectedItemId, onSelectItemId); + + const onChangeAutoSwitch = useCallback((autoSwitch: boolean) => { + setAutoSwitch(autoSwitch); + }, []); + + useEffect(() => { + if (items.length === 0) { + onSelectItemId(null); + return; + } + if (selectedItemId === null && items.length > 0) { + onSelectItemId(items[0]?.item_id ?? null); + return; + } + }, [items, onSelectItemId, selectedItem, selectedItemId]); const socket = useStore($socket); useEffect(() => { @@ -497,29 +526,17 @@ const StagingArea = memo(() => { return ( - - - Generations - - - - + {selectedItem && selectedItemIndex !== null && ( - )} - {!selectedItem && No queued generations} + {!selectedItem && No generation selected} Auto-switch @@ -529,48 +546,46 @@ const StagingArea = memo(() => { {items.map((item, i) => ( - ))} - - + + ); }); StagingArea.displayName = 'StagingArea'; -const queueItemCardSx = { +const StagingAreaHeader = memo(() => { + const dispatch = useAppDispatch(); + + const startOver = useCallback(() => { + dispatch(canvasSessionStarted({ sessionType: 'simple' })); + }, [dispatch]); + + return ( + + + Generations + + + + + ); +}); +StagingAreaHeader.displayName = 'StagingAreaHeader'; + +const miniQueueItemSx = { cursor: 'pointer', pos: 'relative', alignItems: 'center', @@ -581,122 +596,154 @@ const queueItemCardSx = { maxW: 'full', minW: 0, minH: 0, - '&[data-size="mini"]': { - borderWidth: 1, - borderRadius: 'base', - '&[data-selected="true"]': { - borderColor: 'invokeBlue.300', - }, - aspectRatio: '1/1', - flexShrink: 0, + borderWidth: 1, + borderRadius: 'base', + '&[data-selected="true"]': { + borderColor: 'invokeBlue.300', }, + aspectRatio: '1/1', + flexShrink: 0, }; const getCardId = (item_id: number) => `queue-item-status-card-${item_id}`; -type QueueItemStatusCardMiniProps = { - item: S['SessionQueueItem']; - isSelected: boolean; - number: number; - onSelectItemId: (item_id: number) => void; - onChangeAutoSwitch: (autoSwitch: boolean) => void; - size: 'mini' | 'full'; -}; +const useOutputImageDTO = (item: S['SessionQueueItem']) => { + const ctx = useStagingContext(); -const QueueItemCard = memo( - ({ item, isSelected, number, onSelectItemId, onChangeAutoSwitch, size }: QueueItemStatusCardMiniProps) => { - const ctx = useStagingContext(); - const hasProgressImage = useHasProgressImage(ctx.$progressData, item.session_id); - - const outputImageName = useMemo(() => { - const nodeId = Object.entries(item.session.source_prepared_mapping).find(([nodeId]) => - isCanvasOutputNodeId(nodeId) - )?.[1][0]; - const output = nodeId ? item.session.results[nodeId] : undefined; - - if (!output) { - return null; - } - - for (const [_name, value] of objectEntries(output)) { - if (isImageField(value)) { - return value.image_name; - } - } + const outputImageName = useMemo(() => { + const nodeId = Object.entries(item.session.source_prepared_mapping).find(([nodeId]) => + isCanvasOutputNodeId(nodeId) + )?.[1][0]; + const output = nodeId ? item.session.results[nodeId] : undefined; + if (!output) { return null; - }, [item.session.results, item.session.source_prepared_mapping]); + } - const { currentData: imageDTO } = useGetImageDTOQuery(outputImageName ?? skipToken); - - const onClick = useCallback(() => { - onSelectItemId(item.item_id); - }, [item.item_id, onSelectItemId]); - - const onDoubleClick = useCallback(() => { - onChangeAutoSwitch(item.status === 'in_progress'); - }, [item.status, onChangeAutoSwitch]); - - const syncIsReady = useCallback(async () => { - if (!imageDTO) { - return; + for (const [_name, value] of objectEntries(output)) { + if (isImageField(value)) { + return value.image_name; } + } + + return null; + }, [item.session.results, item.session.source_prepared_mapping]); + + const { currentData: imageDTO } = useGetImageDTOQuery(outputImageName ?? skipToken); + + const preloadOutputImageAndClearProgress = useCallback( + async (imageDTO: ImageDTO) => { try { await loadImage(imageDTO.image_url, true); clearProgressImage(ctx.$progressData, item.session_id); return; } catch { - // noop + // noop - but should we do something? means image failed to load... } - }, [ctx.$progressData, imageDTO, item.session_id]); + }, + [ctx.$progressData, item.session_id] + ); - useEffect(() => { - syncIsReady(); - }, [syncIsReady]); - - if (imageDTO && !hasProgressImage) { - return ( - - - {size === 'mini' && } - {size === 'full' && ( - <> - - - - )} - - ); + useEffect(() => { + if (!imageDTO) { + return; } + if (!ctx.$progressData.get()[item.session_id]?.progressImage) { + return; + } + preloadOutputImageAndClearProgress(imageDTO); + }, [ctx.$progressData, imageDTO, item.session_id, preloadOutputImageAndClearProgress]); + return imageDTO; +}; + +type MiniQueueItemProps = { + item: S['SessionQueueItem']; + number: number; + isSelected: boolean; + onSelectItemId: (item_id: number) => void; + onChangeAutoSwitch: (autoSwitch: boolean) => void; +}; + +const MiniQueueItem = memo(({ item, isSelected, number, onSelectItemId, onChangeAutoSwitch }: MiniQueueItemProps) => { + const ctx = useStagingContext(); + const hasProgressImage = useHasProgressImage(ctx.$progressData, item.session_id); + const imageDTO = useOutputImageDTO(item); + + const onClick = useCallback(() => { + onSelectItemId(item.item_id); + }, [item.item_id, onSelectItemId]); + + const onDoubleClick = useCallback(() => { + onChangeAutoSwitch(item.status === 'in_progress'); + }, [item.status, onChangeAutoSwitch]); + + if (imageDTO && !hasProgressImage) { return ( - - - {size === 'mini' && } - {size === 'full' && ( - <> - - - - )} + + + ); } -); -QueueItemCard.displayName = 'QueueItemStatusCard'; + + return ( + + + + + ); +}); +MiniQueueItem.displayName = 'MiniQueueItem'; + +const fullSizeQueueItemSx = { + cursor: 'pointer', + pos: 'relative', + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + h: 'full', + maxH: 'full', + maxW: 'full', + minW: 0, + minH: 0, +}; + +type FullSizeQueueItemProps = { + item: S['SessionQueueItem']; + number: number; +}; + +const FullSizeQueueItem = memo(({ item, number }: FullSizeQueueItemProps) => { + const ctx = useStagingContext(); + const hasProgressImage = useHasProgressImage(ctx.$progressData, item.session_id); + const imageDTO = useOutputImageDTO(item); + + if (imageDTO && !hasProgressImage) { + return ( + + + + + + ); + } + + return ( + + + + + + ); +}); +FullSizeQueueItem.displayName = 'FullSizeQueueItem'; const getMessage = (data: S['InvocationProgressEvent']) => { let message = data.message; @@ -859,6 +906,18 @@ const ImageActions = memo(({ imageDTO, ...rest }: { imageDTO: ImageDTO } & Butto }); ImageActions.displayName = 'ImageActions'; +const canvasBgSx = { + position: 'relative', + w: 'full', + h: 'full', + borderRadius: 'base', + overflow: 'hidden', + bg: 'base.900', + '&[data-dynamic-grid="true"]': { + bg: 'base.850', + }, +}; + const CanvasActiveSession = memo(() => { const dynamicGrid = useAppSelector(selectDynamicGrid); const showHUD = useAppSelector(selectShowHUD); @@ -886,15 +945,7 @@ const CanvasActiveSession = memo(() => { renderMenu={renderMenu} withLongPress={false}> {(ref) => ( - +