UNPKG

@bigfishtv/cockpit

Version:

765 lines (695 loc) 22.5 kB
import React, { Component } from 'react' import { connect } from 'react-redux' import { Fieldset, createValue } from '@bigfishtv/react-forms' import classnames from 'classnames' import deepEqual from 'deep-equal' import get from 'lodash/get' import $ from 'jquery' import { getImageUrl } from '../../utils/fileUtils' import { titleCase } from '../../utils/stringUtils' import { curvesHashTable } from '../../utils/colorUtils' import { post } from '../../api/xhrUtils' import { notifyWarning } from '../../actions/notifications' import Spinner from '../Spinner' import PropTypes from 'prop-types' import Icon from '../Icon' import MainContent from '../container/MainContent' import Bulkhead from '../page/Bulkhead' import Button from '../button/Button' import Field from '../form/Field' import Meter from '../input/MeterInput' import Cropper from '../editor/Cropper' import ProgressBarPredictive from '../ProgressBarPredictive' import DropdownAction from '../button/dropdown/DropdownAction' import DropdownItem from '../button/dropdown/DropdownItem' import * as tankCaman from '../../utils/tankCaman' let presetNames = [] const adjustmentOrder = [ 'temperature', 'exposure', 'contrast', 'curves', 'fade', 'vibrance', 'saturation', 'sharpen', 'grain', ] const defaultFormValue = { ratio: null, rotation: 0, crop: { top: 0, left: 0, width: 1, height: 1 }, temperature: 6600, exposure: 0, contrast: 0, darks: 0, lights: 0, fade: 0, vibrance: 0, saturation: 0, sharpen: 0, blur: 0, grain: 0, } const filterTimingEstimate = { exposure: 1, contrast: 1, curves: 1, vibrance: 0.5, saturation: 0.5, sharpen: 1.5, blur: 2, noise: 3.8, } const HeaderToolbar = props => ( <div> {props.saving && props.estimatedTime && <ProgressBarPredictive estimatedTime={props.estimatedTime} />} {props.standalone && <Button text="Close" size="large" onClick={props.onClose} />} {!props.saving && props.savedImageUrl && ( <Button text="View full-resolution" size="large" onClick={() => window.open(props.savedImageUrl)} /> )} <DropdownAction text={props.saving ? 'Saving...' : 'Save'} style="secondary" size="large" pullRight={true} onClick={props.onSave} disabled={props.saving}> <DropdownItem text="Save as a copy" onClick={props.onSaveCopy} /> </DropdownAction> </div> ) function estimatedSaveDuration(asset) { const steps = [...asset.transform_instructions.editSteps, ...asset.transform_instructions.filterSteps] const megapixels = (asset.width * asset.transform_instructions.settings.crop.width * (asset.height * asset.transform_instructions.settings.crop.height)) / 1000000 const equivalentSteps = steps.reduce((counter, step) => counter + (filterTimingEstimate[step.key] || 0.5), 0) // 0.3s for base image edit, 0.5s for round-trip // stats can be found here https://docs.google.com/spreadsheets/d/1qosKrfK9pnxC3rkiJ6VVEh-QZ3PhfclfezBGNrTraAQ/edit?usp=sharing const estimatedSeconds = ((0.55 * megapixels) / 7) * equivalentSteps + 0.3 + 0.5 return estimatedSeconds * 1000 } function getSavedImageUrl(asset) { return asset.transform_instructions ? '/uploads/_transformed/' + asset.filename + '?' + new Date().valueOf() : '/uploads/' + asset.filename } /** * Template for image editor, is also used in a modal */ @connect(({ imageFilterPresets }) => ({ imageFilterPresets })) export default class ImageEdit extends Component { static propTypes = { /** document that component is on -- hangover from attempting to open editor in new window */ currentDocument: PropTypes.any, /** Host string to append before image url e.g. http://dev.project, default is empty string so image urls will just be /imageurlwhatever */ host: PropTypes.string, /** Is component used outside a page template? If true displays things like a close button */ standalone: PropTypes.bool, /** Called on image save -- useful for updating an asset url if being used in a modal */ onAssetChange: PropTypes.func, } static defaultProps = { currentDocument: window.document, host: '', standalone: false, onAssetChange: () => console.warn('[ImageEdit] no onAssetChange prop'), } constructor(props) { super(props) tankCaman.init(window.Caman) presetNames = props.imageFilterPresets.map(preset => preset.title) const formValue = { ...defaultFormValue, ...get(props.asset, 'transform_instructions.settings', {}) } const cropped = !!get(props.asset, 'transform_instructions.settings.crop') const steps = [] get(props.asset, 'transform_instructions.editSteps', []).map(step => { if (step.value) steps[step.key] = step.value }) this.state = { loading: false, cropping: false, saving: false, savedImageUrl: getSavedImageUrl(props.asset), cropped, currentTray: null, currentFilter: formValue.filter, lights: get(formValue, 'lights', 0), darks: get(formValue, 'darks', 0), steps, url: props.host + getImageUrl(props.asset, 'cockpit-edit', null, true), previewUrl: props.host + getImageUrl(props.asset, 'cockpit-small', null, true), width: 640, height: 480, formValue: createValue({ schema: undefined, value: formValue, onChange: this.handleFormValueChange, }), } this.crop = formValue.crop this.origWidth = this.state.width this.origHeight = this.state.height this.stageHeight = 1000 const img = new Image() img.onload = () => { this.setState({ width: img.width, height: img.height }) this.origWidth = img.width this.origHeight = img.height // Go ahead and apply any previous edits, idk why it doesn't work immediately... setTimeout(() => this.applyImageFilter(), 500) } img.src = this.state.url } componentDidMount() { // Set current filter selection based on saved selection if (this.state.currentFilter) this.handleFilterClick(this.state.currentFilter) // @refactor to not use jquery this.stageHeight = $(this.refs.edit_inner).height() } handleSave = (saveAsCopy = false) => { if (this.state.cropping) { this.handleCrop() } const data = { ...this.props.asset, transform_instructions: this.generateInstructions() } this.setState({ saving: true, estimatedSaveDuration: estimatedSaveDuration(data) }) const action = saveAsCopy === true ? 'saveAs' : 'edit' post({ url: '/tank/assets/' + action + '/' + data.id + '.json', data, subject: 'image', callback: response => { // need this in case it's a belongsToMany with join data response._joinData = this.props.asset._joinData this.props.onAssetChange(response, this.props.asset) this.setState({ saving: false, savedImageUrl: getSavedImageUrl(response), }) }, callbackError: response => { if (response.status === 404) { this.props.dispatch(notifyWarning('"Save as a copy" feature requires Tank 3.0.4 or greater')) } this.setState({ saving: false }) console.warn('Error saving image', response) }, }) } handleClose = () => { this.props.onClose() this.props.closeModal() } handleFormValueChange = nextFormValue => { this.setState({ formValue: nextFormValue }) } // generates filter preview thumbnail, staggered for performance handleFilterImageLoad(elementId, filterName, delay = false) { const doIt = () => this.applyPreviewFilter(elementId, filterName) if (delay) setTimeout(doIt, delay) else doIt() } applyPreviewFilter(elementId, filterName) { const _this = this if (typeof Caman != 'undefined') Caman(this.props.currentDocument.getElementById(elementId), function() { this.resize({ width: 200, height: 150 }) this.render() const steps = _this.generateFilterSteps(filterName) _this.applyImageSteps.apply(this, [steps, false]) this.render() }) } applyImageFilter() { // make ref to this because caman binds its own const _this = this const editSteps = this.generateEditSteps() const filterSteps = this.generateFilterSteps() this.setState({ loading: true }) if (typeof Caman != 'undefined') Caman(this.props.currentDocument.getElementById('mainImage'), function() { this.revert() // if any edits have been made do them first if (editSteps.length) { // apply edit steps and render _this.applyImageSteps.apply(this, [editSteps]) this.render(() => { // if filter applied then execute those steps and render if (filterSteps.length) _this.applyImageSteps.apply(this, [filterSteps]) this.render(() => { _this.setState({ loading: false }) }) }) } else { // if filter applied then execute those steps and render if (filterSteps.length) _this.applyImageSteps.apply(this, [filterSteps]) this.render(() => { _this.setState({ loading: false }) }) } }) } // this function has scope of caman image applied to it applyImageSteps(_steps = [], verbose = true) { // copy array const steps = [..._steps] for (let step of steps) { const _step = { ...step } let { key, value } = _step // if value is an array the step's function requires more than 1 argument so run apply on it if (value instanceof Array) { if (verbose) console.log('Applying (with args)', key, value) if (key == 'curves' && (value[value.length - 1] === 'curvesHashTable' || value[value.length - 1] === null)) value[value.length - 1] = curvesHashTable this[key].apply(this, value) // step's function takes no argument so just call it } else if (typeof value == 'undefined') { if (verbose) console.log('Applying (without args)', key, value) this[key]() // in all other cases apply the step with 1 argument, except if value is a numeric 0 } else if (value !== 0) { if (verbose) console.log('Applying (as only arg)', key, value) if (typeof value == 'object') value = { ...value } // if it's an object then copy it this[key](value) } } } generateEditSteps() { let fullSteps = [] const { steps } = this.state const keys = Object.keys(steps) adjustmentOrder.map(key => { if (keys.indexOf(key) >= 0 && steps[key] !== 0) { let value = steps[key] if (value instanceof Array && value.length == 1) value = value[0] if (value !== 0) { const value = steps[key] fullSteps.push({ key, value }) } } }) return fullSteps } generateFilterSteps(filterName = null) { if (filterName === null) filterName = this.state.currentFilter let fullSteps = [] if (filterName !== null && presetNames.indexOf(filterName) >= 0) { this.props.imageFilterPresets.map(preset => { if (preset.title === filterName) fullSteps = [...preset.steps] }) } return fullSteps } generateInstructions() { const { formValue } = this.state const editSteps = this.generateEditSteps() const filterSteps = this.generateFilterSteps() const instructions = { settings: { ...formValue.value }, editSteps, filterSteps, } return instructions } handleFilterClick(filterName) { if (this.state.loading) return this.state.formValue.select('filter').update(filterName) this.setState({ currentFilter: filterName, loading: true }, () => { this.applyImageFilter() }) } handleReset = () => { this.state.formValue.select('filter').update(null) this.setState({ currentFilter: null }, () => { this.applyImageFilter() }) } toggleCrop = () => { this.setState({ cropping: !this.state.cropping }) this.toggleTray('crop') } updateCrop = crop => { this.crop = crop } handleCrop = () => { this.state.formValue.select('crop').update(this.crop) this.setState({ cropping: false, cropped: true, currentTray: null, }) } cancelCrop = () => { this.crop = this.state.formValue.value.crop this.setState({ cropping: false, cropped: !!this.crop, currentTray: null, }) } handleRotate(deg90s) { // keep track of rotation in state const rotation = (this.state.rotation + deg90s * 90) % 360 this.state.formValue.select('rotation').update(rotation) } toggleTray(trayName) { let { currentTray, cropping } = this.state if (currentTray == trayName) currentTray = null else currentTray = trayName this.setState({ currentTray }) if (cropping && trayName != 'crop') this.setState({ cropping: false }) } handleParamChange(param, value) { const { steps } = this.state let newSteps = steps newSteps[param] = typeof value !== 'object' ? [value] : value this.setState({ steps: newSteps }, () => { this.applyImageFilter() }) } handleParamsReset = () => { const newFormValue = { ...defaultFormValue } newFormValue.crop = this.state.formValue.value.crop this.state.formValue.update(newFormValue) this.setState({ steps: [] }, () => { this.applyImageFilter() }) } handleCurveChange(tone, value) { this.setState({ [tone]: value }, () => { const { lights, darks } = this.state this.handleParamChange('curves', [ 'rgb', [0, darks], [100, 100], [180, 180], [255, 255 - lights], curvesHashTable, ]) }) } render() { const { loading, cropping, saving, savedImageUrl, currentTray, currentFilter, formValue, url, previewUrl, height, cropped, } = this.state const resetable = deepEqual(formValue.value, defaultFormValue) const ratio = formValue.value.ratio ? ratioToDecimal(formValue.value.ratio) : null const { rotation, crop } = formValue.value const originalImageStyles = { position: 'absolute', width: this.origWidth, height: this.origHeight, backgroundSize: '100%', backgroundImage: 'url("' + url + '")', transform: 'translate(-50%, -50%) rotate(' + rotation + 'deg)', } // this is not ideal as it messes with cropping but this whole thing needs to be redone soon if (this.stageHeight < this.origHeight) originalImageStyles.zoom = this.stageHeight / (this.origHeight * 1.1) if (cropped && !cropping) { const cropTop = crop.top * this.origHeight const cropLeft = crop.left * this.origWidth const cropWidth = this.origWidth * crop.width const cropHeight = this.origHeight * crop.height const cropRight = cropLeft + cropWidth const cropBottom = cropTop + cropHeight const negativeWidth = cropLeft - (this.origWidth - cropRight) const negativeHeight = cropTop - (this.origHeight - cropBottom) originalImageStyles.clip = 'rect(' + cropTop + 'px ' + cropRight + 'px ' + cropBottom + 'px ' + cropLeft + 'px)' originalImageStyles.transform = 'translate(calc(-50% - ' + negativeWidth / 2 + 'px), calc(-50% - ' + negativeHeight / 2 + 'px)) rotate(' + rotation + 'deg)' } return ( <MainContent size="full"> <Bulkhead title="Edit Image" Toolbar={HeaderToolbar} onSave={this.handleSave} onSaveCopy={this.handleSave.bind(this, true)} onClose={this.handleClose} saving={saving} savedImageUrl={savedImageUrl} estimatedTime={this.state.estimatedSaveDuration} standalone={this.props.standalone} /> <div style={{ display: 'flex', flex: 'auto' }}> <div className="edit" ref="edit_inner"> <div className="edit-toolbar"> <Button style="icon" onClick={this.toggleCrop}> <Icon name="crop" /> </Button> <Button style="icon" onClick={() => this.toggleTray('advanced')}> <Icon name="advanced" /> </Button> {/* <Button style="icon" onClick={() => this.handleRotate(1)}> <Icon name="rotate-right" /> </Button> <Button style="icon" onClick={() => this.handleRotate(-1)}> <Icon name="rotate-left" /> </Button> */} </div> {currentTray == 'advanced' ? ( <div className="edit-toolbar edit-toolbar-advanced"> <Fieldset formValue={formValue}> <Field select="temperature" autoLabel={false}> <Meter min={4600} max={8600} icon="temperature" text="Temperature" onAfterChange={this.handleParamChange.bind(this, 'temperature')} /> </Field> <Field select="exposure" autoLabel={false}> <Meter min={-50} max={50} icon="sunny" text="Exposure" onAfterChange={this.handleParamChange.bind(this, 'exposure')} /> </Field> <Field select="contrast" autoLabel={false}> <Meter min={-20} max={20} icon="tonality" text="Contrast" onAfterChange={this.handleParamChange.bind(this, 'contrast')} /> </Field> <Field select="darks" autoLabel={false}> <Meter min={-80} max={80} icon="bring-forward" text="Shadow recovery" onAfterChange={this.handleCurveChange.bind(this, 'darks')} /> </Field> <Field select="lights" autoLabel={false}> <Meter min={-50} max={50} icon="send-backwards" text="Highlight recovery" onAfterChange={this.handleCurveChange.bind(this, 'lights')} /> </Field> <Field select="fade" autoLabel={false}> <Meter min={0} max={100} icon="moon-crescent" text="Fade" onAfterChange={this.handleParamChange.bind(this, 'fade')} /> </Field> <Field select="vibrance" autoLabel={false}> <Meter min={-100} max={100} icon="beach" text="Vibrance" onAfterChange={this.handleParamChange.bind(this, 'vibrance')} /> </Field> <Field select="saturation" autoLabel={false}> <Meter min={-100} max={100} icon="barrel-drop" text="Saturation" onAfterChange={this.handleParamChange.bind(this, 'saturation')} /> </Field> <Field select="sharpen" autoLabel={false}> <Meter min={0} max={100} icon="magic-wand" text="Sharpen" onAfterChange={this.handleParamChange.bind(this, 'sharpen')} /> </Field> <Field select="grain" autoLabel={false}> <Meter min={0} max={10} icon="grain" text="Grain" onAfterChange={this.handleParamChange.bind(this, 'noise')} /> </Field> <Button text={ <span> <Icon name="reset" size={14} /> Reset </span> } size="medium" style="margin-top-small" onClick={this.handleParamsReset} disabled={resetable} /> </Fieldset> </div> ) : currentTray == 'crop' ? ( <div className="edit-toolbar edit-toolbar-crop"> <Fieldset formValue={formValue}> <Field select="ratio" autoLabel={false}> <RatioButton text="3:2" /> </Field> <Field select="ratio" autoLabel={false}> <RatioButton text="4:3" /> </Field> <Field select="ratio" autoLabel={false}> <RatioButton text="5:4" /> </Field> <Field select="ratio" autoLabel={false}> <RatioButton text="1:1" /> </Field> <Field select="ratio" autoLabel={false}> <RatioButton text="4:5" /> </Field> <Field select="ratio" autoLabel={false}> <RatioButton text="3:4" /> </Field> <Field select="ratio" autoLabel={false}> <RatioButton text="2:3" /> </Field> <Field select="ratio" autoLabel={false}> <RatioButton text="2:1" /> </Field> <Field select="ratio" autoLabel={false}> <RatioButton text="1:2" /> </Field> <Field select="ratio" autoLabel={false}> <RatioButton text="16:9" /> </Field> <Field select="ratio" autoLabel={false}> <RatioButton text="9:16" /> </Field> </Fieldset> </div> ) : null} <div className="" style={{ flex: 'auto', display: 'flex', flexDirection: 'column' }}> <div className="edit-inner"> <div style={originalImageStyles}> <img src={url} id="mainImage" /> {cropping && ( <Cropper width={this.origWidth} height={height} ApplyButton={null} fixedRatio={ratio} onChange={this.updateCrop} defaultCrop={this.crop} /> )} </div> {loading && ( <div className="loader-center"> <Spinner size={32} /> </div> )} </div> {cropping && ( <div className="" style={{ flex: 'none', display: 'flex', background: '#ECECEF' }}> <div style={{ margin: '1.6rem auto' }}> <Button text="Cancel" onClick={this.cancelCrop} /> <Button text="Apply Crop" style="primary" onClick={this.handleCrop} style="primary" /> </div> </div> )} </div> </div> </div> <div className="image-filters"> <div className={classnames('image-filter', { active: currentFilter === null })}> <div className="media media-4-3"> <img src={previewUrl} onClick={this.handleReset} /> </div> <h4>Original</h4> </div> {presetNames.map((filterName, i) => ( <div key={i} className={classnames('image-filter', { active: currentFilter == filterName })} onClick={() => this.handleFilterClick(filterName)}> <div className="media media-4-3"> <img src={previewUrl} id={'filter' + i} onLoad={() => this.handleFilterImageLoad('filter' + i, filterName, i * 200)} /> </div> <h4>{titleCase(filterName)}</h4> </div> ))} </div> </MainContent> ) } } const RatioButton = props => ( <Button text={props.text} style={props.value == props.text ? 'primary' : null} onClick={() => props.onChange(props.value !== props.text ? props.text : null)} /> ) /** * Converts a string ratio to a decimal fraction. Returns null if doesn't match format * * ratioToDecimal('3:2') // 1.5 * * @param {string} ratio * @returns {number|null} */ function ratioToDecimal(ratio) { const matches = ratio.match(/^(\d+):(\d+)$/) if (matches) { return parseInt(matches[1]) / parseInt(matches[2]) } return null }