Files
Umbraco-CMS/src/Umbraco.Web.UI.Client/src/common/services/tinymce.service.js
Shannon Deminick 8a2661c966 Merge pull request #6446 from umbraco/v8/bugfix/AB2881-RTE-preventdrag-for-unallowed-configs
Fixes #6424 - Prevent drag & drop of images if button to insert media is not enabled
2019-09-26 16:15:27 +02:00

1498 lines
60 KiB
JavaScript

/**
* @ngdoc service
* @name umbraco.services.tinyMceService
*
*
* @description
* A service containing all logic for all of the Umbraco TinyMCE plugins
*/
function tinyMceService($rootScope, $q, imageHelper, $locale, $http, $timeout, stylesheetResource, macroResource, macroService,
$routeParams, umbRequestHelper, angularHelper, userService, editorService, entityResource, eventsService, localStorageService) {
//These are absolutely required in order for the macros to render inline
//we put these as extended elements because they get merged on top of the normal allowed elements by tiny mce
var extendedValidElements = "@[id|class|style],-div[id|dir|class|align|style],ins[datetime|cite],-ul[class|style],-li[class|style],-h1[id|dir|class|align|style],-h2[id|dir|class|align|style],-h3[id|dir|class|align|style],-h4[id|dir|class|align|style],-h5[id|dir|class|align|style],-h6[id|style|dir|class|align],span[id|class|style]";
var fallbackStyles = [{ title: "Page header", block: "h2" }, { title: "Section header", block: "h3" }, { title: "Paragraph header", block: "h4" }, { title: "Normal", block: "p" }, { title: "Quote", block: "blockquote" }, { title: "Code", block: "code" }];
// these languages are available for localization
var availableLanguages = [
'da',
'de',
'en',
'en_us',
'fi',
'fr',
'he',
'it',
'ja',
'nl',
'no',
'pl',
'pt',
'ru',
'sv',
'zh'
];
//define fallback language
var defaultLanguage = 'en_us';
/**
* Returns a promise of an object containing the stylesheets and styleFormats collections
* @param {any} configuredStylesheets
*/
function getStyles(configuredStylesheets) {
var stylesheets = [];
var styleFormats = [];
var promises = [$q.when(true)]; //a collection of promises, the first one is an empty promise
//queue rules loading
if (configuredStylesheets) {
angular.forEach(configuredStylesheets, function (val, key) {
if (val.indexOf(Umbraco.Sys.ServerVariables.umbracoSettings.cssPath + "/") === 0) {
// current format (full path to stylesheet)
stylesheets.push(val);
}
else {
// legacy format (stylesheet name only) - must prefix with stylesheet folder and postfix with ".css"
stylesheets.push(Umbraco.Sys.ServerVariables.umbracoSettings.cssPath + "/" + val + ".css");
}
promises.push(stylesheetResource.getRulesByName(val).then(function (rules) {
angular.forEach(rules, function (rule) {
var r = {};
r.title = rule.name;
if (rule.selector[0] == ".") {
r.inline = "span";
r.classes = rule.selector.substring(1);
}
else if (rule.selector[0] === "#") {
r.inline = "span";
r.attributes = { id: rule.selector.substring(1) };
}
else if (rule.selector[0] !== "." && rule.selector.indexOf(".") > -1) {
var split = rule.selector.split(".");
r.block = split[0];
r.classes = rule.selector.substring(rule.selector.indexOf(".") + 1).replace(".", " ");
}
else if (rule.selector[0] != "#" && rule.selector.indexOf("#") > -1) {
var split = rule.selector.split("#");
r.block = split[0];
r.classes = rule.selector.substring(rule.selector.indexOf("#") + 1);
}
else {
r.block = rule.selector;
}
styleFormats.push(r);
});
}));
});
}
else {
styleFormats = fallbackStyles;
}
return $q.all(promises).then(function() {
// Always push our Umbraco RTE stylesheet
// So we can style macros, embed items etc...
stylesheets.push(`${Umbraco.Sys.ServerVariables.umbracoSettings.umbracoPath}/assets/css/rte-content.css`);
return $q.when({ stylesheets: stylesheets, styleFormats: styleFormats});
});
}
/** Returns the language to use for TinyMCE */
function getLanguage() {
var language = defaultLanguage;
//get locale from angular and match tinymce format. Angular localization is always in the format of ru-ru, de-de, en-gb, etc.
//wheras tinymce is in the format of ru, de, en, en_us, etc.
var localeId = $locale.id.replace('-', '_');
//try matching the language using full locale format
var languageMatch = _.find(availableLanguages, function (o) { return o === localeId; });
//if no matches, try matching using only the language
if (languageMatch === undefined) {
var localeParts = localeId.split('_');
languageMatch = _.find(availableLanguages, function (o) { return o === localeParts[0]; });
}
//if a match was found - set the language
if (languageMatch !== undefined) {
language = languageMatch;
}
return language;
}
/**
* Gets toolbars for the inlite theme
* @param {any} configuredToolbar
* @param {any} tinyMceConfig
*/
function getToolbars(configuredToolbar, tinyMceConfig) {
//the commands for selection/all
var allowedSelectionToolbar = _.map(_.filter(tinyMceConfig.commands,
function(f) {
return f.mode === "Selection" || f.mode === "All";
}),
function(f) {
return f.alias;
});
//the commands for insert/all
var allowedInsertToolbar = _.map(_.filter(tinyMceConfig.commands,
function(f) {
return f.mode === "Insert" || f.mode === "All";
}),
function(f) {
return f.alias;
});
var insertToolbar = _.filter(configuredToolbar, function (t) {
return allowedInsertToolbar.indexOf(t) !== -1;
}).join(" | ");
var selectionToolbar = _.filter(configuredToolbar, function (t) {
return allowedSelectionToolbar.indexOf(t) !== -1;
}).join(" | ");
return {
insertToolbar: insertToolbar,
selectionToolbar: selectionToolbar
}
}
function uploadImageHandler(blobInfo, success, failure, progress){
let xhr, formData;
xhr = new XMLHttpRequest();
xhr.open('POST', Umbraco.Sys.ServerVariables.umbracoUrls.tinyMceApiBaseUrl + 'UploadImage');
xhr.onloadstart = function(e) {
angularHelper.safeApply($rootScope, function() {
eventsService.emit("rte.file.uploading");
});
};
xhr.onloadend = function(e) {
angularHelper.safeApply($rootScope, function() {
eventsService.emit("rte.file.uploaded");
});
};
xhr.upload.onprogress = function (e) {
progress(e.loaded / e.total * 100);
};
xhr.onerror = function () {
failure('Image upload failed due to a XHR Transport error. Code: ' + xhr.status);
};
xhr.onload = function () {
let json;
if (xhr.status < 200 || xhr.status >= 300) {
failure('HTTP Error: ' + xhr.status);
return;
}
json = JSON.parse(xhr.responseText);
if (!json || typeof json.tmpLocation !== 'string') {
failure('Invalid JSON: ' + xhr.responseText);
return;
}
// Put temp location into localstorage (used to update the img with data-tmpimg later on)
localStorageService.set(`tinymce__${blobInfo.blobUri()}`, json.tmpLocation);
// We set the img src url to be the same as we started
// The Blob URI is stored in TinyMce's cache
// so the img still shows in the editor
success(blobInfo.blobUri());
};
formData = new FormData();
formData.append('file', blobInfo.blob(), blobInfo.blob().name);
xhr.send(formData);
}
function cleanupPasteData(plugin, args) {
// Remove spans
args.content = args.content.replace(/<\s*span[^>]*>(.*?)<\s*\/\s*span>/g, "$1");
// Convert b to strong.
args.content = args.content.replace(/<\s*b([^>]*)>(.*?)<\s*\/\s*b([^>]*)>/g, "<strong$1>$2</strong$3>");
// convert i to em
args.content = args.content.replace(/<\s*i([^>]*)>(.*?)<\s*\/\s*i([^>]*)>/g, "<em$1>$2</em$3>");
}
function sizeImageInEditor(editor, imageDomElement, imgUrl) {
var size = editor.dom.getSize(imageDomElement);
if (editor.settings.maxImageSize && editor.settings.maxImageSize !== 0) {
var newSize = imageHelper.scaleToMaxSize(editor.settings.maxImageSize, size.w, size.h);
editor.dom.setAttrib(imageDomElement, 'width', newSize.width);
editor.dom.setAttrib(imageDomElement, 'height', newSize.height);
// Images inserted via Media Picker will have a URL we can use for ImageResizer QueryStrings
// Images pasted/dragged in are not persisted to media until saved & thus will need to be added
if(imgUrl){
var src = imgUrl + "?width=" + newSize.width + "&height=" + newSize.height;
editor.dom.setAttrib(imageDomElement, 'data-mce-src', src);
}
}
}
function isMediaPickerEnabled(toolbarItemArray){
var insertMediaButtonFound = false;
toolbarItemArray.forEach(toolbarItem => {
if(toolbarItem.indexOf("umbmediapicker") > -1){
insertMediaButtonFound = true;
}
});
return insertMediaButtonFound;
}
return {
/**
* Returns a promise of the configuration object to initialize the TinyMCE editor
* @param {} args
* @returns {}
*/
getTinyMceEditorConfig: function (args) {
//global defaults, called before/during init
tinymce.DOM.events.domLoaded = true;
tinymce.baseURL = Umbraco.Sys.ServerVariables.umbracoSettings.umbracoPath + "/lib/tinymce/"; // trailing slash important
var promises = [
this.configuration(),
getStyles(args.stylesheets)
];
return $q.all(promises).then(function(result) {
var tinyMceConfig = result[0];
var styles = result[1];
var toolbars = getToolbars(args.toolbar, tinyMceConfig);
var plugins = _.map(tinyMceConfig.plugins, function (plugin) {
return plugin.name;
});
//plugins that must always be active
plugins.push("autoresize");
plugins.push("noneditable");
var modeTheme = '';
var modeInline = false;
//Based on mode set
//classic = Theme: modern, inline: false
//inline = Theme: modern, inline: true,
//distraction-free = Theme: inlite, inline: true
switch (args.mode) {
case "classic":
modeTheme = "modern";
modeInline = false;
break;
case "distraction-free":
modeTheme = "inlite";
modeInline = true;
break;
default:
//Will default to 'classic'
modeTheme = "modern";
modeInline = false;
break;
}
//create a baseline Config to exten upon
var config = {
theme: modeTheme,
inline: modeInline,
plugins: plugins,
valid_elements: tinyMceConfig.validElements,
invalid_elements: tinyMceConfig.inValidElements,
extended_valid_elements: extendedValidElements,
menubar: false,
statusbar: false,
relative_urls: false,
autoresize_bottom_margin: 10,
content_css: styles.stylesheets,
style_formats: styles.styleFormats,
language: getLanguage(),
//this would be for a theme other than inlite
toolbar: args.toolbar.join(" "),
//these are for the inlite theme to work
insert_toolbar: toolbars.insertToolbar,
selection_toolbar: toolbars.selectionToolbar,
body_class: "umb-rte",
//see http://archive.tinymce.com/wiki.php/Configuration:cache_suffix
cache_suffix: "?umb__rnd=" + Umbraco.Sys.ServerVariables.application.cacheBuster
};
// Need to check if we are allowed to UPLOAD images
// This is done by checking if the insert image toolbar button is available
if(isMediaPickerEnabled(args.toolbar)){
// Update the TinyMCE Config object to allow pasting
config.images_upload_handler = uploadImageHandler;
config.automatic_uploads = false;
config.images_replace_blob_uris = false;
// This allows images to be pasted in & stored as Base64 until they get uploaded to server
config.paste_data_images = true;
}
if (args.htmlId) {
config.selector = "#" + args.htmlId;
} else if (args.target) {
config.target = args.target;
}
/*
// We are not ready to limit the pasted elements further than default, we will return to this feature. ( TODO: Make this feature an option. )
// We keep spans here, cause removing spans here also removes b-tags inside of them, instead we strip them out later. (TODO: move this definition to the config file... )
var validPasteElements = "-strong/b,-em/i,-u,-span,-p,-ol,-ul,-li,-p/div,-a[href|name],sub,sup,strike,br,del,table[width],tr,td[colspan|rowspan|width],th[colspan|rowspan|width],thead,tfoot,tbody,img[src|alt|width|height],ul,ol,li,hr,pre,dl,dt,figure,figcaption,wbr"
// add elements from user configurated styleFormats to our list of validPasteElements.
// (This means that we only allow H3-element if its configured as a styleFormat on this specific propertyEditor.)
var style, i = 0;
for(; i < styles.styleFormats.length; i++) {
style = styles.styleFormats[i];
if(style.block) {
validPasteElements += "," + style.block;
}
}
*/
/**
The default paste config can be overwritten by defining these properties in the customConfig.
*/
var pasteConfig = {
paste_remove_styles: true,
paste_text_linebreaktype: true, //Converts plaintext linebreaks to br or p elements.
paste_strip_class_attributes: "none",
//paste_word_valid_elements: validPasteElements,
paste_preprocess: cleanupPasteData
};
angular.extend(config, pasteConfig);
if (tinyMceConfig.customConfig) {
//if there is some custom config, we need to see if the string value of each item might actually be json and if so, we need to
// convert it to json instead of having it as a string since this is what tinymce requires
for (var i in tinyMceConfig.customConfig) {
var val = tinyMceConfig.customConfig[i];
if (val) {
val = val.toString().trim();
if (val.detectIsJson()) {
try {
tinyMceConfig.customConfig[i] = JSON.parse(val);
//now we need to check if this custom config key is defined in our baseline, if it is we don't want to
//overwrite the baseline config item if it is an array, we want to concat the items in the array, otherwise
//if it's an object it will overwrite the baseline
if (angular.isArray(config[i]) && angular.isArray(tinyMceConfig.customConfig[i])) {
//concat it and below this concat'd array will overwrite the baseline in angular.extend
tinyMceConfig.customConfig[i] = config[i].concat(tinyMceConfig.customConfig[i]);
}
}
catch (e) {
//cannot parse, we'll just leave it
}
}
if (val === "true") {
tinyMceConfig.customConfig[i] = true;
}
if (val === "false") {
tinyMceConfig.customConfig[i] = false;
}
}
}
angular.extend(config, tinyMceConfig.customConfig);
}
return config;
});
},
/**
* @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 = ["ace", "styleselect", "bold", "italic", "alignleft", "aligncenter", "alignright", "bullist", "numlist", "outdent", "indent", "link", "umbmediapicker", "umbmacro", "umbembeddialog"];
cfg.stylesheets = [];
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
*/
createInsertEmbeddedMedia: function (editor, callback) {
editor.addButton('umbembeddialog', {
icon: 'custom icon-tv',
tooltip: 'Embed',
stateSelector: 'div[data-embed-url]',
onclick: function () {
// Get the selected element
// Check nodename is a DIV and the claslist contains 'embeditem'
var selectedElm = editor.selection.getNode();
var nodeName = selectedElm.nodeName;
var modify = null;
if(nodeName.toUpperCase() === "DIV" && selectedElm.classList.contains("embeditem")){
// See if we can go and get the attributes
var embedUrl = editor.dom.getAttrib(selectedElm, "data-embed-url");
var embedWidth = editor.dom.getAttrib(selectedElm, "data-embed-width");
var embedHeight = editor.dom.getAttrib(selectedElm, "data-embed-height");
var embedConstrain = editor.dom.getAttrib(selectedElm, "data-embed-constrain");
modify = {
url: embedUrl,
width: parseInt(embedWidth) || 0,
height: parseInt(embedHeight) || 0,
constrain: embedConstrain
};
}
if (callback) {
angularHelper.safeApply($rootScope, function() {
// pass the active element along so we can retrieve it later
callback(selectedElm, modify);
});
}
}
});
},
insertEmbeddedMediaInEditor: function (editor, embed, activeElement) {
// Wrap HTML preview content here in a DIV with non-editable class of .mceNonEditable
// This turns it into a selectable/cutable block to move about
var wrapper = tinymce.activeEditor.dom.create('div',
{
'class': 'mceNonEditable embeditem',
'data-embed-url': embed.url,
'data-embed-height': embed.height,
'data-embed-width': embed.width,
'data-embed-constrain': embed.constrain,
'contenteditable': false
},
embed.preview);
if (activeElement) {
activeElement.replaceWith(wrapper); // directly replaces the html node
}
else {
editor.selection.setNode(wrapper);
}
},
createAceCodeEditor: function(editor, callback){
editor.addButton("ace", {
icon: "code",
tooltip: "View Source Code",
onclick: function(){
if (callback) {
angularHelper.safeApply($rootScope, function() {
callback();
});
}
}
});
},
/**
* @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
*/
createMediaPicker: function (editor, callback) {
editor.addButton('umbmediapicker', {
icon: 'custom icon-picture',
tooltip: 'Media Picker',
stateSelector: 'img[data-udi]',
onclick: function () {
var selectedElm = editor.selection.getNode(),
currentTarget;
if (selectedElm.nodeName === 'IMG') {
var img = $(selectedElm);
var hasUdi = img.attr("data-udi") ? true : false;
currentTarget = {
altText: img.attr("alt"),
url: img.attr("src")
};
if (hasUdi) {
currentTarget["udi"] = img.attr("data-udi");
} else {
currentTarget["id"] = img.attr("rel");
}
}
userService.getCurrentUser().then(function (userData) {
if (callback) {
angularHelper.safeApply($rootScope, function() {
callback(currentTarget, userData);
});
}
});
}
});
},
insertMediaInEditor: function (editor, img) {
if (img) {
var data = {
alt: img.altText || "",
src: (img.url) ? img.url : "nothing.jpg",
id: '__mcenew',
'data-udi': img.udi
};
editor.selection.setContent(editor.dom.createHTML('img', data));
$timeout(function () {
var imgElm = editor.dom.get('__mcenew');
sizeImageInEditor(editor, imgElm, img.url);
editor.dom.setAttrib(imgElm, 'id', null);
editor.fire('Change');
}, 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
*/
createInsertMacro: function (editor, callback) {
let self = this;
let activeMacroElement = null; //track an active macro element
/** 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();
}
}
});
});
/** when the contents load we need to find any macros declared and load in their content */
editor.on("SetContent", function (o) {
//get all macro divs and load their content
$(editor.dom.select(".umb-macro-holder.mceNonEditable")).each(function () {
self.loadMacroContent($(this), null);
});
});
/**
* 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 () {
let ctrl = this;
/**
* Check if the macro is currently selected and toggle the menu button
*/
function onNodeChanged(evt) {
//set our macro button active when on a node of class umb-macro-holder
activeMacroElement = getRealMacroElem(evt.element);
//set the button active/inactive
ctrl.active(activeMacroElement !== null);
}
//NOTE: This could be another way to deal with the active/inactive state
//editor.on('ObjectSelected', function (e) {});
//set onNodeChanged event listener
editor.on('NodeChange', onNodeChanged);
},
/** 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.
if (activeMacroElement) {
//we have a macro selected so we'll need to parse it's alias and parameters
var contents = $(activeMacroElement).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,
activeMacroElement: activeMacroElement //pass the active element along so we can retrieve it later
};
}
if (callback) {
angularHelper.safeApply($rootScope, function () {
callback(dialogData);
});
}
}
});
},
insertMacroInEditor: function (editor, macroObject, activeMacroElement) {
//Important note: the TinyMce plugin "noneditable" is used here so that the macro cannot be edited,
// for this to work the mceNonEditable class needs to come last and we also need to use the attribute contenteditable = false
// (even though all the docs and examples say that is not necessary)
//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 + " " + uniqueId + ' mceNonEditable',
'contenteditable': 'false'
},
macroSyntaxComment + '<ins>Macro alias: <strong>' + macroObject.macroAlias + '</strong></ins>');
//if there's an activeMacroElement then replace it, otherwise set the contents of the selected node
if (activeMacroElement) {
activeMacroElement.replaceWith(macroDiv); //directly replaces the html node
}
else {
editor.selection.setNode(macroDiv);
}
var $macroDiv = $(editor.dom.select("div.umb-macro-holder." + uniqueId));
//async load the macro content
this.loadMacroContent($macroDiv, macroObject);
},
/** loads in the macro content async from the server */
loadMacroContent: function ($macroDiv, macroData) {
//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");
// Add the contenteditable="false" attribute
// As just the CSS class of .mceNonEditable is not working by itself?!
// TODO: At later date - use TinyMCE editor DOM manipulation as opposed to jQuery
$macroDiv.attr("contenteditable", "false");
var contentId = $routeParams.id;
//need to wrap in safe apply since this might be occuring outside of angular
angularHelper.safeApply($rootScope, 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, 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 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")
};
// drop the lead char from the anchor text, if it has a value
var anchorVal = anchor[0].dataset.anchor;
if (anchorVal) {
currentTarget.anchor = anchorVal.substring(1);
}
//locallink detection, we do this here, to avoid poluting the editorService
//so the editor service can just expect to get a node-like structure
if (currentTarget.url.indexOf("localLink:") > 0) {
// if the current link has an anchor, it needs to be considered when getting the udi/id
// if an anchor exists, reduce the substring max by its length plus two to offset the removed prefix and trailing curly brace
var linkId = currentTarget.url.substring(currentTarget.url.indexOf(":") + 1, currentTarget.url.lastIndexOf("}"));
//we need to check if this is an INT or a UDI
var parsedIntId = parseInt(linkId, 10);
if (isNaN(parsedIntId)) {
//it's a UDI
currentTarget.udi = linkId;
} else {
currentTarget.id = linkId;
}
}
}
angularHelper.safeApply($rootScope,
function () {
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;
// We want to use the Udi. If it is set, we use it, else fallback to id, and finally to null
var hasUdi = target.udi ? true : false;
var id = hasUdi ? target.udi : (target.id ? target.id : null);
// if an anchor exists, check that it is appropriately prefixed
if (target.anchor && target.anchor[0] !== '?' && target.anchor[0] !== '#') {
target.anchor = (target.anchor.indexOf('=') === -1 ? '#' : '?') + target.anchor;
}
// the href might be an external url, so check the value for an anchor/qs
// href has the anchor re-appended later, hence the reset here to avoid duplicating the anchor
if (!target.anchor) {
var urlParts = href.split(/(#|\?)/);
if (urlParts.length === 3) {
href = urlParts[0];
target.anchor = urlParts[1] + urlParts[2];
}
}
//Create a json obj used to create the attributes for the tag
function createElemAttributes() {
var a = {
href: href,
title: target.name,
target: target.target ? target.target : null,
rel: target.rel ? target.rel : null
};
if (target.anchor) {
a["data-anchor"] = target.anchor;
a.href = a.href + target.anchor;
} else {
a["data-anchor"] = null;
}
return a;
}
function insertLink() {
if (anchorElm) {
editor.dom.setAttribs(anchorElm, createElemAttributes());
editor.selection.select(anchorElm);
editor.execCommand('mceEndTyping');
} else {
editor.execCommand('mceInsertLink', false, createElemAttributes());
}
}
if (!href && !target.anchor) {
editor.execCommand('unlink');
return;
}
//if we have an id, it must be a locallink:id
if (id) {
href = "/{localLink:" + id + "}";
insertLink();
return;
}
if (!href) {
href = "";
}
// Is email and not //user@domain.com and protocol (e.g. mailto:, sip:) is not specified
if (href.indexOf('@') > 0 && href.indexOf('//') === -1 && href.indexOf(':') === -1) {
// assume it's a mailto link
href = 'mailto:' + href;
insertLink();
return;
}
// Is www. prefixed
if (/^\s*www\./i.test(href)) {
href = 'http://' + href;
insertLink();
return;
}
insertLink();
},
pinToolbar : function (editor) {
//we can't pin the toolbar if this doesn't exist (i.e. when in distraction free mode)
if (!editor.editorContainer) {
return;
}
var tinyMce = $(editor.editorContainer);
var toolbar = tinyMce.find(".mce-toolbar");
var toolbarHeight = toolbar.height();
var tinyMceRect = editor.editorContainer.getBoundingClientRect();
var tinyMceTop = tinyMceRect.top;
var tinyMceBottom = tinyMceRect.bottom;
var tinyMceEditArea = tinyMce.find(".mce-edit-area");
// set padding in top of mce so the content does not "jump" up
tinyMceEditArea.css("padding-top", toolbarHeight);
if (tinyMceTop < 177 && ((177 + toolbarHeight) < tinyMceBottom)) {
toolbar
.css("visibility", "visible")
.css("position", "fixed")
.css("top", "177px")
.css("margin-top", "0");
} else {
toolbar
.css("visibility", "visible")
.css("position", "absolute")
.css("top", "auto")
.css("margin-top", "0");
}
},
unpinToolbar: function (editor) {
var tinyMce = $(editor.editorContainer);
var toolbar = tinyMce.find(".mce-toolbar");
var tinyMceEditArea = tinyMce.find(".mce-edit-area");
// reset padding in top of mce so the content does not "jump" up
tinyMceEditArea.css("padding-top", "0");
toolbar.css("position", "static");
},
/** Helper method to initialize the tinymce editor within Umbraco */
initializeEditor: function (args) {
if (!args.editor) {
throw "args.editor is required";
}
//if (!args.model.value) {
// throw "args.model.value is required";
//}
var unwatch = null;
//Starts a watch on the model value so that we can update TinyMCE if the model changes behind the scenes or from the server
function startWatch() {
unwatch = $rootScope.$watch(() => args.model.value, function (newVal, oldVal) {
if (newVal !== oldVal) {
//update the display val again if it has changed from the server;
//uses an empty string in the editor when the value is null
args.editor.setContent(newVal || "", { format: 'raw' });
//we need to manually fire this event since it is only ever fired based on loading from the DOM, this
// is required for our plugins listening to this event to execute
args.editor.fire('LoadContent', null);
}
});
}
//Stops the watch on model.value which is done anytime we are manually updating the model.value
function stopWatch() {
if (unwatch) {
unwatch();
}
}
function syncContent() {
//stop watching before we update the value
stopWatch();
angularHelper.safeApply($rootScope, function () {
args.model.value = args.editor.getContent();
});
//re-watch the value
startWatch();
}
// If we can not find the insert image/media toolbar button
// Then we need to add an event listener to the editor
// That will update native browser drag & drop events
// To update the icon to show you can NOT drop something into the editor
var toolbarItems = args.editor.settings.toolbar.split(" ");
if(isMediaPickerEnabled(toolbarItems) === false){
// Wire up the event listener
args.editor.on('dragend dragover draggesture dragdrop drop drag', function (e) {
e.preventDefault();
e.dataTransfer.effectAllowed = "none";
e.dataTransfer.dropEffect = "none";
e.stopPropagation();
});
}
args.editor.on('SetContent', function (e) {
var content = e.content;
// Upload BLOB images (dragged/pasted ones)
if(content.indexOf('<img src="blob:') > -1){
args.editor.uploadImages(function(data) {
// Once all images have been uploaded
data.forEach(function(item) {
// Select img element
var img = item.element;
// Get img src
var imgSrc = img.getAttribute("src");
var tmpLocation = localStorageService.get(`tinymce__${imgSrc}`)
// Select the img & add new attr which we can search for
// When its being persisted in RTE property editor
// To create a media item & delete this tmp one etc
tinymce.activeEditor.$(img).attr({ "data-tmpimg": tmpLocation });
// Resize the image to the max size configured
// NOTE: no imagesrc passed into func as the src is blob://...
// We will append ImageResizing Querystrings on perist to DB with node save
sizeImageInEditor(args.editor, img);
});
});
// Get all img where src starts with blob: AND does NOT have a data=tmpimg attribute
// This is most likely seen as a duplicate image that has already been uploaded
// editor.uploadImages() does not give us any indiciation that the image been uploaded already
var blobImageWithNoTmpImgAttribute = args.editor.dom.select("img[src^='blob:']:not([data-tmpimg])");
//For each of these selected items
blobImageWithNoTmpImgAttribute.forEach(imageElement => {
var blobSrcUri = args.editor.dom.getAttrib(imageElement, "src");
// Find the same image uploaded (Should be in LocalStorage)
// May already exist in the editor as duplicate image
// OR added to the RTE, deleted & re-added again
// So lets fetch the tempurl out of localstorage for that blob URI item
var tmpLocation = localStorageService.get(`tinymce__${blobSrcUri}`)
if(tmpLocation){
sizeImageInEditor(args.editor, imageElement);
args.editor.dom.setAttrib(imageElement, "data-tmpimg", tmpLocation);
}
});
}
});
args.editor.on('init', function (e) {
if (args.model.value) {
args.editor.setContent(args.model.value);
}
//enable browser based spell checking
args.editor.getBody().setAttribute('spellcheck', true);
//start watching the value
startWatch();
});
args.editor.on('Change', function (e) {
syncContent();
});
//when we leave the editor (maybe)
args.editor.on('blur', function (e) {
syncContent();
});
args.editor.on('ObjectResized', function (e) {
var qs = "?width=" + e.width + "&height=" + e.height + "&mode=max";
var srcAttr = $(e.target).attr("src");
var path = srcAttr.split("?")[0];
$(e.target).attr("data-mce-src", path + qs);
syncContent();
});
args.editor.on('Dirty', function (e) {
//make the form dirty manually so that the track changes works, setting our model doesn't trigger
// the angular bits because tinymce replaces the textarea.
if (args.currentForm) {
args.currentForm.$setDirty();
}
});
let self = this;
//create link picker
self.createLinkPicker(args.editor, function (currentTarget, anchorElement) {
entityResource.getAnchors(args.model.value).then(function (anchorValues) {
var linkPicker = {
currentTarget: currentTarget,
dataTypeKey: args.model.dataTypeKey,
ignoreUserStartNodes: args.model.config.ignoreUserStartNodes,
anchors: anchorValues,
submit: function (model) {
self.insertLinkInEditor(args.editor, model.target, anchorElement);
editorService.close();
},
close: function () {
editorService.close();
}
};
editorService.linkPicker(linkPicker);
});
});
//Create the insert media plugin
self.createMediaPicker(args.editor, function (currentTarget, userData) {
var startNodeId, startNodeIsVirtual;
if (!args.model.config.startNodeId) {
if (args.model.config.ignoreUserStartNodes === true) {
startNodeId = -1;
startNodeIsVirtual = true;
}
else {
startNodeId = userData.startMediaIds.length !== 1 ? -1 : userData.startMediaIds[0];
startNodeIsVirtual = userData.startMediaIds.length !== 1;
}
}
var mediaPicker = {
currentTarget: currentTarget,
onlyImages: true,
showDetails: true,
disableFolderSelect: true,
startNodeId: startNodeId,
startNodeIsVirtual: startNodeIsVirtual,
dataTypeKey: args.model.dataTypeKey,
submit: function (model) {
self.insertMediaInEditor(args.editor, model.selection[0]);
editorService.close();
},
close: function () {
editorService.close();
}
};
editorService.mediaPicker(mediaPicker);
});
//Create the embedded plugin
self.createInsertEmbeddedMedia(args.editor, function (activeElement, modify) {
var embed = {
modify: modify,
submit: function (model) {
self.insertEmbeddedMediaInEditor(args.editor, model.embed, activeElement);
editorService.close();
},
close: function () {
editorService.close();
}
};
editorService.embed(embed);
});
//Create the insert macro plugin
self.createInsertMacro(args.editor, function (dialogData) {
var macroPicker = {
dialogData: dialogData,
submit: function (model) {
var macroObject = macroService.collectValueData(model.selectedMacro, model.macroParams, dialogData.renderingEngine);
self.insertMacroInEditor(args.editor, macroObject, dialogData.activeMacroElement);
editorService.close();
},
close: function () {
editorService.close();
}
};
editorService.macroPicker(macroPicker);
});
self.createAceCodeEditor(args.editor, function () {
// TODO: CHECK TO SEE WHAT WE NEED TO DO WIT MACROS (See code block?)
/*
var html = editor.getContent({source_view: true});
html = html.replace(/<span\s+class="CmCaReT"([^>]*)>([^<]*)<\/span>/gm, String.fromCharCode(chr));
editor.dom.remove(editor.dom.select('.CmCaReT'));
html = html.replace(/(<div class=".*?umb-macro-holder.*?mceNonEditable.*?"><!-- <\?UMBRACO_MACRO macroAlias="(.*?)".*?\/> --> *<ins>)[\s\S]*?(<\/ins> *<\/div>)/ig, "$1Macro alias: <strong>$2</strong>$3");
*/
var aceEditor = {
content: args.editor.getContent(),
view: 'views/propertyeditors/rte/codeeditor.html',
submit: function (model) {
args.editor.setContent(model.content);
args.editor.fire('Change');
editorService.close();
},
close: function () {
editorService.close();
}
};
editorService.open(aceEditor);
});
}
};
}
angular.module('umbraco.services').factory('tinyMceService', tinyMceService);