UNPKG

@print-one/grapesjs

Version:

Free and Open Source Web Builder Framework

527 lines (480 loc) 17.1 kB
/** * This module manages CSS rules in the canvas. * You can customize the initial state of the module from the editor initialization, by passing the following [Configuration Object](https://github.com/GrapesJS/grapesjs/blob/master/src/css_composer/config/config.ts) * ```js * const editor = grapesjs.init({ * cssComposer: { * // options * } * }) * ``` * * Once the editor is instantiated you can use its API. Before using these methods you should get the module from the instance * * ```js * const css = editor.Css; * ``` * * * [addRules](#addrules) * * [setRule](#setrule) * * [getRule](#getrule) * * [getRules](#getrules) * * [remove](#remove) * * [clear](#clear) * * [CssRule]: css_rule.html * * @module Css */ import { isArray, isString, isUndefined } from 'underscore'; import { isObject } from '../utils/mixins'; import Selectors from '../selector_manager/model/Selectors'; import Selector from '../selector_manager/model/Selector'; import defaults, { CssComposerConfig } from './config/config'; import CssRule, { CssRuleJSON, CssRuleProperties } from './model/CssRule'; import CssRules from './model/CssRules'; import CssRulesView from './view/CssRulesView'; import { ItemManagerModule } from '../abstract/Module'; import EditorModel from '../editor/model/Editor'; import Component from '../dom_components/model/Component'; import { ObjectAny } from '../common'; /** @private */ interface RuleOptions { /** * At-rule type, eg. `media` */ atRuleType?: string; /** * At-rule parameters, eg. `(min-width: 500px)` */ atRuleParams?: string; } /** @private */ interface SetRuleOptions extends RuleOptions { /** * If the rule exists already, merge passed styles instead of replacing them. */ addStyles?: boolean; } /** @private */ export interface GetSetRuleOptions { state?: string; mediaText?: string; addOpts?: ObjectAny; current?: boolean; } type CssRuleStyle = Required<CssRuleProperties>['style']; export default class CssComposer extends ItemManagerModule<CssComposerConfig & { pStylePrefix?: string }> { rules: CssRules; rulesView?: CssRulesView; Selectors = Selectors; storageKey = 'styles'; /** * Initializes module. Automatically called with a new instance of the editor * @param {Object} config Configurations * @private */ constructor(em: EditorModel) { super(em, 'CssComposer', null, {}, defaults); const { config } = this; const ppfx = config.pStylePrefix; if (ppfx) config.stylePrefix = ppfx + config.stylePrefix; // @ts-ignore config.rules = this.em.config.style || config.rules || ''; this.rules = new CssRules([], config); } /** * On load callback * @private */ onLoad() { this.rules.add(this.config.rules, { silent: true }); } /** * Do stuff after load * @param {Editor} em * @private */ postLoad() { const um = this.em?.get('UndoManager'); um && um.add(this.getAll()); } store() { return this.getProjectData(); } load(data: any) { return this.loadProjectData(data, { // @ts-ignore Fix add() first in CssRules all: this.rules, }); } /** * Add new rule to the collection, if not yet exists with the same selectors * @param {Array<Selector>} selectors Array of selectors * @param {String} state Css rule state * @param {String} width For which device this style is oriented * @param {Object} props Other props for the rule * @param {Object} opts Options for the add of new rule * @return {Model} * @private * @example * var sm = editor.SelectorManager; * var sel1 = sm.add('myClass1'); * var sel2 = sm.add('myClass2'); * var rule = cssComposer.add([sel1, sel2], 'hover'); * rule.set('style', { * width: '100px', * color: '#fff', * }); * */ add(selectors: any, state?: string, width?: string, opts = {}, addOpts = {}) { const s = state || ''; const w = width || ''; const opt = { ...opts } as CssRuleProperties; let rule = this.get(selectors, s, w, opt); // do not create rules that were found before // unless this is a single at-rule, for which multiple declarations // make sense (e.g. multiple `@font-type`s) if (rule && rule.config && !rule.config.singleAtRule) { return rule; } else { opt.state = s; opt.mediaText = w; opt.selectors = []; // #4727: Prevent updating atRuleType if already defined if (w && !opt.atRuleType) { opt.atRuleType = 'media'; } rule = new CssRule(opt, this.config); // @ts-ignore rule.get('selectors').add(selectors, addOpts); this.rules.add(rule, addOpts); return rule; } } /** * Get the rule * @param {String|Array<Selector>} selectors Array of selectors or selector string, eg `.myClass1.myClass2` * @param {String} state Css rule state, eg. 'hover' * @param {String} width Media rule value, eg. '(max-width: 992px)' * @param {Object} ruleProps Other rule props * @return {Model|null} * @private * @example * const sm = editor.SelectorManager; * const sel1 = sm.add('myClass1'); * const sel2 = sm.add('myClass2'); * const rule = cssComposer.get([sel1, sel2], 'hover', '(max-width: 992px)'); * // Update the style * rule.set('style', { * width: '300px', * color: '#000', * }); * */ get( selectors: any, state?: string, width?: string, ruleProps?: Omit<CssRuleProperties, 'selectors'> ): CssRule | undefined { let slc = selectors; if (isString(selectors)) { const sm = this.em.Selectors; const singleSel = selectors.split(',')[0].trim(); const node = this.em.Parser.parserCss.checkNode({ selectors: singleSel } as any)[0]; slc = sm.get(node.selectors as string[]); } return this.rules.find(rule => rule.compare(slc, state, width, ruleProps)) || null; } getAll() { return this.rules; } /** * Add a raw collection of rule objects * This method overrides styles, in case, of already defined rule * @param {String|Array<Object>} data CSS string or an array of rule objects, eg. [{selectors: ['class1'], style: {....}}, ..] * @param {Object} opts Options * @param {Object} props Additional properties to add on rules * @return {Array<Model>} * @private */ addCollection(data: string | CssRuleJSON[], opts: Record<string, any> = {}, props = {}) { const { em } = this; const result: CssRule[] = []; if (isString(data)) { data = em.Parser.parseCss(data); } const d = data instanceof Array ? data : [data]; for (var i = 0, l = d.length; i < l; i++) { const rule = (d[i] || {}) as CssRuleJSON; if (!rule.selectors) continue; const sm = em?.Selectors; if (!sm) console.warn('Selector Manager not found'); const sl = rule.selectors; const sels = sl instanceof Array ? sl : [sl]; const newSels = []; for (let j = 0, le = sels.length; j < le; j++) { // @ts-ignore const selec = sm.add(sels[j]); newSels.push(selec); } const modelExists = this.get(newSels, rule.state, rule.mediaText, rule); const model = this.add(newSels, rule.state, rule.mediaText, rule, opts); const updateStyle = !modelExists || !opts.avoidUpdateStyle; const style = rule.style || {}; isObject(props) && model.set(props, opts); if (updateStyle) { const styleUpdate = opts.extend ? { ...model.get('style'), ...style } : style; model.set('style', styleUpdate, opts); } result.push(model); } return result; } /** * Add CssRules via CSS string. * @param {String} css CSS string of rules to add. * @returns {Array<[CssRule]>} Array of rules * @example * const addedRules = css.addRules('.my-cls{ color: red } @media (max-width: 992px) { .my-cls{ color: darkred } }'); * // Check rules * console.log(addedRules.map(rule => rule.toCSS())); */ addRules(css: string) { return this.addCollection(css); } /** * Add/update the CssRule. * @param {String} selectors Selector string, eg. `.myclass` * @param {Object} style Style properties and values. If the rule exists, styles will be replaced unless `addStyles` option is used. * @param {Object} [opts={}] Additional properties. * @param {String} [opts.atRuleType=''] At-rule type, eg. `media`. * @param {String} [opts.atRuleParams=''] At-rule parameters, eg. `(min-width: 500px)`. * @param {Boolean} [opts.addStyles=false] If the rule exists already, merge passed styles instead of replacing them. * @returns {[CssRule]} The new/updated CssRule. * @example * // Simple class-based rule * const rule = css.setRule('.class1.class2', { color: 'red' }); * console.log(rule.toCSS()) // output: .class1.class2 { color: red } * // With state and other mixed selector * const rule = css.setRule('.class1.class2:hover, div#myid', { color: 'red' }); * // output: .class1.class2:hover, div#myid { color: red } * // With media * const rule = css.setRule('.class1:hover', { color: 'red' }, { * atRuleType: 'media', * atRuleParams: '(min-width: 500px)', * }); * // output: `@media (min-width: 500px) { .class1:hover { color: red } }` * * // Update styles of existent rule * css.setRule('.class1', { color: 'red', background: 'red' }); * css.setRule('.class1', { color: 'blue' }, { addStyles: true }); * // output: .class1 { color: blue; background: red } */ setRule(selectors: any, style: CssRuleProperties['style'] = {}, opts: SetRuleOptions = {}) { const { atRuleType, atRuleParams } = opts; const node = this.em.Parser.parserCss.checkNode({ selectors, style, })[0]; const { state, selectorsAdd } = node; const sm = this.em.Selectors; const selector = sm.add(node.selectors as any); const rule = this.add(selector, state, atRuleParams, { selectorsAdd, atRule: atRuleType, }); if (opts.addStyles) { rule.addStyle(style, opts); } else { rule.setStyle(style, opts); } return rule; } /** * Get the CssRule. * @param {String} selectors Selector string, eg. `.myclass:hover` * @param {Object} [opts={}] Additional properties * @param {String} [opts.atRuleType=''] At-rule type, eg. `media` * @param {String} [opts.atRuleParams=''] At-rule parameters, eg. '(min-width: 500px)' * @returns {[CssRule]} * @example * const rule = css.getRule('.myclass1:hover'); * const rule2 = css.getRule('.myclass1:hover, div#myid'); * const rule3 = css.getRule('.myclass1', { * atRuleType: 'media', * atRuleParams: '(min-width: 500px)', * }); */ getRule(selectors: any, opts: RuleOptions = {}) { const { em } = this; const sm = em.Selectors; const node = em.Parser.parserCss.checkNode({ selectors })[0]; // @ts-ignore const selector = sm.get(node.selectors); const { state, selectorsAdd } = node; const { atRuleType, atRuleParams } = opts; return selector ? this.get(selector, state, atRuleParams, { selectorsAdd, atRuleType, }) : undefined; } /** * Get all rules or filtered by a matching selector. * @param {String} [selector=''] Selector, eg. `.myclass` * @returns {Array<[CssRule]>} * @example * // Take all the component specific rules * const id = someComponent.getId(); * const rules = css.getRules(`#${id}`); * console.log(rules.map(rule => rule.toCSS())) * // All rules in the project * console.log(css.getRules()) */ getRules(selector: string) { const rules = this.getAll(); if (!selector) return [...rules.models]; const optRuleSel = { sort: true }; const sels = isString(selector) ? selector.split(',').map(s => s.trim()) : selector; const result = rules.filter(r => sels.indexOf(r.getSelectors().getFullString(null, optRuleSel)) >= 0); return result; } /** * Add/update the CSS rule with id selector * @param {string} name Id selector name, eg. 'my-id' * @param {Object} style Style properties and values * @param {Object} [opts={}] Custom options, like `state` and `mediaText` * @return {CssRule} The new/updated rule * @private * @example * const rule = css.setIdRule('myid', { color: 'red' }); * const ruleHover = css.setIdRule('myid', { color: 'blue' }, { state: 'hover' }); * // This will add current CSS: * // #myid { color: red } * // #myid:hover { color: blue } */ setIdRule(name: string, style: CssRuleStyle = {}, opts: GetSetRuleOptions = {}) { const { addOpts = {}, mediaText } = opts; const state = opts.state || ''; const media = !isUndefined(mediaText) ? mediaText : this.em.getCurrentMedia(); const sm = this.em.Selectors; const selector = sm.add({ name, type: Selector.TYPE_ID }, addOpts); const rule = this.add(selector, state, media, {}, addOpts); rule.setStyle(style, { ...opts, ...addOpts }); return rule; } /** * Get the CSS rule by id selector * @param {string} name Id selector name, eg. 'my-id' * @param {Object} [opts={}] Custom options, like `state` and `mediaText` * @return {CssRule} * @private * @example * const rule = css.getIdRule('myid'); * const ruleHover = css.setIdRule('myid', { state: 'hover' }); */ getIdRule(name: string, opts: GetSetRuleOptions = {}) { const { mediaText } = opts; const state = opts.state || ''; const media = !isUndefined(mediaText) ? mediaText : this.em.getCurrentMedia(); const selector = this.em.Selectors.get(name, Selector.TYPE_ID); return selector && this.get(selector, state, media); } /** * Add/update the CSS rule with class selector * @param {string} name Class selector name, eg. 'my-class' * @param {Object} style Style properties and values * @param {Object} [opts={}] Custom options, like `state` and `mediaText` * @return {CssRule} The new/updated rule * @private * @example * const rule = css.setClassRule('myclass', { color: 'red' }); * const ruleHover = css.setClassRule('myclass', { color: 'blue' }, { state: 'hover' }); * // This will add current CSS: * // .myclass { color: red } * // .myclass:hover { color: blue } */ setClassRule(name: string, style: CssRuleStyle = {}, opts: GetSetRuleOptions = {}) { const state = opts.state || ''; const media = opts.mediaText || this.em.getCurrentMedia(); const sm = this.em.Selectors; const selector = sm.add({ name, type: Selector.TYPE_CLASS }); const rule = this.add(selector, state, media); rule.setStyle(style, opts); return rule; } /** * Get the CSS rule by class selector * @param {string} name Class selector name, eg. 'my-class' * @param {Object} [opts={}] Custom options, like `state` and `mediaText` * @return {CssRule} * @private * @example * const rule = css.getClassRule('myclass'); * const ruleHover = css.getClassRule('myclass', { state: 'hover' }); */ getClassRule(name: string, opts: GetSetRuleOptions = {}) { const state = opts.state || ''; const media = opts.mediaText || this.em.getCurrentMedia(); const selector = this.em.Selectors.get(name, Selector.TYPE_CLASS); return selector && this.get(selector, state, media); } /** * Remove rule, by CssRule or matching selector (eg. the selector will match also at-rules like `@media`) * @param {String|[CssRule]|Array<[CssRule]>} rule CssRule or matching selector. * @return {Array<[CssRule]>} Removed rules * @example * // Remove by CssRule * const toRemove = css.getRules('.my-cls'); * css.remove(toRemove); * // Remove by selector * css.remove('.my-cls-2'); */ remove(rule: string | CssRule, opts?: any) { const toRemove = isString(rule) ? this.getRules(rule) : rule; const result = this.getAll().remove(toRemove, opts); return isArray(result) ? result : [result]; } /** * Remove all rules * @return {this} */ clear(opts = {}) { this.getAll().reset([], opts); return this; } getComponentRules(cmp: Component, opts: GetSetRuleOptions = {}) { let { state, mediaText, current } = opts; if (current) { state = this.em.get('state') || ''; mediaText = this.em.getCurrentMedia(); } const id = cmp.getId(); const rules = this.getAll().filter(r => { if (!isUndefined(state) && r.get('state') !== state) return false; if (!isUndefined(mediaText) && r.get('mediaText') !== mediaText) return false; return r.getSelectorsString() === `#${id}`; }); return rules; } /** * Render the block of CSS rules * @return {HTMLElement} * @private */ render() { this.rulesView?.remove(); this.rulesView = new CssRulesView({ collection: this.rules, config: this.config, }); return this.rulesView.render().el; } destroy() { this.rules.reset(); this.rules.stopListening(); this.rulesView?.remove(); } }