/**
* Silex, live web creation
* http://projects.silexlabs.org/?/silex/
*
* Copyright (c) 2012 Silex Labs
* http://www.silexlabs.org/
*
* Silex is available under the GPL license
* http://www.silexlabs.org/silex/silex-licensing/
*/
import { SelectableState } from 'drag-drop-stage-component/src/ts/Types';
import {Constants} from '../../../Constants';
import {StyleName} from '../../model/Data';
import {Visibility} from '../../model/Data';
import {StyleData} from '../../model/Data';
import {Tracker} from '../../service/tracker';
import {Controller} from '../../types';
import {Model} from '../../types';
import {SilexNotification} from '../../utils/notification';
import {PaneBase} from './pane-base';
/**
* @fileoverview The style editor pane is displayed in the property panel on the
* right. It is a prodotype component used to edit the css styles
*
*/
export class StyleEditorPane extends PaneBase {
// tracker for analytics
tracker: any;
styleComboPrevValue: StyleName = '';
// Build the UI
styleCombo: any;
pseudoClassCombo: any;
mobileOnlyCheckbox: any;
// select elements which have this style
selectionCountTotal: any;
// select only elements on this page
selectionCountPage: any;
/**
*
* @param element container to render the UI
* @param model model class which holds
* the model instances - views use it for
* read operation only
* @param controller structure which holds
* the controller instances
*/
constructor(element: HTMLElement, model: Model, controller: Controller) {
super(element, model, controller);
this.tracker = Tracker.getInstance();
this.styleCombo = this.element.querySelector('.class-name-style-combo-box');
this.pseudoClassCombo = this.element.querySelector('.pseudoclass-style-combo-box');
this.mobileOnlyCheckbox = this.element.querySelector('.visibility-style-checkbox');
this.pseudoClassCombo.onchange = (e) => {
this.tracker.trackAction('style-editor-events', 'select-pseudo-class');
this.controller.editMenuController.editStyle(this.styleCombo.value, this.getPseudoClass(), this.getVisibility());
const styleData = (this.model.property.getStyleData(this.styleCombo.value) || {} as StyleData);
this.updateTagButtonBar(styleData);
};
this.mobileOnlyCheckbox.onchange = (e) => {
// switch the mobile editor mode
this.controller.propertyToolController.view.workspace.setMobileEditor(
this.mobileOnlyCheckbox.checked);
// refresh the view
this.controller.propertyToolController.refreshView();
};
this.styleCombo.onchange = (e) => {
this.tracker.trackAction('style-editor-events', 'apply-style');
this.applyStyle(this.states.map((state) => state.el), this.styleCombo.value);
// refresh the view
this.controller.propertyToolController.refreshView();
};
(this.element.querySelector('.add-style') as HTMLElement).onclick = (e) => {
this.tracker.trackAction('style-editor-events', 'create-style');
this.createStyle();
};
(this.element.querySelector('.remove-style') as HTMLElement).onclick = (e) => {
this.tracker.trackAction('style-editor-events', 'remove-style');
// delete from styles list
this.deleteStyle(this.styleCombo.value);
};
// un-apply style
(this.element.querySelector('.unapply-style') as HTMLElement).onclick = (e) => {
this.tracker.trackAction('style-editor-events', 'unapply-style');
this.states.filter((state) => state.el !== this.model.body.getBodyElement())
.forEach((state) => {
state.el.classList.remove(this.styleCombo.value);
});
// refresh the view
this.controller.propertyToolController.refreshView();
};
this.selectionCountTotal = this.element.querySelector('.total');
this.selectionCountTotal.onclick = (e) => {
this.tracker.trackAction(
'style-editor-events', 'select-elements-with-style');
const newSelection = this.getElementsWithStyle(this.styleCombo.value, true);
this.model.body.setSelection(newSelection);
};
this.selectionCountPage = this.element.querySelector('.on-page');
this.selectionCountPage.onclick = (e) => {
this.tracker.trackAction(
'style-editor-events', 'select-all-elements-with-style');
const newSelection =
this.getElementsWithStyle(this.styleCombo.value, false);
this.model.body.setSelection(newSelection);
};
// duplicate a style
(this.element.querySelector('.duplicate-style') as HTMLElement).onclick = (e) => {
this.tracker.trackAction('style-editor-events', 'duplicate-style');
this.createStyle(this.model.property.getStyleData(this.styleCombo.value));
};
// reset style:
// this.model.component.initStyle(this.styleCombo.options[this.styleCombo.selectedIndex].text,
// this.styleCombo.value, this.getPseudoClass(), this.getVisibility());
// rename style
(this.element.querySelector('.edit-style') as HTMLElement).onclick = (e) => {
this.tracker.trackAction('style-editor-events', 'edit-style');
const oldClassName = this.styleCombo.value;
if (oldClassName === Constants.BODY_STYLE_CSS_CLASS) {
SilexNotification.alert('Rename a style', `
The style '${Constants.BODY_STYLE_NAME}' is a special style, you can not rename it.
`,
() => {});
} else {
const data = this.model.property.getStyleData(oldClassName);
this.createStyle(data, (name) => {
// update the style name
this.getElementsWithStyle(oldClassName, true).forEach((el) => {
el.classList.add(this.styleCombo.value);
});
// delete the old one
if (oldClassName !== Constants.EMPTY_STYLE_CLASS_NAME) {
// case of rename the empty style (=> only create a new style)
this.deleteStyle(oldClassName, false);
}
});
}
};
// for tracking only
(this.element.querySelector('.style-editor-tag-form .labels') as HTMLElement).onclick =
(e) => {
this.tracker.trackAction('style-editor-events', 'select-tag');
};
}
/**
* Get all the elements which have a given style
* @param includeOffPage, if false it excludes the elements which are not
* visible in the current page
*/
getElementsWithStyle(styleName: StyleName, includeOffPage: boolean): HTMLElement[] {
const doc = this.model.file.getContentDocument();
const newSelection: HTMLElement[] = Array.from(doc.querySelectorAll('.' + styleName));
if (includeOffPage) {
return newSelection;
} else {
return newSelection.filter(
(el) => this.model.page.isInPage(el) ||
this.model.page.getPagesForElement(el).length === 0);
}
}
/**
* get the visibility (mobile+desktop or desktop) of the style being edited
*/
getVisibility(): Visibility {
return Constants.STYLE_VISIBILITY[this.isMobile() ? 1 : 0];
}
/**
* @return true if we are in mobile editor
* because views (view.workspace.get/setMobileEditor) is not accessible from
* other views
* FIXME: find another way to expose isMobileEditor to views
*/
isMobile(): boolean {
return document.body.classList.contains('mobile-mode');
}
/**
* apply a style to a set of elements, remove old styles
*/
applyStyle(elements: HTMLElement[], newStyle: StyleName) {
if (newStyle === Constants.BODY_STYLE_CSS_CLASS) {
SilexNotification.alert('Apply a style', `
The style '${Constants.BODY_STYLE_NAME}' is a special style, it is already applyed to all elements.
`,
() => {});
} else {
this.controller.propertyToolController.undoCheckPoint();
const noBody = elements.filter((el) => el !== this.model.body.getBodyElement());
if (noBody.length > 0) {
noBody.forEach((el) => {
// un-apply the old style if there was one
this.removeAllStyles(el);
// apply the new style if there is one
el.classList.add(newStyle);
});
this.controller.propertyToolController.refreshView();
} else {
SilexNotification.alert('Apply a style', 'Error: you need to select at least 1 element for this action.', () => {});
}
}
}
removeAllStyles(el: HTMLElement) {
this.getStyles([el]).forEach((styleName) => el.classList.remove(styleName));
}
/**
* retrieve the styles applyed to the set of elements
*/
getStyles(elements: HTMLElement[]): StyleName[] {
const allStyles = this.model.component.getProdotypeComponents(Constants.STYLE_TYPE);
return elements
.map((element) => element.className.split(' ')
.filter((className) => className !== ''))
// About this reduce:
// no initial value so the first element in the array will be used, it
// will start with the 2nd element keep only the styles defined in the
// style editor to array of class names in common to all selected elements
// from array of elements to array of array of classNames
.reduce((prev, classNames) => {
return prev.filter((prevClassName) => classNames.indexOf(prevClassName) > -1);
})
.filter((className) => allStyles.find((style: StyleData) => style.className === className));
}
/**
* redraw the properties
* @param states the elements currently selected
* @param pageNames the names of the pages which appear in the current HTML file
* @param currentPageName the name of the current page
*/
redraw(states: SelectableState[], pageNames: string[], currentPageName: string) {
super.redraw(states, pageNames, currentPageName);
// mobile mode
this.mobileOnlyCheckbox.checked = this.isMobile();
// edit the style of the selection
if (states.length > 0) {
// get the selected elements style, i.e. which style applies to them
const selectionStyle = (() => {
// get the class names common to the selection
const classNames = this.getStyles(states.map((state) => state.el));
// choose the style to edit
if (classNames.length >= 1) {
return classNames[0];
}
return Constants.BODY_STYLE_CSS_CLASS;
})();
this.updateStyleList(selectionStyle);
// show text styles only when a text box is selected
const onlyTexts = this.states.length > 0
&& this.states.filter((state) => this.model.element.getType(state.el) !== Constants.TYPE_TEXT).length === 0;
if (onlyTexts) {
this.element.classList.remove('style-editor-notext');
} else {
this.element.classList.add('style-editor-notext');
}
} else {
// FIXME: no need to recreate the whole style list every time the
// selection changes
this.updateStyleList(Constants.BODY_STYLE_CSS_CLASS);
// show the text styles in the case of "all style" so that the user can edit text styles, even when no text box is selected
this.element.classList.remove('style-editor-notext');
}
}
/**
* update the list of styles
* @param styleName: option to select, or null for hide editor or
* `Component.EMPTY_STYLE_CLASS_NAME` for add an empty selection and
* select it
*/
updateStyleList(styleName: StyleName) {
// reset the combo box
this.styleCombo.innerHTML = '';
// add all the existing styles to the dropdown list
// append options to the dom
(styleName === Constants.EMPTY_STYLE_CLASS_NAME ? [{
className: Constants.EMPTY_STYLE_CLASS_NAME,
displayName: Constants.EMPTY_STYLE_DISPLAY_NAME,
}] : [])
.concat(this.model.component.getProdotypeComponents(Constants.STYLE_TYPE) as Array<{className: string, displayName: string}>)
.map((obj) => {
// create the combo box option
const option = document.createElement('option');
option.value = obj.className;
option.innerHTML = obj.displayName;
return option;
})
.forEach((option) => this.styleCombo.appendChild(option));
if (styleName != null ) {
const styleNameNotNull = (styleName as StyleName);
// set the new selection
this.styleCombo.value = (styleNameNotNull as string);
this.element.classList.remove('no-style');
// populate combos
const styleData = (this.model.property.getStyleData(styleNameNotNull) || {} as StyleData);
this.populatePseudoClassCombo(styleData);
this.pseudoClassCombo.disabled = false;
// store prev value
if (this.styleComboPrevValue !== styleNameNotNull) {
// reset state
this.pseudoClassCombo.selectedIndex = 0;
}
this.styleComboPrevValue = styleNameNotNull;
// start editing the style with prodotype
this.controller.editMenuController.editStyle(styleNameNotNull, this.getPseudoClass(), this.getVisibility());
// update selection count
const total = this.getElementsWithStyle(styleNameNotNull, true).length;
const onPage = total === 0 ?
0 :
this.getElementsWithStyle(styleNameNotNull, false).length;
this.selectionCountPage.innerHTML =
`${onPage} on this page (select), `;
this.selectionCountTotal.innerHTML =
`${total} total (select)`;
// update tags buttons
this.updateTagButtonBar(styleData);
} else {
this.element.classList.add('no-style');
}
}
/**
* mark tags push buttons to show which tags have styles
*/
updateTagButtonBar(styleData: StyleData) {
const visibilityData = (styleData.styles || {})[this.getVisibility()] || {};
const tagData = visibilityData[this.getPseudoClass()] || {};
Array.from(this.element.querySelectorAll('[data-prodotype-name]'))
.forEach((el: HTMLElement) => {
const tagName = el.getAttribute('data-prodotype-name');
const label = el.getAttribute('data-initial-value') + (tagData[tagName] ? ' *' : '');
if (el.innerHTML !== label) {
el.innerHTML = label;
}
});
}
/**
* useful to mark combo elements with "*" when there is data there
*/
populatePseudoClassCombo(styleData: StyleData) {
const visibilityData = (styleData.styles || {})[this.getVisibility()];
// populate pseudo class combo
const selectedIndex = this.pseudoClassCombo.selectedIndex;
this.pseudoClassCombo.innerHTML = '';
// get the list of pseudo classes out of prodotype definition
// {"name":"Text
// styles","props":[{"name":"pseudoClass","type":["normal",":hover",":focus-within",
// ...
const componentsDef = this.model.component.getComponentsDef(Constants.STYLE_TYPE);
const pseudoClasses = componentsDef.text.props.filter((prop) => prop.name === 'pseudoClass')[0].type;
// append options to the dom
pseudoClasses
.map((pseudoClass) => {
// create the combo box options
const option = document.createElement('option');
option.value = pseudoClass;
option.innerHTML = pseudoClass +
(!!visibilityData && !!visibilityData[pseudoClass] ? ' *' : '');
return option;
})
.forEach((option) => this.pseudoClassCombo.appendChild(option));
// keep selection
this.pseudoClassCombo.selectedIndex = selectedIndex;
}
/**
* @return normal if pseudo class is ''
*/
getPseudoClass(): string {
return this.pseudoClassCombo.value === '' ? 'normal' :
this.pseudoClassCombo.value;
}
/**
* utility function to create a style in the style combo box or duplicate one
*/
createStyle(opt_data?: StyleData, opt_cbk?: ((p1?: string) => any)) {
const noBody = this.states.filter((state) => state.el !== this.model.body.getBodyElement());
if (noBody.length <= 0) {
SilexNotification.alert('Create a style', 'Error: you need to select at least 1 element for this action.', () => {});
} else {
SilexNotification.prompt('Create a style', 'Enter a name for your style!', opt_data ? opt_data.displayName : '', 'Your Style', (accept, name) => {
if (accept && name && name !== '') {
this.controller.propertyToolController.undoCheckPoint();
const className = 'style-' + name.replace(/ /g, '-').toLowerCase();
this.model.component.initStyle(name, className, opt_data);
this.applyStyle(noBody.map((state) => state.el), className);
// FIXME: needed to select className but
// model.Component::initStyle calls refreshView which calls
// updateStyleList
this.updateStyleList(className);
if (opt_cbk) {
opt_cbk(name);
}
this.controller.propertyToolController.refreshView();
}
});
}
}
/**
* utility function to delete a style in the style
* @param opt_confirm, default is true, if false it will skip user
* confirmation popin
*/
deleteStyle(name: string, opt_confirm?: boolean) {
if (opt_confirm === false) {
this.doDeleteStyle(name);
} else {
if (name === Constants.BODY_STYLE_CSS_CLASS) {
SilexNotification.alert('Delete a style', `
The style '${Constants.BODY_STYLE_NAME}' is a special style, you can not delete it.
`, () => {});
} else {
SilexNotification.confirm('Delete a style', `
I am about to delete the style ${name}!
Are you sure?
`, (accept) => {
if (accept) {
this.doDeleteStyle(name);
}
});
}
}
}
/**
* utility function to delete a style in the style
*/
private doDeleteStyle(name: string) {
const option =
this.styleCombo.querySelector('option[value="' + name + '"]');
// undo checkpoint
this.controller.propertyToolController.undoCheckPoint();
// remove from elements
Array.from(this.model.file.getContentDocument().querySelectorAll('.' + name))
.filter((el) => el !== this.model.body.getBodyElement())
.forEach((el: HTMLElement) => el.classList.remove(name));
// undo checkpoint
this.controller.settingsDialogController.undoCheckPoint();
// remove from model
this.model.component.removeStyle(option.value);
this.styleCombo.removeChild(option);
this.controller.propertyToolController.refreshView();
}
}