UNPKG

grapesjs-clot

Version:

Free and Open Source Web Builder Framework

798 lines (713 loc) 27.4 kB
/** * With Style Manager you build categories (called sectors) of CSS properties which could be used to customize the style of components. * You can customize the initial state of the module from the editor initialization, by passing the following [Configuration Object](https://github.com/artf/grapesjs/blob/master/src/style_manager/config/config.js) * ```js * const editor = grapesjs.init({ * styleManager: { * // options * } * }) * ``` * * Once the editor is instantiated you can use its API and listen to its events. Before using these methods, you should get the module from the instance. * * ```js * // Listen to events * editor.on('style:sector:add', (sector) => { ... }); * * // Use the API * const styleManager = editor.StyleManager; * styleManager.addSector(...); * ``` * ## Available Events * * `style:sector:add` - Sector added. The [Sector] is passed as an argument to the callback. * * `style:sector:remove` - Sector removed. The [Sector] is passed as an argument to the callback. * * `style:sector:update` - Sector updated. The [Sector] and the object containing changes are passed as arguments to the callback. * * `style:property:add` - Property added. The [Property] is passed as an argument to the callback. * * `style:property:remove` - Property removed. The [Property] is passed as an argument to the callback. * * `style:property:update` - Property updated. The [Property] and the object containing changes are passed as arguments to the callback. * * `style:target` - Target selection changed. The target (or `null` in case the target is deselected) is passed as an argument to the callback. * <!-- * * `styleManager:update:target` - The target (Component or CSSRule) is changed * * `styleManager:change` - Triggered on style property change from new selected component, the view of the property is passed as an argument to the callback * * `styleManager:change:{propertyName}` - As above but for a specific style property * --> * * ## Methods * * [getConfig](#getconfig) * * [addSector](#addsector) * * [getSector](#getsector) * * [getSectors](#getsectors) * * [removeSector](#removesector) * * [addProperty](#addproperty) * * [getProperty](#getproperty) * * [getProperties](#getproperties) * * [removeProperty](#removeproperty) * * [select](#select) * * [getSelected](#getselected) * * [getSelectedAll](#getselectedall) * * [getSelectedParents](#getselectedparents) * * [addStyleTargets](#addstyletargets) * * [getBuiltIn](#getbuiltin) * * [getBuiltInAll](#getbuiltinall) * * [addBuiltIn](#addbuiltin) * * [addType](#addtype) * * [getType](#gettype) * * [getTypes](#gettypes) * * [Sector]: sector.html * [CssRule]: css_rule.html * [Component]: component.html * [Property]: property.html * * @module StyleManager */ import { isElement, isUndefined, isArray, isString, debounce } from 'underscore'; import { isComponent } from 'utils/mixins'; import Module from 'common/module'; import { Model } from 'common'; import defaults from './config/config'; import Sector from './model/Sector'; import Sectors from './model/Sectors'; import Properties from './model/Properties'; import PropertyFactory from './model/PropertyFactory'; import SectorsView from './view/SectorsView'; import { parse, stringify } from 'flatted'; import { myEditor } from '..'; import { ClientState, ClientStateEnum, setState, ApplyingLocalOp, ApplyingBufferedLocalOp } from '../utils/WebSocket'; export const evAll = 'style'; export const evPfx = `${evAll}:`; export const evSector = `${evPfx}sector`; export const evSectorAdd = `${evSector}:add`; export const evSectorRemove = `${evSector}:remove`; export const evSectorUpdate = `${evSector}:update`; export const evProp = `${evPfx}property`; export const evPropAdd = `${evProp}:add`; export const evPropRemove = `${evProp}:remove`; export const evPropUp = `${evProp}:update`; export const evLayerSelect = `${evPfx}layer:select`; export const evTarget = `${evPfx}target`; export const evCustom = `${evPfx}custom`; const propDef = value => value || value === 0; export default () => { let properties; var sectors, SectView; return { ...Module, Sector, events: { all: evAll, sectorAdd: evSectorAdd, sectorRemove: evSectorRemove, sectorUpdate: evSectorUpdate, propertyAdd: evPropAdd, propertyRemove: evPropRemove, propertyUpdate: evPropUp, layerSelect: evLayerSelect, target: evTarget, custom: evCustom, }, name: 'StyleManager', /** * Get configuration object * @name getConfig * @function * @return {Object} */ /** * Initialize module. Automatically called with a new instance of the editor * @param {Object} config Configurations * @private */ init(config = {}) { this.__initConfig(defaults, config); const c = this.config; const { em } = c; const ppfx = c.pStylePrefix; if (ppfx) c.stylePrefix = ppfx + c.stylePrefix; this.builtIn = new PropertyFactory(); properties = new Properties([], { em, module: this }); sectors = new Sectors([], { ...c, module: this }); const model = new Model({ targets: [] }); this.model = model; this.__listenAdd(sectors, evSectorAdd); this.__listenRemove(sectors, evSectorRemove); this.__listenUpdate(sectors, evSectorUpdate); // Triggers for the selection refresh and properties const ev = 'component:toggled component:update:classes change:state change:device frame:resized selector:type'; const upAll = debounce(() => this.__upSel()); model.listenTo(em, ev, upAll); // Triggers only for properties (avoid selection refresh) const upProps = debounce(() => { this.__upProps(); this.__trgCustom(); }); model.listenTo(em, 'styleable:change undo redo', upProps); // Triggers only custom event const trgCustom = debounce(() => this.__trgCustom()); model.listenTo(em, `${evLayerSelect} ${evTarget}`, trgCustom); // Other listeners model.on('change:lastTarget', () => em.trigger(evTarget, this.getSelected())); return this; }, __upSel() { this.select(this.em.getSelectedAll()); }, __trgCustom(opts = {}) { this.__ctn = this.__ctn || opts.container; this.em.trigger(this.events.custom, { container: this.__ctn }); }, __trgEv(event, ...data) { this.em.trigger(event, ...data); }, onLoad() { // Use silent as sectors' view will be created and rendered on StyleManager.render sectors.add(this.config.sectors, { silent: true }); }, postRender() { this.__appendTo(); }, /** * Add new sector. If the sector with the same id already exists, that one will be returned. * @param {String} id Sector id * @param {Object} sector Sector definition. Check the [available properties](sector.html#properties) * @param {Object} [options={}] Options * @param {Number} [options.at] Position index (by default, will be appended at the end). * @returns {[Sector]} Added Sector * @example * const sector = styleManager.addSector('mySector',{ * name: 'My sector', * open: true, * properties: [{ name: 'My property'}] * }, { at: 0 }); * // With `at: 0` we place the new sector at the beginning of the list * */ addSector(id, sector, options = {}) { let result = this.getSector(id); if (!result) { sector.id = id; result = sectors.add(sector, options); } return result; }, /** * Get sector by id. * @param {String} id Sector id * @returns {[Sector]|null} * @example * const sector = styleManager.getSector('mySector'); * */ getSector(id, opts = {}) { const res = sectors.where({ id })[0]; !res && opts.warn && this._logNoSector(id); return res || null; }, /** * Get all sectors. * @param {Object} [opts={}] Options * @param {Boolean} [opts.visible] Returns only visible sectors * @returns {Array<[Sector]>} * @example * const sectors = styleManager.getSectors(); * */ getSectors(opts = {}) { const res = sectors && sectors.models ? (opts.array ? [...sectors.models] : sectors) : []; return opts.visible ? res.filter(s => s.isVisible()) : res; }, /** * Remove sector by id. * @param {String} id Sector id * @returns {[Sector]} Removed sector * @example * const removed = styleManager.removeSector('mySector'); */ removeSector(id) { return this.getSectors().remove(this.getSector(id, { warn: 1 })); }, /** * Add new property to the sector. * @param {String} sectorId Sector id. * @param {Object} property Property definition. Check the [base available properties](property.html#properties) + others based on the `type` of your property. * @param {Object} [opts={}] Options * @param {Number} [opts.at] Position index (by default, will be appended at the end). * @returns {[Property]|null} Added property or `null` in case the sector doesn't exist. * @example * const property = styleManager.addProperty('mySector', { * label: 'Minimum height', * property: 'min-height', * type: 'select', * default: '100px', * options: [ * { id: '100px', label: '100' }, * { id: '200px', label: '200' }, * ], * }, { at: 0 }); */ addProperty(sectorId, property, opts = {}) { const sector = this.getSector(sectorId, { warn: 1 }); let prop = null; if (sector) prop = sector.addProperty(property, opts); return prop; }, /** * Get the property. * @param {String} sectorId Sector id. * @param {String} id Property id. * @returns {[Property]|null} * @example * const property = styleManager.getProperty('mySector', 'min-height'); */ getProperty(sectorId, id) { const sector = this.getSector(sectorId, { warn: 1 }); let prop; if (sector) { prop = sector.get('properties').filter(prop => prop.get('property') === id || prop.get('id') === id)[0]; } return prop || null; }, /** * Get all properties of the sector. * @param {String} sectorId Sector id. * @returns {Collection<[Property]>|null} Collection of properties * @example * const properties = styleManager.getProperties('mySector'); */ getProperties(sectorId) { let props = null; const sector = this.getSector(sectorId, { warn: 1 }); if (sector) props = sector.get('properties'); return props; }, /** * Remove the property. * @param {String} sectorId Sector id. * @param {String} id Property id. * @returns {[Property]|null} Removed property * @example * const property = styleManager.removeProperty('mySector', 'min-height'); */ removeProperty(sectorId, id) { const props = this.getProperties(sectorId); return props ? props.remove(this.getProperty(sectorId, id)) : null; }, /** * Select new target. * The target could be a Component, CSSRule, or a CSS selector string. * @param {[Component]|[CSSRule]|String} target * @returns {Array<[Component]|[CSSRule]>} Array containing selected Components or CSSRules * @example * // Select the first button in the current page * const wrapperCmp = editor.Pages.getSelected().getMainComponent(); * const btnCmp = wrapperCmp.find('button')[0]; * btnCmp && styleManager.select(btnCmp); * * // Set as a target the CSS selector * styleManager.select('.btn > span'); */ select(target, opts = {}) { const { em } = this; const trgs = isArray(target) ? target : [target]; const { stylable } = opts; const cssc = em.get('CssComposer'); let targets = []; trgs.filter(Boolean).forEach(target => { let model = target; if (isString(target)) { const rule = cssc.getRule(target) || cssc.setRule(target); !isUndefined(stylable) && rule.set({ stylable }); model = rule; } targets.push(model); }); const component = opts.component || targets.filter(t => isComponent(t)).reverse()[0]; targets = targets.map(t => this.getModelToStyle(t)); const state = em.getState(); const lastTarget = targets.slice().reverse()[0]; const lastTargetParents = this.getParentRules(lastTarget, { state, component }); let stateTarget = this.__getStateTarget(); // Handle the creation and update of the state rule, if enabled. em.skip(() => { if (state && lastTarget?.getState?.()) { const style = lastTarget.getStyle(); if (!stateTarget) { stateTarget = cssc.getAll().add({ selectors: 'gjs-selected', style, important: true }); } else { stateTarget.setStyle(style); } } else if (stateTarget) { cssc.remove(stateTarget); stateTarget = null; } }); this.model.set({ targets, lastTarget, lastTargetParents, stateTarget, component }); this.__upProps(opts); return targets; }, /** * Get the last selected target. * By default, the Style Manager shows styles of the last selected target. * @returns {[Component]|[CSSRule]|null} */ getSelected() { return this.model.get('lastTarget') || null; }, /** * Get the array of selected targets. * @returns {Array<[Component]|[CSSRule]>} */ getSelectedAll() { return this.model.get('targets'); }, /** * Get parent rules of the last selected target. * @returns {Array<[CSSRule]>} */ getSelectedParents() { return this.model.get('lastTargetParents') || []; }, __getStateTarget() { return this.model.get('stateTarget') || null; }, // be called when applying remote op applyUpdateStyle(opts) { let target = this.em.get('DomComponents').getById(opts.id); target.addStyle(opts.style, opts.opts); }, /** * Update selected targets with a custom style. * @param {Object} style Style object * @param {Object} [opts={}] Options * @example * styleManager.addStyleTargets({ color: 'red' }); */ addStyleTargets(style, opts) { //console.log('style_manager/index.js => addStyleTargets start'); //console.log('this.getSelectedAll(): ' + JSON.stringify(this.getSelectedAll())); //console.log('this.getSelected(): ' + JSON.stringify(this.getSelected())); this.getSelectedAll().map(t => t.addStyle(style, opts)); // Update state rule const target = this.em.getSelected(); //const targetState = this.__getStateTarget(); let id = target.getId(); target.setStyle(target.getStyle(), opts); // target && targetState?.setStyle(target.getStyle(), opts); //console.log('style_manager/index.js => addStyleTargets end'); let opOpts = { id: id, style: style, opts: opts, }; let op = { action: 'update-style', opts: opOpts, }; if (ClientState == ClientStateEnum.Synced) { // set state to ApplyingLocalOp setState(ClientStateEnum.ApplyingLocalOp); // increase localTS and set localOp ApplyingLocalOp(op); } else if (ClientState == ClientStateEnum.AwaitingACK || ClientState == ClientStateEnum.AwaitingWithBuffer) { // set state to ApplyingBufferedLocalOp setState(ClientStateEnum.ApplyingBufferedLocalOp); // push the op to buffer ApplyingBufferedLocalOp(op); } }, /** * Return built-in property definition * @param {String} prop Property name. * @returns {Object|null} Property definition. * @example * const widthPropDefinition = styleManager.getBuiltIn('width'); */ getBuiltIn(prop) { return this.builtIn.get(prop); }, /** * Get all the available built-in property definitions. * @returns {Object} */ getBuiltInAll() { return this.builtIn.props; }, /** * Add built-in property definition. * If the property exists already, it will extend it. * @param {String} prop Property name. * @param {Object} definition Property definition. * @returns {Object} Added property definition. * @example * const sector = styleManager.addBuiltIn('new-property', { * type: 'select', * default: 'value1', * options: [{ id: 'value1', label: 'Some label' }, ...], * }) */ addBuiltIn(prop, definition) { return this.builtIn.add(prop, definition); }, /** * Get what to style inside Style Manager. If you select the component * without classes the entity is the Component itself and all changes will * go inside its 'style' property. Otherwise, if the selected component has * one or more classes, the function will return the corresponding CSS Rule * @param {Model} model * @return {Model} * @private */ getModelToStyle(model, options = {}) { const { em } = this; const { skipAdd } = options; if (em && model?.toHTML) { const config = em.getConfig(); const um = em.get('UndoManager'); const cssC = em.get('CssComposer'); const sm = em.get('SelectorManager'); const smConf = sm ? sm.getConfig() : {}; const state = !config.devicePreviewMode ? em.get('state') : ''; const classes = model.get('classes'); const valid = classes.getStyleable(); const hasClasses = valid.length; const useClasses = !smConf.componentFirst || options.useClasses; const addOpts = { noCount: 1 }; const opts = { state, addOpts }; let rule; // I stop undo manager here as after adding the CSSRule (generally after // selecting the component) and calling undo() it will remove the rule from // the collection, therefore updating it in style manager will not affect it // #268 um.stop(); if (hasClasses && useClasses) { const deviceW = em.getCurrentMedia(); rule = cssC.get(valid, state, deviceW); if (!rule && !skipAdd) { rule = cssC.add(valid, state, deviceW, {}, addOpts); } } else if (config.avoidInlineStyle) { const id = model.getId(); rule = cssC.getIdRule(id, opts); !rule && !skipAdd && (rule = cssC.setIdRule(id, {}, opts)); if (model.is('wrapper')) rule.set('wrapper', 1, addOpts); } rule && (model = rule); um.start(); } return model; }, getParentRules(target, { state, component } = {}) { const { em } = this; let result = []; if (em && target) { const sel = component; const cssC = em.get('CssComposer'); const cssGen = em.get('CodeManager').getGenerator('css'); const cmp = target.toHTML ? target : target.getComponent(); const optsSel = { combination: true, array: true }; let cmpRules = []; let otherRules = []; let rules = []; // Componente related rule if (cmp) { cmpRules = cssC.getRules(`#${cmp.getId()}`); otherRules = sel ? cssC.getRules(sel.getSelectors().getFullName(optsSel)) : []; rules = otherRules.concat(cmpRules); } else { cmpRules = sel ? cssC.getRules(`#${sel.getId()}`) : []; otherRules = cssC.getRules(target.getSelectors().getFullName(optsSel)); rules = cmpRules.concat(otherRules); } const all = rules .filter(rule => (!isUndefined(state) ? rule.get('state') === state : 1)) .sort(cssGen.sortRules) .reverse(); // Slice removes rules not related to the current device result = all.slice(all.indexOf(target) + 1); } return result; }, /** * Add new property type * @param {string} id Type ID * @param {Object} definition Definition of the type. * @example * styleManager.addType('my-custom-prop', { * // Create UI * create({ props, change }) { * const el = document.createElement('div'); * el.innerHTML = '<input type="range" class="my-input" min="10" max="50"/>'; * const inputEl = el.querySelector('.my-input'); * inputEl.addEventListener('change', event => change({ event })); * inputEl.addEventListener('input', event => change({ event, partial: true })); * return el; * }, * // Propagate UI changes up to the targets * emit({ props, updateStyle }, { event, partial }) { * const { value } = event.target; * updateStyle(`${value}px`, { partial }); * }, * // Update UI (eg. when the target is changed) * update({ value, el }) { * el.querySelector('.my-input').value = parseInt(value, 10); * }, * // Clean the memory from side effects if necessary (eg. global event listeners, etc.) * destroy() {} *}) */ addType(id, definition) { properties.addType(id, definition); }, /** * Get type * @param {string} id Type ID * @return {Object} Type definition */ getType(id) { return properties.getType(id); }, /** * Get all types * @return {Array} */ getTypes() { return properties.getTypes(); }, /** * Create new UI property from type (Experimental) * @param {string} id Type ID * @param {Object} [options={}] Options * @param {Object} [options.model={}] Custom model object * @param {Object} [options.view={}] Custom view object * @return {PropertyView} * @private * @example * const propView = styleManager.createType('number', { * model: {units: ['px', 'rem']} * }); * propView.render(); * propView.model.on('change:value', ...); * someContainer.appendChild(propView.el); */ createType(id, { model = {}, view = {} } = {}) { const { config } = this; const type = this.getType(id); if (type) { return new type.view({ model: new type.model(model), config, ...view, }); } }, /** * Render sectors and properties * @return {HTMLElement} * @private * */ render() { const { config, em } = this; const el = SectView && SectView.el; SectView = new SectorsView({ el, em, config, collection: sectors, module: this, }); return SectView.render().el; }, _logNoSector(sectorId) { const { em } = this; em && em.logWarning(`'${sectorId}' sector not found`); }, __upProps(opts) { const lastTarget = this.getSelected(); if (!lastTarget) return; const component = this.model.get('component'); const lastTargetParents = this.getSelectedParents(); const style = lastTarget.getStyle(); const parentStyles = lastTargetParents.map(p => ({ target: p, style: p.getStyle(), })); sectors.map(sector => { sector.getProperties().map(prop => { this.__upProp(prop, style, parentStyles, opts); }); }); // Update sectors/properties visibility sectors.forEach(sector => { const props = sector.getProperties(); props.forEach(prop => { const isVisible = prop.__checkVisibility({ target: lastTarget, component, sectors }); prop.set('visible', isVisible); }); const sectorVisible = props.some(p => p.isVisible()); sector.set('visible', sectorVisible); }); }, __upProp(prop, style, parentStyles, opts) { const name = prop.getName(); const value = style[name]; const hasVal = propDef(value); const isStack = prop.getType() === 'stack'; const isComposite = prop.getType() === 'composite'; const opt = { ...opts, __up: true }; const canUpdate = !isComposite && !isStack; let newLayers = isStack ? prop.__getLayersFromStyle(style) : []; let newProps = isComposite ? prop.__getPropsFromStyle(style) : {}; let newValue = hasVal ? value : null; let parentTarget = null; if ((isStack && newLayers === null) || (isComposite && newProps === null)) { const method = isStack ? '__getLayersFromStyle' : '__getPropsFromStyle'; const parentItem = parentStyles.filter(p => prop[method](p.style) !== null)[0]; if (parentItem) { newValue = parentItem.style[name]; parentTarget = parentItem.target; const val = prop[method](parentItem.style); if (isStack) { newLayers = val; } else { newProps = val; } } } else if (!hasVal) { newValue = null; const parentItem = parentStyles.filter(p => propDef(p.style[name]))[0]; if (parentItem) { newValue = parentItem.style[name]; parentTarget = parentItem.target; } } prop.__setParentTarget(parentTarget); canUpdate && prop.__getFullValue() !== newValue && prop.upValue(newValue, opt); isStack && prop.__setLayers(newLayers || []); if (isComposite) { const props = prop.getProperties(); // Detached has to be treathed as separate properties if (prop.isDetached()) { const newStyle = prop.__getPropsFromStyle(style, { byName: true }) || {}; const newParentStyles = parentStyles.map(p => ({ ...p, style: prop.__getPropsFromStyle(p.style, { byName: true }) || {}, })); props.map(pr => this.__upProp(pr, newStyle, newParentStyles, opts)); } else { prop.__setProperties(newProps || {}, opt); prop.getProperties().map(pr => pr.__setParentTarget(parentTarget)); } } }, destroy() { [properties, sectors].forEach(coll => { coll.reset(); coll.stopListening(); }); SectView && SectView.remove(); [properties, sectors, SectView].forEach(i => (i = {})); this.em = {}; this.config = {}; this.builtIn = {}; this.model = {}; }, }; };