silex-website-builder-goodevent
Version:
Free and easy website builder for everyone.
475 lines (430 loc) • 16 kB
text/typescript
/**
* 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/
*/
/**
* @fileoverview
* This class is used to manage Prodotype components
* Components are based on Silex elements, use Prodotype to render a templates
*/
import { Constants } from '../../Constants';
import { Prodotype, ProdotypeCompDef } from '../externs';
import { Model, View } from '../types';
import { ComponentData, PseudoClass, PseudoClassData, SilexId, StyleData, StyleName, Visibility } from './Data';
/**
* Manage Prodotype components and styles
*
* @class {silex.model.Component}
*/
export class Component {
prodotypeComponent: Prodotype = null;
prodotypeStyle: Prodotype = null;
componentEditorElement: HTMLElement = null;
styleEditorElement: HTMLElement = null;
readyCbkArr: Array<(p1: any) => any> = [];
/**
* @param model model class which holds the other models
* @param view view class which holds the other views
*/
constructor(public model: Model, public view: View) {}
/**
* load the Prodotype library
*/
init(componentEditorElement, styleEditorElement) {
this.componentEditorElement = componentEditorElement;
this.styleEditorElement = styleEditorElement;
// tslint:disable:no-string-literal
this.prodotypeComponent = new window['Prodotype'](componentEditorElement, './prodotype/components');
this.prodotypeStyle = new window['Prodotype'](styleEditorElement, './prodotype/styles');
this.prodotypeComponent.ready((err) => {
this.readyCbkArr.forEach((cbk) => cbk(err));
this.readyCbkArr = [];
});
}
/**
* notify when Prodotype library is ready
* @param cbk callback to be called when prodotype is ready
*/
ready(cbk: (p1: any) => any) {
if (this.prodotypeComponent) {
this.prodotypeComponent.ready((err) => cbk(err));
} else {
this.readyCbkArr.push(cbk);
}
}
/**
* check existance and possibly create the body style if it is missing
* @param doc docment of the iframe containing the website
*/
initStyles(doc: Document) {
const element = doc.body;
// make sure that the style exists
const styleData = this.model.property.getStyleData(Constants.BODY_STYLE_CSS_CLASS);
if (!styleData) {
this.initStyle(Constants.BODY_STYLE_NAME, Constants.BODY_STYLE_CSS_CLASS, null);
}
// make sure that body has the style
element.classList.add(Constants.BODY_STYLE_CSS_CLASS);
}
/**
* not needed? we sometimes use !!this.model.property.getElementData(element,
* Constants.COMPONENT_TYPE)
* @return true if el is a component (not only an element)
*/
isComponent(el: HTMLElement): boolean {
return el.classList.contains(Constants.COMPONENT_CLASS_NAME);
}
/**
* get Prodotype descriptor of the components
* @return component descriptors
*/
getComponentsDef(type: string): ProdotypeCompDef {
const obj = type === Constants.COMPONENT_TYPE ? this.prodotypeComponent : this.prodotypeStyle;
return obj ? obj.componentsDef : ({} as ProdotypeCompDef);
}
/**
* @param element component just added
* @param templateName type of component
*/
initComponent(element: HTMLElement, templateName: string) {
const name = this.prodotypeComponent.createName(templateName, this.getProdotypeComponents(Constants.COMPONENT_TYPE));
// for selection (select all components)
element.classList.add(Constants.COMPONENT_CLASS_NAME);
// for styles (select buttons and apply a style)
this.model.property.setElementComponentData(element, {name, templateName});
// first rendering of the component
this.render(element, () => {
// update the dependencies once the component is added
this.updateDepenedencies(Constants.COMPONENT_TYPE);
});
// css styles
const componentsDef = this.getComponentsDef(Constants.COMPONENT_TYPE);
const comp = componentsDef[templateName];
if (comp) {
// apply the style found in component definition
if (comp.initialCss) {
this.applyStyleTo(element, comp.initialCss);
}
// same for the container inside the element (content node)
if (comp.initialCssContentContainer) {
this.applyStyleTo(
this.model.element.getContentNode(element),
comp.initialCssContentContainer);
}
// same for CSS classes to apply
// apply the style found in component definition
// this includes the css class of the component (component-templateName)
const cssClasses = this.getCssClasses(templateName);
if (cssClasses) {
const oldClassName = this.model.element.getClassName(element);
this.model.element.setClassName(
element, oldClassName + ' ' + cssClasses.join(' '));
}
}
}
/**
* render the component
* this is made using prodotype
* the template is expanded with the data we have for this component
* used when the component is created, or duplicated (paste)
* @param element component to render
*/
render(element: HTMLElement, opt_cbk?: (() => any)) {
this.renderType(element, Constants.COMPONENT_TYPE, () => {
this.renderType(element, Constants.STYLE_TYPE, opt_cbk);
});
}
getProdotype(type) {
switch (type) {
case Constants.COMPONENT_TYPE:
return this.prodotypeComponent;
case Constants.STYLE_TYPE:
return this.prodotypeStyle;
default:
throw new Error('Unknown type in renderType');
}
}
/**
* render a component or style
*/
renderType(element: HTMLElement, type: SilexId|StyleName, opt_cbk?: (() => any)) {
const data = type === Constants.COMPONENT_TYPE ?
this.model.property.getElementComponentData(element) :
this.model.property.getElementStyleData(element);
if (data) {
const templateName = data.templateName;
const prodotype = this.getProdotype(type);
prodotype.decorate(templateName, data).then((html) => {
this.model.element.setInnerHtml(element, html);
// notify the owner
if (opt_cbk) {
opt_cbk();
}
// execute the scripts
// FIXME: should exec scripts only after dependencies are loaded
this.executeScripts(element);
});
} else {
if (opt_cbk) {
opt_cbk();
}
}
}
/**
* get all CSS classes set on this component when it is created
* this includes the css class of the component (component-templateName)
* @param templateName the component's template name
* @return an array of CSS classes
*/
getCssClasses(templateName: string): string[] {
const componentsDef = this.getComponentsDef(Constants.COMPONENT_TYPE);
const comp = componentsDef[templateName];
let cssClasses = [Constants.COMPONENT_CLASS_NAME + '-' + templateName];
if (comp) {
// class name is either an array
// or a string or null
switch (typeof comp.initialCssClass) {
case 'undefined':
break;
case 'string':
cssClasses = cssClasses.concat(comp.initialCssClass.split(' '));
break;
default:
cssClasses = cssClasses.concat(comp.initialCssClass);
}
} else {
console.error(`Error: component's definition not found in prodotype templates, with template name "${templateName}".`);
}
return cssClasses;
}
/**
* eval the scripts found in an element
* this is useful when we render a template, since the scripts are executed
* only when the page loads
*/
executeScripts(element: HTMLElement) {
// execute the scripts
const scripts = element.querySelectorAll('script');
for (const el of scripts) {
// tslint:disable:no-string-literal
this.model.file.getContentWindow()['eval'](el.innerText);
}
}
/**
* apply a style to an element
*/
applyStyleTo(element: HTMLElement, styleObj: any) {
const style = this.model.property.getStyle(element, false) || {};
for (const name in styleObj) {
style[name] = styleObj[name];
}
this.model.property.setStyle(element, style, false);
}
/**
* @param type, Constants.COMPONENT_TYPE or Constants.STYLE_TYPE
*/
getProdotypeComponents(type: string): Array<ComponentData|StyleData> {
const className = type === Constants.COMPONENT_TYPE ? Constants.COMPONENT_CLASS_NAME : Constants.STYLE_CLASS_NAME;
const attrName = type === Constants.COMPONENT_TYPE ? Constants.ELEMENT_ID_ATTR_NAME : 'data-style-id';
return Array.from(this.model.file.getContentDocument().querySelectorAll('.' + className))
.map((el) => {
const attr = el.getAttribute(attrName);
const data = type === Constants.COMPONENT_TYPE ? this.model.property.getComponentData(attr) : this.model.property.getStyleData(attr);
return data;
})
.filter((data) => !!data);
}
/**
* update the dependencies of Prodotype components
* FIXME: should have a callback to know if/when scripts are loaded
* @param type, Constants.COMPONENT_TYPE or Constants.STYLE_TYPE
*/
updateDepenedencies(type: string) {
const head = this.model.head.getHeadElement();
const components = this.getProdotypeComponents(type);
const prodotype = this.getProdotype(type);
// remove unused dependencies (scripts and style sheets)
const nodeList =
this.model.head.getHeadElement().querySelectorAll('[data-dependency]');
const elements = [];
for (const el of nodeList) {
elements.push(el);
}
const unused = prodotype.getUnusedDependencies(elements, components);
for (const el of unused) {
head.removeChild(el);
}
// add missing dependencies (scripts and style sheets)
const missing = prodotype.getMissingDependencies(head, components);
for (const el of missing) {
el.setAttribute('data-dependency', '');
head.appendChild(el);
}
}
/**
* hide component editors
*/
resetSelection(type: string) {
if (type === Constants.COMPONENT_TYPE) {
if (this.prodotypeComponent) {
this.prodotypeComponent.edit();
}
} else {
if (this.prodotypeStyle) {
this.prodotypeStyle.edit();
}
}
}
/**
* remove the editable elements from an HTML element and store them in an HTML
* fragment
* @param parentElement, the element whose children we want to save
* @return an HTML fragment with the editable children in it
*/
saveEditableChildren(parentElement: HTMLElement): DocumentFragment {
const fragment = document.createDocumentFragment();
Array.from(parentElement.children)
.forEach((el) => {
if (el.classList.contains('editable-style')) {
fragment.appendChild(el.cloneNode(true));
}
});
return fragment;
}
removeStyle(className) {
// remove prodotype data from json object
this.model.property.setStyleData(className);
// remove style from dom
const head = this.model.head.getHeadElement();
const elStyle = head.querySelector(`[data-style-id="${className}"]`);
if (elStyle) {
head.removeChild(elStyle);
}
// update dependencies
this.updateDepenedencies(Constants.STYLE_TYPE);
}
/**
* save an empty style or reset a style
*/
initStyle(displayName: string, className: StyleName, opt_data?: StyleData) {
// render all pseudo classes in all visibility object
this.getPseudoClassData(opt_data || ({
className: '',
displayName: '',
templateName: '',
styles: {desktop: {normal: {}}},
} as StyleData))
.forEach((pseudoClassData) => {
this.componentStyleChanged(className, pseudoClassData.pseudoClass, pseudoClassData.visibility, pseudoClassData.data, displayName);
});
this.updateDepenedencies(Constants.STYLE_TYPE);
}
/**
* build an array of all the data we provide to Prodotype for the "text"
* template
*/
getPseudoClassData(styleData: StyleData): Array<{visibility: Visibility, pseudoClass: PseudoClass, data: PseudoClassData}> {
// return all pseudo classes in all visibility object
// flatten
// build an object for each pseudoClass
// build an object for each existing visibility
return Constants.STYLE_VISIBILITY
.map((visibility) => {
return {
visibility,
data: styleData.styles[visibility],
};
})
.filter((obj) => !!obj.data)
.map((vData) => {
const arrayOfPCData = [];
for (const pcName in vData.data) {
arrayOfPCData.push({
visibility: vData.visibility,
pseudoClass: pcName,
/* unused, the data is in data */
data: vData.data[pcName],
});
}
return arrayOfPCData;
})
.reduce((acc, val) => acc.concat(val), []);
}
/**
* apply the style to the dom and save it to the JSON object
*/
componentStyleChanged(className: StyleName, pseudoClass: PseudoClass, visibility: Visibility, opt_data?: PseudoClassData, opt_displayName?: string) {
// create a new style if needed
// if (className === Constants.EMPTY_STYLE_CLASS_NAME) {
// const textBoxes = this.model.body.getSelection().filter((el) => this.model.element.getType(el) === Constants.TYPE_TEXT);
// if (textBoxes.length > 0) {
// // create a new unique name
// const allStyles = this.getProdotypeComponents(Constants.STYLE_TYPE) as StyleData[];
// const baseDisplayName = textBoxes.length === 1 ? 'Text Style ' : 'Group Style ';
// const baseClassName = textBoxes.length === 1 ? 'text-style-' : 'group-style-';
// let idx = 1;
// while (allStyles.filter((obj) => obj.className === baseClassName + idx.toString()).length > 0) {
// idx++;
// }
// opt_displayName = baseDisplayName + idx;
// className = baseClassName + idx;
// // apply to the selection
// textBoxes.forEach((element) => element.classList.add(className));
// }
// }
// expose the class name and pseudo class to the prodotype template
const newData = opt_data || {};
newData.className = className;
newData.pseudoClass = pseudoClass;
// store the component's data for later edition
const styleData = (this.model.property.getStyleData(className) || {
className,
templateName: 'text',
displayName: opt_displayName,
styles: {},
} as StyleData);
if (!styleData.styles[visibility]) {
styleData.styles[visibility] = {};
}
styleData.styles[visibility][pseudoClass] = newData;
this.model.property.setStyleData(className, styleData);
// update the head style with the new template
const head = this.model.head.getHeadElement();
let elStyle = head.querySelector(`[data-style-id="${className}"]`);
if (!elStyle) {
const doc = this.model.file.getContentDocument();
elStyle = doc.createElement('style');
elStyle.className = Constants.STYLE_CLASS_NAME;
elStyle.setAttribute('type', 'text/css');
elStyle.setAttribute('data-style-id', className);
head.appendChild(elStyle);
}
// render all pseudo classes in all visibility object
const pseudoClassData = this.getPseudoClassData(styleData);
if (pseudoClassData.length > 0) {
Promise.all(pseudoClassData.map((obj) => {
return this.prodotypeStyle.decorate('text', obj.data)
.then((html) => this.addMediaQuery(html, obj.visibility));
}) as Array<Promise<string>>)
.then((htmlStrings) => {
elStyle.innerHTML = htmlStrings.join('');
});
}
}
/**
* add a media query around the style string
* when needed for mobile-only
*/
addMediaQuery(html: string, visibility: Visibility) {
if (visibility === Constants.STYLE_VISIBILITY[0]) {
return html;
}
return this.model.property.addMediaQuery(html);
}
}