feat: file drag and drop, multiple file select in media library (#783)

This commit is contained in:
Daniel Lautzenheiser 2023-05-08 12:32:05 -04:00 committed by GitHub
parent 4ff3967e18
commit e78ebbe65e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 744 additions and 262 deletions

View File

@ -215,11 +215,14 @@ export function persistMedia(
currentFolder?: string,
) {
const { field } = opts;
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
return async (
dispatch: ThunkDispatch<RootState, {}, AnyAction>,
getState: () => RootState,
): Promise<AssetProxy | null> => {
const state = getState();
const config = state.config.config;
if (!config) {
return;
return null;
}
const backend = currentBackend(config);
@ -246,7 +249,7 @@ export function persistMedia(
color: 'error',
}))
) {
return;
return null;
} else {
await dispatch(deleteMedia(existingFile));
}
@ -277,12 +280,14 @@ export function persistMedia(
assetProxy,
draft: Boolean(editingDraft),
});
return dispatch(addDraftEntryMediaFile(mediaFile));
await dispatch(addDraftEntryMediaFile(mediaFile));
return assetProxy;
} else {
mediaFile = await backend.persistMedia(config, assetProxy);
}
return dispatch(mediaPersisted(mediaFile, currentFolder));
await dispatch(mediaPersisted(mediaFile, currentFolder));
return assetProxy;
} catch (error) {
console.error(error);
dispatch(
@ -296,7 +301,8 @@ export function persistMedia(
},
}),
);
return dispatch(mediaPersistFailed());
await dispatch(mediaPersistFailed());
return null;
}
};
}

View File

@ -0,0 +1,117 @@
import { Check as CheckIcon } from '@styled-icons/material/Check';
import React, { useCallback, useRef } from 'react';
import classNames from '@staticcms/core/lib/util/classNames.util';
import type { ChangeEventHandler, FC, KeyboardEvent, MouseEvent } from 'react';
export interface CheckboxProps {
checked: boolean;
disabled?: boolean;
onChange: ChangeEventHandler<HTMLInputElement>;
}
const Checkbox: FC<CheckboxProps> = ({ checked, disabled = false, onChange }) => {
const inputRef = useRef<HTMLInputElement | null>(null);
const handleNoop = useCallback((event: KeyboardEvent | MouseEvent) => {
event.stopPropagation();
event.preventDefault();
}, []);
const handleKeydown = useCallback((event: KeyboardEvent) => {
if (event.code === 'Enter' || event.code === 'Space') {
event.stopPropagation();
event.preventDefault();
inputRef.current?.click();
}
}, []);
const handleClick = useCallback((event: MouseEvent) => {
event.stopPropagation();
event.preventDefault();
inputRef.current?.click();
}, []);
return (
<label
className={classNames(
`
relative
inline-flex
items-center
cursor-pointer
`,
disabled && 'cursor-default',
)}
onClick={handleNoop}
onKeyDown={handleKeydown}
>
<input
data-testid="switch-input"
ref={inputRef}
type="checkbox"
checked={checked}
className="sr-only peer"
disabled={disabled}
onChange={onChange}
onClick={handleNoop}
onKeyDown={handleKeydown}
/>
<div
className={classNames(
`
w-6
h-6
peer
peer-focus:ring-4
peer-focus:ring-blue-300
dark:peer-focus:ring-blue-800
peer-checked:after:translate-x-full
text-blue-600
border-gray-300
rounded
focus:ring-blue-500
dark:focus:ring-blue-600
dark:ring-offset-gray-800
focus:ring-2
dark:border-gray-600
select-none
flex
items-center
justify-center
`,
disabled
? `
peer-checked:bg-blue-600/25
peer-checked:after:border-gray-500/75
bg-gray-100/75
dark:bg-gray-700/75
`
: `
peer-checked:bg-blue-600
peer-checked:after:border-white
bg-gray-100
dark:bg-gray-700
`,
)}
onClick={handleClick}
onKeyDown={handleKeydown}
>
{checked ? (
<CheckIcon
className="
w-5
h-5
text-white
"
onClick={handleClick}
onKeyDown={handleKeydown}
/>
) : null}
</div>
</label>
);
};
export default Checkbox;

View File

@ -36,6 +36,7 @@ const MediaLibraryModal: FC = () => {
hover:bg-gray-100
dark:bg-slate-800
dark:hover:bg-slate-900
z-[1]
"
variant="outlined"
aria-label="add"

View File

@ -17,8 +17,10 @@ import {
loadMediaDisplayURL,
persistMedia,
} from '@staticcms/core/actions/mediaLibrary';
import useDragHandlers from '@staticcms/core/lib/hooks/useDragHandlers';
import useFolderSupport from '@staticcms/core/lib/hooks/useFolderSupport';
import useMediaFiles from '@staticcms/core/lib/hooks/useMediaFiles';
import useMediaPersist from '@staticcms/core/lib/hooks/useMediaPersist';
import { fileExtension } from '@staticcms/core/lib/util';
import classNames from '@staticcms/core/lib/util/classNames.util';
import MediaLibraryCloseEvent from '@staticcms/core/lib/util/events/MediaLibraryCloseEvent';
@ -27,7 +29,6 @@ import { selectConfig } from '@staticcms/core/reducers/selectors/config';
import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft';
import { selectMediaLibraryState } from '@staticcms/core/reducers/selectors/mediaLibrary';
import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks';
import alert from '../../common/alert/Alert';
import Button from '../../common/button/Button';
import IconButton from '../../common/button/IconButton';
import confirm from '../../common/confirm/Confirm';
@ -69,7 +70,7 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({
t,
}) => {
const [currentFolder, setCurrentFolder] = useState<string | undefined>(undefined);
const [selectedFile, setSelectedFile] = useState<MediaFile | null>(null);
const [selectedFile, setSelectedFile] = useState<string | string[] | null>(null);
const [query, setQuery] = useState<string | undefined>(undefined);
const config = useAppSelector(selectConfig);
@ -184,19 +185,60 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({
* Toggle asset selection on click.
*/
const handleAssetSelect = useCallback(
(asset: MediaFile) => {
if (
!canInsert ||
selectedFile?.key === asset.key ||
(!forFolder && asset.isDirectory) ||
(forFolder && !asset.isDirectory)
) {
(asset: MediaFile, action: 'add' | 'remove' | 'replace') => {
if (!canInsert || (!forFolder && asset.isDirectory) || (forFolder && !asset.isDirectory)) {
return;
}
setSelectedFile(asset);
if (action === 'replace') {
if (selectedFile === asset.path) {
return;
}
setSelectedFile(field?.multiple ? [asset.path] : asset.path);
return;
}
if (action === 'add') {
if (!field?.multiple) {
return;
}
const newValue = Array.isArray(selectedFile)
? selectedFile
: selectedFile
? [selectedFile]
: [];
if (newValue.includes(asset.path)) {
return;
}
setSelectedFile([...newValue, asset.path]);
return;
}
if (action === 'remove') {
if (!field?.multiple) {
return;
}
const newValue = Array.isArray(selectedFile)
? [...selectedFile]
: selectedFile
? [selectedFile]
: [];
const index = newValue.indexOf(asset.path);
if (index < 0) {
return;
}
newValue.splice(index, 1);
setSelectedFile(newValue);
return;
}
},
[canInsert, forFolder, selectedFile?.key],
[canInsert, field?.multiple, forFolder, selectedFile],
);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
@ -209,58 +251,23 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({
/**
* Upload a file.
*/
const handlePersist = useCallback(
async (event: ChangeEvent<HTMLInputElement> | DragEvent) => {
/**
* Stop the browser from automatically handling the file input click, and
* get the file for upload, and retain the synthetic event for access after
* the asynchronous persist operation.
*/
let fileList: FileList | null;
if ('dataTransfer' in event) {
fileList = event.dataTransfer?.files ?? null;
} else {
event.persist();
fileList = event.target.files;
const handlePersist = useMediaPersist({
mediaConfig,
field,
currentFolder,
callback: (_files, assets) => {
if (assets.length === 1 && assets[0]) {
setSelectedFile(assets[0].path);
} else if (field?.multiple) {
setSelectedFile(assets.filter(f => f).map(f => f!.path));
}
if (!fileList) {
return;
}
event.stopPropagation();
event.preventDefault();
const files = [...Array.from(fileList)];
const maxFileSize =
typeof mediaConfig.max_file_size === 'number' ? mediaConfig.max_file_size : 512000;
for (const file of files) {
if (maxFileSize && file.size > maxFileSize) {
alert({
title: 'mediaLibrary.mediaLibrary.fileTooLargeTitle',
body: {
key: 'mediaLibrary.mediaLibrary.fileTooLargeBody',
options: {
size: Math.floor(maxFileSize / 1000),
},
},
});
} else {
await dispatch(persistMedia(file, { field }, currentFolder));
setSelectedFile(files[0] as unknown as MediaFile);
scrollToTop();
}
}
if (!('dataTransfer' in event)) {
event.target.value = '';
}
},
[mediaConfig.max_file_size, field, dispatch, currentFolder],
);
});
const { dragOverActive, handleDragEnter, handleDragLeave, handleDragOver, handleDrop } =
useDragHandlers(handlePersist);
const handleURLChange = useCallback(
(url: string) => {
@ -272,14 +279,14 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({
const handleAltChange = useCallback(
(alt: string) => {
if (!url && !selectedFile?.path) {
if (!url && !selectedFile) {
return;
}
setAlt(alt);
dispatch(insertMedia((url ?? selectedFile?.path) as string, field, alt, currentFolder));
dispatch(insertMedia((url ?? selectedFile) as string, field, alt, currentFolder));
},
[dispatch, field, selectedFile?.path, url, currentFolder],
[dispatch, field, selectedFile, url, currentFolder],
);
const handleOpenDirectory = useCallback(
@ -375,13 +382,12 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({
* editor field that launched the media library can retrieve it.
*/
const handleInsert = useCallback(() => {
if (!selectedFile?.path) {
if (!selectedFile) {
return;
}
const { path } = selectedFile;
setUrl(path);
dispatch(insertMedia(path, field, alt, currentFolder));
setUrl(selectedFile);
dispatch(insertMedia(selectedFile, field, alt, currentFolder));
if (!insertOptions?.chooseUrl && !insertOptions?.showAlt) {
handleClose();
@ -478,6 +484,30 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({
return (
<>
<div
onDrop={handleDrop}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
className={classNames(
`
relative
border-2
transition-colors
w-full
h-full
`,
isDialog && 'rounded-lg',
dragOverActive ? 'border-blue-500' : 'border-transparent',
)}
>
<div
className="
-m-0.5
w-full
h-full
"
>
<div className="flex flex-col w-full h-full">
<CurrentMediaDetails
collection={collection}
@ -564,7 +594,7 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({
</div>
) : null}
</div>
<div className="flex gap-3 items-center relative z-20">
<div className="flex gap-3 items-center relative">
<FileUploadButton imagesOnly={forImage} onChange={handlePersist} />
{canInsert ? (
<Button
@ -602,7 +632,11 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({
<MediaLibraryCardGrid
scrollContainerRef={scrollContainerRef}
mediaItems={tableData}
isSelectedFile={file => selectedFile?.key === file.key}
isSelectedFile={file =>
Array.isArray(selectedFile) && field?.multiple
? selectedFile.includes(file.path)
: selectedFile === file.path
}
onAssetSelect={handleAssetSelect}
canLoadMore={hasNextPage}
onLoadMore={handleLoadMore}
@ -617,9 +651,37 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({
field={field}
isDialog={isDialog}
onDelete={handleDelete}
hasSelection={
Array.isArray(selectedFile) ? selectedFile.length > 0 : Boolean(selectedFile)
}
allowMultiple={replaceIndex === undefined && (field?.multiple ?? false)}
/>
)}
</div>
<div
className={classNames(
`
absolute
inset-0
flex
items-center
justify-center
pointer-events-none
font-bold
text-blue-500
bg-white/75
dark:text-blue-400
dark:bg-slate-800/75
transition-opacity
`,
isDialog && 'rounded-lg',
dragOverActive ? 'opacity-100' : 'opacity-0',
)}
>
{t(`mediaLibrary.mediaLibraryModal.${forImage ? 'dropImages' : 'dropFiles'}`)}
</div>
</div>
</div>
<FolderCreationDialog
open={folderCreationOpen}
onClose={handleFolderCreationDialogClose}

View File

@ -9,6 +9,7 @@ import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft';
import { useAppSelector } from '@staticcms/core/store/hooks';
import Button from '../../common/button/Button';
import Checkbox from '../../common/checkbox/Checkbox';
import Image from '../../common/image/Image';
import Pill from '../../common/pill/Pill';
import CopyToClipBoardButton from './CopyToClipBoardButton';
@ -21,7 +22,7 @@ import type {
TranslatedProps,
UnknownField,
} from '@staticcms/core/interface';
import type { FC, KeyboardEvent } from 'react';
import type { ChangeEvent, FC, KeyboardEvent } from 'react';
interface MediaLibraryCardProps<T extends MediaField, EF extends BaseField = UnknownField> {
isSelected?: boolean;
@ -36,7 +37,9 @@ interface MediaLibraryCardProps<T extends MediaField, EF extends BaseField = Unk
collection?: Collection<EF>;
field?: T;
currentFolder?: string;
onSelect: () => void;
hasSelection: boolean;
allowMultiple: boolean;
onSelect: (action: 'add' | 'remove' | 'replace') => void;
onDirectoryOpen: () => void;
loadDisplayURL: () => void;
onDelete: () => void;
@ -55,6 +58,8 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
collection,
field,
currentFolder,
hasSelection,
allowMultiple,
onSelect,
onDirectoryOpen,
loadDisplayURL,
@ -100,13 +105,26 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
const handleOnKeyUp = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'Enter') {
onSelect();
if (event.code === 'Enter' || event.code === 'Space') {
onSelect('replace');
}
},
[onSelect],
);
const handleClick = useCallback(() => {
onSelect('replace');
}, [onSelect]);
const handleCheckboxChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
event.stopPropagation();
event.preventDefault();
onSelect(event.target.checked ? 'add' : 'remove');
},
[onSelect],
);
return (
<div
className="
@ -117,7 +135,7 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
tabIndex={-1}
>
<div
onClick={onSelect}
onClick={handleClick}
onDoubleClick={isDirectory ? onDirectoryOpen : undefined}
data-testid={`media-card-${displayURL.url}`}
className="
@ -223,11 +241,26 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
) : null}
</div>
<div className="relative">
<div
className="
absolute
top-3
left-3
flex
items-center
gap-1
z-20
"
>
{hasSelection && allowMultiple ? (
<Checkbox checked={isSelected} onChange={handleCheckboxChange} />
) : null}
{isDraft ? (
<Pill data-testid="draft-text" color="primary" className="absolute top-3 left-3 z-20">
<Pill data-testid="draft-text" color="primary" className="">
{draftText}
</Pill>
) : null}
</div>
{url && isViewableImage ? (
<Image src={url} className="w-media-card h-media-card-image rounded-md" />
) : isDirectory ? (

View File

@ -42,7 +42,7 @@ export interface MediaLibraryCardGridProps {
scrollContainerRef: React.MutableRefObject<HTMLDivElement | null>;
mediaItems: MediaFile[];
isSelectedFile: (file: MediaFile) => boolean;
onAssetSelect: (asset: MediaFile) => void;
onAssetSelect: (asset: MediaFile, action: 'add' | 'remove' | 'replace') => void;
canLoadMore?: boolean;
onLoadMore: () => void;
onDirectoryOpen: (dir: string) => void;
@ -57,6 +57,8 @@ export interface MediaLibraryCardGridProps {
field?: MediaField;
isDialog: boolean;
onDelete: (file: MediaFile) => void;
hasSelection: boolean;
allowMultiple: boolean;
}
export type CardGridItemData = MediaLibraryCardGridProps & {
@ -81,6 +83,8 @@ const CardWrapper = ({
collection,
field,
onDelete,
hasSelection,
allowMultiple,
},
}: GridChildComponentProps<CardGridItemData>) => {
const left = useMemo(
@ -96,7 +100,7 @@ const CardWrapper = ({
);
const top = useMemo(
() => parseFloat(`${typeof style.top === 'number' ? style.top ?? 0 : style.top}`),
() => parseFloat(`${typeof style.top === 'number' ? style.top ?? 0 : style.top}`) + 4,
[style.top],
);
@ -120,7 +124,7 @@ const CardWrapper = ({
key={file.key}
isSelected={isSelectedFile(file)}
text={file.name}
onSelect={() => onAssetSelect(file)}
onSelect={action => onAssetSelect(file, action)}
onDirectoryOpen={() => onDirectoryOpen(file.path)}
currentFolder={currentFolder}
isDraft={file.draft}
@ -134,6 +138,8 @@ const CardWrapper = ({
collection={collection}
field={field}
onDelete={() => onDelete(file)}
hasSelection={hasSelection}
allowMultiple={allowMultiple}
/>
</div>
);

View File

@ -5,4 +5,5 @@ export { default as useIsMediaAsset } from './useIsMediaAsset';
export { default as useMediaAsset } from './useMediaAsset';
export { default as useMediaFiles } from './useMediaFiles';
export { default as useMediaInsert } from './useMediaInsert';
export { default as useMediaPersist } from './useMediaPersist';
export { default as useUUID } from './useUUID';

View File

@ -0,0 +1,58 @@
import { useCallback, useMemo, useState } from 'react';
import type { DragEvent } from 'react';
interface UseDragHandlersState {
dragOverActive: boolean;
counter: number;
}
export default function useDragHandlers(onDrop: (event: DragEvent) => void) {
const [{ dragOverActive }, setState] = useState<UseDragHandlersState>({
dragOverActive: false,
counter: 0,
});
const handleDragEnter = useCallback((event: DragEvent) => {
event.preventDefault();
setState(old => ({
dragOverActive: true,
counter: old.counter + 1,
}));
}, []);
const handleDragOver = useCallback((event: DragEvent) => {
event.preventDefault();
}, []);
const handleDragLeave = useCallback((event: DragEvent) => {
event.preventDefault();
setState(old => ({
dragOverActive: old.counter - 1 <= 0 ? false : old.dragOverActive,
counter: old.counter - 1,
}));
}, []);
const handleDrop = useCallback(
(event: DragEvent) => {
event.preventDefault();
setState({
dragOverActive: false,
counter: 0,
});
onDrop(event);
},
[onDrop],
);
return useMemo(
() => ({
dragOverActive,
handleDragEnter,
handleDragOver,
handleDragLeave,
handleDrop,
}),
[dragOverActive, handleDragEnter, handleDragLeave, handleDragOver, handleDrop],
);
}

View File

@ -101,7 +101,7 @@ export default function useMediaFiles(field?: MediaField, currentFolder?: string
const draftFiles = entryFolderFiles.filter(
file => file.draft == true && !files.find(f => f.id === file.id),
);
files.unshift(...draftFiles);
files.push(...draftFiles);
}
return files.map(file => ({ key: file.id, ...file }));
}
@ -109,7 +109,32 @@ export default function useMediaFiles(field?: MediaField, currentFolder?: string
}, [collection, config, currentFolderMediaFiles, entry, field, mediaLibraryFiles, currentFolder]);
return useMemo(
() => files.filter(file => file.name !== '.gitkeep' && (folderSupport || !file.isDirectory)),
() =>
files
.filter(file => file.name !== '.gitkeep' && (folderSupport || !file.isDirectory))
.sort((a, b) => {
const aIsDirectory = a.isDirectory ?? false;
const bIsDirectory = b.isDirectory ?? false;
if (aIsDirectory !== bIsDirectory) {
if (aIsDirectory) {
return -1;
}
return 1;
}
const aIsDraft = a.draft ?? false;
const bIsDraft = b.draft ?? false;
if (aIsDraft !== bIsDraft) {
if (aIsDraft) {
return -1;
}
return 1;
}
return a.name.localeCompare(b.name);
}),
[files, folderSupport],
);
}

View File

@ -0,0 +1,78 @@
import { useCallback } from 'react';
import { persistMedia } from '@staticcms/core/actions/mediaLibrary';
import { useAppDispatch } from '@staticcms/core/store/hooks';
import alert from '../../components/common/alert/Alert';
import type { MediaField, MediaLibraryConfig } from '@staticcms/core/interface';
import type { AssetProxy } from '@staticcms/core/valueObjects';
import type { ChangeEvent, DragEvent } from 'react';
export interface UseMediaPersistProps {
mediaConfig?: MediaLibraryConfig;
field?: MediaField;
currentFolder?: string;
callback?: (files: File[], assetProxies: (AssetProxy | null)[]) => void;
}
export default function useMediaPersist({
mediaConfig,
field,
currentFolder,
callback,
}: UseMediaPersistProps) {
const dispatch = useAppDispatch();
return useCallback(
async (event: ChangeEvent<HTMLInputElement> | DragEvent) => {
/**
* Stop the browser from automatically handling the file input click, and
* get the file for upload, and retain the synthetic event for access after
* the asynchronous persist operation.
*/
let fileList: FileList | null;
if ('dataTransfer' in event) {
fileList = event.dataTransfer?.files ?? null;
} else {
event.persist();
fileList = event.target.files;
}
if (!fileList) {
return;
}
event.stopPropagation();
event.preventDefault();
const files = [...Array.from(fileList)];
const maxFileSize =
typeof mediaConfig?.max_file_size === 'number' ? mediaConfig.max_file_size : 512000;
const assetProxies: (AssetProxy | null)[] = [];
for (const file of files) {
if (maxFileSize && file.size > maxFileSize) {
alert({
title: 'mediaLibrary.mediaLibrary.fileTooLargeTitle',
body: {
key: 'mediaLibrary.mediaLibrary.fileTooLargeBody',
options: {
size: Math.floor(maxFileSize / 1000),
},
},
});
} else {
const assetProxy = await dispatch(persistMedia(file, { field }, currentFolder));
assetProxies.push(assetProxy);
}
}
callback?.(files, assetProxies);
if (!('dataTransfer' in event)) {
event.target.value = '';
}
},
[mediaConfig?.max_file_size, dispatch, field, currentFolder, callback],
);
}

View File

@ -234,6 +234,8 @@ const en: LocalePhrasesRoot = {
deleting: 'Deleting...',
deleteSelected: 'Delete selected',
chooseSelected: 'Choose selected',
dropImages: 'Drop images to upload',
dropFiles: 'Drop files to upload',
},
folderSupport: {
newFolder: 'New folder',

View File

@ -13,17 +13,22 @@ import Button from '@staticcms/core/components/common/button/Button';
import Field from '@staticcms/core/components/common/field/Field';
import Image from '@staticcms/core/components/common/image/Image';
import Link from '@staticcms/core/components/common/link/Link';
import useDragHandlers from '@staticcms/core/lib/hooks/useDragHandlers';
import useMediaInsert from '@staticcms/core/lib/hooks/useMediaInsert';
import useMediaPersist from '@staticcms/core/lib/hooks/useMediaPersist';
import useUUID from '@staticcms/core/lib/hooks/useUUID';
import { basename } from '@staticcms/core/lib/util';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { KeyboardSensor, PointerSensor } from '@staticcms/core/lib/util/dnd.util';
import { isEmpty } from '@staticcms/core/lib/util/string.util';
import { selectConfig } from '@staticcms/core/reducers/selectors/config';
import { useAppSelector } from '@staticcms/core/store/hooks';
import SortableImage from './components/SortableImage';
import SortableLink from './components/SortableLink';
import type { DragEndEvent } from '@dnd-kit/core';
import type { FileOrImageField, MediaPath, WidgetControlProps } from '@staticcms/core/interface';
import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy';
import type { FC, MouseEvent } from 'react';
const MAX_DISPLAY_LENGTH = 50;
@ -128,6 +133,41 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
handleOnChange,
);
const config = useAppSelector(selectConfig);
const handlePersistCallback = useCallback(
(_files: File[], assetProxies: (AssetProxy | null)[]) => {
const newPath =
assetProxies.length > 1 && allowsMultiple
? [
...(Array.isArray(internalValue) ? internalValue : [internalValue]),
...assetProxies.filter(f => f).map(f => f!.path),
]
: assetProxies[0]?.path;
if ((Array.isArray(newPath) && newPath.length === 0) || !newPath) {
return;
}
handleOnChange({
path: newPath,
});
},
[allowsMultiple, handleOnChange, internalValue],
);
/**
* Upload a file.
*/
const handlePersist = useMediaPersist({
mediaConfig: field.media_library ?? config?.media_library,
field,
callback: handlePersistCallback,
});
const { dragOverActive, handleDragEnter, handleDragLeave, handleDragOver, handleDrop } =
useDragHandlers(handlePersist);
const chooseUrl = useMemo(() => field.choose_url ?? false, [field.choose_url]);
const handleUrl = useCallback(
@ -417,6 +457,21 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
return useMemo(
() => (
<div
onDrop={handleDrop}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
className={classNames(
`
relative
border-2
transition-colors
`,
dragOverActive ? 'border-blue-500' : 'border-transparent',
)}
>
<div className="-m-0.5">
<Field
inputRef={allowsMultiple ? undefined : uploadButtonRef}
label={label}
@ -429,8 +484,46 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
>
{content}
</Field>
<div
className={classNames(
`
absolute
inset-0
flex
items-center
justify-center
pointer-events-none
font-bold
text-blue-500
bg-white/75
dark:text-blue-400
dark:bg-slate-800/75
transition-opacity
`,
dragOverActive ? 'opacity-100' : 'opacity-0',
)}
>
{t(`mediaLibrary.mediaLibraryModal.${forImage ? 'dropImages' : 'dropFiles'}`)}
</div>
</div>
</div>
),
[content, disabled, errors, field.hint, allowsMultiple, forSingleList, hasErrors, label],
[
handleDrop,
handleDragEnter,
handleDragLeave,
handleDragOver,
dragOverActive,
allowsMultiple,
label,
errors,
hasErrors,
field.hint,
forSingleList,
disabled,
content,
t,
],
);
},
);