diff --git a/studio-ui/ui/app/src/components/FormsEngine/controls/ImagePicker.tsx b/studio-ui/ui/app/src/components/FormsEngine/controls/ImagePicker.tsx
index 8652e4c7c..e6d0c7bee 100644
--- a/studio-ui/ui/app/src/components/FormsEngine/controls/ImagePicker.tsx
+++ b/studio-ui/ui/app/src/components/FormsEngine/controls/ImagePicker.tsx
@@ -14,7 +14,7 @@
* along with this program. If not, see .
*/
-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';
@@ -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';
@@ -68,9 +74,9 @@ 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';
@@ -78,6 +84,8 @@ export function ImagePicker(props: ImagePickerProps) {
const actionsReady = Boolean(dataSources?.context) && actions.length > 0 && !dataSourcesLoading;
const [anchorEl, setAnchorEl] = useState(null);
const [addMenuOpen, setAddMenuOpen] = useState(false);
+ const [rejectedExternalUrl, setRejectedExternalUrl] = useState(null);
+ const selectionRequestRef = useRef(0);
useEffect(() => {
// If there's a default value and no value has been set yet, set it as the value.
@@ -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'
@@ -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,
@@ -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.' })
@@ -132,6 +148,8 @@ export function ImagePicker(props: ImagePickerProps) {
) : null;
const handleRemoveImage = () => {
+ selectionRequestRef.current += 1;
+ setRejectedExternalUrl(null);
setValue(null);
};
@@ -147,15 +165,25 @@ export function ImagePicker(props: ImagePickerProps) {
>
{actionMenuItems}
-
+
+ {rejectedExternalUrl && (
+ setRejectedExternalUrl(null)}
+ >
+ {' '}
+ {' '}
+
+
+ )}
{hasValue ? (
-
+
diff --git a/studio-ui/ui/app/src/components/FormsEngine/controls/VideoPicker.tsx b/studio-ui/ui/app/src/components/FormsEngine/controls/VideoPicker.tsx
index a9dea6299..be4bd08ea 100644
--- a/studio-ui/ui/app/src/components/FormsEngine/controls/VideoPicker.tsx
+++ b/studio-ui/ui/app/src/components/FormsEngine/controls/VideoPicker.tsx
@@ -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';
@@ -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);
const [addMenuOpen, setAddMenuOpen] = useState(false);
@@ -99,7 +97,7 @@ export function VideoPicker(props: VideoPickerProps) {
{hasValue ? (
-
+
diff --git a/studio-ui/ui/app/src/components/FormsEngine/lib/controlHelpers.tsx b/studio-ui/ui/app/src/components/FormsEngine/lib/controlHelpers.tsx
index 350bd17e6..c52f80c64 100644
--- a/studio-ui/ui/app/src/components/FormsEngine/lib/controlHelpers.tsx
+++ b/studio-ui/ui/app/src/components/FormsEngine/lib/controlHelpers.tsx
@@ -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';
@@ -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();
@@ -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,
@@ -371,7 +373,7 @@ export const showImageCropDialog = ({
mimeType,
subtitle: restrictions ? : undefined,
restrictions,
- writeContent,
+ writeContent: canWriteContent,
onCrop: (blob: Blob, newPath: string) => {
dispatch(popDialog({ id: dialogId }));
onCrop?.(blob, newPath);
diff --git a/studio-ui/ui/app/src/utils/content.ts b/studio-ui/ui/app/src/utils/content.ts
index da1af7cee..53c7aaacc 100644
--- a/studio-ui/ui/app/src/utils/content.ts
+++ b/studio-ui/ui/app/src/utils/content.ts
@@ -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 {
@@ -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 {
- if (!restrictions || (!isImage(path) && !isBlobUrl(path) && !path.startsWith('data:image/'))) {
+export function validateImageRestrictions(
+ path: string,
+ restrictions?: ImageRestrictions,
+ mimeType?: string
+): Promise {
+ // 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) => {
diff --git a/studio-ui/ui/app/src/utils/string.ts b/studio-ui/ui/app/src/utils/string.ts
index f563e5e6e..6be743610 100644
--- a/studio-ui/ui/app/src/utils/string.ts
+++ b/studio-ui/ui/app/src/utils/string.ts
@@ -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);
+}
+
+/**
+ * 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;