How do we fire scripts after topic HTML is rendered in DOM?

TL:DR; Are there APIs like onPageChange or hooks to know when the topic is loaded and the HTML is rendered?


I’m working on an accessibility community, where people can embed games and tools from CodePen. The problem is that the CodePens only fill the 700px content area, which makes the games too small to be playable.

To fix this, I’m working on a component that makes all CodePens widescreen:

I’m using jQuery, which means I have to wait until topic’s HTML actually loads. I can get close with the onPageChange api, but the problem is that the topics HTML isn’t actually in the DOM yet.

Are there other apis?

5 Likes

I think you should be able to use the following:

https://github.com/discourse/discourse/blob/7a7c6af21e71a3ff2f743222e1f0f97e44d7df9f/app/assets/javascripts/discourse/lib/plugin-api.js.es6#L184-L198

You may also want to take a look at @Johani’s iframe lightbox component:

https://github.com/hnb-ku/discourse-iframe-lightboxes/blob/master/desktop/head_tag.html

5 Likes

Like @tshenry pointed out, what you’re looking for is decorateCooked

For basic changes, something like this works:

api.decorateCooked($elem => $elem.css("background", "yellow"));

If you need something more complicated, you have two choices, you can either use this syntax:

api.decorateCooked($elem => {
  // 1
  console.log("foo");
  // 2
  $elem.css("background", "yellow");
  // 3
  console.log("bar");
});

or create your own jQuery plugins like so:

$.fn.doSomething = function() {
  // 1
  console.log("foo");
  // 2
  $(this).css("background", "yellow");
  // 3
  console.log("bar");
  // you have to add this at the end for chainability
  return this;
};

api.decorateCooked($elem => $elem.doSomething());

Do note that decorateCooked runs every time

1- a post is loaded (yes, that means once for every post in the stream).
2- the composer preview is updated - this can be very expensive if you don’t watch out.

What that means is that you have to be mindful and only fire scripts on the posts you want to target.

So, if you don’t want the changes to happen in the composer you would add the onlyStream option. You would do that with something like this:

$.fn.doSomething = function() {

  // do your work
  
  return this;
};

api.decorateCooked($elem => $elem.doSomething(), { onlyStream: true });

This also ensures that your script ignores other places where the post might appear like the user post-stream.

And if you only want to target posts that contain specific elements you can use something like this

$.fn.doSomething = function() {
  const targetElement = $(this).children("[data-theme-test]").length;
  if (!targetElement) return;

  // do your work
  
  return this;
};

api.decorateCooked($elem => $elem.doSomething(), { onlyStream: true });

and that would ensure that your script only fires on the posts that contain the element you want to target and also ignore the composer preview.

If you have external scripts you want to load in order to do some work, you can use loadScript and you can do that with something like this:

const loadScript = require("discourse/lib/load-script").default;

$.fn.doSomething = function() {
  const targetElement = $(this).children("[data-theme-test]").length;
  if (!targetElement) return;

  loadScript(
    "//cdnjs.cloudflare.com/ajax/libs/iframe-resizer/4.1.1/iframeResizer.min.js"
  ).then(() => {
    // do your work
  });

  return this;
};

api.decorateCooked($elem => $elem.doSomething(), { onlyStream: true });

Do note that if that’s the case, you will also need to watch out for CSP issues

Finally, if you need to transverse the dom, you might need a little bit of Ember. By default, everything in cooked div should be available for jQuery. However, say you want to target one of the parents of cooked, well you can use something like this:

const loadScript = require("discourse/lib/load-script").default;

$.fn.doSomething = function() {
  const targetElement = $(this).children("[data-theme-test]").length;
  if (!targetElement) return;

  Ember.run.scheduleOnce("afterRender", () => {
    loadScript(
      "//cdnjs.cloudflare.com/ajax/libs/iframe-resizer/4.1.1/iframeResizer.min.js"
    ).then(() => {
      // do your work
    });
  });

  return this;
};

api.decorateCooked($elem => $elem.doSomething(), { onlyStream: true });

and that will allow you to fire a script after everything renders and after you load an external script while ignoring posts you don’t want to target and ignoring the composer preview.

Let me know if you need any clarification.

16 Likes

Thanks for this response and especially for the extra info on working with loadScript, it was super helpful! I can now get iframes to go widescreen and even fullscreen in the same tab

8 Likes

I know documentation breaks, but given this topic’s title a heads up for future readers.

Nowadays decorateCookedElement seems to be preferred:

3 Likes