How to use TinyMce the EPiServer-way in a custom property
We have a couple of custom properties where we need to have a TinyMce editor since the editor should be able to input an xhtml string and want’s to add links and so on. We have lately have had a lot of problems with those properties not working as they should and mostly it is the TinyMce-part that is not working. After I talked to Erik Kärrsgård at the MeetUp I arrange a while ago I was inspired to dig deeper in EPiServers source code to see how they do it. As Erik said at the meetup, you should not be afraid to look in the source code it is not forbidden to extend/use their code in your custom property, what you should be aware about is that if they change and fix something you have “got inspired by” you also have to do that fix/change.
I started to use JustDecompile (http://www.telerik.com/products/decompiler.aspx) and after a while I found the EditorDescriptor for the Xhtml-string and from that I could navigate to the js-file and html-template used in that descriptor. When I had come so far I decided to take one of our custom property and change it to use the TinyMce-editor the way EPiServer do it, and to do it in a Alloy-site so I could add it to GitHub for all of you to get help and insperation from.
The custom property
The custom property I chose have four part in it, first a section showing the value of the parent page for a property with the exact name as this property, second two radiobuttons that the editor use to choose if they want to use a local value or the parent value, third a button that copies the value from the parent page to this page if the editor clicks on it and forth a textarea where the user can write a local value in, we call this property InheritXhtml.
The property saves the number zero if the editor choose to use local value or the number one if the editor choose to use the value from the parent followed by the sign : and after that the value inserted in the local value textarea. So it could look something like this: “0:My local value” or “1:My local value”. Later when rendering the system checks if the string starts with zero or one and acts on that. Pretty simple task but not possible to do without a custom property even with a local block.
How I solved it
The first thing to do is to create the EditorDescriptor for the property and here I am using nearly exactly the same code as EPiServer do for xhtml-string. First, here are the code:
1 using EPiServer; 2 using EPiServer.Cms.Shell.Extensions; 3 using EPiServer.Cms.Shell.UI.ObjectEditing; 4 using EPiServer.Core; 5 using EPiServer.Editor.TinyMCE; 6 using EPiServer.ServiceLocation; 7 using EPiServer.Shell.ObjectEditing; 8 using EPiServer.Shell.ObjectEditing.EditorDescriptors; 9 using System; 10 using System.Collections.Generic; 11 using System.Linq; 12 13 namespace TinyMceInCustomProperties.Business.EditorDescriptors 14 { 15 [EditorDescriptorRegistration(TargetType = typeof(XhtmlString), UIHint = Global.SiteUIHints.ExtendedXhtml)] 16 public class ExtendedXhtmlEditorDescriptor : EditorDescriptor 17 { 18 private IList<string> _nonLegacyPlugins; 19 20 public ExtendedXhtmlEditorDescriptor() 21 { 22 ClientEditingClass = "alloy/editors/ExtendedTinyMCEEditor"; 23 List<string> strs = new List<string>() 24 { 25 "advhr", 26 "advimage", 27 "advlink", 28 "advlist", 29 "autoresize", 30 "autosave", 31 "bbcode", 32 "contextmenu", 33 "directionality", 34 "emotions", 35 "epiaccesskeysremove", 36 "epiautoresize", 37 "epicontentfragment", 38 "epidynamiccontent", 39 "epieditordisable", 40 "epiexternaltoolbar", 41 "epifilebrowser", 42 "epifloatingtoolbar", 43 "epiimageeditor", 44 "epiimageresize", 45 "epilink", 46 "epipersonalizedcontent", 47 "epiquote", 48 "epistylematcher", 49 "epitrailing", 50 "epiwindowmanager", 51 "epivisualaid", 52 "fullpage", 53 "fullscreen", 54 "iespell", 55 "inlinepopups", 56 "insertdatetime", 57 "layer", 58 "legacyoutput", 59 "lists", 60 "media", 61 "nonbreaking", 62 "noneditable", 63 "pagebreak", 64 "paste", 65 "preview", 66 "print", 67 "save", 68 "searchreplace", 69 "spellchecker", 70 "style", 71 "tabfocus", 72 "table", 73 "template", 74 "visualchars", 75 "wordcount", 76 "xhtmlxtras" 77 }; 78 this._nonLegacyPlugins = strs; 79 } 80 81 private void CorrectPluginsList(TinyMCEInitOptions options) 82 { 83 List<string> strs = new List<string>() 84 { 85 "epiexternaltoolbar" 86 }; 87 List<string> strs1 = new List<string>() 88 { 89 "lists", 90 "epicontentfragment" 91 }; 92 string item = options.InitOptions["plugins"] as string; 93 char[] chrArray = new char[] { ',' }; 94 IEnumerable<string> strs2 = item.Split(chrArray).Union<string>(strs1).Except<string>(strs).Intersect<string>(this._nonLegacyPlugins); 95 options.InitOptions["plugins"] = string.Join(",", strs2); 96 } 97 98 private IEnumerable<string> GetLegacyPluginsList(TinyMCEInitOptions options) 99 { 100 string item = options.InitOptions["plugins"] as string; 101 char[] chrArray = new char[] { ',' }; 102 return item.Split(chrArray).Except<string>(this._nonLegacyPlugins); 103 } 104 105 public override void ModifyMetadata(ExtendedMetadata metadata, IEnumerable<Attribute> attributes) 106 { 107 IContent model; 108 base.ModifyMetadata(metadata, attributes); 109 TinyMCESettings setting = (TinyMCESettings)((PropertyData)metadata.Model).GetSetting(typeof(TinyMCESettings)); 110 ExtendedMetadata extendedMetadatum = ((ContentDataMetadata)metadata).FindTopMostContentMetadata(); 111 if (extendedMetadatum != null) 112 { 113 model = extendedMetadatum.Model as IContent; 114 } 115 else 116 { 117 model = null; 118 } 119 TinyMCEInitOptions tinyMCEInitOption = new TinyMCEInitOptions((TinyMCEInitOptions.InitType)1, setting, model); 120 IEnumerable<string> legacyPluginsList = this.GetLegacyPluginsList(tinyMCEInitOption); 121 this.CorrectPluginsList(tinyMCEInitOption); 122 if (metadata.IsReadOnly) 123 { 124 tinyMCEInitOption.InitOptions["readonly"] = true; 125 tinyMCEInitOption.InitOptions["body_class"] = "mceReadOnly"; 126 } 127 if (!tinyMCEInitOption.InitOptions.ContainsKey("schema")) 128 { 129 tinyMCEInitOption.InitOptions["schema"] = "html5"; 130 } 131 metadata.EditorConfiguration.Add("width", tinyMCEInitOption.InitOptions["width"]); 132 metadata.EditorConfiguration.Add("height", tinyMCEInitOption.InitOptions["height"]); 133 metadata.EditorConfiguration.Add("settings", tinyMCEInitOption.InitOptions); 134 metadata.EditorConfiguration.Add("legacyPlugins", legacyPluginsList); 135 metadata.EditorConfiguration.Add("parentValue", parentValue(model, metadata.PropertyName)); 136 IDictionary<string, object> editorConfiguration = metadata.EditorConfiguration; 137 string[] strArrays = new string[] { "fileurl", "link", "episerver.core.icontentdata" }; 138 editorConfiguration["AllowedDndTypes"] = strArrays; 139 Dictionary<string, object> strs = new Dictionary<string, object>() 140 { 141 { "theme_advanced_toolbar_location", "external" }, 142 { "theme_advanced_path", false }, 143 { "theme_advanced_statusbar_location", "none" }, 144 { "body_class", "epi-inline" } 145 }; 146 metadata.CustomEditorSettings["uiParams"] = new { inlineSettings = strs }; 147 metadata.CustomEditorSettings["uiType"] = "epi-cms.contentediting.editors.TinyMCEInlineEditor"; 148 metadata.CustomEditorSettings["uiWrapperType"] = "richtextinline"; 149 } 150 151 private string parentValue(IContent page, string propertyName) 152 { 153 if (page.ParentLink != null) 154 { 155 var repro = ServiceLocator.Current.GetInstance<IContentRepository>(); 156 IContent parent; 157 158 if (repro.TryGet(page.ParentLink, out parent)) 159 { 160 return ((ContentData)parent).GetPropertyValue(propertyName) ?? string.Empty; 161 } 162 } 163 164 return string.Empty; 165 } 166 } 167 }
The only thing I have changed/added in this code is:
line 22: The path to the javascript file
line 135: Added a metadata value for the parentValue
line 151-165: Added a private function that gets the value from the parent if there are a property with the same name on it
Other than that it is exactly the way EPiServer has done it’s editor descriptor for the xhtml property. The class is pretty easy to understand, it define the plugins to be used and sets up a lot of metadata that tinyMce use.
After the editor descriptor is created it is time to create the javascript file and this is the tricky path, since it is a lot of code and a lot to understand. I do not understand all it does but enough to be able to extend it to suit my needs.
Here are the code for the widget:
1 define("alloy/editors/ExtendedTinyMCEEditor", [ 2 // dojo 3 "dojo/_base/array", 4 "dojo/_base/config", 5 "dojo/_base/declare", 6 "dojo/_base/lang", 7 "dojo/_base/window", 8 9 "dojo/dom-style", 10 "dojo/dom-class", 11 "dojo/dom-construct", 12 13 "dojo/Deferred", 14 "dojo/keys", 15 "dojo/on", 16 "dojo/when", 17 // dojox 18 "dojox/html/entities", 19 // dijit 20 "dijit/_TemplatedMixin", 21 "dijit/focus", 22 // epi 23 "epi/dependency", 24 "epi/routes", 25 26 "epi/shell/conversion/ObjectConverterRegistry", 27 "epi/shell/dnd/Target", 28 "epi/shell/layout/_LayoutWidget", 29 "epi/shell/TypeDescriptorManager", 30 "epi/shell/widget/_ValueRequiredMixin", 31 32 "epi-cms/widget/_DndStateMixin", 33 "epi-cms/widget/_HasChildDialogMixin", 34 "epi-cms/widget/_UserResizable", 35 "epi/shell/widget/dialog/Alert", 36 // templates 37 "dojo/text!./templates/ExtendedTinyMCEEditor.html", 38 // resources 39 "epi/i18n!epi/cms/nls/tinymce", 40 "epi/i18n!epi/cms/nls/episerver.tinymce" 41 ], 42 43 function ( 44 // dojo 45 array, 46 config, 47 declare, 48 lang, 49 win, 50 51 domStyle, 52 domClass, 53 domConstruct, 54 55 Deferred, 56 keys, 57 on, 58 when, 59 // dojox 60 htmlEntities, 61 // dijit 62 _TemplatedMixin, 63 focusUtil, 64 // epi 65 dependency, 66 epiRoutes, 67 68 ObjectConverterRegistry, 69 Target, 70 _LayoutWidget, 71 TypeDescriptorManager, 72 _ValueRequiredMixin, 73 74 _DndStateMixin, 75 _HasChildDialogMixin, 76 _UserResizable, 77 Alert, 78 // templates 79 template, 80 // resources 81 oldResources, 82 resources 83 ) { 84 85 /* global tinymce: true */ 86 87 return declare([_LayoutWidget, _TemplatedMixin, _HasChildDialogMixin, _UserResizable, _ValueRequiredMixin, _DndStateMixin], { 88 // summary: 89 // Widget for the tinyMCE editor. 90 // tags: 91 // internal 92 93 // baseClass: [public] String 94 // The widget's base CSS class. 95 baseClass: "epiTinyMCEEditor", 96 97 // width: [public] Number 98 // The editor width. 99 width: null, 100 101 // height: [public] Number 102 // The editor height. 103 height: null, 104 105 // value: [public] String 106 // The editor content. 107 value: null, 108 109 // intermediateChanges: Boolean 110 // Fires onChange for each value change or only on demand 111 intermediateChanges: true, 112 113 // templateString: [protected] String 114 // Template for the widget. 115 templateString: template, 116 117 // settings: [public] object 118 // The editor settings. 119 settings: null, 120 121 // dirtyCheckInterval: [public] Integer 122 // How often should the widget check if it is dirty and raise onChange event, in milliseconds. The value is by default set to 2000 ms 123 dirtyCheckInterval: 2000, 124 125 // autoResizable: [public] Boolean 126 // States if the editor can be resized while text is added. 127 autoResizable: false, 128 129 // isResized: [public] Boolean 130 // Indicates if the widget has been already resized on its initialization. 131 isResized: false, 132 133 tinymceRendered: null, 134 135 dropTarget: null, 136 137 // readOnly: [public] Boolean 138 // Denotes that the editor is read only. 139 readOnly: false, 140 141 // _dirtyCheckTimeout: [private] timeout 142 // Used for storing the dirty check timeout reference when dirtyCheckInterval is set. 143 _dirtyCheckTimeout: null, 144 145 // _editorValue: [private] String 146 // The value set to the editor 147 _editorValue: null, 148 149 // _hasPendingChanges: [private] Boolean 150 // holds an indication if the editor has modified content 151 _hasPendingChanges: false, 152 153 postMixInProperties: function () { 154 155 this.inherited(arguments); 156 157 if (this.autoResizable) { 158 this.height = 0; 159 } 160 161 this.tinymceRendered = new Deferred(); 162 }, 163 164 postCreate: function () { 165 166 this.own(this.dropTarget = new Target(this.dndOverlay, { 167 accept: this.allowedDndTypes, 168 createItemOnDrop: false, 169 readOnly: this.readOnly 170 })); 171 172 this.connect(this.inheritButton, "onclick", "onInheritButtonClick"); 173 this.connect(this.inheritFromParentRadioButton, "onclick", "onInheritRadioButtonClick"); 174 this.connect(this.useLocalValueRadioButton, "onclick", "onInheritRadioButtonClick"); 175 this.connect(this.dropTarget, "onDropData", "onDropData"); 176 this.connect(this.dndOverlay, "onmousemove", "_onOverlayMouseMove"); 177 178 this.inherited(arguments); 179 180 this.divParentValue.innerHTML = this._unencodedValue(this.parentValue); 181 }, 182 183 startup: function () { 184 // summary: 185 // Loads the tinyMCE dependencies and initialize the editor. 186 // 187 // tags: 188 // protected 189 190 if (this._started) { return; } 191 192 this.inherited(arguments); 193 194 if (!window.tinymce) { 195 require(["tinymce/tiny_mce_src"], lang.hitch(this, function () { 196 //set the global nls resources 197 var res = lang.mixin({}, oldResources, resources); 198 tinymce.addI18n({ en: res }); 199 200 // Manually load legacy plugins 201 this._loadLegacyPlugins().then(lang.hitch(this, this._initTinyMCE)); 202 })); 203 } else { 204 this._initTinyMCE(); 205 } 206 }, 207 208 destroy: function () { 209 // summary: 210 // Destroy tinymce widget. 211 // 212 // tags: 213 // protected 214 215 if (this._destroyed) { 216 return; 217 } 218 219 this._cancelDirtyCheckInterval(); 220 221 var ed = this.getEditor(); 222 ed && ed.remove(); 223 224 this.inherited(arguments); 225 }, 226 227 _loadLegacyPlugins: function () { 228 // summary: 229 // Load legacy plugins from Util. 230 // 231 // tags: 232 // private 233 234 var dfd = new Deferred(); 235 236 this.legacyPlugins.forEach(function (plugin) { 237 var url = epiRoutes.getActionPath({ 238 moduleArea: "Util", 239 path: "Editor/tinymce/plugins/" + plugin + "/" + (config.isDebug ? "editor_plugin_src.js" : "editor_plugin.js") 240 }); 241 242 // Queue a plugin to load. 243 tinymce.PluginManager.load(plugin, url); 244 }); 245 246 // Load the whole queue. 247 tinymce.ScriptLoader.loadQueue(function () { 248 // Unfortunately, tinymce would silently fail if something went wrong. 249 dfd.resolve(); 250 }); 251 252 return dfd.promise; 253 }, 254 255 canAccept: function () { 256 return !domClass.contains(this.dndOverlay, "dojoDndTargetDisabled"); 257 }, 258 259 _onDndStart: function () { 260 domStyle.set(this.dndOverlay, "display", "block"); 261 this.inherited(arguments); 262 }, 263 264 _onDndCancel: function () { 265 domStyle.set(this.dndOverlay, "display", "none"); 266 this.inherited(arguments); 267 }, 268 269 _onDndDrop: function () { 270 domStyle.set(this.dndOverlay, "display", "none"); 271 this.inherited(arguments); 272 }, 273 274 _onOverlayMouseMove: function (evt) { 275 this._dragPosition = { x: evt.pageX, y: evt.pageY }; 276 }, 277 278 onInheritButtonClick: function () { 279 var text = this.divParentValue.innerHTML; 280 var ed = this.getEditor(); 281 if (ed && ed.initialized) { 282 ed.setContent(text); 283 } else { 284 this.editorFrame.value = text; 285 } 286 this._onChange(text); 287 }, 288 289 onInheritRadioButtonClick: function () { 290 var ed = this.getEditor(); 291 if (ed && ed.initialized) { 292 this._onChange(ed.getContent(), true); 293 } 294 }, 295 296 onDropData: function (dndData, source, nodes, copy) { 297 //summary: 298 // Handle drop data event. 299 // 300 // dndData: 301 // Dnd data extracted from the dragging items which have the same data type to the current target 302 // 303 // source: 304 // The dnd source. 305 // 306 // nodes: 307 // The dragging nodes. 308 // 309 // copy: 310 // Denote that the drag is copy. 311 // 312 // tags: 313 // private 314 315 var dropItem = dndData ? (dndData.length ? dndData[0] : dndData) : null; 316 317 if (!dropItem) { 318 return; 319 } 320 321 // invoke the onDropping required by SideBySideWrapper and other widgets listening on onDropping 322 if (this.onDropping) { 323 this.onDropping(); 324 } 325 326 this._dropDataProcessor(dropItem); 327 }, 328 329 _dropDataProcessor: function (dropItem) { 330 when(dropItem.data, lang.hitch(this, function (model) { 331 332 // TODO: move this to tinymce plugins instead. could be one which will be called to execute content 333 // and one which knows how to insert the specific content 334 335 // TODO: calculate drop position relative to tiny editor, send this to the plugin so it 336 // could handle content depending on where the drop was done 337 338 var self = this, 339 type = dropItem.type, 340 ed = this.getEditor(); 341 342 function insertLink(url) { 343 ed.focus(); 344 ed.execCommand("CreateLink", false, url); 345 } 346 347 function insertHtml(html) { 348 ed.focus(); 349 if (ed.execCommand('mceInsertContent', false, html)) { 350 self._onChange(ed.getContent()); 351 } 352 } 353 354 function createLink(data) { 355 if (!ed.selection.isCollapsed()) { 356 insertLink(data.url); 357 } else { 358 var strTemplate = "<a href=\"{0}\" title=\"{1}\">{1}</a>"; 359 insertHtml(lang.replace(strTemplate, [data.url, htmlEntities.encode(data.text)])); 360 } 361 } 362 363 function createImage(data) { 364 var strTemplate = "<img alt=\"{alt}\" src=\"{src}\" width=\"{width}\" height=\"{height}\" />"; 365 366 var imgSrc = data.previewUrl || data.url; 367 var imgPreviewNode = domConstruct.create("img", { 368 src: imgSrc, 369 style: { display: "none;" } 370 }, win.body(), "last"); 371 372 // Use a temporary image to get it loaded and obtain the correct geometric attributes 373 // Then use the original url since the browser adds hostname to the src attribute which is not always wanted. 374 on.once(imgPreviewNode, "load", function () { 375 insertHtml(lang.replace(strTemplate, { 376 alt: this.alt, 377 width: this.width, 378 height: this.height, 379 src: imgSrc 380 })); 381 // destroy temporary image preview dom node. 382 domConstruct.destroy(imgPreviewNode); 383 }); 384 } 385 386 if (type && type.indexOf("link") !== -1) { 387 createLink(model); 388 389 } 390 else if (type && type.indexOf("fileurl") !== -1) { 391 createImage(model); 392 } 393 394 var typeId = model.typeIdentifier; 395 396 var editorDropBehaviour = TypeDescriptorManager.getValue(typeId, "editorDropBehaviour"); 397 398 if (editorDropBehaviour) { 399 400 if (editorDropBehaviour === 1) { 401 //Default: Create a content object 402 var html = '<div data-contentlink="' + model.contentLink + '" data-classid="36f4349b-8093-492b-b616-05d8964e4c89" class="mceNonEditable epi-contentfragment">' + model.name + '</div>'; 403 insertHtml(html); 404 return; 405 } 406 407 var converter, baseTypes = TypeDescriptorManager.getInheritanceChain(typeId); 408 409 for (var i = 0; i < baseTypes.length; i++) { 410 var basetype = baseTypes[i]; 411 converter = ObjectConverterRegistry.getConverter(basetype, basetype + ".link"); 412 if (converter) { 413 break; 414 } 415 } 416 417 if (!converter) { 418 return; 419 } 420 421 when(converter.convert(typeId, typeId + ".link", model), lang.hitch(this, function (data) { 422 423 if (!data.url) { 424 //If the page does not have a public url we do nothing. 425 var dialog = new Alert({ 426 description: resources.notabletocreatelinkforpage 427 }); 428 dialog.show(); 429 this.own(dialog); 430 } 431 else { 432 switch (editorDropBehaviour) { 433 case 2://Link 434 createLink(data); 435 break; 436 case 3://Image 437 createImage(data); 438 break; 439 } 440 } 441 })); 442 } 443 })); 444 445 domStyle.set(this.dndOverlay, { "display": "none" }); 446 }, 447 448 focus: function () { 449 // Set focus on the current editor. 450 // 451 // tags: 452 // public 453 454 var ed = this.getEditor(); 455 if (ed) { 456 this._focused = true; 457 ed._gainedFocus = true; 458 when(this.tinymceRendered, lang.hitch(this, function () { 459 tinymce.execCommand('mceFocus', false, this.editorFrame.id); 460 })); 461 } 462 }, 463 464 getEditor: function () { 465 // summary: 466 // Return an editor instance. 467 // 468 // tags: 469 // public 470 // 471 // returns: 472 // A instance of the current editor. 473 474 return (typeof tinymce !== "undefined" && tinymce !== null) ? tinymce.get(this.editorFrame.id) : null; 475 }, 476 477 getEditorDOM: function () { 478 // summary: 479 // Get active editor DOM object 480 // tags: 481 // public 482 483 return (typeof tinymce !== "undefined" && tinymce !== null) ? tinymce.DOM : null; 484 }, 485 486 resizeEditor: function () { 487 // summary: 488 // Resize the editor area. 489 // 490 // description: 491 // Resize the editor area when the autoResizable flag is set to true. 492 // This function is executed only one time and it is called on the 493 // timyMCE's onSetContent event. 494 // 495 // tags: 496 // public 497 498 var ed = this.getEditor(), 499 autoresize_min_height = 0, 500 autoresize_max_height = (tinymce.isIE ? document.body.clientHeight : window.innerHeight) - 200, 501 autoresize_max_width = (tinymce.isIE ? document.body.clientWidth : window.innerWidth) - 200, 502 d = ed.getDoc(), 503 b = d.body, 504 DOM = tinymce.DOM, 505 resizeHeight = this.height, 506 resizeWidth = this.width, 507 myHeight, 508 myWidth; 509 510 myHeight = b.scrollHeight; 511 myWidth = b.scrollWidth; 512 513 // don't make it smaller than the minimum height 514 if (myHeight > autoresize_min_height) { 515 resizeHeight = myHeight; 516 } 517 518 // Don't make it bigger than the maximum height 519 if (myHeight > autoresize_max_height) { 520 resizeHeight = autoresize_max_height; 521 } 522 523 if (myWidth > autoresize_max_width) { 524 resizeWidth = autoresize_max_width; 525 } 526 527 // Resize content element 528 DOM.setStyle(ed.contentWindow.frameElement, 'height', resizeHeight + 80 + 'px'); 529 if (ed.getBody().scrollWidth != ed.getBody().offsetWidth) { 530 var padding = 20; 531 if (resizeHeight == autoresize_max_height) { 532 padding = 30; 533 } 534 535 DOM.setStyle(ed.contentWindow.frameElement, 'width', ed.getBody().scrollWidth + padding + 'px'); 536 } 537 538 this.isResized = true; 539 }, 540 541 _setValueAttr: function (newValue) { 542 //summary: 543 // Value's setter 544 // 545 // tags: 546 // protected 547 548 var ed = this.getEditor(), 549 editableValue = newValue || ""; 550 551 var isInheritting = editableValue.charAt(0) == "1"; 552 if (isInheritting) { 553 this.inheritFromParentRadioButton.checked = true; 554 this.useLocalValueRadioButton.checked = false; 555 } else { 556 this.useLocalValueRadioButton.checked = true; 557 this.inheritFromParentRadioButton.checked = false; 558 } 559 560 editableValue = this._unencodedValue(editableValue); 561 562 this._set("value", editableValue); 563 564 // If the editor has started, set the content to it 565 // otherwise it will be set from the textarea when tiny inits 566 if (ed && ed.initialized) { 567 ed.setContent(editableValue); 568 } else { 569 this.editorFrame.value = editableValue; 570 } 571 }, 572 573 _encodedValue: function (orgValue) { 574 var useLocal = this.useLocalValueRadioButton.checked; 575 576 return (useLocal ? "0:" : "1:") + orgValue; 577 578 }, 579 580 _unencodedValue: function (orgValue) { 581 var isSet = orgValue != null && orgValue !== undefined && orgValue.length >= 2 && orgValue.charAt(1) == ":"; 582 return isSet ? orgValue.substring(2) : orgValue; 583 }, 584 585 _updateTinySettings: function () { 586 587 this.settings = lang.mixin(this.settings, { 588 mode: "exact", 589 width: this.width, 590 height: this.height, 591 relative_urls: false, 592 elements: this.editorFrame.id, 593 readonly: this.readOnly 594 }); 595 }, 596 597 _initTinyMCE: function () { 598 // summary: 599 // Initialize the tinyMCE and create a new editor instance. 600 // 601 // tags: 602 // private 603 604 this._updateTinySettings(); 605 606 if (this.legacyPlugins && this.legacyPlugins.length > 0) { 607 // Add legacy plugins back to list, so tinymce can initialize them 608 this.settings.plugins = this.settings.plugins + "," + this.legacyPlugins.join(","); 609 } 610 611 // initialize the editor with the default settings. 612 if (typeof tinymce !== "undefined") { 613 tinymce.dom.Event.domLoaded = true; 614 this._setupHandle = tinymce.onAddEditor.add(lang.hitch(this, this._setupEditorEventHandling)); 615 tinymce.init(this.settings); 616 } else { 617 console.error("Couldn't initialize the editor"); 618 } 619 }, 620 621 _isFullScreen: function (ed) { 622 return ed.editorId === "mce_fullscreen"; 623 }, 624 625 _handlePopupWindow: function (ed) { 626 // summary: 627 // Keep track of popup window showing/hiding to prevent unexpected blur event on widget's container. 628 // 629 // ed: 630 // Instance of the current editor. 631 // 632 // tags: 633 // private 634 635 ed.windowManager.onOpen.add(lang.hitch(this, function () { 636 this.isShowingChildDialog = true; 637 })); 638 639 ed.windowManager.onClose.add(lang.hitch(this, function () { 640 //gain focus 641 this.focus(); 642 643 this.isShowingChildDialog = this._isFullScreen(ed); 644 645 // Attribute changes won't trigger the onChange but we record them and set _hasPendingChanges 646 if (!this.isShowingChildDialog && this._hasPendingChanges) { 647 this._onChange(ed.getContent()); 648 } 649 })); 650 }, 651 652 _handlePopupMenu: function (ed) { 653 // summary: 654 // Keep track of popup menu showing/hiding to prevent unexpected blur event on widget's container. 655 // 656 // ed: 657 // Instance of the current editor. 658 // 659 // tags: 660 // private 661 662 var control; 663 for (var i in ed.controlManager.controls) { 664 //loop through the controls 665 control = ed.controlManager.controls[i]; 666 667 //check if the control has a popup 668 if (control.onRenderMenu) { 669 //hook in onrendermenu event 670 control.onRenderMenu.add(lang.hitch(this, function (parent, menu) { 671 672 //set isShowingChildDialog on when show menu 673 menu.onShowMenu.add(lang.hitch(this, function () { 674 this.isShowingChildDialog = true; 675 })); 676 677 //set isShowingChildDialog off when hide menu 678 menu.onHideMenu.add(lang.hitch(this, function () { 679 this.isShowingChildDialog = this._isFullScreen(ed); 680 })); 681 })); 682 } 683 } 684 }, 685 686 _setupEditorEventHandling: function (manager, ed) { 687 // summary: 688 // Hook up to the tinyMCE events. 689 // 690 // ed: 691 // Instance of the current editor. 692 // 693 // tags: 694 // private 695 696 // Check if the event was by the correct instance. 697 if (ed.id !== this.editorFrame.id) { 698 return; 699 } 700 701 tinymce.onAddEditor.remove(this._setupHandle); 702 703 ed.onInit.add(lang.hitch(this, function () { 704 705 !this.tinymceRendered.isResolved() && this.tinymceRendered.resolve(); 706 707 //register iframe 708 this.own(focusUtil.registerIframe(ed.contentWindow.frameElement)); 709 710 this._handlePopupMenu(ed); 711 this._handlePopupWindow(ed); 712 713 if (this.autoResizable) { 714 setTimeout(lang.hitch(this, this.resizeEditor), 200); 715 } else { 716 this.set("isResized", true); 717 } 718 719 this._startDirtyCheckInterval(); 720 721 // Add event handler on editor selection after its initialized 722 if (ed && ed.selection) { 723 ed.selection.onSetContent.add(lang.hitch(this, function (selection, args) { 724 var isSet = args.set; 725 // If new content is set to current selection, raise event to save it! 726 if (isSet) { 727 this._onChange(ed.getContent()); 728 } 729 })); 730 } 731 })); 732 733 ed.onSetContent.add(lang.hitch(this, function (ed, e) { 734 this.onSetContent(ed, e); 735 })); 736 737 ed.onKeyUp.add(lang.hitch(this, function (ed, e) { 738 this.onKeyUp(ed, e); 739 })); 740 741 ed.onChange.add(lang.hitch(this, function (ed, e) { 742 this._onChange(ed.getContent()); 743 })); 744 745 ed.onRemove.add(lang.hitch(this, function (ed) { 746 this.onEditorRemoved(ed); 747 })); 748 749 ed.onRedo.add(lang.hitch(this, function (ed, level) { 750 this.onRedo(ed, level); 751 })); 752 753 ed.onUndo.add(lang.hitch(this, function (ed, level) { 754 this.onUndo(ed, level); 755 })); 756 757 ed.onBeforeExecCommand.add(lang.hitch(this, function (ed, cmd, ui, val, a) { 758 if (cmd === "mceFullScreen") { 759 // About to toggle fullscreen 760 this.isShowingChildDialog = !this._isFullScreen(ed); 761 } 762 })); 763 }, 764 765 // events. 766 767 onSetContent: function (/*Object*/ed, /*Object*/e) { 768 // summary: 769 // Raised when the content is set to the editor. 770 // 771 // ed: 772 // The editor instance. 773 // 774 // tags: 775 // callback public 776 777 var newValue = ed.getContent(), 778 hasChanged = this.get("_editorValue") !== newValue; 779 780 // in init phase; set a value to start with 781 e.initial && this.set("_editorValue", newValue); 782 783 if (hasChanged) { 784 this.validate(); 785 this.onLayoutChanged(); 786 !e.initial && this.set("_hasPendingChanges", true); 787 } 788 }, 789 790 onEditorRemoved: function (/*Object*/ed) { 791 // summary: 792 // Raised when a instance of the editor is removed. 793 // 794 // ed: 795 // The editor instance. 796 // 797 // tags: 798 // callback public 799 800 }, 801 802 _isMetaKey: function (/*Object*/e) { 803 // summary: 804 // Check if the key pressed is a metakey. 805 // 806 // e: 807 // The keyboard event. 808 // 809 // tags: 810 // private 811 // 812 // returns: 813 // Return true if the key pressed is a metakey, otherwise return false. 814 815 if (e.keyCode == keys.CTRL || e.keyCode == keys.ALT || e.keyCode == keys.SHIFT || e.keyCode == keys.META) { 816 return true; //Boolean 817 } 818 819 return false; //Boolean 820 }, 821 822 onKeyUp: function (/*Object*/editor, /*Object*/e) { 823 // summary: 824 // Raised when a keyboard key is released on the editor. 825 // 826 // editor: 827 // The editor instance. 828 // 829 // e: 830 // The event object. 831 // 832 // tags: 833 // callback public 834 835 if (editor) { 836 if (!tinymce.VK.metaKeyPressed(e) && !this._isMetaKey(e)) { 837 this.set("_hasPendingChanges", editor.undoManager.typing); 838 } 839 } 840 }, 841 842 _onChange: function (val, forcechange) { 843 // summary: 844 // Raised when the editor's content is changed. 845 // 846 // val: 847 // The editor's changed value 848 // 849 // tags: 850 // callback public 851 if (forcechange == null) { 852 forcechange = false; 853 } 854 855 var hasChanged = this.get("_editorValue") !== val || forcechange == true; 856 857 if (hasChanged) { 858 this.set("_editorValue", val); 859 860 val = this._encodedValue(val); 861 this._set("value", val); 862 863 if (this.validate()) { 864 this.set("_hasPendingChanges", false); 865 this.onChange(val); 866 } 867 } 868 }, 869 870 onChange: function (val) { 871 console.log("onChange, val: " + val); 872 // summary 873 }, 874 875 _stopEditing: function () { 876 // summary: 877 // Stop editor editing function 878 // tags: 879 // protected 880 881 var ed = this.getEditor(), 882 val = ed.getContent(); 883 884 this._focused = false; 885 886 // This is common way to hide a validation popup in dojo 887 this.displayMessage(null); 888 889 if (ed && ed.undoManager && ed.undoManager.typing) { 890 ed.undoManager.typing = 0; 891 ed.undoManager.add(); 892 } 893 894 if (this._hasPendingChanges) { 895 if (!this.intermediateChanges) { 896 this._onChange(val); 897 } else { 898 this._set("value", val); 899 } 900 } 901 }, 902 903 _onBlur: function () { 904 // summary: 905 // Hide validation pop up and set focused flag off. 906 // 907 // tags: 908 // private 909 910 this._stopEditing(); 911 912 this.inherited(arguments); 913 }, 914 915 _cancelDirtyCheckInterval: function () { 916 // summary: 917 // Stop any running intervalled dirty check 918 // tags: 919 // private 920 921 if (this._dirtyCheckTimeout) { 922 clearTimeout(this._dirtyCheckTimeout); 923 this._dirtyCheckTimeout = null; 924 } 925 }, 926 927 _startDirtyCheckInterval: function () { 928 // summary: 929 // Calls _dirtyCheck and schedules a new one according to the dirtyCheckInterval flag 930 // tags: 931 // private 932 933 if (this._destroyed || !this.intermediateChanges) { 934 return; 935 } 936 937 this._cancelDirtyCheckInterval(); 938 939 this._dirtyCheck(); 940 941 this._dirtyCheckTimeout = setTimeout(lang.hitch(this, this._startDirtyCheckInterval), this.dirtyCheckInterval); 942 }, 943 944 _dirtyCheck: function () { 945 // summary: 946 // Check if the editor is dirty and raise onChange event. 947 // 948 // tags: 949 // private 950 951 var ed = this.getEditor(); 952 if (ed) { 953 if (this._hasPendingChanges) { 954 this._onChange(ed.getContent()); 955 } 956 } 957 }, 958 959 onRedo: function (/*Object*/ed, level) { 960 // summary: 961 // Raised by the redo event. 962 // 963 // ed: 964 // The editor instance. 965 // 966 // tags: 967 // callback public 968 969 this.set("_hasPendingChanges", true); 970 }, 971 972 onUndo: function (/*Object*/ed, level) { 973 // summary: 974 // Raised undo event. 975 // 976 // ed: 977 // The editor instance. 978 // 979 // tags: 980 // callback public 981 this.set("_hasPendingChanges", true); 982 } 983 }); 984 }); 985
This is pretty much the exact file that you will find in this path in the source code from EPiServer (EPiServer.Cms.Shell.UI\7.14.0.0\ClientResources\epi-cms\contentediting\editors\TinyMCEEditor.js).
First the code depends on a html template that looks like this:
1 <div id="widget_${id}"> 2 Inherited value<br /> 3 <div class="epi-formsWidgetWrapper"> 4 <div data-dojo-attach-point="divParentValue" class="dijitTextBox dijitTextArea dijitExpandingTextArea" style="width: 582px; height: 130px;"> 5 </div> 6 </div> 7 <div id="parentLocalControlContainer-123454"> 8 <div style="float:left; padding-left:200px; padding-right: 12px; margin-top: 20px;"> 9 <input type="radio" name="inheritFromParentRadioButton" data-dojo-attach-point="inheritFromParentRadioButton">Use inherited value<br> 10 <input type="radio" name="inheritFromParentRadioButton" data-dojo-attach-point="useLocalValueRadioButton">Use local value 11 </div> 12 <div style="float:left; margin-top: 12px;"> 13 <button data-dojo-type="dijit/form/Button" data-dojo-attach-point="inheritButton" style="height:26px;margin-top:12px;" type="button"> ▼ </button> 14 </div> 15 </div> 16 <div style="clear: both;"> 17 Local value<br /> 18 <div style="display: inline-block" data-dojo-attach-point="stateNode, tooltipNode"> 19 <textarea data-dojo-attach-point="editorFrame" id="${id}_editorFrame" style="border: none;"></textarea> 20 </div> 21 <div data-dojo-attach-point="dndOverlay" style="background: rgba(0, 0, 0, 0.01); position: absolute; left: 0; top: 0; right: 0; bottom: 0; display: none"></div> 22 </div> 23 </div> 24
The original template looks like this:
1 <div id="widget_${id}"> 2 <div style="display: inline-block" data-dojo-attach-point="stateNode, tooltipNode"> 3 <textarea data-dojo-attach-point="editorFrame" id="${id}_editorFrame" style="border: none;"></textarea> 4 </div> 5 <div data-dojo-attach-point="dndOverlay" style="background: rgba(0, 0, 0, 0.01); position: absolute; left: 0; top: 0; right: 0; bottom: 0; display: none"></div> 6 </div> 7
So I have change that file a lot and that is just to be able to add the extra parts that I need for my custom property. As you can see I use DOJO attach point for all my extra elements and that is a key thing if you want to have a more easy to understand widget, since it makes the code more easy to understand (when you have learned the basic of DOJO).
So, to make my custom property to work I had to make some changes to the javascript file and the first thing I did was to set up connectors to my extra elements, see line 172-174. I also set up the value in the ParentValue box with this code: “this.divParentValue.innerHTML = this._unencodedValue(this.parentValue);” Then I created two functions that are used for these events. The first “onInheritButtonClick” takes the value from the div containing the parent value en updates the local value with it and calls _onChange so the widget should know that something has happend so it will save it to the database. The other function “onInheritRadioButtonClick” does not really do anything other than to call _onChange because all the logic for handling what value that has been changes lies in that function.
In the _onChange function I have added one line of code saying “val = this._encodedValue(val);” and this is because I need to save the combined value to the database, not the value that is in the textarea and the function _encodedValue does the calculation on how to set up the string that should be saved. This is a function I have written also, and is not in EPiServers code. I have also added a extra parameter to the function that force the value to be updated and that i because if the editor just change the select box, EPiServer will think that the value has not changed because the value in the checkbox is the same as before, so I needed a way to keep the function to not do changes that are not needed but still be able to force an update and this was the way I solved that.
In the function “_setValueAttr” that is called when the widget starts and EPiServer sets the value I added code to setup the elements in the property and in there I call a custom made function called “_unencodedValue” that removes the first two characters if there are any that looks like 1: or 0: so the textarea gets the “clean” property value.
This is pretty much it, all other code is code written by EPiServer and it is almost 1000 lines of code so I wont go into it but there are a lot of good comments that will guide you through it.
The source code for this you can find here: https://github.com/hesta96/TinyMceInCustomProperties
I think it is just do clone it and then press F5 to run it, but I am no git expert so I might have forgot some file needed, if you can’t get it to work, please place a comment here and I will see if I can fix it. If you have any suggestions on how it could be better, please do a pull request and I will gladly look at it and update with your code. I am no DOJO expert so there might be some things that can be done better but this is working and that proves that you can add tinyMce to your custom properties just the same way EPiServer does it!
Comments