silex-website-builder-goodevent
Version:
Free and easy website builder for everyone.
652 lines (589 loc) • 21.2 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 access Silex elements properties
*/
import { Constants } from '../../Constants';
import { Font, Model, View } from '../types';
import { Style } from '../utils/style';
import { ComponentData, CssRule, JsonData, ProdotypeData, ProdotypeTypes, SilexData, SilexId, StyleData, StyleName } from './Data';
export interface CSSRuleInfo {
rule: CSSRule;
parent: CSSRule|StyleSheet;
index: number;
}
/**
* @param model model class which holds the other models
* @param view view class which holds the other views
*/
export class Property {
static EMPTY_PRODOTYPE_DATA: ProdotypeData = {component: {}, style: {}};
/**
* constant for the ID of the style tag
* containing all CSS rules for the elements on stage
* which are being edited with the wysiwyg
*/
static INLINE_STYLE_TAG_CLASS_NAME = 'silex-inline-styles';
/**
* constant for the ID of the HTML node used
* to store Silex data as a JSON object of type SilexData
* containing all CSS rules for the elements on stage
* which are being edited with the wysiwyg
*/
static JSON_STYLE_TAG_CLASS_NAME = 'silex-json-styles';
/**
* constant for the prefix of the IDs given to Silex editable elements
*/
static ELEMENT_ID_PREFIX = 'silex-id-';
/**
* constant for the value of media query for mobile version
* @static
*/
static MOBILE_MEDIA_QUERY = 'only screen and (max-width: 480px), only screen and (max-device-width: 480px)';
/**
* the current file's silex style sheet which holds silex elements styles
* this is stored for performance reasons
*/
styleSheet: CSSStyleSheet = null;
/**
* a number appended to the current timestamp in order to make unique Silex
* IDs
*/
nextId: number = 0;
stylesObj: SilexData = {};
fonts: Font[] = [];
mobileStylesObj: SilexData = {};
/**
* arbitrary data for prodotype components
*/
prodotypeDataObj: ProdotypeData = Property.EMPTY_PRODOTYPE_DATA;
constructor(public model: Model, public view: View) {}
setFonts(fonts: Font[]) {
this.fonts = fonts;
}
/**
* @return returns a copy of this.fonts
*/
getFonts(): Font[] {
return this.fonts.slice();
}
/**
* get/set Silex ID
* @return uniqueId
*/
getSilexId(element: HTMLElement): SilexId {
return element.getAttribute(Constants.ELEMENT_ID_ATTR_NAME);
}
/**
* get/set Silex ID
*/
setSilexId(element: HTMLElement, uniqueId: SilexId) {
const oldId = this.getSilexId(element);
if (oldId) {
element.classList.remove(oldId);
}
element.setAttribute(Constants.ELEMENT_ID_ATTR_NAME, uniqueId);
element.classList.add(uniqueId);
}
/**
* @param opt_doc docment of the iframe containing the website
*/
getElementBySilexId(uniqueId: SilexId, opt_doc?: Document):
Element {
opt_doc = opt_doc || this.model.file.getContentDocument();
return opt_doc.querySelector(
'[' + Constants.ELEMENT_ID_ATTR_NAME + '="' + uniqueId + '"]');
}
/**
* @param opt_doc docment of the iframe containing the website
*/
generateSilexId(opt_doc?: Document): SilexId {
let uniqueId;
do {
uniqueId = Date.now().toString() + '-' + this.nextId++;
} while (this.getElementBySilexId(uniqueId, opt_doc));
return uniqueId;
}
/**
* @param doc docment of the iframe containing the website
*/
initSilexId(element: HTMLElement, doc?: Document) {
// add the selector for this element
const idAndClass = Property.ELEMENT_ID_PREFIX + this.generateSilexId(doc);
this.setSilexId(element, idAndClass);
}
/**
* Convert the styles to json and save it in a script tag
*/
saveProperties(doc) {
let styleTag = doc.querySelector('.' + Property.JSON_STYLE_TAG_CLASS_NAME);
if (!styleTag) {
styleTag = doc.createElement('script');
styleTag.type = 'text/json';
styleTag.classList.add(Property.JSON_STYLE_TAG_CLASS_NAME);
doc.head.appendChild(styleTag);
}
// always save as json, it used to be javascript and sometimes it tabs mess
// up the json
styleTag.type = 'text/json';
const obj = ({
fonts: this.fonts || [],
desktop: this.stylesObj || {},
mobile: this.mobileStylesObj || {},
prodotypeData: {
component: this.prodotypeDataObj.component || {},
style: this.prodotypeDataObj.style || {},
},
} as JsonData);
// TODO: it is useless to store an array, a single object would be better
styleTag.innerHTML = JSON.stringify([obj]);
}
/**
* Load the styles from the json saved in a script tag
*/
loadProperties(doc) {
const styleTag = doc.querySelector('.' + Property.JSON_STYLE_TAG_CLASS_NAME);
if (styleTag != null ) {
const styles = (JSON.parse(styleTag.innerHTML)[0] as any);
this.fonts = styles.fonts || [];
this.stylesObj = styles.desktop || {};
this.mobileStylesObj = styles.mobile || {};
this.prodotypeDataObj = styles.prodotypeData &&
styles.prodotypeData.component &&
styles.prodotypeData.style ?
({
component: styles.prodotypeData.component,
style: styles.prodotypeData.style,
} as ProdotypeData) :
Property.EMPTY_PRODOTYPE_DATA;
// FIXME: put this in backward compat
if (styles.componentData) {
this.prodotypeDataObj.component = styles.componentData;
}
} else {
this.fonts = [];
this.stylesObj = {};
this.mobileStylesObj = {};
this.prodotypeDataObj = Property.EMPTY_PRODOTYPE_DATA;
console.info('Warning: no JSON styles array found in the dom');
}
}
/**
* check existance and possibly create a style tag holding Silex elements
* styles
* @param doc docment of the iframe containing the website
*/
initStyles(doc: Document): HTMLElement {
// make sure of the existance of the style tag with Silex definitions
let styleTag: HTMLElement = doc.querySelector('.' + Property.INLINE_STYLE_TAG_CLASS_NAME);
if (!styleTag) {
styleTag = doc.createElement('style');
styleTag.classList.add(Property.INLINE_STYLE_TAG_CLASS_NAME);
styleTag.setAttribute('type', 'text/css');
doc.head.appendChild(styleTag);
}
this.styleSheet = null;
for (const s of doc.styleSheets) {
if (s.ownerNode &&
s.ownerNode === styleTag) {
this.styleSheet = s as CSSStyleSheet;
}
}
if (this.styleSheet === null) {
console.error('no stylesheet found');
}
return styleTag;
}
/**
* get / set the data associated with an ID
* if opt_prodotypeData is null this data set will removed
*/
setComponentData(id: SilexId, opt_prodotypeData?: ComponentData) {
this.setProdotypeData(id, ProdotypeTypes.COMPONENT, opt_prodotypeData);
}
/**
* get / set the data associated with an ID
* if opt_prodotypeData is null this data set will removed
*/
setStyleData(id: SilexId, opt_prodotypeData?: StyleData) {
this.setProdotypeData(id, ProdotypeTypes.STYLE, opt_prodotypeData);
}
/**
* get / set the data associated with an element
* @return a clone of the data object
*/
getComponentData(id: SilexId): ComponentData {
return (this.getProdotypeData(id, ProdotypeTypes.COMPONENT) as ComponentData |null);
}
/**
* get / set the data associated with an element
* @return a clone of the data object
*/
getStyleData(id: StyleName): StyleData {
return (this.getProdotypeData(id, ProdotypeTypes.STYLE) as StyleData |null);
}
/**
* get / set the data associated with an element
* if opt_componentData is null this will remove the rule
*/
setElementComponentData(element: HTMLElement, opt_componentData?: ComponentData) {
// call private generic method
this.setElementData( element, ProdotypeTypes.COMPONENT, opt_componentData);
}
/**
* get / set the data associated with an element
* if opt_componentData is null this will remove the rule
*/
setElementStyleData(element: HTMLElement, opt_componentData?: StyleData) {
// call private generic method
this.setElementData(
element, ProdotypeTypes.STYLE, opt_componentData);
}
/**
* get / set the data associated with an element
* @return a clone of the data object
*/
getElementComponentData(element: HTMLElement): ComponentData {
// call private generic method
return (
this.getElementData(
element, ProdotypeTypes.COMPONENT) as
ComponentData |
null);
}
/**
* get / set the data associated with an element
* @return a clone of the data object
*/
getElementStyleData(element: HTMLElement): StyleData {
// call private generic method
return (
this.getElementData(element, ProdotypeTypes.STYLE) as
StyleData |
null);
}
/**
* get / set the css style of an element
* this creates or update a rule in the style tag with id
* INLINE_STYLE_TAG_CLASS_NAME if style is null this will remove the rule
*/
setStyle(element: HTMLElement, styleObj: any, isMobile = this.view.workspace.getMobileEditor()) {
const deleteStyle = !styleObj;
const style = styleObj || {};
const elementId = (this.getSilexId(element) as SilexId);
if (!deleteStyle) {
// styles of sections are special
if (this.model.element.isSection(element)) {
// do not apply width to sections
delete style.width;
// apply height to section content and not section itself
const contentElement = (this.model.element.getContentNode(element) as HTMLElement);
const contentStyle = this.getStyle(contentElement, isMobile) || {};
if (style['min-height'] && style['min-height'] !== contentStyle['min-height']) {
contentStyle['min-height'] = style['min-height'];
this.setStyle(contentElement, contentStyle, isMobile);
}
// do not apply min-height to the section itself
delete style['min-height'];
} else if (style.width
&& this.model.element.isSectionContent(element)
&& !this.view.workspace.getMobileEditor()) {
// set website width
const width = parseInt(style.width);
if (!!width && this.model.head.getWebsiteWidth() !== width) {
this.model.head.setWebsiteWidth(width);
}
delete style.width;
}
}
// store in JSON
const targetObj = isMobile ? this.mobileStylesObj : this.stylesObj;
if (deleteStyle) {
delete targetObj[elementId];
} else {
targetObj[elementId] = style as CssRule;
}
// find the index of the rule for the given element
const cssRuleObject = this.findCssRule(elementId, isMobile);
// update or create the rule
if (cssRuleObject) {
this.styleSheet.deleteRule(cssRuleObject.index);
}
// convert style to string
if (!deleteStyle) {
// we use the class name because elements have their ID as a css class too
const styleStr = '.' + elementId + '{' + Style.styleToString(style) + '} ';
if (isMobile) {
// add the rule to the dom to see the changes, mobile rules after
// desktop ones
this.styleSheet.insertRule(this.addMediaQuery(styleStr), this.styleSheet.cssRules.length);
} else {
this.styleSheet.insertRule(styleStr, 0);
}
}
this.view.stageWrapper.redraw();
}
/**
* get / set the css style of an element
* @param opt_isMobile defaults to the global setting of silex.view.Workspace
* @return a clone of the style object
*/
getStyle(element: HTMLElement, opt_isMobile?: boolean): CssRule {
const elementId = (this.getSilexId(element) as SilexId);
const isMobile = opt_isMobile != null ? opt_isMobile : this.view.workspace.getMobileEditor();
const targetObj = (isMobile ? this.mobileStylesObj : this.stylesObj as SilexData);
const style = (targetObj[elementId] as CssRule);
if (!!style) {
// convert to obj (also makes it a copy we can change)
const clone = (JSON.parse(JSON.stringify(style)) as CssRule);
// styles of sections are special
// the min-height of the section is stored on its content container
if (this.model.element.isSection(element)) {
// min-height of sections is the min-height of section content
const contentElement = (this.model.element.getContentNode(element) as HTMLElement);
if (contentElement === element) {
console.warn('This section has no content, how is this possible?', element);
} else {
const contentStyle = this.getStyle(contentElement, isMobile);
if (contentStyle) {
clone['min-height'] = contentStyle['min-height'];
}
}
}
if (this.model.element.isSection(element) || this.model.element.isSectionContent(element)) {
clone.width = this.model.head.getWebsiteWidth() + 'px';
}
return clone;
}
return null;
}
/**
* @return null if not found
*/
findCssRule(elementId: string, isMobile: boolean): CSSRuleInfo {
// find the rule for the given element
for (let idx = 0; idx < this.styleSheet.cssRules.length; idx++) {
const cssRule = this.styleSheet.cssRules[idx] as any; // FIXME: should be CSSRule ?
// we use the class name because elements have their ID as a css class too
if ((isMobile === false && cssRule.selectorText === '.' + elementId) ||
(cssRule.media && cssRule.cssRules && cssRule.cssRules[0] &&
cssRule.cssRules[0].selectorText === '.' + elementId)) {
return {
rule: cssRule,
parent: this.styleSheet,
index: idx,
};
}
}
return null;
}
/**
* @param doc docment of the iframe containing the website
* @return the string defining all elements styles
*/
getAllStyles(doc: Document): string {
const elements = doc.querySelectorAll('body, .' + Constants.EDITABLE_CLASS_NAME);
let allStyles = '';
for (const element of elements) {
const elementId = (this.getSilexId(element as HTMLElement) as SilexId);
// desktop
if (this.stylesObj[elementId]) {
const styleStr = Style.styleToString(
this.stylesObj[elementId], '\n ');
allStyles += '.' + elementId + ' {' + styleStr + '\n}\n';
}
// mobile
if (this.mobileStylesObj[elementId]) {
const styleStr = Style.styleToString(
this.mobileStylesObj[elementId], '\n ');
allStyles +=
this.addMediaQuery('.' + elementId + ' {' + styleStr + '\n}\n');
}
}
return allStyles;
}
/**
* add a media query around the style string
* will make the style mobile-only
*/
addMediaQuery(styleStr: string) {
return '@media ' + Property.MOBILE_MEDIA_QUERY + '{' + styleStr + '}';
}
/**
* compute the bounding box of the given elements
* it takes into account only the elements which have top, left, width and
* height set in px
* @return the bounding box containing all the elements
*/
getBoundingBox(elements: HTMLElement[]): {top?: number, left?: number, width?: number, height?: number} {
// compute the positions and sizes, which may end up to be NaN or a number
let top = NaN; let left = NaN; let right = NaN; let bottom = NaN;
// browse all elements and compute the containing rect
elements.forEach((element) => {
// retrieve the styles strings (with "px")
let elementStyle = this.getStyle(element, false);
if (!elementStyle) {
elementStyle = {
'top': '',
'left': '',
'width': '',
'height': '',
'min-height': '',
};
} else {
if (!elementStyle.top) {
elementStyle.top = '';
}
if (!elementStyle.left) {
elementStyle.left = '';
}
if (!elementStyle.width) {
elementStyle.width = '';
}
if (!elementStyle.height) {
elementStyle.height = '';
}
}
// in mobile editor, if a mobile style is set use it
if (this.view.workspace.getMobileEditor()) {
const mobileStyle = this.getStyle(element, true);
if (mobileStyle != null ) {
if (!!mobileStyle.top) {
elementStyle.top = mobileStyle.top;
}
if (!!mobileStyle.left) {
elementStyle.left = mobileStyle.left;
}
if (!!mobileStyle.width) {
elementStyle.width = mobileStyle.width;
}
if (!!mobileStyle.height) {
elementStyle.height = mobileStyle.height;
}
}
}
// compute the styles numerical values, which may end up to be NaN or a
// number
const elementMinWidth = elementStyle['min-width'] ?
parseFloat(elementStyle['min-width'].substr(
0, elementStyle['min-width'].indexOf('px'))) :
null;
const elementWidth = Math.max(
elementMinWidth || 0,
parseFloat(elementStyle.width.substr(
0, elementStyle.width.indexOf('px'))));
const elementMinHeight = elementStyle['min-height'] ?
parseFloat(elementStyle['min-height'].substr(
0, elementStyle['min-height'].indexOf('px'))) :
null;
const elementHeight = Math.max(
elementMinHeight || 0,
parseFloat(elementStyle.height.substr(
0, elementStyle.height.indexOf('px'))) ||
0);
const elementTop = parseFloat(
elementStyle.top.substr(0, elementStyle.top.indexOf('px')));
const elementLeft = parseFloat(
elementStyle.left.substr(0, elementStyle.left.indexOf('px')));
const elementRight = (elementLeft || 0) + elementWidth;
const elementBottom = (elementTop || 0) + elementHeight;
// take the smallest top and left and the bigger bottom and rigth
top = isNaN(top) ? elementTop :
Math.min(top, isNaN(elementTop) ? top : elementTop);
left = isNaN(left) ?
elementLeft :
Math.min(left, isNaN(elementLeft) ? left : elementLeft);
bottom = isNaN(bottom) ?
elementBottom :
Math.max(bottom, isNaN(elementBottom) ? bottom : elementBottom);
right = isNaN(right) ?
elementRight :
Math.max(right, isNaN(elementRight) ? right : elementRight);
});
// no value for NaN results
const res: {top?: number, left?: number, width?: number, height?: number} = {};
if (!isNaN(top)) {
res.top = top;
}
if (!isNaN(left)) {
res.left = left;
}
if (!isNaN(bottom)) {
res.height = bottom - (top || 0);
}
if (!isNaN(right)) {
res.width = right - (left || 0);
}
return res;
}
/**
* get / set the data associated with an ID
* if opt_prodotypeData is null this data set will removed
*/
private setProdotypeData(
id: SilexId, type: ProdotypeTypes,
opt_prodotypeData?: ComponentData|StyleData) {
// store in object
if (opt_prodotypeData) {
this.prodotypeDataObj[type][id] = opt_prodotypeData;
} else {
delete this.prodotypeDataObj[type][id];
}
setTimeout(() => {
this.view.stageWrapper.redraw();
}, 0);
}
/**
* get / set the data associated with an element
* @return a clone of the data object
*/
private getProdotypeData(id: SilexId, type: ProdotypeTypes): ComponentData
|StyleData {
const res = this.prodotypeDataObj[type][id];
if (res) {
const clone =
(JSON.parse(JSON.stringify(res)) as ComponentData | StyleData);
// clone the object
return clone;
}
return null;
}
/**
* get / set the data associated with an element
* if opt_componentData is null this will remove the rule
*/
private setElementData(
element: HTMLElement, type: ProdotypeTypes,
opt_componentData?: ComponentData|StyleData) {
// a section's container content can not be a component, but the section
// itself may be
element = this.model.element.noSectionContent(element);
// get the internal ID
const elementId = (this.getSilexId(element) as SilexId);
// store in object
this.setProdotypeData(elementId, type, opt_componentData);
}
/**
* get / set the data associated with an element
* @return a clone of the data object
*/
private getElementData(element: HTMLElement, type: ProdotypeTypes):
ComponentData|StyleData {
// a section's container content can not be a component, but the section
// itself may be
element = this.model.element.noSectionContent(element);
// get the internal ID
const elementId = (this.getSilexId(element) as SilexId);
// returns value of object
return this.getProdotypeData(elementId, type);
}
}