/** * 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 { 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>) .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); } }