Slidecraft 101: Fragments - JS

Fragments are powerful tools in revealjs to allow for changes within slides

slidecraft 101
quarto
Published

August 22, 2024

Hello and welcome back to my multi-part series about what I like to call slidecrafting; The art of putting together slides that are functional and aesthetically pleasing. I will be using quarto presentations. This is the thirteenth post, you can find all other posts in the slidecraft 101 project.

This is the second of two blog posts talking about fragments. They are used to incrementally make things appear on your slides, or as a way to do highlighting. If you are not already familiar with fragments, I recommend that you follow the link and read the section about them before moving on with this blog post.

Fragments 201

When a fragment is either shown or hidden reveal.js (the engine that powers our slides) will dispatch an event. This event can be picked up using JavaScript.

You will need a little bit of Javascript knowledge, but I found that you don’t need a lot of knowledge to produce useful things for slides. Once your slides are rendered in your browser, you can toggle the developer tools, where you can find a javascript console. This is where I do the work needed.

We can capture the event using the following snippets of code

Reveal.on('fragmentshown', (event) => {
  // event.fragment = the fragment DOM element
});
Reveal.on('fragmenthidden', (event) => {
  // event.fragment = the fragment DOM element
});

Reveal is the javascript object that powers the whole presentation. To have fun things happening when we use fragments, we need to write some code inside these curly brackets. The first chunk of code runs whenever a fragment appears, and the second runs whenever a fragment disappears. In in this environment, we have access to the event which is the DOM element of fragment div itself as created in our slides. We can take advantage of that in different ways as you will see.

Event {
  "isTrusted": false,
  "fragment": "Node",
  "fragments": ["Node"],
  "type": "fragmentshown",
  "target": "Node",
  "currentTarget": "Node",
  "eventPhase": 2,
  "bubbles": true,
  "cancelable": true,
  "defaultPrevented": false,
  "composed": false,
  "timeStamp": 2259.5,
  "srcElement": "Node",
  "returnValue": true,
  "cancelBubble": false,
  "NONE": 0,
  "CAPTURING_PHASE": 1,
  "AT_TARGET": 2,
  "BUBBLING_PHASE": 3
}

Last note, you can have multiple of these Reveal.on() statements, as they will trigger one after another. So depending on how you want to organize, both styles are valid.

// one statement
Reveal.on('fragmentshown', (event) => {
  fragment_style_1(event);
  fragment_style_2(event);
  fragment_style_3(event);
});

// multiple statements
Reveal.on('fragmentshown', (event) => {
  fragment_style_1(event);
});
Reveal.on('fragmentshown', (event) => {
  fragment_style_2(event);
});
Reveal.on('fragmentshown', (event) => {
  fragment_style_3(event);
});

Lastly, the way I set up my revealjs slides to do javascript is by setting the include-after-body attribute in the yaml file,

format:
  revealjs:
    include-after-body: 
      - "_color.html"

and having it point to a file that looks like this:

<script type="text/javascript">

</script>

then inside we put my javascript code, which for this blog post will be some Reveal.on() calls.

Color changing

This first example is going to be an illustrative example of what we can do and how to do it. And it will thus not be very useful.

The first lesson I want to show is that you are not limited to modifying the content inside the fragment. This means that we can actually have empty fragments that modify some other element. So in our document, we could have a slide that looks like this:

## Color Changing Title Text

::: {.fragment .color}
:::

I want to change the color of the header when the fragment triggers. To do that we need two things.

  1. The color to change it into
  2. Access to the header element

The first part is easy, I found a “random javascript” script online. We start by assigning that to a variable.

random_color = '#'+(Math.random()*0xFFFFFF<<0).toString(16);

Next, we need to find the header. Remember the Reveal object I mentioned earlier? It has a very handy .getCurrentSlide() method. When run we get the current slide we are on, which is exactly what we need.

Reveal.getCurrentSlide()
<section id="color-changing-title-text" class="slide level2 present" style="display:​ block;​" data-fragment="-1">
<h2>​Color Changing Title Text​</h2>
<div class="fragment color" data-fragment-index="0"></div>
<div class="quarto-auto-generated-content"></div>
</section>

From this, we can get to the title using .querySelector()

Note

We don’t need .querySelectorAll() because by definition there will only be one h2 on a quarto slide using default options.

Reveal
  .getCurrentSlide()
  .querySelector("h2")
<h2>Color Changing Title Text</h2>

We can then change the color by selecting the style element of the div and updating the color variable.

Reveal
  .getCurrentSlide()
  .querySelector("h2")
  .style
  .color = random_color;

And that is technically all we need. Put that code inside the Reveal.on() statements, and the color of the header will change each time the fragment is triggered.

One thing worth remembering is that this javascript code will run everything a fragment is run. So to limit it, we can make sure it only runs when we want it to. This is why I gave the fragment a .color class. We can use the following if statement to make sure our code only runs when we want it to.

if (event.fragment.classList.contains("color")) {

}

We could stop here. But I want to show a little more with this example. For right the color changes randomly, but we could allow for a little bit of information transfer. HTML has this concept called datasets. Each div can have a data set of information. We should use this to give our fragments more flexibility.

Luckily it is quite effortless to specify data set values in quarto. Below is the same fragment div as before, but with a data set value named color.

:::: {.fragment .color data-color="orange"}
:::

We can now on the javascript side pull out this value with ease.

color = event.fragment.dataset.color;
Warning

We are not doing any input checking, so this code will fail silently if you don’t have a color specified in the div.

And set it the same as before.

Reveal
  .getCurrentSlide()
  .querySelector("h2")
  .style
  .color = color;

This will give us the final fragment code as follows

Reveal.on('fragmentshown', (event) => {
  if (event.fragment.classList.contains("color")) {
 random_color = '#'+(Math.random()*0xFFFFFF<<0).toString(16);
  
 Reveal
      .getCurrentSlide()
      .querySelector("h2")
      .style
      .color = random_color;
  }
});

Reveal.on('fragmenthidden', (event) => {
  if (event.fragment.classList.contains("color")) {
 color = event.fragment.dataset.color;

 Reveal
      .getCurrentSlide()
      .querySelector("h2")
      .style
      .color = color;
  }
});

qmd js

Scroll output

Sometimes you run into a situation where you want to interact with an element on a slide. This can happen when you need to scroll or toggle something. While that would be fine to do by hand, it can be hard to do casually, and impossible to do if you are using a clicker.

Scrolling text in a window is one thing that isn’t that hard to do with JavaScript.

We will follow the same steps as before.

  1. Find the element we want to show
  2. Figure out how to scroll it

The element can again be found using .getCurrentSlide() and querySelector() after a little digging.

Reveal
  .getCurrentSlide()
  .querySelector(".cell-output code")

Next, we need to figure out how to scroll it. This can be done using the .scrollTo() method. This function should be passed on to how much we want to scroll and how. As far as I know, this can only be set using pixel values so we have to try a couple of times to get it right. 1000 appears enough for this example to get us all the way to the bottom. Setting behavior to smooth for a little flair.

{
  top: 1000,
  behavior: "smooth",
}

This means that the fragment is finished with

Reveal.on('fragmentshown', (event) => {
  if (event.fragment.classList.contains("scroll")) {
 Reveal
    .getCurrentSlide()
    .querySelector(".cell-output code")
    .scrollTo({
 top: 1000,
 behavior: "smooth",
    })
  }
});

But wait! What if you have to go back? this is where fragmenthidden is needed, we simply take the preview code and say we want to go back to the top by setting top to 0.

Reveal.on('fragmenthidden', (event) => {
  if (event.fragment.classList.contains("scroll")) {
 Reveal
    .getCurrentSlide()
    .querySelector(".cell-output code")
    .scrollTo({
 top: 0,
 behavior: "smooth",
    })
  }
});
Note

Some changes to our slides are really hard to reverse. They would thus make for bad fragments. You could implement them halfway without the fragmenthidden and you would just need to be really confident that you never have to go backwards in your slides.

qmd js

Tip

We didn’t do it here, but you could use dataset values to help determine which elements should be scrolled and how much to scroll them by instead of hardcoding it all as we do here.

Tabset advance

Quarto also has tabset support for slides, which is again a very nice feature. It runs into the same clicker interaction we noted earlier. It requires a mouse to correctly toggle in the middle of a presentation.

We can deal with this as well. As always we need to find the elements and how to toggle them.

Note

The astute reader will notice that the following will only work on a tabset with 2 tabs. Making this fragment work for multiple tabs is left as an exercise for the reader.

We are again using .getCurrentSlide() and querySelector(), and with some trial and error, determine that the following two CSS selectors captures the two tabs.

  • .panel-tabset ul li:first-of-type a
  • .panel-tabset ul li:last-of-type a

And we are lucky because these elements have a working .click() method that we can use.

This means that the full fragment looks like this:

Reveal.on('fragmentshown', (event) => {
  if (event.fragment.classList.contains("tabswitch")) {
 Reveal
      .getCurrentSlide()
      .querySelector(".panel-tabset ul li:last-of-type a")
      .click()
  }
});

Reveal.on('fragmenthidden', (event) => {
  if (event.fragment.classList.contains("tabswitch")) {
 Reveal
      .getCurrentSlide()
      .querySelector(".panel-tabset ul li:first-of-type a")
      .click()
  }
});

qmd js

advance embedded slides

The last example I’ll show for now is one you have seen me use already. I like to put quarto slides inside quarto slides. However, it becomes messy to advance the embedded slides, because they take focus of the mouse. I have used a fragment to advance these.

We start by embedding a set of slides in our set of slides. We do thing with <iframe class="slide-deck" src="_fragment-scroll.html" style="width:100%; height: 500px;" ></iframe>.

The Reveal object has a fairly extensive API you can use. So we just need to fetch the right Reveal object so we can use the .left() and .right() methods to advance the slides. It took me a while to find the right code, but .contentWindow was the missing piece. The following returns the embedded Reveal object.

Reveal
  .getCurrentSlide()
  .querySelector("iframe")
  .contentWindow
  .Reveal

Which then gives us the following as our fragment

Reveal.on('fragmentshown', event => {
  if (event.fragment.classList.contains("advance-slide")) {
 Reveal
      .getCurrentSlide()
       .querySelector("iframe")
      .contentWindow
      .Reveal
      .right()
    }
});
Reveal.on('fragmenthidden', event => {
  if (event.fragment.classList.contains("advance-slide")) {
 Reveal
      .getCurrentSlide()
      .querySelector("iframe")
      .contentWindow
      .Reveal
      .left()
    }
});

qmd js

Roundup

I hope you learned a lot, I truly believe that there is a lot of flexibility in fragments.