grapesjs-clot
Version:
Free and Open Source Web Builder Framework
525 lines (457 loc) • 15.9 kB
JavaScript
import { Model } from 'common';
import { isUndefined, isString, isArray, result, keys, each, includes } from 'underscore';
import { capitalize, camelCase, hasWin } from 'utils/mixins';
/**
* @typedef Property
* @property {String} id Property id, eg. `my-property-id`.
* @property {String} property Related CSS property name, eg. `text-align`.
* @property {String} default Defaul value of the property.
* @property {String} label Label to use in UI, eg. `Text Align`.
* @property {Function} [onChange] Change callback.
* \n
* ```js
* onChange: ({ property, from, to }) => {
* console.log(`Changed property`, property.getName(), { from, to });
* }
* ```
*
*/
export default class Property extends Model {
initialize(props = {}, opts = {}) {
this.em = opts.em;
const id = this.get('id') || '';
const name = this.get('name') || this.get('label') || '';
!this.get('property') && this.set('property', (name || id).replace(/ /g, '-'));
const prop = this.get('property');
!this.get('id') && this.set('id', prop);
!name && this.set('name', capitalize(prop).replace(/-/g, ' '));
this.on('change', this.__upTargets);
Property.callInit(this, props, opts);
}
__getParentProp() {
return this.collection?.opts?.parentProp;
}
__upTargets(p, opts = {}) {
const { em } = this;
const sm = em.get('StyleManager');
const name = this.getName();
const isClear = opts.__clear;
const value = isClear ? '' : this.__getFullValue(opts);
const parentProp = this.__getParentProp();
const to = this.changedAttributes();
const from = keys(to).reduce((a, i) => {
a[i] = this.previous(i);
return a;
}, {});
const kProps = [...keys(this.__getClearProps()), '__p'];
const toProps = keys(to);
const applyStyle = !opts.__up && !parentProp && (isClear || kProps.some(k => toProps.indexOf(k) >= 0));
const onChange = this.get('onChange');
const evOpts = { property: this, from, to, value, opts };
sm.__trgEv(sm.events.propertyUpdate, evOpts);
onChange && onChange(evOpts);
applyStyle && this.__upTargetsStyle({ [name]: value }, opts);
}
__upTargetsStyle(style, opts) {
//console.log('style_manager/model/Property.js => __upTargetsStyle start');
//sconsole.trace();
const sm = this.em?.get('StyleManager');
sm?.addStyleTargets({ ...style, __p: !!opts.avoidStore }, opts);
//console.log('style_manager/model/Property.js => __upTargetsStyle end');
}
_up(props, opts = {}) {
if (opts.noTarget) opts.__up = true;
const { partial, ...rest } = opts;
props.__p = !!(rest.avoidStore || partial);
return this.set(props, { ...rest, avoidStore: props.__p });
}
up(props, opts = {}) {
this.set(props, { ...opts, __up: true });
}
init() {}
/**
* Get property id.
* @returns {String}
*/
getId() {
return this.get('id');
}
/**
* Get the property type.
* The type of the property is defined on property creation and based on its value the proper Property class is assigned.
* The default type is `base`.
* @returns {String}
*/
getType() {
return this.get('type');
}
/**
* Get name (the CSS property name).
* @returns {String}
*/
getName() {
return this.get('property');
}
/**
* Get property label.
* @param {Object} [opts={}] Options
* @param {Boolean} [opts.locale=true] Use the locale string from i18n module
* @returns {String}
*/
getLabel(opts = {}) {
const { locale = true } = opts;
const id = this.getId();
const name = this.get('name') || this.get('label');
return (locale && this.em?.t(`styleManager.properties.${id}`)) || name;
}
/**
* Get property value.
* @param {Object} [opts={}] Options
* @param {Boolean} [opts.noDefault=false] Avoid returning the default value
* @returns {String}
*/
getValue(opts = {}) {
const { noDefault } = opts;
const val = this.get('value');
return !this.hasValue() && !noDefault ? this.getDefaultValue() : val;
}
/**
* Check if the property has value.
* @param {Object} [opts={}] Options
* @param {Boolean} [opts.noParent=false] Ignore the value if it comes from the parent target.
* @returns {Boolean}
*/
hasValue(opts = {}) {
const { noParent } = opts;
const parentValue = noParent && this.getParentTarget();
const val = this.get('value');
return !isUndefined(val) && val !== '' && !parentValue;
}
/**
* Indicates if the current value is coming from a parent target (eg. another CSSRule).
* @returns {Boolean}
*/
hasValueParent() {
return this.hasValue() && !this.hasValue({ noParent: true });
}
/**
* Get the CSS style object of the property.
* @param {Object} [opts={}] Options
* @param {Boolean} [opts.camelCase] Return property name in camelCase.
* @return {Object}
* @example
* // In case the property is `color` with a value of `red`.
* console.log(property.getStyle());
* // { color: 'red' };
*/
getStyle(opts = {}) {
const name = this.getName();
const key = opts.camelCase ? camelCase(name) : name;
return { [key]: this.__getFullValue(opts) };
}
/**
* Get the default value.
* @return {string}
*/
getDefaultValue() {
const def = this.get('default');
return `${!isUndefined(def) ? def : this.get('defaults')}`;
}
/**
* Update the value.
* The change is also propagated to the selected targets (eg. CSS rule).
* @param {String} value New value
* @param {Object} [opts={}] Options
* @param {Boolean} [opts.partial=false] If `true` the update on targets won't be considered complete (not stored in UndoManager)
* @param {Boolean} [opts.noTarget=false] If `true` the change won't be propagated to selected targets.
*/
upValue(value, opts = {}) {
const parsed = value === null || value === '' ? this.__getClearProps() : this.__parseValue(value, opts);
return this._up(parsed, opts);
}
/**
* Check if the property is visible
* @returns {Boolean}
*/
isVisible() {
return !!this.get('visible');
}
/**
* Clear the value.
* The change is also propagated to the selected targets (eg. the css property is cleared).
* @param {Object} [opts={}] Options
* @param {Boolean} [opts.noTarget=false] If `true` the change won't be propagated to selected targets.
*/
clear(opts = {}) {
this._up(this.__getClearProps(), { ...opts, __clear: true });
}
/**
* Indicates if the current value comes directly from the selected target and so can be cleared.
* @returns {Boolean}
*/
canClear() {
const parent = this.getParent();
return parent ? parent.__canClearProp(this) : this.hasValue({ noParent: true });
}
/**
* If the current property is a sub-property, this will return the parent Property.
* @returns {[Property]|null}
*/
getParent() {
return this.__getParentProp() || null;
}
/**
* Indicates if the property is full-width in UI.
* @returns {Boolean}
*/
isFull() {
return !!this.get('full');
}
__parseValue(value, opts) {
return this.parseValue(value, opts);
}
__getClearProps() {
return { value: '' };
}
/**
* Update value
* @param {any} value
* @param {Boolen} [complete=true] Indicates if it's a final state
* @param {Object} [opts={}] Options
* @private
*/
setValue(value, complete = 1, opts = {}) {
const parsed = this.parseValue(value);
const avoidStore = !complete;
!avoidStore && this.set({ value: undefined }, { avoidStore, silent: true });
this.set(parsed, { avoidStore, ...opts });
}
/**
* Like `setValue` but, in addition, prevents the update of the input element
* as the changes should come from the input itself.
* This method is useful with the definition of custom properties
* @param {any} value
* @param {Boolen} [complete=true] Indicates if it's a final state
* @param {Object} [opts={}] Options
* @private
* @deprecated
*/
setValueFromInput(value, complete, opts = {}) {
this.setValue(value, complete, { ...opts, fromInput: 1 });
}
/**
* Parse a raw value, generally fetched from the target, for this property
* @param {string} value Raw value string
* @return {Object}
* @private
* @example
* // example with an Input type
* prop.parseValue('translateX(10deg)');
* // -> { value: 10, unit: 'deg', functionName: 'translateX' }
*
*/
parseValue(value, opts = {}) {
const result = { value };
const imp = '!important';
if (isString(value) && value.indexOf(imp) !== -1) {
result.value = value.replace(imp, '').trim();
result.important = 1;
}
if (!this.get('functionName') && !opts.complete) {
return result;
}
const args = [];
let valueStr = `${result.value}`;
let start = valueStr.indexOf('(') + 1;
let end = valueStr.lastIndexOf(')');
const functionName = valueStr.substring(0, start - 1);
if (functionName) result.functionName = functionName;
args.push(start);
// Will try even if the last closing parentheses is not found
if (end >= 0) {
args.push(end);
}
result.value = String.prototype.substring.apply(valueStr, args);
if (opts.numeric) {
const num = parseFloat(result.value);
result.unit = result.value.replace(num, '');
result.value = num;
}
return result;
}
/**
* Helper function to safely split a string of values.
* Useful when style values are inside functions
* eg:
* -> input: 'value(1,2,4), 123, value(4,5)' -- default separator: ','
* -> output: ['value(1,2,4)', '123', 'value(4,5)']
* @param {String} values Values to split
* @param {String} [separator] Separator
* @private
*/
// splitValues(values, separator = ',') {
// const res = [];
// const op = '(';
// const cl = ')';
// let curr = '';
// let acc = 0;
// (values || '').split('').forEach(str => {
// if (str == op) {
// acc++;
// curr = curr + op;
// } else if (str == cl && acc > 0) {
// acc--;
// curr = curr + cl;
// } else if (str === separator && acc == 0) {
// res.push(curr);
// curr = '';
// } else {
// curr = curr + str;
// }
// });
// curr !== '' && res.push(curr);
// return res.map(i => i.trim());
// }
__getFullValue({ withDefault } = {}) {
return !this.hasValue() && withDefault ? this.getDefaultValue() : this.getFullValue();
}
/**
* Get a complete value of the property.
* This probably will replace the getValue when all
* properties models will be splitted
* @param {String} val Custom value to replace the one on the model
* @return {string}
* @private
*/
getFullValue(val) {
const fn = this.get('functionName');
const def = this.getDefaultValue();
let value = isUndefined(val) ? this.get('value') : val;
const hasValue = !isUndefined(value) && value !== '';
if (value && def && value === def) {
return def;
}
if (fn && hasValue) {
const fnParameter = fn === 'url' ? `'${value.replace(/'/g, '')}'` : value;
value = `${fn}(${fnParameter})`;
}
if (hasValue && this.get('important')) {
value = `${value} !important`;
}
return value || '';
}
__setParentTarget(value) {
this.__parentTarget = value;
}
getParentTarget() {
return this.__parentTarget || null;
}
__parseFn(input = '') {
const start = input.indexOf('(') + 1;
const end = input.lastIndexOf(')');
return {
name: input.substring(0, start - 1).trim(),
value: String.prototype.substring.apply(input, [start, end >= 0 ? end : undefined]).trim(),
};
}
__checkVisibility({ target, component, sectors }) {
const trg = component || target;
if (!trg) return false;
const id = this.getId();
const property = this.getName();
const toRequire = this.get('toRequire');
const requires = this.get('requires');
const requiresParent = this.get('requiresParent');
const unstylable = trg.get('unstylable');
const stylableReq = trg.get('stylable-require');
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 = keys(requires);
sectors.forEach(sector => {
sector.getProperties().forEach(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 = component && component.parent();
const parentEl = parent && parent.getEl();
if (parentEl) {
const styles = hasWin() ? window.getComputedStyle(parentEl) : {};
each(requiresParent, (values, property) => {
stylable = stylable && styles[property] && includes(values, styles[property]);
});
} else {
stylable = false;
}
}
return !!stylable;
}
}
Property.callParentInit = function (property, ctx, props, opts = {}) {
property.prototype.initialize.apply(ctx, [
props,
{
...opts,
skipInit: 1,
},
]);
};
Property.callInit = function (context, props, opts = {}) {
!opts.skipInit && context.init(props, opts);
};
Property.getDefaults = function () {
return result(this.prototype, 'defaults');
};
Property.prototype.defaults = {
name: '',
property: '',
type: '',
defaults: '',
info: '',
value: '',
icon: '',
functionName: '',
status: '',
visible: true,
fixedValues: ['initial', 'inherit'],
onChange: null,
// If true, the property will be forced to be full width
full: 0,
// If true to the value will be added '!important'
important: 0,
// If true, will be hidden by default and will show up only for targets
// which require this property (via `stylable-require`)
// Use case:
// you can add all SVG CSS properties with toRequire as true
// and then require them on SVG Components
toRequire: 0,
// Specifies dependency on other properties of the selected object.
// Property is shown only when all conditions are matched.
//
// example: { display: ['flex', 'block'], position: ['absolute'] };
// in this case the property is only shown when display is
// of value 'flex' or 'block' AND position is 'absolute'
requires: null,
// Specifies dependency on properties of the parent of the selected object.
// Property is shown only when all conditions are matched.
requiresParent: null,
};