home ~ projects ~ socials

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:

  • The code block is made by wrapping a web component's custom element around a regular chunk of HTML. When the JavaScript for the component fires it adds the button functionality to the bottom of the block
  • The component is designed to work as a progressive enhancement. Specifically, if JavaScript is off or doesn't trigger properly the code block still shows up
  • The line numbers and syntax highlighting are done directly in the HTML and CSS from my site generator1 (i.e. they'll still work even if the JavaScript doesn't)
  • The height of the code block doesn't shrink if you reduce the font size or turn off wrapping. (This keeps the buttons in the same position unless you enlarge the font in which case they get pushed down if the area needs to grow)

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

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

HTML

<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);
-- end of line --

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.

References

The rust crate I'm using for syntax highlighting.

This is how I'm using the rust syntect create to generate the HTML with the empty spans for the line numbers.

This is the main post I used for reference when figuring out how to add line numbers in CSS. My approach is a little different in that I'm not wrapping the entire line. Instead, I'm using the single empty span at the start of each line.

Footnotes

My web site engine that I've been building over the past few years.