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, currentFolder?: string,
) { ) {
const { field } = opts; 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 state = getState();
const config = state.config.config; const config = state.config.config;
if (!config) { if (!config) {
return; return null;
} }
const backend = currentBackend(config); const backend = currentBackend(config);
@ -246,7 +249,7 @@ export function persistMedia(
color: 'error', color: 'error',
})) }))
) { ) {
return; return null;
} else { } else {
await dispatch(deleteMedia(existingFile)); await dispatch(deleteMedia(existingFile));
} }
@ -277,12 +280,14 @@ export function persistMedia(
assetProxy, assetProxy,
draft: Boolean(editingDraft), draft: Boolean(editingDraft),
}); });
return dispatch(addDraftEntryMediaFile(mediaFile)); await dispatch(addDraftEntryMediaFile(mediaFile));
return assetProxy;
} else { } else {
mediaFile = await backend.persistMedia(config, assetProxy); mediaFile = await backend.persistMedia(config, assetProxy);
} }
return dispatch(mediaPersisted(mediaFile, currentFolder)); await dispatch(mediaPersisted(mediaFile, currentFolder));
return assetProxy;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
dispatch( 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 hover:bg-gray-100
dark:bg-slate-800 dark:bg-slate-800
dark:hover:bg-slate-900 dark:hover:bg-slate-900
z-[1]
" "
variant="outlined" variant="outlined"
aria-label="add" aria-label="add"

View File

@ -17,8 +17,10 @@ import {
loadMediaDisplayURL, loadMediaDisplayURL,
persistMedia, persistMedia,
} from '@staticcms/core/actions/mediaLibrary'; } from '@staticcms/core/actions/mediaLibrary';
import useDragHandlers from '@staticcms/core/lib/hooks/useDragHandlers';
import useFolderSupport from '@staticcms/core/lib/hooks/useFolderSupport'; import useFolderSupport from '@staticcms/core/lib/hooks/useFolderSupport';
import useMediaFiles from '@staticcms/core/lib/hooks/useMediaFiles'; import useMediaFiles from '@staticcms/core/lib/hooks/useMediaFiles';
import useMediaPersist from '@staticcms/core/lib/hooks/useMediaPersist';
import { fileExtension } from '@staticcms/core/lib/util'; import { fileExtension } from '@staticcms/core/lib/util';
import classNames from '@staticcms/core/lib/util/classNames.util'; import classNames from '@staticcms/core/lib/util/classNames.util';
import MediaLibraryCloseEvent from '@staticcms/core/lib/util/events/MediaLibraryCloseEvent'; 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 { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft';
import { selectMediaLibraryState } from '@staticcms/core/reducers/selectors/mediaLibrary'; import { selectMediaLibraryState } from '@staticcms/core/reducers/selectors/mediaLibrary';
import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks'; import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks';
import alert from '../../common/alert/Alert';
import Button from '../../common/button/Button'; import Button from '../../common/button/Button';
import IconButton from '../../common/button/IconButton'; import IconButton from '../../common/button/IconButton';
import confirm from '../../common/confirm/Confirm'; import confirm from '../../common/confirm/Confirm';
@ -69,7 +70,7 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({
t, t,
}) => { }) => {
const [currentFolder, setCurrentFolder] = useState<string | undefined>(undefined); 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 [query, setQuery] = useState<string | undefined>(undefined);
const config = useAppSelector(selectConfig); const config = useAppSelector(selectConfig);
@ -184,19 +185,60 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({
* Toggle asset selection on click. * Toggle asset selection on click.
*/ */
const handleAssetSelect = useCallback( const handleAssetSelect = useCallback(
(asset: MediaFile) => { (asset: MediaFile, action: 'add' | 'remove' | 'replace') => {
if ( if (!canInsert || (!forFolder && asset.isDirectory) || (forFolder && !asset.isDirectory)) {
!canInsert ||
selectedFile?.key === asset.key ||
(!forFolder && asset.isDirectory) ||
(forFolder && !asset.isDirectory)
) {
return; 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); const scrollContainerRef = useRef<HTMLDivElement | null>(null);
@ -209,58 +251,23 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({
/** /**
* Upload a file. * Upload a file.
*/ */
const handlePersist = useCallback( const handlePersist = useMediaPersist({
async (event: ChangeEvent<HTMLInputElement> | DragEvent) => { mediaConfig,
/** field,
* Stop the browser from automatically handling the file input click, and currentFolder,
* get the file for upload, and retain the synthetic event for access after callback: (_files, assets) => {
* the asynchronous persist operation. if (assets.length === 1 && assets[0]) {
*/ setSelectedFile(assets[0].path);
} else if (field?.multiple) {
let fileList: FileList | null; setSelectedFile(assets.filter(f => f).map(f => f!.path));
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;
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(); 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( const handleURLChange = useCallback(
(url: string) => { (url: string) => {
@ -272,14 +279,14 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({
const handleAltChange = useCallback( const handleAltChange = useCallback(
(alt: string) => { (alt: string) => {
if (!url && !selectedFile?.path) { if (!url && !selectedFile) {
return; return;
} }
setAlt(alt); 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( const handleOpenDirectory = useCallback(
@ -375,13 +382,12 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({
* editor field that launched the media library can retrieve it. * editor field that launched the media library can retrieve it.
*/ */
const handleInsert = useCallback(() => { const handleInsert = useCallback(() => {
if (!selectedFile?.path) { if (!selectedFile) {
return; return;
} }
const { path } = selectedFile; setUrl(selectedFile);
setUrl(path); dispatch(insertMedia(selectedFile, field, alt, currentFolder));
dispatch(insertMedia(path, field, alt, currentFolder));
if (!insertOptions?.chooseUrl && !insertOptions?.showAlt) { if (!insertOptions?.chooseUrl && !insertOptions?.showAlt) {
handleClose(); handleClose();
@ -478,6 +484,30 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({
return ( 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"> <div className="flex flex-col w-full h-full">
<CurrentMediaDetails <CurrentMediaDetails
collection={collection} collection={collection}
@ -564,7 +594,7 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({
</div> </div>
) : null} ) : null}
</div> </div>
<div className="flex gap-3 items-center relative z-20"> <div className="flex gap-3 items-center relative">
<FileUploadButton imagesOnly={forImage} onChange={handlePersist} /> <FileUploadButton imagesOnly={forImage} onChange={handlePersist} />
{canInsert ? ( {canInsert ? (
<Button <Button
@ -602,7 +632,11 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({
<MediaLibraryCardGrid <MediaLibraryCardGrid
scrollContainerRef={scrollContainerRef} scrollContainerRef={scrollContainerRef}
mediaItems={tableData} mediaItems={tableData}
isSelectedFile={file => selectedFile?.key === file.key} isSelectedFile={file =>
Array.isArray(selectedFile) && field?.multiple
? selectedFile.includes(file.path)
: selectedFile === file.path
}
onAssetSelect={handleAssetSelect} onAssetSelect={handleAssetSelect}
canLoadMore={hasNextPage} canLoadMore={hasNextPage}
onLoadMore={handleLoadMore} onLoadMore={handleLoadMore}
@ -617,9 +651,37 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({
field={field} field={field}
isDialog={isDialog} isDialog={isDialog}
onDelete={handleDelete} onDelete={handleDelete}
hasSelection={
Array.isArray(selectedFile) ? selectedFile.length > 0 : Boolean(selectedFile)
}
allowMultiple={replaceIndex === undefined && (field?.multiple ?? false)}
/> />
)} )}
</div> </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 <FolderCreationDialog
open={folderCreationOpen} open={folderCreationOpen}
onClose={handleFolderCreationDialogClose} 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 { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft';
import { useAppSelector } from '@staticcms/core/store/hooks'; import { useAppSelector } from '@staticcms/core/store/hooks';
import Button from '../../common/button/Button'; import Button from '../../common/button/Button';
import Checkbox from '../../common/checkbox/Checkbox';
import Image from '../../common/image/Image'; import Image from '../../common/image/Image';
import Pill from '../../common/pill/Pill'; import Pill from '../../common/pill/Pill';
import CopyToClipBoardButton from './CopyToClipBoardButton'; import CopyToClipBoardButton from './CopyToClipBoardButton';
@ -21,7 +22,7 @@ import type {
TranslatedProps, TranslatedProps,
UnknownField, UnknownField,
} from '@staticcms/core/interface'; } 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> { interface MediaLibraryCardProps<T extends MediaField, EF extends BaseField = UnknownField> {
isSelected?: boolean; isSelected?: boolean;
@ -36,7 +37,9 @@ interface MediaLibraryCardProps<T extends MediaField, EF extends BaseField = Unk
collection?: Collection<EF>; collection?: Collection<EF>;
field?: T; field?: T;
currentFolder?: string; currentFolder?: string;
onSelect: () => void; hasSelection: boolean;
allowMultiple: boolean;
onSelect: (action: 'add' | 'remove' | 'replace') => void;
onDirectoryOpen: () => void; onDirectoryOpen: () => void;
loadDisplayURL: () => void; loadDisplayURL: () => void;
onDelete: () => void; onDelete: () => void;
@ -55,6 +58,8 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
collection, collection,
field, field,
currentFolder, currentFolder,
hasSelection,
allowMultiple,
onSelect, onSelect,
onDirectoryOpen, onDirectoryOpen,
loadDisplayURL, loadDisplayURL,
@ -100,13 +105,26 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
const handleOnKeyUp = useCallback( const handleOnKeyUp = useCallback(
(event: KeyboardEvent) => { (event: KeyboardEvent) => {
if (event.key === 'Enter') { if (event.code === 'Enter' || event.code === 'Space') {
onSelect(); onSelect('replace');
} }
}, },
[onSelect], [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 ( return (
<div <div
className=" className="
@ -117,7 +135,7 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
tabIndex={-1} tabIndex={-1}
> >
<div <div
onClick={onSelect} onClick={handleClick}
onDoubleClick={isDirectory ? onDirectoryOpen : undefined} onDoubleClick={isDirectory ? onDirectoryOpen : undefined}
data-testid={`media-card-${displayURL.url}`} data-testid={`media-card-${displayURL.url}`}
className=" className="
@ -223,11 +241,26 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
) : null} ) : null}
</div> </div>
<div className="relative"> <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 ? ( {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} {draftText}
</Pill> </Pill>
) : null} ) : null}
</div>
{url && isViewableImage ? ( {url && isViewableImage ? (
<Image src={url} className="w-media-card h-media-card-image rounded-md" /> <Image src={url} className="w-media-card h-media-card-image rounded-md" />
) : isDirectory ? ( ) : isDirectory ? (

View File

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

View File

@ -5,4 +5,5 @@ export { default as useIsMediaAsset } from './useIsMediaAsset';
export { default as useMediaAsset } from './useMediaAsset'; export { default as useMediaAsset } from './useMediaAsset';
export { default as useMediaFiles } from './useMediaFiles'; export { default as useMediaFiles } from './useMediaFiles';
export { default as useMediaInsert } from './useMediaInsert'; export { default as useMediaInsert } from './useMediaInsert';
export { default as useMediaPersist } from './useMediaPersist';
export { default as useUUID } from './useUUID'; 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( const draftFiles = entryFolderFiles.filter(
file => file.draft == true && !files.find(f => f.id === file.id), 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 })); 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]); }, [collection, config, currentFolderMediaFiles, entry, field, mediaLibraryFiles, currentFolder]);
return useMemo( 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], [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...', deleting: 'Deleting...',
deleteSelected: 'Delete selected', deleteSelected: 'Delete selected',
chooseSelected: 'Choose selected', chooseSelected: 'Choose selected',
dropImages: 'Drop images to upload',
dropFiles: 'Drop files to upload',
}, },
folderSupport: { folderSupport: {
newFolder: 'New folder', 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 Field from '@staticcms/core/components/common/field/Field';
import Image from '@staticcms/core/components/common/image/Image'; import Image from '@staticcms/core/components/common/image/Image';
import Link from '@staticcms/core/components/common/link/Link'; 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 useMediaInsert from '@staticcms/core/lib/hooks/useMediaInsert';
import useMediaPersist from '@staticcms/core/lib/hooks/useMediaPersist';
import useUUID from '@staticcms/core/lib/hooks/useUUID'; import useUUID from '@staticcms/core/lib/hooks/useUUID';
import { basename } from '@staticcms/core/lib/util'; import { basename } from '@staticcms/core/lib/util';
import classNames from '@staticcms/core/lib/util/classNames.util'; import classNames from '@staticcms/core/lib/util/classNames.util';
import { KeyboardSensor, PointerSensor } from '@staticcms/core/lib/util/dnd.util'; import { KeyboardSensor, PointerSensor } from '@staticcms/core/lib/util/dnd.util';
import { isEmpty } from '@staticcms/core/lib/util/string.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 SortableImage from './components/SortableImage';
import SortableLink from './components/SortableLink'; import SortableLink from './components/SortableLink';
import type { DragEndEvent } from '@dnd-kit/core'; import type { DragEndEvent } from '@dnd-kit/core';
import type { FileOrImageField, MediaPath, WidgetControlProps } from '@staticcms/core/interface'; import type { FileOrImageField, MediaPath, WidgetControlProps } from '@staticcms/core/interface';
import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy';
import type { FC, MouseEvent } from 'react'; import type { FC, MouseEvent } from 'react';
const MAX_DISPLAY_LENGTH = 50; const MAX_DISPLAY_LENGTH = 50;
@ -128,6 +133,41 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
handleOnChange, 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 chooseUrl = useMemo(() => field.choose_url ?? false, [field.choose_url]);
const handleUrl = useCallback( const handleUrl = useCallback(
@ -417,6 +457,21 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
return useMemo( 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 <Field
inputRef={allowsMultiple ? undefined : uploadButtonRef} inputRef={allowsMultiple ? undefined : uploadButtonRef}
label={label} label={label}
@ -429,8 +484,46 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
> >
{content} {content}
</Field> </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,
],
); );
}, },
); );