keystone
Version:
Web Application Framework and Admin GUI / Content Management System built on Express.js and Mongoose
340 lines (299 loc) • 8.45 kB
JavaScript
/*
TODO: CloudinaryImageType actally supports 'remove' and 'reset' actions, but
this field will only submit `""` when 'remove' is clicked. @jossmac we need to
work out whether we're going to support deleting through the UI.
*/
import React, { PropTypes } from 'react';
import Field from '../Field';
import cloudinaryResize from '../../../admin/client/utils/cloudinaryResize';
import { Button, FormField, FormInput, FormNote } from '../../../admin/client/App/elemental';
import ImageThumbnail from '../../components/ImageThumbnail';
import FileChangeMessage from '../../components/FileChangeMessage';
import HiddenFileInput from '../../components/HiddenFileInput';
import Lightbox from 'react-images';
const SUPPORTED_TYPES = ['image/*', 'application/pdf', 'application/postscript'];
const SUPPORTED_REGEX = new RegExp(/^image\/|application\/pdf|application\/postscript/g);
let uploadInc = 1000;
const buildInitialState = (props) => ({
removeExisting: false,
uploadFieldPath: `CloudinaryImage-${props.path}-${++uploadInc}`,
userSelectedFile: null,
});
module.exports = Field.create({
propTypes: {
collapse: PropTypes.bool,
label: PropTypes.string,
note: PropTypes.string,
path: PropTypes.string.isRequired,
value: PropTypes.shape({
format: PropTypes.string,
height: PropTypes.number,
public_id: PropTypes.string,
resource_type: PropTypes.string,
secure_url: PropTypes.string,
signature: PropTypes.string,
url: PropTypes.string,
version: PropTypes.number,
width: PropTypes.number,
}),
},
displayName: 'CloudinaryImageField',
statics: {
type: 'CloudinaryImage',
getDefaultValue: () => ({}),
},
getInitialState () {
return buildInitialState(this.props);
},
componentWillReceiveProps (nextProps) {
// console.log('CloudinaryImageField nextProps:', nextProps);
},
componentWillUpdate (nextProps) {
// Reset the action state when the value changes
// TODO: We should add a check for a new item ID in the store
if (this.props.value.public_id !== nextProps.value.public_id) {
this.setState({
removeExisting: false,
userSelectedFile: null,
});
}
},
// ==============================
// HELPERS
// ==============================
hasLocal () {
return !!this.state.userSelectedFile;
},
hasExisting () {
return !!(this.props.value && this.props.value.url);
},
hasImage () {
return this.hasExisting() || this.hasLocal();
},
getFilename () {
const { format, height, public_id, width } = this.props.value;
return this.state.userSelectedFile
? this.state.userSelectedFile.name
: `${public_id}.${format} (${width}×${height})`;
},
getImageSource (height = 90) {
// TODO: This lets really wide images break the layout
let src;
if (this.hasLocal()) {
src = this.state.dataUri;
} else if (this.hasExisting()) {
src = cloudinaryResize(this.props.value.public_id, {
crop: 'fit',
height: height,
format: 'jpg',
secure: this.props.secure,
});
}
return src;
},
// ==============================
// METHODS
// ==============================
triggerFileBrowser () {
this.refs.fileInput.clickDomNode();
},
handleFileChange (event) {
const userSelectedFile = event.target.files[0];
this.setState({ userSelectedFile });
},
// Toggle the lightbox
openLightbox (event) {
event.preventDefault();
this.setState({
lightboxIsVisible: true,
});
},
closeLightbox () {
this.setState({
lightboxIsVisible: false,
});
},
// Handle image selection in file browser
handleImageChange (e) {
if (!window.FileReader) {
return alert('File reader not supported by browser.');
}
var reader = new FileReader();
var file = e.target.files[0];
if (!file) return;
if (!file.type.match(SUPPORTED_REGEX)) {
return alert('Unsupported file type. Supported formats are: GIF, PNG, JPG, BMP, ICO, PDF, TIFF, EPS, PSD, SVG');
}
reader.readAsDataURL(file);
reader.onloadstart = () => {
this.setState({
loading: true,
});
};
reader.onloadend = (upload) => {
this.setState({
dataUri: upload.target.result,
loading: false,
userSelectedFile: file,
});
this.props.onChange({ file: file });
};
},
// If we have a local file added then remove it and reset the file field.
handleRemove (e) {
var state = {};
if (this.state.userSelectedFile) {
state.userSelectedFile = null;
} else if (this.hasExisting()) {
state.removeExisting = true;
}
this.setState(state);
},
undoRemove () {
this.setState(buildInitialState(this.props));
},
// ==============================
// RENDERERS
// ==============================
renderLightbox () {
const { value } = this.props;
if (!value || !value.public_id) return;
return (
<Lightbox
currentImage={0}
images={[{ src: this.getImageSource(600) }]}
isOpen={this.state.lightboxIsVisible}
onClose={this.closeLightbox}
showImageCount={false}
/>
);
},
renderImagePreview () {
const { value } = this.props;
// render icon feedback for intent
let mask;
if (this.hasLocal()) mask = 'upload';
else if (this.state.removeExisting) mask = 'remove';
else if (this.state.loading) mask = 'loading';
const shouldOpenLightbox = value.format !== 'pdf';
return (
<ImageThumbnail
component="a"
href={this.getImageSource(600)}
onClick={shouldOpenLightbox && this.openLightbox}
mask={mask}
target="__blank"
style={{ float: 'left', marginRight: '1em' }}
>
<img src={this.getImageSource()} style={{ height: 90 }} />
</ImageThumbnail>
);
},
renderFileNameAndOptionalMessage (showChangeMessage = false) {
return (
<div>
{this.hasImage() ? (
<FileChangeMessage>
{this.getFilename()}
</FileChangeMessage>
) : null}
{showChangeMessage && this.renderChangeMessage()}
</div>
);
},
renderChangeMessage () {
if (this.state.userSelectedFile) {
return (
<FileChangeMessage color="success">
Save to Upload
</FileChangeMessage>
);
} else if (this.state.removeExisting) {
return (
<FileChangeMessage color="danger">
Save to Remove
</FileChangeMessage>
);
} else {
return null;
}
},
// Output [cancel/remove/undo] button
renderClearButton () {
const clearText = this.hasLocal() ? 'Cancel' : 'Remove Image';
return this.state.removeExisting ? (
<Button variant="link" onClick={this.undoRemove}>
Undo Remove
</Button>
) : (
<Button variant="link" color="cancel" onClick={this.handleRemove}>
{clearText}
</Button>
);
},
renderImageToolbar () {
return (
<div key={this.props.path + '_toolbar'} className="image-toolbar">
<Button onClick={this.triggerFileBrowser}>
{this.hasImage() ? 'Change' : 'Upload'} Image
</Button>
{this.hasImage() ? this.renderClearButton() : null}
</div>
);
},
renderFileInput () {
if (!this.shouldRenderField()) return null;
return (
<HiddenFileInput
accept={SUPPORTED_TYPES.join()}
ref="fileInput"
name={this.state.uploadFieldPath}
onChange={this.handleImageChange}
/>
);
},
// This renders a hidden input that holds the payload data for how the field
// should be updated. It should be upload:{filename}, undefined, or 'remove'
renderActionInput () {
if (!this.shouldRenderField()) return null;
if (this.state.userSelectedFile || this.state.removeExisting) {
let value = '';
if (this.state.userSelectedFile) {
value = `upload:${this.state.uploadFieldPath}`;
} else if (this.state.removeExisting && this.props.autoCleanup) {
value = 'delete';
}
return (
<input
name={this.getInputName(this.props.path)}
type="hidden"
value={value}
/>
);
} else {
return null;
}
},
renderUI () {
const { label, note, path } = this.props;
const imageContainer = (
<div style={this.hasImage() ? { marginBottom: '1em' } : null}>
{this.hasImage() && this.renderImagePreview()}
{this.hasImage() && this.renderFileNameAndOptionalMessage(this.shouldRenderField())}
</div>
);
const toolbar = this.shouldRenderField()
? this.renderImageToolbar()
: <FormInput noedit />;
return (
<FormField label={label} className="field-type-cloudinaryimage" htmlFor={path}>
{imageContainer}
{toolbar}
{!!note && <FormNote note={note} />}
{this.renderLightbox()}
{this.renderFileInput()}
{this.renderActionInput()}
</FormField>
);
},
});