mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-10 21:50:14 -07:00
Image rendering: view on click
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box } from '@mui/joy';
|
||||
|
||||
import { BlocksContainer } from '~/modules/blocks/BlocksContainers';
|
||||
import { RenderImageRefDBlob } from '~/modules/blocks/image/RenderImageRefDBlob';
|
||||
@@ -78,17 +77,15 @@ export function BlockPartImageRef(props: {
|
||||
scaledImageSx={scaledImageSx}
|
||||
variant='content-part'
|
||||
/>
|
||||
) : (
|
||||
<Box>
|
||||
ContentPartImageRef: unknown reftype
|
||||
</Box>
|
||||
)}
|
||||
) : 'BlockPartImageRef: unknown reftype'}
|
||||
|
||||
{/* Image viewer modal */}
|
||||
{!props.disableViewer && viewingImageRefPart && (
|
||||
<ViewImageRefPartModal
|
||||
imageRefPart={viewingImageRefPart}
|
||||
onClose={() => setViewingImageRefPart(null)}
|
||||
onDeleteFragment={onFragmentDelete ? handleDeleteFragment : undefined}
|
||||
onReplaceFragment={onFragmentReplace ? handleReplaceFragment : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,17 +4,18 @@ import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box, Button } from '@mui/joy';
|
||||
import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined';
|
||||
|
||||
import { RenderImageRefDBlob } from '~/modules/blocks/image/RenderImageRefDBlob';
|
||||
import { RenderImageURL } from '~/modules/blocks/image/RenderImageURL';
|
||||
|
||||
import { getImageAsset } from '~/common/stores/blob/dblobs-portability';
|
||||
|
||||
import type { DMessageImageRefPart } from '~/common/stores/chat/chat.fragments';
|
||||
import type { DMessageContentFragment, DMessageImageRefPart } from '~/common/stores/chat/chat.fragments';
|
||||
import { AppBreadcrumbs } from '~/common/components/AppBreadcrumbs';
|
||||
import { GoodModal } from '~/common/components/modals/GoodModal';
|
||||
import { convert_Base64WithMimeType_To_Blob } from '~/common/util/blobUtils';
|
||||
import { downloadBlob } from '~/common/util/downloadUtils';
|
||||
import { useIsMobile } from '~/common/components/useMatchMedia';
|
||||
|
||||
import { BlockPartImageRef } from './BlockPartImageRef';
|
||||
import { AppBreadcrumbs } from '~/common/components/AppBreadcrumbs';
|
||||
|
||||
|
||||
const imageViewerModalSx: SxProps = {
|
||||
maxWidth: '90vw',
|
||||
@@ -28,10 +29,11 @@ const imageViewerContainerSx: SxProps = {
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto',
|
||||
|
||||
// pre-compensate the Block > Render Items 1.5 margin
|
||||
m: -1.5,
|
||||
// pre-compensate the RenderImageRefDBlob > Sheet's 1.5 (BlocksContainer-alike) margin
|
||||
mx: -1.5,
|
||||
// add some margin to unclip the Sheet's shadow
|
||||
'& > div': {
|
||||
pt: 1.5,
|
||||
mb: 0.5,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -39,6 +41,8 @@ const imageViewerContainerSx: SxProps = {
|
||||
export function ViewImageRefPartModal(props: {
|
||||
imageRefPart: DMessageImageRefPart,
|
||||
onClose: () => void,
|
||||
onDeleteFragment?: () => void,
|
||||
onReplaceFragment?: (newFragment: DMessageContentFragment) => void,
|
||||
}) {
|
||||
|
||||
// state
|
||||
@@ -49,7 +53,7 @@ export function ViewImageRefPartModal(props: {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// derived state
|
||||
const { dataRef, altText } = props.imageRefPart;
|
||||
const { dataRef, altText, width, height } = props.imageRefPart;
|
||||
const isDBlob = dataRef.reftype === 'dblob';
|
||||
|
||||
// handlers
|
||||
@@ -133,11 +137,27 @@ export function ViewImageRefPartModal(props: {
|
||||
sx={imageViewerModalSx}
|
||||
>
|
||||
<Box sx={imageViewerContainerSx}>
|
||||
<BlockPartImageRef
|
||||
disableViewer={true /* we're in the Modal, we won't pop this up anymore */}
|
||||
imageRefPart={props.imageRefPart}
|
||||
contentScaling='sm'
|
||||
/>
|
||||
{dataRef.reftype === 'dblob' ? (
|
||||
<RenderImageRefDBlob
|
||||
dataRefDBlobAssetId={dataRef.dblobAssetId}
|
||||
dataRefMimeType={dataRef.mimeType}
|
||||
dataRefBytesSize={dataRef.bytesSize}
|
||||
imageAltText={altText}
|
||||
imageWidth={width}
|
||||
imageHeight={height}
|
||||
onDeleteFragment={props.onDeleteFragment}
|
||||
onReplaceFragment={props.onReplaceFragment}
|
||||
// onViewImage={} we're already viewing the image in the dialog
|
||||
// scaledImageSx={} we reset scale in this dialog
|
||||
variant='content-part'
|
||||
/>
|
||||
) : dataRef.reftype === 'url' ? (
|
||||
<RenderImageURL
|
||||
imageURL={dataRef.url}
|
||||
expandableText={altText}
|
||||
variant='content-part'
|
||||
/>
|
||||
) : 'ViewImageRefPartModal: unknown reftype'}
|
||||
</Box>
|
||||
</GoodModal>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Alert, Box, IconButton, Sheet } from '@mui/joy';
|
||||
import { Box, Sheet } from '@mui/joy';
|
||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import ReplayIcon from '@mui/icons-material/Replay';
|
||||
import WarningRoundedIcon from '@mui/icons-material/WarningRounded';
|
||||
import ZoomOutMapIcon from '@mui/icons-material/ZoomOutMap';
|
||||
|
||||
import { Link } from '~/common/components/Link';
|
||||
|
||||
import type { RenderBlockInputs } from '../blocks.types';
|
||||
import { OverlayButton, overlayButtonsActiveSx, overlayButtonsClassName, overlayButtonsTopRightSx, StyledOverlayButton } from '../OverlayButton';
|
||||
import { OverlayButton, overlayButtonsActiveSx, overlayButtonsClassName, overlayButtonsTopRightSx } from '../OverlayButton';
|
||||
|
||||
|
||||
/// Heuristics to parse Markdown images (as URLs) ///
|
||||
@@ -79,7 +75,6 @@ export const RenderImageURL = (props: {
|
||||
const [loadingTimeout, setLoadingTimeout] = React.useState(false);
|
||||
const [deleteArmed, setDeleteArmed] = React.useState(false);
|
||||
const [regenArmed, setRegenArmed] = React.useState(false);
|
||||
const [showDalleAlert, setShowDalleAlert] = React.useState(true);
|
||||
|
||||
// Effect
|
||||
React.useEffect(() => {
|
||||
@@ -124,177 +119,150 @@ export const RenderImageURL = (props: {
|
||||
setRegenArmed(armed => !armed);
|
||||
}, [handleImageRegenerate, regenArmed]);
|
||||
|
||||
const handleImageClick = React.useCallback((e: React.MouseEvent) => {
|
||||
if (onViewImage) {
|
||||
e.stopPropagation();
|
||||
handleViewImage(e);
|
||||
} else if (props.imageURL?.startsWith('http')) {
|
||||
e.stopPropagation();
|
||||
window.open(props.imageURL, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}, [onViewImage, handleViewImage, props.imageURL]);
|
||||
|
||||
|
||||
// derived state
|
||||
const isCard = props.variant === 'attachment-card';
|
||||
const isImageClickable = !props.disabled && (!!onViewImage || (!!props.imageURL && props.imageURL.startsWith('http')));
|
||||
|
||||
// Only show regeneration in modal context (when not showing a viewer button)
|
||||
const showRegenerate = !!onImageRegenerate && !onViewImage;
|
||||
const isOnButton = props.variant === 'attachment-button';
|
||||
const isTempDalleUrl = props.imageURL?.startsWith('https://oaidalle') || false;
|
||||
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Sheet
|
||||
color={isCard ? 'primary' : undefined}
|
||||
variant={isCard ? 'outlined' : 'solid'}
|
||||
aria-disabled={props.disabled}
|
||||
onClick={props.onClick}
|
||||
className={props.className}
|
||||
sx={{
|
||||
// style
|
||||
mx: isOnButton ? undefined : 1.5, // 1.5 like the other 'Render*' components
|
||||
minWidth: isOnButton ? 20 : 256,
|
||||
minHeight: isOnButton ? 20 : 128,
|
||||
boxShadow: isCard ? undefined : isOnButton ? '0 2px 6px 0 rgba(0, 0, 0, 0.2)' : 'sm',
|
||||
|
||||
<Sheet
|
||||
color={isCard ? 'primary' : undefined}
|
||||
variant={isCard ? 'outlined' : 'solid'}
|
||||
aria-disabled={props.disabled}
|
||||
onClick={props.onClick}
|
||||
className={props.className}
|
||||
sx={{
|
||||
// style
|
||||
mx: isOnButton ? undefined : 1.5, // 1.5 like the other 'Render*' components
|
||||
minWidth: isOnButton ? 20 : 256,
|
||||
minHeight: isOnButton ? 20 : 128,
|
||||
boxShadow: isCard ? undefined : isOnButton ? '0 2px 6px 0 rgba(0, 0, 0, 0.2)' : 'sm',
|
||||
// enable anchoring
|
||||
position: 'relative',
|
||||
|
||||
// enable anchoring
|
||||
position: 'relative',
|
||||
// resizeable image
|
||||
'& picture': { display: 'flex', justifyContent: 'center' },
|
||||
'& img': { maxWidth: '100%', maxHeight: '100%', filter: props.disabled ? 'grayscale(100%)' : undefined },
|
||||
[`&:hover > .${overlayButtonsClassName}`]: overlayButtonsActiveSx,
|
||||
'&:hover .overlay-text': overlayButtonsActiveSx,
|
||||
|
||||
// resizeable image
|
||||
'& picture': { display: 'flex', justifyContent: 'center' },
|
||||
'& img': { maxWidth: '100%', maxHeight: '100%', filter: props.disabled ? 'grayscale(100%)' : undefined },
|
||||
[`&:hover > .${overlayButtonsClassName}`]: overlayButtonsActiveSx,
|
||||
'&:hover .overlay-text': overlayButtonsActiveSx,
|
||||
// layout
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
||||
// layout
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
// this shall apply font scaling and maybe margins, not much
|
||||
...props.scaledImageSx,
|
||||
}}
|
||||
>
|
||||
|
||||
// this shall apply font scaling and maybe margins, not much
|
||||
...props.scaledImageSx,
|
||||
}}
|
||||
>
|
||||
{/* Image and Overlay - clickable to view/maximize */}
|
||||
<Box sx={{ position: 'relative', cursor: isImageClickable ? 'pointer' : undefined }} onClick={isImageClickable ? handleImageClick : undefined}>
|
||||
|
||||
{/* Image and Overlay */}
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
|
||||
{/* Image / Loading Indicator */}
|
||||
{props.imageURL ? (
|
||||
<picture>
|
||||
<img src={props.imageURL} alt={props.expandableText ? `Generated Image: ${props.expandableText}` : 'Generated Image'} />
|
||||
</picture>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
p: { xs: 1, md: 3 },
|
||||
overflowWrap: 'anywhere',
|
||||
whiteSpace: 'break-spaces',
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{loadingTimeout ? 'Image Missing' : 'Loading...'}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* [overlay] Description */}
|
||||
{!!props.overlayText && (
|
||||
<Box className='overlay-text' sx={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: `rgba(0 0 0 / 0.85)`,
|
||||
// backgroundColor: `rgba(${theme.vars.palette.neutral.darkChannel} / 0.85)`,
|
||||
p: { xs: 1, md: 2 },
|
||||
opacity: infoOpen ? 1 : 0,
|
||||
transition: 'opacity 0.16s cubic-bezier(.17,.84,.44,1)',
|
||||
}}>
|
||||
{props.overlayText}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Bottom Expander: information */}
|
||||
{!!props.expandableText && infoOpen && (
|
||||
<Box sx={{
|
||||
p: { xs: 1, md: 2 },
|
||||
overflowWrap: 'anywhere',
|
||||
whiteSpace: 'break-spaces',
|
||||
}}>
|
||||
{props.expandableText}
|
||||
{/* Image / Loading Indicator */}
|
||||
{props.imageURL ? (
|
||||
<picture>
|
||||
<img src={props.imageURL} alt={props.expandableText ? `Generated Image: ${props.expandableText}` : 'Generated Image'} />
|
||||
</picture>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
p: { xs: 1, md: 3 },
|
||||
overflowWrap: 'anywhere',
|
||||
whiteSpace: 'break-spaces',
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{loadingTimeout ? 'Image Missing' : 'Loading...'}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* [overlay] Buttons (RenderImage) */}
|
||||
{!props.disabled && <Box className={overlayButtonsClassName} sx={overlayButtonsGridSx}>
|
||||
{/* [overlay] Description */}
|
||||
{!!props.overlayText && (
|
||||
<Box className='overlay-text' sx={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: `rgba(0 0 0 / 0.85)`,
|
||||
// backgroundColor: `rgba(${theme.vars.palette.neutral.darkChannel} / 0.85)`,
|
||||
p: { xs: 1, md: 2 },
|
||||
opacity: infoOpen ? 1 : 0,
|
||||
transition: 'opacity 0.16s cubic-bezier(.17,.84,.44,1)',
|
||||
}}>
|
||||
{props.overlayText}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{!!props.expandableText && (
|
||||
<OverlayButton tooltip={infoOpen ? 'Hide Prompt' : 'Show Prompt'} variant={infoOpen ? 'solid' : 'outlined'} color={isCard ? 'primary' : undefined} onClick={handleToggleInfoOpen} sx={{ gridRow: '1', gridColumn: '1' }}>
|
||||
<InfoOutlinedIcon />
|
||||
</OverlayButton>
|
||||
)}
|
||||
|
||||
{!!props.imageURL && (
|
||||
props.onViewImage ? (
|
||||
<OverlayButton tooltip='View Image' variant='outlined' color={isCard ? 'primary' : undefined} onClick={handleViewImage} sx={{ gridRow: '1', gridColumn: '2' }}>
|
||||
<ZoomOutMapIcon />
|
||||
</OverlayButton>
|
||||
) : props.imageURL.startsWith('http') ? (
|
||||
<StyledOverlayButton variant='outlined' color={isCard ? 'primary' : undefined} component={Link} href={props.imageURL} download={props.expandableText || 'Image'} target='_blank' sx={{ gridRow: '1', gridColumn: '2' }}>
|
||||
<ZoomOutMapIcon />
|
||||
</StyledOverlayButton>
|
||||
) : <span />
|
||||
)}
|
||||
|
||||
|
||||
{/* Deletion */}
|
||||
|
||||
{deleteArmed && !regenArmed && (
|
||||
<OverlayButton tooltip='Confirm Deletion' placement='bottom' variant='outlined' color='danger' onClick={onImageDelete} sx={{ gridRow: '2', gridColumn: '1' }}>
|
||||
<DeleteForeverIcon sx={{ color: 'danger.solidBg' }} />
|
||||
</OverlayButton>
|
||||
)}
|
||||
|
||||
{!!onImageDelete && !regenArmed && (
|
||||
<OverlayButton tooltip={deleteArmed ? 'Cancel Deletion' : 'Delete Image'} placement='bottom' variant={deleteArmed ? 'solid' : 'outlined'} color={isCard ? 'primary' : undefined} onClick={handleToggleDeleteArmed} sx={{ gridRow: '2', gridColumn: '2' }}>
|
||||
{deleteArmed ? <CloseRoundedIcon /> : <DeleteOutlineIcon />}
|
||||
</OverlayButton>
|
||||
)}
|
||||
|
||||
{!!onImageRegenerate && !deleteArmed && (
|
||||
<OverlayButton tooltip={regenArmed ? 'Cancel Regeneration' : 'Draw again with the present configuration'} placement='bottom' variant={regenArmed ? 'solid' : 'outlined'} onClick={handleToggleRegenArmed} sx={{ gridRow: '2', gridColumn: '1' }}>
|
||||
{regenArmed
|
||||
? <CloseRoundedIcon />
|
||||
: <ReplayIcon />
|
||||
}
|
||||
</OverlayButton>
|
||||
)}
|
||||
|
||||
{/* Regenerate [armed, arming] buttons */}
|
||||
{regenArmed && !deleteArmed && (
|
||||
<OverlayButton tooltip='Confirm Regeneration' placement='bottom' variant='outlined' color='success' onClick={handleImageRegenerate} sx={{ gridRow: '2', gridColumn: '2' }}>
|
||||
<ReplayIcon sx={{ color: 'success.solidBg' }} />
|
||||
</OverlayButton>
|
||||
)}
|
||||
|
||||
</Box>}
|
||||
</Sheet>
|
||||
|
||||
|
||||
{/* (Remove in 2025) Dalle Warning notice */}
|
||||
{isTempDalleUrl && showDalleAlert && (
|
||||
<Alert
|
||||
variant='soft' color='neutral'
|
||||
startDecorator={<WarningRoundedIcon />}
|
||||
endDecorator={
|
||||
<IconButton variant='soft' aria-label='Close Alert' onClick={() => setShowDalleAlert(on => !on)} sx={{ my: -0.5 }}>
|
||||
<CloseRoundedIcon />
|
||||
</IconButton>
|
||||
}
|
||||
sx={{
|
||||
mx: 0.5,
|
||||
...props.scaledImageSx,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>Please Save Locally</strong> · OpenAI will delete this image link from their servers one hour after creation.
|
||||
</div>
|
||||
</Alert>
|
||||
{/* Bottom Expander: information */}
|
||||
{!!props.expandableText && infoOpen && (
|
||||
<Box sx={{
|
||||
p: { xs: 1, md: 2 },
|
||||
overflowWrap: 'anywhere',
|
||||
whiteSpace: 'break-spaces',
|
||||
}}>
|
||||
{props.expandableText}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
{/* [overlay] Buttons (RenderImage) */}
|
||||
{!props.disabled && <Box className={overlayButtonsClassName} sx={overlayButtonsGridSx}>
|
||||
|
||||
{/* Info toggle */}
|
||||
{!!props.expandableText && (
|
||||
<OverlayButton tooltip={infoOpen ? 'Hide Prompt' : 'Show Prompt'} variant={infoOpen ? 'solid' : 'outlined'} color={isCard ? 'primary' : undefined} onClick={handleToggleInfoOpen} sx={{ gridRow: '1', gridColumn: '1' }}>
|
||||
<InfoOutlinedIcon />
|
||||
</OverlayButton>
|
||||
)}
|
||||
|
||||
{/* Delete toggle/cancel */}
|
||||
{!!onImageDelete && !regenArmed && (
|
||||
<OverlayButton tooltip={deleteArmed ? 'Cancel Deletion' : 'Delete Image'} placement='bottom' variant={deleteArmed ? 'solid' : 'outlined'} color={isCard ? 'primary' : undefined} onClick={handleToggleDeleteArmed} sx={{ gridRow: '1', gridColumn: '2' }}>
|
||||
{deleteArmed ? <CloseRoundedIcon /> : <DeleteOutlineIcon />}
|
||||
</OverlayButton>
|
||||
)}
|
||||
|
||||
{/* Delete confirm (armed) */}
|
||||
{deleteArmed && !regenArmed && (
|
||||
<OverlayButton tooltip='Confirm Deletion' placement='bottom' variant='outlined' color='danger' onClick={onImageDelete} sx={{ gridRow: '2', gridColumn: '2' }}>
|
||||
<DeleteForeverIcon sx={{ color: 'danger.solidBg' }} />
|
||||
</OverlayButton>
|
||||
)}
|
||||
|
||||
{/* Regenerate toggle/cancel - only in modal context (click image to view inline) */}
|
||||
{showRegenerate && !deleteArmed && (
|
||||
<OverlayButton tooltip={regenArmed ? 'Cancel Regeneration' : 'Draw again'} placement='bottom' variant={regenArmed ? 'solid' : 'outlined'} onClick={handleToggleRegenArmed} sx={{ gridRow: onImageDelete ? '2' : '1', gridColumn: onImageDelete ? '1' : '2' }}>
|
||||
{regenArmed ? <CloseRoundedIcon /> : <ReplayIcon />}
|
||||
</OverlayButton>
|
||||
)}
|
||||
|
||||
{/* Regenerate confirm (armed) */}
|
||||
{regenArmed && !deleteArmed && (
|
||||
<OverlayButton tooltip='Confirm Regeneration' placement='bottom' variant='outlined' color='success' onClick={handleImageRegenerate} sx={{ gridRow: '2', gridColumn: '2' }}>
|
||||
<ReplayIcon sx={{ color: 'success.solidBg' }} />
|
||||
</OverlayButton>
|
||||
)}
|
||||
|
||||
</Box>}
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user