Aleksandar Trajanovski
Jan 27, 2015
  8173
(1 votes)

Creating Custom EPiServer TinyMCE Plugin

I have received a request from client, to be able to add accordion in the tinymce editor with clicking only on a button in the toolbar of the editor. I said ok as always :), but subsequent questions were coming on my mind, you need to add items in the accordion and again ok :), jquery table with add rows and delete rows will do the job. That question sorted, another question came up, modal window for editing and inserting items and guess what again jquery. The body of the inserted items needs to be styled so I need to use tinymce editor for that, ok I have that. Let’s roll :).

The html structure of the accordion is following:

  1. <div class="accordion-panel-group">
  2.     <h3 class="header-small linkbox-header no-border">Optional <span class="color-brand">title</span></h3>
  3.     <div class="panel-group" id="accordion">
  4.         <div class="panel panel-default">
  5.             <div class="panel-heading">
  6.                 <h4 class="panel-title">
  7.                     <a data-toggle="collapse" data-parent="#accordion" href="#collapseOne">
  8.                         <!--  All exept the first header needs class="collapsed"-->
  9.                         <span class="plus"></span> Collapsible Group Item #1
  10.                     </a>
  11.                 </h4>
  12.             </div>
  13.             <div id="collapseOne" class="panel-collapse collapse in">
  14.                 <div class="panel-body">
  15.                                       Body Text.
  16.                 </div>
  17.             </div>
  18.         </div>
  19.         <div class="panel panel-default">
  20.             <div class="panel-heading">
  21.                 <h4 class="panel-title">
  22.                     <a data-toggle="collapse" data-parent="#accordion" href="#collapseTwo" class="collapsed">
  23.                         <span class="plus"></span> Collapsible Group Item #2
  24.                     </a>
  25.                 </h4>
  26.             </div>
  27.             <div id="collapseTwo" class="panel-collapse collapse">
  28.                 <div class="panel-body">
  29.                    Body Text.
  30.                 </div>
  31.             </div>
  32.         </div>
  33.         <div class="panel panel-default">
  34.             <div class="panel-heading">
  35.                 <h4 class="panel-title">
  36.                     <a data-toggle="collapse" data-parent="#accordion" href="#collapseThree" class="collapsed">
  37.                         <span class="plus"></span> Collapsible Group Item #3
  38.                     </a>
  39.                 </h4>
  40.             </div>
  41.             <div id="collapseThree" class="panel-collapse collapse">
  42.                 <div class="panel-body">
  43.                                       Body Text.
  44.                 </div>
  45.             </div>
  46.         </div>
  47.     </div>
  48. </div>

I have started from here. I have done the basic classes and add the basic js files.

1. AccordionPlugin.cs :

  1. [TinyMCEPluginButton(
  2. PlugInName = "accordionplugin", ButtonName = "accordionbutton", GroupName = "misc", LanguagePath =
  3. "/admin/tinymce/plugins/accordionplugin/accordionbutton",
  4. IconUrl = "/admin/tinymce/plugins/accordionplugin/images/accordion.png")]
  5. public class AccordionPlugin
  6. {
  7. }

As you can see this is basic class, and here i have defined only the Icon Url on line 4.

2. editor_plugin_src.js:

  1. /// <reference path="AccordionSnippet.html" />
  2. (function (tinymce, $) {
  3. // Load plugin specific language pack
  4. tinymce.PluginManager.requireLangPack('accordionplugin');
  5. tinymce.create('tinymce.plugins.accordionplugin', {
  6. init: function (ed, url) {
  7. ed.addCommand('mceAccordion', function () {
  8. var parentNode = ed.dom.getParent(ed.selection.getNode(), "div.accordion-panel-group");
  9. var insideAccordion = parentNode != null;
  10. var accordionID = null;
  11. if (insideAccordion)//if inside get accordion id
  12. {
  13. if (parentNode.children[1] != null) {
  14. accordionID = parentNode.children[1].id;
  15. }
  16. else {
  17. accordionID = parentNode.children[0].id;
  18. }
  19. }
  20. ed.windowManager.open({//for edit accordion
  21. file: url + '/AccordionSnippet.html',
  22. inline: false,
  23. scrollbars: true,
  24. maximizable: true
  25. }, {
  26. argUrl: url, // Plugin absolute URL
  27. argParentNode: parentNode, // Accordion
  28. argAccordionID: accordionID // Accordion ID
  29. });
  30. });
  31. // Register infobox button
  32. ed.addButton('accordionbutton', {
  33. title: 'Add Accordion',
  34. cmd: 'mceAccordion',
  35. image: url + '/images/accordion.png',
  36. "class": ""
  37. });
  38. ed.onNodeChange.add(function (ed, cm, n, co) {
  39. var insideAccordion = ed.dom.getParent(n, "div.accordion-panel-group") != null;
  40. cm.setActive('accordionbutton', insideAccordion);
  41. ed.save();
  42. });
  43. ed.onActivate.add(function (ed) {
  44. ed.save();
  45. });
  46. ed.onChange.add(function (ed) {
  47. ed.save();
  48. });
  49. ed.onClick.add(function (ed) {
  50. ed.save();
  51. });
  52.  
  53. },
  54. /**y
  55. * Returns information about the plugin as a name/value array.
  56. *
  57. * @return {Object} Name/value array containing information about the plugin.
  58. */
  59. getInfo: function () {
  60. return {
  61. longname: 'Accordion plugin',
  62. author: 'ProPeople',
  63. authorurl: '',
  64. infourl: '',
  65. version: "1.0"
  66. };
  67. }
  68. });
  69. // Register plugin
  70. tinymce.PluginManager.add('accordionplugin', tinymce.plugins.accordionplugin);
  71. }(tinymce, epiJQuery));

On line 8,9 and 11 I'm requesting the parent node of my selection in the tinymce editor. The class "accordion-panel-group" is my parent node and in line 11 i'm checking to see if the selection is inside that node. If it is I'm getting the id of the accordion panel (If there is title the second child has the id-panel, if not the first child has the id). After that activate window manager of the editor with parameters. And here comes AccordionSnippet.html on line 21.

Thanks to this guy with his post i managed to make a popup tinymce dialog.

4. AccordionSnippet.html:

  1. <head>
  2.     <title>Manage accordion</title>
  3.     <script src="js/jquery-1.8.3.js"></script>
  4.     <link href="js/jquery-ui.css" rel="stylesheet" />
  5.     <script src="js/jquery-ui.js"></script>
  6.     <script src="../../../tinymceCustom/tiny_mce_popup.js"></script>
  7.     <script src="../../../tinymceCustom/tiny_mce.js"></script>
  8.     <script type="text/javascript" src="js/AccordionSnippet.js"></script>
  9.     <script src="../../../tinymceCustom/jquery.tinymce.js"></script>
  10.     <script type="text/javascript">
  11.         $(function () {    
  12.             tinyMCE.init({
  13.                 theme: 'advanced',
  14.                 mode: 'none',
  15.                 theme_advanced_toolbar_location: "top"
  16.             });
  17.         });
  18.     </script>
  19.     <link href="js/Accordion.css" rel="stylesheet" />
  20.     <script src="js/Accordion.js"></script>
  21. </head>
  22. <body style="overflow-y: scroll; height: 600px;">
  23.     <div>
  24.         <div id="dialog-form" title="Add new accordion item">
  25.             <p class="validateTips">
  26.                 All form fields are required.
  27.             </p>
  28.             <form>
  29.                 <fieldset>
  30.                     <label for="titleA">
  31.                         Title
  32.                     </label>
  33.                     <input type="text" name="titleA" id="titleA" value="" class="text ui-widget-content ui-corner-all" />
  34.                     <label for="bodyText">
  35.                         Body
  36.                     </label>
  37.                     <textarea rows="25" cols="85" name="bodyText" id="bodyText" class="text ui-widget-content ui-corner-all">Your content here</textarea>
  38.                 </fieldset>
  39.             </form>
  40.         </div>
  41.         <div id="accordion-contain" class="ui-widget" style="width:680px; padding-bottom:100px">
  42.             <h1>Current accordion</h1>
  43.             <table id="accordionItems" class="ui-widget ui-widget-content" style="width:100%;">
  44.                 <thead>
  45.                     <tr class="ui-widget-header ">
  46.                         <th style="width:170px;">Title </th>
  47.                         <th style="width:500px;">Body </th>
  48.                         <th style="width:5px;"></th>
  49.                         <th style="width:5px;"></th>
  50.                     </tr>
  51.                 </thead>
  52.                 <tbody>
  53.                     <!--here goes dinamically accordion items-->
  54.                 </tbody>
  55.             </table>
  56.             <a class="createa" href="">Add new accordion item</a>
  57.         </div>
  58.         <form onsubmit="cfHtmlSnippetDialog.insert();return false;" action="#">
  59.             <div id="SnippetCode" class="editablecontent" style="display:none;">
  60.                 <!--here goes the accordion-->
  61.                 <div class="accordion-panel-group">
  62.                     <h3 class="header-small linkbox-header no-border">Optional <span class="color-brand">title</span></h3>
  63.                     <div class="panel-group" id="accordion">
  64.                         <!--here goes the accordion items-->
  65.                     </div>
  66.                 </div>
  67.                 <!--here goes the accordion ends-->
  68.             </div>
  69.             <div class="mceActionPanel">
  70.                 <div style="position: fixed; bottom: 0px; right:0px;background-color:white; width:100%">
  71.                     <table style="width:100%; text-align:right">
  72.                         <tr>
  73.                             <td>
  74.                                 &nbsp;
  75.                             </td>
  76.                             <td style="width:5px">
  77.                                 <table style="width:100%; text-align:right">
  78.                                     <tr>
  79.                                         <td>
  80.                                             <input type="button" id="insert" name="insert" value="{#insert}" onclick="cfHtmlSnippetDialog.insert();" />
  81.                                         </td>
  82.                                         <td>
  83.                                             <input type="button" id="cancel" name="cancel" value="{#cancel}" onclick="tinyMCEPopup.close();" />
  84.                                         </td>
  85.                                     </tr>
  86.                                 </table>
  87.                             </td>
  88.                         </tr>
  89.                     </table>
  90.                 </div>
  91.             </div>
  92.         </form>
  93.         <script type="text/javascript">
  94.             ////init configuration of the dialog//////
  95.             ////setting up the html in the hidden div
  96.             var parentNode = tinyMCEPopup.getWindowArg("argParentNode");
  97.             if (parentNode != null) {
  98.                 document.getElementById('SnippetCode').innerHTML = parentNode.innerHTML
  99.             }
  100.             var _parentNode = tinyMCEPopup.getWindowArg("argParentNode");
  101.             var _accordionID = tinyMCEPopup.getWindowArg("argAccordionID");
  102.             var min = 1;
  103.             var max = 50;
  104.             var random = Math.floor(Math.random() * (max - min + 1)) + min;
  105.             randomNumber = random;
  106.             if (parentNode != null) {
  107.                 edit = true;
  108.                 parentNode = _parentNode;
  109.                 accordionID = _accordionID;
  110.             }
  111.             else {
  112.                 accordionID = 'accordionID' + random;
  113.             }
  114.         </script>
  115.     </div>
  116. </body>
  117. </html>

As you can see in line 6,7,9 i have included my own downloaded version of tinymce (majorVersion:"3",minorVersion:"5.11",releaseDate:"2014-05-08") downloaded from their site. Because by EPiServer on this link In the last section "Using the TinyMCE editor from template pages" we can read that if we want to use TinyMCE editor in template pages as in our case AccordionSnippet.html, we need to download version from the official site and this version is not connected with the existing one on Episerver, because of that we are not able to insert local media files in body text. Line 11 is init function of tinymce. Line 24 the div is my dialog form for anoter popup where i can insert or edit items in the accordion (Title and Body text). Line 43 is the table with the conatining items displayed in table view. Line 63 is hidden accordion panel where i'm hiding the changes before the are updated on the main tinymce editor. Below i have the insert and close buttons for the snippet.

5. AccordionSnippet.js

  1. tinyMCEPopup.requireLangPack();
  2. var edit = false;
  3. var parentNode = null;
  4. var accordionID = null;
  5. var randomNumber = null;
  6. var cfHtmlSnippetDialog = {
  7.     init: function () {
  8.         //empty function
  9.     },
  10.     insert: function () {
  11.         var sel_text = 0;
  12.         var ed = tinyMCEPopup.editor;
  13.         if (ed.selection.getContent()) {
  14.             content = ed.selection.getContent();
  15.             sel_text = 1;
  16.         } else {
  17.             content = ed.getContent();
  18.         }
  19.         if (edit) {
  20.             tinyMCEPopup.editor.dom.select('#' + accordionID)[0].innerHTML = jQuery('#' + accordionID).html();
  21.             tinyMCEPopup.execCommand('mceInsertContent', false, "");
  22.             tinyMCE.triggerSave();
  23.         } else {
  24.             tinyMCEPopup.execCommand('mceInsertContent', false, "<div class='accordion-panel-group'>" + jQuery('.accordion-panel-group').html() + "</div>");
  25.         }
  26.         tinyMCE.triggerSave();
  27.         tinyMCEPopup.close();
  28.       
  29.     }
  30. };
  31. tinyMCEPopup.onInit.add(cfHtmlSnippetDialog.init, cfHtmlSnippetDialog);

Here comes the inser fuction of the snippet where depends it is edit or new accordion.

6. Accordion.js:

  1. $(document).ready(function () {
  2.     //change the template accordion id in the hidden div
  3.     jQuery('#accordion').attr("id", accordionID);
  4.     var parentNodeInnerHTML = $("#SnippetCode").html();
  5.     var atitles = [];//get all accordion items titles
  6.     $("#SnippetCode").find("a.collapsed").each(function () { atitles.push($(this).text()); });
  7.     var tbodies = [];//get all accordion items bodies
  8.     $("#SnippetCode").find(".panel-body").each(function () { tbodies.push($(this).html()); });
  9.  
  10.     $.each(atitles, function (i, item) {
  11.         //fills the table with the accordion items data
  12.         $("#accordionItems tbody").append("<tr>" + "<td>" + $.trim(atitles[i]) + "</td>" + "<td>" + $.trim(tbodies[i]) + "</td>" + "<td><a href='' class='edit'>Edit</a></td>" + "<td><span class='delete'><a href=''>Delete</a></span></td>" + "</tr>");
  13.     });
  14. });
  15.  
  16. $(function () {
  17.     function recreate_accordion() {
  18.         if ($('#' + accordionID + '').length > 0) {
  19.             // dostuff
  20.             $('#' + accordionID).empty();//removes item from the accordion
  21.             $('#' + accordionID).html('');//removes item from the accordion
  22.             $('#' + accordionID).children().remove();//removes item from the accordion
  23.             $('#accordionItems> tbody  > tr').each(function (ri, el) {
  24.                 var row = $(this);
  25.                 var _atitle = $(row.children().get(0)).text(),//accordion item title
  26.                      _tbody = $(row.children().get(1)).html();//accordion item body
  27.                 var rowIndex = ri;
  28.                 $("#" + accordionID).append(//appends item to the accordion
  29.                 "<div class='panel panel-default'>" +
  30.                 "   <div class='panel-heading'>" +
  31.                 "       <h4 class='panel-title'>" +
  32.                 "           <a data-toggle='collapse' data-parent='#" + accordionID + "' href='#collapseOne" + rowIndex + randomNumber + "' class='collapsed'>" +
  33.                 "               <span class='plus'>&nbsp;</span>" + $.trim(_atitle) + "</a>" +
  34.                 "       </h4>" +
  35.                 "   </div>" +
  36.                 "   <div id='collapseOne" + rowIndex + randomNumber + "' class='panel-collapse collapse'>" +
  37.                 "       <div class='panel-body'>" + $.trim(_tbody) + "</div>" +
  38.                 "   </div>" +
  39.                 "</div>");
  40.             });
  41.         }
  42.     }
  43.     function addTinyMCE() {
  44.         $('textarea').tinymce({
  45.             // Location of TinyMCE script
  46.             script_url: '../../../../../tiny_mce/tiny_mce.js',
  47.             mode: "none",
  48.             theme: "advanced",
  49.             theme_advanced_toolbar_location: "top"
  50.         });
  51.     }
  52.     function removeTinyMCE() {
  53.         if (tinyMCE.getInstanceById('bodyText')) {
  54.             tinyMCE.execCommand('mceFocus', false, 'bodyText');
  55.             tinyMCE.execCommand('mceRemoveControl', false, 'bodyText');
  56.         }
  57.     }
  58.     var new_dialog = function (type, row) {
  59.         var dlg = $("#dialog-form");
  60.         var atitle = dlg.find(("#titleA")),
  61.         tbody = dlg.find(("#bodyText"));
  62.         atitle.val("");
  63.         tbody.val("");
  64.         type = type || 'Create';
  65.         var config = {
  66.             autoOpen: true,
  67.             height: 575,
  68.             width: 645,
  69.             modal: true,
  70.             open: addTinyMCE,
  71.             buttons: {
  72.                 "Add accordion item": save_data,
  73.                 "Cancel": function () {
  74.                     removeTinyMCE();
  75.                     dlg.dialog("close");
  76.                 }
  77.             },
  78.             close: function () {
  79.                 removeTinyMCE();
  80.                 dlg.dialog("destroy");
  81.             }
  82.         };
  83.         if (type === 'Edit') {
  84.  
  85.             config.title = "Edit accordion item";
  86.             get_data();
  87.             delete (config.buttons['Add accordion item']);
  88.             config.buttons['Edit accordion item'] = function () {
  89.                 var helpValue = tbody.val();
  90.                 helpValue = helpValue.replace("<p>", "");
  91.                 helpValue = helpValue.replace("</p>", "");
  92.                 tbody.val(helpValue);
  93.                 $(row.children().get(0)).text(atitle.val());
  94.                 $(row.children().get(1)).html(helpValue);
  95.                 recreate_accordion();
  96.                 dlg.dialog("close");
  97.             };
  98.         }
  99.         dlg.dialog(config);
  100.         dlg.dialog("option", "resizable", true);
  101.         function get_data() {
  102.             var _atitle = $(row.children().get(0)).text(),
  103.         _tbody = $(row.children().get(1)).html();
  104.             atitle.val($.trim(_atitle));
  105.             tbody.val($.trim(_tbody));
  106.         }
  107.  
  108.         function save_data() {
  109.             $("#accordionItems tbody").append("<tr>" + "<td>" + atitle.val() + "</td>" + "<td>" + tbody.val() + "</td>" + "<td><a href='' class='edit'>Edit</a></td>" + "<td><span class='delete'><a href=''>Delete</a></span></td>" + "</tr>");
  110.             $("#" + accordionID).append( //appends item to the accordion
  111.             "<div class='panel panel-default'>" +
  112.             "   <div class='panel-heading'>" +
  113.             "       <h4 class='panel-title'>" +
  114.             "           <a data-toggle='collapse' data-parent='#" + accordionID + "' href='#collapseOne" + ($('#accordionItems tbody tr').length + 1) + randomNumber + "' class='collapsed'>" +
  115.             "               <span class='plus'>&nbsp;</span>" + $.trim(atitle.val()) + "</a>" +
  116.             "       </h4>" +
  117.             "   </div>" +
  118.             "   <div id='collapseOne" + ($('#accordionItems tbody tr').length + 1) + randomNumber + "' class='panel-collapse collapse'>" +
  119.             "       <div class='panel-body'>" + $.trim(tbody.val()) + "</div>" +
  120.             "   </div>" +
  121.             "</div>");
  122.  
  123.             dlg.dialog("close");
  124.         }
  125.     };
  126.     //delete function
  127.     $(document).on('click', 'span.delete', function () {
  128.         $(this).parents('tr:first').remove();
  129.         recreate_accordion();//recreates the html of the accordion in the hidden div
  130.         return false;
  131.     });
  132.     //edit function
  133.     $(document).on('click', 'td a.edit', function () {
  134.         new_dialog('Edit', $(this).parents('tr'));
  135.         recreate_accordion();//recreates the html of the accordion in the hidden div
  136.         return false;
  137.     });
  138.     $(document).on('click', 'a.createa', function () {
  139.         new_dialog();
  140.         return false;
  141.     });
  142. });

In this js file in the ready function i get all the titles and body texts and fill the table with the items to diplayed it. The function recreate_accordion i used to recreate the hidden accordion (explained before) on every change so i can keep track of changes. Functions addTinyMCE and removeTinyMCE are required for init and removing of the tinymce on the textarea (thanks to this guy on this link i have understand why). The other functions are pretty explanatory by themself.

You can use these files as copy/paste to your project, but if before you have added the mentioned tinymce version and jquery version. The css class i have not provided them becasue you can build them by yourself or find on internet.

I'm using the tinymce editor as plugin for the episerver tinymce editor to create styled items for the accordion and the accordion itself. The chalenge was the setup to work episerver tinymce edtor and the classic tinymce editor along with jquery and tinymce jquery together.

IMPROVEMENTS

Using the tinymce in popup window as plugin in episerver has limitations, one of that is that you cannot select local files of episerver as links in the body text. As mentioned above episerver reccomend to use downloaded version of tinymce.

BUT some gys have tried something and they are on good direction

1. (http://world.episerver.com/forum/developer-forum/EPiServer-7-CMS/Thread-Container/2013/11/How-well-can-TinyMCE-run-outside-of-Edit-Mode/)

2. (http://world.episerver.com/Modules/Forum/Pages/Thread.aspx?id=85047)

3. (https://gist.github.com/mvirkkunen/10484486)

Thanks for reading, if you have any questions or remarks please dont hesitate to write me :).

12345

Jan 27, 2015

Comments

Henrik Fransas
Henrik Fransas Jan 27, 2015 12:49 PM

If you just want to add a button that adds something to the TinyMCE you can add your own plugin, but if you like to extend the whole editor look at my blog post on how to do it:
http://world.episerver.com/blogs/Henrik-Fransas/Dates/2014/11/how-to-use-tinymce-in-a-custom-property-the-episerver-wayclean-version/

Aleksandar Trajanovski
Aleksandar Trajanovski Jan 27, 2015 01:27 PM

Henrik thanks for commenting, but i have reedited my blog if you want to check it out :)

Henrik Fransas
Henrik Fransas Jan 27, 2015 01:50 PM

Will look at it

Henrik Fransas
Henrik Fransas Jan 28, 2015 02:38 PM

Seems like you have solved it, that's good and a good description

Lahiru Dhananjaya
Lahiru Dhananjaya Jul 21, 2015 08:51 AM

Hi i have built the plugin and working well in Firefox,Chrome.But got an issue with IE 11.It says 
SCRIPT28: Out of stack space when opening the plugin and when loading the tinyMCE editor

IE throw the error "not responding due to a long running script " and after that it crashes the IE.All times it happened when loading the tinyMCE editor. Couldn't identify where the code gone wrong when intializing tinymce in accordion.js

Lahiru Dhananjaya
Lahiru Dhananjaya Jul 21, 2015 11:11 AM

var config = {
autoOpen: true,
height: 650,
width: 750,
modal: true,
open: addTinyMCE,
buttons: {
"Add accordion item": save_data,
"Cancel": function () {
removeTinyMCE();
dlg.dialog("close");
}

function addTinyMCE() {
$('textarea').tinymce({
// Location of TinyMCE script
script_url: '../../../tiny_mce.js',
theme: "advanced",
mode: 'none',
theme_advanced_toolbar_location: "top",
height: $('textarea').outerHeight(),
width: $('textarea').outerWidth()
});
}

Issue with this is "open: addTinyMCE" .So when i commented it ,it is working fine without tinyMCE.But i need to load tinyMCE in the editor in the Internet Explorer as well.In other browsers it is working fine.

Lahiru Dhananjaya
Lahiru Dhananjaya Aug 21, 2015 12:54 PM

Also one bug i found when it testing in the Safari browser, it throw the error of "type error:null is not an object insert onclick" in AccordionSnippet.js

Aleksandar Trajanovski
Aleksandar Trajanovski Sep 2, 2015 10:33 AM

Hi Lahiru DhananjayaI have tested on IE11 and Safari and it is working fine, i have attached pictures, you can see them bellow. All the best.

Please login to comment.
Latest blogs
Copy Optimizely SaaS CMS Settings to ENV Format Via Bookmarklet

Do you work with multiple Optimizely SaaS CMS instances? Use a bookmarklet to automatically copy them to your clipboard, ready to paste into your e...

Daniel Isaacs | Dec 22, 2024 | Syndicated blog

Increase timeout for long running SQL queries using SQL addon

Learn how to increase the timeout for long running SQL queries using the SQL addon.

Tomas Hensrud Gulla | Dec 20, 2024 | Syndicated blog

Overriding the help text for the Name property in Optimizely CMS

I recently received a question about how to override the Help text for the built-in Name property in Optimizely CMS, so I decided to document my...

Tomas Hensrud Gulla | Dec 20, 2024 | Syndicated blog

Resize Images on the Fly with Optimizely DXP's New CDN Feature

With the latest release, you can now resize images on demand using the Content Delivery Network (CDN). This means no more storing multiple versions...

Satata Satez | Dec 19, 2024