A Code Block Web Component With Line Numbers, Wrap Toggles, Font Sizing And Copy Buttons

Introduction

My site is built from my notes. A lot of my notes have code samples. That means a lot of site code blocks.

I've never been happy with any of the formats I've used for blocks. There's always this tricky dance of trying to balance the font size to show enough of tabbed lines and the amount of wrapping that occurs overall. Any global setting that works well for some blocks sucks for others.

So, I made a web component to make each block customizable.

Here's what it looks like:

Hello, Code Block
fn main() {
    println!("This is a long line of text. The goal is to trigger the wrapping button on the code block. If your browser is big enough that it doesn't wrap, consider resizing it down and refreshing the page to see the button.");
}

Some Details

You can grab the javascript and css for the component further below, but first a few notes about how things work:

Next Steps

There are a copule other things I want to add to the component:

  1. Add the ability to start line numbering an arbitrary number

  2. A flag to turn off the "Copy" button. This would be for times like when I'm pulling out a single line of code from a larger section and copying the code wouldn't really make sense without the rest of the context

  3. A feature to collapse longer blocks of code. For example, I'm not really documenting the code blocks for the componet below. They're mainly there to copy and paste if you want to use them. So, there's no need to have them be their full lenght by default.

  4. Only show the button to toggle wrapping if the content needs it. (I had an initial version of this working and like it, but it doesn't play nice with the way I'm handling the loading of my site with the visibility turned off to help minimize flashing when determining the color scheme to use)

  5. Figure out a couple spacing issues with the height of the code block and the width of buttons that are marked by comments in the JavaScript. These also might have to do with the way I'm loading color scheme for my site, but I need to look into it more.

The Code

Here's another example of the output along with all the code necessary to produce it. You should pretty much be able to copy/paste the CSS and JavaScript and then have your site mimic the HTML tags and classes to get things to work.

(Note that the code on the page is live so I changed the various names to add an -x to prevent things from conflicting with my production version. You can, of course, change the names to anything that fits more with your naming conventions.)

Output From HTML

A Basic Example
Alfa
Bravo
Charlie is a long line that will wrap if your browser window is small enough. If you've got a big monitor and it hasn't wrapped yet, try making the browser window smaller...
Delta
<aws-code-block-x>
    <div class="aws-code-block-title-x">A Basic Example</div>
    <div class="aws-code-block-wrapper-x">
        <div class="aws-code-block-sidebar-x"></div>
        <pre><code><span class="aws-code-block-marker-x"></span>Alfa
<span class="aws-code-block-marker-x"></span>Bravo
<span class="aws-code-block-marker-x"></span>Charlie is a long line that will wrap if your browser window is small enough. If you've got a big monitor and it hasn't wrapped yet, try making the browser window smaller...
<span class="aws-code-block-marker-x"></span>Delta</code></pre></div>
</aws-code-block-x>

CSS

aws-code-block-x {
    border-radius: 0.3rem;
    border: 1px solid blue;
    display: block;
    margin-bottom: 2rem;
    position: relative;

    .aws-code-block-buttons-x {
        text-align: right;
        border-top: 1px solid blue;
    }

    .aws-code-block-buttons-x button {
        padding-block: 0.3rem;
    }

    .aws-code-block-sidebar-x {
        border-right: 1px solid blue;
    }

    .aws-code-block-marker-x{
        counter-increment: codeBlockLineNumberX;
    }

    .aws-code-block-marker-x:before {
        content: counter(codeBlockLineNumberX);
        display: inline-block;
        margin-left: -5ch;
        padding-right: 2ch;
        position: absolute;
        text-align: end;
        width: 5ch;
    }

    .aws-code-block-title-x {
        text-align: center;
        border-bottom: 1px solid blue;
    }

    .aws-code-block-wrapper-x {
        counter-reset: codeBlockLineNumberX;
        display: grid;
        grid-template-columns: 5ch 1fr;
    }

    .aws-code-block-wrapper-x pre {
        overflow-wrap: break-word;
        padding-left: 1ch;
        white-space: pre-wrap; 
    }

    .aws-code-block-wrapper-x pre.no-wrapping {
        white-space: pre; 
        overflow-wrap: normal;
        overflow-x: auto; 
        overscroll-behavior-x: none;
    }
}

JavaScript

class AwsCodeBlockX extends HTMLElement {
    connectedCallback() {
        this.pre = this.querySelector('pre');
        if (this.pre !== null) {
            this.minHeight = 0;
            this.addButtonsDiv();
            this.makeToggleWrapButton();
            this.makeReduceButton();
            this.makeEnlargeButton();
            this.makeCopyButton();
            this.setMinHeight();
        }
    }

    addButtonsDiv() {
        this.buttonsDiv = document.createElement("div");
        this.buttonsDiv.classList.add("aws-code-block-buttons-x");
        this.appendChild(this.buttonsDiv);
    }

    checkForWrap() {
        // Eventually this will be used to only output
        // the wrap toggling button when necessary
        return true;
    }

    async copyCode() {
        try {
          await navigator.clipboard.writeText(this.pre.innerText);
          this.copyButton.innerHTML = 'Copied';
        } catch (err) {
          this.copyButton.innerHTML = 'Copy Failed';
        }
        setTimeout(() => 
            {this.copyButton.innerHTML = 'Copy Code'}, 
            1200
        );
    }

    enlargeFont() {
        this.pre.style.fontSize = `${this.getFontSize() * 1.05}px`;
        this.setMinHeight();
    }

    getFontSize() {
        const styles = window.getComputedStyle(this.pre);
        const size = parseFloat(
            styles.getPropertyValue('font-size')
        );
        return size;
    }

    makeCopyButton() {
        this.copyButton = document.createElement("button");
        this.copyButton.innerHTML = "Copy Code";
        this.copyButton.addEventListener(
            "click", 
            () => { this.copyCode(); }
        );
        this.buttonsDiv.appendChild(this.copyButton);
        const copyButtonRect = this.copyButton.getBoundingClientRect();
        // TODO: Figure out why adding 20px here is necessary to
        // keep the button text from wrapping.
        this.copyButton.style.width = `${copyButtonRect.width + 20}px`;
    }

    makeEnlargeButton() {
        this.enlargeButton = document.createElement("button");
        this.enlargeButton.innerHTML = "Enlarge Font";
        this.enlargeButton.addEventListener(
            "click", 
            () => { this.enlargeFont(); }
        );
        this.buttonsDiv.appendChild(this.enlargeButton);
    }

    makeReduceButton() {
        this.reduceButton = document.createElement("button");
        this.reduceButton.innerHTML = "Reduce Font";
        this.reduceButton.addEventListener(
            "click", 
            () => { this.reduceFont(); }
        );
        this.buttonsDiv.appendChild(this.reduceButton);
    }

    makeToggleWrapButton() {
        if (this.checkForWrap()) {
            this.wrapState = "On";
            this.toggleWrapButton = document.createElement("button");
            this.toggleWrapButton.innerHTML = 'Turn Wrapping Off';
            this.toggleWrapButton.addEventListener(
                "click", 
                () => { this.toggleWrap() }
            );
            this.buttonsDiv.appendChild(this.toggleWrapButton);
            const toggleWrapButtonRect = 
                this.toggleWrapButton.getBoundingClientRect();
            // NOTE: Adding 20 pixels here. I'm not sure why
            // that's necessary, but without it the button
            // changes size when changing the text in this
            // example
            this.toggleWrapButton.style.minWidth = 
                `${toggleWrapButtonRect.width + 20}px`;
        }
    }

    reduceFont() {
        this.pre.style.fontSize = `${this.getFontSize() * 0.95}px`;
    }

    setMinHeight() {
        // TODO: Figure out why this is adding an extra lines
        // worth of space to the bottom of the pre element
        // in this example.
        const preRect = this.pre.getBoundingClientRect();
        if (this.minHeight < preRect.height) {
            this.minHeight = preRect.height;
            this.pre.style.minHeight = `${preRect.height}px`;
        }
    }

    toggleWrap() {
        this.pre.classList.toggle("no-wrapping");
        this.toggleWrapButton.innerHTML = 
            `Turn Wrapping ${this.wrapState}`;
        this.wrapState = this.wrapState === "On" ? "Off" : "On";
        this.setMinHeight();
    }
}

customElements.define("aws-code-block-x", AwsCodeBlockX);
~ fin ~

Endnotes

  • I haven't really written up how all this works. I try to use good names in the code. Hopefully, they'll get the point across. If not, hit me up on mastodon.

  • This is my first real web component. I asked a lot of questions to a lot of folks to figure everything out. I'm still at the early part of the learning curve though. If you see something weird or scary that I should know about, I'm all ears.

  • As mentioned above, my site generator outputs all the HTML necessary to add the line numbers and syntax highlighting via CSS with needing JavaScript for the process. If you want to use something like prisma it'll take a little more work on your side.

Footnotes

References