Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
import type { FlexProps } from '@invoke-ai/ui-library';
import { Box, chakra, Flex, IconButton, Tooltip, useShiftModifier } from '@invoke-ai/ui-library';
import {
Box,
chakra,
Flex,
IconButton,
Input,
InputGroup,
InputRightElement,
Tooltip,
useShiftModifier,
} from '@invoke-ai/ui-library';
import { getOverlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants';
import { useClipboard } from 'common/hooks/useClipboard';
import { isString } from 'es-toolkit/compat';
import { Formatter, TableCommaPlacement } from 'fracturedjsonjs';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import type { CSSProperties } from 'react';
import { memo, useCallback, useMemo } from 'react';
import type { ChangeEvent, CSSProperties } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCopyBold, PiDownloadSimpleBold } from 'react-icons/pi';
import { PiCopyBold, PiDownloadSimpleBold, PiXBold } from 'react-icons/pi';

const formatter = new Formatter();
formatter.Options.TableCommaPlacement = TableCommaPlacement.BeforePadding;
Expand All @@ -22,6 +32,10 @@ type Props = {
withCopy?: boolean;
extraCopyActions?: { label: string; getData: (data: unknown) => unknown }[];
wrapData?: boolean;
withSearch?: boolean;
searchTerm?: string;
onSearchTermChange?: (value: string) => void;
showSearchInput?: boolean;
} & FlexProps;

const overlayscrollbarsOptions = getOverlayScrollbarsParams({
Expand All @@ -40,11 +54,18 @@ const DataViewer = (props: Props) => {
withCopy = true,
extraCopyActions,
wrapData = true,
withSearch = false,
searchTerm: searchTermProp,
onSearchTermChange,
showSearchInput = true,
...rest
} = props;
const dataString = useMemo(() => (isString(data) ? data : formatter.Serialize(data)) ?? '', [data]);
const shift = useShiftModifier();
const clipboard = useClipboard();
const [internalSearchTerm, setInternalSearchTerm] = useState('');
const isControlledSearch = searchTermProp !== undefined;
const searchTerm = isControlledSearch ? searchTermProp : internalSearchTerm;
const handleCopy = useCallback(() => {
clipboard.writeText(dataString);
}, [clipboard, dataString]);
Expand All @@ -61,14 +82,74 @@ const DataViewer = (props: Props) => {

const { t } = useTranslation();

const highlightedDataString = useMemo(() => {
if (!searchTerm) {
return dataString;
}

const regex = new RegExp(`(${escapeRegExp(searchTerm)})`, 'gi');
const parts = dataString.split(regex);

return parts.map((part, index) => {
const isMatch = index % 2 === 1;
if (!isMatch) {
return <span key={index}>{part}</span>;
}
return (
<chakra.mark key={index} bg="accent.700" color="accent.50" px={1} borderRadius="sm">
{part}
</chakra.mark>
);
});
}, [dataString, searchTerm]);

const setSearchTerm = useCallback(
(value: string) => {
if (isControlledSearch) {
onSearchTermChange?.(value);
return;
}
setInternalSearchTerm(value);
},
[isControlledSearch, onSearchTermChange]
);

const handleChangeSearch = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value);
},
[setSearchTerm]
);

const handleClearSearch = useCallback(() => {
setSearchTerm('');
}, [setSearchTerm]);

return (
<Flex bg="base.800" borderRadius="base" flexGrow={1} w="full" h="full" position="relative" {...rest}>
<Box position="absolute" top={0} left={0} right={0} bottom={0} overflow="auto" p={2} fontSize="sm">
<OverlayScrollbarsComponent defer style={overlayScrollbarsStyles} options={overlayscrollbarsOptions}>
<ChakraPre whiteSpace={wrapData ? 'pre-wrap' : undefined}>{dataString}</ChakraPre>
<ChakraPre whiteSpace={wrapData ? 'pre-wrap' : undefined}>{highlightedDataString}</ChakraPre>
</OverlayScrollbarsComponent>
</Box>
<Flex position="absolute" top={0} insetInlineEnd={0} p={2}>
<Flex position="absolute" top={0} insetInlineEnd={0} p={2} gap={2} alignItems="center">
{withSearch && showSearchInput && (
<InputGroup size="sm" w={48}>
<Input placeholder={t('common.search')} value={searchTerm} onChange={handleChangeSearch} />
{searchTerm && (
<InputRightElement h="full" pe={2}>
<IconButton
aria-label={t('boards.clearSearch')}
icon={<PiXBold size={16} />}
variant="link"
opacity={0.7}
onClick={handleClearSearch}
size="sm"
/>
</InputRightElement>
)}
</InputGroup>
)}
{withDownload && (
<Tooltip label={`${t('gallery.download')} ${label} JSON`}>
<IconButton
Expand Down Expand Up @@ -131,3 +212,5 @@ const ExtraCopyAction = ({ label, data, getData }: ExtraCopyActionProps) => {
</Tooltip>
);
};

const escapeRegExp = (value: string) => value.replace(/[-/\\^$*+?.()|[\]{}]/g, '$&');
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import DataViewer from './DataViewer';

type Props = {
image: ImageDTO;
searchTerm?: string;
showSearchInput?: boolean;
};

const ImageMetadataGraphTabContent = ({ image }: Props) => {
const ImageMetadataGraphTabContent = ({ image, searchTerm, showSearchInput }: Props) => {
const { t } = useTranslation();
const { currentData } = useDebouncedImageWorkflow(image);
const graph = useMemo(() => {
Expand All @@ -29,7 +31,14 @@ const ImageMetadataGraphTabContent = ({ image }: Props) => {
}

return (
<DataViewer fileName={`${image.image_name.replace('.png', '')}_graph`} data={graph} label={t('nodes.graph')} />
<DataViewer
fileName={`${image.image_name.replace('.png', '')}_graph`}
data={graph}
label={t('nodes.graph')}
withSearch
searchTerm={searchTerm}
showSearchInput={showSearchInput}
/>
);
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
import { ExternalLink, Flex, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library';
import {
ExternalLink,
Flex,
IconButton,
Input,
InputGroup,
InputRightElement,
Tab,
TabList,
TabPanel,
TabPanels,
Tabs,
} from '@invoke-ai/ui-library';
import { IAINoContentFallback, IAINoContentFallbackWithSpinner } from 'common/components/IAIImageFallback';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import ImageMetadataGraphTabContent from 'features/gallery/components/ImageMetadataViewer/ImageMetadataGraphTabContent';
import { ImageMetadataHandlers } from 'features/metadata/parsing';
import { memo } from 'react';
import type { ChangeEvent } from 'react';
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PiXBold } from 'react-icons/pi';
import { useDebouncedMetadata } from 'services/api/hooks/useDebouncedMetadata';
import type { ImageDTO } from 'services/api/types';

Expand All @@ -16,6 +30,16 @@ type ImageMetadataViewerProps = {
image: ImageDTO;
};

const CODE_TAB_PADDING_INLINE = 18;
const TAB_INDEX = {
recall: 0,
metadata: 1,
imageDetails: 2,
workflow: 3,
graph: 4,
} as const;
const TAB_COUNT = Object.keys(TAB_INDEX).length;

const ImageMetadataViewer = ({ image }: ImageMetadataViewerProps) => {
// TODO: fix hotkeys
// const dispatch = useAppDispatch();
Expand All @@ -25,11 +49,40 @@ const ImageMetadataViewer = ({ image }: ImageMetadataViewerProps) => {
const { t } = useTranslation();

const { metadata, isLoading } = useDebouncedMetadata(image.image_name);
const [activeTabIndex, setActiveTabIndex] = useState(0);
const [searchTerms, setSearchTerms] = useState<string[]>(() => Array(TAB_COUNT).fill(''));
const isSearchableTab = activeTabIndex !== TAB_INDEX.recall;
const activeSearchTerm = searchTerms[activeTabIndex] ?? '';

const handleTabChange = useCallback((index: number) => {
setActiveTabIndex(index);
}, []);

const handleChangeSearch = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setSearchTerms((prev) => {
const next = [...prev];
next[activeTabIndex] = value;
return next;
});
},
[activeTabIndex]
);

const handleClearSearch = useCallback(() => {
setSearchTerms((prev) => {
const next = [...prev];
next[activeTabIndex] = '';
return next;
});
}, [activeTabIndex]);

return (
<Flex
layerStyle="first"
padding={4}
paddingInline={16}
gap={1}
flexDirection="column"
width="full"
Expand All @@ -41,14 +94,42 @@ const ImageMetadataViewer = ({ image }: ImageMetadataViewerProps) => {
<ExternalLink href={image.image_url} label={image.image_name} />
<UnrecallableMetadataDatum metadata={metadata} handler={ImageMetadataHandlers.CreatedBy} />

<Tabs variant="line" isLazy={true} display="flex" flexDir="column" w="full" h="full">
<TabList>
<Tab>{t('metadata.recallParameters')}</Tab>
<Tab>{t('metadata.metadata')}</Tab>
<Tab>{t('metadata.imageDetails')}</Tab>
<Tab>{t('metadata.workflow')}</Tab>
<Tab>{t('nodes.graph')}</Tab>
</TabList>
<Tabs
variant="line"
isLazy={true}
display="flex"
flexDir="column"
w="full"
h="full"
index={activeTabIndex}
onChange={handleTabChange}
>
<Flex alignItems="flex-start" gap={2} borderBottomWidth="1px" borderColor="base.600">
<TabList flex="1" pb={2} borderBottom="none">
<Tab>{t('metadata.recallParameters')}</Tab>
<Tab>{t('metadata.metadata')}</Tab>
<Tab>{t('metadata.imageDetails')}</Tab>
<Tab>{t('metadata.workflow')}</Tab>
<Tab>{t('nodes.graph')}</Tab>
</TabList>
{isSearchableTab && (
<InputGroup size="sm" w={48} me={6}>
<Input placeholder={t('common.search')} value={activeSearchTerm} onChange={handleChangeSearch} />
{activeSearchTerm && (
<InputRightElement h="full" pe={2}>
<IconButton
aria-label={t('boards.clearSearch')}
icon={<PiXBold size={16} />}
variant="link"
opacity={0.7}
onClick={handleClearSearch}
size="sm"
/>
</InputRightElement>
)}
</InputGroup>
)}
</Flex>

<TabPanels>
<TabPanel>
Expand All @@ -62,31 +143,53 @@ const ImageMetadataViewer = ({ image }: ImageMetadataViewerProps) => {
</TabPanel>
<TabPanel>
{metadata ? (
<DataViewer
fileName={`${image.image_name.replace('.png', '')}_metadata`}
data={metadata}
label={t('metadata.metadata')}
/>
<Flex w="full" h="full" paddingInline={CODE_TAB_PADDING_INLINE}>
<DataViewer
fileName={`${image.image_name.replace('.png', '')}_metadata`}
data={metadata}
label={t('metadata.metadata')}
withSearch
searchTerm={searchTerms[TAB_INDEX.metadata]}
showSearchInput={false}
/>
</Flex>
) : (
<IAINoContentFallback label={t('metadata.noMetaData')} />
)}
</TabPanel>
<TabPanel>
{image ? (
<DataViewer
fileName={`${image.image_name.replace('.png', '')}_details`}
data={image}
label={t('metadata.imageDetails')}
/>
<Flex w="full" h="full" paddingInline={CODE_TAB_PADDING_INLINE}>
<DataViewer
fileName={`${image.image_name.replace('.png', '')}_details`}
data={image}
label={t('metadata.imageDetails')}
withSearch
searchTerm={searchTerms[TAB_INDEX.imageDetails]}
showSearchInput={false}
/>
</Flex>
) : (
<IAINoContentFallback label={t('metadata.noImageDetails')} />
)}
</TabPanel>
<TabPanel>
<ImageMetadataWorkflowTabContent image={image} />
<Flex w="full" h="full" paddingInline={CODE_TAB_PADDING_INLINE}>
<ImageMetadataWorkflowTabContent
image={image}
searchTerm={searchTerms[TAB_INDEX.workflow]}
showSearchInput={false}
/>
</Flex>
</TabPanel>
<TabPanel>
<ImageMetadataGraphTabContent image={image} />
<Flex w="full" h="full" paddingInline={CODE_TAB_PADDING_INLINE}>
<ImageMetadataGraphTabContent
image={image}
searchTerm={searchTerms[TAB_INDEX.graph]}
showSearchInput={false}
/>
</Flex>
</TabPanel>
</TabPanels>
</Tabs>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import DataViewer from './DataViewer';

type Props = {
image: ImageDTO;
searchTerm?: string;
showSearchInput?: boolean;
};

const ImageMetadataWorkflowTabContent = ({ image }: Props) => {
const ImageMetadataWorkflowTabContent = ({ image, searchTerm, showSearchInput }: Props) => {
const { t } = useTranslation();
const { currentData } = useDebouncedImageWorkflow(image);
const workflow = useMemo(() => {
Expand All @@ -33,6 +35,9 @@ const ImageMetadataWorkflowTabContent = ({ image }: Props) => {
fileName={`${image.image_name.replace('.png', '')}_workflow`}
data={workflow}
label={t('metadata.workflow')}
withSearch
searchTerm={searchTerm}
showSearchInput={showSearchInput}
/>
);
};
Expand Down
Loading