Files
Umbraco-CMS/src/Umbraco.Web.UI.Client/src/common/services/tinymce.service.js
T

777 lines
33 KiB
JavaScript

/**
* @ngdoc service
* @name umbraco.services.tinyMceService
*
*
* @description
* A service containing all logic for all of the Umbraco TinyMCE plugins
*/
function tinyMceService(dialogService, $log, imageHelper, $http, $timeout, macroResource, macroService, $routeParams, umbRequestHelper, angularHelper, userService) {
return {
/**
* @ngdoc method
* @name umbraco.services.tinyMceService#configuration
* @methodOf umbraco.services.tinyMceService
*
* @description
* Returns a collection of plugins available to the tinyMCE editor
*
*/
configuration: function () {
return umbRequestHelper.resourcePromise(
$http.get(
umbRequestHelper.getApiUrl(
"rteApiBaseUrl",
"GetConfiguration"), { cache: true }),
'Failed to retrieve tinymce configuration');
},
/**
* @ngdoc method
* @name umbraco.services.tinyMceService#defaultPrevalues
* @methodOf umbraco.services.tinyMceService
*
* @description
* Returns a default configration to fallback on in case none is provided
*
*/
defaultPrevalues: function () {
var cfg = {};
cfg.toolbar = ["code", "bold", "italic", "styleselect","alignleft", "aligncenter", "alignright", "bullist","numlist", "outdent", "indent", "link", "image", "umbmediapicker", "umbembeddialog", "umbmacro"];
cfg.stylesheets = [];
cfg.dimensions = { height: 500 };
cfg.maxImageSize = 500;
return cfg;
},
/**
* @ngdoc method
* @name umbraco.services.tinyMceService#createInsertEmbeddedMedia
* @methodOf umbraco.services.tinyMceService
*
* @description
* Creates the umbrco insert embedded media tinymce plugin
*
* @param {Object} editor the TinyMCE editor instance
* @param {Object} $scope the current controller scope
*/
createInsertEmbeddedMedia: function (editor, scope, callback) {
editor.addButton('umbembeddialog', {
icon: 'custom icon-tv',
tooltip: 'Embed',
onclick: function () {
if (callback) {
callback();
}
}
});
},
insertEmbeddedMediaInEditor: function(editor, preview) {
editor.insertContent(preview);
},
/**
* @ngdoc method
* @name umbraco.services.tinyMceService#createMediaPicker
* @methodOf umbraco.services.tinyMceService
*
* @description
* Creates the umbrco insert media tinymce plugin
*
* @param {Object} editor the TinyMCE editor instance
* @param {Object} $scope the current controller scope
*/
createMediaPicker: function (editor, scope, callback) {
editor.addButton('umbmediapicker', {
icon: 'custom icon-picture',
tooltip: 'Media Picker',
onclick: function () {
var selectedElm = editor.selection.getNode(),
currentTarget;
if(selectedElm.nodeName === 'IMG'){
var img = $(selectedElm);
currentTarget = {
altText: img.attr("alt"),
url: img.attr("src"),
id: img.attr("rel")
};
}
userService.getCurrentUser().then(function(userData) {
if(callback) {
callback(currentTarget, userData);
}
});
}
});
},
insertMediaInEditor: function(editor, img) {
if(img) {
var data = {
alt: img.altText || "",
src: (img.url) ? img.url : "nothing.jpg",
rel: img.id,
'data-id': img.id,
id: '__mcenew'
};
editor.insertContent(editor.dom.createHTML('img', data));
$timeout(function () {
var imgElm = editor.dom.get('__mcenew');
var size = editor.dom.getSize(imgElm);
if (editor.settings.maxImageSize && editor.settings.maxImageSize !== 0) {
var newSize = imageHelper.scaleToMaxSize(editor.settings.maxImageSize, size.w, size.h);
var s = "width: " + newSize.width + "px; height:" + newSize.height + "px;";
editor.dom.setAttrib(imgElm, 'style', s);
editor.dom.setAttrib(imgElm, 'id', null);
if (img.url) {
var src = img.url + "?width=" + newSize.width + "&height=" + newSize.height;
editor.dom.setAttrib(imgElm, 'data-mce-src', src);
}
}
}, 500);
}
},
/**
* @ngdoc method
* @name umbraco.services.tinyMceService#createUmbracoMacro
* @methodOf umbraco.services.tinyMceService
*
* @description
* Creates the insert umbrco macro tinymce plugin
*
* @param {Object} editor the TinyMCE editor instance
* @param {Object} $scope the current controller scope
*/
createInsertMacro: function (editor, $scope, callback) {
var createInsertMacroScope = this;
/** Adds custom rules for the macro plugin and custom serialization */
editor.on('preInit', function (args) {
//this is requires so that we tell the serializer that a 'div' is actually allowed in the root, otherwise the cleanup will strip it out
editor.serializer.addRules('div');
/** This checks if the div is a macro container, if so, checks if its wrapped in a p tag and then unwraps it (removes p tag) */
editor.serializer.addNodeFilter('div', function (nodes, name) {
for (var i = 0; i < nodes.length; i++) {
if (nodes[i].attr("class") === "umb-macro-holder" && nodes[i].parent && nodes[i].parent.name.toUpperCase() === "P") {
nodes[i].parent.unwrap();
}
}
});
});
/**
* Because the macro gets wrapped in a P tag because of the way 'enter' works, this
* method will return the macro element if not wrapped in a p, or the p if the macro
* element is the only one inside of it even if we are deep inside an element inside the macro
*/
function getRealMacroElem(element) {
var e = $(element).closest(".umb-macro-holder");
if (e.length > 0) {
if (e.get(0).parentNode.nodeName === "P") {
//now check if we're the only element
if (element.parentNode.childNodes.length === 1) {
return e.get(0).parentNode;
}
}
return e.get(0);
}
return null;
}
/** Adds the button instance */
editor.addButton('umbmacro', {
icon: 'custom icon-settings-alt',
tooltip: 'Insert macro',
onPostRender: function () {
var ctrl = this;
var isOnMacroElement = false;
/**
if the selection comes from a different element that is not the macro's
we need to check if the selection includes part of the macro, if so we'll force the selection
to clear to the next element since if people can select part of the macro markup they can then modify it.
*/
function handleSelectionChange() {
if (!editor.selection.isCollapsed()) {
var endSelection = tinymce.activeEditor.selection.getEnd();
var startSelection = tinymce.activeEditor.selection.getStart();
//don't proceed if it's an entire element selected
if (endSelection !== startSelection) {
//if the end selection is a macro then move the cursor
//NOTE: we don't have to handle when the selection comes from a previous parent because
// that is automatically taken care of with the normal onNodeChanged logic since the
// evt.element will be the macro once it becomes part of the selection.
var $testForMacro = $(endSelection).closest(".umb-macro-holder");
if ($testForMacro.length > 0) {
//it came from before so move after, if there is no after then select ourselves
var next = $testForMacro.next();
if (next.length > 0) {
editor.selection.setCursorLocation($testForMacro.next().get(0));
}
else {
selectMacroElement($testForMacro.get(0));
}
}
}
}
}
/** helper method to select the macro element */
function selectMacroElement(macroElement) {
// move selection to top element to ensure we can't edit this
editor.selection.select(macroElement);
// check if the current selection *is* the element (ie bug)
var currentSelection = editor.selection.getStart();
if (tinymce.isIE) {
if (!editor.dom.hasClass(currentSelection, 'umb-macro-holder')) {
while (!editor.dom.hasClass(currentSelection, 'umb-macro-holder') && currentSelection.parentNode) {
currentSelection = currentSelection.parentNode;
}
editor.selection.select(currentSelection);
}
}
}
/**
* Add a node change handler, test if we're editing a macro and select the whole thing, then set our isOnMacroElement flag.
* If we change the selection inside this method, then we end up in an infinite loop, so we have to remove ourselves
* from the event listener before changing selection, however, it seems that putting a break point in this method
* will always cause an 'infinite' loop as the caret keeps changing.
*/
function onNodeChanged(evt) {
//set our macro button active when on a node of class umb-macro-holder
var $macroElement = $(evt.element).closest(".umb-macro-holder");
handleSelectionChange();
//set the button active
ctrl.active($macroElement.length !== 0);
if ($macroElement.length > 0) {
var macroElement = $macroElement.get(0);
//remove the event listener before re-selecting
editor.off('NodeChange', onNodeChanged);
selectMacroElement(macroElement);
//set the flag
isOnMacroElement = true;
//re-add the event listener
editor.on('NodeChange', onNodeChanged);
}
else {
isOnMacroElement = false;
}
}
/** when the contents load we need to find any macros declared and load in their content */
editor.on("LoadContent", function (o) {
//get all macro divs and load their content
$(editor.dom.select(".umb-macro-holder.mceNonEditable")).each(function() {
createInsertMacroScope.loadMacroContent($(this), null, $scope);
});
});
/** This prevents any other commands from executing when the current element is the macro so the content cannot be edited */
editor.on('BeforeExecCommand', function (o) {
if (isOnMacroElement) {
if (o.preventDefault) {
o.preventDefault();
}
if (o.stopImmediatePropagation) {
o.stopImmediatePropagation();
}
return;
}
});
/** This double checks and ensures you can't paste content into the rendered macro */
editor.on("Paste", function (o) {
if (isOnMacroElement) {
if (o.preventDefault) {
o.preventDefault();
}
if (o.stopImmediatePropagation) {
o.stopImmediatePropagation();
}
return;
}
});
//set onNodeChanged event listener
editor.on('NodeChange', onNodeChanged);
/**
* Listen for the keydown in the editor, we'll check if we are currently on a macro element, if so
* we'll check if the key down is a supported key which requires an action, otherwise we ignore the request
* so the macro cannot be edited.
*/
editor.on('KeyDown', function (e) {
if (isOnMacroElement) {
var macroElement = editor.selection.getNode();
//get the 'real' element (either p or the real one)
macroElement = getRealMacroElem(macroElement);
//prevent editing
e.preventDefault();
e.stopPropagation();
var moveSibling = function (element, isNext) {
var $e = $(element);
var $sibling = isNext ? $e.next() : $e.prev();
if ($sibling.length > 0) {
editor.selection.select($sibling.get(0));
editor.selection.collapse(true);
}
else {
//if we're moving previous and there is no sibling, then lets recurse and just select the next one
if (!isNext) {
moveSibling(element, true);
return;
}
//if there is no sibling we'll generate a new p at the end and select it
editor.setContent(editor.getContent() + "<p>&nbsp;</p>");
editor.selection.select($(editor.dom.getRoot()).children().last().get(0));
editor.selection.collapse(true);
}
};
//supported keys to move to the next or prev element (13-enter, 27-esc, 38-up, 40-down, 39-right, 37-left)
//supported keys to remove the macro (8-backspace, 46-delete)
//TODO: Should we make the enter key insert a line break before or leave it as moving to the next element?
if ($.inArray(e.keyCode, [13, 40, 39]) !== -1) {
//move to next element
moveSibling(macroElement, true);
}
else if ($.inArray(e.keyCode, [27, 38, 37]) !== -1) {
//move to prev element
moveSibling(macroElement, false);
}
else if ($.inArray(e.keyCode, [8, 46]) !== -1) {
//delete macro element
//move first, then delete
moveSibling(macroElement, false);
editor.dom.remove(macroElement);
}
return ;
}
});
},
/** The insert macro button click event handler */
onclick: function () {
var dialogData = {
//flag for use in rte so we only show macros flagged for the editor
richTextEditor: true
};
//when we click we could have a macro already selected and in that case we'll want to edit the current parameters
//so we'll need to extract them and submit them to the dialog.
var macroElement = editor.selection.getNode();
macroElement = getRealMacroElem(macroElement);
if (macroElement) {
//we have a macro selected so we'll need to parse it's alias and parameters
var contents = $(macroElement).contents();
var comment = _.find(contents, function(item) {
return item.nodeType === 8;
});
if (!comment) {
throw "Cannot parse the current macro, the syntax in the editor is invalid";
}
var syntax = comment.textContent.trim();
var parsed = macroService.parseMacroSyntax(syntax);
dialogData = {
macroData: parsed
};
}
if(callback) {
callback(dialogData);
}
}
});
},
insertMacroInEditor: function(editor, macroObject, $scope) {
//put the macro syntax in comments, we will parse this out on the server side to be used
//for persisting.
var macroSyntaxComment = "<!-- " + macroObject.syntax + " -->";
//create an id class for this element so we can re-select it after inserting
var uniqueId = "umb-macro-" + editor.dom.uniqueId();
var macroDiv = editor.dom.create('div',
{
'class': 'umb-macro-holder ' + macroObject.macroAlias + ' mceNonEditable ' + uniqueId
},
macroSyntaxComment + '<ins>Macro alias: <strong>' + macroObject.macroAlias + '</strong></ins>');
editor.selection.setNode(macroDiv);
var $macroDiv = $(editor.dom.select("div.umb-macro-holder." + uniqueId));
//async load the macro content
this.loadMacroContent($macroDiv, macroObject, $scope);
},
/** loads in the macro content async from the server */
loadMacroContent: function($macroDiv, macroData, $scope) {
//if we don't have the macroData, then we'll need to parse it from the macro div
if (!macroData) {
var contents = $macroDiv.contents();
var comment = _.find(contents, function (item) {
return item.nodeType === 8;
});
if (!comment) {
throw "Cannot parse the current macro, the syntax in the editor is invalid";
}
var syntax = comment.textContent.trim();
var parsed = macroService.parseMacroSyntax(syntax);
macroData = parsed;
}
var $ins = $macroDiv.find("ins");
//show the throbber
$macroDiv.addClass("loading");
var contentId = $routeParams.id;
//need to wrap in safe apply since this might be occuring outside of angular
angularHelper.safeApply($scope, function() {
macroResource.getMacroResultAsHtmlForEditor(macroData.macroAlias, contentId, macroData.macroParamsDictionary)
.then(function (htmlResult) {
$macroDiv.removeClass("loading");
htmlResult = htmlResult.trim();
if (htmlResult !== "") {
$ins.html(htmlResult);
}
});
});
},
createLinkPicker: function(editor, $scope, onClick) {
function createLinkList(callback) {
return function() {
var linkList = editor.settings.link_list;
if (typeof(linkList) === "string") {
tinymce.util.XHR.send({
url: linkList,
success: function(text) {
callback(tinymce.util.JSON.parse(text));
}
});
} else {
callback(linkList);
}
};
}
function showDialog(linkList) {
var data = {}, selection = editor.selection, dom = editor.dom, selectedElm, anchorElm, initialText;
var win, linkListCtrl, relListCtrl, targetListCtrl;
function linkListChangeHandler(e) {
var textCtrl = win.find('#text');
if (!textCtrl.value() || (e.lastControl && textCtrl.value() === e.lastControl.text())) {
textCtrl.value(e.control.text());
}
win.find('#href').value(e.control.value());
}
function buildLinkList() {
var linkListItems = [{
text: 'None',
value: ''
}];
tinymce.each(linkList, function(link) {
linkListItems.push({
text: link.text || link.title,
value: link.value || link.url,
menu: link.menu
});
});
return linkListItems;
}
function buildRelList(relValue) {
var relListItems = [{
text: 'None',
value: ''
}];
tinymce.each(editor.settings.rel_list, function(rel) {
relListItems.push({
text: rel.text || rel.title,
value: rel.value,
selected: relValue === rel.value
});
});
return relListItems;
}
function buildTargetList(targetValue) {
var targetListItems = [{
text: 'None',
value: ''
}];
if (!editor.settings.target_list) {
targetListItems.push({
text: 'New window',
value: '_blank'
});
}
tinymce.each(editor.settings.target_list, function(target) {
targetListItems.push({
text: target.text || target.title,
value: target.value,
selected: targetValue === target.value
});
});
return targetListItems;
}
function buildAnchorListControl(url) {
var anchorList = [];
tinymce.each(editor.dom.select('a:not([href])'), function(anchor) {
var id = anchor.name || anchor.id;
if (id) {
anchorList.push({
text: id,
value: '#' + id,
selected: url.indexOf('#' + id) !== -1
});
}
});
if (anchorList.length) {
anchorList.unshift({
text: 'None',
value: ''
});
return {
name: 'anchor',
type: 'listbox',
label: 'Anchors',
values: anchorList,
onselect: linkListChangeHandler
};
}
}
function updateText() {
if (!initialText && data.text.length === 0) {
this.parent().parent().find('#text')[0].value(this.value());
}
}
selectedElm = selection.getNode();
anchorElm = dom.getParent(selectedElm, 'a[href]');
data.text = initialText = anchorElm ? (anchorElm.innerText || anchorElm.textContent) : selection.getContent({format: 'text'});
data.href = anchorElm ? dom.getAttrib(anchorElm, 'href') : '';
data.target = anchorElm ? dom.getAttrib(anchorElm, 'target') : '';
data.rel = anchorElm ? dom.getAttrib(anchorElm, 'rel') : '';
if (selectedElm.nodeName === "IMG") {
data.text = initialText = " ";
}
if (linkList) {
linkListCtrl = {
type: 'listbox',
label: 'Link list',
values: buildLinkList(),
onselect: linkListChangeHandler
};
}
if (editor.settings.target_list !== false) {
targetListCtrl = {
name: 'target',
type: 'listbox',
label: 'Target',
values: buildTargetList(data.target)
};
}
if (editor.settings.rel_list) {
relListCtrl = {
name: 'rel',
type: 'listbox',
label: 'Rel',
values: buildRelList(data.rel)
};
}
var injector = angular.element(document.getElementById("umbracoMainPageBody")).injector();
var dialogService = injector.get("dialogService");
var currentTarget = null;
//if we already have a link selected, we want to pass that data over to the dialog
if(anchorElm){
var anchor = $(anchorElm);
currentTarget = {
name: anchor.attr("title"),
url: anchor.attr("href"),
target: anchor.attr("target")
};
//locallink detection, we do this here, to avoid poluting the dialogservice
//so the dialog service can just expect to get a node-like structure
if(currentTarget.url.indexOf("localLink:") > 0){
currentTarget.id = currentTarget.url.substring(currentTarget.url.indexOf(":")+1,currentTarget.url.length-1);
}
}
if(onClick) {
onClick(currentTarget, anchorElm);
}
}
editor.addButton('link', {
icon: 'link',
tooltip: 'Insert/edit link',
shortcut: 'Ctrl+K',
onclick: createLinkList(showDialog),
stateSelector: 'a[href]'
});
editor.addButton('unlink', {
icon: 'unlink',
tooltip: 'Remove link',
cmd: 'unlink',
stateSelector: 'a[href]'
});
editor.addShortcut('Ctrl+K', '', createLinkList(showDialog));
this.showDialog = showDialog;
editor.addMenuItem('link', {
icon: 'link',
text: 'Insert link',
shortcut: 'Ctrl+K',
onclick: createLinkList(showDialog),
stateSelector: 'a[href]',
context: 'insert',
prependToContext: true
});
},
insertLinkInEditor: function(editor, target, anchorElm) {
var href = target.url;
function insertLink() {
if (anchorElm) {
editor.dom.setAttribs(anchorElm, {
href: href,
title: target.name,
target: target.target ? target.target : null,
rel: target.rel ? target.rel : null,
'data-id': target.id ? target.id : null
});
editor.selection.select(anchorElm);
editor.execCommand('mceEndTyping');
} else {
editor.execCommand('mceInsertLink', false, {
href: href,
title: target.name,
target: target.target ? target.target : null,
rel: target.rel ? target.rel : null,
'data-id': target.id ? target.id : null
});
}
}
if (!href) {
editor.execCommand('unlink');
return;
}
//if we have an id, it must be a locallink:id, aslong as the isMedia flag is not set
if(target.id && (angular.isUndefined(target.isMedia) || !target.isMedia)){
href = "/{localLink:" + target.id + "}";
insertLink();
return;
}
// Is email and not //user@domain.com
if (href.indexOf('@') > 0 && href.indexOf('//') === -1 && href.indexOf('mailto:') === -1) {
href = 'mailto:' + href;
insertLink();
return;
}
// Is www. prefixed
if (/^\s*www\./i.test(href)) {
href = 'http://' + href;
insertLink();
return;
}
insertLink();
}
};
}
angular.module('umbraco.services').factory('tinyMceService', tinyMceService);