AO3: Comment Formatting and Preview

Adds buttons to insert HTML formatting, and shows a live preview box of what the comment will look like

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         AO3: Comment Formatting and Preview
// @namespace    https://greatest.deepsurf.us/en/users/906106-escctrl
// @version      7.2
// @description  Adds buttons to insert HTML formatting, and shows a live preview box of what the comment will look like
// @author       escctrl
// @license      GNU GPL-3.0-only
// @match        *://*.archiveofourown.org/*
// @require      https://update.greatest.deepsurf.us/scripts/542049/1780689/AO3%3A%20Initialize%20jQueryUI.js
// @grant        none
// ==/UserScript==

/* global q, qa, ins, $, createMenu, initGUI */

/*********** INITIALIZING ***********/

(function() {

    'use strict';

    if (window.self !== window.top) return; // make sure script isn't running in an iFrame

    let cfg = 'cmtFmtDialog';
    let main = q('#main');

    // the available standard buttons, display & insert stuff
    // SVGs from Lucide https://lucide.dev (Copyright (c) Cole Bemis 2013-2022 as part of Feather (MIT) and Lucide Contributors 2022 https://lucide.dev/license)
    // changelog: removed xmlns, width/height, classes. for "Bold" increased stroke-width.
    let settingsStandard = new Map([
        ["bold", { text: "Bold", ins_pre: "<b>", ins_app: "</b>",
                icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8"/></svg>` }],
        ["italic", { text: "Italic", ins_pre: "<em>", ins_app: "</em>",
                icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" x2="10" y1="4" y2="4"/><line x1="14" x2="5" y1="20" y2="20"/><line x1="15" x2="9" y1="4" y2="20"/></svg>` }],
        ["underline", { text: "Underline", ins_pre: "<u>", ins_app: "</u>",
                    icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4v6a6 6 0 0 0 12 0V4"/><line x1="4" x2="20" y1="20" y2="20"/></svg>` }],
        ["strike", { text: "Strikethrough", ins_pre: "<s>", ins_app: "</s>",
                icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4H9a3 3 0 0 0-2.83 4"/><path d="M14 12a4 4 0 0 1 0 8H6"/><line x1="4" x2="20" y1="12" y2="12"/></svg>` }],
        ["link", { text: "Link", ins_pre: "<a href=\"\">", ins_app: "</a>",
                icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 1 1 0 10h-2"/><line x1="8" x2="16" y1="12" y2="12"/></svg>` }],
        ["image", { text: "Image", ins_pre: "<img src=\"", ins_app: "\" />",
                icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>` }],
        ["quote", { text: "Quote", ins_pre: "<blockquote>", ins_app: "</blockquote>",
                icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z"/><path d="M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z"/></svg>` }],
        ["paragraph", { text: "Paragraph", ins_pre: "<p>", ins_app: "</p>",
                    icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 4v16"/><path d="M17 4v16"/><path d="M19 4H9.5a4.5 4.5 0 0 0 0 9H13"/></svg>` }],
        ["listnum", { text: "Numbered List", ins_pre: "<ol><li>", ins_app: "</li></ol>",
                    icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5h10"/><path d="M11 12h10"/><path d="M11 19h10"/><path d="M4 4h1v5"/><path d="M4 9h2"/><path d="M6.5 20H3.4c0-1 2.6-1.925 2.6-3.5a1.5 1.5 0 0 0-2.6-1.02"/></svg>` }],
        ["listbull", { text: "Bullet List", ins_pre: "<ul><li>", ins_app: "</li></ul>",
                    icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 5h.01"/><path d="M3 12h.01"/><path d="M3 19h.01"/><path d="M8 5h13"/><path d="M8 12h13"/><path d="M8 19h13"/></svg>` }],
        ["listitem", { text: "List Item", ins_pre: "<li>", ins_app: "</li>",
                    icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="1"/></svg>` }],
    ]);

    ins(q("head"), 'beforeend', `<style type="text/css">ul.actions.fmtButtons svg, #${cfg} svg { width: 1em; height: 1em; display: inline-block; vertical-align: -0.125em; }
        ul.actions.fmtButtons { float: left; }
        div.fmtPreview.userstuff { border: 1px inset #f0f0f0; min-height: 1em; padding: 0.2em 1em; line-height: 1.5;
            code { display: revert; }
        }
        #float_cmt_dlg ul.actions.fmtButtons { font-size: 80%; }
        #${cfg} {
            .sortStandard li.ui-sortable-placeholder { max-height: 0.1em; }
            #custombutton {
                details { margin-bottom: 0.5em; }
                ol { padding-left: 1em; }
                li { display: list-item; }
                table { width: 100%;
                    textarea { min-height: unset; height: 3em; }
                    textarea, input[type=text] { width: unset; border-radius: 0.2em; }
                    textarea.err { background-image: url(); }
                }
            }
            div.grid2x2 { display: grid; grid-template-columns: auto 1fr; gap: 0.2em; }
            div.grid2x2 > * { align-self: center; }
        } </style>`);

    /*********** HANDLING BUTTON BAR AND PREVIEWS ***********/

    const [buttonOrder, settingsCustom] = loadConfigFromStorage();

    // create the HTML for the buttons bar (to be inserted BEFORE <textarea>s)
    let btnBar = [];
    buttonOrder.forEach((btnFmt) => {
        let source = btnFmt.startsWith('custom') ? settingsCustom.get(btnFmt) : settingsStandard.get(btnFmt);
        btnBar.push(`<li title="${source.text}"><button type="button" class="${btnFmt}">${source.icon === "" ? source.text : source.icon}</button></li>`);
    });
    btnBar = `<ul class="actions fmtButtons">${btnBar.join("")}</ul>`;

    // create the HTML for the preview box (to be inserted AFTER <textarea>s)
    let preview = `<div class='fmtPreview userstuff' title='Comment Preview (approximate)'></div>`;

    // delegated event handlers for button clicks and update of the comment preview
    q('body').addEventListener('click', function(e) {
        let fmtButtonClicked = e.target.closest('ul.fmtButtons button');
        if (fmtButtonClicked) insertFormat(fmtButtonClicked);
    });
    q('body').addEventListener('input', function(e) {
        if (e.target.matches('textarea.fmtTextbox')) updatePreview(e.target);
    });

    function insertFormat(elm) { // click event function called with the <button> that was clicked
        let area = [...elm.parentElement.parentElement.parentNode.children].filter((child) => child.classList.contains('fmtTextbox')).at(0); // button->li->ul->whatever->textarea
        let text = area.value; // the original content of the comment box
        let cursor_start = area.selectionStart, cursor_end = area.selectionEnd; // any highlighted text
        let fmt = elm.className.startsWith('custom') ? settingsCustom.get(elm.className) : settingsStandard.get(elm.className); // grab the formatting HTML corresponding to the clicked button

        // set the comment box text with the new content, and focus back on it
        area.value =
            text.slice(0, cursor_start) + // text from before cursor position or highlight
            fmt.ins_pre + text.slice(cursor_start, cursor_end) + fmt.ins_app + // wrap any highlighted text in the formatting HTML
            text.slice(cursor_end); // text from after cursor position or highlight
        area.focus();

        // set the cursor position to the same value so we don't highlight anymore
        let cursor_new =
            // if we only inserted format HTML, set it between the halves so you can enter the text to format
            (cursor_start == cursor_end) ? cursor_start + fmt.ins_pre.length :
            // if we highlighted, and this is a link (so the link text is already done), set the cursor into the href=""
            (elm.className == "link") ? cursor_start + fmt.ins_pre.length - 2 :
            // otherwise always set it at the end of the inserted text i.e. the same distance from the end as originally
            area.value.length - (text.length - cursor_end);
        area.selectionStart = area.selectionEnd = cursor_new;

        // manually trigger the value-has-changed event so the preview updates (not calling updatePreview directly as it would fail on Sticky Comment Box)
        area.dispatchEvent(new Event('input', { bubbles: true }));
    }

    function updatePreview(elm) { // click event function called with the <textarea> that was updated
        let content = elm.value.trim();
        let prevbox = [...elm.parentNode.children].filter((child) => child.classList.contains('fmtPreview')).at(0);

        // if the textbox is still empty, show a simple placeholder
        if (content === "") prevbox.innerHTML = "<p><i>preview</i></p>";
        else {
            // if there is text, turn double linebreaks into paragraphs and single linebreaks into <br>
            // linebreak compatibility
            const lbr = (content.indexOf("\r\n") > -1) ? "\r\n" :
                        (content.indexOf("\r") > -1) ? "\r" : "\n";

            // remove obvious issues: whitespaces between <li>'s, a <br> plus linebreak (while editing)
            content = content.replace(/<\/li>\W+<li>/ig, '</li><li>');
            content = content.replace(/<br \/>(\r\n|\r|\n)/ig, '<br />');

            content = content.split(`${lbr}${lbr}`); // split content at each two linebreaks in a row
            const regexLine = new RegExp(`${lbr}`, "g");
            content.forEach((v, i) => {
                v = v.replace(regexLine, "<br />"); // a single linebreak is replaced by a <br>
                content[i] = "<p>"+v.trim()+"</p>"; // two linebreaks are wrapped in a <p>
            });

            prevbox.innerHTML = content.join(lbr);
        }
    }

    function loadConfigFromStorage() {
        // which configuration of custom buttons do we have?
        let cfgCustom = {
            old: localStorage.getItem('cmtfmtcustom'),
            new: localStorage.getItem('commentFormat-custom'),
            final: new Map() // in case neither exist, it's already an empty Map(), which is all we need
        };

        if (cfgCustom.old && cfgCustom.new) localStorage.removeItem('cmtfmtcustom'); // if new exists already, delete old
        else if (cfgCustom.old) { // if only old exists, translate and store again
            new Map(JSON.parse(cfgCustom.old)).forEach((val, key) => {
                let objVal = {};
                JSON.parse(val).forEach((v) => { objVal[v[0]] = v[1]; }); // turn the Array/Map into an object
                if (objVal.text === "") objVal.text = "(blank)"; // old code didn't enforce text, we do now
                objVal.placeholder = objVal.icon; // old FontAwesome v4 unicodes can be seen here
                objVal.icon = ""; // set icon blank since it isn't an SVG yet
                cfgCustom.final.set(key, objVal);
            });
            localStorage.setItem('commentFormat-custom', JSON.stringify(Array.from( cfgCustom.final.entries() ))); // store new version
            localStorage.removeItem('cmtfmtcustom'); // delete old version
        }
        if (cfgCustom.new) cfgCustom.final = new Map(JSON.parse(cfgCustom.new)); // if new existed, by itself or together with old, use it

        let cfgOrder = {
            old: localStorage.getItem('cmtfmtstandard'),
            new: localStorage.getItem('commentFormat-order'),
            final: Array.from(settingsStandard.keys() ).concat(Array.from(cfgCustom.final.keys() )) // in case neither exist, populate it with all standard & custom buttons
        };
        if (cfgOrder.old && cfgOrder.new) localStorage.removeItem('cmtfmtstandard'); // if new exists already, delete old
        else if (cfgOrder.old) { // if only old exists, translate and store again
            cfgOrder.final = JSON.parse(cfgOrder.old).filter((x) => x[1] === "true").map((x) => x[0]); // keep only the ones that were "true"
            cfgOrder.final = [...cfgOrder.final, ...Array.from(cfgCustom.final.keys() )]; // merge that list with the custom buttons (always at the end)
            localStorage.setItem('commentFormat-order', JSON.stringify(cfgOrder.final)); // store new version
            localStorage.removeItem('cmtfmtstandard'); // delete old version
        }
        if (cfgOrder.new) cfgOrder.final = JSON.parse(cfgOrder.new); // if new existed, by itself or together with old, use it

        return [cfgOrder.final, cfgCustom.final];
    }

    /*********** SUPPORTING ALL THE DIFFERENT TEXT AREAS ***********/

    // ** anything that is visible on the page immediately (not dynamically loaded):
    qa(`textarea#work_summary, textarea#work_notes, textarea#work_endnotes, textarea#chapter_summary, textarea#chapter_notes, textarea#chapter_endnotes,
        textarea[id^=collection_collection_profile_attributes]:not([id*=notification]), textarea[id^=prompt_meme_signup_instructions], textarea[id^=gift_exchange_signup_instructions],
        textarea[id^="comment_content_for"],
        textarea#bookmark_notes,
        #peekTopLevelCmt textarea,
        textarea#profile_about_me,
        textarea#series_summary, textarea#series_series_notes`).forEach((ta) => {
        ins(ta, 'beforebegin', btnBar);
        ins(ta, 'afterend', preview);
        ta.classList.add('fmtTextbox');
        updatePreview(ta); // update the preview for reloaded pages with cached comment text
    });

    // ** anything that is visible on the page immediately, but doesn't get a preview:
    qa(`#float_cmt_userinput textarea`).forEach((ta) => {
        ins(ta, 'beforebegin', btnBar);
        ta.classList.add('fmtTextbox');
    });

    // ** the dynamically loaded ones, with preview:
    if(qa('#feedback, #reply-to-comment, #main.comments-show').length > 0) {
        // inbox replies, work/tag replies, editing existing comments
        const obsComment = new MutationObserver(function(mutList, obs) {
            for (const mut of mutList) { for (const node of mut.addedNodes) {
                // check if the added node is our comment box
                if (node.nodeType == 1 && node.id.startsWith('comment_form_for')) {
                    let ta = q('textarea', node);
                    ins(ta, 'beforebegin', btnBar);
                    ins(ta, 'afterend', preview);
                    ta.classList.add('fmtTextbox');
                    updatePreview(ta); // update the preview for reloaded pages with cached comment text
                }
            }}
        });
        obsComment.observe(qa('#feedback, #reply-to-comment, #main.comments-show')[0], { attributes: false, childList: true, subtree: true });
    }
    if (qa('div[id^="bookmark_form_placement_for_"]').length > 0) {
        // on bookmarks, there's either an Edit button to manage my own bookmark, or a Save button to bookmark that work
        const obsBookmark = new MutationObserver(function(mutList, obs) {
            for (const mut of mutList) { for (const node of mut.addedNodes) {
                // check if the added node is our bookmark form
                if (node.nodeType == 1 && node.id === 'bookmark-form') {
                    let ta = q('textarea', node);
                    ins(ta, 'beforebegin', btnBar);
                    ins(ta, 'afterend', preview);
                    ta.classList.add('fmtTextbox');
                    updatePreview(ta); // update the preview with existing notes
                }
            }}
        });

        // listening to the places where Ao3 adds the HTML for the add/edit bookmark box
        // unfortunately the only way to listen to multiple elements is to loop through the list, but then we don't need to listen to the whole tree (:
        qa('div[id^="bookmark_form_placement_for_"]').forEach((el) => obsBookmark.observe(el, { attributes: false, childList: true, subtree: false }) );
    }
    if (qa('.work.index, .work.listbox', main).length > 0) {
        // on works listings, we might have the Safekeeping Buttons
        const obsBookmark = new MutationObserver(function(mutList, obs) {
            for (const mut of mutList) { for (const node of mut.addedNodes) {
                // check if the added node is our bookmark form
                if (node.nodeType == 1 && node.id === 'bookmark_form_placement') {
                    let ta = q('textarea', node);
                    ins(ta, 'beforebegin', btnBar);
                    ins(ta, 'afterend', preview);
                    ta.classList.add('fmtTextbox');
                    updatePreview(ta); // update the preview with existing notes
                }
            }}
        });
        // only listen for the form, if the Safekeeping bookmark button actually shows up (script is running)
        let btnSafeKeeping = qa('.blurb ul.actions li.bookmark').length;
        if (btnSafeKeeping === 0) { // if it wasn't loaded yet, listen for five seconds
            const obsSafeKeeping = new MutationObserver(function(mutList, obs) {
                for (const mut of mutList) { for (const node of mut.addedNodes) {
                    // check if the added node is our bookmark button
                    if (node.nodeType == 1 && node.tagName === "LI" && node.className == 'bookmark') {
                        obsSafeKeeping.disconnect(); // we only need to wait for the first
                        qa('.work.blurb').forEach((el) => obsBookmark.observe(el, { attributes: false, childList: true, subtree: false }) );
                    }
                }}
            });
            obsSafeKeeping.observe(q('.work.index ul.actions'), { attributes: false, childList: true, subtree: false });
            let timeout = setTimeout(() => { obsSafeKeeping.disconnect(); }, 5000); // failsafe: stop listening after 5 seconds (in case the other script isn't installed)
        }
        else qa('.work.blurb').forEach((el) => obsBookmark.observe(el, { attributes: false, childList: true, subtree: false }) );
    }

    // ** the dynamically loaded ones, without preview:
    if (main.classList.contains('works-show') || main.classList.contains('chapters-show')) {
        // Sticky Comment Box script compatibility
        const obsStickyComment = new MutationObserver(function(mutList, obs) {
            for (const mut of mutList) { for (const node of mut.addedNodes) {
                // check if the added node is our comment box
                if (node.id == 'float_cmt_dlg') {
                    obs.disconnect(); // stop listening, the dialog is static once loaded
                    let ta = q(`textarea`, node);
                    ins(ta, 'beforebegin', btnBar);
                    ta.classList.add('fmtTextbox');
                }
            }}
        });
        obsStickyComment.observe(q('body'), { attributes: false, childList: true, subtree: false });
        let timeout = setTimeout(() => { obsStickyComment.disconnect(); }, 5000); // failsafe: stop listening after 5 seconds (in case the other script isn't installed)
    }
    if (q('#wrangulator')) {
        // View & Post Comment From Bin script compatibility
        const obsCommentFromBin = new MutationObserver(function(mutList, obs) {
            for (const mut of mutList) { for (const node of mut.addedNodes) {
                // check if the added node is our comment box
                if (node.id == 'peekTopLevelCmt') {
                    obs.disconnect(); // stop listening, the dialog is re-used for every tag comment
                    let ta = q(`textarea`, node);
                    ins(ta, 'beforebegin', btnBar);
                    ta.classList.add('fmtTextbox');
                }
            }}
        });
        obsCommentFromBin.observe(main, { attributes: false, childList: true, subtree: false });
        let timeout = setTimeout(() => { obsCommentFromBin.disconnect(); }, 5000); // failsafe: stop listening after 5 seconds (in case the other script isn't installed)
    }

    /***************** CONFIG DIALOG *****************/

    // Library function: creates the "Userscripts" menu item with (id, heading) parameters
    createMenu(cfg, "Comment Formatting Buttons");

    // config rarely is opened, so we avoid running through its setup on every page load by initializing only on first click (it adds a listener for subsequent clicks)
    q("#opencfg_"+cfg).addEventListener("click", async function(e) {
        // Library function: initializes webix and the window component with (id, heading, maxWidth, views that may need to be styled) parameters
        //                   returns the (empty) layout component to which all other webix "views" can be added
        let uiElem = await initGUI(e, cfg, "Comment Formatting Buttons", 700);
        if (uiElem !== false) createDialog(uiElem);
    }, { once: true });

    function createDialog(dlg) {
        // SVGs from Lucide https://lucide.dev (Copyright (c) Cole Bemis 2013-2022 as part of Feather (MIT) and Lucide Contributors 2022 https://lucide.dev/license)
        // changelog: removed xmlns, width/height, classes.
        const icons = {
            error: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>`,
            delete: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>`,
            add: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M8 12h8"/><path d="M12 8v8"/></svg>`,
            ext: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h6"/><path d="m21 3-9 9"/><path d="M15 3h6v6"/></svg>`
        };

        let btnSorting = [], btnCustomForEditing = [];
        const newcustomrow = (id="", cust={ text:"", icon:"", ins_pre:"", ins_app:"" }) => `<tr>
                <td><button class="remove" type="button" title="Delete this button" data-custid="${id}">${icons.delete}</button></td>
                <td><div class="grid2x2">
                    <label>Text:</label><input type="text" name="text" value="${cust.text}">
                    <label>Icon:</label><textarea name="icon" ${ cust.placeholder ? `class="ui-state-error"` : ""}>${cust.icon}</textarea>
                </div>
                ${ cust.placeholder ? `<div style="white-space: wrap; font-size: smaller;">Get your old icon as SVG:<br />
                Check <a href="https://fontawesome.com/v4/cheatsheet/">FontAwsome v4</a> to find the name of &amp;#x${cust.placeholder};<br />
                then search for it in <a href="https://fontawesome.com/search?ic=free-collection">their latest icon library</a>.</div>` : "" }
                </td>
                <td><div class="grid2x2">
                    <label>Before:</label><input type="text" name="ins_pre" value="${cust.ins_pre}">
                    <label>After:</label><input type="text" name="ins_app" value="${cust.ins_app}">
                </div></td>
                </tr>`;
        settingsCustom.forEach((val, key) => btnCustomForEditing.push(newcustomrow(key, val))); // custom buttons to be edited in the table

        const newsortingitem = (k, s, c) => `<li title="${s.text}">
            <label for="${k}">${s.icon === "" ? s.text : s.icon}</label>
            <input type="checkbox" id="${k}" name="${k}" ${c ? `checked="checked"` : ""}>
            </li>`;
        buttonOrder.forEach((btnFmt) => { // btnSettings has the checked and ordered buttons (standard + custom)
            let source = btnFmt.startsWith('custom') ? settingsCustom.get(btnFmt) : settingsStandard.get(btnFmt);
            btnSorting.push(newsortingitem(btnFmt, source, true));
        });
        let btnUnchecked = [...Array.from(settingsStandard.keys()), ...Array.from(settingsCustom.keys())].filter((x) => !buttonOrder.includes(x)); // find anything that wasn't active
        btnUnchecked.forEach((btnFmt) => {
            let source = btnFmt.startsWith('custom') ? settingsCustom.get(btnFmt) : settingsStandard.get(btnFmt);
            btnSorting.push(newsortingitem(btnFmt, source, false));
        });

        $(dlg).html(`<form>
        <fieldset id='stdbutton'>
            <legend>Button Order</legend>
            <p>Select the buttons you'd like to have on the button bar, deselect to hide them.<br />You can drag them into a different order as well.</p>
            <ul class="sortStandard">${btnSorting.join("")}</ul>
        </fieldset>
        <fieldset id='custombutton'>
            <legend>Custom HTML or text</legend>
            <details><summary>To define a custom button:</summary>
            <ol><li>Provide a button text (required).</li>
            <li>Paste an icon SVG (for example from <a href="https://lucide.dev/icons" target=_blank>Lucide ${icons.ext}</a>) into the "Icon" field (optional).</li>
            <li>Put the text you want inserted around the cursor position into the Before and After fields.</li>
            <li>The button appears in the Button Order section above. Drag it to the position you want.</li></ol></details>
            <table class="listCustom">
                <thead><tr><th> </th><th>Button Appearance</th><th>Insert Around Cursor</th></tr></thead>
                <tbody>${btnCustomForEditing.join("")}</tbody>
            </table>
            <button class="add" type="button">${icons.add} Add another button</button>
        </fieldset>
        <p>Any changes only apply after reloading the page.</p>`);

        // the save/reset/cancel buttons and handling of storage
        $(dlg).dialog('option', 'buttons', [
            {
                text: "Reset",
                click: function() {
                    localStorage.removeItem('commentFormat-order');
                    localStorage.removeItem('commentFormat-custom');
                    qa(".listCustom button.remove").forEach(d => d.click());
                    qa(".sortStandard input[type='checkbox']").forEach((c) => { c.checked = true; });
                    $( `#${cfg} input[type='checkbox']` ).checkboxradio("refresh");
                    $( this ).dialog( "close" );
                }
            },
            {
                text: "Cancel",
                click: function() { $( this ).dialog( "close" ); }
            },
            {
                text: "Save",
                "class": "ui-priority-primary",
                click: function() {
                    let thisDlg = q(`#${cfg}`);

                    // check all user input on custom buttons
                    qa(".listCustom tbody tr", thisDlg).forEach((row) => {
                        validateIconTextarea(q('textarea', row)); // check that the SVG is valid
                        validateButtonText(q('input[name="text"]', row)); // check that the text label was given
                    });
                    if (qa(".listCustom .ui-state-error", thisDlg).length === 0) { // if there were no errors, build the Map and store
                        let btnActive = [...qa(".sortStandard input[type='checkbox']:checked", thisDlg)].map((x) => x.id); // get the ordered list of active buttons

                        let customChecked = new Map();
                        qa(".listCustom tbody tr", thisDlg).forEach((row, i) => {
                            let oldid = q('button.remove', row).dataset.custid;
                            if (btnActive.indexOf(oldid) !== -1) { // update the ordered button IDs to be continuous and match...
                                btnActive[btnActive.indexOf(oldid)] = 'custom'+i;
                            }
                            let parts = {};
                            qa('[name]', row).forEach((field) => { parts[field.name] = field.value; });
                            customChecked.set('custom'+i, parts); // ... with the way we store the custom buttons
                        });

                        localStorage.setItem('commentFormat-custom', JSON.stringify(Array.from(customChecked.entries() )));
                        localStorage.setItem('commentFormat-order', JSON.stringify(btnActive));

                        $( this ).dialog( "close" );
                    }
                }
            },
        ]);

        $(dlg).dialog('open');

        $( `#${cfg} input[type='checkbox']` ).checkboxradio({ icon: false }); // turn checkboxes into pretty buttons

        $( ".sortStandard" ).sortable({
            cursor: "grabbing", // switches cursor while dragging a tag for A+ cursor responsiveness
            containment: "parent" // limits dragging to the box and avoids scrollbars
        }).disableSelection(); // disable text selection

        $(dlg).on('click', '#custombutton button.add', (e) => { // add a new row for custom buttons
            let nextitem = parseInt($(`#${cfg} .listCustom tbody tr:last-of-type button.remove`)[0]?.dataset.custid.match(/\d+/)[0] ?? -1) + 1; // continue numbering
            ins(q(`#${cfg} .listCustom tbody`), 'beforeend', newcustomrow("custom"+nextitem));
            ins(q(`#${cfg} .sortStandard`), 'beforeend', newsortingitem("custom"+nextitem, { icon:"", text:"(blank)" }, true)); // add sortable listitem
            $(`#${cfg} input#custom${nextitem}`).checkboxradio({ icon: false }); // turn into pretty button
            $( ".sortStandard" ).sortable( "refresh" ); // recognize the new button for drag&drop
        });
        $(dlg).on('click', '#custombutton button.remove', (e) => { // delete this custom button row and the corresponding sortable item
            $(`.sortStandard input#${e.target.closest('button.remove').dataset.custid}`).parent().remove();
            e.target.closest('tr').remove();
        });
        $(dlg).on('change', '#custombutton input[name=text], #custombutton textarea[name=icon]', (e) => { // when the custom button text has changed
            let row = e.target.closest('tr');
            let label = q(`.sortStandard li:has(input#${ q('button.remove', row).dataset.custid }) label`);
            label.innerHTML = validateSVG(q('textarea[name=icon]', row).value) ? q('textarea[name=icon]', row).value : (q('input[name=text]', row).value || "(blank)");
            label.parentElement.title = q('input[name=text]', row).value || "(blank)";
        });
    }

    function validateButtonText(input) {
        if (input.value === "") {
            input.placeholder = "a text is required";
            input.classList.add('ui-state-error');
        }
        else {
            input.placeholder = "";
            input.classList.remove('ui-state-error');
        }
    }

    function validateIconTextarea(area) {
        if (area.value !== "" && !validateSVG(area.value)) {
            area.value = "";
            area.placeholder = "not a valid SVG, please try again";
            area.classList.add('ui-state-error');
        }
        else {
            area.placeholder = "";
            area.classList.remove('ui-state-error');
        }
    }

    function validateSVG(testText) {
        if (testText === "") return false;
        let doc = new DOMParser().parseFromString(testText, "image/svg+xml"); // in Firefox this throws an XML Parsing Error that can't be caught
        return (q('parsererror', doc)) ? false : true;
    }

})();