keystone
Version:
Web Application Framework and Admin GUI / Content Management System built on Express.js and Mongoose
309 lines (280 loc) • 8.26 kB
JavaScript
/*
TODO: This component manages thumbnails using some wacky internal state.
It works, but would really benefit from a cleanup/rewrite. It may not behave
as expected in different situations (i.e. does not report updated value
to props.onChange correctly as the user interacts with it)
*/
import _ from 'lodash';
import async from 'async';
import React, { cloneElement } from 'react';
import Field from '../Field';
import { Button, FormField, FormNote } from '../../../admin/client/App/elemental';
import Lightbox from 'react-images';
import cloudinaryResize from '../../../admin/client/utils/cloudinaryResize';
import Thumbnail from './CloudinaryImagesThumbnail';
import HiddenFileInput from '../../components/HiddenFileInput';
import FileChangeMessage from '../../components/FileChangeMessage';
const SUPPORTED_TYPES = ['image/*', 'application/pdf', 'application/postscript'];
const SUPPORTED_REGEX = new RegExp(/^image\/|application\/pdf|application\/postscript/g);
const RESIZE_DEFAULTS = {
crop: 'fit',
format: 'jpg',
};
let uploadInc = 1000;
module.exports = Field.create({
displayName: 'CloudinaryImagesField',
statics: {
type: 'CloudinaryImages',
getDefaultValue: () => ([]),
},
getInitialState () {
return this.buildInitialState(this.props);
},
componentWillUpdate (nextProps) {
// Reset the thumbnails and upload ID when the item value changes
// TODO: We should add a check for a new item ID in the store
const value = _.map(this.props.value, 'public_id').join();
const nextValue = _.map(nextProps.value, 'public_id').join();
if (value !== nextValue) {
this.setState(this.buildInitialState(nextProps));
}
},
buildInitialState (props) {
const uploadFieldPath = `CloudinaryImages-${props.path}-${++uploadInc}`;
const thumbnails = props.value ? props.value.map((img, index) => {
return this.getThumbnail({
value: img,
imageSourceSmall: cloudinaryResize(img.public_id, {
...RESIZE_DEFAULTS,
height: 90,
secure: props.secure,
}),
imageSourceLarge: cloudinaryResize(img.public_id, {
...RESIZE_DEFAULTS,
height: 600,
width: 900,
secure: props.secure,
}),
}, index);
}) : [];
return { thumbnails, uploadFieldPath };
},
getThumbnail (props, index) {
return (
<Thumbnail
key={`thumbnail-${index}`}
inputName={this.getInputName(this.props.path)}
openLightbox={(e) => this.openLightbox(e, index)}
shouldRenderActionButton={this.shouldRenderField()}
toggleDelete={this.removeImage.bind(this, index)}
{...props}
/>
);
},
// ==============================
// HELPERS
// ==============================
triggerFileBrowser () {
this.refs.fileInput.clickDomNode();
},
hasFiles () {
return this.refs.fileInput && this.refs.fileInput.hasValue();
},
openLightbox (event, index) {
event.preventDefault();
this.setState({
lightboxIsVisible: true,
lightboxImageIndex: index,
});
},
closeLightbox () {
this.setState({
lightboxIsVisible: false,
lightboxImageIndex: null,
});
},
lightboxPrevious () {
this.setState({
lightboxImageIndex: this.state.lightboxImageIndex - 1,
});
},
lightboxNext () {
this.setState({
lightboxImageIndex: this.state.lightboxImageIndex + 1,
});
},
// ==============================
// METHODS
// ==============================
removeImage (index) {
const newThumbnails = [...this.state.thumbnails];
const target = newThumbnails[index];
// Use splice + clone to toggle the isDeleted prop
newThumbnails.splice(index, 1, cloneElement(target, {
isDeleted: !target.props.isDeleted,
}));
this.setState({ thumbnails: newThumbnails });
},
getCount (key) {
var count = 0;
this.state.thumbnails.forEach((thumb) => {
if (thumb && thumb.props[key]) count++;
});
return count;
},
clearFiles () {
this.refs.fileInput.clearValue();
this.setState({
thumbnails: this.state.thumbnails.filter(function (thumb) {
return !thumb.props.isQueued;
}),
});
},
uploadFile (event) {
if (!window.FileReader) {
return alert('File reader not supported by browser.');
}
// FileList not a real Array; process it into one and check the types
const files = [];
for (let i = 0; i < event.target.files.length; i++) {
const f = event.target.files[i];
if (!f.type.match(SUPPORTED_REGEX)) {
return alert('Unsupported file type. Supported formats are: GIF, PNG, JPG, BMP, ICO, PDF, TIFF, EPS, PSD, SVG');
}
files.push(f);
}
let index = this.state.thumbnails.length;
async.mapSeries(files, (file, callback) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (e) => {
callback(null, this.getThumbnail({
isQueued: true,
imageSourceSmall: e.target.result,
}, index++));
};
}, (err, thumbnails) => {
this.setState({
thumbnails: [...this.state.thumbnails, ...thumbnails],
});
});
},
// ==============================
// RENDERERS
// ==============================
renderFileInput () {
if (!this.shouldRenderField()) return null;
return (
<HiddenFileInput
accept={SUPPORTED_TYPES.join()}
key={this.state.uploadFieldPath}
multiple
name={this.state.uploadFieldPath}
onChange={this.uploadFile}
ref="fileInput"
/>
);
},
renderValueInput () {
if (!this.shouldRenderField()) return null;
// This renders an input with either the upload field reference, or an
// empty value to reset the field if all images have been removed
if (this.hasFiles()) {
return (
<input
name={this.getInputName(this.props.path)}
value={`upload:${this.state.uploadFieldPath}`}
type="hidden"
/>
);
} else if (this.getCount('isDeleted') === this.props.value.length) {
return (
<input
name={this.getInputName(this.props.path)}
value=""
type="hidden"
/>
);
}
},
renderLightbox () {
const { value, secure } = this.props;
if (!value || !value.length) return;
const images = value.map(image => ({
src: cloudinaryResize(image.public_id, {
...RESIZE_DEFAULTS,
height: 600,
width: 900,
secure,
}),
}));
return (
<Lightbox
images={images}
currentImage={this.state.lightboxImageIndex}
isOpen={this.state.lightboxIsVisible}
onClickPrev={this.lightboxPrevious}
onClickNext={this.lightboxNext}
onClose={this.closeLightbox}
/>
);
},
renderToolbar () {
if (!this.shouldRenderField()) return null;
const uploadCount = this.getCount('isQueued');
const deleteCount = this.getCount('isDeleted');
// provide a gutter for the change message
// only required when no cancel button, which has equiv. padding
const uploadButtonStyles = !this.hasFiles()
? { marginRight: 10 }
: {};
// prepare the change message
const changeMessage = uploadCount || deleteCount ? (
<FileChangeMessage>
{uploadCount && deleteCount ? `${uploadCount} added and ${deleteCount} removed` : null}
{uploadCount && !deleteCount ? `${uploadCount} image added` : null}
{!uploadCount && deleteCount ? `${deleteCount} image removed` : null}
</FileChangeMessage>
) : null;
// prepare the save message
const saveMessage = uploadCount || deleteCount ? (
<FileChangeMessage color={!deleteCount ? 'success' : 'danger'}>
Save to {!deleteCount ? 'Upload' : 'Confirm'}
</FileChangeMessage>
) : null;
// clear floating images above
const toolbarStyles = {
clear: 'both',
};
return (
<div style={toolbarStyles}>
<Button onClick={this.triggerFileBrowser} style={uploadButtonStyles} data-e2e-upload-button="true">
Upload Images
</Button>
{this.hasFiles() && (
<Button variant="link" color="cancel" onClick={this.clearFiles}>
Clear selection
</Button>
)}
{changeMessage}
{saveMessage}
</div>
);
},
renderUI () {
const { label, note, path } = this.props;
const { thumbnails } = this.state;
return (
<FormField label={label} className="field-type-cloudinaryimages" htmlFor={path}>
<div>
{thumbnails}
</div>
{this.renderValueInput()}
{this.renderFileInput()}
{this.renderToolbar()}
{!!note && <FormNote note={note} />}
{this.renderLightbox()}
</FormField>
);
},
});