Discourse Lightbox - Migrating away from Magnific popup

Discourse allows users to upload images in posts (and other places). Sometimes those images are too big to display, so Discourse creates a scaled version of the image and adds that to the post. When you click on that scaled image, you get a nice overlay that contains the full-size image - This is commonly referred to as lightboxing.

Discourse currently uses a library called Magnific popup to handle lightboxing behavior. This topic is about revamping lightboxes in Discourse by migrating from Magnific popup to something more in line with the expectations users and developers have today.

The why

Magnific is a great library, and the number of stars on the project page significantly indicates how many problems it has solved for both users and developers over the years.

It was also ahead of its time in terms of ease of use and feature offerings. This becomes apparent when you consider that almost all the functionality you see in it today was already in v 1.0, which came out in 2014.

So, where does that leave us? I’ll keep it short. Magnific popup was made for an entirely different world where cross-browser compatibility was far behind where it’s at today. This means four things.

  1. it has jQuery as a dependency
  2. it contains code meant for ancient browsers
  3. the average device used to access the web has changed a lot since then
  4. it was designed with something other than single-page applications like Discourse as a priority - namely, static pages. This has performance concerns specific to single-page applications, which we’ll get into later.

Given the current state of web standards and how you can do all kinds of magic with vanilla Javascript, it’s understandable that large projects like Discourse are moving away from having jQuery as a dependency. Note that discussions about jQuery are off-topic here, and this is mostly just about context.

So, that’s the why… less jQuery, less ancient browser code, better overall performance, and better support for the - now very different - average device.

The how

There’s no shortage of solutions on offer, and there is a discussion to be had about not reinventing the wheel, but… let’s not go there and let’s keep it short.

Perhaps the shortest way to address this sticking point is to say… no matter how well something off the shelf fits… it won’t fit as nicely as something tailor-made, and let’s leave it at that.

There’s quite a few things to consider here, whether it be feature excess with libraries that cover numerous use cases like images, iframes, and video and the like, or lack of clarity around licensing.

Knowing the specific use cases that Discourse needs to support makes it possible to avoid unnecessary code, allowing additional targeted features to be added without ending up with a lot of overhead.

So, we now have a pretty good baseline. The requirements are

  1. no jQuery
  2. only focus on images for now
  3. support for quality of life improvements like swiping on mobile
  4. integrate it with Discourse to allow further customization/improvements using the current theme/plugin systems.

The what

Discourse is an Ember shop, so the new lightbox would need to be an Ember component to handle the markup and data. Also, since there’s an expectation that you should be able to set up lightboxes anywhere on the app, having a lightbox service makes a lot of sense. This will allow developers to either:

  1. inject the service into any component and be able to link the service’s setup/cleanup methods to the component life-cycle
  2. lookup the service anywhere on the app and be able to call its setup/cleanup methods

Additionally, as a quality-of-life improvement, we can have a bit of abstraction and create a couple of utility functions that can be used in themes. More on that later.

Since one of the goals is to allow themes/plugins to extend the functionality, the lightbox service will communicate state changes via appEvents. It will emit the following events.

  • lightbox:opened
  • lightbox:item-will-change
  • lightbox:item-did-change
  • lightbox:closed

Each event will be triggered with the type of data developers would typically expect. For example, the lightbox:opened event will contain a list of all the items and the current item that the lightbox opened at. More on this later.

With that out of the way, let’s move on.

Over the last few weeks, I’ve been working on a draft PR that introduces Discourse Lightbox.

At first glance, it might look a bit big so, let’s break it down.

Testing something that relies on a lot of user interaction is prickly, which is why the lightbox test suit includes 63 tests with 291 assertions.

With the size of the tests out of the way, let’s make a quick size comparison with Magnific popup (both unminified)

Discourse Lightbox LOC Magnific Popup LOC Difference
Javascript 1197 1860 (35%)
CSS 813 351 131%
Templates 401 0 -
Total 2411 2211 9.1%

So, Discourse Lightbox has about 9% more code than Magnific. Of course, that’s only part of the story for two reasons.

  1. We haven’t factored in jQuery, which Magnific depends on. jQuery 3.6 is roughly 11k LOC.
  2. Discourse Lightbox adds more features compared to Magnific.

Let’s get into those features,

Any jank you see in the videos below is related to the recording, not what users will experience. All the animations/transitions will run at a solid 60FPS.

So, without any more delay,

basic layout

For comparison, here’s the same in the current Magnific implementation

A few points about the videos above

  1. The new lightbox will use the image as a backdrop instead of the generic semi-translucent dark background. The image will, by definition, complement itself. Note that this does not add any network overhead. The image used in the backdrop is the one from the post body, which means that it will have already been cached when users open the lightbox.

  2. Discourse Lightbox opts for a fixed UI. Instead of having the close button, title, and image metadata attached to the image, they have their own reserved spaces. This will help reduce jumpiness as you navigate between images of different dimensions

  3. Navigating between images can be done with the arrows in the lightbox or via keyboard shortcuts. or for next and or for previous. On RTL locales, the shortcuts are flipped accordingly.

The layout on mobile is very similar, except that the arrows are not displayed because it adds swipe gestures for navigation.

Swipe left on mobile for next, and swipe right for previous. On RTL locales, the swipe gestures are flipped.

Zooming

The new lightbox has a dedicated zoom button that’s visible when the image you’re viewing is larger than the viewport’s dimensions. Clicking the zoom button will zoom the image in, and clicking the button again will zoom the image out. Additionally, there’s now a keyboard shortcut for zooming in/out Z. Lastly, if the image is zoomable, clicking on it will have the same effect.

Here’s a video demonstrating all three methods

Note that while not demonstrated in the video above, the cursor, when you hover over an image that can be zoomed, will change to reflect that. It will also change to a zoom-out icon when you hover over an image that’s already zoomed.

Zooming works differently in the new lightbox. On desktop, the zoomed section of the image will follow the cursor.

There’s no hover on mobile; it uses regular touch-scroll to move around in a zoomed image.

Rotation

The new lightbox adds a dedicated rotation button to rotate the image in 90-degree increments. Rotation also has a keyboard shortcut. The R key. Here’s what that looks like

Rotation and zooming can be combined.

Fullscreen mode

The new lightbox adds a fullscreen button. The button will make the browser window enter fullscreen mode. The keyboard shortcut is M

It will keep track of its state and go back to the regular mode when fullscreen is toggled off or when the lightbox is closed.

Download

The current lightbox adds a “download” link below the image. The new lightbox does the same but changes that to an icon and adds it to the footer of the lightbox instead. It still respects the same permissions. If…

prevent_anons_from_downloading_images

is enabled, and the user is not logged in, the download icon will not be displayed.

New Tab

The current lightbox adds an “original” link below the image that opens it in a new tab. The new lightbox does the same but adds it as an icon in the lightbox header. It will also respect the same permission set for the download icon.

Image Title

The new lightbox focuses mainly on the image. Image titles are truncated to one line by default but support expanding. Here’s an example of what that looks like

There’s a keyboard shortcut to expand/collapse the title T, and the title will not be displayed when the image is zoomed/rotated.

Carousel

A newly available feature is showing all the images in the gallery in a carousel. The layout will depend on the device’s screen; it can be horizontal or vertical, and here’s what it looks like.

There’s a keyboard shortcut to toggle the carousel. A

and here’s what it looks like on mobile

Swiping down on mobile will toggle the carousel on/off.

Closing

The Esc still works like before on desktop to close the lightbox. There’s now an additional swipe gesture on mobile, and you can swipe up to close the lightbox.

Accessibility

Beyond the basics like button labels, the new lightbox adds a screen reader announcer element off-screen. When you navigate to an image inside the lightbox, it will read its index and title based on the following format.

image %{current} of %{total}: %{title}

Here’s a short example of that

The new lightbox also removes all the unnecessary buttons that serve no purpose on screen readers via aria-hidden.

Remember that accessibility is an ongoing mission, and this is by no means complete. There’s always something to improve, but I stopped here to keep things simple for v1.

This covers all the features of the new lightbox.

Let’s move on to a bit of developer-speak.

Event-listeners

The current lightbox adds click-event listeners to each individual lightbox image in cooked posts. That means a post with 20 images will have 20 click event listeners for lightboxes.

The new lightbox leverages on event-delegation and only adds one event listener to the post itself.

Here’s an anon user, incognito window count of the current lightbox on a post with 20 images after forcing garbage collection

current lightbox event listner count

and here’s the same post with the new lightbox

proposed lightbox event listner count

Further, navigating within the lightbox currently adds event listeners, which end up being orphaned and thus interfere with garbage collection. Here’s a chart of

  1. Load a topic page with one post that contains 20 images.
  2. Open the lightbox and navigate through all 20 images three consecutive times.
  3. close the lightbox
  4. force browser garbage collection

current lightbox:

current lightbox event listener count after navigating in a lightbox with 20 images 3 times is 292

new lightbox:

propsed lightbox event listener count after navigating in a lightbox with 20 images 3 times is 223

As for the event listeners on the lightboxes themselves, these don’t seem to be cleaned up currently in Magnific (remember, it wasn’t built for single-page-applications).

A quick note about the tests above. They’re very rudimentary, and the numbers are not supposed to be “scientific” the goal here is figure out the direction and not the exact numbers.

Developer notes

Let’s talk about setting up and cleaning up lightboxes with the new Discourse Lightbox.

Setting up and cleaning up lightboxes

Developers have two options.

  1. injecting the lightbox service into a component via

    import { inject as service } from "@ember/service";
    
    //...
    
    @service lightbox
    

    You can then call

    this.lightbox.setupLightboxes({
      container: yourContainer // DOM node
      selector: ".css-selector" // string selector for the elements you want to lightbox
    })
    

    Then whenever you want to clean up. just call

    this.lightbox.cleanupLightboxes()

    That’s it.

  2. If you don’t want to inject the lightbox service, you can import setupLightboxes and cleanupLightboxes like this

    import {
      cleanupLightboxes,
      setupLightboxes,
    } from "discourse/lib/lightbox";
    

    The rest is the same as injecting the service. Those two functions will look up the service for you. So

    setupLightboxes({
     container: yourContainer // DOM node
     selector: ".css-selector" // string selector for the elements you want to lightbox
    })
    
    //....
    
    cleanupLightboxes()
    

Note that both calling from the service directly or via the utility functions will also accept a nodeList for backward compatibility, but it’s not recommended.

One last note about this is that you can also have a non-image act as a trigger for opening a lightbox that you set up. For example

<div class="my-container">
  <img class="my-selector" src="foo">
  <img class="my-selector" src="bar">
  ....
  <button>Open Lightbox</button>
</div>

I would do something like this to set up basic lightboxes on the div above.

import {
  cleanupLightboxes,
  setupLightboxes,
} from "discourse/lib/lightbox";

//...

setupLightboxes({
   container: document.querySelector(".my-container"),
   selector: ".my-selector"
})

To make the button open the lightbox, all you need to do is add data-lightbox-trigger like so

<button data-lightbox-trigger>Open Lightbox</button>

The rest is handled automagically.

Finally, whenever you want to clean up, call

cleanupLightboxes()

Cleaning up is not really critical since the lightbox service will automatically cleanup whenever the dom:clean event fires in the app (on route transitions)

Listening to lightbox events

The new lightbox will fire events, as we discussed earlier. Those events are:

  • lightbox:opened
  • lightbox:item-will-change
  • lightbox:item-did-change
  • lightbox:closed

lightbox:opened

This event will fire when the lightbox is opened and contains two objects.

  1. items: this is an array of all the images in the current lightbox. Each of them will be an object.
  2. currentItem: this is the object for the current item the lightbox was opened at.

An item object looks like this:

{
  "fullsizeURL": "https://d11a6trkgmumsb.cloudfront.net/original/4X/2/3/c/23c5746c48803deac5c105081dba20555187c3ff.jpeg",
  "smallURL": "https://d11a6trkgmumsb.cloudfront.net/optimized/4X/2/3/c/23c5746c48803deac5c105081dba20555187c3ff_2_600x750.jpeg",
  "downloadURL": "/uploads/short-url/56rKTvkvmL6c2C8OFQtMo3D8sIn.jpeg?dl=1",
  "title": "Close-up Photo of a Black Microphone on Stand",
  "fileDetails": "1200×1500 88.9 KB",
  "dominantColor": "793C6D",
  "aspectRatio": "400 / 500",
  "index": 0,
  "cssVars": "--dominant-color: #793C6D;--aspect-ratio: 400 / 500;--small-url: url(https://d11a6trkgmumsb.cloudfront.net/optimized/4X/2/3/c/23c5746c48803deac5c105081dba20555187c3ff_2_600x750.jpeg);",
  "isLoaded": true,
  "hasLoadingError": false,
  "width": 1200,
  "height": 1500,
  "canZoom": true
}

lightbox:item-will-change

This event fires right before the current item in the lightbox changes. It will have the currentItem (the one that’s about to change)

lightbox:item-did-change

This event fires right after the item in the lightbox changes and is finished loading, and it will have currentItem as an argument.

lightbox:closed

This event fires right after the lightbox is closed and has no arguments.

With the events above. A theoretical theme component can easily add analytics to lightbox like so

api.onAppEvent('lightbox:opened', ({items, currentItem}) => {
  console.log({items});
  console.log({currentItem});

  // your analytics code here
});

or other similar ideas.

The CSS

The new lightbox uses the BEM naming convention for HTML classes. Here’s a complete list of the selectors you can use

html.has-lightbox {
  // css for the HTML element when lightboxes are open
}

.d-lightbox {
  &--is-visible {
    // Main lightbox element
  }

  &__content {
    // lightbox inner content wrapper
  }
}

.d-lightbox {
  &__content__header {
    // lightbox header
  }
}

.d-lightbox {
  &__content__body {
    // lightbox body (contains the main image)

    &__backdrop {
      // lightbox backdrop
    }

    &__main-image {
      // lightbox main image
    }

    &__error-message {
      // lightbox error message
    }

    &__previous-button,
    &__next-button {
      // main lightbox previous/next buttons
    }
  }
}

.d-lightbox {
  &__content__footer {
    // lightbox footer

    &__main-title {
      // lightbox image title
     
      &__item-file-details {
        // lightbox file details e.g "1000x582 183KB"
      }
    }
  }
}

.d-lightbox {
  &__content__carousel {
    // lightbox carousel container

    &__previous-button,
    &__next-button {
      // lightbox carousel previous/next buttons
    }
  }
}

.d-lightbox {
  &__content__carousel {
    &__carousel-items {
      // lightbox carousel items container

      &__item,
      &__item--is-current {
        // lightbox carousel item
      }

      &__item--is-current {
        // lightbox carousel current item
      }
    }
  }
}

.d-lightbox {
  &--is-vertical &__content__carousel {
    // lightbox carousel vertical styles
  }
}

.d-lightbox {
  &--is-horizontal &__content__carousel {
    // lightbox carousel horizontal styles
  }
}

.d-lightbox {
  .btn-flat {
    // styles for all lightbox buttons
  }
}

.d-lightbox {
  &__content {
    &__focus-trap,
    &__screen-reader-announcer {
      // lightbox focus trap and screen reader announcer. These are offscreen
    }
  }
}

/* State styles */

// Carousel
.d-lightbox {
  &--has-carousel {
    // lightbox styles when carousel is open
  }
}

// expanded title
.d-lightbox {
  &--has-expanded-title {
    // lightbox styles when title is expanded
  }
}

// Zoom
.d-lightbox {
  &--can-zoom {
    // lightbox styles when image can be zoomed
  }

  &--is-zoomed {
    // lightbox styles when image is zoomed
  }
}

// Rotate
.d-lightbox {
  &--is-rotated {
    // lightbox styles when image is rotated
  }
}

// Fullscreen
.d-lightbox {
  &--is-fullscreen {
    // lightbox styles when image is fullscreen
  }
}

Now that we covered the what, we can finally move on to…

The when

Right now, the PR is ready for review, which needs to happen first. After it passes review, it will be available to sites that update. The PR adds a new temporary site setting as we transition from Magnific popup to Discourse Lightbox. The name of the setting is:

enable_experimental_lightbox

If the setting is disabled, the PR will have no effect, and everything will continue to work like before with Magnific popup.

Discourse Lightbox will replace Magnific popup in cooked posts, chat messages, and image uploader components when the setting is toggled on.

Road map

  1. PR Review
  2. PR Merge
  3. General Feedback window (1-2 weeks)
  4. PR to remove Magnific popup from core and remove the experimental site setting.
  5. TBD: stretch goals such as a theme component that explores different layouts (should be straightforward since the new lightbox uses CSS grid for the layout)

Acknowledgments

  • This work was generously sponsored by CDCK :pray:
  • Much love :heart: goes out to Dmytro Semenov, the creator of Magnific popup, for creating something that was way ahead of its time.
  • The images used in the demos above are courtesy of Irina Iriser @pexels
37 Likes

I am used, from youtube and other desktop applications, that F goes fullscreen.
Is there a reason not to use muscle memory for the shortcut?

4 Likes

This one is particularly looking nice. :raised_hands:

One thing I would have wanted is being able to go to next post images while using arrows. That way if Im in a topic like “Cute cats” I can just use arrows to navigate to next post images seamlessly. There would be various things to take into consideration but that would be very nice. ATM I’m forced to close the light box go to next post, click on first image, …

15 Likes

bootstrap 3 also requires jqwery. Discourse uses bootstrap 3. So jqwery will be installed as a dependency on our forum. Is that true?

2 Likes

EmberJS also used to require jQuery and Discourse is a EmberJS app. We’ve been working, for a couple of years now, on removing jQuery usage from Discourse itself, official plugins and components.

It’s a lot of work but we are getting closer. This very topic is one of the many efforts towards that.

11 Likes

This has now been implemented. :partying_face:

We’ll be collecting feedback in that topic for a short period, so check it out and let us know what you think. :slight_smile: :+1:

6 Likes