UNPKG

grapesjs-clot

Version:

Free and Open Source Web Builder Framework

1,510 lines (1,364 loc) 63.6 kB
import { isUndefined, isFunction, isArray, isEmpty, isBoolean, has, isString, forEach, result, bindAll, keys, } from 'underscore'; import { shallowDiff, capitalize, isEmptyObj, isObject, toLowerCase } from 'utils/mixins'; import Styleable from 'domain_abstract/model/Styleable'; import { Model } from 'backbone'; import Components from './Components'; import Selector from 'selector_manager/model/Selector'; import Selectors from 'selector_manager/model/Selectors'; import Traits from 'trait_manager/model/Traits'; const escapeRegExp = str => { return str.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&'); }; const avoidInline = em => em && em.getConfig('avoidInlineStyle'); export const eventDrag = 'component:drag'; export const keySymbols = '__symbols'; export const keySymbol = '__symbol'; export const keySymbolOvrd = '__symbol_ovrd'; export const keyUpdate = 'component:update'; export const keyUpdateInside = `${keyUpdate}-inside`; /** * The Component object represents a single node of our template structure, so when you update its properties the changes are * immediately reflected on the canvas and in the code to export (indeed, when you ask to export the code we just go through all * the tree of nodes). * An example on how to update properties: * ```js * component.set({ * tagName: 'span', * attributes: { ... }, * removable: false, * }); * component.get('tagName'); * // -> 'span' * ``` * * [Component]: component.html * * @typedef Component * @property {String} [type=''] Component type, eg. `text`, `image`, `video`, etc. * @property {String} [tagName='div'] HTML tag of the component, eg. `span`. Default: `div` * @property {Object} [attributes={}] Key-value object of the component's attributes, eg. `{ title: 'Hello' }` Default: `{}` * @property {String} [name=''] Name of the component. Will be used, for example, in Layers and badges * @property {Boolean} [removable=true] When `true` the component is removable from the canvas, default: `true` * @property {Boolean|String|Function} [draggable=true] Indicates if it's possible to drag the component inside others. * You can also specify a query string to indentify elements, * eg. `'.some-class[title=Hello], [data-gjs-type=column]'` means you can drag the component only inside elements * containing `some-class` class and `Hello` title, and `column` components. In the case of a function, target and destination components are passed as arguments, return a Boolean to indicate if the drag is possible. Default: `true` * @property {Boolean|String|Function} [droppable=true] Indicates if it's possible to drop other components inside. You can use * a query string as with `draggable`. In the case of a function, target and destination components are passed as arguments, return a Boolean to indicate if the drop is possible. Default: `true` * @property {Boolean} [badgable=true] Set to false if you don't want to see the badge (with the name) over the component. Default: `true` * @property {Boolean|Array<String>} [stylable=true] True if it's possible to style the component. * You can also indicate an array of CSS properties which is possible to style, eg. `['color', 'width']`, all other properties * will be hidden from the style manager. Default: `true` * @property {Array<String>} [stylable-require=[]] Indicate an array of style properties to show up which has been marked as `toRequire`. Default: `[]` * @property {Array<String>} [unstylable=[]] Indicate an array of style properties which should be hidden from the style manager. Default: `[]` * @property {Boolean} [highlightable=true] It can be highlighted with 'dotted' borders if true. Default: `true` * @property {Boolean} [copyable=true] True if it's possible to clone the component. Default: `true` * @property {Boolean} [resizable=false] Indicates if it's possible to resize the component. It's also possible to pass an object as [options for the Resizer](https://github.com/artf/grapesjs/blob/master/src/utils/Resizer.js). Default: `false` * @property {Boolean} [editable=false] Allow to edit the content of the component (used on Text components). Default: `false` * @property {Boolean} [layerable=true] Set to `false` if you need to hide the component inside Layers. Default: `true` * @property {Boolean} [selectable=true] Allow component to be selected when clicked. Default: `true` * @property {Boolean} [hoverable=true] Shows a highlight outline when hovering on the element if `true`. Default: `true` * @property {Boolean} [void=false] This property is used by the HTML exporter as void elements don't have closing tags, eg. `<br/>`, `<hr/>`, etc. Default: `false` * @property {Object} [style={}] Component default style, eg. `{ width: '100px', height: '100px', 'background-color': 'red' }` * @property {String} [styles=''] Component related styles, eg. `.my-component-class { color: red }` * @property {String} [content=''] Content of the component (not escaped) which will be appended before children rendering. Default: `''` * @property {String} [icon=''] Component's icon, this string will be inserted before the name (in Layers and badge), eg. it can be an HTML string '<i class="fa fa-square-o"></i>'. Default: `''` * @property {String|Function} [script=''] Component's javascript. More about it [here](/modules/Components-js.html). Default: `''` * @property {String|Function} [script-export=''] You can specify javascript available only in export functions (eg. when you get the HTML). * If this property is defined it will overwrite the `script` one (in export functions). Default: `''` * @property {Array<Object|String>} [traits=''] Component's traits. More about it [here](/modules/Traits.html). Default: `['id', 'title']` * @property {Array<String>} [propagate=[]] Indicates an array of properties which will be inhereted by all NEW appended children. * For example if you create a component likes this: `{ removable: false, draggable: false, propagate: ['removable', 'draggable'] }` * and append some new component inside, the new added component will get the exact same properties indicated in the `propagate` array (and the `propagate` property itself). Default: `[]` * @property {Array<Object>} [toolbar=null] Set an array of items to show up inside the toolbar when the component is selected (move, clone, delete). * Eg. `toolbar: [ { attributes: {class: 'fa fa-arrows'}, command: 'tlb-move' }, ... ]`. * By default, when `toolbar` property is falsy the editor will add automatically commands `core:component-exit` (select parent component, added if there is one), `tlb-move` (added if `draggable`) , `tlb-clone` (added if `copyable`), `tlb-delete` (added if `removable`). * @property {Collection<Component>} [components=null] Children components. Default: `null` */ 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; const propagate = this.get('propagate'); propagate && this.set('propagate', isArray(propagate) ? propagate : [propagate]); if (parentAttr && parentAttr.propagate && !propagate) { const newAttr = {}; const toPropagate = parentAttr.propagate; toPropagate.forEach(prop => (newAttr[prop] = parent.get(prop))); newAttr.propagate = toPropagate; this.set({ ...newAttr, ...props }); } // 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, '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) { // Add component styles const cssc = em && em.get('CssComposer'); const { styles, type } = this.attributes; if (styles && cssc) { cssc.addCollection(styles, {}, { group: `cmp:${type}` }); } 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); um.add(this.getSelectors()); 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()); um.remove(this.getSelectors()); 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) : 0; } /** * 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<Component>} * @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; } /** * 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 => { 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<Component>} New added component/s * @example * component.replaceWith('<div>Some new content</div>'); * // -> 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<String>} 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(options = {}, optsAdd = {}) { const em = this.em; const prop = isString(options) ? options : ''; const opts = prop ? optsAdd : options; 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(prop); } } return Styleable.getStyle.call(this, prop); } /** * 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'))); classes.length && (attributes.class = classes.join(' ')); } // Add style if (!opts.noStyle) { const style = this.get('style'); if (isObject(style) && !isEmptyObj(style)) { attributes.style = this.styleToString({ inline: 1 }); } } // 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; } // Symbols should always have an id if (this.__getSymbol() || this.__getSymbols()) { addId = 1; } if (addId) { attributes.id = id; } } return attributes; } /** * Add classes * @param {Array<String>|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>|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>|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; } 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); 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); // 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 }); }); } } __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, }); // Here, before appending a new symbol, I have to ensure there are no previously // 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); } } 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'; this.off(event, this.initTraits); 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.on(event, this.initTraits); 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('<video></video><div></div>')[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] Component Definitions or HTML string * @param {Object} [opts={}] Options, same as in `Component.append()` * @returns {Collection|Array<[Component]>} * @example * // Set new collection * component.components('<span></span><div></div>'); * // 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) : []; } } /** * If exists, returns the child component at specific index. * @param {Number} index Index of the component to return * @returns {[Component]|null} * @example * // Return first child * component.getChildAt(0); * // Return second child * component.getChildAt(1); */ getChildAt(index) { return this.components().at(index || 0) || null; } /** * If exists, returns the last child component. * @returns {[Component]|null} * @example * const lastChild = component.getLastChild(); */ getLastChild() { const children = this.components(); return children.at(children.length - 1) || null; } /** * Remove all inner components * * @return {this} */ empty(opts = {}) { this.components().reset(null, opts); return this; } /** * Get the parent component, if exists * @return {Component|null} * @example * component.parent(); * // -> Component */ parent(opts = {}) { const coll = this.collection || (opts.prev && this.prevColl); return coll ? coll.parent : null; } /** * 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); } } __loadTraits(tr, opts = {}) { let traitsI = tr || this.get('traits'); if (!(traitsI instanceof Traits)) { traitsI = isFunction(traitsI) ? traitsI(this) : traitsI; const traits = new Traits([], this.opt); traits.setTarget(this); if (traitsI.length) { traitsI.forEach(tr => tr.attributes && delete tr.attributes.value); traits.add(traitsI); } this.set({ traits }, opts); } return this; } /** * Get traits. * @returns {Array<Trait>} * @example * const traits = component.getTraits(); * console.log(traits); * // [Trait, Trait, Trait, ...] */ getTraits() { this.__loadTraits(); return [...this.get('traits').models]; } /** * Replace current collection of traits with a new one. * @param {Array<Object>} traits Array of trait definitions * @returns {Array<Trait>} * @example * const traits = component.setTraits([{ type: 'checkbox', name: 'disabled'}, ...]); * console.log(traits); * // [Trait, ...] */ setTraits(traits) { const tr = isArray(traits) ? traits : [traits]; this.set({ traits: tr }); return this.getTraits(); } /** * Get the trait by id/name. * @param {String} id The `id` or `name` of the trait * @return {Trait|null} Trait getModelToStyle * @example * const traitTitle = component.getTrait('title'); * traitTitle && traitTitle.set('label', 'New label'); */ getTrait(id) { return ( this.getTraits().filter(trait => { return trait.get('id') === id || trait.get('name') === id; })[0] || null ); } /** * 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 trait = this.getTrait(id); trait && trait.set(props); this.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<String>} id The `id`/`name` of the trait (or an array) * @return {Array<Trait>} Array of removed traits * @example * component.removeTrait('title'); * component.removeTrait(['title', 'id']); */ removeTrait(id) { const ids = isArray(id) ? id : [id]; const toRemove = ids.map(id => this.getTrait(id)); const traits = this.get('traits'); const removed = toRemove.length ? traits.remove(toRemove) : []; this.em?.trigger('component:toggled'); return isArray(removed) ? removed : [removed]; } /** * Add new trait/s. * @param {String|Object|Array<String|Object>} trait Trait to add (or an array of traits) * @param {Options} opts Options for the add * @return {Array<Trait>} 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 = {}) { this.__loadTraits(); const added = this.get('traits').add(trait, opts); this.em?.trigger('component:toggled'); return isArray(added) ? added : [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; } 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); } 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.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, name } = this.attributes; const defName = type || tagName; const nameTag = !type && tagName; const i18nPfx = 'domComponents.names.'; const i18nName = name && em?.t(`${i18nPfx}${name}`); const i18nNameTag = nameTag && 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 || // Use local component `name` key (eg. `domComponents.names.myComponentName`) name || // Use component `name` key i18nNameTag || // Use local component `tagName` key (eg. `domComponents.names.div`) capitalize(nameTag) || // Use component `tagName` key i18nDefName || // Use local component `type` key (eg. `domComponents.names.image`) capitalize(defName) // Use component `type` key ); } /** * 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 ones or you can even pass a function to generate attributes dynamically. * @param {Boolean} [opts.withProps] Include component properties as `data-gjs-*` attributes. This allows you to have re-importable HTML. * @param {Boolean} [opts.altQuoteAttr] In case the attribute value contains a `"` char, instead of escaping it (`attr="value &quot;"`), the attribute will be quoted using single quotes (`attr='value "'`). * @return {String} HTML string * @example * // Simple HTML return * component.set({ tagName: 'span' }); * component.setAttributes({ title: 'Hello' }); * component.toHTML(); * // -> <span title="Hello"></span> * * // Custom attributes * component.toHTML({ attributes: { 'data-test': 'Hello' } }); * // -> <span data-test="Hello"></span> * * // Custom dynamic attributes * component.toHTML({ * attributes(component, attributes) { * if (component.get('tagName') == 'span') { * attributes.title = 'Custom attribute'; * } * return attributes; * }, * }); * // -> <span title="Custom attribute"></span> */ 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; } } if (opts.withProps) { const props = this.toJSON(); forEach(props, (value, key) => { const skipProps = ['classes', 'attributes', 'components']; if (key[0] !== '_' && skipProps.indexOf(key) < 0) { attributes[`data-gjs-${key}`] = isArray(value) || isObject(value) ? JSON.stringify(value) : value; } }); } for (let attr in attributes) { const val = attributes[attr]; if (!isUndefined(val) && val !== null) { if (isBoolean(val)) { val && attrs.push(attr); } else { let valueRes = ''; if (opts.altQuoteAttr && isString(val) && val.indexOf('"') >= 0) { valueRes = `'${val.replace(/'/g, '&apos;')}'`; } else { const value = isString(val) ? val.replace(/"/g, '&quot;') : val; valueRes = `"${value}"`; } attrs.push(`${attr}=${valueRes}`); } } } const attrString = attrs.length ? ` ${attrs.join(' ')}` : ''; const inner = model.getInnerHTML(opts); let code = `<${tag}${attrString}${sTag ? '/' : ''}>${inner}`; !sTag && (code += `</${tag}>`); return code; } /** * Get inner HTML of the component * @param {Object} [opts={}] Same options of `toHTML` * @returns {String} HTML string */ getInnerHTML(opts) { return this.__innerHTML(opts); } __innerHTML(opts = {}) { const cmps = this.components(); return !cmps.length ? this.get('content') : cmps.map(c => c.toHTML(opts)).join(''); } /** * 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 = 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(); } } if (this.em.getConfig('avoidDefaults')) { this.getChangedProps(obj); }