From 5c45eef86e634e3ca4524b842344dd59de3ab994 Mon Sep 17 00:00:00 2001 From: David Cook Date: Wed, 23 Oct 2024 05:45:03 +0000 Subject: [PATCH] Bug 11583: Add WYSIWYG editor to HTML notices/letters This change adds a WYSIWYG editor to HTML notices/letters. Test plan: 0. Apply the patch 1. In "Local use" system preferences, create "UseWYSIWYGinNotices" and set it to "1" 2. Go to http://localhost:8081/cgi-bin/koha/tools/letter.pl 3. Edit any template 4. Open the "Email" tab 5. Toggle "HTML message" on/off and see the WYSIWYG editor appear and disappear 6. Try adding content with the "Insert->" button 7. For notices like AUTO_RENEWALS_DGST, try the "View default" and "Copy to template" button. (NOTE: You'll want to wrap the AUTO_RENEWALS_DGST text with

so that it doesn't destroy your whitespace when converting to HTML)

8. Try adding text and formatting using the WYSIWYG
9. Try adding text and formatting using the "<>" source code button

NOTE: If you click on a template token in the WYSIWYG, you should
notice that it's uneditable. You must edit it in source code mode.
This is because in the WYSIWYG the template token has been converted
to a HTML friendly placeholder.
---
 .../prog/en/modules/tools/letter.tt           |  13 +-
 .../intranet-tmpl/prog/js/letter_editor.js    | 208 ++++++++++++++++++
 2 files changed, 217 insertions(+), 4 deletions(-)
 create mode 100644 koha-tmpl/intranet-tmpl/prog/js/letter_editor.js

diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/tools/letter.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/tools/letter.tt
index 784d15d0f8..d21da2881e 100644
--- a/koha-tmpl/intranet-tmpl/prog/en/modules/tools/letter.tt
+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/tools/letter.tt
@@ -648,15 +648,15 @@
                                 
                                 [% IF letter.is_html %]
                                     [% IF sms_readonly %]
-                                        
+                                        
                                     [% ELSE %]
-                                        
+                                        
                                     [% END %]
                                 [% ELSE %]
                                     [% IF sms_readonly %]
-                                        
+                                        
                                     [% ELSE %]
-                                        
+                                        
                                     [% END %]
                                 [% END %]
                             
@@ -835,6 +835,11 @@
         });
      
     [% Asset.js("js/letter.js") | $raw %]
+    [% IF Koha.Preference('UseWYSIWYGinNotices') %]
+        [% Asset.js("lib/tiny_mce/tinymce.min.js") | $raw %]
+        [% INCLUDE 'str/tinymce_i18n.inc' %]
+        [% Asset.js("js/letter_editor.js") | $raw %]
+    [% END %]
 [% END %]
 
 [% INCLUDE 'intranet-bottom.inc' %]
diff --git a/koha-tmpl/intranet-tmpl/prog/js/letter_editor.js b/koha-tmpl/intranet-tmpl/prog/js/letter_editor.js
new file mode 100644
index 0000000000..232a63275b
--- /dev/null
+++ b/koha-tmpl/intranet-tmpl/prog/js/letter_editor.js
@@ -0,0 +1,208 @@
+    function initTinyMce(textarea){
+        let editor = tinyMCE.init({
+            branding : false,
+            block_unsupported_drop : false,
+            custom_elements:"style,link,~link",
+            content_style: `
+                pre {
+                    all: unset;
+                    display: block;
+                    white-space: pre;
+                }
+            `,
+            extended_valid_elements:"style,link[href|rel]",
+            forced_root_block : '', //this is applied between BeforeSetContent and SetContent
+            menubar : "file edit view insert format tools table",
+            plugins: "autoresize code",
+            //plugins : "autoresize table hr link image charmap lists code emoticons",
+            autoresize_bottom_margin: 60,
+            relative_urls : false,
+            selector: "#" + textarea.id,
+            verify_html: false,
+            toolbar: [
+                "formatselect | bold italic | cut copy paste | alignleft aligncenter alignright | outdent indent | image link unlink anchor cleanup hr",
+                "table | bullist numlist | undo redo | removeformat | emoticons charmap | forecolor backcolor | code"
+            ],
+            entities: '',
+            entity_encoding: 'raw',
+            cleanup: false,
+            remove_linebreaks: false,
+            preserve_newlines: true,
+            setup: function(editor){
+                editor.on('change', function () {
+                    editor.save(); // This updates the textarea on each change
+                });
+                editor.on('BeforeSetContent', function(e){
+                    //NOTE: this gets called by undo with "raw" format,
+                    //and we don't want to double-replace template syntax tokens
+                    if (e.format == 'html'){
+                        //NOTE: At this point, all we have is a string, so we tokenize and put in HTML-friendly placeholders
+                        e.content = e.content.replace(/\[%([\s\S]*?)%\]/g, function(match, content) {
+                            let encoded_content = btoa(content);
+                            let placeholder = `[%${content}%]`;
+                            return placeholder;
+                        });
+                        e.content = e.content.replace(/<<([\s\S]*?)>>/g, function(match, content) {
+                            let encoded_content = btoa(content);
+                            let rv = `<<${content}>>`;
+                            return rv;
+                        });
+                    }
+                });
+                editor.on('GetContent', function(e){
+                    //NOTE: GetContent fires after the content is serialized from the editor.
+                    //NOTE: We re-parse the content, and apply our own custom serializer, which
+                    //converts HTML placeholders back into template tokens using the appropriate syntax.
+                    //NOTE: We need a custom serializer, because otherwise our template tokens will be
+                    //escaped by the HTML serializer as if they were HTML when they're not.
+                    const parser = new DOMParser();
+                    const doc = parser.parseFromString(e.content, "text/html");
+                    if (doc){
+                        const text = serialize_template(doc);
+                        if (text){
+                            e.content = text;
+                        }
+                    }
+                    //NOTE: If we don't like the DOM-based method, there is a regex method, which works reasonably well:
+                    /*
+                    try {
+                        e.content = e.content.replace(/\[%([\s\S]*?)%\]<\/span>/g,function (match,content){
+                            let decoded_content = atob(content);
+                            let rv = `[%${decoded_content}%]`;
+                            return rv;
+                        });
+                    } catch(error) {
+                        console.error(error);
+                    }
+                    */
+                    /*
+                    try {
+                        e.content = e.content.replace(/<<([\s\S]*?)>><\/span>/g,function (match,content){
+                            let decoded_content = atob(content);
+                            let rv = `<<${decoded_content}>>`;
+                            return rv;
+                        });
+                    } catch(error){
+                        console.error(error);
+                    }
+                    */
+                });
+            },
+        });
+        return editor;
+    }
+
+    const void_elements = {
+        "area": true,
+        "base": true,
+        "br": true,
+        "col": true,
+        "embed": true,
+        "hr": true,
+        "img": true,
+        "input": true,
+        "link": true,
+        "meta": true,
+        "param": true,
+        "source": true,
+        "track": true,
+        "wbr": true
+    };
+
+    //NOTE: This function is a custom serializer
+    function processNode(node) {
+        let result = '';
+        if (node.nodeType === Node.TEXT_NODE) {
+             // For text nodes, simply append the raw content
+            result += node.nodeValue;
+        } else if (node.nodeType === Node.ELEMENT_NODE) {
+            // For element nodes, append the tag and recursively traverse children
+            let node_name = node.tagName.toLowerCase();
+
+            let skip = 0;
+            if (node_name == 'body'){
+                skip = 1 ;
+            }
+
+            if ( node_name == 'span' && node.classList.contains('template') ){
+                const original_text = node.dataset.original;
+                let decoded_content = atob(original_text);
+                result += `[%${decoded_content}%]`;
+            }
+            else if ( node_name == 'span' && node.classList.contains('legacy_template') ) {
+                const original_text = node.dataset.original;
+                let decoded_content = atob(original_text);
+                result += `<<${decoded_content}>>`;
+            }
+            else {
+                //NOTE: This is all other regular nodes
+                if ( !skip ) {
+                    result += `<${node.tagName.toLowerCase()}`;
+                    for ( let attr of node . attributes ) {
+                        result += ` ${attr.name}="${attr.value}"`;
+                    }
+                    result += `>`;
+                }
+
+                // Process child nodes
+                for (let child of node.childNodes) {
+                    result += processNode(child);
+                }
+                if (!skip){
+                    if (! void_elements[node_name]){
+                        result += ``;
+                    }
+                }
+            }
+        }
+        return result;
+    }
+
+    function serialize_template(doc) {
+        return processNode(doc.body);
+    }
+
+    let email_inputs = document.querySelectorAll('.content_email');
+    email_inputs.forEach((email_input) => {
+        let containerid = email_input.id.replace(/^content_/,"");
+        let panel = $("#" + containerid + "_panel");
+        let html_btn = document.querySelector(`#is_html_${containerid}`);
+        if (html_btn.checked){
+            initTinyMce(email_input);
+        }
+        $(`button[data-containerid=${containerid}`).on('click',function(){
+            let myListBox = $(panel).find('select[name="SQLfieldname"]');
+            if($(myListBox).find('option').length > 0) {
+                $(myListBox).find('option').each( function (){
+                    if ( $(this).prop('selected') && $(this).val().length > 0 ) {
+                        let editor = tinyMCE.get(email_input.id);
+                        if (editor){
+                            editor.insertContent("<<" + $(this).val() + ">>");
+                        }
+                    }
+                });
+            }
+        });
+    });
+    $("#noticeSampleModal").on("click", ".copy", function(){
+        let content = $('#noticeSampleModal .template-body').text();
+        let replaceid = $('#noticeSampleModal').data('replaceid');
+        let editor = tinyMCE.get(replaceid);
+        if (editor){
+            editor.setContent(content);
+        }
+    });
+    $(".is_html_chkbox").on("click", function(el){
+        const target = el.target;
+        let shortened_id = target.id.replace(/^is_html_/,"");
+        let textarea_id = `content_${shortened_id}`;
+        if (target.checked){
+            let textarea = document.querySelector(`#${textarea_id}`);
+            initTinyMce(textarea);
+        } else {
+            let editor = tinyMCE.get(textarea_id);
+            if (editor){
+                editor.remove();
+            }
+        }
+    });
-- 
2.39.5