Browse Source

Improve SelectorManager and introduce component-first selection (#2474)

Improve SelectorManager and introduce component-first selection
pull/2475/head
Artur Arseniev 7 years ago
committed by GitHub
parent
commit
af6de78937
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      dist/css/grapes.min.css
  2. 4
      src/commands/view/OpenStyleManager.js
  3. 4
      src/dom_components/model/Component.js
  4. 17
      src/dom_components/view/ComponentView.js
  5. 6
      src/domain_abstract/model/Styleable.js
  6. 5
      src/editor/index.js
  7. 18
      src/editor/model/Editor.js
  8. 14
      src/i18n/locale/fr.js
  9. 86
      src/selector_manager/config/config.js
  10. 62
      src/selector_manager/index.js
  11. 6
      src/selector_manager/model/Selectors.js
  12. 23
      src/selector_manager/view/ClassTagView.js
  13. 345
      src/selector_manager/view/ClassTagsView.js
  14. 6
      src/style_manager/index.js
  15. 30
      src/style_manager/view/PropertyView.js
  16. 86
      src/style_manager/view/SectorsView.js
  17. 102
      src/styles/scss/_gjs_selectors.scss
  18. 6
      src/utils/mixins.js
  19. 4
      test/specs/code_manager/model/CodeModels.js
  20. 14
      test/specs/dom_components/view/ComponentV.js
  21. 1
      test/specs/selector_manager/index.js
  22. 137
      test/specs/selector_manager/view/ClassTagsView.js

2
dist/css/grapes.min.css

File diff suppressed because one or more lines are too long

4
src/commands/view/OpenStyleManager.js

@ -58,8 +58,10 @@ export default {
toggleSm() {
const { target, sender } = this;
if (sender && sender.get && !sender.get('active')) return;
const { componentFirst } = target.get('SelectorManager').getConfig();
const selectedAll = target.getSelectedAll().length;
if (target.getSelectedAll().length === 1) {
if (selectedAll === 1 || (selectedAll > 1 && componentFirst)) {
this.$cn2.show();
this.$header.hide();
} else {

4
src/dom_components/model/Component.js

@ -380,7 +380,7 @@ const Component = Backbone.Model.extend(Styleable).extend(
const em = this.em;
if (em && em.getConfig('avoidInlineStyle')) {
const state = this.get('state');
const state = em.get('state');
const cc = em.get('CssComposer');
const rule = cc.getIdRule(this.getId(), { state });
this.rule = rule;
@ -408,7 +408,7 @@ const Component = Backbone.Model.extend(Styleable).extend(
const style = this.get('style') || {};
prop = isString(prop) ? this.parseStyle(prop) : prop;
prop = { ...prop, ...style };
const state = this.get('state');
const state = em.get('state');
const cc = em.get('CssComposer');
const propOrig = this.getStyle();
this.rule = cc.setIdRule(this.getId(), prop, { ...opts, state });

17
src/dom_components/view/ComponentView.js

@ -33,7 +33,6 @@ export default Backbone.View.extend({
this.listenTo(model, 'change:attributes', this.renderAttributes);
this.listenTo(model, 'change:highlightable', this.updateHighlight);
this.listenTo(model, 'change:status', this.updateStatus);
this.listenTo(model, 'change:state', this.updateState);
this.listenTo(model, 'change:script', this.reset);
this.listenTo(model, 'change:content', this.updateContent);
this.listenTo(model, 'change', this.handleChange);
@ -150,22 +149,6 @@ export default Backbone.View.extend({
}
},
/**
* Fires on state update. If the state is not empty will add a helper class
* @param {Event} e
* @private
* */
updateState(e) {
var cl = 'hc-state';
var state = this.model.get('state');
if (state) {
this.$el.addClass(cl);
} else {
this.$el.removeClass(cl);
}
},
/**
* Update item on status change
* @param {Event} e

6
src/domain_abstract/model/Styleable.js

@ -105,5 +105,11 @@ export default {
getSelectors() {
return this.get('selectors') || this.get('classes');
},
getSelectorsString() {
return this.selectorsToString
? this.selectorsToString()
: this.getSelectors().getFullString();
}
};

5
src/editor/index.js

@ -74,7 +74,10 @@
* * `canvas:dragdata` - On any dataTransfer parse, `DataTransfer` instance and the `result` are passed as arguments.
* By changing `result.content` you're able to customize what is dropped
* ### Selectors
* * `selector:add` - Triggers when a new selector/class is created
* * `selector:add` - New selector is add. Passes the new selector as an argument
* * `selector:remove` - Selector removed. Passes the removed selector as an argument
* * `selector:update` - Selector updated. Passes the updated selector as an argument
* * `selector:state` - State changed. Passes the new state value as an argument
* ### RTE
* * `rte:enable` - RTE enabled. The view, on which RTE is enabled, is passed as an argument
* * `rte:disable` - RTE disabled. The view, on which RTE is disabled, is passed as an argument

18
src/editor/model/Editor.js

@ -417,6 +417,24 @@ export default Backbone.Model.extend({
return this.get('CssComposer').getAll();
},
/**
* Change the selector state
* @param {String} value State value
* @returns {this}
*/
setState(value) {
this.set('state', value);
return this;
},
/**
* Get the current selector state
* @returns {String}
*/
getState() {
return this.get('state');
},
/**
* Returns HTML built inside canvas
* @return {string} HTML string

14
src/i18n/locale/fr.js

@ -68,7 +68,8 @@ export default {
}
},
styleManager: {
empty: 'Veuillez sélectionner un élément avant d\'utiliser le gestionnaire de style',
empty:
"Veuillez sélectionner un élément avant d'utiliser le gestionnaire de style",
layer: 'Calque',
fileButton: 'Images',
sectors: {
@ -132,9 +133,9 @@ export default {
'box-shadow-h': 'Ombre de boîte: horizontale',
'box-shadow-v': 'Ombre de boîte: verticale',
'box-shadow-blur': 'Flou ombre de boîte',
'box-shadow-spread': 'Extension d\'ombre de boîte',
'box-shadow-color': 'Couleur d\'ombre de boîte',
'box-shadow-type': 'Type d\'ombre de boîte',
'box-shadow-spread': "Extension d'ombre de boîte",
'box-shadow-color': "Couleur d'ombre de boîte",
'box-shadow-type': "Type d'ombre de boîte",
background: 'Fond',
'background-image': 'Image de fond',
'background-repeat': 'Répéter fond',
@ -167,7 +168,8 @@ export default {
}
},
traitManager: {
empty: 'Veuillez sélectionner un élément pour modifier les paramètres de cet élément',
empty:
'Veuillez sélectionner un élément pour modifier les paramètres de cet élément',
label: 'Paramètres composant',
traits: {
// The core library generates the name by their `name` property
@ -175,7 +177,7 @@ export default {
id: 'Identifiant',
alt: 'Texte alternatif',
title: 'Titre',
href: 'Source lien',
href: 'Source lien'
},
// In a simple trait, like text input, these are used on input attributes
attributes: {

86
src/selector_manager/config/config.js

@ -14,5 +14,89 @@ export default {
// Custom selector name escaping strategy, eg.
// name => name.replace(' ', '_')
escapeName: 0
escapeName: 0,
// Custom selected name strategy (the string you see after 'Selected')
// ({ result, state, target }) => {
// return `${result} - ID: ${target.getId()}`
// }
selectedName: 0,
// Icon used to add new selector
iconAdd:
'<svg viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"></path></svg>',
// Icon used to sync styles
iconSync:
'<svg viewBox="0 0 24 24"><path d="M12 18c-3.31 0-6-2.69-6-6 0-1 .25-1.97.7-2.8L5.24 7.74A7.93 7.93 0 0 0 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4m0-11V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1-.25 1.97-.7 2.8l1.46 1.46A7.93 7.93 0 0 0 20 12c0-4.42-3.58-8-8-8z"></path></svg>',
// Icon to show when the selector is enabled
iconTagOn:
'<svg viewBox="0 0 24 24"><path d="M19 19H5V5h10V3H5c-1.11 0-2 .89-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-8h-2m-11.09-.92L6.5 11.5 11 16 21 6l-1.41-1.42L11 13.17l-3.09-3.09z"></path></svg>',
// Icon to show when the selector is disabled
iconTagOff:
'<svg viewBox="0 0 24 24"><path d="M19 3H5c-1.11 0-2 .89-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5a2 2 0 0 0-2-2m0 2v14H5V5h14z"></path></svg>',
// Icon used to remove the selector
iconTagRemove:
'<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"></path></svg>',
/**
* Custom render function for the Select Manager
* @example
* render: ({ el, labelHead, labelStates, labelInfo, }) => {
* // You can use the default `el` to extend/edit the current
* // DOM element of the Selector Manager
* const someEl = document.createElement('div');
* // ...
* el.appendChild(someEl);
* // no need to return anything from the function
*
* // Create and return a new DOM element
* const newEl = document.createElement('div');
* // ...
* return newEl;
*
* // Return an HTML string for a completely different layout.
* // Use `data-*` attributes to make the module recognize some elements:
* // `data-states` - Where to append state `<option>` elements (or just write yours)
* // `data-selectors` - Where to append selectors
* // `data-input` - Input element which is used to add new selectors
* // `data-add` - Element which triggers the add of a new selector on click
* // `data-sync-style` - Element which triggers the sync of styles (visible with `componentFirst` enabled)
* // `data-selected` - Where to print selected selectors
* return `
* <div class="my-sm-header">
* <div>${labelHead}</div>
* <div>
* <select data-states>
* <option value="">${labelStates}</option>
* </select>
* </div>
* </div>
* <div class="my-sm-body">
* <div data-selectors></div>
* <input data-input/>
* <span data-add>Add</span>
* <span data-sync-style>Sync</span>
* </div>
* <div class="my-sm-info">
* <div>${labelInfo}</div>
* <div data-selected></div>
* </div>
* `;
* }
*/
render: 0,
// When you select a component in the canvas the selected Model (Component or CSS Rule)
// is passed to the StyleManager which will be then able to be styled, these are the cases:
// * Selected component doesn't have any classes: Component will be passed
// * Selected component has at least one class: The CSS Rule will be passed
//
// With this option enabled, also in the second case, the Component will be passed.
// This method allows to avoid styling classes directly and make, for example, some
// unintended changes below the visible canvas area (when components share same classes)
componentFirst: 0
};

62
src/selector_manager/index.js

@ -39,12 +39,15 @@
* * [add](#add)
* * [addClass](#addclass)
* * [get](#get)
* * [getAll](#getAll)
* * [getAll](#getall)
* * [setState](#setstate)
* * [getState](#getstate)
*
* @module SelectorManager
*/
import { isString, isElement, isObject, isArray } from 'underscore';
import { isComponent, isRule } from 'utils/mixins';
import defaults from './config/config';
import Selector from './model/Selector';
import Selectors from './model/Selectors';
@ -55,7 +58,7 @@ const isClass = str => isString(str) && str[0] == '.';
export default config => {
var c = config || {};
var selectors, selectorTags;
var selectors;
return {
Selector,
@ -90,12 +93,13 @@ export default config => {
};
const em = c.em;
const ppfx = c.pStylePrefix;
this.em = em;
if (ppfx) {
c.stylePrefix = ppfx + c.stylePrefix;
}
selectorTags = new ClassTagsView({
this.selectorTags = new ClassTagsView({
collection: new Selectors([], { em, config: c }),
config: c
});
@ -103,6 +107,16 @@ export default config => {
// Global selectors container
selectors = new Selectors(c.selectors);
selectors.on('add', model => em.trigger('selector:add', model));
selectors.on('remove', model => em.trigger('selector:remove', model));
selectors.on('change', model =>
em.trigger(
'selector:update',
model,
model.previousAttributes(),
model.changedAttributes()
)
);
em.on('change:state', (m, value) => em.trigger('selector:state', value));
return this;
},
@ -116,6 +130,42 @@ export default config => {
}
},
select(value, opts = {}) {
const targets = Array.isArray(value) ? value : [value];
const toSelect = this.em.get('StyleManager').setTarget(targets, opts);
const res = toSelect
.filter(i => i)
.map(sel =>
isComponent(sel)
? sel
: isRule(sel) && !sel.get('selectorsAdd')
? sel
: sel.getSelectorsString()
);
this.selectorTags.componentChanged({ targets: res });
return this;
},
/**
* Change the selector state
* @param {String} value State value
* @returns {this}
* @example
* selectorManager.setState('hover');
*/
setState(value) {
this.em.setState(value);
return this;
},
/**
* Get the current selector state
* @returns {String}
*/
getState() {
return this.em.setState();
},
addSelector(name, opt = {}) {
let opts = { ...opt };
@ -256,12 +306,12 @@ export default config => {
*/
render(selectors) {
if (selectors) {
var view = new ClassTagsView({
this.selectorTags = new ClassTagsView({
collection: new Selectors(selectors),
config: c
});
return view.render().el;
} else return selectorTags.render().el;
return this.selectorTags.render().el;
} else return this.selectorTags.render().el;
}
};
};

6
src/selector_manager/model/Selectors.js

@ -14,8 +14,10 @@ export default Backbone.Collection.extend({
);
},
getValid() {
return filter(this.models, item => !item.get('private'));
getValid({ noDisabled } = {}) {
return filter(this.models, item => !item.get('private')).filter(item =>
noDisabled ? item.get('active') : 1
);
},
getFullString(collection) {

23
src/selector_manager/view/ClassTagView.js

@ -4,14 +4,14 @@ const inputProp = 'contentEditable';
export default Backbone.View.extend({
template() {
const { pfx, model } = this;
const { pfx, model, config } = this;
const label = model.get('label') || '';
return `
<span id="${pfx}checkbox" class="fa" data-tag-status></span>
<span id="${pfx}checkbox" class="${pfx}tag-status" data-tag-status></span>
<span id="${pfx}tag-label" data-tag-name>${label}</span>
<span id="${pfx}close" data-tag-remove>
&Cross;
<span id="${pfx}close" class="${pfx}tag-close" data-tag-remove>
${config.iconTagRemove}
</span>
`;
},
@ -98,8 +98,10 @@ export default Backbone.View.extend({
*/
removeTag() {
const { em, model } = this;
const sel = em && em.getSelected();
if (!model.get('protected') && sel) sel.getSelectors().remove(model);
const targets = em && em.getSelectedAll();
targets.forEach(sel => {
!model.get('protected') && sel && sel.getSelectors().remove(model);
});
},
/**
@ -107,16 +109,15 @@ export default Backbone.View.extend({
* @private
*/
updateStatus() {
const { model, $el } = this;
const chkOn = 'fa-check-square-o';
const chkOff = 'fa-square-o';
const { model, $el, config } = this;
const { iconTagOn, iconTagOff } = config;
const $chk = $el.find('[data-tag-status]');
if (model.get('active')) {
$chk.removeClass(chkOff).addClass(chkOn);
$chk.html(iconTagOn);
$el.removeClass('opac50');
} else {
$chk.removeClass(chkOn).addClass(chkOff);
$chk.html(iconTagOff);
$el.addClass('opac50');
}
},

345
src/selector_manager/view/ClassTagsView.js

@ -1,73 +1,122 @@
import { template, debounce } from 'underscore';
import { isEmpty, isArray, isString, debounce } from 'underscore';
import Backbone from 'backbone';
import ClassTagView from './ClassTagView';
export default Backbone.View.extend({
template: template(`
<div id="<%= pfx %>up">
<div id="<%= pfx %>label"><%= label %></div>
<div id="<%= pfx %>status-c">
<span id="<%= pfx %>input-c">
<div class="<%= ppfx %>field <%= ppfx %>select">
<span id="<%= ppfx %>input-holder">
<select id="<%= pfx %>states">
<option value=""><%= statesLabel %></option>
</select>
</span>
<div class="<%= ppfx %>sel-arrow">
<div class="<%= ppfx %>d-s-arrow"></div>
template({
labelInfo,
labelStates,
labelHead,
iconSync,
iconAdd,
pfx,
ppfx
}) {
return `
<div id="${pfx}up" class="${pfx}header">
<div id="${pfx}label" class="${pfx}header-label">${labelHead}</div>
<div id="${pfx}status-c" class="${pfx}header-status">
<span id="${pfx}input-c" data-states-c>
<div class="${ppfx}field ${ppfx}select">
<span id="${ppfx}input-holder">
<select id="${pfx}states" data-states>
<option value="">${labelStates}</option>
</select>
</span>
<div class="${ppfx}sel-arrow">
<div class="${ppfx}d-s-arrow"></div>
</div>
</div>
</div>
</span>
</div>
</div>
<div id="${pfx}tags-field" class="${ppfx}field">
<div id="${pfx}tags-c" data-selectors></div>
<input id="${pfx}new" data-input/>
<span id="${pfx}add-tag" class="${pfx}tags-btn ${pfx}tags-btn__add" data-add>
${iconAdd}
</span>
<span class="${pfx}tags-btn ${pfx}tags-btn__sync" style="display: none" data-sync-style>
${iconSync}
</span>
</div>
</div>
<div id="<%= pfx %>tags-field" class="<%= ppfx %>field">
<div id="<%= pfx %>tags-c"></div>
<input id="<%= pfx %>new" />
<span id="<%= pfx %>add-tag" class="fa fa-plus"></span>
</div>
<div id="<%= pfx %>sel-help">
<div id="<%= pfx %>label"><%= selectedLabel %></div>
<div id="<%= pfx %>sel"></div>
<div style="clear:both"></div>
</div>`),
events: {},
<div class="${pfx}sels-info">
<div class="${pfx}label-sel">${labelInfo}:</div>
<div class="${pfx}sels" data-selected></div>
<div style="clear:both"></div>
</div>`;
},
events: {
'change [data-states]': 'stateChanged',
'click [data-add]': 'startNewTag',
'focusout [data-input]': 'endNewTag',
'keyup [data-input]': 'onInputKeyUp',
'click [data-sync-style]': 'syncStyle'
},
initialize(o = {}) {
this.config = o.config || {};
this.pfx = this.config.stylePrefix || '';
this.ppfx = this.config.pStylePrefix || '';
this.className = this.pfx + 'tags';
this.addBtnId = this.pfx + 'add-tag';
this.newInputId = this.pfx + 'new';
this.stateInputId = this.pfx + 'states';
this.stateInputC = this.pfx + 'input-c';
this.states = this.config.states || [];
this.events['click #' + this.addBtnId] = 'startNewTag';
this.events['blur #' + this.newInputId] = 'endNewTag';
this.events['keyup #' + this.newInputId] = 'onInputKeyUp';
this.events['change #' + this.stateInputId] = 'stateChanged';
const { em } = this.config;
const emitter = this.getStyleEmitter();
const coll = this.collection;
this.target = this.config.em;
this.em = em;
const toList = 'component:toggled component:update:classes';
const toListCls = 'component:update:classes change:state';
this.listenTo(em, toList, this.componentChanged);
this.listenTo(emitter, 'styleManager:update', this.componentChanged);
this.listenTo(
em,
'component:toggled component:update:classes',
this.componentChanged
);
this.listenTo(em, 'component:update:classes', this.updateSelector);
this.listenTo(this.collection, 'add', this.addNew);
this.listenTo(this.collection, 'reset', this.renderClasses);
this.listenTo(this.collection, 'remove', this.tagRemoved);
this.listenTo(em, toListCls, this.__handleStateChange);
this.listenTo(em, 'styleable:change change:device', this.checkSync); // component:styleUpdate
this.listenTo(coll, 'add', this.addNew);
this.listenTo(coll, 'reset', this.renderClasses);
this.listenTo(coll, 'remove', this.tagRemoved);
this.delegateEvents();
},
syncStyle() {
const { em } = this;
const target = this.getTarget();
const cssC = em.get('CssComposer');
const opts = { noDisabled: 1 };
const selectors = this.getCommonSelectors({ opts });
const state = em.get('state');
const mediaText = em.getCurrentMedia();
const ruleComponents = [];
const rule =
cssC.get(selectors, state, mediaText) ||
cssC.add(selectors, state, mediaText);
let style;
this.getTargets().forEach(target => {
const ruleComponent = cssC.getIdRule(target.getId(), {
state,
mediaText
});
style = ruleComponent.getStyle();
ruleComponent.setStyle({});
ruleComponents.push(ruleComponent);
});
style && rule.addStyle(style);
em.trigger('component:toggled');
em.trigger('component:sync-style', {
component: target,
selectors,
mediaText,
rule,
ruleComponents,
state
});
},
getStyleEmitter() {
const { em } = this;
const sm = em && em.get('StyleManager');
@ -120,8 +169,8 @@ export default Backbone.View.extend({
* @param {Object} e
* @private
*/
startNewTag(e) {
this.$addBtn.get(0).style.display = 'none';
startNewTag() {
this.$addBtn.css({ display: 'none' });
this.$input.show().focus();
},
@ -130,8 +179,8 @@ export default Backbone.View.extend({
* @param {Object} e
* @private
*/
endNewTag(e) {
this.$addBtn.get(0).style.display = '';
endNewTag() {
this.$addBtn.css({ display: '' });
this.$input.hide().val('');
},
@ -145,31 +194,77 @@ export default Backbone.View.extend({
else if (e.keyCode === 27) this.endNewTag();
},
checkStates() {
const state = this.em.getState();
const statesEl = this.getStates();
statesEl && statesEl.val(state);
},
/**
* Triggered when component is changed
* @param {Object} e
* @private
*/
componentChanged: debounce(function(target) {
target = target || this.getTarget();
this.compTarget = target;
let validSelectors = [];
if (target) {
const state = target.get('state');
state && this.getStates().val(state);
const selectors = target.getSelectors();
validSelectors = selectors.getValid();
componentChanged: debounce(function({ targets } = {}) {
this.updateSelection(targets);
}),
updateSelection(targets) {
let trgs = targets || this.getTargets();
trgs = isArray(trgs) ? trgs : [trgs];
let selectors = [];
if (trgs && trgs.length) {
selectors = this.getCommonSelectors({ targets: trgs });
this.checkSync({ validSelectors: selectors });
}
this.collection.reset(selectors);
this.updateStateVis(trgs);
return selectors;
},
getCommonSelectors({ targets, opts = {} } = {}) {
const trgs = targets || this.getTargets();
const selectors = trgs
.map(tr => tr.getSelectors && tr.getSelectors().getValid(opts))
.filter(i => i);
return this._commonSelectors(...selectors);
},
_commonSelectors(...args) {
if (!args.length) return [];
if (args.length === 1) return args[0];
if (args.length === 2)
return args[0].filter(item => args[1].indexOf(item) >= 0);
return args
.slice(1)
.reduce((acc, item) => this._commonSelectors(acc, item), args[0]);
},
checkSync: debounce(function() {
const { $btnSyncEl, config, collection } = this;
const target = this.getTarget();
let hasStyle;
if (target && config.componentFirst && collection.length) {
const style = target.getStyle();
hasStyle = !isEmpty(style);
}
this.collection.reset(validSelectors);
this.updateStateVis(target);
$btnSyncEl && $btnSyncEl[hasStyle ? 'show' : 'hide']();
}),
getTarget() {
return this.target.getSelected();
},
getTargets() {
return this.target.getSelectedAll();
},
/**
* Update states visibility. Hides states in case there is no tags
* inside collection
@ -178,32 +273,55 @@ export default Backbone.View.extend({
updateStateVis(target) {
const em = this.em;
const avoidInline = em && em.getConfig('avoidInlineStyle');
const display = this.collection.length || avoidInline ? 'block' : 'none';
const display = this.collection.length || avoidInline ? '' : 'none';
this.getStatesC().css('display', display);
this.updateSelector(target);
},
__handleStateChange() {
this.updateSelector(this.getTargets());
},
/**
* Update selector helper
* @return {this}
* @private
*/
updateSelector(target) {
const { pfx, collection, el } = this;
const selected = target || this.getTarget();
this.compTarget = selected;
if (!selected || !selected.get) return;
const state = selected.get('state');
const coll = collection;
let result = coll.getFullString(selected.getSelectors().getStyleable());
result =
result ||
selected.get('selectorsAdd') ||
(selected.getId ? `#${selected.getId()}` : '');
result += state ? `:${state}` : '';
const elSel = el.querySelector(`#${pfx}sel`);
elSel && (elSel.innerHTML = result);
updateSelector(targets) {
const elSel = this.el.querySelector('[data-selected]');
const result = [];
let trgs = targets || this.getTargets();
trgs = isArray(trgs) ? trgs : [trgs];
trgs.forEach(target => result.push(this.__getName(target)));
elSel && (elSel.innerHTML = result.join(', '));
this.checkStates();
},
__getName(target) {
const { pfx, config, em } = this;
const { selectedName, componentFirst } = config;
let result;
if (isString(target)) {
result = `<span class="${pfx}sel-gen">${target}</span>`;
} else {
if (!target || !target.get) return;
const selectors = target.getSelectors().getStyleable();
const state = em.get('state');
const idRes = target.getId
? `<span class="${pfx}sel-cmp">${target.getName()}</span><span class="${pfx}sel-id">#${target.getId()}</span>`
: '';
result = this.collection.getFullString(selectors);
result = result
? `<span class="${pfx}sel-rule">${result}</span>`
: target.get('selectorsAdd') || idRes;
result = componentFirst && idRes ? idRes : result;
result += state ? `<span class="${pfx}sel-state">:${state}</span>` : '';
result = selectedName ? selectedName({ result, state, target }) : result;
}
return result && `<span class="${pfx}sel">${result}</span>`;
},
/**
@ -211,11 +329,10 @@ export default Backbone.View.extend({
* @param {Object} e
* @private
*/
stateChanged(e) {
if (this.compTarget) {
this.compTarget.set('state', this.$states.val());
this.updateSelector();
}
stateChanged(ev) {
const { em } = this;
const { value } = ev.target;
em.set('state', value);
},
/**
@ -224,24 +341,21 @@ export default Backbone.View.extend({
* @private
*/
addNewTag(label) {
const target = this.target;
const component = this.compTarget;
const { em } = this;
if (!label.trim()) {
return;
}
if (!label.trim()) return;
if (target) {
const sm = target.get('SelectorManager');
if (em) {
const sm = em.get('SelectorManager');
const model = sm.add({ label });
if (component) {
const compCls = component.getSelectors();
compCls.add(model);
this.getTargets().forEach(target => {
target.getSelectors().add(model);
this.collection.add(model);
this.updateStateVis();
}
});
}
this.endNewTag();
},
@ -284,7 +398,7 @@ export default Backbone.View.extend({
* @private
*/
getClasses() {
return this.$el.find(`#${this.pfx}tags-c`);
return this.$el.find('[data-selectors]');
},
/**
@ -293,7 +407,10 @@ export default Backbone.View.extend({
* @private
*/
getStates() {
if (!this.$states) this.$states = this.$el.find('#' + this.stateInputId);
if (!this.$states) {
const el = this.$el.find('[data-states]');
this.$states = el[0] && el;
}
return this.$states;
},
@ -308,22 +425,28 @@ export default Backbone.View.extend({
},
render() {
const { em, pfx, ppfx, $el } = this;
$el.html(
this.template({
selectedLabel: em.t('selectorManager.selected'),
statesLabel: em.t('selectorManager.emptyState'),
label: em.t('selectorManager.label'),
pfx,
ppfx
})
);
this.$input = $el.find('input#' + this.newInputId);
this.$addBtn = $el.find('#' + this.addBtnId);
const { em, pfx, ppfx, config, $el, el } = this;
const { render, iconSync, iconAdd } = config;
const tmpOpts = {
iconSync,
iconAdd,
labelHead: em.t('selectorManager.label'),
labelStates: em.t('selectorManager.emptyState'),
labelInfo: em.t('selectorManager.selected'),
ppfx,
pfx,
el
};
$el.html(this.template(tmpOpts));
const renderRes = render && render(tmpOpts);
renderRes && renderRes !== el && $el.empty().append(renderRes);
this.$input = $el.find('[data-input]');
this.$addBtn = $el.find('[data-add]');
this.$classes = $el.find('#' + pfx + 'tags-c');
this.$states = $el.find('#' + this.stateInputId);
this.$statesC = $el.find('#' + this.stateInputC);
this.$states.append(this.getStateOptions());
this.$btnSyncEl = $el.find('[data-sync-style]');
this.$input.hide();
const statesEl = this.getStates();
statesEl && statesEl.append(this.getStateOptions());
this.renderClasses();
$el.attr('class', `${this.className} ${ppfx}one-bg ${ppfx}two-color`);
return this;

6
src/style_manager/index.js

@ -265,7 +265,9 @@ export default () => {
const config = em.getConfig();
const um = em.get('UndoManager');
const cssC = em.get('CssComposer');
const state = !config.devicePreviewMode ? model.get('state') : '';
const sm = em.get('SelectorManager');
const smConf = sm ? sm.getConfig() : {};
const state = !config.devicePreviewMode ? em.get('state') : '';
const valid = classes.getStyleable();
const hasClasses = valid.length;
const opts = { state };
@ -277,7 +279,7 @@ export default () => {
// #268
um.stop();
if (hasClasses) {
if (hasClasses && !smConf.componentFirst) {
const deviceW = em.getCurrentMedia();
rule = cssC.get(valid, state, deviceW);

30
src/style_manager/view/PropertyView.js

@ -159,6 +159,11 @@ export default Backbone.View.extend({
return this.getTargetModel();
},
getTargets() {
const { targets } = this.propTarget;
return targets || [this.getTarget()];
},
/**
* Returns Styleable model
* @return {Model|null}
@ -343,20 +348,30 @@ export default Backbone.View.extend({
* @param {Object} opt Options
* */
modelValueChanged(e, val, opt = {}) {
const em = this.config.em;
const model = this.model;
const value = model.getFullValue();
const target = this.getTarget();
const prop = model.get('property');
const onChange = this.onChange;
// Avoid element update if the change comes from it
if (!opt.fromInput) {
this.setValue(value);
}
this.getTargets().forEach(target => this.__updateTarget(target, opt));
},
__updateTarget(target, opt = {}) {
const { model } = this;
const { em } = this.config;
const prop = model.get('property');
const value = model.getFullValue();
const onChange = this.onChange;
// Check if component is allowed to be styled
if (!target || !this.isTargetStylable() || !this.isComponentStylable()) {
if (
!target ||
!this.isTargetStylable(target) ||
!this.isComponentStylable()
) {
return;
}
@ -367,10 +382,11 @@ export default Backbone.View.extend({
if (onChange && !opt.fromParent) {
onChange(target, this, opt);
} else {
this.updateTargetStyle(value, null, opt);
this.updateTargetStyle(value, null, { ...opt, target });
}
}
// TODO: use target if componentFirst
const component = em && em.getSelected();
if (em && component) {
@ -388,7 +404,7 @@ export default Backbone.View.extend({
*/
updateTargetStyle(value, name = '', opts = {}) {
const property = name || this.model.get('property');
const target = this.getTarget();
const target = opts.target || this.getTarget();
const style = target.getStyle();
if (value) {

86
src/style_manager/view/SectorsView.js

@ -1,9 +1,11 @@
import Backbone from 'backbone';
import { extend, isString } from 'underscore';
import { extend, isString, isArray } from 'underscore';
import { isTaggableNode } from 'utils/mixins';
import { appendAtIndex } from 'utils/dom';
import SectorView from './SectorView';
const helperCls = 'hc-state';
export default Backbone.View.extend({
initialize(o = {}) {
const config = o.config || {};
@ -23,7 +25,7 @@ export default Backbone.View.extend({
this.propTarget = target;
const coll = this.collection;
const events =
'component:toggled component:update:classes component:update:state change:device';
'component:toggled component:update:classes change:state change:device';
this.listenTo(coll, 'add', this.addTo);
this.listenTo(coll, 'reset', this.render);
this.listenTo(this.target, events, this.targetUpdated);
@ -39,20 +41,34 @@ export default Backbone.View.extend({
this.addToCollection(model, null, opts);
},
toggleStateCls(targets = [], enable) {
targets.forEach(trg => {
const el = trg.getEl();
el && el.classList[enable ? 'add' : 'remove'](helperCls);
});
},
/**
* Fired when target is updated
* @private
*/
targetUpdated() {
targetUpdated(trg) {
const em = this.target;
const pt = this.propTarget;
const targets = em.getSelectedAll();
let model = em.getSelected();
const mdToClear = trg && !!trg.toHTML ? trg : model;
// Clean components
mdToClear && this.toggleStateCls([mdToClear]);
if (!model) return;
const config = em.get('Config');
const state = !config.devicePreviewMode ? model.get('state') : '';
const state = !config.devicePreviewMode ? em.get('state') : '';
const { componentFirst } = em.get('SelectorManager').getConfig();
const el = model.getEl();
pt.helper = null;
pt.targets = null;
// Create computed style container
if (el && isTaggableNode(el)) {
@ -63,7 +79,6 @@ export default Backbone.View.extend({
// Create a new rule for the state as a helper
const appendStateRule = (style = {}) => {
const cc = em.get('CssComposer');
const helperCls = 'hc-state';
const rules = cc.getAll();
let helperRule = cc.getClassRule(helperCls);
@ -81,50 +96,61 @@ export default Backbone.View.extend({
};
model = em.get('StyleManager').getModelToStyle(model);
state && appendStateRule(model.getStyle());
if (state) {
appendStateRule(model.getStyle());
this.toggleStateCls(targets, 1);
}
pt.model = model;
if (componentFirst) pt.targets = targets;
pt.trigger('update');
},
/**
* Select different target for the Style Manager.
* It could be a Component, CSSRule, or a string of any CSS selector
* @param {Component|CSSRule|String} target
* @return {Styleable} A Component or CSSRule
* @param {Component|CSSRule|String|Array<Component|CSSRule|String>} target
* @return {Array<Styleable>} Array of Components/CSSRules
*/
setTarget(target, opts = {}) {
const em = this.target;
const config = em.get('Config');
const trgs = isArray(target) ? target : [target];
const { targetIsClass, stylable } = opts;
let model = target;
const models = [];
if (isString(target)) {
let rule;
const rules = em.get('CssComposer').getAll();
trgs.forEach(target => {
let model = target;
if (targetIsClass) {
rule = rules.filter(
rule => rule.get('selectors').getFullString() === target
)[0];
}
if (isString(target)) {
let rule;
const rules = em.get('CssComposer').getAll();
if (!rule) {
rule = rules.filter(rule => rule.get('selectorsAdd') === target)[0];
}
if (targetIsClass) {
rule = rules.filter(
rule => rule.get('selectors').getFullString() === target
)[0];
}
if (!rule) {
rule = rules.filter(rule => rule.get('selectorsAdd') === target)[0];
}
if (!rule) {
rule = rules.add({ selectors: [], selectorsAdd: target });
}
if (!rule) {
rule = rules.add({ selectors: [], selectorsAdd: target });
stylable && rule.set({ stylable });
model = rule;
}
stylable && rule.set({ stylable });
model = rule;
}
models.push(model);
});
const state = !config.devicePreviewMode ? model.get('state') : '';
const pt = this.propTarget;
pt.model = model;
pt.trigger('styleManager:update', model);
return model;
pt.targets = models;
pt.trigger('update');
return models;
},
/**

102
src/styles/scss/_gjs_selectors.scss

@ -6,13 +6,78 @@
@extend .#{$sm-prefix}field, .#{$sm-prefix}select;
}
##{$clm-prefix}add-tag,
.#{$clm-prefix}tags-btn {
background-color: rgba(255, 255, 255, 0.15);
border-radius: 2px;
padding: 3px;
margin-right: 3px;
border: 1px solid rgba(0, 0, 0, 0.15);
min-width: 24px;
height: 24px;
box-sizing: border-box;
cursor: pointer;
}
.#{$clm-prefix} {
&tags-btn svg {
fill: currentColor;
display: block;
}
&header {
display: flex;
align-items: center;
margin: 7px 0;
&-status {
flex-shrink: 1;
margin-left: auto;
}
}
&tag {
display: flex;
overflow: hidden;
align-items: center;
border-radius: 3px;
margin: 0 3px 3px 0;
padding: 5px;
cursor: default;
&-status,
&-close {
min-width: 12px;
flex-shrink: 1;
svg {
vertical-align: middle;
fill: currentColor;
}
}
}
&sels-info {
margin: 7px 0;
}
&sel-id {
font-size: 0.9em;
@include opacity(0.5);
}
&label-sel {
float: left;
padding-right: 5px;
}
}
.#{$clm-prefix}tags {
@extend .#{$sm-prefix}sector;
font-size: $fontSizeS;
padding: 10px 5px;
##{$clm-prefix}label,
##{$clm-prefix}sel {
padding: 7px 0;
float: left;
@ -23,28 +88,19 @@
margin-left: 5px;
}
##{$clm-prefix}status-c {
float: right;
}
##{$clm-prefix}tags-field {
clear: both;
padding: 5px;
margin-bottom: 5px;
display: flex;
flex-wrap: wrap;
}
##{$clm-prefix}tags-c {
display: inline-block;
display: flex;
flex-wrap: wrap;
vertical-align: top;
}
##{$clm-prefix}add-tag {
background-color: rgba(255, 255, 255, 0.15);
border-radius: 2px;
padding: $paddElClm;
box-shadow: 1px 1px 0 rgba(255, 255, 255, 0.2) inset;
border: 1px solid rgba(0, 0, 0, 0.15);
cursor: pointer;
overflow: hidden;
}
##{$clm-prefix}new {
@ -55,17 +111,8 @@
display: none;
}
.#{$clm-prefix}tag {
display: inline-block;
border-radius: 3px;
margin: 0 3px 3px 0;
padding: 5px;
cursor: default;
}
##{$clm-prefix}close {
@extend .opac50;
@include opacity(0.85);
font-size: 20px;
line-height: 0;
cursor: pointer;
@ -73,7 +120,7 @@
@extend .no-select;
&:hover {
@include opacity(0.7);
@include opacity(1);
}
}
@ -85,6 +132,9 @@
}
##{$clm-prefix}tag-label {
flex-grow: 1;
text-overflow: ellipsis;
overflow: hidden;
padding: 0 3px;
cursor: text;
}

6
src/utils/mixins.js

@ -209,6 +209,8 @@ const getKeyChar = ev => String.fromCharCode(getKeyCode(ev));
const isEscKey = ev => getKeyCode(ev) === 27;
const capitalize = str => str.charAt(0).toUpperCase() + str.substring(1);
const isComponent = obj => obj && obj.toHTML;
const isRule = obj => obj && obj.toCSS;
export {
on,
@ -229,5 +231,7 @@ export {
getPointerEvent,
getUnitFromValue,
capitalize,
appendStyles
appendStyles,
isComponent,
isRule
};

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

@ -276,7 +276,7 @@ describe('CssGenerator', () => {
em.getConfig().avoidInlineStyle = 1;
const state = 'hover';
comp.config.avoidInlineStyle = 1;
comp.set('state', state);
em.get('SelectorManager').setState(state);
comp.setStyle({ color: 'red' });
const id = comp.getId();
const result = `#${id}:${state}{color:red;}`;
@ -288,7 +288,7 @@ describe('CssGenerator', () => {
const state = 'hover';
comp.config.avoidInlineStyle = 1;
comp.setStyle({ color: 'blue' });
comp.set('state', state);
em.get('SelectorManager').setState(state);
comp.setStyle({ color: 'red' });
const id = comp.getId();
const result = `#${id}{color:blue;}#${id}:${state}{color:red;}`;

14
test/specs/dom_components/view/ComponentV.js

@ -39,19 +39,19 @@ describe('ComponentView', () => {
});
test('Add helper class on update of state', () => {
model.set('state', 'test');
em.setSelected(model);
em.setState('test');
expect(fixtures.innerHTML).toEqual(
'<div data-gjs-type="default" data-highlightable="1" class="' +
hClass +
'"></div>'
`<div data-gjs-type="default" data-highlightable="1" class="selected ${hClass}"></div>`
);
});
test('Clean form helper state', () => {
model.set('state', 'test');
model.set('state', '');
em.setSelected(model);
em.setState('test');
em.setState();
expect(fixtures.innerHTML).toEqual(
'<div data-gjs-type="default" data-highlightable="1" class=""></div>'
'<div data-gjs-type="default" data-highlightable="1" class="selected"></div>'
);
});

1
test/specs/selector_manager/index.js

@ -26,6 +26,7 @@ describe('SelectorManager', () => {
test('Able to add default selectors', () => {
var cm = new SelectorManager().init({
em,
selectors: ['test1', 'test2', 'test3']
});
expect(cm.getAll().length).toEqual(3);

137
test/specs/selector_manager/view/ClassTagsView.js

@ -1,6 +1,7 @@
import ClassTagsView from 'selector_manager/view/ClassTagsView';
import Selectors from 'selector_manager/model/Selectors';
import Component from 'dom_components/model/Component';
import Rule from 'css_composer/model/CssRule';
import Editor from 'editor/model/Editor';
describe('ClassTagsView', () => {
@ -10,13 +11,16 @@ describe('ClassTagsView', () => {
testContext = {};
});
var view;
var fixture;
var fixtures;
var testLabel;
var coll;
var target;
var em;
let view;
let fixture;
let fixtures;
let coll;
let target;
let em;
let compTest;
const getSelectorNames = arr => arr.map(item => item.getFullName());
const newComponent = obj => new Component(obj, { em });
const newRule = obj => new Rule(obj, { em });
beforeAll(() => {
document.body.innerHTML = '<div id="fixtures"></div>';
@ -30,9 +34,10 @@ describe('ClassTagsView', () => {
beforeEach(() => {
target = new Editor();
em = target;
coll = new Selectors();
view = new ClassTagsView({
config: { em: target },
config: { em },
collection: coll
});
@ -42,16 +47,16 @@ describe('ClassTagsView', () => {
}
};
testContext.compTargetStub = new Component();
compTest = new Component();
testContext.compTargetStub = compTest;
fixtures.innerHTML = '';
fixture.empty().appendTo(fixtures);
fixture.append(view.render().el);
testContext.btnAdd = view.$addBtn;
testContext.input = view.$el.find('input#' + view.newInputId);
testContext.$tags = fixture.find('#tags-c');
testContext.$states = fixture.find('#states');
testContext.$statesC = fixture.find('#input-c');
testContext.input = view.$el.find('[data-input]');
testContext.$tags = fixture.find('[data-selectors]');
testContext.$statesC = fixture.find('[data-states-c]');
});
afterEach(() => {
@ -81,7 +86,7 @@ describe('ClassTagsView', () => {
test('Stop tag creation', () => {
testContext.btnAdd.trigger('click');
testContext.input.val('test');
testContext.input.trigger('blur');
testContext.input.trigger('focusout');
expect(testContext.btnAdd.css('display')).not.toEqual('none');
expect(testContext.input.css('display')).toEqual('none');
expect(testContext.input.val()).toEqual(null);
@ -129,9 +134,8 @@ describe('ClassTagsView', () => {
});
test('Accept new tags', () => {
view.compTarget = testContext.compTargetStub;
em.setSelected(compTest);
view.addNewTag('test');
view.compTarget = testContext.compTargetStub;
view.addNewTag('test2');
expect(testContext.$tags.children().length).toEqual(2);
});
@ -155,19 +159,18 @@ describe('ClassTagsView', () => {
test('States are visible in case of more tags inside', () => {
coll.add({ label: 'test' });
view.updateStateVis();
expect(testContext.$statesC.css('display')).toEqual('block');
expect(testContext.$statesC.css('display')).toEqual('');
});
test('Update state visibility on new tag', () => {
sinon.stub(view, 'updateStateVis');
sinon.stub(target, 'get').returns(testContext.targetStub);
view.compTarget = testContext.compTargetStub;
em.setSelected(compTest);
view.addNewTag('test');
expect(view.updateStateVis.called).toEqual(true);
});
test('Update state visibility on removing of the tag', () => {
view.compTarget = testContext.compTargetStub;
em.setSelected(compTest);
view.addNewTag('test');
sinon.stub(view, 'updateStateVis');
coll.remove(coll.at(0));
@ -187,6 +190,100 @@ describe('ClassTagsView', () => {
);
});
describe('_commonSelectors', () => {
test('Returns empty array with no arguments', () => {
expect(view._commonSelectors()).toEqual([]);
});
test('Returns the first item if only one argument is passed', () => {
const item = [1, 2];
expect(view._commonSelectors(item)).toEqual(item);
});
test('Returns corret output with 2 arrays', () => {
const item1 = [1, 2, 3, 4];
const item2 = [3, 4, 5, 6];
expect(view._commonSelectors(item1, item2)).toEqual([3, 4]);
});
test('Returns corret output with more arrays', () => {
const item1 = [1, 2, 3, 4, 5];
const item2 = [3, 4, 5, 6];
const item3 = [30, 5, 6];
expect(view._commonSelectors(item1, item2, item3)).toEqual([5]);
});
});
describe('getCommonSelectors', () => {
test('Returns empty array with no targets', () => {
expect(view.getCommonSelectors({ targets: [] })).toEqual([]);
});
test('Returns the selectors of a single component', () => {
const cmp = newComponent({ classes: 'test1 test2 test3' });
const selectors = cmp.getSelectors();
const result = view.getCommonSelectors({ targets: [cmp] });
expect(getSelectorNames(result)).toEqual(getSelectorNames(selectors));
});
test('Returns common selectors of two components', () => {
const cmp1 = newComponent({ classes: 'test1 test2 test3' });
const cmp2 = newComponent({ classes: 'test1 test2' });
const result = view.getCommonSelectors({ targets: [cmp1, cmp2] });
expect(getSelectorNames(result)).toEqual(['.test1', '.test2']);
});
test('Returns common selectors of more components', () => {
const cmp1 = newComponent({ classes: 'test1 test2 test3' });
const cmp2 = newComponent({ classes: 'test1 test2' });
const cmp3 = newComponent({ classes: 'test2 test3' });
const result = view.getCommonSelectors({ targets: [cmp1, cmp2, cmp3] });
expect(getSelectorNames(result)).toEqual(['.test2']);
});
test('Returns empty array with components without common selectors', () => {
const cmp1 = newComponent({ classes: 'test1 test2 test3' });
const cmp2 = newComponent({ classes: 'test1 test2' });
const cmp3 = newComponent({ classes: 'test4' });
const result = view.getCommonSelectors({ targets: [cmp1, cmp2, cmp3] });
expect(getSelectorNames(result)).toEqual([]);
});
});
describe('updateSelection', () => {
test('Returns empty array without targets', () => {
expect(view.updateSelection([])).toEqual([]);
});
test('Returns empty array with invalid selectors', () => {
expect(view.updateSelection('body .test')).toEqual([]);
});
test('Returns array with common selectors from Components', () => {
const cmp1 = newComponent({ classes: 'test1 test2 test3' });
const cmp2 = newComponent({ classes: 'test1 test2' });
const cmp3 = newComponent({ classes: 'test2 test3' });
const result = view.updateSelection([cmp1, cmp2, cmp3]);
expect(getSelectorNames(result)).toEqual(['.test2']);
});
test('Returns array with common selectors from CssRule', () => {
const rule1 = newRule({ selectors: 'test1 test2 test3'.split(' ') });
const rule2 = newRule({ selectors: 'test1 test2'.split(' ') });
const rule3 = newRule({ selectors: 'test2 test3'.split(' ') });
const result = view.updateSelection([rule1, rule2, rule3]);
expect(getSelectorNames(result)).toEqual(['.test2']);
});
test('Returns array with common selectors from CssRule and Components', () => {
const rule1 = newRule({ selectors: 'test1 test2 test3'.split(' ') });
const rule2 = newRule({ selectors: 'test1 test2'.split(' ') });
const cmp1 = newComponent({ classes: 'test2 test3' });
const result = view.updateSelection([rule1, rule2, cmp1]);
expect(getSelectorNames(result)).toEqual(['.test2']);
});
});
describe('Should be rendered correctly', () => {
test('Has label', () => {
expect(view.$el.find('#label')[0]).toBeTruthy();

Loading…
Cancel
Save