From 5f60908f7282061e890ddaeeb5a59c53c8337fdb Mon Sep 17 00:00:00 2001 From: Artur Arseniev Date: Tue, 11 Sep 2018 08:00:54 +0200 Subject: [PATCH 01/10] Create BrowserParserCss --- src/editor/model/Editor.js | 4 + src/parser/model/BrowserParserCss.js | 200 ++++++++++++++++++++++++++ src/parser/model/ParserCss.js | 201 +-------------------------- 3 files changed, 210 insertions(+), 195 deletions(-) create mode 100644 src/parser/model/BrowserParserCss.js diff --git a/src/editor/model/Editor.js b/src/editor/model/Editor.js index 2c66604c0..561fc599c 100644 --- a/src/editor/model/Editor.js +++ b/src/editor/model/Editor.js @@ -641,5 +641,9 @@ module.exports = Backbone.Model.extend({ } else { el[varName][name] = value; } + }, + + getCustomParserCss() { + return null; } }); diff --git a/src/parser/model/BrowserParserCss.js b/src/parser/model/BrowserParserCss.js new file mode 100644 index 000000000..fb400323c --- /dev/null +++ b/src/parser/model/BrowserParserCss.js @@ -0,0 +1,200 @@ +import { keys } from 'underscore'; + +// At-rules +// https://developer.mozilla.org/it/docs/Web/API/CSSRule#Type_constants +const atRules = { + 4: 'media', + 5: 'font-face', + 6: 'page', + 7: 'keyframes', + 11: 'counter-style', + 12: 'supports', + 13: 'document', + 14: 'font-feature-values', + 15: 'viewport' +}; +const atRuleKeys = keys(atRules); +const singleAtRules = ['5', '6', '11', '15']; + +/** + * Parse selector string to array. + * Only classe based are valid as CSS rules inside editor, not valid + * selectors will be dropped as additional + * It's ok with the last part of the string as state (:hover, :active) + * @param {string} str Selectors string + * @return {Object} + * @example + * var res = parseSelector('.test1, .test1.test2, .test2 .test3'); + * console.log(res); + * // { + * //result: [['test1'], ['test1', 'test2']], + * //add: ['.test2 .test3'] + * //} + */ +export const parseSelector = (str = '') => { + const add = []; + const result = []; + const sels = str.split(','); + + for (var i = 0, len = sels.length; i < len; i++) { + var sel = sels[i].trim(); + + // Will accept only concatenated classes and last + // class might be with state (eg. :hover), nothing else. + // Can also accept SINGLE ID selectors, eg. `#myid`, `#myid:hover` + // Composed are not valid: `#myid.some-class`, `#myid.some-class:hover` + if ( + /^(\.{1}[\w\-]+)+(:{1,2}[\w\-()]+)?$/gi.test(sel) || + /^(#{1}[\w\-]+){1}(:{1,2}[\w\-()]+)?$/gi.test(sel) + ) { + var cls = sel.split('.').filter(Boolean); + result.push(cls); + } else { + add.push(sel); + } + } + + return { + result, + add + }; +}; + +/** + * Parse style declarations of the node + * @param {CSSRule} node + * @return {Object} + */ +export const parseStyle = node => { + const stl = node.style; + const style = {}; + + for (var i = 0, len = stl.length; i < len; i++) { + const propName = stl[i]; + const propValue = stl.getPropertyValue(propName); + const important = stl.getPropertyPriority(propName); + style[propName] = `${propValue}${important ? ` !${important}` : ''}`; + } + + return style; +}; + +/** + * Get the condition when possible + * @param {CSSRule} node + * @return {string} + */ +export const parseCondition = node => { + const condition = + node.conditionText || + (node.media && node.media.mediaText) || + node.name || + node.selectorText || + ''; + return condition.trim(); +}; + +/** + * Fetch data from node + * @param {StyleSheet|CSSRule} el + * @return {Array} + */ +export const parseNode = el => { + var result = []; + var nodes = el.cssRules || []; + + for (var i = 0, len = nodes.length; i < len; i++) { + const node = nodes[i]; + const type = node.type.toString(); + let singleAtRule = 0; + let atRuleType = ''; + let condition = ''; + // keyText is for CSSKeyframeRule + let sels = node.selectorText || node.keyText; + const isSingleAtRule = singleAtRules.indexOf(type) >= 0; + + // Check if the node is an at-rule + if (isSingleAtRule) { + singleAtRule = 1; + atRuleType = atRules[type]; + condition = parseCondition(node); + } else if (atRuleKeys.indexOf(type) >= 0) { + var subRules = parseNode(node); + condition = parseCondition(node); + + for (var s = 0, lens = subRules.length; s < lens; s++) { + var subRule = subRules[s]; + condition && (subRule.mediaText = condition); + subRule.atRuleType = atRules[type]; + } + result = result.concat(subRules); + } + + if (!sels && !isSingleAtRule) continue; + const style = parseStyle(node); + const selsParsed = parseSelector(sels); + const selsAdd = selsParsed.add; + sels = selsParsed.result; + + let lastRule; + // For each group of selectors + for (var k = 0, len3 = sels.length; k < len3; k++) { + var selArr = sels[k]; + var model = {}; + singleAtRule && (model.singleAtRule = singleAtRule); + atRuleType && (model.atRuleType = atRuleType); + + //Isolate state from selector + var stateArr = selArr[selArr.length - 1].split(/:(.+)/); + if (stateArr[1]) { + selArr[selArr.length - 1] = stateArr[0]; + model.state = stateArr[1]; + stateArr.splice(stateArr.length - 1, 1); + } + + model.selectors = selArr; + model.style = style; + lastRule = model; + result.push(model); + } + + // Need to push somewhere not class-based selectors, if some rule was + // created will push them there, otherwise will create a new rule + if (selsAdd.length) { + var selsAddStr = selsAdd.join(', '); + if (lastRule) { + lastRule.selectorsAdd = selsAddStr; + } else { + const model = { + selectors: [], + selectorsAdd: selsAddStr, + style + }; + singleAtRule && (model.singleAtRule = singleAtRule); + atRuleType && (model.atRuleType = atRuleType); + condition && (model.mediaText = condition); + result.push(model); + } + } + // console.log('LAST PUSH', result[result.length - 1]); + } + + return result; +}; + +/** + * Parse CSS string and return the array of objects + * @param {String} str CSS string + * @return {Array} Array of objects for the definition of CSSRules + */ +export default str => { + const el = document.createElement('style'); + el.innerHTML = str; + + // There is no .sheet before adding it to the + document.head.appendChild(el); + const sheet = el.sheet; + document.head.removeChild(el); + + return parseNode(sheet); +}; diff --git a/src/parser/model/ParserCss.js b/src/parser/model/ParserCss.js index 4c3310aec..6dad4cb2b 100644 --- a/src/parser/model/ParserCss.js +++ b/src/parser/model/ParserCss.js @@ -1,203 +1,14 @@ -import { keys } from 'underscore'; - -// At-rules -// https://developer.mozilla.org/it/docs/Web/API/CSSRule#Type_constants -const atRules = { - 4: 'media', - 5: 'font-face', - 6: 'page', - 7: 'keyframes', - 11: 'counter-style', - 12: 'supports', - 13: 'document', - 14: 'font-feature-values', - 15: 'viewport' -}; -const atRuleKeys = keys(atRules); -const singleAtRules = ['5', '6', '11', '15']; +import BrowserCssParser from './BrowserParserCss'; module.exports = config => ({ - /** - * Parse selector string to array. - * Only classe based are valid as CSS rules inside editor, not valid - * selectors will be dropped as additional - * It's ok with the last part of the string as state (:hover, :active) - * @param {string} str Selectors string - * @return {Object} - * @example - * var res = ParserCss.parseSelector('.test1, .test1.test2, .test2 .test3'); - * console.log(res); - * // { - * //result: [['test1'], ['test1', 'test2']], - * //add: ['.test2 .test3'] - * //} - */ - parseSelector(str = '') { - const add = []; - const result = []; - const sels = str.split(','); - - for (var i = 0, len = sels.length; i < len; i++) { - var sel = sels[i].trim(); - - // Will accept only concatenated classes and last - // class might be with state (eg. :hover), nothing else. - // Can also accept SINGLE ID selectors, eg. `#myid`, `#myid:hover` - // Composed are not valid: `#myid.some-class`, `#myid.some-class:hover` - if ( - /^(\.{1}[\w\-]+)+(:{1,2}[\w\-()]+)?$/gi.test(sel) || - /^(#{1}[\w\-]+){1}(:{1,2}[\w\-()]+)?$/gi.test(sel) - ) { - var cls = sel.split('.').filter(Boolean); - result.push(cls); - } else { - add.push(sel); - } - } - - return { - result, - add - }; - }, - - /** - * Parse style declarations of the node - * @param {CSSRule} node - * @return {Object} - */ - parseStyle(node) { - const stl = node.style; - const style = {}; - - for (var i = 0, len = stl.length; i < len; i++) { - const propName = stl[i]; - const propValue = stl.getPropertyValue(propName); - const important = stl.getPropertyPriority(propName); - style[propName] = `${propValue}${important ? ` !${important}` : ''}`; - } - - return style; - }, - - /** - * Get the condition when possible - * @param {CSSRule} node - * @return {string} - */ - parseCondition(node) { - const condition = - node.conditionText || - (node.media && node.media.mediaText) || - node.name || - node.selectorText || - ''; - return condition.trim(); - }, - - /** - * Fetch data from node - * @param {StyleSheet|CSSRule} el - * @return {Array} - */ - parseNode(el) { - var result = []; - var nodes = el.cssRules || []; - - for (var i = 0, len = nodes.length; i < len; i++) { - const node = nodes[i]; - const type = node.type.toString(); - let singleAtRule = 0; - let atRuleType = ''; - let condition = ''; - // keyText is for CSSKeyframeRule - let sels = node.selectorText || node.keyText; - const isSingleAtRule = singleAtRules.indexOf(type) >= 0; - - // Check if the node is an at-rule - if (isSingleAtRule) { - singleAtRule = 1; - atRuleType = atRules[type]; - condition = this.parseCondition(node); - } else if (atRuleKeys.indexOf(type) >= 0) { - var subRules = this.parseNode(node); - condition = this.parseCondition(node); - - for (var s = 0, lens = subRules.length; s < lens; s++) { - var subRule = subRules[s]; - condition && (subRule.mediaText = condition); - subRule.atRuleType = atRules[type]; - } - result = result.concat(subRules); - } - - if (!sels && !isSingleAtRule) continue; - const style = this.parseStyle(node); - const selsParsed = this.parseSelector(sels); - const selsAdd = selsParsed.add; - sels = selsParsed.result; - - let lastRule; - // For each group of selectors - for (var k = 0, len3 = sels.length; k < len3; k++) { - var selArr = sels[k]; - var model = {}; - singleAtRule && (model.singleAtRule = singleAtRule); - atRuleType && (model.atRuleType = atRuleType); - - //Isolate state from selector - var stateArr = selArr[selArr.length - 1].split(/:(.+)/); - if (stateArr[1]) { - selArr[selArr.length - 1] = stateArr[0]; - model.state = stateArr[1]; - stateArr.splice(stateArr.length - 1, 1); - } - - model.selectors = selArr; - model.style = style; - lastRule = model; - result.push(model); - } - - // Need to push somewhere not class-based selectors, if some rule was - // created will push them there, otherwise will create a new rule - if (selsAdd.length) { - var selsAddStr = selsAdd.join(', '); - if (lastRule) { - lastRule.selectorsAdd = selsAddStr; - } else { - const model = { - selectors: [], - selectorsAdd: selsAddStr, - style - }; - singleAtRule && (model.singleAtRule = singleAtRule); - atRuleType && (model.atRuleType = atRuleType); - condition && (model.mediaText = condition); - result.push(model); - } - } - // console.log('LAST PUSH', result[result.length - 1]); - } - - return result; - }, - /** * Parse CSS string to a desired model object - * @param {string} str HTML string - * @return {Object|Array} + * @param {String} str CSS string + * @return {Array} */ parse(str) { - const el = document.createElement('style'); - el.innerHTML = str; - - // There is no .sheet before adding it to the - document.head.appendChild(el); - const sheet = el.sheet; - document.head.removeChild(el); - const result = this.parseNode(sheet); - - return result.length == 1 ? result[0] : result; + const { em } = config; + const customParser = em.getCustomParserCss(); + return customParser ? customParser(str) : BrowserCssParser(str); } }); From 9a0f4985183d5e845b6d4669085855d6d15ce3ba Mon Sep 17 00:00:00 2001 From: Artur Arseniev Date: Tue, 11 Sep 2018 08:11:15 +0200 Subject: [PATCH 02/10] Update CSS parser tests --- test/specs/parser/model/ParserCss.js | 50 ++++++++++++++++------------ 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/test/specs/parser/model/ParserCss.js b/test/specs/parser/model/ParserCss.js index 4b858a7c7..4cd64359f 100644 --- a/test/specs/parser/model/ParserCss.js +++ b/test/specs/parser/model/ParserCss.js @@ -1,13 +1,21 @@ +import { parseSelector } from 'parser/model/BrowserParserCss'; const ParserCss = require('parser/model/ParserCss'); const Selector = require('selector_manager/model/Selector'); module.exports = { run() { - describe('ParserCss', () => { - var obj; + describe.only('ParserCss', () => { + let obj; + let config; + let customParser; beforeEach(() => { - obj = new ParserCss(); + config = { + em: { + getCustomParserCss: () => customParser + } + }; + obj = new ParserCss(config); }); afterEach(() => { @@ -17,26 +25,26 @@ module.exports = { test('Parse selector', () => { var str = '.test'; var result = [['test']]; - expect(obj.parseSelector(str).result).toEqual(result); + expect(parseSelector(str).result).toEqual(result); }); test('Parse selectors', () => { var str = '.test1, .test1.test2, .test2.test3'; var result = [['test1'], ['test1', 'test2'], ['test2', 'test3']]; - expect(obj.parseSelector(str).result).toEqual(result); + expect(parseSelector(str).result).toEqual(result); }); test('Ignore not valid selectors', () => { var str = '.test1.test2, .test2 .test3, div > .test4, #test.test5, .test6'; var result = [['test1', 'test2'], ['test6']]; - expect(obj.parseSelector(str).result).toEqual(result); + expect(parseSelector(str).result).toEqual(result); }); test('Parse selectors with state', () => { var str = '.test1. test2, .test2>test3, .test4.test5:hover'; var result = [['test4', 'test5:hover']]; - expect(obj.parseSelector(str).result).toEqual(result); + expect(parseSelector(str).result).toEqual(result); }); test('Parse simple rule', () => { @@ -48,7 +56,7 @@ module.exports = { width: '50px' } }; - expect(obj.parse(str)).toEqual(result); + expect(obj.parse(str)).toEqual([result]); }); test('Parse rule with more selectors', () => { @@ -57,7 +65,7 @@ module.exports = { selectors: ['test1', 'test2'], style: { color: 'red', test: 'value' } }; - expect(obj.parse(str)).toEqual(result); + expect(obj.parse(str)).toEqual([result]); }); test('Parse same rule with more selectors', () => { @@ -106,7 +114,7 @@ module.exports = { style: { color: 'red' }, state: 'hover' }; - expect(obj.parse(str)).toEqual(result); + expect(obj.parse(str)).toEqual([result]); }); test('Parse rule with state like after', () => { @@ -116,7 +124,7 @@ module.exports = { style: { color: 'red' }, state: ':after' }; - expect(obj.parse(str)).toEqual(result); + expect(obj.parse(str)).toEqual([result]); }); test('Parse rule with nth-x state', () => { @@ -126,7 +134,7 @@ module.exports = { style: { color: 'red' }, state: 'nth-of-type(2n)' }; - expect(obj.parse(str)).toEqual(result); + expect(obj.parse(str)).toEqual([result]); }); // Phantom don't find 'node.conditionText' so will skip it @@ -140,7 +148,7 @@ module.exports = { state: 'hover', mediaText: 'only screen and (max-width: 992px)' }; - expect(obj.parse(str)).toEqual(result); + expect(obj.parse(str)).toEqual([result]); }); // Phantom don't find 'node.conditionText' so will skip it @@ -153,7 +161,7 @@ module.exports = { state: 'hover', mediaText: '(max-width: 992px)' }; - expect(obj.parse(str)).toEqual(result); + expect(obj.parse(str)).toEqual([result]); }); // Phantom doesn't find 'node.conditionText' so will skip it @@ -190,7 +198,7 @@ module.exports = { selectorsAdd: '.class1 .class2, div > .class3', style: { color: 'red' } }; - expect(obj.parse(str)).toEqual(result); + expect(obj.parse(str)).toEqual([result]); }); test('Parse rule with mixed selectors', () => { @@ -221,7 +229,7 @@ module.exports = { width: '100px' } }; - expect(obj.parse(str)).toEqual(result); + expect(obj.parse(str)).toEqual([result]); }); test('Parse rule with CSS variables', () => { @@ -237,7 +245,7 @@ module.exports = { '--some-width': '55px' } }; - expect(obj.parse(str)).toEqual(result); + expect(obj.parse(str)).toEqual([result]); }); // Can't test keyframes https://github.com/NV/CSSOM/issues/95 @@ -274,7 +282,7 @@ module.exports = { singleAtRule: 1, style: { 'font-family': '"Open Sans"' } }; - expect(obj.parse(str)).toEqual(result); + expect(obj.parse(str)).toEqual([result]); }); test('Parses multiple font-face at-rules', () => { @@ -316,7 +324,7 @@ module.exports = { selectors: ['#test'], style: { color: 'red' } }; - expect(obj.parse(str)).toEqual(result); + expect(obj.parse(str)).toEqual([result]); }); test('Parse ID rule with state', () => { @@ -326,7 +334,7 @@ module.exports = { state: 'hover', style: { color: 'red' } }; - expect(obj.parse(str)).toEqual(result); + expect(obj.parse(str)).toEqual([result]); }); test('Avoid composed selectors with ID', () => { @@ -336,7 +344,7 @@ module.exports = { selectorsAdd: '#test.class, #test.class:hover', style: { color: 'red' } }; - expect(obj.parse(str)).toEqual(result); + expect(obj.parse(str)).toEqual([result]); }); }); } From c799a0fe12dff8b67b6793bf622266834d4567ed Mon Sep 17 00:00:00 2001 From: Artur Arseniev Date: Wed, 12 Sep 2018 08:32:46 +0200 Subject: [PATCH 03/10] Add setCustomParserCss method the the Editor API --- src/editor/index.js | 33 ++++++++++++++++++++++--- src/editor/model/Editor.js | 6 ++++- src/parser/config/config.js | 8 +++++- src/parser/index.js | 21 +++++++++------- test/specs/parser/model/ParserCss.js | 37 +++++++++++++++++++++++++++- 5 files changed, 90 insertions(+), 15 deletions(-) diff --git a/src/editor/index.js b/src/editor/index.js index c2a1b44bf..72fb0c853 100644 --- a/src/editor/index.js +++ b/src/editor/index.js @@ -567,6 +567,30 @@ module.exports = config => { this.RichTextEditor.customRte = obj; }, + /** + * Replace the default CSS parser with a custom one. + * The parser function receives a CSS string as a parameter and expects + * an array of CSSRule objects as a result. If you need to remove the + * custom parser, pass the `null` to this method + * @param {Function|null} parser Parser function + * @return {this} + * @example + * editor.setCustomParserCss(css => { + * const result = []; + * // ... parse the CSS string + * result.push({ + * selectors: '.someclass, div .otherclass', + * style: { color: 'red' } + * }) + * // ... + * return result; + * }); + */ + setCustomParserCss(parser) { + em.setCustomParserCss(parser); + return this; + }, + /** * Attach event * @param {string} event Event name @@ -574,7 +598,8 @@ module.exports = config => { * @return {this} */ on(event, callback) { - return em.on(event, callback); + em.on(event, callback); + return this; }, /** @@ -584,7 +609,8 @@ module.exports = config => { * @return {this} */ off(event, callback) { - return em.off(event, callback); + em.off(event, callback); + return this; }, /** @@ -593,7 +619,8 @@ module.exports = config => { * @return {this} */ trigger(event) { - return em.trigger.apply(em, arguments); + em.trigger.apply(em, arguments); + return this; }, /** diff --git a/src/editor/model/Editor.js b/src/editor/model/Editor.js index 561fc599c..b757bd845 100644 --- a/src/editor/model/Editor.js +++ b/src/editor/model/Editor.js @@ -644,6 +644,10 @@ module.exports = Backbone.Model.extend({ }, getCustomParserCss() { - return null; + return this.get('Parser').parserCss; + }, + + setCustomParserCss(parser) { + this.get('Parser').parserCss = parser; } }); diff --git a/src/parser/config/config.js b/src/parser/config/config.js index 31922f6db..5afc514ca 100644 --- a/src/parser/config/config.js +++ b/src/parser/config/config.js @@ -1,3 +1,9 @@ module.exports = { - textTags: ['br', 'b', 'i', 'u', 'a', 'ul', 'ol'] + textTags: ['br', 'b', 'i', 'u', 'a', 'ul', 'ol'], + + // Custom CSS parser + parserCss: null, + + // Custom HTML parser + parserHtml: null }; diff --git a/src/parser/index.js b/src/parser/index.js index c9d6bb021..15d0cde39 100644 --- a/src/parser/index.js +++ b/src/parser/index.js @@ -1,13 +1,17 @@ module.exports = () => { - var c = {}, + let conf = {}, defaults = require('./config/config'), parserCss = require('./model/ParserCss'), parserHtml = require('./model/ParserHtml'); - var pHtml, pCss; + let pHtml, pCss; return { compTypes: '', + parserCss: null, + + parserHtml: null, + /** * Name of the module * @type {String} @@ -30,13 +34,12 @@ module.exports = () => { * } * ... */ - init(config) { - c = config || {}; - for (var name in defaults) { - if (!(name in c)) c[name] = defaults[name]; - } - pHtml = new parserHtml(c); - pCss = new parserCss(c); + init(config = {}) { + conf = { ...defaults, ...config }; + this.parserCss = conf.parserCss || null; + this.parserHtml = conf.parserHtml || null; + pHtml = new parserHtml(conf); + pCss = new parserCss(conf); return this; }, diff --git a/test/specs/parser/model/ParserCss.js b/test/specs/parser/model/ParserCss.js index 4cd64359f..756fd5e4b 100644 --- a/test/specs/parser/model/ParserCss.js +++ b/test/specs/parser/model/ParserCss.js @@ -4,7 +4,7 @@ const Selector = require('selector_manager/model/Selector'); module.exports = { run() { - describe.only('ParserCss', () => { + describe('ParserCss', () => { let obj; let config; let customParser; @@ -346,6 +346,41 @@ module.exports = { }; expect(obj.parse(str)).toEqual([result]); }); + + test('Parse CSS with custom parser', () => { + var str = '.test1 { color:red }'; + var result = { + selectors: ['test1'], + style: { color: 'red' } + }; + obj = new ParserCss({ + em: { + getCustomParserCss: () => () => [result] + } + }); + expect(obj.parse(str)).toEqual([result]); + }); + + test.only('Parse CSS with custom async parser', done => { + var str = '.test1 { color:red }'; + var result = { + selectors: ['test1'], + style: { color: 'red' } + }; + obj = new ParserCss({ + em: { + getCustomParserCss: () => { + return async function() { + return [result]; + }; + } + } + }); + obj.parse(str).then(res => { + expect(res).toEqual([result]); + done(); + }); + }); }); } }; From 197273e668229b57007af6d691e88647cf7ec03e Mon Sep 17 00:00:00 2001 From: Artur Arseniev Date: Wed, 12 Sep 2018 19:12:22 +0200 Subject: [PATCH 04/10] Refactor custom CSS parser configuration --- src/editor/index.js | 10 ++++++++-- src/editor/model/Editor.js | 8 -------- src/parser/index.js | 11 +++++++++-- src/parser/model/ParserCss.js | 5 ++--- test/specs/parser/model/ParserCss.js | 24 +++++++----------------- 5 files changed, 26 insertions(+), 32 deletions(-) diff --git a/src/editor/index.js b/src/editor/index.js index 72fb0c853..5cc2c3b2f 100644 --- a/src/editor/index.js +++ b/src/editor/index.js @@ -223,6 +223,12 @@ module.exports = config => { */ RichTextEditor: em.get('RichTextEditor'), + /** + * @property {Parser} + * @private + */ + Parser: em.get('Parser'), + /** * @property {Utils} * @private @@ -571,7 +577,7 @@ module.exports = config => { * Replace the default CSS parser with a custom one. * The parser function receives a CSS string as a parameter and expects * an array of CSSRule objects as a result. If you need to remove the - * custom parser, pass the `null` to this method + * custom parser, pass `null` as the argument * @param {Function|null} parser Parser function * @return {this} * @example @@ -587,7 +593,7 @@ module.exports = config => { * }); */ setCustomParserCss(parser) { - em.setCustomParserCss(parser); + this.Parser.getConfig().parserCss = parser; return this; }, diff --git a/src/editor/model/Editor.js b/src/editor/model/Editor.js index b757bd845..2c66604c0 100644 --- a/src/editor/model/Editor.js +++ b/src/editor/model/Editor.js @@ -641,13 +641,5 @@ module.exports = Backbone.Model.extend({ } else { el[varName][name] = value; } - }, - - getCustomParserCss() { - return this.get('Parser').parserCss; - }, - - setCustomParserCss(parser) { - this.get('Parser').parserCss = parser; } }); diff --git a/src/parser/index.js b/src/parser/index.js index 15d0cde39..8f714d5b8 100644 --- a/src/parser/index.js +++ b/src/parser/index.js @@ -19,6 +19,14 @@ module.exports = () => { */ name: 'Parser', + /** + * Get config object + * @return {Object} + */ + getConfig() { + return conf; + }, + /** * Initialize module. Automatically called with a new instance of the editor * @param {Object} config Configurations @@ -36,8 +44,7 @@ module.exports = () => { */ init(config = {}) { conf = { ...defaults, ...config }; - this.parserCss = conf.parserCss || null; - this.parserHtml = conf.parserHtml || null; + conf.Parser = this; pHtml = new parserHtml(conf); pCss = new parserCss(conf); return this; diff --git a/src/parser/model/ParserCss.js b/src/parser/model/ParserCss.js index 6dad4cb2b..1e9666053 100644 --- a/src/parser/model/ParserCss.js +++ b/src/parser/model/ParserCss.js @@ -1,14 +1,13 @@ import BrowserCssParser from './BrowserParserCss'; -module.exports = config => ({ +module.exports = (config = {}) => ({ /** * Parse CSS string to a desired model object * @param {String} str CSS string * @return {Array} */ parse(str) { - const { em } = config; - const customParser = em.getCustomParserCss(); + const customParser = config.parserCss; return customParser ? customParser(str) : BrowserCssParser(str); } }); diff --git a/test/specs/parser/model/ParserCss.js b/test/specs/parser/model/ParserCss.js index 756fd5e4b..b49d7b332 100644 --- a/test/specs/parser/model/ParserCss.js +++ b/test/specs/parser/model/ParserCss.js @@ -351,35 +351,25 @@ module.exports = { var str = '.test1 { color:red }'; var result = { selectors: ['test1'], - style: { color: 'red' } + style: { color: 'blue' } }; obj = new ParserCss({ - em: { - getCustomParserCss: () => () => [result] - } + parserCss: () => [result] }); expect(obj.parse(str)).toEqual([result]); }); - test.only('Parse CSS with custom async parser', done => { + test('Parse CSS with custom async parser', async () => { var str = '.test1 { color:red }'; var result = { selectors: ['test1'], - style: { color: 'red' } + style: { color: 'blue' } }; obj = new ParserCss({ - em: { - getCustomParserCss: () => { - return async function() { - return [result]; - }; - } - } - }); - obj.parse(str).then(res => { - expect(res).toEqual([result]); - done(); + parserCss: async () => [result] }); + const cssResult = await obj.parse(str); + expect(cssResult).toEqual([result]); }); }); } From d473722d948df2ce5fe47aa805ee012e8e9a4858 Mon Sep 17 00:00:00 2001 From: Artur Arseniev Date: Fri, 14 Sep 2018 01:21:04 +0200 Subject: [PATCH 05/10] Start with the node check --- src/parser/index.js | 5 +++++ src/parser/model/ParserCss.js | 19 ++++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/parser/index.js b/src/parser/index.js index 8f714d5b8..b45924300 100644 --- a/src/parser/index.js +++ b/src/parser/index.js @@ -60,6 +60,11 @@ module.exports = () => { return pHtml.parse(str, pCss); }, + /** + * Parse CSS string and return valid model + * @param {string} str CSS string + * @return {Array} + */ parseCss(str) { return pCss.parse(str); } diff --git a/src/parser/model/ParserCss.js b/src/parser/model/ParserCss.js index 1e9666053..289603d86 100644 --- a/src/parser/model/ParserCss.js +++ b/src/parser/model/ParserCss.js @@ -1,3 +1,4 @@ +import { isString } from 'underscore'; import BrowserCssParser from './BrowserParserCss'; module.exports = (config = {}) => ({ @@ -7,7 +8,23 @@ module.exports = (config = {}) => ({ * @return {Array} */ parse(str) { + let result = []; const customParser = config.parserCss; - return customParser ? customParser(str) : BrowserCssParser(str); + const nodes = customParser ? customParser(str) : BrowserCssParser(str); + nodes.forEach(node => (result = result.concat(this.checkNode(node)))); + + return result; + }, + + /** + * Check the returned node from a custom parser and transforms it to + * a valid object for the CSS composer + * @return {[type]} + */ + checkNode(node) { + if (isString(node.selectors)) { + } + + return node; } }); From c48768abe918a91d8fa66cfca4582c5cb2dce731 Mon Sep 17 00:00:00 2001 From: Artur Arseniev Date: Fri, 14 Sep 2018 19:08:58 +0200 Subject: [PATCH 06/10] Add checkNode in ParserCSS and start tests --- src/parser/model/BrowserParserCss.js | 53 +++++++++++------ src/parser/model/ParserCss.js | 27 ++++++++- test/specs/parser/model/ParserCss.js | 86 +++++++++++++++++++++++++++- 3 files changed, 146 insertions(+), 20 deletions(-) diff --git a/src/parser/model/BrowserParserCss.js b/src/parser/model/BrowserParserCss.js index fb400323c..4acd59b29 100644 --- a/src/parser/model/BrowserParserCss.js +++ b/src/parser/model/BrowserParserCss.js @@ -15,6 +15,7 @@ const atRules = { }; const atRuleKeys = keys(atRules); const singleAtRules = ['5', '6', '11', '15']; +const singleAtRulesNames = ['font-face', 'page', 'counter-style', 'viewport']; /** * Parse selector string to array. @@ -94,6 +95,38 @@ export const parseCondition = node => { return condition.trim(); }; +/** + * Create node for the editor + * @param {Array} selectors Array containing strings of classes + * @param {Object} style Key-value object of style declarations + * @return {Object} + */ +export const createNode = (selectors, style, opts = {}) => { + const node = {}; + const selLen = selectors.length; + const lastClass = selectors[selLen - 1]; + const stateArr = lastClass ? lastClass.split(/:(.+)/) : []; + const state = stateArr[1]; + const { atRule, selectorsAdd, mediaText } = opts; + const singleAtRule = singleAtRulesNames.indexOf(atRule) >= 0; + singleAtRule && (node.singleAtRule = 1); + atRule && (node.atRuleType = atRule); + selectorsAdd && (node.selectorsAdd = selectorsAdd); + mediaText && (node.mediaText = mediaText); + + // Isolate the state from selectors + if (state) { + selectors[selLen - 1] = stateArr[0]; + node.state = state; + stateArr.splice(stateArr.length - 1, 1); + } + + node.selectors = selectors; + node.style = style; + + return node; +}; + /** * Fetch data from node * @param {StyleSheet|CSSRule} el @@ -139,23 +172,11 @@ export const parseNode = el => { let lastRule; // For each group of selectors for (var k = 0, len3 = sels.length; k < len3; k++) { - var selArr = sels[k]; - var model = {}; - singleAtRule && (model.singleAtRule = singleAtRule); - atRuleType && (model.atRuleType = atRuleType); - - //Isolate state from selector - var stateArr = selArr[selArr.length - 1].split(/:(.+)/); - if (stateArr[1]) { - selArr[selArr.length - 1] = stateArr[0]; - model.state = stateArr[1]; - stateArr.splice(stateArr.length - 1, 1); - } - - model.selectors = selArr; - model.style = style; - lastRule = model; + const model = createNode(sels[k], style, { + atRule: atRules[type] + }); result.push(model); + lastRule = model; } // Need to push somewhere not class-based selectors, if some rule was diff --git a/src/parser/model/ParserCss.js b/src/parser/model/ParserCss.js index 289603d86..7dcdb0273 100644 --- a/src/parser/model/ParserCss.js +++ b/src/parser/model/ParserCss.js @@ -1,5 +1,8 @@ import { isString } from 'underscore'; -import BrowserCssParser from './BrowserParserCss'; +import BrowserCssParser, { + parseSelector, + createNode +} from './BrowserParserCss'; module.exports = (config = {}) => ({ /** @@ -22,7 +25,27 @@ module.exports = (config = {}) => ({ * @return {[type]} */ checkNode(node) { - if (isString(node.selectors)) { + const { selectors, style } = node; + + if (isString(selectors)) { + const nodes = []; + const selsParsed = parseSelector(selectors); + const classSets = selsParsed.result; + const opts = { + atRule: node.atRule, + selectorsAdd: selsParsed.add.join(', '), + mediaText: node.params + }; + + if (classSets.length) { + classSets.forEach(classSet => { + nodes.push(createNode(classSet, style, opts)); + }); + } else { + nodes.push(createNode([], style, opts)); + } + + node = nodes; } return node; diff --git a/test/specs/parser/model/ParserCss.js b/test/specs/parser/model/ParserCss.js index b49d7b332..4db32157f 100644 --- a/test/specs/parser/model/ParserCss.js +++ b/test/specs/parser/model/ParserCss.js @@ -4,7 +4,7 @@ const Selector = require('selector_manager/model/Selector'); module.exports = { run() { - describe('ParserCss', () => { + describe.only('ParserCss', () => { let obj; let config; let customParser; @@ -359,7 +359,7 @@ module.exports = { expect(obj.parse(str)).toEqual([result]); }); - test('Parse CSS with custom async parser', async () => { + test.skip('Parse CSS with custom async parser', async () => { var str = '.test1 { color:red }'; var result = { selectors: ['test1'], @@ -371,6 +371,88 @@ module.exports = { const cssResult = await obj.parse(str); expect(cssResult).toEqual([result]); }); + + test('Check node with font-face rule', () => { + const style = { + 'font-family': '"Glyphicons Halflings"', + src: + 'url("https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/fonts/glyphicons-halflings-regular.eot")' + }; + expect( + obj.checkNode({ + atRule: 'font-face', + selectors: '', + style: style + }) + ).toEqual([ + { + style: style, + atRuleType: 'font-face', + singleAtRule: 1, + selectors: [] + } + ]); + }); + + test('Check node with keyframes rule', () => { + const style = { opacity: 0 }; + expect( + obj.checkNode({ + atRule: 'keyframes', + params: 'name', + selectors: 'from', + style: style + }) + ).toEqual([ + { + selectors: [], + atRuleType: 'keyframes', + selectorsAdd: 'from', + style: style, + mediaText: 'name' + } + ]); + }); + + test('Check node with media rule', () => { + const style = { color: 'blue' }; + expect( + obj.checkNode({ + atRule: 'media', + params: 'screen and (min-width: 480px)', + selectors: '.class-test.class2:hover, div > span ', + style + }) + ).toEqual([ + { + atRuleType: 'media', + selectors: ['class-test', 'class2'], + selectorsAdd: 'div > span', + style: style, + state: 'hover', + mediaText: 'screen and (min-width: 480px)' + } + ]); + }); + + test('Check node with a rule containing id', () => { + const style = { color: 'blue' }; + expect( + obj.checkNode({ + selectors: '#main', + style: { border: '1px solid black' } + }) + ).toEqual([ + { + atRuleType: 'media', + selectors: ['class-test', 'class2'], + selectorsAdd: 'div > span', + style: style, + state: 'hover', + mediaText: 'screen and (min-width: 480px)' + } + ]); + }); }); } }; From baaaa6bbfdb98d9b6e68699df917a6b1cd89c03d Mon Sep 17 00:00:00 2001 From: Artur Arseniev Date: Fri, 14 Sep 2018 19:29:15 +0200 Subject: [PATCH 07/10] Update checkNode and add more tests --- src/parser/model/ParserCss.js | 7 +++- test/specs/parser/model/ParserCss.js | 59 +++++++++++++++++++++++----- 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/src/parser/model/ParserCss.js b/src/parser/model/ParserCss.js index 7dcdb0273..a6cc93e17 100644 --- a/src/parser/model/ParserCss.js +++ b/src/parser/model/ParserCss.js @@ -31,9 +31,9 @@ module.exports = (config = {}) => ({ const nodes = []; const selsParsed = parseSelector(selectors); const classSets = selsParsed.result; + const selectorsAdd = selsParsed.add.join(', '); const opts = { atRule: node.atRule, - selectorsAdd: selsParsed.add.join(', '), mediaText: node.params }; @@ -45,6 +45,11 @@ module.exports = (config = {}) => ({ nodes.push(createNode([], style, opts)); } + if (selectorsAdd) { + const lastNode = nodes[nodes.length - 1]; + lastNode.selectorsAdd = selectorsAdd; + } + node = nodes; } diff --git a/test/specs/parser/model/ParserCss.js b/test/specs/parser/model/ParserCss.js index 4db32157f..504eb049a 100644 --- a/test/specs/parser/model/ParserCss.js +++ b/test/specs/parser/model/ParserCss.js @@ -4,7 +4,7 @@ const Selector = require('selector_manager/model/Selector'); module.exports = { run() { - describe.only('ParserCss', () => { + describe('ParserCss', () => { let obj; let config; let customParser; @@ -436,20 +436,61 @@ module.exports = { }); test('Check node with a rule containing id', () => { - const style = { color: 'blue' }; + const style = { border: '1px solid black !important' }; expect( obj.checkNode({ - selectors: '#main', - style: { border: '1px solid black' } + selectors: '#main:hover', + style }) ).toEqual([ { - atRuleType: 'media', - selectors: ['class-test', 'class2'], - selectorsAdd: 'div > span', - style: style, + selectors: ['#main'], state: 'hover', - mediaText: 'screen and (min-width: 480px)' + style: style + } + ]); + }); + + test('Check node with multiple class selectors', () => { + const style = { + border: '1px solid black !important', + 'background-repeat': 'repeat-y, no-repeat' + }; + expect( + obj.checkNode({ + selectors: + '.class1, .class1.class2:hover, div > .test:hover, span.test2', + style + }) + ).toEqual([ + { + selectors: ['class1'], + style: style + }, + { + selectors: ['class1', 'class2'], + state: 'hover', + selectorsAdd: 'div > .test:hover, span.test2', + style: style + } + ]); + }); + + test('Check node with a rule containing CSS variables', () => { + const style = { + '--some-color': 'red', + '--some-width': '55px' + }; + expect( + obj.checkNode({ + selectors: ':root', + style + }) + ).toEqual([ + { + selectors: [], + selectorsAdd: ':root', + style: style } ]); }); From 217fbc1355a9a530a30ef885413e3f9f664848c0 Mon Sep 17 00:00:00 2001 From: Artur Arseniev Date: Fri, 14 Sep 2018 22:52:26 +0200 Subject: [PATCH 08/10] Implement a simple log event system --- src/editor/index.js | 20 ++++++++++++++++++++ src/editor/model/Editor.js | 12 ++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/editor/index.js b/src/editor/index.js index 5cc2c3b2f..dbeb84d6e 100644 --- a/src/editor/index.js +++ b/src/editor/index.js @@ -597,6 +597,26 @@ module.exports = config => { return this; }, + /** + * Trigger event log message + * @param {String} msg String to log + * @param {Object} [opts={}] Custom options + * @param {String} [opts.ns=''] Namespace of the log (eg. to use in plugins) + * @param {String} [opts.level='debug'] Level of the log, `debug`, `info`, `warning`, `error` + * @return {this} + * @example + * editor.log('Something done!', { ns: 'from-plugin-x', level: 'info' }); + * // This will trigger following events + * // `log`, `log:info`, `log-from-plugin-x`, `log-from-plugin-x:info` + * // Callbacks of those events will always receive the message and + * // options, as arguments, eg: + * // editor.on('log:info', (msg, opts) => console.info(msg, opts)) + */ + log(msg, opts = {}) { + em.log(msg, opts); + return this; + }, + /** * Attach event * @param {string} event Event name diff --git a/src/editor/model/Editor.js b/src/editor/model/Editor.js index 2c66604c0..e2bb71edb 100644 --- a/src/editor/model/Editor.js +++ b/src/editor/model/Editor.js @@ -621,6 +621,18 @@ module.exports = Backbone.Model.extend({ return !!this.get('editing'); }, + log(msg, opts = {}) { + const { ns, level = 'debug' } = opts; + this.trigger('log', msg, opts); + level && this.trigger(`log:${level}`, msg, opts); + + if (ns) { + const logNs = `log-${ns}`; + this.trigger(logNs, msg, opts); + level && this.trigger(`${logNs}:${level}`, msg, opts); + } + }, + /** * Set/get data from the HTMLElement * @param {HTMLElement} el From 6bcb55baf2596c6f6505ac8314f9ae7826b4f2f1 Mon Sep 17 00:00:00 2001 From: Artur Arseniev Date: Sat, 15 Sep 2018 00:16:24 +0200 Subject: [PATCH 09/10] Pass editor in custom CSS parser --- src/editor/index.js | 2 +- src/parser/model/ParserCss.js | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/editor/index.js b/src/editor/index.js index dbeb84d6e..ae8f97d88 100644 --- a/src/editor/index.js +++ b/src/editor/index.js @@ -599,7 +599,7 @@ module.exports = config => { /** * Trigger event log message - * @param {String} msg String to log + * @param {*} msg Message to log * @param {Object} [opts={}] Custom options * @param {String} [opts.ns=''] Namespace of the log (eg. to use in plugins) * @param {String} [opts.level='debug'] Level of the log, `debug`, `info`, `warning`, `error` diff --git a/src/parser/model/ParserCss.js b/src/parser/model/ParserCss.js index a6cc93e17..dd2edae96 100644 --- a/src/parser/model/ParserCss.js +++ b/src/parser/model/ParserCss.js @@ -12,8 +12,9 @@ module.exports = (config = {}) => ({ */ parse(str) { let result = []; - const customParser = config.parserCss; - const nodes = customParser ? customParser(str) : BrowserCssParser(str); + const { parserCss, em = {} } = config; + const editor = em && em.get('Editor'); + const nodes = parserCss ? parserCss(str, editor) : BrowserCssParser(str); nodes.forEach(node => (result = result.concat(this.checkNode(node)))); return result; From 1a4ecd6e0105bb604fa1e6e4f08a52fcc7f96b7d Mon Sep 17 00:00:00 2001 From: Artur Arseniev Date: Sat, 15 Sep 2018 01:22:20 +0200 Subject: [PATCH 10/10] Take in account singleAtRule in CSS generator --- src/code_manager/model/CssGenerator.js | 13 ++++++++++--- src/parser/model/ParserCss.js | 2 +- test/specs/grapesjs/index.js | 9 +++++---- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/code_manager/model/CssGenerator.js b/src/code_manager/model/CssGenerator.js index 5f01c4d29..cc210a776 100644 --- a/src/code_manager/model/CssGenerator.js +++ b/src/code_manager/model/CssGenerator.js @@ -72,9 +72,16 @@ module.exports = require('backbone').Model.extend({ for (let atRule in atRules) { let rulesStr = ''; const mRules = atRules[atRule]; - mRules.forEach( - rule => (rulesStr += this.buildFromRule(rule, dump, opts)) - ); + + mRules.forEach(rule => { + const ruleStr = this.buildFromRule(rule, dump, opts); + + if (rule.get('singleAtRule')) { + code += `${atRule}{${ruleStr}}`; + } else { + rulesStr += ruleStr; + } + }); if (rulesStr) { code += `${atRule}{${rulesStr}}`; diff --git a/src/parser/model/ParserCss.js b/src/parser/model/ParserCss.js index dd2edae96..10d8b3dd2 100644 --- a/src/parser/model/ParserCss.js +++ b/src/parser/model/ParserCss.js @@ -13,7 +13,7 @@ module.exports = (config = {}) => ({ parse(str) { let result = []; const { parserCss, em = {} } = config; - const editor = em && em.get('Editor'); + const editor = em && em.get && em.get('Editor'); const nodes = parserCss ? parserCss(str, editor) : BrowserCssParser(str); nodes.forEach(node => (result = result.concat(this.checkNode(node)))); diff --git a/test/specs/grapesjs/index.js b/test/specs/grapesjs/index.js index 76ea8bf64..e23a542d6 100644 --- a/test/specs/grapesjs/index.js +++ b/test/specs/grapesjs/index.js @@ -164,18 +164,19 @@ describe('GrapesJS', () => { ` ` + htmlString; const editor = obj.init(config); const css = editor.getCss(); const styles = editor.getStyle(); expect(styles.length).toEqual(2); + expect((css.match(/@font-face/g) || []).length).toEqual(2); }); test('Set components as HTML', () => {