Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Expand Up @@ -14,7 +14,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
Expand All @@ -31,16 +31,22 @@ import { listItemIconClasses } from '@mui/material/ListItemIcon';
import Menu from '@mui/material/Menu';
import { useImageInfo } from '../../../hooks/useImageInfo';
import { svgIconClasses } from '@mui/material/SvgIcon';
import { ensureSingleSlash } from '../../../utils/string';
import { isExternalMediaUrl, resolveMediaUrl } from '../../../utils/string';
import { useDispatch } from 'react-redux';
import Tooltip from '@mui/material/Tooltip';
import { downloadMedia, getImageRestrictionMessages, showImageCropDialog } from '../lib/controlHelpers';
import Alert from '@mui/material/Alert';
import {
downloadMedia,
getImageRestrictionMessages,
ImageRestrictionSubtitle,
showImageCropDialog
} from '../lib/controlHelpers';
import type { ImageRestrictions } from '../../ImageEditorDialog/types';
import Skeleton from '@mui/material/Skeleton';
import { nnou, nou } from '../../../utils/object';
import { validateImageRestrictions } from '../../../utils/content';
import GroupedDataSourceActionMenuItems from '../components/GroupedDataSourceActionMenuItems';
import type { DataSourceSelection } from '../dataSources/types';
import type { DataSourceAssetSelection, DataSourceSelection } from '../dataSources/types';
import { showSystemNotification } from '../../../state/actions/system';
import { EmptyState } from '../../EmptyState';

Expand Down Expand Up @@ -68,16 +74,18 @@ export function ImagePicker(props: ImagePickerProps) {
// endregion

const value = nnou(valueProp) ? valueProp : (defaultValue ?? '');
const { imageInfo, isFetchingDimensions, isFetchingMetadata, errorDimensions, errorMetadata } = useImageInfo(
value ? ensureSingleSlash(`${guestBase}${value}`) : ''
);
const mediaUrl = value ? resolveMediaUrl(guestBase, value) : '';
const { imageInfo, isFetchingDimensions, isFetchingMetadata, errorDimensions, errorMetadata } =
useImageInfo(mediaUrl);
const hasValue = Boolean(value);
const actions = dataSources?.actions ?? [];
const dataSourcesLoading = dataSources?.status === 'loading';
const dataSourcesError = dataSources?.status === 'error';
const actionsReady = Boolean(dataSources?.context) && actions.length > 0 && !dataSourcesLoading;
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [addMenuOpen, setAddMenuOpen] = useState(false);
const [rejectedExternalUrl, setRejectedExternalUrl] = useState<string | null>(null);
Comment thread
jvega190 marked this conversation as resolved.
const selectionRequestRef = useRef(0);

useEffect(() => {
// If there's a default value and no value has been set yet, set it as the value.
Expand All @@ -88,6 +96,7 @@ export function ImagePicker(props: ImagePickerProps) {

const imageRestrictionMessages = getImageRestrictionMessages(restrictions);
const applySelection = (selection: DataSourceSelection | DataSourceSelection[] | null) => {
const requestId = ++selectionRequestRef.current;
const selected = Array.isArray(selection) ? selection[0] : selection;
const path =
selected?.kind === 'asset' && typeof selected.relativeUrl === 'string'
Expand All @@ -96,9 +105,17 @@ export function ImagePicker(props: ImagePickerProps) {
? selected.path
: undefined;
if (!path) return;
validateImageRestrictions(path, restrictions)
setRejectedExternalUrl(null);
validateImageRestrictions(path, restrictions, (selected as DataSourceAssetSelection).mimeType)
.then((meetsRestrictions) => {
if (!meetsRestrictions) {
if (requestId !== selectionRequestRef.current) return;
if (meetsRestrictions) {
setValue(path);
} else if (isExternalMediaUrl(path)) {
// Cropping requires writing the result to a site path, which isn't possible for an external URL. The
// crop would be discarded and the field would keep the offending URL, so reject the selection instead.
setRejectedExternalUrl(path);
} else {
showImageCropDialog({
dispatch,
path,
Expand All @@ -108,11 +125,10 @@ export function ImagePicker(props: ImagePickerProps) {
writeContent: true,
onCrop: (_blob: Blob, newPath: string) => setValue(newPath ?? path)
});
} else {
setValue(path);
}
})
.catch(() => {
if (requestId !== selectionRequestRef.current) return;
dispatch(
showSystemNotification({
message: formatMessage({ defaultMessage: 'Unable to validate image restrictions.' })
Expand All @@ -132,6 +148,8 @@ export function ImagePicker(props: ImagePickerProps) {
) : null;

const handleRemoveImage = () => {
selectionRequestRef.current += 1;
setRejectedExternalUrl(null);
setValue(null);
};

Expand All @@ -147,15 +165,25 @@ export function ImagePicker(props: ImagePickerProps) {
>
{actionMenuItems}
</Menu>
<FormsEngineField field={field}>
<FormsEngineField field={field} isValid={rejectedExternalUrl ? false : undefined}>
{rejectedExternalUrl && (
<Alert
severity="error"
variant="outlined"
sx={{ border: 'none' }}
onClose={() => setRejectedExternalUrl(null)}
>
<FormattedMessage
defaultMessage="The image at {url} was not applied."
values={{ url: rejectedExternalUrl }}
/>{' '}
<ImageRestrictionSubtitle restrictions={restrictions} />{' '}
<FormattedMessage defaultMessage="External images can't be cropped. Select an image that meets the requirements." />
</Alert>
)}
{hasValue ? (
<Card sx={{ display: 'flex' }}>
<CardMedia
component="img"
sx={{ width: '40%' }}
image={`${guestBase}${value}`}
alt="Live from space album cover"
/>
<CardMedia component="img" sx={{ width: '40%' }} image={mediaUrl} alt="" />
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<CardContent sx={{ flex: '1 0 auto' }}>
<Typography component="div" variant="body1" marginBottom={1}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import { DeleteOutlined, DownloadOutlined, EditOutlined } from '@mui/icons-material';
import { svgIconClasses } from '@mui/material';
import { ensureSingleSlash } from '../../../utils/string';
import { resolveMediaUrl } from '../../../utils/string';
import useVideoInfo from '../../../hooks/useVideoInfo';
import Skeleton from '@mui/material/Skeleton';
import { downloadMedia } from '../lib/controlHelpers';
Expand All @@ -47,13 +47,11 @@ export interface VideoPickerProps extends ControlProps {
export function VideoPicker(props: VideoPickerProps) {
const { field, value, setValue, readonly: formReadonly, dataSources } = props;
const { guestBase } = useEnv();
// TODO: For testing, by using 3000 as the guestBase both the fetch in `useImageInfo` and the download functionality will work
// const guestBase = 'http://localhost:3000';
const hasValue = Boolean(value);
const mediaUrl = value ? resolveMediaUrl(guestBase, value) : '';
const { formatMessage } = useIntl();
const { videoInfo, isFetchingMetadata, isFetchingDimensions, errorDimensions, errorMetadata } = useVideoInfo(
value ? ensureSingleSlash(`${guestBase}${value}`) : ''
);
const { videoInfo, isFetchingMetadata, isFetchingDimensions, errorDimensions, errorMetadata } =
useVideoInfo(mediaUrl);
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const [addMenuOpen, setAddMenuOpen] = useState(false);

Expand Down Expand Up @@ -99,7 +97,7 @@ export function VideoPicker(props: VideoPickerProps) {
<FormsEngineField field={field}>
{hasValue ? (
<Card sx={{ display: 'flex' }}>
<CardMedia component="video" sx={{ width: '40%' }} image={ensureSingleSlash(`${guestBase}${value}`)} />
<CardMedia component="video" sx={{ width: '40%' }} image={mediaUrl} />
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<CardContent sx={{ flex: '1 0 auto' }}>
<Typography component="div" variant="body1" marginBottom={1}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import ContentType from '../../../models/ContentType';
import FormsEngineField from '../components/FormsEngineField';
import { FormsEngineAtoms, ItemContext, ItemMetaContext, StableGlobalContext } from './formsEngineContext';
import { getFileNameFromPath } from '../../../utils/path';
import { ensureSingleSlash } from '../../../utils/string';
import { isExternalMediaUrl, resolveMediaUrl } from '../../../utils/string';
import { Dispatch as ReduxDispatch } from 'redux';
import { BrowseFilesDialogProps } from '../../BrowseFilesDialog';
import { nanoid } from 'nanoid';
Expand Down Expand Up @@ -208,7 +208,7 @@ export function renderFieldControl(
* */
export function downloadMedia(base: string, url: string) {
const link = document.createElement('a');
link.href = ensureSingleSlash(`${base}${url}`);
link.href = resolveMediaUrl(base, url);
link.download = getFileNameFromPath(url); // Extracts the file name from the URL
document.body.appendChild(link);
link.click();
Expand Down Expand Up @@ -362,6 +362,8 @@ export const showImageCropDialog = ({
onCrop: (blob: Blob, newPath?: string) => void;
}): void => {
const dialogId = nanoid();
// Remote/absolute URLs load as `src` in the editor; writing cropped content back requires a site path.
const canWriteContent = writeContent !== false && !isExternalMediaUrl(path);
dispatch(
pushDialog({
id: dialogId,
Expand All @@ -371,7 +373,7 @@ export const showImageCropDialog = ({
mimeType,
subtitle: restrictions ? <ImageRestrictionSubtitle restrictions={restrictions} /> : undefined,
restrictions,
writeContent,
writeContent: canWriteContent,
onCrop: (blob: Blob, newPath: string) => {
dispatch(popDialog({ id: dialogId }));
onCrop?.(blob, newPath);
Expand Down
13 changes: 10 additions & 3 deletions studio-ui/ui/app/src/utils/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { ContentType, ContentTypeField } from '../models/ContentType';
import LookupTable from '../models/LookupTable';
import ContentInstance, { ContentInstanceBase } from '../models/ContentInstance';
import { deserialize, fromString, getInnerHtml, getInnerHtmlNumber, serialize, wrapElementInAuxDocument } from './xml';
import { fileNameFromPath, replaceAccentedVowels, unescapeHTML } from './string';
import { fileNameFromPath, isExternalMediaUrl, replaceAccentedVowels, unescapeHTML } from './string';
import { getRootPath, isRootPath, withIndex, withoutIndex } from './path';
import { isFolder, isNavigable, isPreviewable } from '../components/PathNavigator/utils';
import {
Expand Down Expand Up @@ -1208,8 +1208,15 @@ function doesImageMeetSizeRestrictions(file: HTMLImageElement, restrictions?: Im
* @param restrictions - Optional size restrictions to validate the image against.
* @returns Promise that resolves to true if the image meets the restrictions or no restrictions are provided, false otherwise.
* */
export function validateImageRestrictions(path: string, restrictions?: ImageRestrictions): Promise<boolean> {
if (!restrictions || (!isImage(path) && !isBlobUrl(path) && !path.startsWith('data:image/'))) {
export function validateImageRestrictions(
path: string,
restrictions?: ImageRestrictions,
mimeType?: string
): Promise<boolean> {
// External URLs (including blob/data URLs) may have no extension or a query string, so extension detection can't be
// used to rule them out. They're loaded and validated; non-images resolve as valid via the error handler below.
const isValidationCandidate = isImage(path) || mimeType?.startsWith('image/') || isExternalMediaUrl(path);
if (!restrictions || !isValidationCandidate) {
return Promise.resolve(true);
}
return new Promise((resolve) => {
Expand Down
21 changes: 21 additions & 0 deletions studio-ui/ui/app/src/utils/string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,27 @@ export function ensureSingleSlash(url: string): string {
return /^(http|https):\/\//g.test(url) ? url.replace(/([^:]\/)\/+/g, '$1') : url.replace(/\/+/g, '/');
}

/**
* True when the value is already a loadable absolute/remote media URL (http(s), data, or blob).
* Site-relative paths like `/static-assets/...` return false.
*/
export function isExternalMediaUrl(url: string): boolean {
if (!url) return false;
return /^(https?:)?\/\//i.test(url) || /^(data|blob):/i.test(url);
}
Comment thread
jvega190 marked this conversation as resolved.

/**
* Resolves a media field value for preview / fetch / download.
* Absolute/remote URLs are returned as-is; site paths are prefixed with `guestBase` (FE1 image-picker parity).
*/
export function resolveMediaUrl(guestBase: string, value: string): string {
if (!value) return value;
if (isExternalMediaUrl(value)) {
return value;
}
return ensureSingleSlash(`${guestBase}${value}`);
}

export function getSimplifiedVersion(version: string, options: { minor?: boolean; patch?: boolean } = {}) {
if (!version) {
return version;
Expand Down