@bigfishtv/cockpit
Version:
765 lines (695 loc) • 22.5 kB
JavaScript
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
}