/**
* 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 A controller listens to a view element,
* and call the main {silex.controller.Controller} controller's methods
*
*/
import { Constants } from '../../Constants';
import { ComponentData, PseudoClass, StyleName, Visibility } from '../model/Data';
import { DomDirection, SilexElement } from '../model/element';
import { ClipboardItem, FileInfo, LinkData, Model, View } from '../types';
import { InvalidationManager } from '../utils/invalidation-manager';
import { SilexNotification } from '../utils/notification';
import { Style } from '../utils/style';
import { FileExplorer } from '../view/dialog/file-explorer';
import { ControllerBase } from './controller-base';
/**
* @param view view class which holds the other views
*/
export class EditMenuController extends ControllerBase {
/**
* invalidation mechanism
*/
undoredoInvalidationManager: InvalidationManager;
constructor(model: Model, view: View) {
super(model, view);
this.undoredoInvalidationManager = new InvalidationManager(1000);
}
/**
* undo the last action
*/
undo() {
if (ControllerBase.undoHistory.length > 0) {
this.model.body.emptySelection();
this.undoredoInvalidationManager.callWhenReady(() => {
if (ControllerBase.getStatePending === 0 &&
ControllerBase.undoHistory.length > 0) {
const state = this.getState();
ControllerBase.redoHistory.push(state);
const prevState = ControllerBase.undoHistory.pop();
this.restoreState(prevState);
this.view.menu.redraw();
} else {
requestAnimationFrame(() => this.undo());
}
});
}
}
/**
* redo the last action
*/
redo() {
if (ControllerBase.redoHistory.length > 0) {
this.model.body.emptySelection();
this.undoredoInvalidationManager.callWhenReady(() => {
if (ControllerBase.redoHistory.length > 0) {
const state = this.getState();
ControllerBase.undoHistory.push(state);
const prevState = ControllerBase.redoHistory.pop();
this.restoreState(prevState);
this.view.menu.redraw();
}
});
}
}
/**
* copy the selection for later paste
*/
copySelection() {
ControllerBase.clipboard = this.cloneItems(this.model.body.getSelection());
this.view.contextMenu.redraw();
}
/**
* clone the selection and make an array of ClipboardItem
*/
cloneItems(elements: HTMLElement[]): ClipboardItem[] {
this.tracker.trackAction('controller-events', 'info', 'copy', 0);
const body = this.model.body.getBodyElement();
// select the sections instead of their container content
const clonesData =
// clone the elements
elements
.map((element) => this.model.element.noSectionContent(element))
.filter((element) => {
// not the body
return body !== element;
// // not an element which has a selected parent
// // FIXME: closest is not yet defined on Element in google
// // closure, remove the array access ['closest'] when it is
// && element.parentElement['closest']('.' + Constants.SELECTED_CLASS_NAME) == null;
})
.map((element) => {
return {
el: element.cloneNode(true) as HTMLElement,
parent: element.parentElement,
};
});
if (clonesData.length > 0) {
// reset clipboard
const clipboard: ClipboardItem[] = [];
// add each selected element to the clipboard
clonesData.forEach((data) => {
// copy the element and its children
clipboard.push(this.recursiveCopy(data.el, data.parent));
});
return clipboard;
}
return [];
}
/**
* make a recursive copy of an element styles/mobileStyle/componentData
* the element and its children are already clones of the selection
* this is needed to "freez" elements properties
* return {silex.types.ClipboardItem}
*/
recursiveCopy(element: HTMLElement, parent: HTMLElement): ClipboardItem {
// duplicate the node
const res: ClipboardItem = {
parent,
element,
style: this.model.property.getStyle(element, false),
mobileStyle: this.model.property.getStyle(element, true),
componentData: this.model.property.getElementComponentData(element),
children: [],
};
// case of a container, handle its children
if (this.model.element.getType(res.element) === Constants.TYPE_CONTAINER) {
const len = res.element.childNodes.length;
for (let idx = 0; idx < len; idx++) {
const el = (res.element.childNodes[idx] as HTMLElement);
if (el.nodeType === 1 && this.model.element.getType(el) != null ) {
res.children.push(this.recursiveCopy(el, el.parentElement));
}
}
}
return res;
}
/**
* paste the previously copied element
*/
pasteClipBoard() {
const elements = this.pasteItems(ControllerBase.clipboard);
// copy again so that we can paste several times (elements will be duplicated again)
ControllerBase.clipboard = this.cloneItems(elements);
}
/**
* paste the previously copied element
*/
pasteItems(clipboard, toDefaultPostion = true): HTMLElement[] {
this.tracker.trackAction('controller-events', 'info', 'paste', 0);
// default is selected element
if (clipboard && clipboard.length > 0) {
// undo checkpoint
this.undoCheckPoint();
// take the scroll into account (drop at (100, 100) from top left corner of the window, not the stage)
let offset = 0;
// add to the container
const selection = clipboard.map((clipboardItem) => {
const element = this.recursivePaste(clipboardItem) as HTMLElement;
// reset editable option
this.doAddElement(element);
// add to stage and set the "silex-just-added" css class
if (toDefaultPostion) {
this.model.element.addElementDefaultPosition(element, offset);
} else {
this.model.element.addElement(clipboardItem.parent, element, offset + 20);
}
offset += 20;
// this is what will be added to selection
return element;
});
// send the scroll to the target
this.view.stageWrapper.center(selection);
// select the new elements
this.model.body.setSelection(selection);
// refresh elements positions
this.view.stageWrapper.redraw();
return selection;
}
}
/**
* add the stored properties of the element and its children to the dom
* also reset the ID of the element and its children
* the elements have already been added to stage
*/
recursivePaste(clipboardItem: ClipboardItem): HTMLElement {
const element = clipboardItem.element;
// reset the ID
this.model.property.initSilexId(element);
// add its children
clipboardItem.children.forEach((childItem) => {
const childElement = this.recursivePaste(childItem);
});
// init component props
if (clipboardItem.componentData) {
this.model.property.setElementComponentData(element, clipboardItem.componentData);
// re-render components (makes inner ID change)
this.model.component.render(element);
}
// add this element
this.model.element.addElement(clipboardItem.parent, element);
// keep the original style
this.model.property.setStyle(element, clipboardItem.style, false);
this.model.property.setStyle(element, clipboardItem.mobileStyle, true);
return element;
}
/**
* duplicate selection
*/
duplicate() {
const copied = this.cloneItems(this.model.body.getSelection());
this.pasteItems(copied, false);
}
/**
* remove selected elements from the stage
*/
removeSelectedElements() {
const elements = this.model.body.getSelection();
if (!!elements.find((el) => el === this.model.body.getBodyElement())) {
SilexNotification.alert('Delete elements',
'Error: I can not delete the body as it is the root container of all your website. Please select an element to delete it.',
() => {},
);
} else {
// confirm and delete
SilexNotification.confirm('Delete elements', 'I am about to delete the selected element(s), are you sure?',
(accept) => {
if (accept) {
// undo checkpoint
this.undoCheckPoint();
// do remove selected elements
elements.forEach((element) => {
this.model.element.removeElement(element);
});
}
}, 'delete', 'cancel',
);
}
}
/**
* edit an {silex.types.Element} element
* take its type into account and open the corresponding editor
*/
editElement(opt_element?: HTMLElement) {
// undo checkpoint
this.undoCheckPoint();
// default is selected element
const element = opt_element || this.model.body.getSelection()[0];
// open the params tab for the components
// or the editor for the elements
if (this.model.component.isComponent(element)) {
this.view.propertyTool.openParamsTab();
} else {
switch (this.model.element.getType(element)) {
case Constants.TYPE_TEXT:
// open the text editor
this.view.textFormatBar.startEditing(this.view.fileExplorer);
// this.view.propertyTool.openStyleTab();
break;
case Constants.TYPE_HTML:
this.view.htmlEditor.open();
this.view.htmlEditor.setSelection([element]);
break;
case Constants.TYPE_IMAGE:
this.view.fileExplorer.openFile(FileExplorer.IMAGE_EXTENSIONS)
.then((blob) => {
if (blob) {
// load the image
this.model.element.setImageUrl(element, blob.url);
}
})
.catch((error) => {
SilexNotification.notifyError(
'Error: I did not manage to load the image. \n' +
(error.message || ''));
});
break;
}
}
}
/**
* @param element, the component to edit
*/
editComponent(element: HTMLElement) {
if (this.model.component.isComponent(element)) {
const componentData = this.model.property.getElementComponentData(element);
if (element && this.model.component.prodotypeComponent && componentData) {
this.model.component.prodotypeComponent.edit(
componentData,
this.model.component.getProdotypeComponents(Constants.COMPONENT_TYPE) as ComponentData[],
componentData.templateName, {
onChange: (newData, html) => {
// undo checkpoint
this.undoCheckPoint();
// remove the editable elements temporarily
const tempElements = this.model.component.saveEditableChildren(element);
// store the component's data for later edition
this.model.property.setElementComponentData(element, newData);
// update the element with the new template
this.model.element.setInnerHtml(element, html);
// execute the scripts
this.model.component.executeScripts(element);
// put back the editable elements
element.appendChild(tempElements);
},
onBrowse: (e, url, cbk) => this.onBrowse(e, url, cbk),
onEditLink: (e, linkData, cbk) =>
this.onEditLink(e, linkData, cbk),
});
}
this.model.component.componentEditorElement.classList.remove('hide-panel');
} else {
this.model.component.componentEditorElement.classList.add('hide-panel');
this.model.component.resetSelection(Constants.COMPONENT_TYPE);
}
}
onEditLink(e: Event, linkData: LinkData, cbk: (p1: LinkData) => any) {
e.preventDefault();
const pages = this.model.page.getPages();
this.linkDialog.open(linkData, pages, (_linkData) => {
cbk(_linkData);
});
}
onBrowse(e: Event, url: string, cbk: (p1: FileInfo[]) => any) {
e.preventDefault();
// browse with CE
const promise = this.view.fileExplorer.openFile();
// add tracking and undo/redo checkpoint
this.track(promise, 'prodotype.browse');
this.undoredo(promise);
// handle the result
promise
.then((fileInfo: FileInfo) => {
if (fileInfo) {
cbk([{
url: fileInfo.absPath,
modified: fileInfo.modified,
name: fileInfo.name,
size: fileInfo.size,
mime: fileInfo.mime,
path: '',
absPath: '',
folder: '',
service: '',
isDir: true,
}]);
}
})
.catch((error) => {
SilexNotification.notifyError('Error: I could not select the file.
' + (error.message || ''));
});
}
/**
* @param className, the css class to edit the style for
* @param pseudoClass, e.g. normal, :hover, ::first-letter
* @param visibility, e.g. mobile only, desktop and mobile...
*/
editStyle(className: StyleName, pseudoClass: PseudoClass, visibility: Visibility) {
const styleData = this.model.property.getStyleData(className) || {styles: {}};
const visibilityData = styleData.styles[visibility] || {};
const pseudoClassData = visibilityData[pseudoClass] || {
templateName: 'text',
className,
pseudoClass,
};
this.model.component.prodotypeStyle.edit(
pseudoClassData,
[{displayName: '', name: '', templateName: ''}]
.concat(this.model.property.getFonts()
.map((font) => {
return {
displayName: font.family,
name: font.family,
templateName: '',
};
}),
),
'text', {
onChange: (newData, html) => this.model.component.componentStyleChanged(className, pseudoClass, visibility, newData),
onBrowse: (e, url, cbk) => this.onBrowse(e, url, cbk),
},
);
}
/**
* get the index of the element in the DOM
*/
indexOfElement(element: HTMLElement): number {
const len = element.parentElement.childNodes.length;
for (let idx = 0; idx < len; idx++) {
if (element.parentElement.childNodes[idx] === element) {
return idx;
}
}
return -1;
}
/**
* Move the selected elements in the DOM
* This will move its over or under other elements if the z-index CSS
* properties are not set
*/
move(direction: DomDirection) {
// undo checkpoint
this.undoCheckPoint();
// get the selected elements
const elements = this.model.body.getSelection();
// sort the array
elements.sort((a, b) => {
return this.indexOfElement(a) - this.indexOfElement(b);
});
// move up
elements.forEach((element) => {
const stylesObj =
this.model.file.getContentWindow().getComputedStyle(element);
const reverse = stylesObj.position !== 'absolute';
if (reverse) {
switch (direction) {
case DomDirection.UP:
direction = DomDirection.DOWN;
break;
case DomDirection.DOWN:
direction = DomDirection.UP;
break;
case DomDirection.TOP:
direction = DomDirection.BOTTOM;
break;
case DomDirection.BOTTOM:
direction = DomDirection.TOP;
break;
}
}
this.model.element.move(element, direction);
});
}
/**
* Move the selected elements in the DOM
* This will move its over or under other elements if the z-index CSS
* properties are not set
*/
moveUp() {
this.move(DomDirection.UP);
}
/**
* Move the selected elements in the DOM
* This will move its over or under other elements if the z-index CSS
* properties are not set
*/
moveDown() {
this.move(DomDirection.DOWN);
}
/**
* Move the selected elements in the DOM
* This will move its over or under other elements if the z-index CSS
* properties are not set
*/
moveToTop() {
this.move(DomDirection.TOP);
}
/**
* Move the selected elements in the DOM
* This will move its over or under other elements if the z-index CSS
* properties are not set
*/
moveToBottom() {
this.move(DomDirection.BOTTOM);
}
}