drapcode-builder
Version:
Drapcode Builder Library
703 lines (604 loc) • 18.6 kB
JavaScript
import Backbone from 'backbone';
import { bindAll, isArray, isUndefined, debounce } from 'underscore';
import { camelCase, isObject } from 'utils/mixins';
import { includes, each } from 'underscore';
const clearProp = 'data-clear-style';
export default Backbone.View.extend({
template() {
const { pfx, ppfx } = this;
return `
<div class="${pfx}label" data-sm-label></div>
<div class="${ppfx}fields" data-sm-fields></div>
`;
},
templateLabel(model) {
const { pfx, em } = this;
const { parent } = model;
const { icon = '', info = '', id, name } = model.attributes;
const label = (em && em.t(`styleManager.properties.${id}`)) || name;
return `
<span class="${pfx}icon ${icon}" title="${info}">
${label}
</span>
${!parent ? `<b class="${pfx}clear" ${clearProp}>⨯</b>` : ''}
`;
},
templateInput(model) {
return `
<div class="${this.ppfx}field">
<input placeholder="${model.getDefaultValue()}"/>
</div>
`;
},
events: {
change: 'inputValueChanged',
[`click [${clearProp}]`]: 'clear'
},
initialize(o = {}) {
bindAll(this, 'targetUpdated', '__change', '__updateStyle');
this.config = o.config || {};
const em = this.config.em;
this.em = em;
this.pfx = this.config.stylePrefix || '';
this.ppfx = this.config.pStylePrefix || '';
this.target = o.target || {};
this.propTarget = o.propTarget || {};
this.onChange = o.onChange;
this.onInputRender = o.onInputRender || {};
this.customValue = o.customValue || {};
const model = this.model;
this.property = model.get('property');
this.input = null;
const pfx = this.pfx;
this.inputHolderId = '#' + pfx + 'input-holder';
this.sector = model.collection && model.collection.sector;
this.__destroyFn = this.destroy ? this.destroy.bind(this) : () => {};
model.view = this;
if (!model.get('value')) {
model.set('value', model.getDefaultValue());
}
em && em.on(`update:component:style:${this.property}`, this.targetUpdated);
//em && em.on(`styleable:change:${this.property}`, this.targetUpdated);
// Listening to changes of properties in this.requires, so that styleable
// changes based on other properties are propagated
const requires = model.get('requires');
requires &&
Object.keys(requires).forEach(property => {
em && em.on(`component:styleUpdate:${property}`, this.targetUpdated);
});
this.listenTo(
this.propTarget,
'update styleManager:update',
this.targetUpdated
);
this.listenTo(model, 'destroy remove', this.remove);
this.listenTo(model, 'change:value', this.modelValueChanged);
this.listenTo(model, 'targetUpdated', this.targetUpdated);
this.listenTo(model, 'change:visible', this.updateVisibility);
this.listenTo(model, 'change:status', this.updateStatus);
this.listenTo(
model,
'change:name change:className change:full',
this.render
);
const init = this.init && this.init.bind(this);
init && init();
},
remove() {
Backbone.View.prototype.remove.apply(this, arguments);
this.__destroyFn(this._getClbOpts());
},
/**
* Triggers when the status changes. The status indicates if the value of
* the proprerty is changed or inherited
* @private
*/
updateStatus() {
const { model } = this;
const status = model.get('status');
const parent = model.parent;
const pfx = this.pfx;
const ppfx = this.ppfx;
const config = this.config;
const updatedCls = `${ppfx}four-color`;
const computedCls = `${ppfx}color-warn`;
const labelEl = this.$el.children(`.${pfx}label`);
const clearStyleEl = this.getClearEl();
const clearStyle = clearStyleEl ? clearStyleEl.style : {};
labelEl.removeClass(`${updatedCls} ${computedCls}`);
clearStyle.display = 'none';
switch (status) {
case 'updated':
!parent && labelEl.addClass(updatedCls);
if (config.clearProperties) {
clearStyle.display = 'inline';
}
break;
case 'computed':
labelEl.addClass(computedCls);
break;
}
},
/**
* Clear the property from the target
*/
clear(ev) {
ev && ev.stopPropagation();
this.model.clearValue();
// Skip one stack with setTimeout to avoid inconsistencies (eg. visible on padding composite clear)
setTimeout(() => this.targetUpdated());
},
/**
* Get clear element
* @return {HTMLElement}
*/
getClearEl() {
if (!this.clearEl) {
this.clearEl = this.el.querySelector(`[${clearProp}]`);
}
return this.clearEl;
},
/**
* Returns selected target which should have 'style' property
* @return {Model|null}
*/
getTarget() {
return this.getTargetModel();
},
getTargets() {
const { targets } = this.propTarget;
return targets || [this.getTarget()];
},
/**
* Returns Styleable model
* @return {Model|null}
*/
getTargetModel() {
return this.propTarget && this.propTarget.model;
},
/**
* Returns helper Styleable model
* @return {Model|null}
*/
getHelperModel() {
return this.propTarget && this.propTarget.helper;
},
/**
* Triggers when the value of element input/s is changed, so have to update
* the value of the model which will propogate those changes to the target
*/
inputValueChanged(ev) {
ev && ev.stopPropagation();
if (this.emit) return;
this.model.setValueFromInput(this.getInputValue());
this.elementUpdated();
},
/**
* Fired when the element of the property is updated
*/
elementUpdated() {
this.setStatus('updated');
},
setStatus(value) {
this.model.set('status', value);
const parent = this.model.parent;
parent && value == 'updated' && parent.set('status', value);
},
emitUpdateTarget: debounce(function() {
const em = this.config.em;
em && em.trigger('styleManager:update:target', this.getTarget());
}),
_getTargetData() {
const { model, config } = this;
const targetValue = this.getTargetValue({ ignoreDefault: 1 });
const defaultValue = model.getDefaultValue();
const computedValue = this.getComputedValue();
let value = '';
let status = '';
if (targetValue) {
value = targetValue;
if (config.highlightChanged) {
status = 'updated';
}
} else if (
computedValue &&
config.showComputed &&
computedValue != defaultValue
) {
value = computedValue;
if (config.highlightComputed) {
status = 'computed';
}
} else {
value = defaultValue;
status = '';
}
return {
value,
status,
targetValue,
defaultValue,
computedValue
};
},
/**
* Fired when the target is changed
* */
targetUpdated(mod, val, opts = {}) {
// Skip properties rendered in Stack Layers
if (this.config.fromLayer) return;
this.emitUpdateTarget();
if (!this.checkVisibility()) {
return;
}
const config = this.config;
const em = config.em;
const { model } = this;
const property = model.get('property');
const { status, value, ...targetData } = this._getTargetData();
const data = {
status,
value,
...targetData
};
this.setStatus(status);
model.setValue(value, 0, { fromTarget: 1, ...opts });
if (em) {
em.trigger('styleManager:change', this, property, value, data);
em.trigger(`styleManager:change:${property}`, this, value, data);
this._emitUpdate(data);
}
return data;
},
_emitUpdate(addData = {}) {
const { em, model } = this;
if (!em) return;
const property = model.get('property');
const data = { ...this._getEventData(), ...addData };
const { id } = data;
em.trigger('style:update', data);
em.trigger(`style:update:${property}`, data);
property !== id && em.trigger(`style:update:${id}`, data);
},
_getEventData() {
const { model } = this;
return {
propertyView: this,
targets: this.getTargets(),
value: model.getFullValue(),
property: model,
id: model.get('id'),
name: model.get('property')
};
},
checkVisibility() {
var result = 1;
// Check if need to hide the property
if (this.config.hideNotStylable) {
if (!this.isTargetStylable() || !this.isComponentStylable()) {
this.hide();
result = 0;
} else {
this.show();
}
// Sector is not passed to Composite and Stack types
if (this.sector) {
this.sector.trigger('updateVisibility');
}
}
return result;
},
/**
* Get the value of this property from the target (eg, Component, CSSRule)
* @param {Object} [opts] Options
* @param {Boolean} [options.fetchFromFunction]
* @param {Boolean} [options.ignoreDefault]
* @return string
* @private
*/
getTargetValue(opts = {}) {
let result;
const { model } = this;
const target = this.getTargetModel();
const customFetchValue = this.customValue;
if (!target) {
return result;
}
result = target.getStyle()[model.get('property')];
if (!result && !opts.ignoreDefault) {
result = model.getDefaultValue();
}
if (typeof customFetchValue == 'function' && !opts.ignoreCustomValue) {
let index = model.collection.indexOf(model);
let customValue = customFetchValue(this, index, result);
if (customValue) {
result = customValue;
}
}
return result;
},
/**
* Returns computed value
* @return {String}
* @private
*/
getComputedValue() {
const target = this.propTarget;
const computed = target.computed || {};
const computedDef = target.computedDefault || {};
const avoid = this.config.avoidComputed || [];
const property = this.model.get('property');
const notToSkip = avoid.indexOf(property) < 0;
const value = computed[property];
const valueDef = computedDef[camelCase(property)];
return (computed && notToSkip && valueDef !== value && value) || '';
},
/**
* Returns value from input
* @return {string}
*/
getInputValue() {
const input = this.getInputEl();
return input ? input.value : '';
},
/**
* Triggers when the `value` of the model changes, so the target and
* the input element should be updated
* @param {Object} e Event
* @param {Mixed} val Value
* @param {Object} opt Options
* */
modelValueChanged(e, val, opt = {}) {
const model = this.model;
const value = model.getFullValue();
// Avoid element update if the change comes from it
if (!opt.fromInput) {
this.setValue(value);
}
// Avoid target update if the changes comes from it
if (!opt.fromTarget) {
this.getTargets().forEach(target => this.__updateTarget(target, opt));
}
},
__updateTarget(target, opt = {}) {
const { model } = this;
const { em } = this.config;
const prop = model.get('property');
const value = model.getFullValue();
const onChange = this.onChange;
// Check if component is allowed to be styled
if (
!target ||
!this.isTargetStylable(target) ||
!this.isComponentStylable()
) {
return;
}
// Avoid target update if the changes comes from it
if (!opt.fromTarget) {
// The onChange is used by Composite/Stack properties, so I'd avoid sending
// it back if the change comes from one of those
if (onChange && !opt.fromParent) {
onChange(target, this, opt);
} else {
this.updateTargetStyle(value, null, { ...opt, target });
}
}
// TODO: use target if componentFirst
const component = em && em.getSelected();
if (em && component) {
!opt.noEmit && em.trigger('component:update', component);
em.trigger('component:styleUpdate', component, prop);
em.trigger(`component:styleUpdate:${prop}`, component);
}
this._emitUpdate();
},
/**
* Update target style
* @param {string} value
* @param {string} name
* @param {Object} opts
*/
updateTargetStyle(value, name = '', opts = {}) {
const property = name || this.model.get('property');
const target = opts.target || this.getTarget();
const style = target.getStyle();
if (value) {
style[property] = value;
} else {
delete style[property];
}
// Forces to trigger the change (for UndoManager)
if (opts.avoidStore) {
style.__ = 1;
} else {
delete style.__;
}
target.setStyle(style, opts);
// Helper is used by `states` like ':hover' to show its preview
const helper = this.getHelperModel();
helper && helper.setStyle(style, opts);
},
/**
* Check if target is stylable with this property
* The target could be the Component as the CSS Rule
* @return {Boolean}
*/
isTargetStylable(target) {
const trg = target || this.getTarget();
const model = this.model;
const id = model.get('id');
const property = model.get('property');
const toRequire = model.get('toRequire');
const unstylable = trg.get('unstylable');
const stylableReq = trg.get('stylable-require');
const requires = model.get('requires');
const requiresParent = model.get('requiresParent');
const sectors = this.sector ? this.sector.collection : null;
const selected = this.em ? this.em.getSelected() : null;
let stylable = trg.get('stylable');
// Stylable could also be an array indicating with which property
// the target could be styled
if (isArray(stylable)) {
stylable = stylable.indexOf(property) >= 0;
}
// Check if the property was signed as unstylable
if (isArray(unstylable)) {
stylable = unstylable.indexOf(property) < 0;
}
// Check if the property is available only if requested
if (toRequire) {
stylable =
!target ||
(stylableReq &&
(stylableReq.indexOf(id) >= 0 || stylableReq.indexOf(property) >= 0));
}
// Check if the property is available based on other property's values
if (sectors && requires) {
const properties = Object.keys(requires);
sectors.each(sector => {
sector.get('properties').each(model => {
if (includes(properties, model.id)) {
const values = requires[model.id];
stylable = stylable && includes(values, model.get('value'));
}
});
});
}
// Check if the property is available based on parent's property values
if (requiresParent) {
const parent = selected && selected.parent();
const parentEl = parent && parent.getEl();
if (parentEl) {
const styles = window.getComputedStyle(parentEl);
each(requiresParent, (values, property) => {
stylable =
stylable && styles[property] && includes(values, styles[property]);
});
} else {
stylable = false;
}
}
return stylable;
},
/**
* Check if the selected component is stylable with this property
* The target could be the Component as the CSS Rule
* @return {Boolean}
*/
isComponentStylable() {
const em = this.em;
const component = em && em.getSelected();
if (!component) {
return true;
}
return this.isTargetStylable(component);
},
/**
* Passed a raw value you have to update the input element, generally
* is the value fetched from targets, so you can receive values with
* functions, units, etc. (eg. `rotateY(45deg)`)
* get also
* @param {string} value
* @private
*/
setRawValue(value) {
this.setValue(this.model.parseValue(value));
},
/**
* Update the element input.
* Usually the value is a result of `model.getFullValue()`
* @param {String} value The value from the model
* */
setValue(value) {
const model = this.model;
let val = isUndefined(value) ? model.getDefaultValue() : value;
if (this.update) return this.__update(val);
const input = this.getInputEl();
input && (input.value = val);
},
getInputEl() {
if (!this.input) {
this.input = this.el.querySelector('input');
}
return this.input;
},
updateVisibility() {
this.el.style.display = this.model.get('visible') ? 'block' : 'none';
},
show() {
this.model.set('visible', 1);
},
hide() {
this.model.set('visible', 0);
},
/**
* Clean input
* */
cleanValue() {
this.setValue('');
},
clearCached() {
this.clearEl = null;
this.input = null;
this.$input = null;
},
__update(value) {
const update = this.update && this.update.bind(this);
update &&
update({
...this._getClbOpts(),
value
});
},
__change(...args) {
const emit = this.emit && this.emit.bind(this);
emit && emit(this._getClbOpts(), ...args);
},
__updateStyle(value, { complete, ...opts } = {}) {
const final = complete !== false;
if (isObject(value)) {
this.getTargets().forEach(target =>
target.addStyle(value, { avoidStore: !final })
);
} else {
this.model.setValueFromInput(value, complete, opts);
}
final && this.elementUpdated();
},
_getClbOpts() {
const { model, el } = this;
return {
el,
props: model.attributes,
setProps: (...args) => model.set(...args),
change: this.__change,
updateStyle: this.__updateStyle,
targets: this.getTargets()
};
},
render() {
this.clearCached();
const { pfx, model, el, $el } = this;
const property = model.get('property');
const full = model.get('full');
const cls = model.get('className') || '';
const className = `${pfx}property`;
this.createdEl && this.__destroyFn(this._getClbOpts());
$el.empty().append(this.template(model));
$el.find('[data-sm-label]').append(this.templateLabel(model));
const create = this.create && this.create.bind(this);
this.createdEl = create && create(this._getClbOpts());
$el
.find('[data-sm-fields]')
.append(this.createdEl || this.templateInput(model));
el.className = `${className} ${pfx}${model.get(
'type'
)} ${className}__${property} ${cls}`.trim();
el.className += full ? ` ${className}--full` : '';
this.updateStatus();
const onRender = this.onRender && this.onRender.bind(this);
onRender && onRender();
this.setValue(model.get('value'), { fromTarget: 1 });
}
});