3

I recently built a userscript for Board Games Stack Exchange. On page load, it scans questions and answers for certain plaintext patterns and replaces them with some HTML, making the posts prettier.

It only works on page load, however. If someone hits edit and edits the post in place (where they get to do the edit and save process in an AJAXy way without leaving the page), then when they save their edit, their freshly-edited post will be reloaded and won't be prettified by the userscript.

Or if pictures make it clearer:

  1. Have post that was prettified on page load.

    pictures!

  2. Do some editing.

    edit panel open

  3. Save, post gets saved all AJAX-style and reloaded, and is no longer prettified.

    oh no!

How can I have my userscript reliably detect that a post's finished being edited, and do stuff to the freshly reloaded post only once there's something to do stuff to?

(It occurs to me there are other scenarios, such as someone else edits the post whilst you're reading it, and that bar appears at the top offering to reload the answer. If there's some solution that helps in these situations in general, that would be excellent.)

3

The way SOUP handles this is by using a jQuery .ajaxComplete handler to detect when new content is loaded dynamically. SOUP actually has a set of wrapper functions that encapsulate this functionality, but here's a simple stand-alone example from this script, which I originally wrote for SOUP but decided to also make available separately:

// re-run the hotfix whenever new posts are loaded via AJAX
var urlRegex = /^\/posts\/(ajax-load-realtime|\d+\/edit-submit)\/|^\/review\/(next-task|task-reviewed)\b/;
$(document).ajaxComplete(function (event, xhr, settings) {
    if (urlRegex.test(settings.url)) setTimeout(fixCardLinks, 10);
});

The urlRegex expression matches all the various AJAX endpoints used by SE to load new posts (including reviews) that I know of. For requests that load comments rather than posts, you can use the regexp /^\/posts\/((\d+)\/comments|comments\/(\d+))\b/ (where the (\d+) groups conveniently contain the ID of the post the comments were loaded for).

(The easiest to find these AJAX URLs is probably to use the Network tab in Firefox/Chrome developer tools, although it's also possible to just examine the SE code.)

Based on my testing, the setTimeout() is actually not needed in most cases, since the .ajaxComplete event fires after the specific callbacks for the AJAX request. It's in the code mostly because I never got around to removing it.


Ps. The script I linked above also hooks into the SE Markdown editor's live preview mechanism. The editor code actually has quite a diverse set of hooks, but for tweaking the preview output, I've found two useful methods:

Method 1 is to use the postConversion hook, which lets you modify the HTML code generated by the Markdown parser before it's injected into the preview pane. (Trivia: This is how the [tag:foo] syntax is implemented in the SE code.)

One advantage of this method is that there's no risk of "flickering" in the preview, because you get to modify the HTML before it's displayed. The main disadvantage is that you get the HTML code as plain text, so for anything non-trivial, you'll need to kluge up your own HTML parsing.

Anyway, here's how you define a postConversion hook, taken straight from the script above:

StackExchange.ifUsing('editor', function () {
    StackExchange.MarkdownEditor.creationCallbacks.add(function (editor) {
        editor.getConverter().hooks.chain('postConversion', makeMtGLinks);
    });
});

The callback function, here named makeMtGLinks, will be called with the generated HTML as its first parameter, and should return the same HTML code with any modifications you want to make to it. Note that this code will get called pretty often (possibly on every keystroke), so you should try to keep it fairly well optimized.

Method 2 uses the onPreviewRefresh hook, which is called after the preview pane has been updated. If you'd prefer to make your modifications through the DOM, rather than messing with raw HTML, this is the way to go. The code to define one of these hooks looks like this:

StackExchange.ifUsing('editor', function () {
    StackExchange.MarkdownEditor.creationCallbacks.add(function (editor, postfix) {
        editor.hooks.chain('onPreviewRefresh', function () {
            // ... hook code here ...
        });
    });
});

Note the extra postfix parameter, which (generally) needs to be passed to the hook code. This parameter is needed to locate the correct preview pane, and is important for correctly and efficiently supporting multiple editor instances on one page (as might happen e.g. if the user wants to ask a question and immediately self-answer). Specifically, the ID of the preview pane will be "wmd-preview" + postfix; usually, you'd start your hook code by obtaining a jQuery object pointing to the preview pane with $("#wmd-preview" + postfix), and then work from there.

2

There should be a StackExchange method you could hook into, but I don't see it. Also, post text can change for a few reasons. (The user finished an edit, or clicked on a snippet button, or clicked on one of those "This post has been edited" alerts, or triggered a spoiler, etc.)

To keep things simple and robust, I recommend just using an interval. EG:

var yourMarkupRegex = /\{\w{1,3}\}/;

//-- Poll for edits, irregardless of the cause:
setInterval ( function () { 
    var allPostText = $('.post-text').text ();
    if (yourMarkupRegex.test (allPostText) ) {
        // CALL YOUR PRETTIFIER FUNCTION HERE.
    }
}, 222);


Update:

I found an event that seems to fire whenever a post is edited, directly or remotely, or otherwise changed by the page. It's the changeData! event. It fires on a few other page changes but not too often, except it is triggered up to 50 times on some page loads that I observed!

This offers an alternative to timers (Still the overall best approach, in my experience). You must intercept events on the page's instance of jQuery.
The following code needs either @grant none mode, or to be injected. :

var yourMarkupRegex = /\{\w{1,3}\}/;

var _oldJQueryEventTrigger = jQuery.event.trigger;
jQuery.event.trigger = function (event, data, elem, onlyHandlers) {
    if (event == "ajaxStop") {
        Prettifier ();

        jQuery.event.trigger = function (event, data, elem, onlyHandlers) {
            if (event == "changeData!") {
                Prettifier ();
            }
            _oldJQueryEventTrigger (event, data, elem, onlyHandlers);
        }
    }
    _oldJQueryEventTrigger (event, data, elem, onlyHandlers);
}

function Prettifier () {
    var allPostText = $('.post-text').text ();
    if (yourMarkupRegex.test (allPostText) ) {
        // CALL YOUR PRETTIFIER FUNCTION HERE.
    }
}

Important:
Because changeData! fires up to scores of times during page load, we wait until the first ajaxStop event to start triggering this way.

ajaxStop fires near the end of the page load process (on Stack Exchange pages) and a fair bit before the window load event fires.

  • This is definitely a reliable solution, though I'm hoping there's some more precise way to handle it. – doppelgreener Jan 10 '15 at 15:24
  • You could try MutationObservers, but I think you'll find that they get pretty fiddly/brittle for this kind of thing. – Brock Adams Jan 10 '15 at 21:00
  • I use MutationObserver to watch the document root and reapply event handlers in my stuff. It's flawless as far as I can tell. MutationObserver is the correct answer I think. – rojo Jan 11 '15 at 13:57
  • Found an event that's close to what we want and added code showing how to exploit it. Note that it is less impactful, on average, than the simple timer (which has no human-noticeable impact on performance), and much less peak impact than rojo's raw MutationObserver approach (which needs tuning and targeting). But this kind of code is also more complicated and brittle. – Brock Adams Jan 14 '15 at 5:24

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .