Browse Source

Merge pull request #4214 from artf/dev

Merge dev
pull/4254/head
Artur Arseniev 5 years ago
committed by GitHub
parent
commit
0ca43b041f
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      CONTRIBUTING.md
  2. 2
      dist/css/grapes.min.css
  3. 4
      dist/grapes.min.js
  4. 2
      dist/grapes.min.js.map
  5. 1
      docs/api/storage_manager.md
  6. 5288
      index.d.ts
  7. 4
      package.json
  8. 6
      src/code_manager/model/CssGenerator.js
  9. 16
      src/code_manager/model/HtmlGenerator.js
  10. 18
      src/commands/view/ComponentDelete.js
  11. 3
      src/commands/view/SelectComponent.js
  12. 1
      src/common/index.js
  13. 4
      src/common/module.js
  14. 22
      src/css_composer/model/CssRule.js
  15. 4
      src/css_composer/model/CssRules.js
  16. 3
      src/dom_components/index.js
  17. 29
      src/dom_components/model/Component.js
  18. 86
      src/dom_components/model/ComponentLink.js
  19. 48
      src/dom_components/model/ComponentWrapper.js
  20. 7
      src/dom_components/view/ComponentTextView.js
  21. 7
      src/dom_components/view/ComponentWrapperView.js
  22. 11
      src/editor/config/config.js
  23. 23
      src/editor/index.js
  24. 12
      src/editor/model/Editor.js
  25. 6
      src/parser/model/ParserHtml.js
  26. 14
      src/rich_text_editor/model/RichTextEditor.js
  27. 8
      src/selector_manager/view/ClassTagsView.js
  28. 16
      src/storage_manager/config/config.js
  29. 1
      src/storage_manager/index.js
  30. 17
      src/style_manager/index.js
  31. 6
      src/styles/scss/_gjs_canvas.scss
  32. 2
      src/styles/scss/_gjs_rte.scss
  33. 99
      src/trait_manager/index.js
  34. 147
      src/trait_manager/model/Trait.js
  35. 23
      src/trait_manager/model/Traits.js
  36. 74
      test/specs/code_manager/model/CodeModels.js
  37. 4
      test/specs/dom_components/index.js
  38. 4
      test/specs/dom_components/model/Component.js
  39. 21
      test/specs/grapesjs/headless.js
  40. 95
      test/specs/grapesjs/index.js
  41. 222
      test/specs/parser/model/ParserHtml.js
  42. 29
      webpack.config.js
  43. 2250
      yarn.lock

2
CONTRIBUTING.md

@ -18,7 +18,7 @@ Working on your first Pull Request? You can learn how from this **free** series,
## Submitting code
Any code change should be submitted as a pull request. Before start working on something make always a search in opened issues and pull requests, this might help you to avoid waisting time.
Any code change should be submitted as a pull request. Before start working on something make always a search in opened issues and pull requests, this might help you to avoid wasting time.
A pull request could be a bug fix, new feature and much more, but in all cases, **open a new issue** and talk about what you want to do. Often happens to work on something already fixed (ready to release) or in progress.

2
dist/css/grapes.min.css

File diff suppressed because one or more lines are too long

4
dist/grapes.min.js

File diff suppressed because one or more lines are too long

2
dist/grapes.min.js.map

File diff suppressed because one or more lines are too long

1
docs/api/storage_manager.md

@ -44,7 +44,6 @@ storageManager.add(...);
* [setAutosave][4]
* [getStepsBeforeSave][5]
* [setStepsBeforeSave][6]
* [setStepsBeforeSave][6]
* [getStorages][7]
* [getCurrent][8]
* [getCurrentStorage][9]

5288
index.d.ts

File diff suppressed because it is too large

4
package.json

@ -1,7 +1,7 @@
{
"name": "grapesjs",
"description": "Free and Open Source Web Builder Framework",
"version": "0.18.3",
"version": "0.18.4",
"author": "Artur Arseniev",
"license": "BSD-3-Clause",
"homepage": "http://grapesjs.com",
@ -28,7 +28,7 @@
"@vuepress/plugin-google-analytics": "^1.8.2",
"documentation": "^13.2.5",
"eslint": "^7.32.0",
"grapesjs-cli": "^2.0.0",
"grapesjs-cli": "^3.0.0",
"husky": "^2.7.0",
"jest": "^24.9.0",
"lint-staged": "^8.2.1",

6
src/code_manager/model/CssGenerator.js

@ -27,17 +27,13 @@ export default Backbone.Model.extend({
const avoidInline = em && em.getConfig('avoidInlineStyle');
const style = model.styleToString();
const classes = model.get('classes');
const wrapperIsBody = opts.wrapperIsBody;
const isWrapper = model.get('wrapper');
this.ids.push(`#${model.getId()}`);
// Let's know what classes I've found
classes.each(model => this.compCls.push(model.getFullName()));
if (!avoidInline && style) {
let selector = `#${model.getId()}`;
selector = wrapperIsBody && isWrapper ? 'body' : selector;
code = `${selector}{${style}}`;
code = `#${model.getId()}{${style}}`;
}
const components = model.components();

16
src/code_manager/model/HtmlGenerator.js

@ -2,7 +2,6 @@ import { Model } from 'backbone';
export default class HTMLGenerator extends Model {
build(model, opts = {}) {
const models = model.components();
const htmlOpts = {};
const { em } = opts;
@ -33,19 +32,6 @@ export default class HTMLGenerator extends Model {
};
}
if (opts.exportWrapper) {
return model.toHTML({
...htmlOpts,
...(opts.wrapperIsBody && model.is('wrapper') && { tag: 'body' })
});
}
return this.buildModels(models, htmlOpts);
}
buildModels(models, opts = {}) {
let code = '';
models.forEach(mod => (code += mod.toHTML(opts)));
return code;
return model.toHTML(htmlOpts);
}
}

18
src/commands/view/ComponentDelete.js

@ -1,27 +1,23 @@
import { isArray } from 'underscore';
export default {
run(ed, sender, opts = {}) {
run(ed, s, opts = {}) {
const toSelect = [];
let components = opts.component || ed.getSelectedAll();
components = isArray(components) ? [...components] : [components];
// It's important to deselect components first otherwise,
// with undo, the component will be set with the wrong `collection`
ed.select(null);
components.forEach(component => {
if (!component || !component.get('removable')) {
components.filter(Boolean).forEach(component => {
if (!component.get('removable')) {
toSelect.push(component);
return this.em.logWarning('The element is not removable', {
component
component,
});
}
component.remove();
component.collection && toSelect.push(component);
});
toSelect.length && ed.select(toSelect);
ed.select(toSelect);
return components;
}
},
};

3
src/commands/view/SelectComponent.js

@ -89,7 +89,8 @@ export default {
.getFrames()
.forEach(frame => {
const { view } = frame;
view && trigger(view.getWindow(), view.getBody());
const win = view?.getWindow();
win && trigger(win, view.getBody());
});
},

1
src/common/index.js

@ -1,3 +1,4 @@
export { default as Model } from './Model';
export { default as Collection } from './Collection';
export { default as View } from './View';
export { default as Module } from './module';

4
src/common/module.js

@ -28,6 +28,7 @@ export default {
...conf,
};
this.em = this.config.em;
this.cls = [];
},
__initListen(opts = {}) {
@ -116,5 +117,8 @@ export default {
coll.reset();
});
this.em = 0;
this.config = 0;
this.view?.remove();
this.view = 0;
},
};

22
src/css_composer/model/CssRule.js

@ -36,7 +36,9 @@ export default class CssRule extends Model.extend(Styleable) {
singleAtRule: false,
important: false,
group: '',
_undo: true
// If true, won't be stored in JSON or showed in CSS
shallow: false,
_undo: true,
};
}
@ -115,15 +117,11 @@ export default class CssRule extends Model.extend(Styleable) {
selectorsToString(opts = {}) {
const result = [];
const state = this.get('state');
const wrapper = this.get('wrapper');
const addSelector = this.get('selectorsAdd');
const isBody = wrapper && opts.body;
const selOpts = {
escape: str => (CSS && CSS.escape ? CSS.escape(str) : str)
escape: str => (CSS && CSS.escape ? CSS.escape(str) : str),
};
const selectors = isBody
? 'body'
: this.get('selectors').getFullString(0, selOpts);
const selectors = this.get('selectors').getFullString(0, selOpts);
const stateStr = state && !opts.skipState ? `:${state}` : '';
selectors && result.push(`${selectors}${stateStr}`);
addSelector && !opts.skipAdd && result.push(addSelector);
@ -169,10 +167,7 @@ export default class CssRule extends Model.extend(Styleable) {
if (atRuleType !== 'media' || !mediaText) {
return deviceDefault || null;
}
return (
devices.filter(d => d.getWidthMedia() === getMediaLength(mediaText))[0] ||
null
);
return devices.filter(d => d.getWidthMedia() === getMediaLength(mediaText))[0] || null;
}
/**
@ -262,10 +257,7 @@ export default class CssRule extends Model.extend(Styleable) {
const wd = width || '';
const selAdd = ruleProps.selectorsAdd || '';
let atRule = ruleProps.atRuleType || '';
const sel =
!isArray(selectors) && !selectors.models
? [selectors]
: selectors.models || selectors;
const sel = !isArray(selectors) && !selectors.models ? [selectors] : selectors.models || selectors;
// Fix atRuleType in case is not specified with width
if (wd && !atRule) atRule = 'media';

4
src/css_composer/model/CssRules.js

@ -19,7 +19,7 @@ export default Collection.extend({
toJSON(opts) {
const result = Collection.prototype.toJSON.call(this, opts);
return result.filter(i => i.style);
return result.filter(rule => rule.style && !rule.shallow);
},
onAdd(model, c, o) {
@ -38,5 +38,5 @@ export default Collection.extend({
}
opt.em = this.editor;
return Collection.prototype.add.apply(this, [models, opt]);
}
},
});

3
src/dom_components/index.js

@ -58,6 +58,7 @@ import defaults from './config/config';
import Component, { keyUpdate, keyUpdateInside } from './model/Component';
import Components from './model/Components';
import ComponentView from './view/ComponentView';
import ComponentWrapperView from './view/ComponentWrapperView';
import ComponentsView from './view/ComponentsView';
import ComponentTableCell from './model/ComponentTableCell';
import ComponentTableCellView from './view/ComponentTableCellView';
@ -196,7 +197,7 @@ export default () => {
{
id: 'wrapper',
model: ComponentWrapper,
view: ComponentView,
view: ComponentWrapperView,
},
{
id: 'default',

29
src/dom_components/model/Component.js

@ -1064,36 +1064,29 @@ export default class Component extends Model.extend(Styleable) {
const model = this;
const ppfx = (em && em.getConfig('stylePrefix')) || '';
if (!model.get('toolbar')) {
var tb = [];
if (model.collection) {
if (!model.get('toolbar') && em) {
const tb = [];
model.collection &&
tb.push({
attributes: { class: 'fa fa-arrow-up' },
label: em.getIcon('arrowUp'),
command: ed => ed.runCommand('core:component-exit', { force: 1 }),
});
}
if (model.get('draggable')) {
model.get('draggable') &&
tb.push({
attributes: {
class: `fa fa-arrows ${ppfx}no-touch-actions`,
draggable: true,
},
//events: hasDnd(this.em) ? { dragstart: 'execCommand' } : '',
attributes: { class: `${ppfx}no-touch-actions`, draggable: true },
label: em.getIcon('move'),
command: 'tlb-move',
});
}
if (model.get('copyable')) {
model.get('copyable') &&
tb.push({
attributes: { class: 'fa fa-clone' },
label: em.getIcon('copy'),
command: 'tlb-clone',
});
}
if (model.get('removable')) {
model.get('removable') &&
tb.push({
attributes: { class: 'fa fa-trash-o' },
label: em.getIcon('delete'),
command: 'tlb-delete',
});
}
model.set('toolbar', tb);
}
}

86
src/dom_components/model/ComponentLink.js

@ -1,53 +1,41 @@
import Component from './ComponentText';
import { forEach } from 'underscore';
import { toLowerCase } from 'utils/mixins';
import ComponentText from './ComponentText';
export default Component.extend(
{
defaults: {
...Component.prototype.defaults,
type: 'link',
tagName: 'a',
traits: ['title', 'href', 'target']
},
/**
* Returns object of attributes for HTML
* @return {Object}
* @private
*/
getAttrToHTML(...args) {
const attr = Component.prototype.getAttrToHTML.apply(this, args);
delete attr.onmousedown;
return attr;
}
},
{
isComponent(el) {
let result;
if (toLowerCase(el.tagName) === 'a') {
result = {
type: 'link',
editable: 0
};
// The link is editable only if, at least, one of its
// children is a text node (not empty one)
const children = el.childNodes;
const len = children.length;
if (!len) delete result.editable;
for (let i = 0; i < len; i++) {
const child = children[i];
if (child.nodeType == 3 && child.textContent.trim() != '') {
delete result.editable;
break;
}
}
}
const type = 'link';
export default class ComponentLink extends ComponentText {}
ComponentLink.prototype.defaults = {
...ComponentText.getDefaults(),
type,
tagName: 'a',
traits: ['title', 'href', 'target'],
};
ComponentLink.isComponent = (el, opts = {}) => {
let result;
return result;
}
if (toLowerCase(el.tagName) === 'a') {
const textTags = opts.textTags || [];
result = { type, editable: false };
// The link is editable only if, at least, one of its
// children is a text node (not empty one)
const children = el.childNodes;
const len = children.length;
if (!len) delete result.editable;
forEach(children, child => {
const { tagName } = child;
if (
(child.nodeType == 3 && child.textContent.trim() !== '') ||
(tagName && textTags.indexOf(toLowerCase(tagName)) >= 0)
) {
delete result.editable;
}
});
}
);
return result;
};

48
src/dom_components/model/ComponentWrapper.js

@ -1,11 +1,10 @@
// We need this one just to identify better the wrapper type
import Component from './Component';
export default Component.extend(
{
defaults: {
...Component.prototype.defaults,
__wrapper: 1,
export default class ComponentWrapper extends Component {
defaults() {
return {
...Component.getDefaults(),
tagName: 'body',
removable: false,
copyable: false,
draggable: false,
@ -18,23 +17,22 @@ export default Component.extend(
'background-repeat',
'background-attachment',
'background-position',
'background-size'
]
},
__postAdd() {
const um = this.em && this.em.get('UndoManager');
um && !this.__hasUm && um.add(this);
return Component.prototype.__postAdd.call(this, arguments);
},
__postRemove() {
const um = this.em && this.em.get('UndoManager');
um && um.remove(this);
return Component.prototype.__postRemove.call(this, arguments);
}
},
{
isComponent() {
return false;
}
'background-size',
],
};
}
);
__postAdd() {
const um = this.em && this.em.get('UndoManager');
um && !this.__hasUm && um.add(this);
return Component.prototype.__postAdd.call(this, arguments);
}
__postRemove() {
const um = this.em && this.em.get('UndoManager');
um && um.remove(this);
return Component.prototype.__postRemove.call(this, arguments);
}
}
ComponentWrapper.isComponent = () => false;

7
src/dom_components/view/ComponentTextView.js

@ -43,7 +43,11 @@ export default ComponentView.extend({
parent = parent.parent();
}
delegate = parent;
if (parent && parent.get('editable')) {
delegate = parent;
} else {
result = true;
}
}
}
@ -62,6 +66,7 @@ export default ComponentView.extend({
// text components will not block the editing (#1394)
if (!result) {
if (delegate) {
ev?.stopPropagation?.();
em.setSelected(delegate);
delegate.trigger('active', ev);
}

7
src/dom_components/view/ComponentWrapperView.js

@ -0,0 +1,7 @@
import ComponentView from './ComponentView';
export default class ComponentWrapperView extends ComponentView {
tagName() {
return 'div';
}
}

11
src/editor/config/config.js

@ -114,12 +114,6 @@ export default {
// Enable multiple selection
multipleSelection: 1,
// Show the wrapper component in the final code, eg. in editor.getHtml()
exportWrapper: 0,
// The wrapper, if visible, will be shown as a `<body>`
wrapperIsBody: 1,
// Pass default available options wherever `editor.getHtml()` is called
optsHtml: {},
@ -173,6 +167,11 @@ export default {
move: '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M13,6V11H18V7.75L22.25,12L18,16.25V13H13V18H16.25L12,22.25L7.75,18H11V13H6V16.25L1.75,12L6,7.75V11H11V6H7.75L12,1.75L16.25,6H13Z"/></svg>',
plus: '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z" /></svg>',
caret: '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M7,10L12,15L17,10H7Z" /></svg>',
delete:
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z" /></svg>',
copy: '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z" /></svg>',
arrowUp:
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M13,20H11V8L5.5,13.5L4.08,12.08L12,4.16L19.92,12.08L18.5,13.5L13,8V20Z" /></svg>',
},
// Dom element

23
src/editor/index.js

@ -438,6 +438,18 @@ export default (config = {}, opts = {}) => {
return em.store(clb);
},
/**
* Get the JSON project data, which could be stored and loaded back with `editor.loadProject(json)`
* @returns {Object}
* @example
* @private
* console.log(editor.getProject());
* // { pages: [...], styles: [...], ... }
*/
getProject() {
return em.storeData();
},
/**
* Get the JSON data object, which could be stored and loaded back with `editor.loadData(json)`
* @returns {Object}
@ -458,6 +470,17 @@ export default (config = {}, opts = {}) => {
return em.load(clb);
},
/**
* Load data from the JSON project
* @param {Object} data Project to load
* @example
* @private
* editor.loadProject({ pages: [...], styles: [...], ... })
*/
loadProject(data) {
return em.loadData(data);
},
/**
* Load data from the JSON data object
* @param {Object} data Data to load

12
src/editor/model/Editor.js

@ -557,13 +557,11 @@ export default class EditorModel extends Model {
*/
getHtml(opts = {}) {
const { config } = this;
const { optsHtml, exportWrapper, wrapperIsBody } = config;
const { optsHtml } = config;
const js = config.jsInHtml ? this.getJs(opts) : '';
const cmp = opts.component || this.get('DomComponents').getComponent();
let html = cmp
? this.get('CodeManager').getCode(cmp, 'html', {
exportWrapper,
wrapperIsBody,
...optsHtml,
...opts,
})
@ -580,7 +578,7 @@ export default class EditorModel extends Model {
*/
getCss(opts = {}) {
const config = this.config;
const { optsCss, wrapperIsBody } = config;
const { optsCss } = config;
const avoidProt = opts.avoidProtected;
const keepUnusedStyles = !isUndefined(opts.keepUnusedStyles) ? opts.keepUnusedStyles : config.keepUnusedStyles;
const cssc = this.get('CssComposer');
@ -590,7 +588,6 @@ export default class EditorModel extends Model {
wrp &&
this.get('CodeManager').getCode(wrp, 'css', {
cssc,
wrapperIsBody,
keepUnusedStyles,
...optsCss,
...opts,
@ -782,6 +779,11 @@ export default class EditorModel extends Model {
return (this.getCurrentFrame() || {}).model;
}
getIcon(icon) {
const icons = this.getConfig('icons') || {};
return icons[icon] || '';
}
/**
* Return the count of changes made to the content and not yet stored.
* This count resets at any `store()`

6
src/parser/model/ParserHtml.js

@ -140,7 +140,9 @@ export default config => {
// Set tag name if not yet done
if (!model.tagName) {
model.tagName = node.tagName ? node.tagName.toLowerCase() : '';
const tag = node.tagName || '';
const ns = node.namespaceURI || '';
model.tagName = tag && ns === 'http://www.w3.org/1999/xhtml' ? tag.toLowerCase() : tag;
}
if (attrsLen) {
@ -316,7 +318,7 @@ export default config => {
}
em && em.trigger(`${event}:root`, { input: str, root: el });
const result = this.parseNode(el);
const result = this.parseNode(el, cf);
// I have to keep it otherwise it breaks the DomComponents.addComponent (returns always array)
const resHtml = result.length === 1 && !c.returnArray ? result[0] : result;
res.html = resHtml;

14
src/rich_text_editor/model/RichTextEditor.js

@ -92,6 +92,7 @@ export default class RichTextEditor {
this.setEl(el);
this.updateActiveActions = this.updateActiveActions.bind(this);
this.__onKeydown = this.__onKeydown.bind(this);
this.__onPaste = this.__onPaste.bind(this);
const acts = (settings.actions || []).map(action => {
let result = action;
@ -201,6 +202,7 @@ export default class RichTextEditor {
el.contentEditable = !!enable;
method(el, 'mouseup keyup', this.updateActiveActions);
method(doc, 'keydown', this.__onKeydown);
method(doc, 'paste', this.__onPaste);
this.enabled = enable;
if (enable) {
@ -237,6 +239,18 @@ export default class RichTextEditor {
}
}
__onPaste(ev) {
const clipboardData = ev.clipboardData || window.clipboardData;
const text = clipboardData.getData('text');
const textHtml = clipboardData.getData('text/html');
// Replace \n with <br> in case of plain text
if (text && !textHtml) {
ev.preventDefault();
const html = text.replace(/(?:\r\n|\r|\n)/g, '<br/>');
this.doc.execCommand('insertHTML', false, html);
}
}
/**
* Sync actions with the current RTE
*/

8
src/selector_manager/view/ClassTagsView.js

@ -154,8 +154,12 @@ export default Backbone.View.extend({
* @private
*/
onInputKeyUp(e) {
if (e.keyCode === 13) this.addNewTag(this.$input.val());
else if (e.keyCode === 27) this.endNewTag();
if (e.keyCode === 13) {
e.preventDefault();
this.addNewTag(this.$input.val());
} else if (e.keyCode === 27) {
this.endNewTag();
}
},
checkStates() {

16
src/storage_manager/config/config.js

@ -3,10 +3,10 @@ export default {
id: 'gjs-',
// Enable/Disable autosaving
autosave: 1,
autosave: true,
// Indicates if load data inside editor after init
autoload: 1,
autoload: true,
// Indicates which storage to use. Available: local | remote
type: 'local',
@ -16,20 +16,20 @@ export default {
stepsBeforeSave: 1,
//Enable/Disable components model (JSON format)
storeComponents: 1,
storeComponents: true,
//Enable/Disable styles model (JSON format)
storeStyles: 1,
storeStyles: true,
//Enable/Disable saving HTML template
storeHtml: 1,
storeHtml: true,
//Enable/Disable saving CSS template
storeCss: 1,
storeCss: true,
// ONLY FOR LOCAL STORAGE
// If enabled, checks if browser supports Local Storage
checkLocal: 1,
checkLocal: true,
// ONLY FOR REMOTE STORAGE
// Custom parameters to pass with the remote storage request, eg. csrf token
@ -63,5 +63,5 @@ export default {
// currentOpts => {
// return currentOpts.method === 'post' ? { method: 'patch' } : {};
// }
fetchOptions: ''
fetchOptions: '',
};

1
src/storage_manager/index.js

@ -38,7 +38,6 @@
* * [setAutosave](#setautosave)
* * [getStepsBeforeSave](#getstepsbeforesave)
* * [setStepsBeforeSave](#setstepsbeforesave)
* * [setStepsBeforeSave](#setstepsbeforesave)
* * [getStorages](#getstorages)
* * [getCurrent](#getcurrent)
* * [getCurrentStorage](#getcurrentstorage)

17
src/style_manager/index.js

@ -63,7 +63,7 @@
* @module StyleManager
*/
import { isElement, isUndefined, isArray, isString, debounce } from 'underscore';
import { isUndefined, isArray, isString, debounce, bindAll } from 'underscore';
import { isComponent } from 'utils/mixins';
import Module from 'common/module';
import { Model } from 'common';
@ -127,6 +127,7 @@ export default () => {
* @private
*/
init(config = {}) {
bindAll(this, '__clearStateTarget');
this.__initConfig(defaults, config);
const c = this.config;
const { em } = c;
@ -145,6 +146,8 @@ export default () => {
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);
// Clear state target on any component selection change, without debounce (#4208)
model.listenTo(em, 'component:toggled', this.__clearStateTarget);
// Triggers only for properties (avoid selection refresh)
const upProps = debounce(() => {
@ -176,6 +179,16 @@ export default () => {
this.em.trigger(event, ...data);
},
__clearStateTarget() {
const { em } = this;
const stateTarget = this.__getStateTarget();
stateTarget &&
em?.skip(() => {
em.get('CssComposer').remove(stateTarget);
this.model.set({ stateTarget: null });
});
},
onLoad() {
// Use silent as sectors' view will be created and rendered on StyleManager.render
sectors.add(this.config.sectors, { silent: true });
@ -367,7 +380,7 @@ export default () => {
if (state && lastTarget?.getState?.()) {
const style = lastTarget.getStyle();
if (!stateTarget) {
stateTarget = cssc.getAll().add({ selectors: 'gjs-selected', style, important: true });
stateTarget = cssc.getAll().add({ selectors: 'gjs-selected', style, shallow: true, important: true });
} else {
stateTarget.setStyle(style);
}

6
src/styles/scss/_gjs_canvas.scss

@ -304,11 +304,9 @@ $guide_pad: 5px !default;
}
.#{$app-prefix}toolbar-item {
padding: 5px 7px;
font-size: 0.8rem;
cursor: pointer;
width: 26px;
vertical-align: middle;
padding: 5px;
cursor: pointer;
display: inline-block;
svg {

2
src/styles/scss/_gjs_rte.scss

@ -17,7 +17,7 @@
align-items: center;
justify-content: center;
padding: 5px;
min-width: 25px;
width: 25px;
border-right: 1px solid $mainDkColor;
text-align: center;
cursor: pointer;

99
src/trait_manager/index.js

@ -1,5 +1,5 @@
import { defaults, isElement } from 'underscore';
import defaultOpts from './config/config';
import { debounce } from 'underscore';
import defaults from './config/config';
import TraitsView from './view/TraitsView';
import TraitView from './view/TraitView';
import TraitSelectView from './view/TraitSelectView';
@ -7,22 +7,32 @@ import TraitCheckboxView from './view/TraitCheckboxView';
import TraitNumberView from './view/TraitNumberView';
import TraitColorView from './view/TraitColorView';
import TraitButtonView from './view/TraitButtonView';
import { Model, Module } from 'common';
export const evAll = 'trait';
export const evPfx = `${evAll}:`;
export const evCustom = `${evPfx}custom`;
export default () => {
let c = {};
let TraitsViewer;
const typesDef = {
text: TraitView,
number: TraitNumberView,
select: TraitSelectView,
checkbox: TraitCheckboxView,
color: TraitColorView,
button: TraitButtonView
button: TraitButtonView,
};
return {
...Module,
TraitsView,
events: {
all: evAll,
custom: evCustom,
},
/**
* Name of the module
* @type {String}
@ -32,33 +42,64 @@ export default () => {
/**
* Get configuration object
* @name getConfig
* @function
* @return {Object}
* @private
*/
getConfig() {
return c;
},
/**
* Initialize module. Automatically called with a new instance of the editor
* @param {Object} config Configurations
* @private
*/
init(config = {}) {
c = config;
defaults(c, defaultOpts);
this.__initConfig(defaults, config);
const c = this.config;
const model = new Model();
this.model = model;
const { em } = this;
const ppfx = c.pStylePrefix;
this.types = { ...typesDef };
ppfx && (c.stylePrefix = `${ppfx}${c.stylePrefix}`);
const upAll = debounce(() => this.__upSel());
model.listenTo(em, 'component:toggled', upAll);
const update = debounce(() => this.__onUp());
model.listenTo(em, 'trait:update', update);
return this;
},
postRender() {
const elTo = this.getConfig().appendTo;
__upSel() {
this.select(this.em.getSelected());
},
if (elTo) {
const el = isElement(elTo) ? elTo : document.querySelector(elTo);
el.appendChild(this.render());
}
__onUp() {
this.select(this.getSelected());
},
select(component) {
const traits = component ? component.getTraits() : [];
this.model.set({ component, traits });
this.__trgCustom();
},
getSelected() {
return this.model.get('component') || null;
},
getCurrent() {
return this.model.get('traits') || [];
},
__trgCustom(opts = {}) {
this.__ctn = this.__ctn || opts.container;
this.em.trigger(this.events.custom, { container: this.__ctn });
},
postRender() {
this.__appendTo();
},
/**
@ -67,7 +108,7 @@ export default () => {
* @private
*/
getTraitsViewer() {
return TraitsViewer;
return this.view;
},
/**
@ -98,21 +139,23 @@ export default () => {
},
render() {
const el = TraitsViewer && TraitsViewer.el;
TraitsViewer = new TraitsView({
let { view } = this;
const config = this.getConfig();
const el = view && view.el;
view = new TraitsView({
el,
collection: [],
editor: c.em,
config: c
editor: config.em,
config,
});
TraitsViewer.itemsView = this.getTypes();
TraitsViewer.updatedCollection();
return TraitsViewer.el;
view.itemsView = this.getTypes();
view.updatedCollection();
this.view = view;
return view.el;
},
destroy() {
TraitsViewer && TraitsViewer.remove();
[c, TraitsViewer].forEach(i => (i = {}));
}
this.__destroy();
},
};
};

147
src/trait_manager/model/Trait.js

@ -1,55 +1,108 @@
import Backbone from 'backbone';
import { Model } from 'common';
import { isUndefined } from 'underscore';
export default Backbone.Model.extend({
defaults: {
type: 'text', // text, number, range, select
label: '',
name: '',
min: '',
max: '',
unit: '',
step: 1,
value: '',
target: '',
default: '',
placeholder: '',
changeProp: 0,
options: []
},
/**
* @typedef Trait
* @property {String} id Trait id, eg. `my-trait-id`.
* @property {String} type Trait type, defines how the trait should rendered. Possible values: `text` (default), `number`, `select`, `checkbox`, `color`, `button`
* @property {String} label The trait label to show for the rendered trait.
* @property {String} name The name of the trait used as a key for the attribute/property. By default, the name is used as attribute name or property in case `changeProp` in enabled.
* @property {Boolean} changeProp If `true` the trait value is applied on component
*
*/
export default class Trait extends Model {
initialize() {
const target = this.get('target');
const name = this.get('name');
const changeProp = this.get('changeProp');
const { target, name, changeProp } = this.attributes;
!this.get('id') && this.set('id', name);
if (target) {
this.target = target;
this.unset('target');
const targetEvent = changeProp
? `change:${name}`
: `change:attributes:${name}`;
const targetEvent = changeProp ? `change:${name}` : `change:attributes:${name}`;
this.listenTo(target, targetEvent, this.targetUpdated);
}
},
}
/**
* Get the trait id.
* @returns {String}
*/
getId() {
return this.get('id');
}
/**
* Get the trait type.
* @returns {String}
*/
getType() {
return this.get('type');
}
/**
* Get the trait name.
* @returns {String}
*/
getName() {
return this.get('name');
}
/**
* Get the trait label.
* @param {Object} [opts={}] Options.
* @param {Boolean} [opts.locale=true] Use the locale string from i18n module.
* @returns {String}
*/
getLabel(opts = {}) {
const { locale = true } = opts;
const id = this.getId();
const name = this.get('label') || this.getName();
return (locale && this.em?.t(`traitManager.traits.labels.${id}`)) || name;
}
/**
* Get the trait value.
* The value is taken from component attributes by default or from properties if the trait has the `changeProp` enabled.
* @returns {any}
*/
getValue() {
return this.getTargetValue();
}
/**
* Return all the propeties
* @returns {Object}
* Update the trait value.
* The value is applied on component attributes by default or on properties if the trait has the `changeProp` enabled.
* @param {any} value Value of the trait.
* @param {Object} [opts={}] Options.
* @param {Boolean} [opts.partial] If `true` the update won't be considered complete (not stored in UndoManager).
*/
setValue(value, opts = {}) {
const valueOpts = {};
if (opts.partial) {
valueOpts.avoidStore = true;
}
this.setTargetValue(value, valueOpts);
if (opts.partial === false) {
this.setTargetValue('');
this.setTargetValue(value);
}
}
props() {
return this.attributes;
},
}
targetUpdated() {
const value = this.getTargetValue();
this.set({ value }, { fromTarget: 1 });
},
getValue() {
return this.getTargetValue();
},
this.em?.trigger('trait:update', {
trait: this,
component: this.target,
});
}
getTargetValue() {
const name = this.get('name');
@ -63,7 +116,7 @@ export default Backbone.Model.extend({
}
return !isUndefined(value) ? value : '';
},
}
setTargetValue(value, opts = {}) {
const target = this.target;
@ -84,7 +137,7 @@ export default Backbone.Model.extend({
attrs[name] = valueToSet;
target.set('attributes', attrs, opts);
}
},
}
setValueFromInput(value, final = 1, opts = {}) {
const toSet = { value };
@ -95,12 +148,8 @@ export default Backbone.Model.extend({
this.set('value', '', opts);
this.set(toSet, opts);
}
},
}
/**
* Get the initial value of the trait
* @return {string}
*/
getInitValue() {
const target = this.target;
const name = this.get('name');
@ -113,4 +162,20 @@ export default Backbone.Model.extend({
return value || this.get('value') || this.get('default');
}
});
}
Trait.prototype.defaults = {
type: 'text',
label: '',
name: '',
min: '',
max: '',
unit: '',
step: 1,
value: '',
target: '',
default: '',
placeholder: '',
changeProp: 0,
options: [],
};

23
src/trait_manager/model/Traits.js

@ -1,32 +1,31 @@
import Backbone from 'backbone';
import { Collection } from 'common';
import { isString, isArray } from 'underscore';
import Trait from './Trait';
import TraitFactory from './TraitFactory';
export default Backbone.Collection.extend({
model: Trait,
export default class Traits extends Collection {
initialize(coll, options = {}) {
this.em = options.em || '';
this.em = options.em;
this.listenTo(this, 'add', this.handleAdd);
this.listenTo(this, 'reset', this.handleReset);
},
}
handleReset(coll, { previousModels = [] } = {}) {
previousModels.forEach(model => model.trigger('remove'));
},
}
handleAdd(model) {
model.em = this.em;
const target = this.target;
if (target) {
model.target = target;
}
},
}
setTarget(target) {
this.target = target;
},
}
add(models, opt) {
const em = this.em;
@ -49,6 +48,8 @@ export default Backbone.Collection.extend({
}
}
return Backbone.Collection.prototype.add.apply(this, [models, opt]);
return Collection.prototype.add.apply(this, [models, opt]);
}
});
}
Traits.prototype.model = Trait;

74
test/specs/code_manager/model/CodeModels.js

@ -20,7 +20,7 @@ describe('HtmlGenerator', () => {
{},
{
em,
componentTypes: dcomp.componentTypes
componentTypes: dcomp.componentTypes,
}
);
});
@ -30,41 +30,37 @@ describe('HtmlGenerator', () => {
});
test('Build correctly one component', () => {
expect(obj.build(comp)).toEqual('');
expect(obj.build(comp)).toEqual('<div></div>');
});
test('Build correctly empty component inside', () => {
var m1 = comp.get('components').add({});
expect(obj.build(comp)).toEqual('<div></div>');
comp.get('components').add({});
expect(obj.build(comp)).toEqual('<div><div></div></div>');
});
test('Build correctly not empty component inside', () => {
var m1 = comp.get('components').add({
const m1 = comp.get('components').add({
tagName: 'article',
attributes: {
'data-test1': 'value1',
'data-test2': 'value2'
}
'data-test2': 'value2',
},
});
expect(obj.build(comp)).toEqual(
'<article data-test1="value1" data-test2="value2"></article>'
);
expect(obj.build(m1)).toEqual('<article data-test1="value1" data-test2="value2"></article>');
});
test('Build correctly component with classes', () => {
var m1 = comp.get('components').add({
const m1 = comp.get('components').add({
tagName: 'article',
attributes: {
'data-test1': 'value1',
'data-test2': 'value2'
}
'data-test2': 'value2',
},
});
['class1', 'class2'].forEach(item => {
m1.get('classes').add({ name: item });
});
expect(obj.build(comp)).toEqual(
'<article data-test1="value1" data-test2="value2" class="class1 class2"></article>'
);
expect(obj.build(m1)).toEqual('<article data-test1="value1" data-test2="value2" class="class1 class2"></article>');
});
});
@ -82,7 +78,7 @@ describe('CssGenerator', () => {
{},
{
em,
componentTypes: dcomp.componentTypes
componentTypes: dcomp.componentTypes,
}
);
});
@ -105,12 +101,10 @@ describe('CssGenerator', () => {
tagName: 'article',
style: {
prop1: 'value1',
prop2: 'value2'
}
prop2: 'value2',
},
});
expect(obj.build(comp)).toEqual(
'#' + m1.getId() + '{prop1:value1;prop2:value2;}'
);
expect(obj.build(comp)).toEqual('#' + m1.getId() + '{prop1:value1;prop2:value2;}');
});
test('Build correctly component with class styled', () => {
@ -121,9 +115,7 @@ describe('CssGenerator', () => {
var rule = cssc.add(cls1);
rule.set('style', { prop1: 'value1', prop2: 'value2' });
expect(obj.build(comp, { cssc })).toEqual(
'.class1{prop1:value1;prop2:value2;}'
);
expect(obj.build(comp, { cssc })).toEqual('.class1{prop1:value1;prop2:value2;}');
});
test('Build correctly component styled with class and state', () => {
@ -135,9 +127,7 @@ describe('CssGenerator', () => {
rule.set('style', { prop1: 'value1', prop2: 'value2' });
rule.set('state', 'hover');
expect(obj.build(comp, { cssc })).toEqual(
'.class1:hover{prop1:value1;prop2:value2;}'
);
expect(obj.build(comp, { cssc })).toEqual('.class1:hover{prop1:value1;prop2:value2;}');
});
test('Build correctly with more classes', () => {
@ -149,9 +139,7 @@ describe('CssGenerator', () => {
var rule = cssc.add([cls1, cls2]);
rule.set('style', { prop1: 'value1', prop2: 'value2' });
expect(obj.build(comp, { cssc })).toEqual(
'.class1.class2{prop1:value1;prop2:value2;}'
);
expect(obj.build(comp, { cssc })).toEqual('.class1.class2{prop1:value1;prop2:value2;}');
});
test('Build rules with mixed classes', () => {
@ -175,9 +163,7 @@ describe('CssGenerator', () => {
rule.set('style', { prop1: 'value1', prop2: 'value2' });
rule.set('selectorsAdd', '.class1 .class2, div > .class4');
expect(obj.build(comp, { cssc })).toEqual(
'.class1 .class2, div > .class4{prop1:value1;prop2:value2;}'
);
expect(obj.build(comp, { cssc })).toEqual('.class1 .class2, div > .class4{prop1:value1;prop2:value2;}');
});
test('Build correctly with class styled out', () => {
@ -191,9 +177,7 @@ describe('CssGenerator', () => {
var rule2 = cssc.add(cls2);
rule2.set('style', { prop2: 'value2' });
expect(obj.build(comp, { cssc })).toEqual(
'.class1.class2{prop1:value1;}.class2{prop2:value2;}'
);
expect(obj.build(comp, { cssc })).toEqual('.class1.class2{prop1:value1;}.class2{prop2:value2;}');
});
test('Rule with media query', () => {
@ -206,9 +190,7 @@ describe('CssGenerator', () => {
rule.set('style', { prop1: 'value1' });
rule.set('mediaText', '(max-width: 999px)');
expect(obj.build(comp, { cssc })).toEqual(
'@media (max-width: 999px){.class1.class2{prop1:value1;}}'
);
expect(obj.build(comp, { cssc })).toEqual('@media (max-width: 999px){.class1.class2{prop1:value1;}}');
});
test('Rules mixed with media queries', () => {
@ -263,7 +245,7 @@ describe('CssGenerator', () => {
{},
{
em,
componentTypes: dcomp.componentTypes
componentTypes: dcomp.componentTypes,
}
);
comp.setStyle({ color: 'red' });
@ -299,7 +281,7 @@ describe('CssGenerator', () => {
[
['@media (max-width: 999px)', 999],
['@media (min-width: 123%)', 123],
['@media (min-width: 1040rem)', 1040]
['@media (min-width: 1040rem)', 1040],
].forEach(item => {
expect(obj.getQueryLength(item[0])).toBe(item[1]);
});
@ -312,14 +294,14 @@ describe('CssGenerator', () => {
'@font-face': 2,
'@media (max-width: 768px)': 3,
'@media (max-width: 1020ch)': 4,
'@media (max-width: 10%)': 5
'@media (max-width: 10%)': 5,
})
).toEqual([
{ key: '@font-face', value: 2 },
{ key: '@media (max-width: 1020ch)', value: 4 },
{ key: '@media (max-width: 768px)', value: 3 },
{ key: '@media (max-width: 480px)', value: 1 },
{ key: '@media (max-width: 10%)', value: 5 }
{ key: '@media (max-width: 10%)', value: 5 },
]);
});
@ -330,14 +312,14 @@ describe('CssGenerator', () => {
'@font-face': 2,
'@media (min-width: 768px)': 3,
'@media (min-width: 1020ch)': 4,
'@media (min-width: 10%)': 5
'@media (min-width: 10%)': 5,
})
).toEqual([
{ key: '@font-face', value: 2 },
{ key: '@media (min-width: 10%)', value: 5 },
{ key: '@media (min-width: 480px)', value: 1 },
{ key: '@media (min-width: 768px)', value: 3 },
{ key: '@media (min-width: 1020ch)', value: 4 }
{ key: '@media (min-width: 1020ch)', value: 4 },
]);
});
});

4
test/specs/dom_components/index.js

@ -152,12 +152,12 @@ describe('DOM Components', () => {
obj = em.get('DomComponents');
const cc = em.get('CssComposer');
const id = 'idtest';
const comp = obj.addComponent(`
const component = obj.addComponent(`
<div id="${id}" style="color:red; padding: 50px 100px">Text</div>
<style>
#${id} { background-color: red }
</style>`);
expect(em.getHtml()).toEqual(`<div id="${id}">Text</div>`);
expect(em.getHtml({ component })).toEqual(`<div id="${id}">Text</div>`);
expect(obj.getComponents().length).toEqual(1);
obj.getComponents().first().addStyle({ margin: '10px' });
expect(cc.getAll().length).toEqual(1);

4
test/specs/dom_components/model/Component.js

@ -547,13 +547,13 @@ describe('Link Component', () => {
<div>text</div>
<div>here </div>`;
obj = ComponentLink.isComponent(aEl);
expect(obj).toEqual({ type: 'link', editable: 0 });
expect(obj).toEqual({ type: 'link', editable: false });
});
test('Link element with only an image inside is not editable', () => {
aEl.innerHTML = '<img src="##"/>';
obj = ComponentLink.isComponent(aEl);
expect(obj).toEqual({ type: 'link', editable: 0 });
expect(obj).toEqual({ type: 'link', editable: false });
});
});

21
test/specs/grapesjs/headless.js

@ -13,12 +13,13 @@ describe('GrapesJS Headless', () => {
let editor;
const cmpObj = {
attributes: { class: 'cls', test: 'value' },
components: { type: 'textnode', content: 'Test' }
components: { type: 'textnode', content: 'Test' },
};
const cmpStr = '<div class="cls" test="value">Test</div>';
const fullHtml = `<body>${cmpStr}</body>`;
const styleObj = {
selectors: [{ name: 'cls' }],
style: { color: 'red' }
style: { color: 'red' },
};
const styleStr = '.cls{color:red;}';
@ -37,7 +38,7 @@ describe('GrapesJS Headless', () => {
expect(comp.toHTML()).toBe(cmpStr);
expect(editor.Selectors.getAll().length).toBe(1); // 1 selector is created
expect(editor.Css.getAll().length).toBe(0); // No CSS
expect(editor.getHtml()).toBe(cmpStr);
expect(editor.getHtml()).toBe(fullHtml);
expect(editor.getCss()).toBe(''); // same as default
});
@ -55,10 +56,10 @@ describe('GrapesJS Headless', () => {
copyable: false,
selectable: true,
content: 'Hello!',
_innertext: false
}
]
}
_innertext: false,
},
],
},
]);
expect(res.length).toBe(1);
const resHtml = '<h1>Hello!</h1>';
@ -66,7 +67,7 @@ describe('GrapesJS Headless', () => {
expect(comp.toHTML()).toBe(resHtml);
expect(editor.Selectors.getAll().length).toBe(0);
expect(editor.Css.getAll().length).toBe(0);
expect(editor.getHtml()).toBe(resHtml);
expect(editor.getHtml()).toBe(`<body>${resHtml}</body>`);
expect(editor.getCss()).toBe('');
});
@ -81,10 +82,10 @@ describe('GrapesJS Headless', () => {
test('Load data', () => {
editor.loadData({
components: [cmpObj],
styles: [styleObj]
styles: [styleObj],
});
expect(editor.Selectors.getAll().length).toBe(1);
expect(editor.getHtml()).toBe(cmpStr);
expect(editor.getHtml()).toBe(fullHtml);
expect(editor.getCss()).toBe(styleStr);
});
});

95
test/specs/grapesjs/index.js

@ -18,7 +18,7 @@ describe('GrapesJS', () => {
},
load(keys, clb) {
return clb(storage);
}
},
};
beforeAll(() => {
@ -27,16 +27,17 @@ describe('GrapesJS', () => {
beforeEach(() => {
storage = {};
htmlString = '<div class="test1"></div><div class="test2"></div>';
const initHtml = '<div class="test1"></div><div class="test2"></div>';
htmlString = `<body>${initHtml}</body>`;
cssString = '.test2{color:red}.test3{color:blue}';
documentEl = '<style>' + cssString + '</style>' + htmlString;
documentEl = '<style>' + cssString + '</style>' + initHtml;
config = {
container: '#' + editorName,
storageManager: {
autoload: 0,
autosave: 0,
type: 0
}
type: 0,
},
};
obj = grapesjs;
document.body.innerHTML = `<div id="fixtures"><div id="${editorName}"></div></div>`;
@ -67,19 +68,19 @@ describe('GrapesJS', () => {
container: document.createElement('div'),
storageManager: {
autoload: 0,
type: 'none'
}
type: 'none',
},
};
var editor = obj.init(configAlt);
expect(editor).toBeTruthy();
});
test('New editor is empty', () => {
var editor = obj.init(config);
var html = editor.getHtml();
//var css = editor.getCss();
var protCss = editor.getConfig().protectedCss;
expect(html ? html : '').toBeFalsy();
const editor = obj.init(config);
const html = editor.getHtml();
//const css = editor.getCss();
const protCss = editor.getConfig().protectedCss;
expect(html).toBe('<body></body>');
//expect((css ? css : '')).toEqual(protCss);
expect(editor.getComponents().length).toEqual(0);
expect(editor.getStyle().length).toEqual(0);
@ -93,9 +94,7 @@ describe('GrapesJS', () => {
const body = editor.Canvas.getBody();
expect(body.outerHTML).toContain(config.baseCss);
expect(body.outerHTML.replace(/\s+/g, ` `)).not.toContain(
`body { margin: 0;`
);
expect(body.outerHTML.replace(/\s+/g, ` `)).not.toContain(`body { margin: 0;`);
});
test('Editor canvas baseCSS defaults to sensible values if not defined', () => {
@ -104,9 +103,7 @@ describe('GrapesJS', () => {
var editor = obj.init(config);
expect(
window.frames[0].document.documentElement.outerHTML.replace(/\s+/g, ` `)
).toContain(`body { margin: 0;`);
expect(window.frames[0].document.documentElement.outerHTML.replace(/\s+/g, ` `)).toContain(`body { margin: 0;`);
});
test('Init editor with html', () => {
@ -114,13 +111,7 @@ describe('GrapesJS', () => {
var editor = obj.init(config);
var comps = editor.DomComponents.getComponents();
expect(comps.length).toEqual(2);
expect(
comps
.at(0)
.get('classes')
.at(0)
.get('name')
).toEqual('test1');
expect(comps.at(0).get('classes').at(0).get('name')).toEqual('test1');
});
test('Init editor with css', () => {
@ -128,13 +119,7 @@ describe('GrapesJS', () => {
var editor = obj.init(config);
var rules = editor.CssComposer.getAll();
expect(rules.length).toEqual(2);
expect(
rules
.at(0)
.get('selectors')
.at(0)
.get('name')
).toEqual('test2');
expect(rules.at(0).get('selectors').at(0).get('name')).toEqual('test2');
});
test('Init editor from element', () => {
@ -193,13 +178,7 @@ describe('GrapesJS', () => {
editor.setStyle(cssString);
var styles = editor.getStyle();
expect(styles.length).toEqual(2);
expect(
styles
.at(1)
.get('selectors')
.at(0)
.get('name')
).toEqual('test3');
expect(styles.at(1).get('selectors').at(0).get('name')).toEqual('test3');
});
test('Set style as as array of objects', () => {
@ -207,20 +186,12 @@ describe('GrapesJS', () => {
editor.setStyle([{ selectors: ['test4'] }, { selectors: ['test5'] }]);
var styles = editor.getStyle();
expect(styles.length).toEqual(2);
expect(
styles
.at(1)
.get('selectors')
.at(0)
.get('name')
).toEqual('test5');
expect(styles.at(1).get('selectors').at(0).get('name')).toEqual('test5');
});
test.skip('Adds new storage as plugin and store data there', done => {
const pluginName = storageId + '-p2';
obj.plugins.add(pluginName, e =>
e.StorageManager.add(storageId, storageMock)
);
obj.plugins.add(pluginName, e => e.StorageManager.add(storageId, storageMock));
config.storageManager.type = storageId;
config.plugins = [pluginName];
const editor = obj.init(config);
@ -239,26 +210,24 @@ describe('GrapesJS', () => {
const style = [
{
selectors: [{ name: 'sclass1' }],
style: { color: 'green' }
style: { color: 'green' },
},
{
selectors: [{ name: 'test2' }],
style: styleResult
style: styleResult,
},
{
selectors: [{ name: 'test3' }],
style: { color: 'black', display: 'block' }
}
style: { color: 'black', display: 'block' },
},
];
storage = {
css: '* { box-sizing: border-box; } body {margin: 0;}',
styles: JSON.stringify(style)
styles: JSON.stringify(style),
};
const pluginName = storageId + '-p';
obj.plugins.add(pluginName, e =>
e.StorageManager.add(storageId, storageMock)
);
obj.plugins.add(pluginName, e => e.StorageManager.add(storageId, storageMock));
config.fromElement = 1;
config.storageManager.type = storageId;
config.plugins = [pluginName];
@ -326,7 +295,7 @@ describe('GrapesJS', () => {
editor.Commands.add('test-command', {
run(ed, caller, opts) {
ed.testVal = ed.getHtml() + opts.val;
}
},
});
editor.runCommand('test-command', { val: 5 });
expect(editor.testVal).toEqual(htmlString + '5');
@ -339,7 +308,7 @@ describe('GrapesJS', () => {
editor.Commands.add('test-command', {
stop(ed, caller, opts) {
ed.testVal = ed.getHtml() + opts.val;
}
},
});
editor.stopCommand('test-command', { val: 5, force: 1 });
expect(editor.testVal).toEqual(htmlString + '5');
@ -356,7 +325,7 @@ describe('GrapesJS', () => {
editor.on(`abort:${id}`, () => (result.abort = 1));
editor.Commands.add(id, {
run() {},
stop() {}
stop() {},
});
editor.runCommand(id);
editor.stopCommand(id);
@ -367,7 +336,7 @@ describe('GrapesJS', () => {
runBefore: 1,
stop: 1,
stopBefore: 1,
abort: 1
abort: 1,
});
});
@ -375,7 +344,7 @@ describe('GrapesJS', () => {
config.deviceManager = {};
config.deviceManager.devices = [
{ name: '1', width: '2' },
{ name: '3', width: '4' }
{ name: '3', width: '4' },
];
var editor = obj.init(config);
expect(editor.DeviceManager.getAll().length).toEqual(2);
@ -516,7 +485,7 @@ describe('GrapesJS', () => {
const toSpy = {
selected() {},
deselected() {},
toggled() {}
toggled() {},
};
const selected = jest.spyOn(toSpy, 'selected');
const deselected = jest.spyOn(toSpy, 'deselected');

222
test/specs/parser/model/ParserHtml.js

@ -10,7 +10,7 @@ describe('ParserHtml', () => {
obj = new ParserHtml({
textTags: ['br', 'b', 'i', 'u'],
pStylePrefix: 'gjs-',
returnArray: 1
returnArray: 1,
});
obj.compTypes = dom.componentTypes;
});
@ -32,8 +32,7 @@ describe('ParserHtml', () => {
});
test('Node with attributes', () => {
var str =
'<div id="test1" class="test2 test3" data-one="test4" strange="test5"></div>';
var str = '<div id="test1" class="test2 test3" data-one="test4" strange="test5"></div>';
var result = [
{
tagName: 'div',
@ -41,9 +40,9 @@ describe('ParserHtml', () => {
attributes: {
'data-one': 'test4',
id: 'test1',
strange: 'test5'
}
}
strange: 'test5',
},
},
];
expect(obj.parse(str).html).toEqual(result);
});
@ -53,7 +52,7 @@ describe('ParserHtml', () => {
var result = {
color: 'black',
width: '100px',
test: 'value'
test: 'value',
};
expect(obj.parseStyle(str)).toEqual(result);
});
@ -62,7 +61,7 @@ describe('ParserHtml', () => {
var str = 'background-image:url("https://some-website.ex"); test:value;';
var result = {
'background-image': 'url("https://some-website.ex")',
test: 'value'
test: 'value',
};
expect(obj.parseStyle(str)).toEqual(result);
});
@ -80,8 +79,7 @@ describe('ParserHtml', () => {
});
test('Style attribute is isolated', () => {
var str =
'<div id="test1" style="color:black; width:100px; test:value;"></div>';
var str = '<div id="test1" style="color:black; width:100px; test:value;"></div>';
var result = [
{
tagName: 'div',
@ -89,9 +87,9 @@ describe('ParserHtml', () => {
style: {
color: 'black',
width: '100px',
test: 'value'
}
}
test: 'value',
},
},
];
expect(obj.parse(str).html).toEqual(result);
});
@ -102,8 +100,8 @@ describe('ParserHtml', () => {
{
tagName: 'div',
attributes: { id: 'test1' },
classes: ['test2', 'test3', 'test4']
}
classes: ['test2', 'test3', 'test4'],
},
];
expect(obj.parse(str).html).toEqual(result);
});
@ -116,9 +114,9 @@ describe('ParserHtml', () => {
type: 'image',
attributes: {
id: 'test1',
src: './index.html'
}
}
src: './index.html',
},
},
];
expect(obj.parse(str).html).toEqual(result);
});
@ -130,15 +128,14 @@ describe('ParserHtml', () => {
tagName: 'div',
attributes: { id: 'test1' },
type: 'text',
components: { type: 'textnode', content: 'test2 ' }
}
components: { type: 'textnode', content: 'test2 ' },
},
];
expect(obj.parse(str).html).toEqual(result);
});
test('Parse text with few text tags', () => {
var str =
'<div id="test1"><br/> test2 <br/> a b <b>b</b> <i>i</i> <u>u</u> test </div>';
var str = '<div id="test1"><br/> test2 <br/> a b <b>b</b> <i>i</i> <u>u</u> test </div>';
var result = [
{
tagName: 'div',
@ -149,53 +146,52 @@ describe('ParserHtml', () => {
{
content: ' test2 ',
type: 'textnode',
tagName: ''
tagName: '',
},
{ tagName: 'br' },
{
content: ' a b ',
type: 'textnode',
tagName: ''
tagName: '',
},
{
components: { type: 'textnode', content: 'b' },
type: 'text',
tagName: 'b'
tagName: 'b',
},
{
content: ' ',
type: 'textnode',
tagName: ''
tagName: '',
},
{
components: { type: 'textnode', content: 'i' },
tagName: 'i',
type: 'text'
type: 'text',
},
{
content: ' ',
type: 'textnode',
tagName: ''
tagName: '',
},
{
components: { type: 'textnode', content: 'u' },
tagName: 'u',
type: 'text'
type: 'text',
},
{
content: ' test ',
type: 'textnode',
tagName: ''
}
]
}
tagName: '',
},
],
},
];
expect(obj.parse(str).html).toEqual(result);
});
test('Parse text with few text tags and nested node', () => {
var str =
'<div id="test1">a b <b>b</b> <i>i</i>c <div>ABC</div> <i>i</i> <u>u</u> test </div>';
var str = '<div id="test1">a b <b>b</b> <i>i</i>c <div>ABC</div> <i>i</i> <u>u</u> test </div>';
var result = [
{
tagName: 'div',
@ -205,60 +201,60 @@ describe('ParserHtml', () => {
{
content: 'a b ',
type: 'textnode',
tagName: ''
tagName: '',
},
{
components: { type: 'textnode', content: 'b' },
tagName: 'b',
type: 'text'
type: 'text',
},
{
content: ' ',
type: 'textnode',
tagName: ''
tagName: '',
},
{
components: { type: 'textnode', content: 'i' },
tagName: 'i',
type: 'text'
type: 'text',
},
{
content: 'c ',
type: 'textnode',
tagName: ''
tagName: '',
},
{
tagName: 'div',
type: 'text',
components: { type: 'textnode', content: 'ABC' }
components: { type: 'textnode', content: 'ABC' },
},
{
content: ' ',
type: 'textnode',
tagName: ''
tagName: '',
},
{
components: { type: 'textnode', content: 'i' },
tagName: 'i',
type: 'text'
type: 'text',
},
{
content: ' ',
type: 'textnode',
tagName: ''
tagName: '',
},
{
components: { type: 'textnode', content: 'u' },
tagName: 'u',
type: 'text'
type: 'text',
},
{
content: ' test ',
type: 'textnode',
tagName: ''
}
]
}
tagName: '',
},
],
},
];
expect(obj.parse(str).html).toEqual(result);
});
@ -272,28 +268,28 @@ describe('ParserHtml', () => {
attributes: { id: 'test1' },
components: [
{
tagName: 'div'
tagName: 'div',
},
{
content: ' ',
type: 'textnode',
tagName: ''
tagName: '',
},
{
tagName: 'footer',
attributes: { id: 'test2' }
attributes: { id: 'test2' },
},
{
tagName: '',
type: 'textnode',
content: ' Text mid '
content: ' Text mid ',
},
{
tagName: 'div',
attributes: { id: 'last' }
}
]
}
attributes: { id: 'last' },
},
],
},
];
expect(obj.parse(str).html).toEqual(result);
});
@ -308,20 +304,20 @@ describe('ParserHtml', () => {
{
tagName: '',
type: 'textnode',
content: 'content1 '
content: 'content1 ',
},
{
tagName: 'div',
type: 'text',
components: { type: 'textnode', content: 'nested' }
components: { type: 'textnode', content: 'nested' },
},
{
tagName: '',
type: 'textnode',
content: ' content2'
}
]
}
content: ' content2',
},
],
},
];
expect(obj.parse(str).html).toEqual(result);
});
@ -335,7 +331,7 @@ describe('ParserHtml', () => {
{
tagName: '',
type: 'textnode',
content: 'content1 '
content: 'content1 ',
},
{
tagName: 'div',
@ -343,17 +339,17 @@ describe('ParserHtml', () => {
{
tagName: 'span',
type: 'text',
components: { type: 'textnode', content: 'nested' }
}
]
components: { type: 'textnode', content: 'nested' },
},
],
},
{
tagName: '',
type: 'textnode',
content: ' content2'
}
]
}
content: ' content2',
},
],
},
];
expect(obj.parse(str).html).toEqual(result);
});
@ -365,25 +361,23 @@ describe('ParserHtml', () => {
});
test('Remove script tags', () => {
var str =
'<div><script>var test;</script></div><div></div><script>var test2;</script>';
var str = '<div><script>var test;</script></div><div></div><script>var test2;</script>';
var result = [{ tagName: 'div' }, { tagName: 'div' }];
expect(obj.parse(str).html).toEqual(result);
});
test('Isolate styles', () => {
var str =
'<div><style>.a{color: red}</style></div><div></div><style>.b{color: blue}</style>';
var str = '<div><style>.a{color: red}</style></div><div></div><style>.b{color: blue}</style>';
var resHtml = [{ tagName: 'div' }, { tagName: 'div' }];
var resCss = [
{
selectors: ['a'],
style: { color: 'red' }
style: { color: 'red' },
},
{
selectors: ['b'],
style: { color: 'blue' }
}
style: { color: 'blue' },
},
];
var res = obj.parse(str, new ParserCss());
expect(res.html).toEqual(resHtml);
@ -411,23 +405,21 @@ describe('ParserHtml', () => {
selectorsAdd: '',
style: {
'font-family': '"Open Sans"',
src:
'url(https://fonts.gstatic.com/s/droidsans/v8/SlGVmQWMvZQIdix7AFxXkHNSbRYXags.woff2)'
src: 'url(https://fonts.gstatic.com/s/droidsans/v8/SlGVmQWMvZQIdix7AFxXkHNSbRYXags.woff2)',
},
singleAtRule: 1,
atRuleType: 'font-face'
atRuleType: 'font-face',
},
{
selectors: [],
selectorsAdd: '',
style: {
'font-family': "'Glyphicons Halflings'",
src:
'url(https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/fonts/glyphicons-halflings-regular.eot)'
src: 'url(https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/fonts/glyphicons-halflings-regular.eot)',
},
singleAtRule: 1,
atRuleType: 'font-face'
}
atRuleType: 'font-face',
},
];
const res = obj.parse(str, new ParserCss());
@ -444,20 +436,20 @@ describe('ParserHtml', () => {
{
tagName: '',
type: 'textnode',
content: ' '
content: ' ',
},
{
tagName: 'p',
components: { type: 'textnode', content: 'TestText' },
type: 'text'
type: 'text',
},
{
tagName: '',
type: 'textnode',
content: ' '
}
]
}
content: ' ',
},
],
},
];
expect(obj.parse(str).html).toEqual(result);
});
@ -472,18 +464,17 @@ describe('ParserHtml', () => {
stuff: 'test',
attributes: {
id: 'test1',
'data-test': 'test-value'
'data-test': 'test-value',
},
type: 'text',
components: { type: 'textnode', content: 'test2 ' }
}
components: { type: 'textnode', content: 'test2 ' },
},
];
expect(obj.parse(str).html).toEqual(result);
});
test('Parse model attributes with true and false', () => {
var str =
'<div id="test1" data-test="test-value" data-gjs-draggable="true" data-gjs-stuff="false">test2 </div>';
var str = '<div id="test1" data-test="test-value" data-gjs-draggable="true" data-gjs-stuff="false">test2 </div>';
var result = [
{
tagName: 'div',
@ -491,11 +482,11 @@ describe('ParserHtml', () => {
stuff: false,
attributes: {
id: 'test1',
'data-test': 'test-value'
'data-test': 'test-value',
},
type: 'text',
components: { type: 'textnode', content: 'test2 ' }
}
components: { type: 'textnode', content: 'test2 ' },
},
];
expect(obj.parse(str).html).toEqual(result);
});
@ -510,10 +501,10 @@ describe('ParserHtml', () => {
test: {
prop1: 'value1',
prop2: 10,
prop3: true
prop3: true,
},
components: { type: 'textnode', content: 'test2 ' }
}
components: { type: 'textnode', content: 'test2 ' },
},
];
expect(obj.parse(str).html).toEqual(result);
});
@ -526,8 +517,8 @@ describe('ParserHtml', () => {
attributes: {},
type: 'text',
test: ['value1', 'value2'],
components: { type: 'textnode', content: 'test2 ' }
}
components: { type: 'textnode', content: 'test2 ' },
},
];
expect(obj.parse(str).html).toEqual(result);
});
@ -535,6 +526,7 @@ describe('ParserHtml', () => {
test('SVG is properly parsed', () => {
const str = `<div>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<linearGradient x1="0%" y1="0%"/>
<path d="M13 12h7v1.5h-7m0-4h7V11h-7m0 3.5h7V16h-7m8-12H3c-1.1 0-2 .9-2 2v13c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2m0 15h-9V6h9"></path>
</svg>
</div>`;
@ -547,21 +539,25 @@ describe('ParserHtml', () => {
tagName: 'svg',
attributes: {
xmlns: 'http://www.w3.org/2000/svg',
viewBox: '0 0 24 24'
viewBox: '0 0 24 24',
},
components: [
{
tagName: 'linearGradient',
attributes: { x1: '0%', y1: '0%' },
type: 'svg-in',
},
{
tagName: 'path',
attributes: {
d:
'M13 12h7v1.5h-7m0-4h7V11h-7m0 3.5h7V16h-7m8-12H3c-1.1 0-2 .9-2 2v13c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2m0 15h-9V6h9'
d: 'M13 12h7v1.5h-7m0-4h7V11h-7m0 3.5h7V16h-7m8-12H3c-1.1 0-2 .9-2 2v13c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2m0 15h-9V6h9',
},
type: 'svg-in'
}
]
}
]
}
type: 'svg-in',
},
],
},
],
},
];
expect(obj.parse(str).html).toEqual(result);
});

29
webpack.config.js

@ -1,6 +1,7 @@
import path from 'path';
import webpack from 'webpack';
import pkg from './package.json';
import TerserPlugin from 'grapesjs-cli/node_modules/terser-webpack-plugin';
const rootDir = path.resolve(__dirname);
@ -11,18 +12,32 @@ export default ({ config }) => ({
filename: 'grapes.min.js',
libraryExport: 'default',
},
optimization: {
minimizer: [new TerserPlugin({
extractComments: false,
terserOptions: {
output: {
comments: false,
quote_style: 3, // Preserve original quotes
preamble: `/*! grapesjs - ${pkg.version} */`,
}
}
})],
},
devServer: {
...config.devServer,
static: [rootDir],
headers: { 'Access-Control-Allow-Origin': '*' },
disableHostCheck: true,
},
module: {
rules: [
...config.module.rules,
],
allowedHosts: 'all',
},
resolve: {
modules: ['src', 'node_modules'],
...config.resolve,
modules: [
...(config.resolve && config.resolve.modules),
'src'
],
alias: {
...(config.resolve && config.resolve.alias),
jquery: 'utils/cash-dom',
backbone: `${rootDir}/node_modules/backbone`,
underscore: `${rootDir}/node_modules/underscore`,

2250
yarn.lock

File diff suppressed because it is too large
Loading…
Cancel
Save