2018-07-28 16:33:42 -04:00

290 lines
8.8 KiB
JavaScript

import React from 'react';
import { connect } from 'react-redux';
import { orderBy, map } from 'lodash';
import fuzzy from 'fuzzy';
import { resolvePath, fileExtension } from 'netlify-cms-lib-util';
import {
loadMedia as loadMediaAction,
persistMedia as persistMediaAction,
deleteMedia as deleteMediaAction,
insertMedia as insertMediaAction,
closeMediaLibrary as closeMediaLibraryAction,
} from 'Actions/mediaLibrary';
import MediaLibraryModal from './MediaLibraryModal';
/**
* Extensions used to determine which files to show when the media library is
* accessed from an image insertion field.
*/
const IMAGE_EXTENSIONS_VIEWABLE = [ 'jpg', 'jpeg', 'webp', 'gif', 'png', 'bmp', 'tiff', 'svg' ];
const IMAGE_EXTENSIONS = [ ...IMAGE_EXTENSIONS_VIEWABLE ];
class MediaLibrary extends React.Component {
/**
* The currently selected file and query are tracked in component state as
* they do not impact the rest of the application.
*/
state = {
selectedFile: {},
query: '',
};
componentDidMount() {
this.props.loadMedia();
}
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 (isOpening && (this.props.privateUpload !== nextProps.privateUpload)) {
this.props.loadMedia({ privateUpload: nextProps.privateUpload });
}
}
/**
* 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, size, queryOrder, url, urlIsPublicPath }) => {
const ext = fileExtension(name).toLowerCase();
return {
key,
name,
type: ext.toUpperCase(),
size,
queryOrder,
url,
urlIsPublicPath,
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.stopPropagation();
event.preventDefault();
event.persist();
const { persistMedia, privateUpload } = this.props;
const { files: fileList } = event.dataTransfer || event.target;
const files = [...fileList];
const file = files[0];
await persistMedia(file, { privateUpload });
event.target.value = null;
this.scrollToTop();
};
/**
* 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 { name, url, urlIsPublicPath } = selectedFile;
const { insertMedia, publicFolder } = this.props;
const publicPath = urlIsPublicPath ? url : resolvePath(name, publicFolder);
insertMedia(publicPath);
this.handleClose();
};
/**
* Removes the selected file from the backend.
*/
handleDelete = () => {
const { selectedFile } = this.state;
const { files, deleteMedia, privateUpload } = this.props;
if (!window.confirm('Are you sure you want to delete selected media?')) {
return;
}
const file = files.find(file => selectedFile.key === file.key);
deleteMedia(file, { privateUpload })
.then(() => {
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,
page,
isPaginating,
privateUpload,
} = this.props;
return (
<MediaLibraryModal
isVisible={isVisible}
canInsert={canInsert}
files={files}
dynamicSearch={dynamicSearch}
dynamicSearchActive={dynamicSearchActive}
forImage={forImage}
isLoading={isLoading}
isPersisting={isPersisting}
isDeleting={isDeleting}
hasNextPage={hasNextPage}
page={page}
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}
setScrollContainerRef={ref => this.scrollContainerRef = ref}
handleAssetClick={this.handleAssetClick}
handleLoadMore={this.handleLoadMore}
/>
);
}
}
const mapStateToProps = state => {
const { config, mediaLibrary } = state;
const configProps = {
publicFolder: config.get('public_folder'),
};
const mediaLibraryProps = {
isVisible: mediaLibrary.get('isVisible'),
canInsert: mediaLibrary.get('canInsert'),
files: mediaLibrary.get('files'),
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'),
page: mediaLibrary.get('page'),
hasNextPage: mediaLibrary.get('hasNextPage'),
isPaginating: mediaLibrary.get('isPaginating'),
};
return { ...configProps, ...mediaLibraryProps };
};
const mapDispatchToProps = {
loadMedia: loadMediaAction,
persistMedia: persistMediaAction,
deleteMedia: deleteMediaAction,
insertMedia: insertMediaAction,
closeMediaLibrary: closeMediaLibraryAction,
};
export default connect(mapStateToProps, mapDispatchToProps)(MediaLibrary);