diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 1f71e39b9ea..61e85cbe2b9 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -2340,7 +2340,8 @@ "next": "Next", "saveToGallery": "Save To Gallery", "showResultsOn": "Showing Results", - "showResultsOff": "Hiding Results" + "showResultsOff": "Hiding Results", + "info": "Info" } }, "upscaling": { diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/SimpleStagingAreaToolbar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/SimpleStagingAreaToolbar.tsx index 3fa5e3e0cab..7c9d5f8721b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/SimpleStagingAreaToolbar.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/SimpleStagingAreaToolbar.tsx @@ -3,6 +3,7 @@ import { SimpleStagingAreaToolbarMenu } from 'features/controlLayers/components/ import { StagingAreaToolbarDiscardAllButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton'; import { StagingAreaToolbarDiscardSelectedButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton'; import { StagingAreaToolbarImageCountButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarImageCountButton'; +import { StagingAreaToolbarInfoButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarInfoButton'; import { StagingAreaToolbarNextButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarNextButton'; import { StagingAreaToolbarPrevButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarPrevButton'; import { memo } from 'react'; @@ -16,6 +17,7 @@ export const SimpleStagingAreaToolbar = memo(() => { + 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 6b57e5cf93d..9fa3f5c9399 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbar.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbar.tsx @@ -6,6 +6,7 @@ import { StagingAreaToolbarAcceptButton } from 'features/controlLayers/component import { StagingAreaToolbarDiscardAllButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton'; import { StagingAreaToolbarDiscardSelectedButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton'; import { StagingAreaToolbarImageCountButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarImageCountButton'; +import { StagingAreaToolbarInfoButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarInfoButton'; import { StagingAreaToolbarMenu } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarMenu'; import { StagingAreaToolbarNextButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarNextButton'; import { StagingAreaToolbarPrevButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarPrevButton'; @@ -42,6 +43,7 @@ export const StagingAreaToolbar = memo(() => { + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarInfoButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarInfoButton.tsx new file mode 100644 index 00000000000..2d8b598ee35 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarInfoButton.tsx @@ -0,0 +1,172 @@ +import { + Divider, + Grid, + IconButton, + Popover, + PopoverBody, + PopoverContent, + PopoverTrigger, + Text, + VStack, +} from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; +import { MetadataItem } from 'features/metadata/components/MetadataItem'; +import { MetadataLoRAs } from 'features/metadata/components/MetadataLoRAs'; +import { useMetadataExtraction } from 'features/metadata/hooks/useMetadataExtraction'; +import { handlers } from 'features/metadata/util/handlers'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiInfoBold } from 'react-icons/pi'; + +export const StagingAreaToolbarInfoButton = memo(({ isDisabled }: { isDisabled?: boolean }) => { + const ctx = useCanvasSessionContext(); + const selectedItem = useStore(ctx.$selectedItem); + const { t } = useTranslation(); + + // Extract metadata using the unified hook + const metadata = useMetadataExtraction(selectedItem); + + if (!selectedItem) { + return ( + } + colorScheme="invokeBlue" + isDisabled={true} + /> + ); + } + + return ( + + + } + colorScheme="invokeBlue" + isDisabled={isDisabled} + /> + + + + + {/* Prompts Section */} + + + Prompts + + + {metadata !== null && ( + <> + + + + )} + + + + + {/* Models and LoRAs Section - Left Column */} + + + {/* Model Section */} + + + Model + + + {metadata !== null && ( + + + + + )} + + + {/* LoRA Section */} + {metadata !== null && } + + + {/* Other Settings Section - Right Column */} + + + Other Settings + + + {metadata !== null && ( + + + + + + + )} + + + + {/* Error Section */} + {selectedItem.error_message && ( + <> + + + + Error + + + {selectedItem.error_message} + + + + )} + + + + + ); +}); + +StagingAreaToolbarInfoButton.displayName = 'StagingAreaToolbarInfoButton'; diff --git a/invokeai/frontend/web/src/features/metadata/components/MetadataItem.tsx b/invokeai/frontend/web/src/features/metadata/components/MetadataItem.tsx index cfa3ee3df34..7ee70596d8c 100644 --- a/invokeai/frontend/web/src/features/metadata/components/MetadataItem.tsx +++ b/invokeai/frontend/web/src/features/metadata/components/MetadataItem.tsx @@ -9,29 +9,58 @@ type MetadataItemProps = { metadata: unknown; handlers: MetadataHandlers; direction?: 'row' | 'column'; + /** Display mode for the metadata item */ + displayMode?: 'default' | 'badge' | 'simple' | 'card'; + /** Color scheme for badge display mode */ + colorScheme?: string; + /** Whether to show copy functionality */ + showCopy?: boolean; + /** Whether to show recall functionality */ + showRecall?: boolean; }; -const _MetadataItem = typedMemo(({ metadata, handlers, direction = 'row' }: MetadataItemProps) => { - const { label, isDisabled, value, renderedValue, onRecall } = useMetadataItem(metadata, handlers); +const _MetadataItem = typedMemo( + ({ + metadata, + handlers, + direction = 'row', + displayMode = 'default', + colorScheme = 'invokeBlue', + showCopy = false, + showRecall = true, + }: MetadataItemProps) => { + const { label, isDisabled, value, renderedValue, onRecall, valueOrNull } = useMetadataItem(metadata, handlers); - if (value === MetadataParseFailedToken) { - return null; - } + if (value === MetadataParseFailedToken) { + return null; + } - if (handlers.getIsVisible && !isSymbol(value) && !handlers.getIsVisible(value)) { - return null; - } + if (handlers.getIsVisible && !isSymbol(value) && !handlers.getIsVisible(value)) { + return null; + } - return ( - - ); -}); + // For display modes other than default, we need the raw value for copy functionality + if (displayMode !== 'default') { + if (!valueOrNull) { + return null; + } + } + + return ( + + ); + } +); export const MetadataItem = typedMemo(_MetadataItem); diff --git a/invokeai/frontend/web/src/features/metadata/components/MetadataItemView.tsx b/invokeai/frontend/web/src/features/metadata/components/MetadataItemView.tsx index 233e427798d..9aa1a8a6060 100644 --- a/invokeai/frontend/web/src/features/metadata/components/MetadataItemView.tsx +++ b/invokeai/frontend/web/src/features/metadata/components/MetadataItemView.tsx @@ -1,28 +1,229 @@ -import { Flex, Text } from '@invoke-ai/ui-library'; +import { Badge, Flex, HStack, IconButton, Text, Tooltip, VStack } from '@invoke-ai/ui-library'; +import { useClipboard } from 'common/hooks/useClipboard'; import { RecallButton } from 'features/metadata/components/RecallButton'; -import { memo } from 'react'; +import { memo, useCallback } from 'react'; +import { PiCopyBold } from 'react-icons/pi'; type MetadataItemViewProps = { - onRecall: () => void; + onRecall?: () => void; label: string; renderedValue: React.ReactNode; isDisabled: boolean; direction?: 'row' | 'column'; + /** Display mode for the metadata item */ + displayMode?: 'default' | 'badge' | 'simple' | 'card'; + /** Color scheme for badge display mode */ + colorScheme?: string; + /** Whether to show copy functionality */ + showCopy?: boolean; + /** Raw value for copy functionality */ + valueOrNull?: unknown; }; export const MetadataItemView = memo( - ({ label, onRecall, isDisabled, renderedValue, direction = 'row' }: MetadataItemViewProps) => { - return ( - - {onRecall && } - - - {label}: - - {renderedValue} + ({ + label, + onRecall, + isDisabled, + renderedValue, + direction = 'row', + displayMode = 'default', + colorScheme = 'invokeBlue', + showCopy = false, + valueOrNull, + }: MetadataItemViewProps) => { + const clipboard = useClipboard(); + + const handleCopy = useCallback(() => { + if (valueOrNull !== null) { + clipboard.writeText(String(valueOrNull)); + } + }, [clipboard, valueOrNull]); + + // Default display mode (original behavior) + if (displayMode === 'default') { + return ( + + {onRecall && } + + + {label}: + + {renderedValue} + - - ); + ); + } + + // Card display mode (for prompts) + if (displayMode === 'card') { + return ( + + + {label} + + + + {renderedValue} + + + {showCopy && ( + + } + onClick={handleCopy} + colorScheme="base" + variant="ghost" + aria-label={`Copy ${label} to clipboard`} + /> + + )} + {onRecall && } + + + + ); + } + + // Simple display mode (for seed, steps, etc.) + if (displayMode === 'simple') { + return ( + + + {label} + + + + {renderedValue} + + + {showCopy && ( + + } + onClick={handleCopy} + colorScheme="base" + variant="ghost" + aria-label={`Copy ${label} to clipboard`} + /> + + )} + {onRecall && } + + + + ); + } + + // Badge display mode (for models, etc.) + if (displayMode === 'badge') { + return ( + + + {label} + + + + {renderedValue} + + + {showCopy && ( + + } + onClick={handleCopy} + colorScheme="base" + variant="ghost" + aria-label={`Copy ${label} to clipboard`} + /> + + )} + {onRecall && } + + + + ); + } + + return null; } ); diff --git a/invokeai/frontend/web/src/features/metadata/components/MetadataLoRAs.tsx b/invokeai/frontend/web/src/features/metadata/components/MetadataLoRAs.tsx index 1b184979898..feced56ad8e 100644 --- a/invokeai/frontend/web/src/features/metadata/components/MetadataLoRAs.tsx +++ b/invokeai/frontend/web/src/features/metadata/components/MetadataLoRAs.tsx @@ -1,14 +1,24 @@ +import { Badge, HStack, IconButton, Text, Tooltip, VStack } from '@invoke-ai/ui-library'; +import { useClipboard } from 'common/hooks/useClipboard'; import type { LoRA } from 'features/controlLayers/store/types'; import { MetadataItemView } from 'features/metadata/components/MetadataItemView'; +import { RecallButton } from 'features/metadata/components/RecallButton'; import type { MetadataHandlers } from 'features/metadata/types'; import { handlers } from 'features/metadata/util/handlers'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { PiCopyBold } from 'react-icons/pi'; type Props = { metadata: unknown; + /** Display mode for LoRA items */ + displayMode?: 'default' | 'badge'; + /** Whether to show copy functionality */ + showCopy?: boolean; + /** Whether to show recall functionality */ + showRecall?: boolean; }; -export const MetadataLoRAs = ({ metadata }: Props) => { +export const MetadataLoRAs = ({ metadata, displayMode = 'default', showCopy = false, showRecall = true }: Props) => { const [loras, setLoRAs] = useState([]); useEffect(() => { @@ -25,32 +35,72 @@ export const MetadataLoRAs = ({ metadata }: Props) => { const label = useMemo(() => handlers.loras.getLabel(), []); - return ( - <> - {loras.map((lora) => ( - - ))} - - ); + // Default display mode (original behavior) + if (displayMode === 'default') { + return ( + <> + {loras.map((lora) => ( + + ))} + + ); + } + + // Badge display mode (for staging area) + if (displayMode === 'badge') { + if (!loras || loras.length === 0) { + return null; + } + + return ( + + + LoRAs + + + {loras.map((lora: LoRA, index: number) => ( + + ))} + + + ); + } + + return null; }; const MetadataViewLoRA = ({ label, lora, handlers, + showRecall = true, }: { label: string; lora: LoRA; handlers: MetadataHandlers; + showRecall?: boolean; }) => { const onRecall = useCallback(() => { - if (!handlers.recallItem) { + if (!handlers.recallItem || !showRecall) { return; } handlers.recallItem(lora, true).catch(() => { // no-op, the toast will show the error }); - }, [handlers, lora]); + }, [handlers, lora, showRecall]); const [renderedValue, setRenderedValue] = useState(null); useEffect(() => { @@ -66,5 +116,120 @@ const MetadataViewLoRA = ({ _renderValue(); }, [handlers, lora]); - return ; + return ( + + ); +}; + +const BadgeLoRA = ({ + lora, + index, + handlers, + showCopy = false, + showRecall = true, +}: { + lora: LoRA; + index: number; + handlers: MetadataHandlers; + showCopy?: boolean; + showRecall?: boolean; +}) => { + const [renderedValue, setRenderedValue] = useState(null); + const _clipboard = useClipboard(); + + useEffect(() => { + const _renderValue = async () => { + if (!handlers.renderItemValue) { + setRenderedValue(`${lora.model.key} - ${lora.weight}`); + return; + } + try { + const rendered = await handlers.renderItemValue(lora); + setRenderedValue(rendered); + } catch { + setRenderedValue(`${lora.model.key} - ${lora.weight}`); + } + }; + + _renderValue(); + }, [handlers, lora]); + + const handleCopy = useCallback(() => { + _clipboard.writeText(`${lora.model.key} - ${lora.weight}`); + }, [_clipboard, lora]); + + const onRecall = useCallback(() => { + if (!handlers.recallItem || !showRecall) { + return; + } + handlers.recallItem(lora, true).catch(() => { + // no-op, the toast will show the error + }); + }, [handlers, lora, showRecall]); + + return ( + + + LoRA {index + 1} + + + + {renderedValue} + + + {showCopy && ( + + } + onClick={handleCopy} + colorScheme="base" + variant="ghost" + aria-label={`Copy LoRA ${index + 1} to clipboard`} + /> + + )} + {showRecall && handlers.recallItem && ( + + )} + + + + ); }; diff --git a/invokeai/frontend/web/src/features/metadata/hooks/useMetadataExtraction.ts b/invokeai/frontend/web/src/features/metadata/hooks/useMetadataExtraction.ts new file mode 100644 index 00000000000..9bf3e208827 --- /dev/null +++ b/invokeai/frontend/web/src/features/metadata/hooks/useMetadataExtraction.ts @@ -0,0 +1,13 @@ +import { extractMetadata } from 'features/metadata/util/metadataExtraction'; +import { useMemo } from 'react'; + +/** + * Hook for extracting metadata from different data structures + * @param data The data object that might contain metadata + * @returns The extracted metadata or null if not found + */ +export const useMetadataExtraction = (data: unknown): unknown => { + return useMemo(() => { + return extractMetadata(data); + }, [data]); +}; diff --git a/invokeai/frontend/web/src/features/metadata/util/metadataExtraction.ts b/invokeai/frontend/web/src/features/metadata/util/metadataExtraction.ts new file mode 100644 index 00000000000..9b46c5added --- /dev/null +++ b/invokeai/frontend/web/src/features/metadata/util/metadataExtraction.ts @@ -0,0 +1,89 @@ +/** + * Utility functions for extracting metadata from different sources + */ + +/** + * Type guard to check if an object has a session property + */ +const hasSession = (data: unknown): data is { session: { graph?: { nodes?: Record } } } => { + return ( + data !== null && + typeof data === 'object' && + 'session' in data && + typeof (data as Record).session === 'object' && + (data as Record).session !== null + ); +}; + +/** + * Type guard to check if an object has a metadata property + */ +const hasMetadata = (data: unknown): data is { metadata: unknown } => { + return data !== null && typeof data === 'object' && 'metadata' in data; +}; + +/** + * Extracts metadata from a session graph + * @param session The session object containing the graph + * @returns The extracted metadata or null if not found + */ +export const extractMetadataFromSession = ( + session: { graph?: { nodes?: Record } } | null +): unknown => { + if (!session?.graph?.nodes) { + return null; + } + + // Find the metadata node (core_metadata with unique suffix) + const nodeKeys = Object.keys(session.graph.nodes); + const metadataNodeKey = nodeKeys.find((key) => key.startsWith('core_metadata:')); + + if (!metadataNodeKey) { + return null; + } + + return session.graph.nodes[metadataNodeKey]; +}; + +/** + * Extracts metadata from an image DTO + * @param image The image DTO object + * @returns The extracted metadata or null if not found + */ +export const extractMetadataFromImage = (image: { metadata?: unknown } | null): unknown => { + return image?.metadata || null; +}; + +/** + * Generic metadata extraction that works with different data structures + * @param data The data object that might contain metadata + * @returns The extracted metadata or null if not found + */ +export const extractMetadata = (data: unknown): unknown => { + if (!data || typeof data !== 'object') { + return null; + } + + // Try to extract from session graph + if (hasSession(data)) { + const sessionMetadata = extractMetadataFromSession(data.session); + if (sessionMetadata) { + return sessionMetadata; + } + } + + // Try to extract from image DTO + if (hasMetadata(data)) { + const imageMetadata = extractMetadataFromImage(data); + if (imageMetadata) { + return imageMetadata; + } + } + + // If the data itself looks like metadata, return it + if (data && typeof data === 'object' && Object.keys(data).length > 0) { + return data; + } + + return null; +};