add media library

* rebase editorial workflow pull requests when behind

* fix async/await transpilation

* add media library pagination

* switch media library to grid layout

* ensure that only cms branches can be force updated
This commit is contained in:
Shawn Erquhart
2017-08-14 09:00:47 -04:00
parent 2a4af64a71
commit 6b45a46a39
43 changed files with 1662 additions and 362 deletions

View File

@ -7,13 +7,22 @@
/* Gross stuff below, React Toolbox hacks */
.nc-appHeader-homeLink,
.nc-appHeader-button,
.nc-appHeader-iconMenu {
margin-left: 2%;
margin-left: 16px;
}
.nc-appHeader-homeLink &icon {
vertical-align: top;
.nc-appHeader-button {
cursor: pointer;
border: 0;
background-color: transparent;
width: 36px;
padding: 6px 0;
text-align: center;
& .nc-appHeader-icon {
vertical-align: top;
}
}
.nc-appHeader-icon,

View File

@ -20,11 +20,6 @@ export default class AppHeader extends React.Component {
onLogoutClick: PropTypes.func.isRequired,
};
state = {
createMenuActive: false,
userMenuActive: false,
};
handleCreatePostClick = (collectionName) => {
const { onCreateEntryClick } = this.props;
if (onCreateEntryClick) {
@ -32,18 +27,6 @@ export default class AppHeader extends React.Component {
}
};
handleCreateButtonClick = () => {
this.setState({
createMenuActive: true,
});
};
handleCreateMenuHide = () => {
this.setState({
createMenuActive: false,
});
};
render() {
const {
user,
@ -51,6 +34,7 @@ export default class AppHeader extends React.Component {
runCommand,
toggleDrawer,
onLogoutClick,
openMediaLibrary,
} = this.props;
const avatarStyle = {
@ -59,7 +43,6 @@ export default class AppHeader extends React.Component {
const theme = {
appBar: 'nc-appHeader-appBar',
homeLink: 'nc-appHeader-homeLink',
iconMenu: 'nc-appHeader-iconMenu',
icon: 'nc-appHeader-icon',
leftIcon: 'nc-appHeader-leftIcon',
@ -76,17 +59,14 @@ export default class AppHeader extends React.Component {
theme={theme}
leftIcon="menu"
onLeftIconClick={toggleDrawer}
onRightIconClick={this.handleRightIconClick}
>
<Link to="/" className="nc-appHeader-homeLink">
<Link to="/" className="nc-appHeader-button">
<FontIcon value="home" className="nc-appHeader-icon" />
</Link>
<IconMenu
theme={theme}
icon="add"
onClick={this.handleCreateButtonClick}
onHide={this.handleCreateMenuHide}
>
<button onClick={openMediaLibrary} className="nc-appHeader-button">
<FontIcon value="perm_media" className="nc-appHeader-icon" />
</button>
<IconMenu icon="add" theme={theme}>
{
collections.filter(collection => collection.get('create')).toList().map(collection =>
<MenuItem

View File

@ -7,39 +7,15 @@
position: relative;
padding: 20px 0 10px 0;
margin-top: 16px;
}
.nc-controlPane-control input,
.nc-controlPane-control textarea,
.nc-controlPane-control select,
.nc-controlPane-control div[contenteditable=true] {
display: block;
width: 100%;
padding: 12px;
margin: 0;
border: var(--textFieldBorder);
border-radius: var(--borderRadius);
outline: 0;
box-shadow: none;
background-color: var(--controlBGColor);
font-size: 16px;
color: var(--textColor);
transition: border-color .3s ease;
&:focus,
&:active {
border-color: var(--primaryColor);
& input,
& textarea,
& select,
& div[contenteditable=true] {
@apply --input;
}
}
.nc-controlPane-control input,
.nc-controlPane-control textarea,
.nc-controlPane-control select {
font-family: var(--fontFamilyMono);
}
.nc-controlPane-label {
display: block;
color: var(--controlLabelColor);

View File

@ -24,7 +24,17 @@ export default class ControlPane extends Component {
};
controlFor(field) {
const { entry, fieldsMetaData, fieldsErrors, getAsset, onChange, onAddAsset, onRemoveAsset } = this.props;
const {
entry,
fieldsMetaData,
fieldsErrors,
mediaPaths,
getAsset,
onChange,
onOpenMediaLibrary,
onAddAsset,
onRemoveAsset
} = this.props;
const widget = resolveWidget(field.get('widget'));
const fieldName = field.get('name');
const value = entry.getIn(['data', fieldName]);
@ -48,9 +58,11 @@ export default class ControlPane extends Component {
controlComponent={widget.control}
field={field}
value={value}
mediaPaths={mediaPaths}
metadata={metadata}
onChange={(newValue, newMetadata) => onChange(fieldName, newValue, newMetadata)}
onValidate={this.props.onValidate.bind(this, fieldName)}
onOpenMediaLibrary={onOpenMediaLibrary}
onAddAsset={onAddAsset}
onRemoveAsset={onRemoveAsset}
getAsset={getAsset}
@ -87,7 +99,9 @@ ControlPane.propTypes = {
fields: ImmutablePropTypes.list.isRequired,
fieldsMetaData: ImmutablePropTypes.map.isRequired,
fieldsErrors: ImmutablePropTypes.map.isRequired,
mediaPaths: ImmutablePropTypes.map.isRequired,
getAsset: PropTypes.func.isRequired,
onOpenMediaLibrary: PropTypes.func.isRequired,
onAddAsset: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onValidate: PropTypes.func.isRequired,

View File

@ -7,7 +7,7 @@
position: absolute;
top: 8px;
right: 20px;
z-index: 1000;
z-index: 299;
opacity: 0.8;
display: flex;
justify-content: flex-end;
@ -41,7 +41,7 @@
height: 55px;
padding: 10px 20px;
position: absolute;
z-index: 9999;
z-index: 299;
left: 0;
right: 0;
bottom: 0;

View File

@ -52,12 +52,14 @@ class EntryEditor extends Component {
fields,
fieldsMetaData,
fieldsErrors,
mediaPaths,
getAsset,
onChange,
enableSave,
showDelete,
onDelete,
onValidate,
onOpenMediaLibrary,
onAddAsset,
onRemoveAsset,
onCancelEdit,
@ -102,9 +104,11 @@ class EntryEditor extends Component {
fields={fields}
fieldsMetaData={fieldsMetaData}
fieldsErrors={fieldsErrors}
mediaPaths={mediaPaths}
getAsset={getAsset}
onChange={onChange}
onValidate={onValidate}
onOpenMediaLibrary={onOpenMediaLibrary}
onAddAsset={onAddAsset}
onRemoveAsset={onRemoveAsset}
ref={c => this.controlPaneRef = c} // eslint-disable-line
@ -166,7 +170,9 @@ EntryEditor.propTypes = {
fields: ImmutablePropTypes.list.isRequired,
fieldsMetaData: ImmutablePropTypes.map.isRequired,
fieldsErrors: ImmutablePropTypes.map.isRequired,
mediaPaths: ImmutablePropTypes.map.isRequired,
getAsset: PropTypes.func.isRequired,
onOpenMediaLibrary: PropTypes.func.isRequired,
onAddAsset: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onValidate: PropTypes.func.isRequired,

View File

@ -0,0 +1,98 @@
@import './MediaLibraryFooter.css';
:root {
--mediaLibraryCardWidth: 300px;
--mediaLibraryCardMargin: 10px;
--mediaLibraryCardOutsideWidth: calc(var(--mediaLibraryCardWidth) + var(--mediaLibraryCardMargin) * 2);
}
.nc-mediaLibrary-dialog {
width: calc(var(--mediaLibraryCardOutsideWidth) + 48px);
@media (width >= 800px) {
width: calc(var(--mediaLibraryCardOutsideWidth) * 2 + 48px);
}
@media (width >= 1120px) {
width: calc(var(--mediaLibraryCardOutsideWidth) * 3 + 48px);
}
@media (width >= 1440px) {
width: calc(var(--mediaLibraryCardOutsideWidth) * 4 + 48px);
}
@media (width >= 1760px) {
width: calc(var(--mediaLibraryCardOutsideWidth) * 5 + 48px);
}
@media (width >= 2080px) {
width: calc(var(--mediaLibraryCardOutsideWidth) * 6 + 48px);
}
}
.nc-mediaLibrary-title {
position: absolute;
margin-top: 20px;
}
.nc-mediaLibrary-searchInput {
@apply --input;
font-family: var(--fontFamilyPrimary);
width: 50%;
max-width: 800px;
margin: 12px auto;
display: inline-block;
position: relative;
z-index: 1;
}
.nc-mediaLibrary-emptyMessage {
height: 100%;
width: 100%;
position: absolute;
top: 0;
left: 0;
display: flex;
justify-content: center;
align-items: center;
}
.nc-mediaLibrary-cardGrid-container {
height: calc(100% - 150px);
margin: 20px auto 0;
overflow-y: auto;
}
.nc-mediaLibrary-cardGrid {
display: flex;
flex-wrap: wrap;
}
.nc-mediaLibrary-card {
width: var(--mediaLibraryCardWidth);
height: 240px;
margin: var(--mediaLibraryCardMargin);
border: var(--textFieldBorder);
border-radius: var(--borderRadius);
cursor: pointer;
overflow: hidden;
}
.nc-mediaLibrary-card-selected {
border-color: var(--primaryColor);
}
.nc-mediaLibrary-cardImage {
width: 100%;
height: 160px;
object-fit: cover;
border-radius: 2px 2px 0 0;
}
.nc-mediaLibrary-cardText {
color: var(--textColor);
padding: 8px;
margin-top: 20px;
overflow-wrap: break-word;
line-height: 1.3 !important;
}

View File

@ -0,0 +1,332 @@
import React from 'react';
import { connect } from 'react-redux';
import { orderBy, get, last, isEmpty, map } from 'lodash';
import c from 'classnames';
import fuzzy from 'fuzzy';
import Waypoint from 'react-waypoint';
import Dialog from '../UI/Dialog';
import { resolvePath } from '../../lib/pathHelper';
import { changeDraftField } from '../../actions/entries';
import {
loadMedia as loadMediaAction,
persistMedia as persistMediaAction,
deleteMedia as deleteMediaAction,
insertMedia as insertMediaAction,
closeMediaLibrary as closeMediaLibraryAction,
} from '../../actions/mediaLibrary';
import MediaLibraryFooter from './MediaLibraryFooter';
/**
* 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' ];
const IMAGE_EXTENSIONS = [ ...IMAGE_EXTENSIONS_VIEWABLE, 'svg' ];
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: '' });
}
}
/**
* Filter an array of file data to include only images.
*/
filterImages = files => {
return files ? files.filter(file => IMAGE_EXTENSIONS.includes(last(file.name.split('.')))) : [];
};
/**
* Transform file data for table display.
*/
toTableData = files => {
const tableData = files && files.map(({ key, name, size, queryOrder, url, urlIsPublicPath }) => {
const ext = last(name.split('.'));
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.
*/
event.stopPropagation();
event.preventDefault();
const { loadMedia, persistMedia, privateUpload } = this.props;
const { files: fileList } = event.dataTransfer || event.target;
const files = [...fileList];
const file = files[0];
/**
* Upload the selected file, then refresh the media library. This should be
* improved in the future, but isn't currently resulting in noticeable
* performance/load time issues.
*/
await persistMedia(file, privateUpload);
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 } = 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)
.then(() => {
this.setState({ selectedFile: {} });
});
};
handleLoadMore = () => {
const { loadMedia, dynamicSearchQuery, page } = this.props;
loadMedia({ query: dynamicSearchQuery, page: page + 1 });
};
/**
* 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) => {
if (event.key === 'Enter' && this.props.dynamicSearch) {
await this.props.loadMedia({ query: this.state.query })
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,
} = this.props;
const { query, selectedFile } = this.state;
const filteredFiles = forImage ? this.filterImages(files) : files;
const queriedFiles = (!dynamicSearch && query) ? this.queryFilter(query, filteredFiles) : filteredFiles;
const tableData = this.toTableData(queriedFiles);
const hasFiles = files && !!files.length;
const hasFilteredFiles = filteredFiles && !!filteredFiles.length;
const hasSearchResults = queriedFiles && !!queriedFiles.length;
const hasMedia = hasSearchResults;
const shouldShowEmptyMessage = !hasMedia;
const emptyMessage = (isLoading && !hasMedia && 'Loading...')
|| (dynamicSearchActive && 'No results.')
|| (!hasFiles && 'No assets found.')
|| (!hasFilteredFiles && 'No images found.')
|| (!hasSearchResults && 'No results.');
return (
<Dialog
isVisible={isVisible}
onClose={this.handleClose}
className="nc-mediaLibrary-dialog"
footer={
<MediaLibraryFooter
onDelete={this.handleDelete}
onPersist={this.handlePersist}
onClose={this.handleClose}
onInsert={this.handleInsert}
hasSelection={hasMedia && !isEmpty(selectedFile)}
forImage={forImage}
canInsert={canInsert}
isPersisting={isPersisting}
isDeleting={isDeleting}
/>
}
>
<h1 className="nc-mediaLibrary-title">{forImage ? 'Images' : 'Assets'}</h1>
<input
className="nc-mediaLibrary-searchInput"
value={query}
onChange={this.handleSearchChange}
onKeyDown={event => this.handleSearchKeyDown(event)}
placeholder="Search..."
disabled={!dynamicSearchActive && !hasFilteredFiles}
autoFocus
/>
<div className="nc-mediaLibrary-cardGrid-container" ref={ref => (this.scrollContainerRef = ref)}>
<div className="nc-mediaLibrary-cardGrid">
{
tableData.map((file, idx) =>
<div
key={file.key}
className={c('nc-mediaLibrary-card', { 'nc-mediaLibrary-card-selected': selectedFile.key === file.key })}
onClick={() => this.handleAssetClick(file)}
tabIndex="-1"
>
<div className="nc-mediaLibrary-cardImage-container">
{
file.isViewableImage
? <img src={file.url} className="nc-mediaLibrary-cardImage"/>
: <div className="nc-mediaLibrary-cardImage"/>
}
</div>
<p className="nc-mediaLibrary-cardText">{file.name}</p>
</div>
)
}
{
hasNextPage
? <Waypoint onEnter={() => this.handleLoadMore()}/>
: null
}
</div>
{
shouldShowEmptyMessage
? <div className="nc-mediaLibrary-emptyMessage"><h1>{emptyMessage}</h1></div>
: null
}
{ isPaginating ? <h1 className="nc-mediaLibrary-paginatingMessage">Loading...</h1> : null }
</div>
</Dialog>
);
}
}
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);

View File

@ -0,0 +1,25 @@
.nc-mediaLibrary-footer-buttonRight {
float: right;
margin-left: 10px;
}
.nc-mediaLibrary-footer-buttonLeft {
float: left;
margin-right: 10px;
}
.nc-mediaLibrary-footer-button-loader {
float: left;
margin: 8px 20px;
}
.nc-mediaLibrary-footer-button-loaderSpinner {
position: static;
}
.nc-mediaLibrary-footer-button-loaderText {
position: relative;
top: 2px;
margin-left: 18px;
}

View File

@ -0,0 +1,64 @@
import React from 'react';
import { Button, BrowseButton } from 'react-toolbox/lib/button';
import Loader from '../UI/loader/Loader';
const MediaLibraryFooter = ({
onDelete,
onPersist,
onClose,
onInsert,
hasSelection,
forImage,
canInsert,
isPersisting,
isDeleting,
}) => {
const shouldShowLoader = isPersisting || isDeleting;
const loaderText = isPersisting ? 'Uploading...' : 'Deleting...';
const loader = (
<div className="nc-mediaLibrary-footer-button-loader">
<Loader className="nc-mediaLibrary-footer-button-loaderSpinner" active/>
<strong className="nc-mediaLibrary-footer-button-loaderText">{loaderText}</strong>
</div>
);
return (
<div>
<Button
label="Delete"
onClick={onDelete}
className="nc-mediaLibrary-footer-buttonLeft"
disabled={shouldShowLoader || !hasSelection}
accent
raised
/>
<BrowseButton
label="Upload"
accept={forImage}
onChange={onPersist}
className="nc-mediaLibrary-footer-buttonLeft"
disabled={shouldShowLoader}
primary
raised
/>
{ shouldShowLoader ? loader : null }
<Button
label="Close"
onClick={onClose}
className="nc-mediaLibrary-footer-buttonRight"
raised
/>
{ !canInsert ? null :
<Button
label="Insert"
onClick={onInsert}
className="nc-mediaLibrary-footer-buttonRight"
disabled={!hasSelection}
primary
raised
/>
}
</div>
);
};
export default MediaLibraryFooter;

View File

@ -0,0 +1,52 @@
.nc-dialog-wrapper {
z-index: 99999;
}
.nc-dialog-root {
height: 80%;
text-align: center;
max-width: 2200px;
}
.nc-dialog-body {
height: 100%;
}
.nc-dialog-contentWrapper {
height: 100%;
}
.nc-dialog-footer {
margin: 24px 0;
width: calc(100% - 48px);
position: absolute;
bottom: 0;
}
/**
* Progress Bar
*/
.nc-dialog-progressOverlay {
background-color: rgba(255, 255, 255, 0.75);
z-index: 1;
height: 100%;
width: 100%;
}
.nc-dialog-progressOverlay-active {
opacity: 1 !important;
}
.nc-dialog-progressBarContainer {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.nc-dialog-progressBar-linear {
width: 80%;
}

View File

@ -0,0 +1,16 @@
import React from 'react';
import FocusTrapReact from 'focus-trap-react';
/**
* A wrapper for focus-trap-react, which we use to completely remove focus traps
* from the DOM rather than using the library's own internal activation/pausing
* mechanisms, which can manifest bugs when nested.
*/
const FocusTrap = props => {
const { active, children, focusTrapOptions, className } = props;
return active
? <FocusTrapReact focusTrapOptions={focusTrapOptions} className={className}>{children}</FocusTrapReact>
: <div className={className}>{children}</div>
}
export default FocusTrap;

View File

@ -0,0 +1,65 @@
import React from 'react';
import RTDialog from 'react-toolbox/lib/dialog';
import Overlay from 'react-toolbox/lib/overlay';
import ProgressBar from 'react-toolbox/lib/progress_bar';
import FocusTrap from './FocusTrap';
const dialogTheme = {
wrapper: 'nc-dialog-wrapper',
dialog: 'nc-dialog-root',
body: 'nc-dialog-body',
};
const progressOverlayTheme = {
overlay: 'nc-dialog-progressOverlay',
active: 'nc-dialog-progressOverlay-active',
};
const progressBarTheme = {
linear: 'nc-dialog-progressBar-linear',
};
const Dialog = ({
type,
isVisible,
isLoading,
loadingMessage,
onClose,
footer,
className,
children,
}) =>
<RTDialog
type={type || 'large'}
active={isVisible}
onEscKeyDown={onClose}
onOverlayClick={onClose}
theme={dialogTheme}
className={className}
>
<FocusTrap
active={isVisible && !isLoading}
focusTrapOptions={{ clickOutsideDeactivates: true, fallbackFocus: '.fallbackFocus' }}
className="nc-dialog-contentWrapper"
>
<Overlay active={isLoading} theme={progressOverlayTheme}>
<FocusTrap
active={isVisible && isLoading}
focusTrapOptions={{ clickOutsideDeactivates: true, initialFocus: 'h1' }}
className="nc-dialog-progressBarContainer"
>
<h1 style={{ marginTop: '-40px' }} tabIndex="-1">{ loadingMessage }</h1>
<ProgressBar type="linear" mode="indeterminate" theme={progressBarTheme}/>
</FocusTrap>
</Overlay>
<div className="fallbackFocus" className="nc-dialog-contentWrapper">
{children}
</div>
{ footer ? <div className="nc-dialog-footer">{footer}</div> : null }
</FocusTrap>
</RTDialog>;
export default Dialog;

View File

@ -1,6 +1,6 @@
import React from 'react';
import CSSTransition from 'react-transition-group/CSSTransition';
import classnames from 'classnames';
import c from 'classnames';
export default class Loader extends React.Component {
@ -50,8 +50,8 @@ export default class Loader extends React.Component {
};
render() {
const { active } = this.props;
const className = classnames('nc-loader-root', { 'nc-loader-active': active });
return <div className={className}>{this.renderChild()}</div>;
const { active, className } = this.props;
const combinedClassName = c('nc-loader-root', { 'nc-loader-active': active }, className);
return <div className={combinedClassName}>{this.renderChild()}</div>;
}
}

View File

@ -1,5 +1,5 @@
:root {
--fontFamily: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
--fontFamilyPrimary: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
--fontFamilyMono: 'SFMono-Regular', Consolas, "Liberation Mono", Menlo, Courier, monospace;
--defaultColor: #333;
--defaultColorLight: #fff;
@ -32,6 +32,27 @@
--borderWidth: 2px;
--border: solid var(--borderWidth) var(--secondaryColor);
--textFieldBorder: var(--border);
--input: {
font-family: 'SFMono-Regular', Consolas, "Liberation Mono", Menlo, Courier, monospace;
display: block;
width: 100%;
padding: 12px;
margin: 0;
border: var(--textFieldBorder);
border-radius: var(--borderRadius);
outline: 0;
box-shadow: none;
background-color: var(--controlBGColor);
font-size: 16px;
color: var(--textColor);
transition: border-color .3s ease;
&:focus,
&:active {
border-color: var(--primaryColor);
}
}
}
.nc-theme-base {

View File

@ -16,21 +16,36 @@ class ControlHOC extends Component {
PropTypes.string,
PropTypes.bool,
]),
mediaPaths: ImmutablePropTypes.map.isRequired,
metadata: ImmutablePropTypes.map,
onChange: PropTypes.func.isRequired,
onValidate: PropTypes.func.isRequired,
onValidate: PropTypes.func,
onOpenMediaLibrary: PropTypes.func.isRequired,
onAddAsset: PropTypes.func.isRequired,
onRemoveAsset: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired,
};
shouldComponentUpdate(nextProps) {
/**
* Allow widgets to provide their own `shouldComponentUpdate` method.
*/
if (this.wrappedControlShouldComponentUpdate) {
return this.wrappedControlShouldComponentUpdate(nextProps);
}
return this.props.value !== nextProps.value;
}
processInnerControlRef = (wrappedControl) => {
if (!wrappedControl) return;
this.wrappedControlValid = wrappedControl.isValid || truthy;
/**
* Get the `shouldComponentUpdate` method from the wrapped control, and
* provide the control instance is the `this` binding.
*/
const { shouldComponentUpdate: scu } = wrappedControl;
this.wrappedControlShouldComponentUpdate = scu && scu.bind(wrappedControl);
};
validate = (skipWrapped = false) => {
@ -113,12 +128,25 @@ class ControlHOC extends Component {
};
render() {
const { controlComponent, field, value, metadata, onChange, onAddAsset, onRemoveAsset, getAsset } = this.props;
const {
controlComponent,
field,
value,
mediaPaths,
metadata,
onChange,
onOpenMediaLibrary,
onAddAsset,
onRemoveAsset,
getAsset
} = this.props;
return React.createElement(controlComponent, {
field,
value,
mediaPaths,
metadata,
onChange,
onOpenMediaLibrary,
onAddAsset,
onRemoveAsset,
getAsset,

View File

@ -1,115 +1,76 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from "react-immutable-proptypes";
import { get } from 'lodash';
import uuid from 'uuid/v4';
import { truncateMiddle } from '../../lib/textHelper';
import { Loader } from '../UI';
import AssetProxy, { createAssetProxy } from '../../valueObjects/AssetProxy';
const MAX_DISPLAY_LENGTH = 50;
export default class FileControl extends React.Component {
state = {
processing: false,
};
constructor(props) {
super(props);
this.controlID = uuid();
}
promise = null;
isValid = () => {
if (this.promise) {
return this.promise;
shouldComponentUpdate(nextProps) {
/**
* Always update if the value changes.
*/
if (this.props.value !== nextProps.value) {
return true;
}
return { error: false };
};
/**
* If there is a media path for this control in the state object, and that
* path is different than the value in `nextProps`, update.
*/
const mediaPath = nextProps.mediaPaths.get(this.controlID);
if (mediaPath && (nextProps.value !== mediaPath)) {
return true;
}
handleFileInputRef = (el) => {
this._fileInput = el;
};
return false;
}
componentWillReceiveProps(nextProps) {
const { mediaPaths, value } = nextProps;
const mediaPath = mediaPaths.get(this.controlID);
if (mediaPath && mediaPath !== value) {
this.props.onChange(mediaPath);
}
}
handleClick = (e) => {
this._fileInput.click();
};
handleDragEnter = (e) => {
e.stopPropagation();
e.preventDefault();
};
handleDragOver = (e) => {
e.stopPropagation();
e.preventDefault();
};
handleChange = (e) => {
e.stopPropagation();
e.preventDefault();
const fileList = e.dataTransfer ? e.dataTransfer.files : e.target.files;
const files = [...fileList];
const imageType = /^image\//;
// Return the first file on the list
const file = files[0];
this.props.onRemoveAsset(this.props.value);
if (file) {
this.setState({ processing: true });
this.promise = createAssetProxy(file.name, file, false, this.props.field.get('private', false))
.then((assetProxy) => {
this.setState({ processing: false });
this.props.onAddAsset(assetProxy);
this.props.onChange(assetProxy.public_path);
});
} else {
this.props.onChange(null);
}
const { field, onOpenMediaLibrary } = this.props;
return onOpenMediaLibrary({ controlID: this.controlID, privateUpload: field.private });
};
renderFileName = () => {
if (!this.props.value) return null;
if (this.value instanceof AssetProxy) {
return truncateMiddle(this.props.value.path, MAX_DISPLAY_LENGTH);
} else {
return truncateMiddle(this.props.value, MAX_DISPLAY_LENGTH);
}
const { value } = this.props;
return value ? truncateMiddle(value, MAX_DISPLAY_LENGTH) : null;
};
render() {
const { processing } = this.state;
const fileName = this.renderFileName();
if (processing) {
return (
<div className="nc-fileControl-imageUpload">
<span className="nc-fileControl-message">
<Loader active />
</span>
</div>
);
}
return (
<div
className="nc-fileControl-imageUpload"
onDragEnter={this.handleDragEnter}
onDragOver={this.handleDragOver}
onDrop={this.handleChange}
>
<div className="nc-fileControl-imageUpload">
<span className="nc-fileControl-message" onClick={this.handleClick}>
{fileName ? fileName : 'Click here to upload a file from your computer, or drag and drop a file directly into this box'}
{fileName ? fileName : 'Click here to select an asset from the asset library'}
</span>
<input
type="file"
onChange={this.handleChange}
className="nc-fileControl-input"
ref={this.handleFileInputRef}
/>
</div>
);
}
}
FileControl.propTypes = {
field: PropTypes.object.isRequired,
mediaPaths: ImmutablePropTypes.map.isRequired,
onAddAsset: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onRemoveAsset: PropTypes.func.isRequired,
onOpenMediaLibrary: PropTypes.func.isRequired,
value: PropTypes.node,
field: PropTypes.object,
};

View File

@ -1,119 +1,74 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from "react-immutable-proptypes";
import uuid from 'uuid/v4';
import { truncateMiddle } from '../../lib/textHelper';
import { Loader } from '../UI';
import AssetProxy, { createAssetProxy } from '../../valueObjects/AssetProxy';
const MAX_DISPLAY_LENGTH = 50;
export default class ImageControl extends React.Component {
state = {
processing: false,
};
constructor(props) {
super(props);
this.controlID = uuid();
}
promise = null;
isValid = () => {
if (this.promise) {
return this.promise;
shouldComponentUpdate(nextProps) {
/**
* Always update if the value changes.
*/
if (this.props.value !== nextProps.value) {
return true;
}
return { error: false };
};
/**
* If there is a media path for this control in the state object, and that
* path is different than the value in `nextProps`, update.
*/
const mediaPath = nextProps.mediaPaths.get(this.controlID);
if (mediaPath && (nextProps.value !== mediaPath)) {
return true;
}
handleFileInputRef = (el) => {
this._fileInput = el;
};
return false;
}
componentWillReceiveProps(nextProps) {
const { mediaPaths, value } = nextProps;
const mediaPath = mediaPaths.get(this.controlID);
if (mediaPath && mediaPath !== value) {
this.props.onChange(mediaPath);
}
}
handleClick = (e) => {
this._fileInput.click();
const { field, onOpenMediaLibrary } = this.props;
return onOpenMediaLibrary({ controlID: this.controlID, forImage: true, privateUpload: field.private });
};
handleDragEnter = (e) => {
e.stopPropagation();
e.preventDefault();
};
handleDragOver = (e) => {
e.stopPropagation();
e.preventDefault();
};
handleChange = (e) => {
e.stopPropagation();
e.preventDefault();
const fileList = e.dataTransfer ? e.dataTransfer.files : e.target.files;
const files = [...fileList];
const imageType = /^image\//;
// Iterate through the list of files and return the first image on the list
const file = files.find((currentFile) => {
if (imageType.test(currentFile.type)) {
return currentFile;
}
});
this.props.onRemoveAsset(this.props.value);
if (file) {
this.setState({ processing: true });
this.promise = createAssetProxy(file.name, file)
.then((assetProxy) => {
this.setState({ processing: false });
this.props.onAddAsset(assetProxy);
this.props.onChange(assetProxy.public_path);
});
} else {
this.props.onChange(null);
}
};
renderImageName = () => {
if (!this.props.value) return null;
if (this.value instanceof AssetProxy) {
return truncateMiddle(this.props.value.path, MAX_DISPLAY_LENGTH);
} else {
return truncateMiddle(this.props.value, MAX_DISPLAY_LENGTH);
}
renderFileName = () => {
const { value } = this.props;
return value ? truncateMiddle(value, MAX_DISPLAY_LENGTH) : null;
};
render() {
const { processing } = this.state;
const imageName = this.renderImageName();
if (processing) {
return (
<div className="nc-fileControl-imageUpload">
<span className="nc-fileControl-message">
<Loader active />
</span>
</div>
);
}
const fileName = this.renderFileName();
return (
<div
className="nc-fileControl-imageUpload"
onDragEnter={this.handleDragEnter}
onDragOver={this.handleDragOver}
onDrop={this.handleChange}
>
<div className="nc-fileControl-imageUpload">
<span className="nc-fileControl-message" onClick={this.handleClick}>
{imageName ? imageName : 'Click here to upload an image from your computer, or drag and drop a file directly into this box'}
{fileName ? fileName : 'Click here to select an image from the image library'}
</span>
<input
type="file"
accept="image/*"
onChange={this.handleChange}
className="nc-fileControl-input"
ref={this.handleFileInputRef}
/>
</div>
);
}
}
ImageControl.propTypes = {
field: PropTypes.object.isRequired,
mediaPaths: ImmutablePropTypes.map.isRequired,
onAddAsset: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onRemoveAsset: PropTypes.func.isRequired,
onOpenMediaLibrary: PropTypes.func.isRequired,
value: PropTypes.node,
};

View File

@ -1,6 +1,7 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { List, Map, fromJS } from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { SortableContainer, SortableElement, SortableHandle } from 'react-sortable-hoc';
import FontIcon from 'react-toolbox/lib/font_icon';
import ObjectControl from './ObjectControl';
@ -36,7 +37,9 @@ export default class ListControl extends Component {
value: PropTypes.node,
field: PropTypes.node,
forID: PropTypes.string,
mediaPaths: ImmutablePropTypes.map.isRequired,
getAsset: PropTypes.func.isRequired,
onOpenMediaLibrary: PropTypes.func.isRequired,
onAddAsset: PropTypes.func.isRequired,
onRemoveAsset: PropTypes.func.isRequired,
};
@ -47,6 +50,16 @@ export default class ListControl extends Component {
this.valueType = null;
}
/**
* Always update so that each nested widget has the option to update. This is
* required because ControlHOC provides a default `shouldComponentUpdate`
* which only updates if the value changes, but every widget must be allowed
* to override this.
*/
shouldComponentUpdate() {
return true;
}
componentDidMount() {
const { field } = this.props;
if (field.get('fields')) {
@ -147,7 +160,7 @@ export default class ListControl extends Component {
};
renderItem = (item, index) => {
const { field, getAsset, onAddAsset, onRemoveAsset } = this.props;
const { field, getAsset, mediaPaths, onOpenMediaLibrary, onAddAsset, onRemoveAsset } = this.props;
const { itemsCollapsed } = this.state;
const collapsed = itemsCollapsed.get(index);
const classNames = ['nc-listControl-item', collapsed ? 'nc-listControl-collapsed' : ''];
@ -167,6 +180,8 @@ export default class ListControl extends Component {
className="nc-listControl-objectControl"
onChange={this.handleChangeFor(index)}
getAsset={getAsset}
onOpenMediaLibrary={onOpenMediaLibrary}
mediaPaths={mediaPaths}
onAddAsset={onAddAsset}
onRemoveAsset={onRemoveAsset}
/>

View File

@ -1,10 +1,13 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from "react-immutable-proptypes";
import { connect } from 'react-redux';
import { Map } from 'immutable';
import { Button } from 'react-toolbox/lib/button';
import { openMediaLibrary } from '../../../../../actions/mediaLibrary';
import ToolbarPluginFormControl from './ToolbarPluginFormControl';
export default class ToolbarPluginForm extends React.Component {
class ToolbarPluginForm extends React.Component {
static propTypes = {
plugin: PropTypes.object.isRequired,
onSubmit: PropTypes.func.isRequired,
@ -12,6 +15,8 @@ export default class ToolbarPluginForm extends React.Component {
onAddAsset: PropTypes.func.isRequired,
onRemoveAsset: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired,
mediaPaths: ImmutablePropTypes.map.isRequired,
onOpenMediaLibrary: PropTypes.func.isRequired,
};
constructor(props) {
@ -37,6 +42,8 @@ export default class ToolbarPluginForm extends React.Component {
onRemoveAsset,
getAsset,
onChange,
onOpenMediaLibrary,
mediaPaths,
} = this.props;
return (
@ -54,6 +61,8 @@ export default class ToolbarPluginForm extends React.Component {
onChange={(val) => {
this.setState({ data: this.state.data.set(field.get('name'), val) });
}}
mediaPaths={mediaPaths}
onOpenMediaLibrary={onOpenMediaLibrary}
/>
))}
</div>
@ -66,3 +75,13 @@ export default class ToolbarPluginForm extends React.Component {
);
}
}
const mapStateToProps = state => ({
mediaPaths: state.mediaLibrary.get('controlMedia'),
});
const mapDispatchToProps = {
onOpenMediaLibrary: openMediaLibrary,
};
export default connect(mapStateToProps, mapDispatchToProps)(ToolbarPluginForm);

View File

@ -1,5 +1,6 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from "react-immutable-proptypes";
import { resolveWidget } from '../../../../Widgets';
const ToolbarPluginFormControl = ({
@ -10,11 +11,13 @@ const ToolbarPluginFormControl = ({
onRemoveAsset,
getAsset,
onChange,
mediaPaths,
onOpenMediaLibrary,
}) => {
const widget = resolveWidget(field.get('widget') || 'string');
const key = `field-${ field.get('name') }`;
const Control = widget.control;
const controlProps = { field, value, onAddAsset, onRemoveAsset, getAsset, onChange };
const controlProps = { field, value, onAddAsset, onRemoveAsset, getAsset, onChange, mediaPaths, onOpenMediaLibrary };
return (
<div className="nc-controlPane-control" key={key}>
@ -34,6 +37,8 @@ ToolbarPluginFormControl.propTypes = {
onRemoveAsset: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
mediaPaths: ImmutablePropTypes.map.isRequired,
onOpenMediaLibrary: PropTypes.func.isRequired,
};
export default ToolbarPluginFormControl;

View File

@ -18,7 +18,7 @@
overflow: hidden;
overflow-x: auto;
min-height: var(--richTextEditorMinHeight);
font-family: var(--fontFamily);
font-family: var(--fontFamilyPrimary);
}
.nc-visualEditor-editor h1 {

View File

@ -1,11 +1,15 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { Map } from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ControlHOC from './ControlHOC';
import { resolveWidget } from '../Widgets';
export default class ObjectControl extends Component {
static propTypes = {
onChange: PropTypes.func.isRequired,
onOpenMediaLibrary: PropTypes.func.isRequired,
mediaPaths: ImmutablePropTypes.map.isRequired,
onAddAsset: PropTypes.func.isRequired,
onRemoveAsset: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired,
@ -24,10 +28,25 @@ export default class ObjectControl extends Component {
* e.g. when debounced, always get the latest object value instead of usin
* `this.props.value` directly.
*/
getObjectValue = () => this.props.value;
getObjectValue = () => this.props.value || Map();
/*
* Always update so that each nested widget has the option to update. This is
* required because ControlHOC provides a default `shouldComponentUpdate`
* which only updates if the value changes, but every widget must be allowed
* to override this.
*/
shouldComponentUpdate() {
return true;
}
onChange = (fieldName, newValue, newMetadata) => {
const newObjectValue = this.getObjectValue().set(fieldName, newValue);
return this.props.onChange(newObjectValue, newMetadata);
};
controlFor(field) {
const { onAddAsset, onRemoveAsset, getAsset, value, onChange } = this.props;
const { onAddAsset, onOpenMediaLibrary, mediaPaths, onRemoveAsset, getAsset, value, onChange } = this.props;
if (field.get('widget') === 'hidden') {
return null;
}
@ -37,20 +56,18 @@ export default class ObjectControl extends Component {
return (<div className="nc-controlPane-widget" key={field.get('name')}>
<div className="nc-controlPane-control" key={field.get('name')}>
<label className="nc-controlPane-label" htmlFor={field.get('name')}>{field.get('label')}</label>
{
React.createElement(widget.control, {
id: field.get('name'),
field,
value: fieldValue,
onChange: (val, metadata) => {
onChange((this.getObjectValue() || Map()).set(field.get('name'), val), metadata);
},
onAddAsset,
onRemoveAsset,
getAsset,
forID: field.get('name'),
})
}
<ControlHOC
controlComponent={widget.control}
field={field}
value={fieldValue}
onChange={this.onChange.bind(this, field.get('name'))}
mediaPaths={mediaPaths}
onOpenMediaLibrary={onOpenMediaLibrary}
onAddAsset={onAddAsset}
onRemoveAsset={onRemoveAsset}
getAsset={getAsset}
forID={field.get('name')}
/>
</div>
</div>);
}