triplebatman

Images, Images, Images

For the latest blog update, I've done a couple of image-related upgrades for the site. The first is a new custom Web Component for handling screenshots within blog posts, and the second is adding Open Graph image tags to posts. I've also done some minor styling updates so I can post properly formatted code snippets.

For the screenshot component, I basically just decided to rip off how Bluesky handles images: when you click on an image, it opens in fullscreen and displays the title / alt text at the bottom, and then clicking anywhere closes the fullscreen view. I like this interaction, and it fixes a problem I noticed with my previous post reviewing SokoFrog - namely, the smaller screenshots are kind of hard to see, and being able to "zoom" in and view a bigger version seems like it would be a nice-to-have feature!

I knew I wanted to have a re-usable component for my Bluesky-ripoff interactive images, but I didn't want to have to download any new libraries. Fortunately, that's where Web Components come in. Web Components allows you to define new custom HTML elements for use throughout your web page / application using simple JavaScript. What I needed was a custom element that displayed an image, handled the behavior of clicking on that image to expand to fullscreen on click (and then collapse out of fullscreen on another click), and then make sure everything was styled correctly.

The initial step - rendering an image - was easy. I just defined a boilerplate custom HTML component (called blog-screenshot) and had it render an img tag based on the attributes passed to the custom component:

    class Screenshot extends HTMLElement {
      constructor() {
        super();
      }
    
      connectedCallback() {
        const shadow = this.attachShadow({ mode: 'open' });

        const image = document.createElement("img");
        image.src = this.getAttribute("src");
        image.setAttribute("title", this.getAttribute("title"));

        shadow.appendChild(image);
      }
    }
    
    customElements.define("blog-screenshot", Screenshot);

This was saved to its own JS file and then imported into my default blog post page. Now, I can just add a blog-screenshot tag into the body of a blog post, pass in the necessary image path and title text as attributes (src and title, respectively), and I'll get an image! Granted, that's how img tags already work so...let's do something more interesting.

First, I know I'm going to also want a fullscreen version of the image, but I don't want it to be visible on initial render, so I'm going to need to add another element to my custom component. I also know I need a wrapper element that holds both the normal version of the image AND the fullscreen version, so that I can properly handle the click behavior later. Having a wrapper element that holds both versions of the image will allow me to add a click event handler to the wrapper that will trigger when clicking EITHER of these child elements.

So, inside the Screenshot class, I added a new wrapper element (I used span in this case), a new fullscreen image element with a style of display: none to ensure it is hidden when the custom component is first rendered, and then restructured my custom component by adding both image elements to the wrapper element, and then adding that wrapper element to our "shadow" DOM from the boilerplate web component code.

    // wrapper element
    const wrapper = document.createElement("span");

    // normal image
    const image = document.createElement("img");
    image.src = this.getAttribute("src");
    image.setAttribute("title", this.getAttribute("title"));

    // fullscreen
    const fullscreenImage = document.createElement("img");
    fullscreenImage.src = this.getAttribute("src");
    fullscreenImage.setAttribute("class", "fullscreen_screenshot");
    fullscreenImage.setAttribute("title", this.getAttribute("title"));

    wrapper.appendChild(image);
    wrapper.appendChild(fullscreenImage);
    shadow.appendChild(wrapper);

Now for the behavior: I know I'm going to want to keep track of whether or not the image is currently in fullscreen mode, and I'm going to want that value to update when I click on the image. Fortunately, web components allows us to spy on attribute values using observedAttributes inside our Screenshot class:

    static get observedAttributes() {
      return ["fullscreen"];
    }

Then, we can add an event listener for clicks inside our connectedCallback function that updates that attribute:

    wrapper.addEventListener('click', () => {
      if (this.hasAttribute("fullscreen")) {
        this.removeAttribute("fullscreen");
        document.body.style.overflow = "scroll";
      } else {
        this.setAttribute("fullscreen", "");
        document.body.style.overflow = "hidden";
      }
    });

I also want to modify the state of the fullscreen image, so I need a way to keep track of that element. This won't be used immediately but we'll need it in the next step. I added a new instance variable in the constructor method, and then initialize that value to our fullscreen image element when it gets created in the connectedCallback:

    constructor() {
      super();
      this._fullscreenImage = null;
    }

    // some more code goes here that isn't changing right Now

    this._fullscreenImage = fullscreenImage;

When we click on custom component, it will fire a click event, which our event listener will intercept. If the component has the attribute fullscreen set, it will remove that attribute. If it isn't set, it will add that attribute. I also modify the style of the body tag to disable scrolling when the fullscreen value is set - that way, you can't scroll the page in the background while viewing a fullscreen image.

Now we can use the state of that attribute to determine if we should render the fullscreen image or not. For this, I use the attributeChanged callback provided by the web component:

    attributeChangedCallback(name, _, newVal) {
      if (name === "fullscreen") {
        this._fullscreenImage.style.display = newVal === null ? "none" : "initial";
      }
    }

This function will be called any time an attribute of the web component changes. Since I only care about the fullscreen attribute, I do a check to make sure that's the one that changed, and when it does, I check if the new value is null. If it is, that means the attribute has been removed and I need to hide the fullscreen image, so I take our _fullscreenImage instance variable and update its style to "none". Otherwise, I set the style to "initial" which will cause the component to be displayed. This is what it looks like now:

Not great! Even though I'm using the same classes to style my image, it doesn't look right. This is because the shadow DOM used to render custom web components doesn't automatically pull in the styles imported into the page it's being rendered in. A little unfortunate, but there's an easy fix - we just attach the stylesheet to our custom web component!

    const linkElem = document.createElement("link");
    linkElem.setAttribute("rel", "stylesheet");
    linkElem.setAttribute("href", "/css/default.css");

    shadow.appendChild(linkElem);

Now our base images look just like our old image style, but we need to add some styling for the fullscreen version. Because currently it looks like this when we click to expand to fullscreen:

We're also missing the title text at the bottom. So, let's restructure a bit and add some styles to our fullscreen image. We're also renaming our instance variable to _fullscreenDiv since it won't just be an image tag anymore!

    // inside our Screenshot class...

    const fullscreenImage = document.createElement("img");
    fullscreenImage.src = this.getAttribute("src");
    fullscreenImage.setAttribute("class", "fullscreen_screenshot");
    fullscreenImage.setAttribute("title", this.getAttribute("title"));

    const fullscreenTitleText = document.createElement("div");
    fullscreenTitleText.innerHTML = this.getAttribute("title");
    fullscreenTitleText.setAttribute("class", "fullscreen_text");

    const fullscreenWrapper = document.createElement("div");
    fullscreenWrapper.setAttribute("class", "fullscreen_wrapper");

    const fullscreenDiv = document.createElement("div");
    fullscreenDiv.setAttribute("class", "fullscreen");
    fullscreenDiv.style.display = "none";

    this._fullscreenDiv = fullscreenDiv;
    // styling for the fullscreen in our CSS file

    .fullscreen {
      position: fixed;
      background-color: black;
      inset: 0;
    }
    
    .fullscreen_wrapper {
      height: 100%;
      display: flex;
      flex-direction: column;
      justify-content: center;
    }
    
    .fullscreen_screenshot {
      height: 100%;
      object-fit: contain;
    }
    
    .fullscreen_text {
      padding: 24px;
      text-align: left;
    }

After styling and adding the wrapper div and title text our fullscreen screenshot looks like this:

And that's basically it! I did add another attribute called multi that's used to apply styles to the non-fullscreen image element when multiple screenshots are placed inside a shared parent div. This just made my life easier than trying to re-build the entire structure of my CSS file to account for multiple screenshots now being nested ina shadow DOM (there might be an even smarter way to do this but I opted for easy over smart).

The final version of the custom blog-screenshot web component looks like this:

    class Screenshot extends HTMLElement {
      constructor() {
        super();
        this._fullscreenDiv = null;
      }
    
      static get observedAttributes() {
        return ["fullscreen"];
      }
    
      attributeChangedCallback(name, _, newVal) {
        if (name === "fullscreen") {
          this._fullscreenDiv.style.display = newVal === null ? "none" : "initial";
        }
      }
    
      connectedCallback() {
        const shadow = this.attachShadow({ mode: 'open' });
    
        // pull styles into shadow root
        const linkElem = document.createElement("link");
        linkElem.setAttribute("rel", "stylesheet");
        linkElem.setAttribute("href", "/css/default.css");
    
        shadow.appendChild(linkElem);
    
        // wrapper for click handling
        const wrapper = document.createElement("span");
        wrapper.addEventListener('click', () => {
          if (this.hasAttribute("fullscreen")) {
            this.removeAttribute("fullscreen");
            document.body.style.overflow = "scroll";
          } else {
            this.setAttribute("fullscreen", "");
            document.body.style.overflow = "hidden";
          }
        });
    
        // normal image
        const image = document.createElement("img");
        image.src = this.getAttribute("src");
        if (this.getAttribute("multi") !== null) {
          image.setAttribute("class", "screenshot multi_screenshot");
        } else {
          image.setAttribute("class", "screenshot");
        }
        image.setAttribute("title", this.getAttribute("title"));
    
        // fullscreen
        const fullscreenImage = document.createElement("img");
        fullscreenImage.src = this.getAttribute("src");
        fullscreenImage.setAttribute("class", "fullscreen_screenshot");
        fullscreenImage.setAttribute("title", this.getAttribute("title"));
    
        const fullscreenTitleText = document.createElement("div");
        fullscreenTitleText.innerHTML = this.getAttribute("title");
        fullscreenTitleText.setAttribute("class", "fullscreen_text");
    
        const fullscreenWrapper = document.createElement("div");
        fullscreenWrapper.setAttribute("class", "fullscreen_wrapper");
    
        const fullscreenDiv = document.createElement("div");
        fullscreenDiv.setAttribute("class", "fullscreen");
        fullscreenDiv.style.display = "none";
    
        this._fullscreenDiv = fullscreenDiv;
    
        fullscreenWrapper.appendChild(fullscreenImage);
        fullscreenWrapper.appendChild(fullscreenTitleText);
        fullscreenDiv.appendChild(fullscreenWrapper);
        wrapper.appendChild(image);
        wrapper.appendChild(fullscreenDiv);
        shadow.appendChild(wrapper);
      }
    }
    
    customElements.define("blog-screenshot", Screenshot);

After I got this working, I updated all my old posts that had screenshots to use the new custom web component, and I'll be using it for images in all my posts going forward.

As for the Open Graph image, that was basically nothing by comparison. I added a column in the DB for posts to store a URL for the image, and then added a meta tag in the header for the post page that pulls in that image from the database in a meta tag, so it just looks like this:

    <meta name="og:image" content="IMAGE LINK GOES HERE">

I also set the default to just be an image based on my profile picture and will only update it for posts that I feel deserve their own custom images. All it really does is populate the little thumbnail-type things that social media sites like Bluesky generate when you post a link (e.g. the images and title / description that pop up when you post a link to a YouTube video).

And that's it for this one. I had fun working with Web Components, and I appreciate that this is built-in functionality that doesn't require downloading a bunch of bloated JS libraries to get a re-usable glorified img tag.

tags:programming