UNPKG

grapesjs_codeapps

Version:

Free and Open Source Web Builder Framework/SC Modification

1,020 lines (915 loc) 32 kB
import { isUndefined, isFunction, isObject, isArray, isEmpty, isBoolean, has, clone, isString, forEach, result, keys } from 'underscore'; import { shallowDiff, hasDnd } from 'utils/mixins'; import Styleable from 'domain_abstract/model/Styleable'; const Backbone = require('backbone'); const Components = require('./Components'); const Selector = require('selector_manager/model/Selector'); const Selectors = require('selector_manager/model/Selectors'); const Traits = require('trait_manager/model/Traits'); const componentList = {}; let componentIndex = 0; const escapeRegExp = str => { return str.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&'); }; const avoidInline = em => em && em.getConfig('avoidInlineStyle'); /** * 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' * ``` * * @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} [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. Default: `true` * @property {Boolean|String} [droppable=true] Indicates if it's possible to drop other components inside. You can use * a query string as with `draggable`. 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 {Array<String>} [style-signature=''] This option comes handy when you need to remove or export strictly component-specific rules. Be default, if this option is not empty, the editor will remove rules when there are no components, of that type, in the canvas. Eg. '['.navbar', '[navbar-']'. 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 {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 {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 like `move`, `delete`, etc. based on its properties. * @property {Collection<Component>} [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: '', attributes: '', traits: ['id', 'title'], propagate: '', toolbar: null }, initialize(props = {}, opt = {}) { 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); } opt.em = em; this.opt = opt; this.em = em; this.config = opt.config || {}; this.ccid = Component.createId(this); this.set('attributes', this.get('attributes') || {}); this.initClasses(); this.initTraits(); this.initComponents(); this.initToolbar(); this.listenTo(this, 'change:script', this.scriptUpdated); this.listenTo(this, 'change:tagName', this.tagUpdated); this.listenTo(this, 'change:attributes', this.attrUpdated); this.set('status', ''); // 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) ); }); this.init(); }, /** * Check component's type * @param {string} type Component type * @return {Boolean} * @example * component.is('image') * // -> false */ is(type) { return !!(this.get('type') == type); }, /** * 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 = []; this.view.$el.find(query).each((el, i, $els) => { const $el = $els.eq(i); const model = $el.data('model'); model && result.push(model); }); 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'); }, /** * Once the tag is updated I have to remove the node and replace it * @private */ tagUpdated() { const coll = this.collection; const at = coll.indexOf(this); coll.remove(this); coll.add(this, { at }); }, /** * 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() { this.setAttributes(this.get('attributes'), { silent: 1 }); }, /** * Update attributes of the component * @param {Object} attrs Key value attributes * @return {this} * @example * component.setAttributes({ id: 'test', 'data-key': 'value' }); */ setAttributes(attrs, opts = {}) { attrs = { ...attrs }; // 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; this.set('attributes', attrs, opts); const attrPrev = { ...this.previous('attributes') }; const diff = shallowDiff(attrPrev, attrs); keys(diff).forEach(pr => this.trigger(`change:attributes:${pr}`)); return this; }, /** * Add attributes to the component * @param {Object} attrs Key value attributes * @return {this} * @example * component.addAttributes({ 'data-key': 'value' }); */ addAttributes(attrs) { const newAttrs = { ...this.getAttributes(), ...attrs }; this.setAttributes(newAttrs); return this; }, /** * Get the style of the component * @return {Object} */ getStyle() { const em = this.em; if (em && em.getConfig('avoidInlineStyle')) { const state = this.get('state'); const cc = em.get('CssComposer'); const rule = cc.getIdRule(this.getId(), { state }); this.rule = rule; if (rule) { return rule.getStyle(); } } 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) { prop = isString(prop) ? this.parseStyle(prop) : prop; prop = { ...prop, ...this.get('style') }; const state = this.get('state'); const cc = em.get('CssComposer'); const propOrig = this.getStyle(); const classes = this.getClasses(); //console.log('id', this.getId()); if (classes.length) { this.rule = cc.setClassRule(classes[0], prop, { ...opts, state }); } else { 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() { const { em } = this; const classes = []; const attributes = { ...this.get('attributes') }; const sm = em && em.get('SelectorManager'); const id = this.getId(); // Add classes 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 hasStyle; // If we don't rely on inline styling we have to check // for the ID selector if (avoidInline(em)) { hasStyle = sm && sm.get(id, sm.Selector.TYPE_ID); } else if (!isEmpty(this.getStyle())) { hasStyle = 1; } if (hasStyle) { attributes.id = this.getId(); } } 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(' ') : []; }, initClasses() { const event = 'change:classes'; const toListen = [this, event, this.initClasses]; this.stopListening(...toListen); const classes = this.normalizeClasses(this.get('classes') || []); const selectors = new Selectors([]); this.set('classes', selectors); selectors.add(classes); 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 && comps.add(components); this.listenTo(...toListen); return this; }, initTraits() { 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); return this; }, init() {}, /** * Add new component children * @param {Component|String} components Component to add * @param {Object} [opts={}] Options, same as in `model.add()`(from backbone) * @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(components, opts = {}) { const result = this.components().add(components, 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 * @return {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) { const coll = this.get('components'); if (isUndefined(components)) { return coll; } else { coll.reset(); return components && this.append(components); } }, /** * Get the parent component, if exists * @return {Component} * @example * component.parent(); * // -> Component */ parent() { const coll = this.collection; 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: 'select-parent' }); } 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-drag' }); } 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); } }, /** * Load traits * @param {Array} traits * @private */ loadTraits(traits, opts = {}) { traits = traits || this.get('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.set('traits', trt, opts); } 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]; }, /** * Normalize input classes from array to array of objects * @param {Array} arr * @return {Array} * @private */ normalizeClasses(arr) { var res = []; const em = this.em; if (!em) return; var clm = em.get('SelectorManager'); if (!clm) return; arr.forEach(val => { var name = ''; if (typeof val === 'string') name = val; else name = val.name; var model = clm.add(name); res.push(model); }); return res; }, /** * Override original clone method * @private */ clone() { const em = this.em; const style = this.getStyle(); const attr = { ...this.attributes }; const opts = { ...this.opt }; attr.attributes = { ...attr.attributes }; delete attr.attributes.id; attr.components = []; attr.classes = []; attr.traits = []; this.get('components').each((md, i) => { attr.components[i] = md.clone(); }); this.get('traits').each((md, i) => { attr.traits[i] = md.clone(); }); if (attr.type !== 'image') { this.get('classes').each((md, i) => { attr.classes[i] = md.get('name'); }); } attr.status = ''; attr.view = ''; opts.collection = null; if (em && em.getConfig('avoidInlineStyle') && !isEmpty(style)) { attr.style = style; } return new this.constructor(attr, opts); }, /** * Get the name of the component * @return {String} * */ getName() { let customName = this.get('name') || this.get('custom-name'); let tag = this.get('tagName'); tag = tag == 'div' ? 'box' : tag; let name = this.get('type') || tag; name = name.charAt(0).toUpperCase() + name.slice(1); return customName || 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 {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(); * // -> <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 classes = []; const tag = model.get('tagName'); const sTag = model.get('void'); const customAttr = opts.attributes; let attributes = this.getAttrToHTML(); // Get custom attributes if requested if (customAttr) { if (isFunction(customAttr)) { attributes = customAttr(model, attributes) || {}; } else if (isObject(customAttr)) { attributes = customAttr; } } for (let attr in attributes) { const val = attributes[attr]; const value = isString(val) ? val.replace(/"/g, '&quot;') : val; if (!isUndefined(value)) { if (isBoolean(value)) { value && attrs.push(attr); } else { attrs.push(`${attr}="${value}"`); } } } let attrString = attrs.length ? ` ${attrs.join(' ')}` : ''; let code = `<${tag}${attrString}${sTag ? '/' : ''}>${model.get( 'content' )}`; model.get('components').each(comp => (code += comp.toHTML(opts))); !sTag && (code += `</${tag}>`); 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(...args) { const obj = Backbone.Model.prototype.toJSON.apply(this, args); obj.attributes = this.getAttributes(); delete obj.attributes.class; delete obj.toolbar; if (this.em.getConfig('avoidDefaults')) { const defaults = result(this, 'defaults'); forEach(defaults, (value, key) => { if (['type', 'content'].indexOf(key) === -1 && obj[key] === value) { delete obj[key]; } }); if (isEmpty(obj.type)) { delete obj.type; } forEach(['attributes', 'style'], prop => { if (isEmpty(defaults[prop]) && isEmpty(obj[prop])) { delete obj[prop]; } }); forEach(['classes', 'components'], prop => { if (isEmpty(defaults[prop]) && !obj[prop].length) { delete obj[prop]; } }); } 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) { const attrs = { ...this.get('attributes') }; attrs.id = id; this.set('attributes', attrs); return this; }, /** * Get the DOM element of the component. This works only of the * component is already rendered * @return {HTMLElement} */ getEl() { return this.view && this.view.el; }, /** * Return script in string format, cleans 'function() {..' from scripts * if it's a function * @param {string|Function} script * @return {string} * @private */ getScriptString(script) { var scr = script || this.get('script'); if (!scr) { return scr; } // 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(); } 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(); return this.attributes[v] || ''; }); return scr; }, emitUpdate(property, ...args) { const em = this.em; const event = 'component:update' + (property ? `:${property}` : ''); em && em.trigger(event, this, ...args); }, /** * 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() { return this.collection.remove(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; } }, { /** * 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() : '' }; }, /** * 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) { componentIndex++; // Testing 1000000 components with `+ 2` returns 0 collisions const ilen = componentIndex.toString().length + 2; const uid = (Math.random() + 1.1).toString(36).slice(-ilen); const nextId = 'i' + uid; componentList[nextId] = model; return nextId; }, getList() { return componentList; } } ); module.exports = Component;