decap-cms-core
Version:
Decap CMS core application, see decap-cms package for the main distribution.
446 lines (431 loc) • 12.4 kB
JavaScript
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import orderBy from 'lodash/orderBy';
import map from 'lodash/map';
import { translate } from 'react-polyglot';
import fuzzy from 'fuzzy';
import { fileExtension } from 'decap-cms-lib-util';
import { loadMedia as loadMediaAction, persistMedia as persistMediaAction, deleteMedia as deleteMediaAction, insertMedia as insertMediaAction, loadMediaDisplayURL as loadMediaDisplayURLAction, closeMediaLibrary as closeMediaLibraryAction } from '../../actions/mediaLibrary';
import { selectMediaFiles } from '../../reducers/mediaLibrary';
import MediaLibraryModal, { fileShape } from './MediaLibraryModal';
/**
* Extensions used to determine which files to show when the media library is
* accessed from an image insertion field.
*/
import { jsx as _jsx } from "@emotion/react/jsx-runtime";
const IMAGE_EXTENSIONS_VIEWABLE = ['jpg', 'jpeg', 'webp', 'gif', 'png', 'bmp', 'tiff', 'svg', 'avif'];
const IMAGE_EXTENSIONS = [...IMAGE_EXTENSIONS_VIEWABLE];
class MediaLibrary extends React.Component {
static propTypes = {
isVisible: PropTypes.bool,
loadMediaDisplayURL: PropTypes.func,
displayURLs: ImmutablePropTypes.map,
canInsert: PropTypes.bool,
files: PropTypes.arrayOf(PropTypes.shape(fileShape)).isRequired,
dynamicSearch: PropTypes.bool,
dynamicSearchActive: PropTypes.bool,
forImage: PropTypes.bool,
isLoading: PropTypes.bool,
isPersisting: PropTypes.bool,
isDeleting: PropTypes.bool,
hasNextPage: PropTypes.bool,
isPaginating: PropTypes.bool,
privateUpload: PropTypes.bool,
config: ImmutablePropTypes.map,
loadMedia: PropTypes.func.isRequired,
dynamicSearchQuery: PropTypes.string,
page: PropTypes.number,
persistMedia: PropTypes.func.isRequired,
deleteMedia: PropTypes.func.isRequired,
insertMedia: PropTypes.func.isRequired,
closeMediaLibrary: PropTypes.func.isRequired,
t: PropTypes.func.isRequired
};
static defaultProps = {
files: []
};
/**
* The currently selected file and query are tracked in component state as
* they do not impact the rest of the application.
*/
state = {
selectedFile: {},
query: '',
isPersisted: false
};
componentDidMount() {
// Manually validate PropTypes - React 19 breaking change
PropTypes.checkPropTypes(MediaLibrary.propTypes, this.props, 'prop', 'MediaLibrary');
this.props.loadMedia();
}
UNSAFE_componentWillReceiveProps(nextProps) {
/**
* We clear old state from the media library when it's being re-opened
* because, when doing so on close, the state is cleared while the media
* library is still fading away.
*/
const isOpening = !this.props.isVisible && nextProps.isVisible;
if (isOpening) {
this.setState({
selectedFile: {},
query: ''
});
}
if (this.state.isPersisted) {
this.setState({
selectedFile: nextProps.files[0],
isPersisted: false
});
}
}
componentDidUpdate(prevProps) {
const isOpening = !prevProps.isVisible && this.props.isVisible;
if (isOpening && prevProps.privateUpload !== this.props.privateUpload) {
this.props.loadMedia({
privateUpload: this.props.privateUpload
});
}
if (this.state.isPersisted) {
this.setState({
selectedFile: this.props.files[0],
isPersisted: false
});
}
}
loadDisplayURL = file => {
const {
loadMediaDisplayURL
} = this.props;
loadMediaDisplayURL(file);
};
/**
* Filter an array of file data to include only images.
*/
filterImages = files => {
return files.filter(file => {
const ext = fileExtension(file.name).toLowerCase();
return IMAGE_EXTENSIONS.includes(ext);
});
};
/**
* Transform file data for table display.
*/
toTableData = files => {
const tableData = files && files.map(({
key,
name,
id,
size,
path,
queryOrder,
displayURL,
draft
}) => {
const ext = fileExtension(name).toLowerCase();
return {
key,
id,
name,
path,
type: ext.toUpperCase(),
size,
queryOrder,
displayURL,
draft,
isImage: IMAGE_EXTENSIONS.includes(ext),
isViewableImage: IMAGE_EXTENSIONS_VIEWABLE.includes(ext)
};
});
/**
* Get the sort order for use with `lodash.orderBy`, and always add the
* `queryOrder` sort as the lowest priority sort order.
*/
const {
sortFields
} = this.state;
const fieldNames = map(sortFields, 'fieldName').concat('queryOrder');
const directions = map(sortFields, 'direction').concat('asc');
return orderBy(tableData, fieldNames, directions);
};
handleClose = () => {
this.props.closeMediaLibrary();
};
/**
* Toggle asset selection on click.
*/
handleAssetClick = asset => {
const selectedFile = this.state.selectedFile.key === asset.key ? {} : asset;
this.setState({
selectedFile
});
};
/**
* Upload a file.
*/
handlePersist = async event => {
/**
* 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.
*/
event.persist();
event.stopPropagation();
event.preventDefault();
const {
persistMedia,
privateUpload,
config,
t,
field
} = this.props;
const {
files: fileList
} = event.dataTransfer || event.target;
const files = [...fileList];
const file = files[0];
const maxFileSize = config.get('max_file_size');
if (maxFileSize && file.size > maxFileSize) {
window.alert(t('mediaLibrary.mediaLibrary.fileTooLarge', {
size: Math.floor(maxFileSize / 1000)
}));
} else {
await persistMedia(file, {
privateUpload,
field
});
this.setState({
isPersisted: true
});
this.scrollToTop();
}
event.target.value = null;
};
/**
* Stores the public path of the file in the application store, where the
* editor field that launched the media library can retrieve it.
*/
handleInsert = () => {
const {
selectedFile
} = this.state;
const {
path
} = selectedFile;
const {
insertMedia,
field
} = this.props;
insertMedia(path, field);
this.handleClose();
};
/**
* Removes the selected file from the backend.
*/
handleDelete = () => {
const {
selectedFile
} = this.state;
const {
files,
deleteMedia,
privateUpload,
t
} = this.props;
if (!window.confirm(t('mediaLibrary.mediaLibrary.onDelete'))) {
return;
}
const file = files.find(file => selectedFile.key === file.key);
deleteMedia(file, {
privateUpload
}).then(() => {
this.setState({
selectedFile: {}
});
});
};
/**
* Downloads the selected file.
*/
handleDownload = () => {
const {
selectedFile
} = this.state;
const {
displayURLs
} = this.props;
const url = displayURLs.getIn([selectedFile.id, 'url']) || selectedFile.url;
if (!url) {
return;
}
const filename = selectedFile.name;
const element = document.createElement('a');
element.setAttribute('href', url);
element.setAttribute('download', filename);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
this.setState({
selectedFile: {}
});
};
/**
*
*/
handleLoadMore = () => {
const {
loadMedia,
dynamicSearchQuery,
page,
privateUpload
} = this.props;
loadMedia({
query: dynamicSearchQuery,
page: page + 1,
privateUpload
});
};
/**
* Executes media library search for implementations that support dynamic
* search via request. For these implementations, the Enter key must be
* pressed to execute search. If assets are being stored directly through
* the GitHub backend, search is in-memory and occurs as the query is typed,
* so this handler has no impact.
*/
handleSearchKeyDown = async event => {
const {
dynamicSearch,
loadMedia,
privateUpload
} = this.props;
if (event.key === 'Enter' && dynamicSearch) {
await loadMedia({
query: this.state.query,
privateUpload
});
this.scrollToTop();
}
};
scrollToTop = () => {
this.scrollContainerRef.scrollTop = 0;
};
/**
* Updates query state as the user types in the search field.
*/
handleSearchChange = event => {
this.setState({
query: event.target.value
});
};
/**
* Filters files that do not match the query. Not used for dynamic search.
*/
queryFilter = (query, files) => {
/**
* Because file names don't have spaces, typing a space eliminates all
* potential matches, so we strip them all out internally before running the
* query.
*/
const strippedQuery = query.replace(/ /g, '');
const matches = fuzzy.filter(strippedQuery, files, {
extract: file => file.name
});
const matchFiles = matches.map((match, queryIndex) => {
const file = files[match.index];
return {
...file,
queryIndex
};
});
return matchFiles;
};
render() {
const {
isVisible,
canInsert,
files,
dynamicSearch,
dynamicSearchActive,
forImage,
isLoading,
isPersisting,
isDeleting,
hasNextPage,
isPaginating,
privateUpload,
displayURLs,
t
} = this.props;
return _jsx(MediaLibraryModal, {
isVisible: isVisible,
canInsert: canInsert,
files: files,
dynamicSearch: dynamicSearch,
dynamicSearchActive: dynamicSearchActive,
forImage: forImage,
isLoading: isLoading,
isPersisting: isPersisting,
isDeleting: isDeleting,
hasNextPage: hasNextPage,
isPaginating: isPaginating,
privateUpload: privateUpload,
query: this.state.query,
selectedFile: this.state.selectedFile,
handleFilter: this.filterImages,
handleQuery: this.queryFilter,
toTableData: this.toTableData,
handleClose: this.handleClose,
handleSearchChange: this.handleSearchChange,
handleSearchKeyDown: this.handleSearchKeyDown,
handlePersist: this.handlePersist,
handleDelete: this.handleDelete,
handleInsert: this.handleInsert,
handleDownload: this.handleDownload,
setScrollContainerRef: ref => this.scrollContainerRef = ref,
handleAssetClick: this.handleAssetClick,
handleLoadMore: this.handleLoadMore,
displayURLs: displayURLs,
loadDisplayURL: this.loadDisplayURL,
t: t
});
}
}
function mapStateToProps(state) {
const {
mediaLibrary
} = state;
const field = mediaLibrary.get('field');
const mediaLibraryProps = {
isVisible: mediaLibrary.get('isVisible'),
canInsert: mediaLibrary.get('canInsert'),
files: selectMediaFiles(state, field),
displayURLs: mediaLibrary.get('displayURLs'),
dynamicSearch: mediaLibrary.get('dynamicSearch'),
dynamicSearchActive: mediaLibrary.get('dynamicSearchActive'),
dynamicSearchQuery: mediaLibrary.get('dynamicSearchQuery'),
forImage: mediaLibrary.get('forImage'),
isLoading: mediaLibrary.get('isLoading'),
isPersisting: mediaLibrary.get('isPersisting'),
isDeleting: mediaLibrary.get('isDeleting'),
privateUpload: mediaLibrary.get('privateUpload'),
config: mediaLibrary.get('config'),
page: mediaLibrary.get('page'),
hasNextPage: mediaLibrary.get('hasNextPage'),
isPaginating: mediaLibrary.get('isPaginating'),
field
};
return {
...mediaLibraryProps
};
}
const mapDispatchToProps = {
loadMedia: loadMediaAction,
persistMedia: persistMediaAction,
deleteMedia: deleteMediaAction,
insertMedia: insertMediaAction,
loadMediaDisplayURL: loadMediaDisplayURLAction,
closeMediaLibrary: closeMediaLibraryAction
};
export default connect(mapStateToProps, mapDispatchToProps)(translate()(MediaLibrary));