DEV Community

Cover image for Developing your own Chrome Extension in Bun and Typescript (Part 2)
JoLo
JoLo

Posted on

Developing your own Chrome Extension in Bun and Typescript (Part 2)

In the previous part of the series, we learned about the structure of building a Chrome extension. We are ready to build and structure our first Chrome Extension with that knowledge. Let’s get started.

Idea

This is the idea of the project

Idea of Preview Linker

Getting Started

First, we create an empty directory with a manifest.json. Initially, we want to inject a script to the current page in the tab by using content_scripts.

{
  "manifest_version": 3,
  "name": "Link previewer",
  "version": "0.0.1",
  "description": "It sneak preview the link by mouse hovering and show key facts using OpenAI GPT model.",
  "icons": {
    "16": "images/icon-16.png",
    "32": "images/icon-32.png",
    "48": "images/icon-48.png",
    "128": "images/icon-128.png"
  },
  "content_scripts": [{
    "js": ["dist/index.js"]
  }]
}
Enter fullscreen mode Exit fullscreen mode

Note that I started with a dist/index.js. This is where we put the bundled code into. For now, let’s create a src/index.ts. Now, we can add Bun by bun init . It will ask if you want to use Typescript. Go ahead and use it. Then, we need to adjust the package.json.

{
  // ..
  "scripts": {
    "dev": "bun build --watch src/index.ts --outfile dist/index.js"
  }
  // ..
}
Enter fullscreen mode Exit fullscreen mode

We want to plan Typescript, so let’s install it: bun install -D chrome-types.
Now, we need to add the types to the tsconfig.json

{
  // tsconfig.json
  // ..
  "types": ["chrome-types"]
}
Enter fullscreen mode Exit fullscreen mode

We need that chrome- object. Otherwise, our IDE may yell at me.

VS Code yelling that cannot find  raw `chrome` endraw - object

If you enter their code from their Chrome Extension Getting Started page, Typescript reveals so many errors🫢

Developing

Okay, it’s time to get our hands dirty. Before that, we need to load an unpacked extension:

  1. Open chrome://extensions in a new tab
  2. Enable the Developer Mode and click on the Load unpacked

Click on Load unpacked

  1. Now, go to the directory of your extension and click on the reload button

Click on Reload Button

You need to reload the extension every time you change, which is quite annoying. Extension Reloader is another extension that can do that for you.

Start Bun

Do you remember the scripts in the package.json from above? First, you get Hot Module reloading whenever a change happens on that file. Second, Bun is bundling the index.ts to index.js and put it into the dist- folder.

bun dev
Enter fullscreen mode Exit fullscreen mode

Add a listener on each link

From our idea, we want to show a tooltip whenever we hover over a link on that page. In doing that, we have to add a listener.

const links = document.querySelectorAll('a')

console.log(links) // Debugging purpose
Enter fullscreen mode Exit fullscreen mode

Go ahead and console.log it and check the links. The querySelectorAll returns a list of Anchor elements.
But here is a little problem. We get a NodeList that is not iterable, and what if an Anchor tag does not href , which we need in our case? So, let’s modify it a bit

const links = Array.from(document.querySelectorAll("a")).filter((link) =>
  link.hasAttribute("href"),
); // Create an Array from an iterable object

// to verify
links.forEach((link) => {
  console.log(link.href);
});
Enter fullscreen mode Exit fullscreen mode

We can add an event listener whenever the mouse enters the link.

const links = Array.from(document.querySelectorAll('a')).filter(link => link.hasAttribute('href'));

links.forEach(link => {
    link.addEventListener('mouseenter', () => {
        console.log(link.href);
    });
});
Enter fullscreen mode Exit fullscreen mode

Each link gets an event listener mouseenter whenever the mouse is entering or hovering over the link.

Add listener when mouse enters links

But what if the page has many links? On Arc, you can hold shift on a link, but it seems to be impossible with vanilla JavaScript. Let’s extend it and add a timer when the user has been on the link for 3 seconds.

const links = Array.from(document.querySelectorAll('a')).filter(link => link.hasAttribute('href'));

links.forEach(link => {
    link.addEventListener('mouseenter', () => {
        console.log(link.href);
        const timer = setTimeout(function() {
            // Here we put the logic after 3000 ms aka 3 seconds
        }, 3000); // Adjust the time (in milliseconds) as needed

        link.addEventListener("mouseleave", function() {
            // Clear the timer if the mouse leaves the link before the specified time
            clearTimeout(timer);
        });
    });
});
Enter fullscreen mode Exit fullscreen mode

We execute the logic after 3 seconds via setTimeout and add another event listener when the user leaves the mouse after leaving the link.

Show the Tooltip

Now, we want to add the tooltip. The approach is that we create a tooltip element, but where to add it? What happens if we hover on the same link again?

The idea is that we create the tooltip and put the element at the bottom of the <body> .

Here, we outsource the code logic in tooltip.ts, to make it more readable. In addition, we create some listeners on the link we’ve just hovered over.

// in tooltip.ts
export function createTooltip(link: HTMLAnchorElement) {
    const tooltip = document.createElement('div');
    tooltip.classList.add('tooltip');
    tooltip.dataset.url = link.href;
    tooltip.innerHTML = 'This is a tooltip';
    document.body.appendChild(tooltip);
    createTooltipAction(link, tooltip);
    return tooltip;
}


function createTooltipAction(link: HTMLAnchorElement, tooltip: HTMLElement) {
    function onMouseEnter() {
        console.log('onMouseEnter');
    }

    function onMouseMove(event: MouseEvent) {
        console.log('onMouseMove');
        tooltip.style.backgroundColor = 'white';
        tooltip.style.color = 'black';
        tooltip.style.display = 'block';
        tooltip.style.position = 'absolute';
        tooltip.style.top = `${event.pageY - 25}px`;
        tooltip.style.left = `${event.pageX - 10}px`;
    }

    function onMouseLeave() {
        console.log('onMouseLeave');
        tooltip.style.display = 'none';
    }

    link.addEventListener('mouseenter', onMouseEnter);
    link.addEventListener('mousemove', onMouseMove);
    link.addEventListener('mouseleave', onMouseLeave);

    return {
        destroy() {
            link.removeEventListener('mouseenter', onMouseEnter);
            link.removeEventListener('mousemove', onMouseMove);
            link.removeEventListener('mouseleave', onMouseLeave);
        },
    };
}
Enter fullscreen mode Exit fullscreen mode

Notice that the tooltip contains the attribute data-url={link.href} . That helps us to find the tooltip and avoid recreating it.

// in index.ts
import { createTooltip } from './tooltip';

const links = Array.from(document.querySelectorAll('a')).filter(link => link.hasAttribute('href'));

links.forEach(link => {
    link.addEventListener('mouseenter', () => {
        const tooltip = document.querySelector<HTMLElement>(
          `[data-url="${link.href}"]`,
        );
        if (tooltip === null) {
          createTooltip(link);
        }
    });
});
Enter fullscreen mode Exit fullscreen mode

Tooltip follows the mouse when entering

Let’s add a nice skeleton animation when the tooltip appears

// tooltip.ts
export function createTooltip(link: HTMLAnchorElement) {
    // ...
    createTooltipSkeleton();
    return tooltip;
}

function createTooltipSkeleton() {
    let linkElement = document.createElement("style");
    linkElement.innerHTML = `
      .tooltip {
        width: 220px;
        height: 80px;
        border-radius: 5px;
      }
      .tooltip .avatar {
        float: left;
        width: 52px;
        height: 52px;
        background-color: #ccc;
        border-radius: 25%;
        margin: 8px;
        background-image: linear-gradient(90deg, #ddd 0px, #e8e8e8 40px, #ddd 80px);
        background-size: 600px;
        animation: shine-avatar 1.6s infinite linear;
      }
      .tooltip .line {
        float: left;
        width: 140px;
        height: 16px;
        margin-top: 12px;
        border-radius: 7px;
        background-image: linear-gradient(90deg, #ddd 0px, #e8e8e8 40px, #ddd 80px);
        background-size: 600px;
        animation: shine-lines 1.6s infinite linear;
      }
      .tooltip .avatar + .line {
        margin-top: 11px;
        width: 100px;
      }
      .tooltip .line ~ .line {
        background-color: #ddd;
      }

      @keyframes shine-lines {
        0% {
          background-position: -100px;
        }
        40%, 100% {
          background-position: 140px;
        }
      }
      @keyframes shine-avatar {
        0% {
          background-position: -32px;
        }
        40%, 100% {
          background-position: 208px;
        }
      }
      `;
    // Append the link element to the head of the document
    document.getElementsByTagName("head")[0].appendChild(linkElement);
}
Enter fullscreen mode Exit fullscreen mode

At this point, we can ask if a framework like React or Vue is not a better choice for better maintenance. But since we use Bun, it is supposed to bundle .tsx files out of the box. That is unfortutately not the case as you need a third party library such as React or Preact. I chose the latter: bun add -D preact and adjust the tsconfig.json :

{
  "compilerOptions": {
    // ...
    /* Preact */
    "jsx": "react-jsx",
    "jsxImportSource": "preact",
    "baseUrl": "./",
    "paths": {
      "react": ["./node_modules/preact/compat/"],
      "react-dom": ["./node_modules/preact/compat/"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, we can rewrite the Tooltip.tsx:

import * as React from "preact";

export default function Tooltip(props: { link: HTMLAnchorElement }) {
  console.log("Tooltip", props.link.href);
  const tooltip = React.createRef();
  createTooltipSkeleton();
  function onMouseMove(event: MouseEvent) {
    console.log("onMouseMove");
    tooltip.current.style.backgroundColor = "white";
    tooltip.current.style.color = "black";
    tooltip.current.style.display = "block";
    tooltip.current.style.position = "absolute";
    tooltip.current.style.top = `${event.pageY - 100}px`;
    tooltip.current.style.left = `${event.pageX - 100}px`;
  }

  function onMouseLeave() {
    console.log("onMouseLeave");
    tooltip.current.style.display = "none";
  }

  props.link.addEventListener("mousemove", onMouseMove);
  props.link.addEventListener("mouseleave", onMouseLeave);
  return (
    <div className="tooltip" data-url={props.link.href} ref={tooltip}>
      <div className="avatar"></div>
      <div className="line"></div>
      <div className="line"></div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The compiler complains if we don’t import React somewhere as if JSX is only possible with React… The VS Code compiler also put React in favor when using JSX

Linting error when JSX without React

I honestly don’t like that approach. There may be good alternatives, such as VanJS, which does not use JSX, and nanoJSX. VanJS results in a smaller bundle ( < 8kb) **but is not so nicely readable as Preact ( < 19kb**).

Comparison between Preact, and VanJS

Anyway, I will put both codes in the repository.

The result is the same 🤩

The Result

Conclusion

The blog post discusses building a basic Chrome extension that displays a tooltip with additional information when the user hovers their mouse over web links. It covers setting up the project structure, adding Typescript for type safety, using Bun to bundle the code, selecting links and adding mouseenter listeners, conditionally displaying a tooltip after 3 seconds, creating the tooltip element and styling it, and positioning the tooltip near the mouse pointer. The post also explores using Preact to manage the tooltip component and compares it to other frameworks like VanJS and NanoJSX that could potentially reduce the bundle size. Overall, the post provides a helpful tutorial for creating a foundational Chrome extension.

Find the repository here.

Check out this repository if you decide to use another framework for your Chrome extension: https://github.com/guocaoyi/create-chrome-ext.

In the next section, we will integrate OpenAI’s SDK and explore the capabilities of Langchain.

Top comments (0)