diff --git a/src/dom_components/model/Component.js b/src/dom_components/model/Component.js index 5dddfd330..36c75095e 100644 --- a/src/dom_components/model/Component.js +++ b/src/dom_components/model/Component.js @@ -13,7 +13,7 @@ import { } from 'underscore'; import { shallowDiff, capitalize, isEmptyObj, isObject } from 'utils/mixins'; import Styleable from 'domain_abstract/model/Styleable'; -import Backbone from 'backbone'; +import { Model } from 'backbone'; import Components from './Components'; import Selector from 'selector_manager/model/Selector'; import Selectors from 'selector_manager/model/Selectors'; @@ -87,1812 +87,1804 @@ export const keyUpdateInside = `${keyUpdate}-inside`; * By default, when `toolbar` property is falsy the editor will add automatically commands like `move`, `delete`, etc. based on its properties. * @property {Collection} [components=null] Children components. Default: `null` */ -const Component = Backbone.Model.extend(Styleable).extend( - { - defaults: { - tagName: 'div', - type: '', - name: '', - removable: true, - draggable: true, - droppable: true, - badgable: true, - stylable: true, - 'stylable-require': '', - 'style-signature': '', - unstylable: '', - highlightable: true, - copyable: true, - resizable: false, - editable: false, - layerable: true, - selectable: true, - hoverable: true, - void: false, - state: '', // Indicates if the component is in some CSS state like ':hover', ':active', etc. - status: '', // State, eg. 'selected' - content: '', - icon: '', - style: '', // Component related style - classes: '', // Array of classes - script: '', - 'script-props': '', - 'script-export': '', - attributes: '', - traits: ['id', 'title'], - propagate: '', - dmode: '', - toolbar: null, - [keySymbol]: 0, - [keySymbols]: 0, - [keySymbolOvrd]: 0, - _undo: true, - _undoexc: ['status', 'open'] - }, - - /** - * Hook method, called once the model is created - */ - init() {}, - - /** - * Hook method, called when the model has been updated (eg. updated some model's property) - * @param {String} property Property name, if triggered after some property update - * @param {*} value Property value, if triggered after some property update - * @param {*} previous Property previous value, if triggered after some property update - */ - updated(property, value, previous) {}, - - /** - * Hook method, called once the model has been removed - */ - removed() {}, - - initialize(props = {}, opt = {}) { - bindAll(this, '__upSymbProps', '__upSymbCls', '__upSymbComps'); - const em = opt.em; - - // Propagate properties from parent if indicated - const parent = this.parent(); - const parentAttr = parent && parent.attributes; - - if (parentAttr && parentAttr.propagate) { - let newAttr = {}; - const toPropagate = parentAttr.propagate; - toPropagate.forEach(prop => (newAttr[prop] = parent.get(prop))); - newAttr.propagate = toPropagate; - newAttr = { ...newAttr, ...props }; - this.set(newAttr); - } +export default class Component extends Model.extend(Styleable) { + /** + * Hook method, called once the model is created + */ + init() {} + + /** + * Hook method, called when the model has been updated (eg. updated some model's property) + * @param {String} property Property name, if triggered after some property update + * @param {*} value Property value, if triggered after some property update + * @param {*} previous Property previous value, if triggered after some property update + */ + updated(property, value, previous) {} + + /** + * Hook method, called once the model has been removed + */ + removed() {} + + initialize(props = {}, opt = {}) { + bindAll(this, '__upSymbProps', '__upSymbCls', '__upSymbComps'); + const em = opt.em; + + // Propagate properties from parent if indicated + const parent = this.parent(); + const parentAttr = parent && parent.attributes; + + if (parentAttr && parentAttr.propagate) { + let newAttr = {}; + const toPropagate = parentAttr.propagate; + toPropagate.forEach(prop => (newAttr[prop] = parent.get(prop))); + newAttr.propagate = toPropagate; + newAttr = { ...newAttr, ...props }; + this.set(newAttr); + } - const propagate = this.get('propagate'); - propagate && - this.set('propagate', isArray(propagate) ? propagate : [propagate]); - - // Check void elements - if ( - opt && - opt.config && - opt.config.voidElements.indexOf(this.get('tagName')) >= 0 - ) { - this.set('void', true); - } + const propagate = this.get('propagate'); + propagate && + this.set('propagate', isArray(propagate) ? propagate : [propagate]); + + // Check void elements + if ( + opt && + opt.config && + opt.config.voidElements.indexOf(this.get('tagName')) >= 0 + ) { + this.set('void', true); + } - opt.em = em; - this.opt = opt; - this.em = em; - this.frame = opt.frame; - this.config = opt.config || {}; - this.set('attributes', { - ...(result(this, 'defaults').attributes || {}), - ...(this.get('attributes') || {}) - }); - this.ccid = Component.createId(this, opt); - this.initClasses(); - this.initTraits(); - this.initComponents(); - this.initToolbar(); - this.initScriptProps(); - this.listenTo(this, 'change:script', this.scriptUpdated); - this.listenTo(this, 'change:tagName', this.tagUpdated); - this.listenTo(this, 'change:attributes', this.attrUpdated); - this.listenTo(this, 'change:attributes:id', this._idUpdated); - this.on('change:toolbar', this.__emitUpdateTlb); - this.on('change', this.__onChange); - this.on(keyUpdateInside, this.__propToParent); - this.set('status', ''); - this.views = []; - - // Register global updates for collection properties - ['classes', 'traits', 'components'].forEach(name => { - const events = `add remove ${name !== 'components' ? 'change' : ''}`; - this.listenTo(this.get(name), events.trim(), (...args) => - this.emitUpdate(name, ...args) - ); - }); + opt.em = em; + this.opt = opt; + this.em = em; + this.frame = opt.frame; + this.config = opt.config || {}; + this.set('attributes', { + ...(result(this, 'defaults').attributes || {}), + ...(this.get('attributes') || {}) + }); + this.ccid = Component.createId(this, opt); + this.initClasses(); + this.initTraits(); + this.initComponents(); + this.initToolbar(); + this.initScriptProps(); + this.listenTo(this, 'change:script', this.scriptUpdated); + this.listenTo(this, 'change:tagName', this.tagUpdated); + this.listenTo(this, 'change:attributes', this.attrUpdated); + this.listenTo(this, 'change:attributes:id', this._idUpdated); + this.on('change:toolbar', this.__emitUpdateTlb); + this.on('change', this.__onChange); + this.on(keyUpdateInside, this.__propToParent); + this.set('status', ''); + this.views = []; + + // Register global updates for collection properties + ['classes', 'traits', 'components'].forEach(name => { + const events = `add remove ${name !== 'components' ? 'change' : ''}`; + this.listenTo(this.get(name), events.trim(), (...args) => + this.emitUpdate(name, ...args) + ); + }); - if (!opt.temporary) { - this.__postAdd(); - this.init(); - this.__isSymbolOrInst() && this.__initSymb(); - em && em.trigger('component:create', this); - } - }, - - __postAdd(opts = {}) { - const { em } = this; - const um = em && em.get('UndoManager'); - const comps = this.components(); - if (um && !this.__hasUm) { - um.add(comps); - this.__hasUm = 1; - } - opts.recursive && comps.map(c => c.__postAdd(opts)); - }, - - __postRemove() { - const { em } = this; - const um = em && em.get('UndoManager'); - if (um) { - um.remove(this.components()); - delete this.__hasUm; - } - }, + if (!opt.temporary) { + this.__postAdd(); + this.init(); + this.__isSymbolOrInst() && this.__initSymb(); + em && em.trigger('component:create', this); + } + } - __onChange(m, opts) { - const changed = this.changedAttributes(); - ['status', 'open', 'toolbar', 'traits'].forEach( - name => delete changed[name] - ); - // Propagate component prop changes - if (!isEmptyObj(changed)) { - this.__changesUp(opts); - this.__propSelfToParent({ component: this, changed, options: opts }); - } - }, - - __changesUp(opts) { - const { em, frame } = this; - [frame, em].forEach(md => md && md.changesUp(opts)); - }, - - __propSelfToParent(props) { - this.trigger(keyUpdate, props); - this.__propToParent(props); - }, - - __propToParent(props) { - const parent = this.parent(); - parent && parent.trigger(keyUpdateInside, props); - }, - - __emitUpdateTlb() { - this.emitUpdate('toolbar'); - }, - - /** - * Check component's type - * @param {string} type Component type - * @return {Boolean} - * @example - * component.is('image') - * // -> false - */ - is(type) { - return !!(this.get('type') == type); - }, - - /** - * Return all the propeties - * @returns {Object} - */ - props() { - return this.attributes; - }, - - /** - * Get the index of the component in the parent collection. - * @return {Number} - */ - index() { - const { collection } = this; - return collection && collection.indexOf(this); - }, - - /** - * Change the drag mode of the component. - * To get more about this feature read: https://github.com/artf/grapesjs/issues/1936 - * @param {String} value Drag mode, options: 'absolute' | 'translate' - * @returns {this} - */ - setDragMode(value) { - return this.set('dmode', value); - }, - - /** - * Find inner components by query string. - * **ATTENTION**: this method works only with already rendered component - * @param {String} query Query string - * @return {Array} Array of components - * @example - * component.find('div > .class'); - * // -> [Component, Component, ...] - */ - find(query) { - const result = []; - const $els = this.view.$el.find(query); - $els.each(i => { - const $el = $els.eq(i); - const model = $el.data('model'); - model && result.push(model); + __postAdd(opts = {}) { + const { em } = this; + const um = em && em.get('UndoManager'); + const comps = this.components(); + if (um && !this.__hasUm) { + um.add(comps); + this.__hasUm = 1; + } + opts.recursive && comps.map(c => c.__postAdd(opts)); + } + + __postRemove() { + const { em } = this; + const um = em && em.get('UndoManager'); + if (um) { + um.remove(this.components()); + delete this.__hasUm; + } + } + + __onChange(m, opts) { + const changed = this.changedAttributes(); + ['status', 'open', 'toolbar', 'traits'].forEach( + name => delete changed[name] + ); + // Propagate component prop changes + if (!isEmptyObj(changed)) { + this.__changesUp(opts); + this.__propSelfToParent({ component: this, changed, options: opts }); + } + } + + __changesUp(opts) { + const { em, frame } = this; + [frame, em].forEach(md => md && md.changesUp(opts)); + } + + __propSelfToParent(props) { + this.trigger(keyUpdate, props); + this.__propToParent(props); + } + + __propToParent(props) { + const parent = this.parent(); + parent && parent.trigger(keyUpdateInside, props); + } + + __emitUpdateTlb() { + this.emitUpdate('toolbar'); + } + + /** + * Check component's type + * @param {string} type Component type + * @return {Boolean} + * @example + * component.is('image') + * // -> false + */ + is(type) { + return !!(this.get('type') == type); + } + + /** + * Return all the propeties + * @returns {Object} + */ + props() { + return this.attributes; + } + + /** + * Get the index of the component in the parent collection. + * @return {Number} + */ + index() { + const { collection } = this; + return collection && collection.indexOf(this); + } + + /** + * Change the drag mode of the component. + * To get more about this feature read: https://github.com/artf/grapesjs/issues/1936 + * @param {String} value Drag mode, options: 'absolute' | 'translate' + * @returns {this} + */ + setDragMode(value) { + return this.set('dmode', value); + } + + /** + * Find inner components by query string. + * **ATTENTION**: this method works only with already rendered component + * @param {String} query Query string + * @return {Array} Array of components + * @example + * component.find('div > .class'); + * // -> [Component, Component, ...] + */ + find(query) { + const result = []; + const $els = this.view.$el.find(query); + $els.each(i => { + const $el = $els.eq(i); + const model = $el.data('model'); + model && result.push(model); + }); + + return result; + } + + /** + * Find all inner components by component type. + * The advantage of this method over `find` is that you can use it + * also before rendering the component + * @param {String} type Component type + * @returns {Array} + * @example + * const allImages = component.findType('image'); + * console.log(allImages[0]) // prints the first found component + */ + findType(type) { + const result = []; + const find = components => + components.forEach(item => { + item.is(type) && result.push(item); + find(item.components()); }); + find(this.components()); + return result; + } - return result; - }, - - /** - * Find all inner components by component type. - * The advantage of this method over `find` is that you can use it - * also before rendering the component - * @param {String} type Component type - * @returns {Array} - * @example - * const allImages = component.findType('image'); - * console.log(allImages[0]) // prints the first found component - */ - findType(type) { - const result = []; - const find = components => + /** + * Find the closest parent component by query string. + * **ATTENTION**: this method works only with already rendered component + * @param {string} query Query string + * @return {Component} + * @example + * component.closest('div.some-class'); + * // -> Component + */ + closest(query) { + const result = this.view.$el.closest(query); + return result.length && result.data('model'); + } + + /** + * Find the closest parent component by its type. + * The advantage of this method over `closest` is that you can use it + * also before rendering the component + * @param {String} type Component type + * @returns {Component} Found component, otherwise `undefined` + * @example + * const Section = component.closestType('section'); + * console.log(Section); + */ + closestType(type) { + let parent = this.parent(); + + while (parent && !parent.is(type)) { + parent = parent.parent(); + } + + return parent; + } + + /** + * The method returns a Boolean value indicating whether the passed + * component is a descendant of a given component + * @param {Component} component Component to check + * @returns {Boolean} + */ + contains(component) { + let result = !1; + if (!component) return result; + const contains = components => { + !result && components.forEach(item => { - item.is(type) && result.push(item); - find(item.components()); + if (item === component) result = !0; + !result && contains(item.components()); }); - find(this.components()); - return result; - }, - - /** - * Find the closest parent component by query string. - * **ATTENTION**: this method works only with already rendered component - * @param {string} query Query string - * @return {Component} - * @example - * component.closest('div.some-class'); - * // -> Component - */ - closest(query) { - const result = this.view.$el.closest(query); - return result.length && result.data('model'); - }, - - /** - * Find the closest parent component by its type. - * The advantage of this method over `closest` is that you can use it - * also before rendering the component - * @param {String} type Component type - * @returns {Component} Found component, otherwise `undefined` - * @example - * const Section = component.closestType('section'); - * console.log(Section); - */ - closestType(type) { - let parent = this.parent(); - - while (parent && !parent.is(type)) { - parent = parent.parent(); + }; + contains(this.components()); + return result; + } + + /** + * Once the tag is updated I have to rerender the element + * @private + */ + tagUpdated() { + this.trigger('rerender'); + } + + /** + * Replace a component with another one + * @param {String|Component} el Component or HTML string + * @return {Component|Array} New added component/s + * @example + * component.replaceWith('
Some new content
'); + * // -> Component + */ + replaceWith(el) { + const coll = this.collection; + const at = coll.indexOf(this); + coll.remove(this); + return coll.add(el, { at }); + } + + /** + * Emit changes for each updated attribute + * @private + */ + attrUpdated(m, v, opts = {}) { + const attrs = this.get('attributes'); + // Handle classes + const classes = attrs.class; + classes && this.setClass(classes); + delete attrs.class; + + // Handle style + const style = attrs.style; + style && this.setStyle(style); + delete attrs.style; + + const attrPrev = { ...this.previous('attributes') }; + const diff = shallowDiff(attrPrev, this.get('attributes')); + keys(diff).forEach(pr => + this.trigger(`change:attributes:${pr}`, this, diff[pr], opts) + ); + } + + /** + * Update attributes of the component + * @param {Object} attrs Key value attributes + * @param {Object} options Options for the model update + * @return {this} + * @example + * component.setAttributes({ id: 'test', 'data-key': 'value' }); + */ + setAttributes(attrs, opts = {}) { + this.set('attributes', { ...attrs }, opts); + return this; + } + + /** + * Add attributes to the component + * @param {Object} attrs Key value attributes + * @param {Object} options Options for the model update + * @return {this} + * @example + * component.addAttributes({ 'data-key': 'value' }); + */ + addAttributes(attrs, opts = {}) { + return this.setAttributes( + { + ...this.getAttributes({ noClass: 1 }), + ...attrs + }, + opts + ); + } + + /** + * Remove attributes from the component + * @param {String|Array} attrs Array of attributes to remove + * @param {Object} options Options for the model update + * @return {this} + * @example + * component.removeAttributes('some-attr'); + * component.removeAttributes(['some-attr1', 'some-attr2']); + */ + removeAttributes(attrs = [], opts = {}) { + const attrArr = Array.isArray(attrs) ? attrs : [attrs]; + const compAttr = this.getAttributes(); + attrArr.map(i => delete compAttr[i]); + return this.setAttributes(compAttr, opts); + } + + /** + * Get the style of the component + * @return {Object} + */ + getStyle(opts = {}) { + const em = this.em; + + if (em && em.getConfig('avoidInlineStyle') && !opts.inline) { + const state = em.get('state'); + const cc = em.get('CssComposer'); + const rule = cc.getIdRule(this.getId(), { state, ...opts }); + this.rule = rule; + + if (rule) { + return rule.getStyle(); } + } - return parent; - }, - - /** - * The method returns a Boolean value indicating whether the passed - * component is a descendant of a given component - * @param {Component} component Component to check - * @returns {Boolean} - */ - contains(component) { - let result = !1; - if (!component) return result; - const contains = components => { - !result && - components.forEach(item => { - if (item === component) result = !0; - !result && contains(item.components()); - }); - }; - contains(this.components()); - return result; - }, - - /** - * Once the tag is updated I have to rerender the element - * @private - */ - tagUpdated() { - this.trigger('rerender'); - }, - - /** - * Replace a component with another one - * @param {String|Component} el Component or HTML string - * @return {Component|Array} New added component/s - * @example - * component.replaceWith('
Some new content
'); - * // -> Component - */ - replaceWith(el) { - const coll = this.collection; - const at = coll.indexOf(this); - coll.remove(this); - return coll.add(el, { at }); - }, - - /** - * Emit changes for each updated attribute - * @private - */ - attrUpdated(m, v, opts = {}) { - const attrs = this.get('attributes'); - // Handle classes - const classes = attrs.class; - classes && this.setClass(classes); - delete attrs.class; - - // Handle style - const style = attrs.style; - style && this.setStyle(style); - delete attrs.style; - - const attrPrev = { ...this.previous('attributes') }; - const diff = shallowDiff(attrPrev, this.get('attributes')); - keys(diff).forEach(pr => - this.trigger(`change:attributes:${pr}`, this, diff[pr], opts) - ); - }, - - /** - * Update attributes of the component - * @param {Object} attrs Key value attributes - * @param {Object} options Options for the model update - * @return {this} - * @example - * component.setAttributes({ id: 'test', 'data-key': 'value' }); - */ - setAttributes(attrs, opts = {}) { - this.set('attributes', { ...attrs }, opts); - return this; - }, - - /** - * Add attributes to the component - * @param {Object} attrs Key value attributes - * @param {Object} options Options for the model update - * @return {this} - * @example - * component.addAttributes({ 'data-key': 'value' }); - */ - addAttributes(attrs, opts = {}) { - return this.setAttributes( - { - ...this.getAttributes({ noClass: 1 }), - ...attrs - }, - opts + return Styleable.getStyle.call(this); + } + + /** + * Set the style on the component + * @param {Object} prop Key value style object + * @return {Object} + * @example + * component.setStyle({ color: 'red' }); + */ + setStyle(prop = {}, opts = {}) { + const em = this.em; + const { opt } = this; + + if ( + em && + em.getConfig('avoidInlineStyle') && + !opt.temporary && + !opts.inline + ) { + const style = this.get('style') || {}; + prop = isString(prop) ? this.parseStyle(prop) : prop; + prop = { ...prop, ...style }; + const state = em.get('state'); + const cc = em.get('CssComposer'); + const propOrig = this.getStyle(opts); + this.rule = cc.setIdRule(this.getId(), prop, { ...opts, state }); + const diff = shallowDiff(propOrig, prop); + this.set('style', '', { silent: 1 }); + keys(diff).forEach(pr => this.trigger(`change:style:${pr}`)); + } else { + prop = Styleable.setStyle.apply(this, arguments); + } + + return prop; + } + + /** + * Return all component's attributes + * @return {Object} + */ + getAttributes(opts = {}) { + const { em } = this; + const classes = []; + const attributes = { ...this.get('attributes') }; + const sm = em && em.get('SelectorManager'); + const id = this.getId(); + + // Add classes + if (!opts.noClass) { + this.get('classes').forEach(cls => + classes.push(isString(cls) ? cls : cls.get('name')) ); - }, - - /** - * Remove attributes from the component - * @param {String|Array} attrs Array of attributes to remove - * @param {Object} options Options for the model update - * @return {this} - * @example - * component.removeAttributes('some-attr'); - * component.removeAttributes(['some-attr1', 'some-attr2']); - */ - removeAttributes(attrs = [], opts = {}) { - const attrArr = Array.isArray(attrs) ? attrs : [attrs]; - const compAttr = this.getAttributes(); - attrArr.map(i => delete compAttr[i]); - return this.setAttributes(compAttr, opts); - }, - - /** - * Get the style of the component - * @return {Object} - */ - getStyle(opts = {}) { - const em = this.em; - - if (em && em.getConfig('avoidInlineStyle') && !opts.inline) { - const state = em.get('state'); - const cc = em.get('CssComposer'); - const rule = cc.getIdRule(this.getId(), { state, ...opts }); - this.rule = rule; - - if (rule) { - return rule.getStyle(); - } - } + classes.length && (attributes.class = classes.join(' ')); + } - return Styleable.getStyle.call(this); - }, - - /** - * Set the style on the component - * @param {Object} prop Key value style object - * @return {Object} - * @example - * component.setStyle({ color: 'red' }); - */ - setStyle(prop = {}, opts = {}) { - const em = this.em; - const { opt } = this; - - if ( - em && - em.getConfig('avoidInlineStyle') && - !opt.temporary && - !opts.inline - ) { - const style = this.get('style') || {}; - prop = isString(prop) ? this.parseStyle(prop) : prop; - prop = { ...prop, ...style }; - const state = em.get('state'); - const cc = em.get('CssComposer'); - const propOrig = this.getStyle(opts); - this.rule = cc.setIdRule(this.getId(), prop, { ...opts, state }); - const diff = shallowDiff(propOrig, prop); - this.set('style', '', { silent: 1 }); - keys(diff).forEach(pr => this.trigger(`change:style:${pr}`)); - } else { - prop = Styleable.setStyle.apply(this, arguments); + // Add style + if (!opts.noStyle) { + const style = this.get('style'); + if (isObject(style) && !isEmptyObj(style)) { + attributes.style = this.styleToString({ inline: 1 }); } + } - return prop; - }, - - /** - * Return all component's attributes - * @return {Object} - */ - getAttributes(opts = {}) { - const { em } = this; - const classes = []; - const attributes = { ...this.get('attributes') }; - const sm = em && em.get('SelectorManager'); - const id = this.getId(); - - // Add classes - if (!opts.noClass) { - this.get('classes').forEach(cls => - classes.push(isString(cls) ? cls : cls.get('name')) - ); - classes.length && (attributes.class = classes.join(' ')); + // Check if we need an ID on the component + if (!has(attributes, 'id')) { + let addId; + + // If we don't rely on inline styling we have to check + // for the ID selector + if (avoidInline(em)) { + addId = sm && sm.get(id, sm.Selector.TYPE_ID); + } else if (!isEmpty(this.getStyle())) { + addId = 1; } - // Add style - if (!opts.noStyle) { - const style = this.get('style'); - if (isObject(style) && !isEmptyObj(style)) { - attributes.style = this.styleToString({ inline: 1 }); - } + // Symbols should always have an id + if (this.__getSymbol() || this.__getSymbols()) { + addId = 1; } - // Check if we need an ID on the component - if (!has(attributes, 'id')) { - let addId; + if (addId) { + attributes.id = id; + } + } - // If we don't rely on inline styling we have to check - // for the ID selector - if (avoidInline(em)) { - addId = sm && sm.get(id, sm.Selector.TYPE_ID); - } else if (!isEmpty(this.getStyle())) { - addId = 1; - } + return attributes; + } - // Symbols should always have an id - if (this.__getSymbol() || this.__getSymbols()) { - addId = 1; - } + /** + * Add classes + * @param {Array|String} classes Array or string of classes + * @return {Array} Array of added selectors + * @example + * model.addClass('class1'); + * model.addClass('class1 class2'); + * model.addClass(['class1', 'class2']); + * // -> [SelectorObject, ...] + */ + addClass(classes) { + const added = this.em.get('SelectorManager').addClass(classes); + return this.get('classes').add(added); + } - if (addId) { - attributes.id = id; - } - } + /** + * Set classes (resets current collection) + * @param {Array|String} classes Array or string of classes + * @return {Array} Array of added selectors + * @example + * model.setClass('class1'); + * model.setClass('class1 class2'); + * model.setClass(['class1', 'class2']); + * // -> [SelectorObject, ...] + */ + setClass(classes) { + this.get('classes').reset(); + return this.addClass(classes); + } - return attributes; - }, - - /** - * Add classes - * @param {Array|String} classes Array or string of classes - * @return {Array} Array of added selectors - * @example - * model.addClass('class1'); - * model.addClass('class1 class2'); - * model.addClass(['class1', 'class2']); - * // -> [SelectorObject, ...] - */ - addClass(classes) { - const added = this.em.get('SelectorManager').addClass(classes); - return this.get('classes').add(added); - }, - - /** - * Set classes (resets current collection) - * @param {Array|String} classes Array or string of classes - * @return {Array} Array of added selectors - * @example - * model.setClass('class1'); - * model.setClass('class1 class2'); - * model.setClass(['class1', 'class2']); - * // -> [SelectorObject, ...] - */ - setClass(classes) { - this.get('classes').reset(); - return this.addClass(classes); - }, - - /** - * Remove classes - * @param {Array|String} classes Array or string of classes - * @return {Array} Array of removed selectors - * @example - * model.removeClass('class1'); - * model.removeClass('class1 class2'); - * model.removeClass(['class1', 'class2']); - * // -> [SelectorObject, ...] - */ - removeClass(classes) { - const removed = []; - classes = isArray(classes) ? classes : [classes]; - const selectors = this.get('classes'); - const type = Selector.TYPE_CLASS; - - classes.forEach(classe => { - const classes = classe.split(' '); - classes.forEach(name => { - const selector = selectors.where({ name, type })[0]; - selector && removed.push(selectors.remove(selector)); - }); + /** + * Remove classes + * @param {Array|String} classes Array or string of classes + * @return {Array} Array of removed selectors + * @example + * model.removeClass('class1'); + * model.removeClass('class1 class2'); + * model.removeClass(['class1', 'class2']); + * // -> [SelectorObject, ...] + */ + removeClass(classes) { + const removed = []; + classes = isArray(classes) ? classes : [classes]; + const selectors = this.get('classes'); + const type = Selector.TYPE_CLASS; + + classes.forEach(classe => { + const classes = classe.split(' '); + classes.forEach(name => { + const selector = selectors.where({ name, type })[0]; + selector && removed.push(selectors.remove(selector)); }); + }); - return removed; - }, - - /** - * Returns component's classes as an array of strings - * @return {Array} - */ - getClasses() { - const attr = this.getAttributes(); - const classStr = attr.class; - return classStr ? classStr.split(' ') : []; - }, - - __logSymbol(type, toUp, opts = {}) { - const symbol = this.__getSymbol(); - const symbols = this.__getSymbols(); - if (!symbol && !symbols) return; - this.em.log(type, { model: this, toUp, context: 'symbols', opts }); - }, - - __initSymb() { - if (this.__symbReady) return; - this.on('change', this.__upSymbProps); - this.__symbReady = 1; - }, - - __isSymbol() { - return isArray(this.get(keySymbols)); - }, - - __isSymbolOrInst() { - return !!(this.__isSymbol() || this.get(keySymbol)); - }, - - __isSymbolTop() { - const parent = this.parent(); - const symb = this.__isSymbolOrInst(); - return ( - symb && - (!parent || (parent && !parent.__isSymbol() && !parent.__getSymbol())) - ); - }, - - __isSymbolNested() { - if (!this.__isSymbolOrInst() || this.__isSymbolTop()) return false; - const symbTopSelf = (this.__isSymbol() - ? this - : this.__getSymbol() - ).__getSymbTop(); - const symbTop = this.__getSymbTop(); - const symbTopMain = symbTop.__isSymbol() - ? symbTop - : symbTop.__getSymbol(); - return symbTopMain !== symbTopSelf; - }, - - __getAllById() { - const { em } = this; - return em ? em.get('DomComponents').allById() : {}; - }, - - __getSymbol() { - let symb = this.get(keySymbol); - if (symb && isString(symb)) { - const ref = this.__getAllById()[symb]; - if (ref) { - symb = ref; - this.set(keySymbol, ref); - } else { - symb = 0; - } - } - return symb; - }, - - __getSymbols() { - let symbs = this.get(keySymbols); - if (symbs && isArray(symbs)) { - symbs.forEach((symb, idx) => { - if (symb && isString(symb)) { - symbs[idx] = this.__getAllById()[symb]; - } - }); - symbs = symbs.filter(symb => symb && !isString(symb)); - } - return symbs; - }, - - __isSymbOvrd(prop = '') { - const ovrd = this.get(keySymbolOvrd); - const [prp] = prop.split(':'); - const props = prop !== prp ? [prop, prp] : [prop]; - return ( - ovrd === true || - (isArray(ovrd) && props.some(p => ovrd.indexOf(p) >= 0)) - ); - }, - - __getSymbToUp(opts = {}) { - let result = []; - const { em } = this; - const { changed } = opts; - const symbEnabled = em && em.get('symbols'); - - if ( - opts.fromInstance || - opts.noPropagate || - opts.fromUndo || - !symbEnabled || - // Avoid updating others if the current component has override - (changed && this.__isSymbOvrd(changed)) - ) { - return result; - } + return removed; + } - const symbols = this.__getSymbols() || []; - const symbol = this.__getSymbol(); - const all = symbol ? [symbol, ...(symbol.__getSymbols() || [])] : symbols; - result = all - .filter(s => s !== this) - // Avoid updating those with override - .filter(s => !(changed && s.__isSymbOvrd(changed))); + /** + * Returns component's classes as an array of strings + * @return {Array} + */ + getClasses() { + const attr = this.getAttributes(); + const classStr = attr.class; + return classStr ? classStr.split(' ') : []; + } - return result; - }, + __logSymbol(type, toUp, opts = {}) { + const symbol = this.__getSymbol(); + const symbols = this.__getSymbols(); + if (!symbol && !symbols) return; + this.em.log(type, { model: this, toUp, context: 'symbols', opts }); + } + + __initSymb() { + if (this.__symbReady) return; + this.on('change', this.__upSymbProps); + this.__symbReady = 1; + } - __getSymbTop(opts) { - let result = this; - let parent = this.parent(opts); + __isSymbol() { + return isArray(this.get(keySymbols)); + } + + __isSymbolOrInst() { + return !!(this.__isSymbol() || this.get(keySymbol)); + } - while (parent && (parent.__isSymbol() || parent.__getSymbol())) { - result = parent; - parent = parent.parent(opts); + __isSymbolTop() { + const parent = this.parent(); + const symb = this.__isSymbolOrInst(); + return ( + symb && + (!parent || (parent && !parent.__isSymbol() && !parent.__getSymbol())) + ); + } + + __isSymbolNested() { + if (!this.__isSymbolOrInst() || this.__isSymbolTop()) return false; + const symbTopSelf = (this.__isSymbol() + ? this + : this.__getSymbol() + ).__getSymbTop(); + const symbTop = this.__getSymbTop(); + const symbTopMain = symbTop.__isSymbol() ? symbTop : symbTop.__getSymbol(); + return symbTopMain !== symbTopSelf; + } + + __getAllById() { + const { em } = this; + return em ? em.get('DomComponents').allById() : {}; + } + + __getSymbol() { + let symb = this.get(keySymbol); + if (symb && isString(symb)) { + const ref = this.__getAllById()[symb]; + if (ref) { + symb = ref; + this.set(keySymbol, ref); + } else { + symb = 0; } + } + return symb; + } + + __getSymbols() { + let symbs = this.get(keySymbols); + if (symbs && isArray(symbs)) { + symbs.forEach((symb, idx) => { + if (symb && isString(symb)) { + symbs[idx] = this.__getAllById()[symb]; + } + }); + symbs = symbs.filter(symb => symb && !isString(symb)); + } + return symbs; + } + __isSymbOvrd(prop = '') { + const ovrd = this.get(keySymbolOvrd); + const [prp] = prop.split(':'); + const props = prop !== prp ? [prop, prp] : [prop]; + return ( + ovrd === true || (isArray(ovrd) && props.some(p => ovrd.indexOf(p) >= 0)) + ); + } + + __getSymbToUp(opts = {}) { + let result = []; + const { em } = this; + const { changed } = opts; + const symbEnabled = em && em.get('symbols'); + + if ( + opts.fromInstance || + opts.noPropagate || + opts.fromUndo || + !symbEnabled || + // Avoid updating others if the current component has override + (changed && this.__isSymbOvrd(changed)) + ) { return result; - }, - - __upSymbProps(m, opts = {}) { - const changed = this.changedAttributes(); - const attrs = changed.attributes || {}; - delete changed.status; - delete changed.open; - delete changed[keySymbols]; - delete changed[keySymbol]; - delete changed[keySymbolOvrd]; - delete changed.attributes; - delete attrs.id; - if (!isEmptyObj(attrs)) changed.attributes = attrs; - if (!isEmptyObj(changed)) { - const toUp = this.__getSymbToUp(opts); - // Avoid propagating overrides to other symbols - keys(changed).map(prop => { - if (this.__isSymbOvrd(prop)) delete changed[prop]; - }); + } - this.__logSymbol('props', toUp, { opts, changed }); - toUp.forEach(child => { - const propsChanged = { ...changed }; - // Avoid updating those with override - keys(propsChanged).map(prop => { - if (child.__isSymbOvrd(prop)) delete propsChanged[prop]; - }); - child.set(propsChanged, { fromInstance: this, ...opts }); - }); - } - }, + const symbols = this.__getSymbols() || []; + const symbol = this.__getSymbol(); + const all = symbol ? [symbol, ...(symbol.__getSymbols() || [])] : symbols; + result = all + .filter(s => s !== this) + // Avoid updating those with override + .filter(s => !(changed && s.__isSymbOvrd(changed))); + + return result; + } + + __getSymbTop(opts) { + let result = this; + let parent = this.parent(opts); - __upSymbCls(m, c, opts = {}) { + while (parent && (parent.__isSymbol() || parent.__getSymbol())) { + result = parent; + parent = parent.parent(opts); + } + + return result; + } + + __upSymbProps(m, opts = {}) { + const changed = this.changedAttributes(); + const attrs = changed.attributes || {}; + delete changed.status; + delete changed.open; + delete changed[keySymbols]; + delete changed[keySymbol]; + delete changed[keySymbolOvrd]; + delete changed.attributes; + delete attrs.id; + if (!isEmptyObj(attrs)) changed.attributes = attrs; + if (!isEmptyObj(changed)) { const toUp = this.__getSymbToUp(opts); - this.__logSymbol('classes', toUp, { opts }); - toUp.forEach(child => { - // This will propagate the change up to __upSymbProps - child.set('classes', this.get('classes'), { fromInstance: this }); + // Avoid propagating overrides to other symbols + keys(changed).map(prop => { + if (this.__isSymbOvrd(prop)) delete changed[prop]; }); - this.__changesUp(opts); - }, - - __upSymbComps(m, c, o) { - const optUp = o || c || {}; - const { fromInstance, fromUndo } = optUp; - const toUpOpts = { fromInstance, fromUndo }; - const isTemp = m.opt.temporary; - - // Reset - if (!o) { - const toUp = this.__getSymbToUp({ - ...toUpOpts, - changed: 'components:reset' - }); - this.__logSymbol('reset', toUp, { components: m.models }); - toUp.forEach(symb => { - const newMods = m.models.map(mod => mod.clone({ symbol: 1 })); - symb.components().reset(newMods, { fromInstance: this, ...c }); + + this.__logSymbol('props', toUp, { opts, changed }); + toUp.forEach(child => { + const propsChanged = { ...changed }; + // Avoid updating those with override + keys(propsChanged).map(prop => { + if (child.__isSymbOvrd(prop)) delete propsChanged[prop]; }); - // Add - } else if (o.add) { - let addedInstances = []; - const isMainSymb = !!this.__getSymbols(); - const toUp = this.__getSymbToUp({ - ...toUpOpts, - changed: 'components:add' + child.set(propsChanged, { fromInstance: this, ...opts }); + }); + } + } + + __upSymbCls(m, c, opts = {}) { + const toUp = this.__getSymbToUp(opts); + this.__logSymbol('classes', toUp, { opts }); + toUp.forEach(child => { + // This will propagate the change up to __upSymbProps + child.set('classes', this.get('classes'), { fromInstance: this }); + }); + this.__changesUp(opts); + } + + __upSymbComps(m, c, o) { + const optUp = o || c || {}; + const { fromInstance, fromUndo } = optUp; + const toUpOpts = { fromInstance, fromUndo }; + const isTemp = m.opt.temporary; + + // Reset + if (!o) { + const toUp = this.__getSymbToUp({ + ...toUpOpts, + changed: 'components:reset' + }); + this.__logSymbol('reset', toUp, { components: m.models }); + toUp.forEach(symb => { + const newMods = m.models.map(mod => mod.clone({ symbol: 1 })); + symb.components().reset(newMods, { fromInstance: this, ...c }); + }); + // Add + } else if (o.add) { + let addedInstances = []; + const isMainSymb = !!this.__getSymbols(); + const toUp = this.__getSymbToUp({ + ...toUpOpts, + changed: 'components:add' + }); + if (toUp.length) { + const addSymb = m.__getSymbol(); + addedInstances = + (addSymb ? addSymb.__getSymbols() : m.__getSymbols()) || []; + addedInstances = [...addedInstances]; + addedInstances.push(addSymb ? addSymb : m); + } + !isTemp && + this.__logSymbol('add', toUp, { + opts: o, + addedInstances: addedInstances.map(c => c.cid), + added: m.cid }); - if (toUp.length) { - const addSymb = m.__getSymbol(); - addedInstances = - (addSymb ? addSymb.__getSymbols() : m.__getSymbols()) || []; - addedInstances = [...addedInstances]; - addedInstances.push(addSymb ? addSymb : m); + // Here, before appending a new symbol, I have to ensure there are no previosly + // created symbols (eg. used mainly when drag components around) + toUp.forEach(symb => { + const symbTop = symb.__getSymbTop(); + const symbPrev = addedInstances.filter(addedInst => { + const addedTop = addedInst.__getSymbTop({ prev: 1 }); + return symbTop && addedTop && addedTop === symbTop; + })[0]; + const toAppend = + symbPrev || m.clone({ symbol: 1, symbolInv: isMainSymb }); + symb.append(toAppend, { fromInstance: this, ...o }); + }); + // Remove + } else { + // Remove instance reference from the symbol + const symb = m.__getSymbol(); + symb && + !o.temporary && + symb.set( + keySymbols, + symb.__getSymbols().filter(i => i !== m) + ); + + // Propagate remove only if the component is an inner symbol + if (!m.__isSymbolTop()) { + const changed = 'components:remove'; + const { index } = o; + const parent = m.parent(); + const opts = { fromInstance: m, ...o }; + const isSymbNested = m.__isSymbolNested(); + let toUpFn = symb => { + const symbPrnt = symb.parent(); + symbPrnt && !symbPrnt.__isSymbOvrd(changed) && symb.remove(opts); + }; + // Check if the parent allows the removing + let toUp = !parent.__isSymbOvrd(changed) + ? m.__getSymbToUp(toUpOpts) + : []; + + if (isSymbNested) { + toUp = parent.__getSymbToUp({ ...toUpOpts, changed }); + toUpFn = symb => { + const toRemove = symb.components().at(index); + toRemove && toRemove.remove({ fromInstance: parent, ...opts }); + }; } + !isTemp && - this.__logSymbol('add', toUp, { + this.__logSymbol('remove', toUp, { opts: o, - addedInstances: addedInstances.map(c => c.cid), - added: m.cid + removed: m.cid, + isSymbNested }); - // Here, before appending a new symbol, I have to ensure there are no previosly - // created symbols (eg. used mainly when drag components around) - toUp.forEach(symb => { - const symbTop = symb.__getSymbTop(); - const symbPrev = addedInstances.filter(addedInst => { - const addedTop = addedInst.__getSymbTop({ prev: 1 }); - return symbTop && addedTop && addedTop === symbTop; - })[0]; - const toAppend = - symbPrev || m.clone({ symbol: 1, symbolInv: isMainSymb }); - symb.append(toAppend, { fromInstance: this, ...o }); - }); - // Remove - } else { - // Remove instance reference from the symbol - const symb = m.__getSymbol(); - symb && - !o.temporary && - symb.set( - keySymbols, - symb.__getSymbols().filter(i => i !== m) - ); - - // Propagate remove only if the component is an inner symbol - if (!m.__isSymbolTop()) { - const changed = 'components:remove'; - const { index } = o; - const parent = m.parent(); - const opts = { fromInstance: m, ...o }; - const isSymbNested = m.__isSymbolNested(); - let toUpFn = symb => { - const symbPrnt = symb.parent(); - symbPrnt && !symbPrnt.__isSymbOvrd(changed) && symb.remove(opts); - }; - // Check if the parent allows the removing - let toUp = !parent.__isSymbOvrd(changed) - ? m.__getSymbToUp(toUpOpts) - : []; - - if (isSymbNested) { - toUp = parent.__getSymbToUp({ ...toUpOpts, changed }); - toUpFn = symb => { - const toRemove = symb.components().at(index); - toRemove && toRemove.remove({ fromInstance: parent, ...opts }); - }; - } - - !isTemp && - this.__logSymbol('remove', toUp, { - opts: o, - removed: m.cid, - isSymbNested - }); - toUp.forEach(toUpFn); - } + toUp.forEach(toUpFn); } + } - this.__changesUp(optUp); - }, - - initClasses(m, c, opts = {}) { - const event = 'change:classes'; - const attrCls = this.get('attributes').class || []; - const toListen = [this, event, this.initClasses]; - const cls = this.get('classes') || attrCls; - const clsArr = isString(cls) ? cls.split(' ') : cls; - this.stopListening(...toListen); - const classes = this.normalizeClasses(clsArr); - const selectors = new Selectors([]); - this.set('classes', selectors, opts); - selectors.add(classes); - selectors.on('add remove reset', this.__upSymbCls); - this.listenTo(...toListen); - return this; - }, - - initComponents() { - const event = 'change:components'; - const toListen = [this, event, this.initComponents]; - this.stopListening(...toListen); - // Have to add components after the init, otherwise the parent - // is not visible - const comps = new Components(null, this.opt); - comps.parent = this; - const components = this.get('components'); - const addChild = !this.opt.avoidChildren; - this.set('components', comps); - addChild && - components && - comps.add( - isFunction(components) ? components(this) : components, - this.opt - ); - comps.on('add remove reset', this.__upSymbComps); - this.listenTo(...toListen); - return this; - }, - - initTraits(changed) { - const { em } = this; - const event = 'change:traits'; - const toListen = [this, event, this.initTraits]; - this.stopListening(...toListen); - this.loadTraits(); - const attrs = { ...this.get('attributes') }; - const traits = this.get('traits'); - traits.each(trait => { - if (!trait.get('changeProp')) { - const name = trait.get('name'); - const value = trait.getInitValue(); - if (name && value) attrs[name] = value; - } - }); - traits.length && this.set('attributes', attrs); - this.listenTo(...toListen); - changed && em && em.trigger('component:toggled'); - return this; - }, - - initScriptProps() { - if (this.opt.temporary) return; - const prop = 'script-props'; - const toListen = [`change:${prop}`, this.initScriptProps]; - this.off(...toListen); - const prevProps = this.previous(prop) || []; - const newProps = this.get(prop) || []; - const prevPropsEv = prevProps.map(e => `change:${e}`).join(' '); - const newPropsEv = newProps.map(e => `change:${e}`).join(' '); - prevPropsEv && this.off(prevPropsEv, this.__scriptPropsChange); - newPropsEv && this.on(newPropsEv, this.__scriptPropsChange); - this.on(...toListen); - }, - - __scriptPropsChange(m, v, opts = {}) { - if (opts.avoidStore) return; - this.trigger('rerender'); - }, - - /** - * Add new component children - * @param {Component|String} components Component to add - * @param {Object} [opts={}] Options for the append action - * @return {Array} Array of appended components - * @example - * someComponent.get('components').length // -> 0 - * const videoComponent = someComponent.append('
')[0]; - * // This will add 2 components (`video` and `div`) to your `someComponent` - * someComponent.get('components').length // -> 2 - * // You can pass components directly - * otherComponent.append(otherComponent2); - * otherComponent.append([otherComponent3, otherComponent4]); - * // append at specific index (eg. at the beginning) - * someComponent.append(otherComponent, { at: 0 }); - */ - append(components, opts = {}) { - const compArr = isArray(components) ? components : [components]; - const toAppend = compArr.map(comp => { - if (isString(comp)) { - return comp; - } else { - // I have to remove components from the old container before adding them to a new one - comp.collection && comp.collection.remove(comp, { temporary: 1 }); - return comp; - } - }); - const result = this.components().add(toAppend, opts); - return isArray(result) ? result : [result]; - }, - - /** - * Set new collection if `components` are provided, otherwise the - * current collection is returned - * @param {Component|String} [components] Components to set - * @param {Object} [opts={}] Options, same as in `Component.append()` - * @return {Collection|Array} - * @example - * // Set new collection - * component.components('
'); - * // Get current collection - * const collection = component.components(); - * console.log(collection.length); - * // -> 2 - */ - components(components, opts = {}) { - const coll = this.get('components'); - - if (isUndefined(components)) { - return coll; - } else { - coll.reset(null, opts); - return components && this.append(components, opts); + this.__changesUp(optUp); + } + + initClasses(m, c, opts = {}) { + const event = 'change:classes'; + const attrCls = this.get('attributes').class || []; + const toListen = [this, event, this.initClasses]; + const cls = this.get('classes') || attrCls; + const clsArr = isString(cls) ? cls.split(' ') : cls; + this.stopListening(...toListen); + const classes = this.normalizeClasses(clsArr); + const selectors = new Selectors([]); + this.set('classes', selectors, opts); + selectors.add(classes); + selectors.on('add remove reset', this.__upSymbCls); + this.listenTo(...toListen); + return this; + } + + initComponents() { + const event = 'change:components'; + const toListen = [this, event, this.initComponents]; + this.stopListening(...toListen); + // Have to add components after the init, otherwise the parent + // is not visible + const comps = new Components(null, this.opt); + comps.parent = this; + const components = this.get('components'); + const addChild = !this.opt.avoidChildren; + this.set('components', comps); + addChild && + components && + comps.add( + isFunction(components) ? components(this) : components, + this.opt + ); + comps.on('add remove reset', this.__upSymbComps); + this.listenTo(...toListen); + return this; + } + + initTraits(changed) { + const { em } = this; + const event = 'change:traits'; + const toListen = [this, event, this.initTraits]; + this.stopListening(...toListen); + this.loadTraits(); + const attrs = { ...this.get('attributes') }; + const traits = this.get('traits'); + traits.each(trait => { + if (!trait.get('changeProp')) { + const name = trait.get('name'); + const value = trait.getInitValue(); + if (name && value) attrs[name] = value; } - }, - - /** - * Remove all inner components - * * @return {this} - */ - empty(opts = {}) { - this.components().reset(null, opts); - return this; - }, - - /** - * Get the parent component, if exists - * @return {Component} - * @example - * component.parent(); - * // -> Component - */ - parent(opts = {}) { - const coll = this.collection || (opts.prev && this.prevColl); - return coll && coll.parent; - }, - - /** - * Script updated - * @private - */ - scriptUpdated() { - this.set('scriptUpdated', 1); - }, - - /** - * Init toolbar - * @private - */ - initToolbar() { - const { em } = this; - const model = this; - const ppfx = (em && em.getConfig('stylePrefix')) || ''; - - if (!model.get('toolbar')) { - var tb = []; - if (model.collection) { - tb.push({ - attributes: { class: 'fa fa-arrow-up' }, - command: ed => ed.runCommand('core:component-exit', { force: 1 }) - }); - } - if (model.get('draggable')) { - tb.push({ - attributes: { - class: `fa fa-arrows ${ppfx}no-touch-actions`, - draggable: true - }, - //events: hasDnd(this.em) ? { dragstart: 'execCommand' } : '', - command: 'tlb-move' - }); - } - if (model.get('copyable')) { - tb.push({ - attributes: { class: 'fa fa-clone' }, - command: 'tlb-clone' - }); - } - if (model.get('removable')) { - tb.push({ - attributes: { class: 'fa fa-trash-o' }, - command: 'tlb-delete' - }); - } - model.set('toolbar', tb); + }); + traits.length && this.set('attributes', attrs); + this.listenTo(...toListen); + changed && em && em.trigger('component:toggled'); + return this; + } + + initScriptProps() { + if (this.opt.temporary) return; + const prop = 'script-props'; + const toListen = [`change:${prop}`, this.initScriptProps]; + this.off(...toListen); + const prevProps = this.previous(prop) || []; + const newProps = this.get(prop) || []; + const prevPropsEv = prevProps.map(e => `change:${e}`).join(' '); + const newPropsEv = newProps.map(e => `change:${e}`).join(' '); + prevPropsEv && this.off(prevPropsEv, this.__scriptPropsChange); + newPropsEv && this.on(newPropsEv, this.__scriptPropsChange); + this.on(...toListen); + } + + __scriptPropsChange(m, v, opts = {}) { + if (opts.avoidStore) return; + this.trigger('rerender'); + } + + /** + * Add new component children + * @param {Component|String} components Component to add + * @param {Object} [opts={}] Options for the append action + * @return {Array} Array of appended components + * @example + * someComponent.get('components').length // -> 0 + * const videoComponent = someComponent.append('
')[0]; + * // This will add 2 components (`video` and `div`) to your `someComponent` + * someComponent.get('components').length // -> 2 + * // You can pass components directly + * otherComponent.append(otherComponent2); + * otherComponent.append([otherComponent3, otherComponent4]); + * // append at specific index (eg. at the beginning) + * someComponent.append(otherComponent, { at: 0 }); + */ + append(components, opts = {}) { + const compArr = isArray(components) ? components : [components]; + const toAppend = compArr.map(comp => { + if (isString(comp)) { + return comp; + } else { + // I have to remove components from the old container before adding them to a new one + comp.collection && comp.collection.remove(comp, { temporary: 1 }); + return comp; } - }, - - /** - * Load traits - * @param {Array} traits - * @private - */ - loadTraits(traits, opts = {}) { - traits = traits || this.get('traits'); - traits = isFunction(traits) ? traits(this) : traits; - - if (!(traits instanceof Traits)) { - const trt = new Traits([], this.opt); - trt.setTarget(this); - - if (traits.length) { - traits.forEach(tr => tr.attributes && delete tr.attributes.value); - trt.add(traits); - } + }); + const result = this.components().add(toAppend, opts); + return isArray(result) ? result : [result]; + } + + /** + * Set new collection if `components` are provided, otherwise the + * current collection is returned + * @param {Component|String} [components] Components to set + * @param {Object} [opts={}] Options, same as in `Component.append()` + * @return {Collection|Array} + * @example + * // Set new collection + * component.components('
'); + * // Get current collection + * const collection = component.components(); + * console.log(collection.length); + * // -> 2 + */ + components(components, opts = {}) { + const coll = this.get('components'); + + if (isUndefined(components)) { + return coll; + } else { + coll.reset(null, opts); + return components && this.append(components, opts); + } + } + + /** + * Remove all inner components + * * @return {this} + */ + empty(opts = {}) { + this.components().reset(null, opts); + return this; + } + + /** + * Get the parent component, if exists + * @return {Component} + * @example + * component.parent(); + * // -> Component + */ + parent(opts = {}) { + const coll = this.collection || (opts.prev && this.prevColl); + return coll && coll.parent; + } + + /** + * Script updated + * @private + */ + scriptUpdated() { + this.set('scriptUpdated', 1); + } - this.set('traits', trt, opts); + /** + * Init toolbar + * @private + */ + initToolbar() { + const { em } = this; + const model = this; + const ppfx = (em && em.getConfig('stylePrefix')) || ''; + + if (!model.get('toolbar')) { + var tb = []; + if (model.collection) { + tb.push({ + attributes: { class: 'fa fa-arrow-up' }, + command: ed => ed.runCommand('core:component-exit', { force: 1 }) + }); + } + if (model.get('draggable')) { + tb.push({ + attributes: { + class: `fa fa-arrows ${ppfx}no-touch-actions`, + draggable: true + }, + //events: hasDnd(this.em) ? { dragstart: 'execCommand' } : '', + command: 'tlb-move' + }); } + if (model.get('copyable')) { + tb.push({ + attributes: { class: 'fa fa-clone' }, + command: 'tlb-clone' + }); + } + if (model.get('removable')) { + tb.push({ + attributes: { class: 'fa fa-trash-o' }, + command: 'tlb-delete' + }); + } + model.set('toolbar', tb); + } + } - return this; - }, - - /** - * Get the trait by id/name - * @param {String} id The `id` or `name` of the trait - * @return {Trait} Trait model - * @example - * const traitTitle = component.getTrait('title'); - * traitTitle && traitTitle.set('label', 'New label'); - */ - getTrait(id) { - return this.get('traits').filter(trait => { - return trait.get('id') === id || trait.get('name') === id; - })[0]; - }, - - /** - * Update a trait - * @param {String} id The `id` or `name` of the trait - * @param {Object} props Object with the props to update - * @return {this} - * @example - * component.updateTrait('title', { - * type: 'select', - * options: [ 'Option 1', 'Option 2' ], - * }); - */ - updateTrait(id, props) { - const { em } = this; - const trait = this.getTrait(id); - trait && trait.set(props); - em && em.trigger('component:toggled'); - return this; - }, - - /** - * Get the trait position index by id/name. Useful in case you want to - * replace some trait, at runtime, with something else. - * @param {String} id The `id` or `name` of the trait - * @return {Number} Index position of the current trait - * @example - * const traitTitle = component.getTraitIndex('title'); - * console.log(traitTitle); // 1 - */ - getTraitIndex(id) { - const trait = this.getTrait(id); - return trait ? this.get('traits').indexOf(trait) : -1; - }, - - /** - * Remove trait/s by id/s. - * @param {String|Array} id The `id`/`name` of the trait (or an array) - * @return {Array} Array of removed traits - * @example - * component.removeTrait('title'); - * component.removeTrait(['title', 'id']); - */ - removeTrait(id) { - const { em } = this; - const ids = isArray(id) ? id : [id]; - const toRemove = ids.map(id => this.getTrait(id)); - const removed = this.get('traits').remove(toRemove); - em && em.trigger('component:toggled'); - return removed; - }, - - /** - * Add trait/s by id/s. - * @param {String|Object|Array} trait Trait to add (or an array of traits) - * @param {Options} opts Options for the add - * @return {Array} Array of added traits - * @example - * component.addTrait('title', { at: 1 }); // Add title trait (`at` option is the position index) - * component.addTrait({ - * type: 'checkbox', - * name: 'disabled', - * }); - * component.addTrait(['title', {...}, ...]); - */ - addTrait(trait, opts = {}) { - const { em } = this; - const added = this.get('traits').add(trait, opts); - em && em.trigger('component:toggled'); - return added; - }, - - /** - * Normalize input classes from array to array of objects - * @param {Array} arr - * @return {Array} - * @private - */ - normalizeClasses(arr) { - const res = []; - const { em } = this; - const clm = em && em.get('SelectorManager'); - if (!clm) return; - if (arr.models) return [...arr.models]; - arr.forEach(val => res.push(clm.add(val))); - return res; - }, - - /** - * Override original clone method - * @private - */ - clone(opt = {}) { - const em = this.em; - const attr = { ...this.attributes }; - const opts = { ...this.opt }; - const id = this.getId(); - const cssc = em && em.get('CssComposer'); - attr.attributes = { ...attr.attributes }; - delete attr.attributes.id; - attr.components = []; - attr.classes = []; - attr.traits = []; - - if (this.__isSymbolTop()) { - opt.symbol = 1; + /** + * Load traits + * @param {Array} traits + * @private + */ + loadTraits(traits, opts = {}) { + traits = traits || this.get('traits'); + traits = isFunction(traits) ? traits(this) : traits; + + if (!(traits instanceof Traits)) { + const trt = new Traits([], this.opt); + trt.setTarget(this); + + if (traits.length) { + traits.forEach(tr => tr.attributes && delete tr.attributes.value); + trt.add(traits); } - this.get('components').each((md, i) => { - attr.components[i] = md.clone({ ...opt, _inner: 1 }); - }); - this.get('traits').each((md, i) => { - attr.traits[i] = md.clone(); - }); - this.get('classes').each((md, i) => { - attr.classes[i] = md.get('name'); - }); + this.set('traits', trt, opts); + } - attr.status = ''; - opts.collection = null; + return this; + } - const cloned = new this.constructor(attr, opts); + /** + * Get the trait by id/name + * @param {String} id The `id` or `name` of the trait + * @return {Trait} Trait model + * @example + * const traitTitle = component.getTrait('title'); + * traitTitle && traitTitle.set('label', 'New label'); + */ + getTrait(id) { + return this.get('traits').filter(trait => { + return trait.get('id') === id || trait.get('name') === id; + })[0]; + } - // Clone component specific rules - const newId = `#${cloned.getId()}`; - const rulesToClone = cssc ? cssc.getRules(`#${id}`) : []; - rulesToClone.forEach(rule => { - const newRule = rule.clone(); - newRule.set('selectors', [newId]); - cssc.getAll().add(newRule); - }); + /** + * Update a trait + * @param {String} id The `id` or `name` of the trait + * @param {Object} props Object with the props to update + * @return {this} + * @example + * component.updateTrait('title', { + * type: 'select', + * options: [ 'Option 1', 'Option 2' ], + * }); + */ + updateTrait(id, props) { + const { em } = this; + const trait = this.getTrait(id); + trait && trait.set(props); + em && em.trigger('component:toggled'); + return this; + } + + /** + * Get the trait position index by id/name. Useful in case you want to + * replace some trait, at runtime, with something else. + * @param {String} id The `id` or `name` of the trait + * @return {Number} Index position of the current trait + * @example + * const traitTitle = component.getTraitIndex('title'); + * console.log(traitTitle); // 1 + */ + getTraitIndex(id) { + const trait = this.getTrait(id); + return trait ? this.get('traits').indexOf(trait) : -1; + } + + /** + * Remove trait/s by id/s. + * @param {String|Array} id The `id`/`name` of the trait (or an array) + * @return {Array} Array of removed traits + * @example + * component.removeTrait('title'); + * component.removeTrait(['title', 'id']); + */ + removeTrait(id) { + const { em } = this; + const ids = isArray(id) ? id : [id]; + const toRemove = ids.map(id => this.getTrait(id)); + const removed = this.get('traits').remove(toRemove); + em && em.trigger('component:toggled'); + return removed; + } + + /** + * Add trait/s by id/s. + * @param {String|Object|Array} trait Trait to add (or an array of traits) + * @param {Options} opts Options for the add + * @return {Array} Array of added traits + * @example + * component.addTrait('title', { at: 1 }); // Add title trait (`at` option is the position index) + * component.addTrait({ + * type: 'checkbox', + * name: 'disabled', + * }); + * component.addTrait(['title', {...}, ...]); + */ + addTrait(trait, opts = {}) { + const { em } = this; + const added = this.get('traits').add(trait, opts); + em && em.trigger('component:toggled'); + return added; + } + + /** + * Normalize input classes from array to array of objects + * @param {Array} arr + * @return {Array} + * @private + */ + normalizeClasses(arr) { + const res = []; + const { em } = this; + const clm = em && em.get('SelectorManager'); + if (!clm) return; + if (arr.models) return [...arr.models]; + arr.forEach(val => res.push(clm.add(val))); + return res; + } - // Symbols - // If I clone an inner symbol, I have to reset it + /** + * Override original clone method + * @private + */ + clone(opt = {}) { + const em = this.em; + const attr = { ...this.attributes }; + const opts = { ...this.opt }; + const id = this.getId(); + const cssc = em && em.get('CssComposer'); + attr.attributes = { ...attr.attributes }; + delete attr.attributes.id; + attr.components = []; + attr.classes = []; + attr.traits = []; + + if (this.__isSymbolTop()) { + opt.symbol = 1; + } + + this.get('components').each((md, i) => { + attr.components[i] = md.clone({ ...opt, _inner: 1 }); + }); + this.get('traits').each((md, i) => { + attr.traits[i] = md.clone(); + }); + this.get('classes').each((md, i) => { + attr.classes[i] = md.get('name'); + }); + + attr.status = ''; + opts.collection = null; + + const cloned = new this.constructor(attr, opts); + + // Clone component specific rules + const newId = `#${cloned.getId()}`; + const rulesToClone = cssc ? cssc.getRules(`#${id}`) : []; + rulesToClone.forEach(rule => { + const newRule = rule.clone(); + newRule.set('selectors', [newId]); + cssc.getAll().add(newRule); + }); + + // Symbols + // If I clone an inner symbol, I have to reset it + cloned.set(keySymbols, 0); + const symbol = this.__getSymbol(); + const symbols = this.__getSymbols(); + + if (!opt.symbol && (symbol || symbols)) { + cloned.set(keySymbol, 0); cloned.set(keySymbols, 0); - const symbol = this.__getSymbol(); - const symbols = this.__getSymbols(); - - if (!opt.symbol && (symbol || symbols)) { - cloned.set(keySymbol, 0); - cloned.set(keySymbols, 0); - } else if (symbol) { - // Contains already a reference to a symbol - symbol.set(keySymbols, [...symbol.__getSymbols(), cloned]); + } else if (symbol) { + // Contains already a reference to a symbol + symbol.set(keySymbols, [...symbol.__getSymbols(), cloned]); + cloned.__initSymb(); + } else if (opt.symbol) { + // Request to create a symbol + if (this.__isSymbol()) { + // Already a symbol, cloned should be an instance + this.set(keySymbols, [...symbols, cloned]); + cloned.set(keySymbol, this); cloned.__initSymb(); - } else if (opt.symbol) { - // Request to create a symbol - if (this.__isSymbol()) { - // Already a symbol, cloned should be an instance - this.set(keySymbols, [...symbols, cloned]); - cloned.set(keySymbol, this); - cloned.__initSymb(); - } else if (opt.symbolInv) { - // Inverted, cloned is the instance, the origin is the main symbol - this.set(keySymbols, [cloned]); - cloned.set(keySymbol, this); - [this, cloned].map(i => i.__initSymb()); - } else { - // Cloned becomes the main symbol - cloned.set(keySymbols, [this]); - [this, cloned].map(i => i.__initSymb()); - this.set(keySymbol, cloned); - } + } else if (opt.symbolInv) { + // Inverted, cloned is the instance, the origin is the main symbol + this.set(keySymbols, [cloned]); + cloned.set(keySymbol, this); + [this, cloned].map(i => i.__initSymb()); + } else { + // Cloned becomes the main symbol + cloned.set(keySymbols, [this]); + [this, cloned].map(i => i.__initSymb()); + this.set(keySymbol, cloned); } + } - const event = 'component:clone'; - em && em.trigger(event, cloned); - this.trigger(event, cloned); - - return cloned; - }, - - /** - * Get the name of the component - * @return {String} - * */ - getName() { - const { em } = this; - const { type, tagName } = this.attributes; - const cName = this.get('name'); - const isDiv = tagName == 'div'; - const tag = isDiv ? 'box' : tagName; - const defName = type || tag; - const nameTag = !type && tagName && !isDiv && tagName; - const i18nPfx = 'domComponents.names.'; - const i18nName = cName && em && em.t(`${i18nPfx}${cName}`); - const i18nNameTag = nameTag && em && em.t(`${i18nPfx}${nameTag}`); - const i18nDefName = - em && (em.t(`${i18nPfx}${type}`) || em.t(`${i18nPfx}${tagName}`)); - return ( - this.get('custom-name') || // Used in Layers (when the user changes the name) - i18nName || - cName || // Component name (check if there is a i18n string for it) - i18nNameTag || - capitalize(nameTag) || // Try name by tag if there is no valid type - i18nDefName || - capitalize(defName) // Use the default name - ); - }, - - /** - * Get the icon string - * @return {String} - */ - getIcon() { - let icon = this.get('icon'); - return icon ? icon + ' ' : ''; - }, - - /** - * Return HTML string of the component - * @param {Object} [opts={}] Options - * @param {String} [opts.tag] Custom tagName - * @param {Object|Function} [opts.attributes=null] You can pass an object of custom attributes to replace - * with the current one or you can even pass a function to generate attributes dynamically - * @return {String} HTML string - * @example - * // Simple HTML return - * component.set({ tagName: 'span' }); - * component.setAttributes({ title: 'Hello' }); - * component.toHTML(); - * // -> - * - * // Custom attributes - * component.toHTML({ attributes: { 'data-test': 'Hello' } }); - * // -> - * - * // Custom dynamic attributes - * component.toHTML({ - * attributes(component, attributes) { - * if (component.get('tagName') == 'span') { - * attributes.title = 'Custom attribute'; - * } - * return attributes; - * }, - * }); - * // -> - */ - toHTML(opts = {}) { - const model = this; - const attrs = []; - const customTag = opts.tag; - const tag = customTag || model.get('tagName'); - const sTag = model.get('void'); - const customAttr = opts.attributes; - let attributes = this.getAttrToHTML(); - delete opts.tag; - - // Get custom attributes if requested - if (customAttr) { - if (isFunction(customAttr)) { - attributes = customAttr(model, attributes) || {}; - } else if (isObject(customAttr)) { - attributes = customAttr; - } - } + const event = 'component:clone'; + em && em.trigger(event, cloned); + this.trigger(event, cloned); - for (let attr in attributes) { - const val = attributes[attr]; - const value = isString(val) ? val.replace(/"/g, '"') : val; + return cloned; + } - if (!isUndefined(value)) { - if (isBoolean(value)) { - value && attrs.push(attr); - } else { - attrs.push(`${attr}="${value}"`); - } - } + /** + * Get the name of the component + * @return {String} + * */ + getName() { + const { em } = this; + const { type, tagName } = this.attributes; + const cName = this.get('name'); + const isDiv = tagName == 'div'; + const tag = isDiv ? 'box' : tagName; + const defName = type || tag; + const nameTag = !type && tagName && !isDiv && tagName; + const i18nPfx = 'domComponents.names.'; + const i18nName = cName && em && em.t(`${i18nPfx}${cName}`); + const i18nNameTag = nameTag && em && em.t(`${i18nPfx}${nameTag}`); + const i18nDefName = + em && (em.t(`${i18nPfx}${type}`) || em.t(`${i18nPfx}${tagName}`)); + return ( + this.get('custom-name') || // Used in Layers (when the user changes the name) + i18nName || + cName || // Component name (check if there is a i18n string for it) + i18nNameTag || + capitalize(nameTag) || // Try name by tag if there is no valid type + i18nDefName || + capitalize(defName) // Use the default name + ); + } + + /** + * Get the icon string + * @return {String} + */ + getIcon() { + let icon = this.get('icon'); + return icon ? icon + ' ' : ''; + } + + /** + * Return HTML string of the component + * @param {Object} [opts={}] Options + * @param {String} [opts.tag] Custom tagName + * @param {Object|Function} [opts.attributes=null] You can pass an object of custom attributes to replace + * with the current one or you can even pass a function to generate attributes dynamically + * @return {String} HTML string + * @example + * // Simple HTML return + * component.set({ tagName: 'span' }); + * component.setAttributes({ title: 'Hello' }); + * component.toHTML(); + * // -> + * + * // Custom attributes + * component.toHTML({ attributes: { 'data-test': 'Hello' } }); + * // -> + * + * // Custom dynamic attributes + * component.toHTML({ + * attributes(component, attributes) { + * if (component.get('tagName') == 'span') { + * attributes.title = 'Custom attribute'; + * } + * return attributes; + * }, + * }); + * // -> + */ + toHTML(opts = {}) { + const model = this; + const attrs = []; + const customTag = opts.tag; + const tag = customTag || model.get('tagName'); + const sTag = model.get('void'); + const customAttr = opts.attributes; + let attributes = this.getAttrToHTML(); + delete opts.tag; + + // Get custom attributes if requested + if (customAttr) { + if (isFunction(customAttr)) { + attributes = customAttr(model, attributes) || {}; + } else if (isObject(customAttr)) { + attributes = customAttr; } + } - const comps = model.get('components'); - const content = !comps.length ? model.get('content') : ''; - const attrString = attrs.length ? ` ${attrs.join(' ')}` : ''; - let code = `<${tag}${attrString}${sTag ? '/' : ''}>${content}`; - comps.forEach(comp => (code += comp.toHTML(opts))); - !sTag && (code += ``); - - return code; - }, - - /** - * Returns object of attributes for HTML - * @return {Object} - * @private - */ - getAttrToHTML() { - var attr = this.getAttributes(); - delete attr.style; - return attr; - }, - - /** - * Return a shallow copy of the model's attributes for JSON - * stringification. - * @return {Object} - * @private - */ - toJSON(opts = {}) { - const obj = Backbone.Model.prototype.toJSON.call(this, opts); - obj.attributes = this.getAttributes(); - delete obj.attributes.class; - delete obj.toolbar; - delete obj.traits; - delete obj.status; - delete obj.open; // used in Layers - - if (!opts.fromUndo) { - const symbol = obj[keySymbol]; - const symbols = obj[keySymbols]; - if (symbols && isArray(symbols)) { - obj[keySymbols] = symbols - .filter(i => i) - .map(i => (i.getId ? i.getId() : i)); - } - if (symbol && !isString(symbol)) { - obj[keySymbol] = symbol.getId(); + for (let attr in attributes) { + const val = attributes[attr]; + const value = isString(val) ? val.replace(/"/g, '"') : val; + + if (!isUndefined(value)) { + if (isBoolean(value)) { + value && attrs.push(attr); + } else { + attrs.push(`${attr}="${value}"`); } } + } + + const comps = model.get('components'); + const content = !comps.length ? model.get('content') : ''; + const attrString = attrs.length ? ` ${attrs.join(' ')}` : ''; + let code = `<${tag}${attrString}${sTag ? '/' : ''}>${content}`; + comps.forEach(comp => (code += comp.toHTML(opts))); + !sTag && (code += ``); + + return code; + } + + /** + * Returns object of attributes for HTML + * @return {Object} + * @private + */ + getAttrToHTML() { + var attr = this.getAttributes(); + delete attr.style; + return attr; + } - if (this.em.getConfig('avoidDefaults')) { - this.getChangedProps(obj); + /** + * Return a shallow copy of the model's attributes for JSON + * stringification. + * @return {Object} + * @private + */ + toJSON(opts = {}) { + const obj = Model.prototype.toJSON.call(this, opts); + obj.attributes = this.getAttributes(); + delete obj.attributes.class; + delete obj.toolbar; + delete obj.traits; + delete obj.status; + delete obj.open; // used in Layers + + if (!opts.fromUndo) { + const symbol = obj[keySymbol]; + const symbols = obj[keySymbols]; + if (symbols && isArray(symbols)) { + obj[keySymbols] = symbols + .filter(i => i) + .map(i => (i.getId ? i.getId() : i)); + } + if (symbol && !isString(symbol)) { + obj[keySymbol] = symbol.getId(); } + } - return obj; - }, + if (this.em.getConfig('avoidDefaults')) { + this.getChangedProps(obj); + } - /** - * Return an object containing only changed props - */ - getChangedProps(res) { - const obj = res || Backbone.Model.prototype.toJSON.apply(this); - const defaults = result(this, 'defaults'); + return obj; + } - forEach(defaults, (value, key) => { - if (['type'].indexOf(key) === -1 && obj[key] === value) { - delete obj[key]; - } - }); + /** + * Return an object containing only changed props + */ + getChangedProps(res) { + const obj = res || Model.prototype.toJSON.apply(this); + const defaults = result(this, 'defaults'); - if (isEmpty(obj.type)) { - delete obj.type; + forEach(defaults, (value, key) => { + if (['type'].indexOf(key) === -1 && obj[key] === value) { + delete obj[key]; } + }); - forEach(['attributes', 'style'], prop => { - if (isEmpty(defaults[prop]) && isEmpty(obj[prop])) { - delete obj[prop]; - } - }); - - forEach(['classes', 'components'], prop => { - if (!obj[prop] || (isEmpty(defaults[prop]) && !obj[prop].length)) { - delete obj[prop]; - } - }); + if (isEmpty(obj.type)) { + delete obj.type; + } - return obj; - }, - - /** - * Return the component id - * @return {String} - */ - getId() { - let attrs = this.get('attributes') || {}; - return attrs.id || this.ccid || this.cid; - }, - - /** - * Set new id on the component - * @param {String} id - * @return {this} - */ - setId(id, opts) { - const attrs = { ...this.get('attributes') }; - attrs.id = id; - this.set('attributes', attrs, opts); - return this; - }, - - /** - * Get the DOM element of the component. - * This works only if the component is already rendered - * @param {Frame} frame Specific frame from which taking the element - * @return {HTMLElement} - */ - getEl(frame) { - const view = this.getView(frame); - return view && view.el; - }, - - /** - * Get the View of the component. - * This works only if the component is already rendered - * @param {Frame} frame Get View of a specific frame - * @return {ComponentView} - */ - getView(frame) { - let { view, views } = this; - - if (frame) { - view = views.filter(view => view._getFrame() === frame.view)[0]; + forEach(['attributes', 'style'], prop => { + if (isEmpty(defaults[prop]) && isEmpty(obj[prop])) { + delete obj[prop]; } + }); - return view; - }, - - getCurrentView() { - const frame = (this.em.get('currentFrame') || {}).model; - return this.getView(frame); - }, - - __getScriptProps() { - const modelProps = this.props(); - const scrProps = this.get('script-props') || []; - return scrProps.reduce((acc, prop) => { - acc[prop] = modelProps[prop]; - return acc; - }, {}); - }, - - /** - * Return script in string format, cleans 'function() {..' from scripts - * if it's a function - * @param {string|Function} script - * @return {string} - * @private - */ - getScriptString(script) { - let scr = script || this.get('script'); - - if (!scr) { - return scr; + forEach(['classes', 'components'], prop => { + if (!obj[prop] || (isEmpty(defaults[prop]) && !obj[prop].length)) { + delete obj[prop]; } + }); - if (this.get('script-props')) { - scr = scr.toString().trim(); - } else { - // Deprecated - // Need to convert script functions to strings - if (typeof scr == 'function') { - var scrStr = scr.toString().trim(); - scrStr = scrStr - .replace(/^function[\s\w]*\(\)\s?\{/, '') - .replace(/\}$/, ''); - scr = scrStr.trim(); - } + return obj; + } - var config = this.em.getConfig(); - var tagVarStart = escapeRegExp(config.tagVarStart || '{[ '); - var tagVarEnd = escapeRegExp(config.tagVarEnd || ' ]}'); - var reg = new RegExp(`${tagVarStart}([\\w\\d-]*)${tagVarEnd}`, 'g'); - scr = scr.replace(reg, (match, v) => { - // If at least one match is found I have to track this change for a - // better optimization inside JS generator - this.scriptUpdated(); - const result = this.attributes[v] || ''; - return isArray(result) || typeof result == 'object' - ? JSON.stringify(result) - : result; - }); - } + /** + * Return the component id + * @return {String} + */ + getId() { + let attrs = this.get('attributes') || {}; + return attrs.id || this.ccid || this.cid; + } + + /** + * Set new id on the component + * @param {String} id + * @return {this} + */ + setId(id, opts) { + const attrs = { ...this.get('attributes') }; + attrs.id = id; + this.set('attributes', attrs, opts); + return this; + } + + /** + * Get the DOM element of the component. + * This works only if the component is already rendered + * @param {Frame} frame Specific frame from which taking the element + * @return {HTMLElement} + */ + getEl(frame) { + const view = this.getView(frame); + return view && view.el; + } + + /** + * Get the View of the component. + * This works only if the component is already rendered + * @param {Frame} frame Get View of a specific frame + * @return {ComponentView} + */ + getView(frame) { + let { view, views } = this; + + if (frame) { + view = views.filter(view => view._getFrame() === frame.view)[0]; + } + + return view; + } + + getCurrentView() { + const frame = (this.em.get('currentFrame') || {}).model; + return this.getView(frame); + } + + __getScriptProps() { + const modelProps = this.props(); + const scrProps = this.get('script-props') || []; + return scrProps.reduce((acc, prop) => { + acc[prop] = modelProps[prop]; + return acc; + }, {}); + } + + /** + * Return script in string format, cleans 'function() {..' from scripts + * if it's a function + * @param {string|Function} script + * @return {string} + * @private + */ + getScriptString(script) { + let scr = script || this.get('script'); + + if (!scr) { return scr; - }, - - emitUpdate(property, ...args) { - const { em } = this; - const event = keyUpdate + (property ? `:${property}` : ''); - const item = property && this.get(property); - property && - this.updated( - property, - item, - property && this.previous(property), - ...args - ); - this.trigger(event, ...args); - em && em.trigger(event, this, ...args); - ['components', 'classes'].indexOf(property) >= 0 && - this.__propSelfToParent({ - component: this, - changed: { [property]: item }, - options: args[2] || args[1] || {} - }); - }, - - /** - * Execute callback function on itself and all inner components - * @param {Function} clb Callback function, the model is passed as an argument - * @return {this} - * @example - * component.onAll(component => { - * // do something with component - * }) - */ - onAll(clb) { - if (isFunction(clb)) { - clb(this); - this.components().forEach(model => model.onAll(clb)); + } + + if (this.get('script-props')) { + scr = scr.toString().trim(); + } else { + // Deprecated + // Need to convert script functions to strings + if (typeof scr == 'function') { + var scrStr = scr.toString().trim(); + scrStr = scrStr + .replace(/^function[\s\w]*\(\)\s?\{/, '') + .replace(/\}$/, ''); + scr = scrStr.trim(); } - return this; - }, - - /** - * Remove the component - * @return {this} - */ - remove(opts = {}) { - const { em } = this; - const coll = this.collection; - const remove = () => { - coll && coll.remove(this, opts); - opts.root && this.components(''); - }; - const rmOpts = { ...opts }; - [this, em].map(i => - i.trigger('component:remove:before', this, remove, rmOpts) + + var config = this.em.getConfig(); + var tagVarStart = escapeRegExp(config.tagVarStart || '{[ '); + var tagVarEnd = escapeRegExp(config.tagVarEnd || ' ]}'); + var reg = new RegExp(`${tagVarStart}([\\w\\d-]*)${tagVarEnd}`, 'g'); + scr = scr.replace(reg, (match, v) => { + // If at least one match is found I have to track this change for a + // better optimization inside JS generator + this.scriptUpdated(); + const result = this.attributes[v] || ''; + return isArray(result) || typeof result == 'object' + ? JSON.stringify(result) + : result; + }); + } + return scr; + } + + emitUpdate(property, ...args) { + const { em } = this; + const event = keyUpdate + (property ? `:${property}` : ''); + const item = property && this.get(property); + property && + this.updated( + property, + item, + property && this.previous(property), + ...args ); - !rmOpts.abort && remove(); - return this; - }, - - /** - * Move the component to another destination component - * @param {Component} component Destination component (so the current one will be appended as a child) - * @param {Object} opts Options for the append action - * @returns {this} - * @example - * // Move the selected component on top of the wrapper - * const dest = editor.getWrapper(); - * editor.getSelected().move(dest, { at: 0 }); - */ - move(component, opts = {}) { - this.remove({ temporary: 1 }); - component && component.append(this, opts); - return this; - }, - - /** - * Reset id of the component and any of its style rule - * @param {Object} [opts={}] Options - * @return {this} - * @private - */ - resetId(opts = {}) { - const { em } = this; - const oldId = this.getId(); - if (!oldId) return; - const newId = Component.createId(this); - this.setId(newId); - const rule = em && em.get('CssComposer').getIdRule(oldId); - const selector = rule && rule.get('selectors').at(0); - selector && selector.set('name', newId); - return this; - }, - - _getStyleRule({ id } = {}) { - const { em } = this; - const idS = id || this.getId(); - return em && em.get('CssComposer').getIdRule(idS); - }, - - _getStyleSelector(opts) { - const rule = this._getStyleRule(opts); - return rule && rule.get('selectors').at(0); - }, - - _idUpdated(m, v, opts = {}) { - if (opts.idUpdate) return; - - const { ccid } = this; - const { id } = this.get('attributes') || {}; - const idPrev = (this.previous('attributes') || {}).id || ccid; - const list = Component.getList(this); - - // If the ID already exists I need to rollback to the old one - if (list[id] || (!id && idPrev)) { - return this.setId(idPrev, { idUpdate: 1 }); - } + this.trigger(event, ...args); + em && em.trigger(event, this, ...args); + ['components', 'classes'].indexOf(property) >= 0 && + this.__propSelfToParent({ + component: this, + changed: { [property]: item }, + options: args[2] || args[1] || {} + }); + } + + /** + * Execute callback function on itself and all inner components + * @param {Function} clb Callback function, the model is passed as an argument + * @return {this} + * @example + * component.onAll(component => { + * // do something with component + * }) + */ + onAll(clb) { + if (isFunction(clb)) { + clb(this); + this.components().forEach(model => model.onAll(clb)); + } + return this; + } + + /** + * Remove the component + * @return {this} + */ + remove(opts = {}) { + const { em } = this; + const coll = this.collection; + const remove = () => { + coll && coll.remove(this, opts); + opts.root && this.components(''); + }; + const rmOpts = { ...opts }; + [this, em].map(i => + i.trigger('component:remove:before', this, remove, rmOpts) + ); + !rmOpts.abort && remove(); + return this; + } + + /** + * Move the component to another destination component + * @param {Component} component Destination component (so the current one will be appended as a child) + * @param {Object} opts Options for the append action + * @returns {this} + * @example + * // Move the selected component on top of the wrapper + * const dest = editor.getWrapper(); + * editor.getSelected().move(dest, { at: 0 }); + */ + move(component, opts = {}) { + this.remove({ temporary: 1 }); + component && component.append(this, opts); + return this; + } - // Remove the old ID reference and add the new one - delete list[idPrev]; - list[id] = this; - this.ccid = id; + /** + * Reset id of the component and any of its style rule + * @param {Object} [opts={}] Options + * @return {this} + * @private + */ + resetId(opts = {}) { + const { em } = this; + const oldId = this.getId(); + if (!oldId) return; + const newId = Component.createId(this); + this.setId(newId); + const rule = em && em.get('CssComposer').getIdRule(oldId); + const selector = rule && rule.get('selectors').at(0); + selector && selector.set('name', newId); + return this; + } + + _getStyleRule({ id } = {}) { + const { em } = this; + const idS = id || this.getId(); + return em && em.get('CssComposer').getIdRule(idS); + } - // Update the style selector name - const selector = this._getStyleSelector({ id: idPrev }); - selector && selector.set({ name: id, label: id }); + _getStyleSelector(opts) { + const rule = this._getStyleRule(opts); + return rule && rule.get('selectors').at(0); + } + + _idUpdated(m, v, opts = {}) { + if (opts.idUpdate) return; + + const { ccid } = this; + const { id } = this.get('attributes') || {}; + const idPrev = (this.previous('attributes') || {}).id || ccid; + const list = Component.getList(this); + + // If the ID already exists I need to rollback to the old one + if (list[id] || (!id && idPrev)) { + return this.setId(idPrev, { idUpdate: 1 }); } - }, - { - /** - * Detect if the passed element is a valid component. - * In case the element is valid an object abstracted - * from the element will be returned - * @param {HTMLElement} - * @return {Object} - * @private - */ - isComponent(el) { - return { tagName: el.tagName ? el.tagName.toLowerCase() : '' }; - }, - - ensureInList(model) { - const list = Component.getList(model); - const id = model.getId(); - const current = list[id]; - - if (!current) { - // Insert in list - list[id] = model; - } else if (current !== model) { - // Create new ID - const nextId = Component.getIncrementId(id, list); - model.setId(nextId); - list[nextId] = model; - } - model.components().forEach(i => Component.ensureInList(i)); - }, - - /** - * Relying simply on the number of components becomes a problem when you - * store and load them back, you might hit collisions with new components - * @param {Model} model - * @return {string} - * @private - */ - createId(model, opts = {}) { - const list = Component.getList(model); - const { idMap = {} } = opts; - let { id } = model.get('attributes'); - let nextId; - - if (id) { - nextId = Component.getIncrementId(id, list, opts); - model.setId(nextId); - if (id !== nextId) idMap[id] = nextId; - } else { - nextId = Component.getNewId(list); - } + // Remove the old ID reference and add the new one + delete list[idPrev]; + list[id] = this; + this.ccid = id; + + // Update the style selector name + const selector = this._getStyleSelector({ id: idPrev }); + selector && selector.set({ name: id, label: id }); + } + static ensureInList(model) { + const list = Component.getList(model); + const id = model.getId(); + const current = list[id]; + + if (!current) { + // Insert in list + list[id] = model; + } else if (current !== model) { + // Create new ID + const nextId = Component.getIncrementId(id, list); + model.setId(nextId); list[nextId] = model; - return nextId; - }, + } - getNewId(list) { - const count = Object.keys(list).length; - // Testing 1000000 components with `+ 2` returns 0 collisions - const ilen = count.toString().length + 2; - const uid = (Math.random() + 1.1).toString(36).slice(-ilen); - let newId = `i${uid}`; + model.components().forEach(i => Component.ensureInList(i)); + } - while (list[newId]) { - newId = Component.getNewId(list); - } + /** + * Relying simply on the number of components becomes a problem when you + * store and load them back, you might hit collisions with new components + * @param {Model} model + * @return {string} + * @private + */ + static createId(model, opts = {}) { + const list = Component.getList(model); + const { idMap = {} } = opts; + let { id } = model.get('attributes'); + let nextId; + + if (id) { + nextId = Component.getIncrementId(id, list, opts); + model.setId(nextId); + if (id !== nextId) idMap[id] = nextId; + } else { + nextId = Component.getNewId(list); + } - return newId; - }, + list[nextId] = model; + return nextId; + } - getIncrementId(id, list, opts = {}) { - const { keepIds = [] } = opts; - let counter = 1; - let newId = id; + static getNewId(list) { + const count = Object.keys(list).length; + // Testing 1000000 components with `+ 2` returns 0 collisions + const ilen = count.toString().length + 2; + const uid = (Math.random() + 1.1).toString(36).slice(-ilen); + let newId = `i${uid}`; - if (keepIds.indexOf(id) < 0) { - while (list[newId]) { - counter++; - newId = `${id}-${counter}`; - } + while (list[newId]) { + newId = Component.getNewId(list); + } + + return newId; + } + + static getIncrementId(id, list, opts = {}) { + const { keepIds = [] } = opts; + let counter = 1; + let newId = id; + + if (keepIds.indexOf(id) < 0) { + while (list[newId]) { + counter++; + newId = `${id}-${counter}`; } + } + + return newId; + } + + /** + * The list of components is taken from the Components module. + * Initially, the list, was set statically on the Component object but it was + * not ok, as it was shared between multiple editor instances + */ + static getList(model) { + const { opt = {} } = model; + const { domc, em } = opt; + const dm = domc || (em && em.get('DomComponents')); + return dm ? dm.componentsById : {}; + } - return newId; - }, - - /** - * The list of components is taken from the Components module. - * Initially, the list, was set statically on the Component object but it was - * not ok, as it was shared between multiple editor instances - */ - getList(model) { - const { opt = {} } = model; - const { domc, em } = opt; - const dm = domc || (em && em.get('DomComponents')); - return dm ? dm.componentsById : {}; - }, - - /** - * This method checks, for each parsed component and style object - * (are not Components/CSSRules yet), for duplicated id and fixes them - * This method is used in Components.js just after the parsing - */ - checkId(components, styles = [], list = {}, opts = {}) { - const comps = isArray(components) ? components : [components]; - const { keepIds = [] } = opts; - comps.forEach(comp => { - const { attributes = {}, components } = comp; - const { id } = attributes; - - // Check if we have collisions with current components - if (id && list[id] && keepIds.indexOf(id) < 0) { - const newId = Component.getIncrementId(id, list); - attributes.id = newId; - // Update passed styles - isArray(styles) && - styles.forEach(style => { - const { selectors } = style; - selectors.forEach((sel, idx) => { - if (sel === `#${id}`) selectors[idx] = `#${newId}`; - }); + /** + * This method checks, for each parsed component and style object + * (are not Components/CSSRules yet), for duplicated id and fixes them + * This method is used in Components.js just after the parsing + */ + static checkId(components, styles = [], list = {}, opts = {}) { + const comps = isArray(components) ? components : [components]; + const { keepIds = [] } = opts; + comps.forEach(comp => { + const { attributes = {}, components } = comp; + const { id } = attributes; + + // Check if we have collisions with current components + if (id && list[id] && keepIds.indexOf(id) < 0) { + const newId = Component.getIncrementId(id, list); + attributes.id = newId; + // Update passed styles + isArray(styles) && + styles.forEach(style => { + const { selectors } = style; + selectors.forEach((sel, idx) => { + if (sel === `#${id}`) selectors[idx] = `#${newId}`; }); - } + }); + } - components && Component.checkId(components, styles, list, opts); - }); - } + components && Component.checkId(components, styles, list, opts); + }); } -); +} -export default Component; +/** + * Detect if the passed element is a valid component. + * In case the element is valid an object abstracted + * from the element will be returned + * @param {HTMLElement} + * @return {Object} + * @private + */ +Component.isComponent = el => { + return { tagName: el.tagName ? el.tagName.toLowerCase() : '' }; +}; + +Component.prototype.defaults = { + tagName: 'div', + type: '', + name: '', + removable: true, + draggable: true, + droppable: true, + badgable: true, + stylable: true, + 'stylable-require': '', + 'style-signature': '', + unstylable: '', + highlightable: true, + copyable: true, + resizable: false, + editable: false, + layerable: true, + selectable: true, + hoverable: true, + void: false, + state: '', // Indicates if the component is in some CSS state like ':hover', ':active', etc. + status: '', // State, eg. 'selected' + content: '', + icon: '', + style: '', // Component related style + classes: '', // Array of classes + script: '', + 'script-props': '', + 'script-export': '', + attributes: '', + traits: ['id', 'title'], + propagate: '', + dmode: '', + toolbar: null, + [keySymbol]: 0, + [keySymbols]: 0, + [keySymbolOvrd]: 0, + _undo: true, + _undoexc: ['status', 'open'] +}; diff --git a/src/parser/model/ParserHtml.js b/src/parser/model/ParserHtml.js index 654031e73..4e090ad21 100644 --- a/src/parser/model/ParserHtml.js +++ b/src/parser/model/ParserHtml.js @@ -128,6 +128,7 @@ export default config => { // the first with a valid result will be that component for (let it = 0; it < ct.length; it++) { const compType = ct[it]; + console.log({ compType, model: compType.model }); obj = compType.model.isComponent(node); if (obj) {