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..1d8b9de6b 100644 --- a/studio-ui/ui/app/src/components/FormsEngine/controls/ImagePicker.tsx +++ b/studio-ui/ui/app/src/components/FormsEngine/controls/ImagePicker.tsx @@ -31,7 +31,7 @@ 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 { resolveMediaUrl } from '../../../utils/string'; import { useDispatch } from 'react-redux'; import Tooltip from '@mui/material/Tooltip'; import { downloadMedia, getImageRestrictionMessages, showImageCropDialog } from '../lib/controlHelpers'; @@ -68,9 +68,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'; @@ -150,12 +150,7 @@ export function ImagePicker(props: ImagePickerProps) { {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/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;