Words by Vernacchia

"Simulating" JS Events


I’ve been working on my Google Workspace Zoom Default Chrome Extension to implement a requested feature where users can use custom zoom values in addition to the predefined zoom values that Google provides in their application’s zoom dropdown.

I thought to myself, “I already simulate events. This should be easy.” So, I started to try and figure it out. If you want to skip ahead, feel free to go to the “Implementing with chrome.debugger section.

But first, let’s have a look at how to “simulate events.”

Mouse Events

Simulating mouse events is pretty simple. Or, is it?

Turns out there’s many, many different ways to do it. Let’s start by figuring out what events are dispatched when a user clicks the button. Try it out in the example below.

const button = document.getElementsByTagName("button")[0];

button.addEventListener("click", () => {
  console.log("click event");
});

button.addEventListener("mouseup", () => {
  console.log("mouseup event");
});

button.addEventListener("mousedown", () => {
  console.log("mousedown event");
});

Read-only

As you can see, there are three events that trigger when you click once (we’ll only handle a single click).

These are (and executed in the following order):

  • mousedown
  • mouseup
  • click

Well well, there’s three events that trigger. Fair enough.

What is interesting though is that click is at the end instead of the events instead of in the middle of the down and up events. Something to think about 😉

Let’s try to replicate this now using just JS.

Using .click()

The first option you’d probably reach for (and myself), is using the click() method that exists on an HTMLElement.

Let’s give it a shot and see how it works.

const button = document.getElementsByTagName("button")[0];
const dotClickBtn = document.getElementById("simulate-with-dot-click");

dotClickBtn.addEventListener("click", () => {
  button.click();
});

button.addEventListener("click", () => {
  console.log("click event");
});

button.addEventListener("mouseup", () => {
  console.log("mouseup event");
});

button.addEventListener("mousedown", () => {
  console.log("mousedown event");
});

Read-only

After trying that, do you see the problem with trying to use click() to simulate clicks with JS?

In case you didn’t, the problem is that when using the click() method, the only event that is triggered when simulating the click is the “click” event. We are missing out on the “mousedown” and “mouseup” events.

This may be fine for some things, but may not work for everything.

Dispatching Events

Let’s make this a bit more bulletproof. Instead of using the click() method, we can actually go about dispatching events on the element in question. To do this you use the EventTarget.dispatchEvent()

Something like below would dispatch a click event on the button.

const button = document.querySelector("button");
const event = new MouseEvent("click", {
    view: window,
    bubbles: true,
    cancelable: true,
    clientX: 0,
    clientY: 0,
    button: 0,
});
button.dispatchEvent(event);

Now, all we need to do is make into something that will execute all three events when a button is clicked. I’ve put it in the example below. Give it a shot.

const button = document.getElementsByTagName("button")[0];
const dotClickBtn = document.getElementById("simulate-with-events");

const simulateMouseEvent = (element, eventName) => {
  element.dispatchEvent(
    new MouseEvent(eventName, {
      view: window,
      bubbles: true,
      cancelable: true,
      clientX: 0,
      clientY: 0,
      button: 0
    })
  )
}

export const simulateClick = (element) => {
  simulateMouseEvent(element, "mousedown")
  simulateMouseEvent(element, "mouseup")
  simulateMouseEvent(element, "click")
}

dotClickBtn.addEventListener("click", () => {
  simulateClick(button)
});

button.addEventListener("click", () => {
  console.log("click event");
});

button.addEventListener("mouseup", () => {
  console.log("mouseup event");
});

button.addEventListener("mousedown", () => {
  console.log("mousedown event");
});

Read-only

🤯 BOOMMMMMM… Now we have finally simulated a “click” using JS. Finally 🔥

Gotchas (still…)

There’s still some potential gotchas when it comes to simulating these events.

This is due to the pesky isTrusted property on the events. TL;DR - some browsers / apps will only respond to trusted events. From MDN:

The isTrusted read-only property of the Event interface is a boolean value that is true when the event was generated by a user action, and false when the event was created or modified by a script or dispatched via EventTarget.dispatchEvent().

This property cannot be faked, so when something won’t happen due to this, you’re pretty much out of luck (aside from what I’ll show you below, which is a hack and only available to Chrome Extensions).

While this isn’t a problem (for the most part) when simulating Mouse Events, it does become a problem when trying to simulate Keyboard Events, which I’ll demonstrate in the next section.

Keyboard Events

Cool cool. Keyboard Events are going to be just as easy. You would’ve thought…

Let’s see what triggers a Keyboard Event.

const input = document.getElementById("simulate-keyboard-input");

input.addEventListener("keydown", (e) => {
  console.log("keydown event: " + e.key);
});

input.addEventListener("keyup", (e) => {
  console.log("keyup event: " + e.key);
});

input.addEventListener("keypress", (e) => {
  console.log("keypress event: " + e.key);
});

Read-only

Depending on what key you hit the following events will fire(in order):

  1. keydown
  2. keypress
  3. keyup

or, if hitting a key like “Shift”:

  1. keydown
  2. keyup

Now that we’ve gotten these events down, let’s try to simulate them. Good thing that we have KeyboardEvents that can be dispatched just like MouseEvents. Let’s give it a shot.

const input = document.getElementById("simulate-keyboard-input");
const btn = document.getElementById("simulate-keyboard-btn");

input.addEventListener("keydown", (e) => {
  console.log("keydown event: " + e.key);
});

input.addEventListener("keyup", (e) => {
  console.log("keyup event: " + e.key);
});

input.addEventListener("keypress", (e) => {
  console.log("keypress event: " + e.key);
});

btn.addEventListener('click', (e) => {
    input.dispatchEvent(
        new KeyboardEvent('keydown', {
            view: window,
            bubbles: true,
            cancelable: true,
            key: "e",
            keyCode: 69,
            code: "KeyE",
            which: 69,
        })
    )
    
    input.dispatchEvent(
        new KeyboardEvent('keypress', {
            view: window,
            bubbles: true,
            cancelable: true,
            key: "e",
            keyCode: 69,
            code: "KeyE",
            which: 69,
        })
    )
    
    input.dispatchEvent(
        new KeyboardEvent('keyup', {
            view: window,
            bubbles: true,
            cancelable: true,
            key: "e",
            keyCode: 69,
            code: "KeyE",
            which: 69,
        })
    )
})

Read-only

Notice anything interesting?? In case you were too fixated on the “Console” window, have a look at the input. Unfortunately, while the events did fire there is nothing in the input box.

Remember how I talked about the isTrusted property on the Event (see above)? Well, this is very much what is happening here. The browser is being smart enough to not let scripts insert values in to input boxes. Basically, it’s a security concern and browsers are right in doing this.

So, where do we go from here? I still want to simulate these events. Maybe we can change the input’s value using JS. Let’s give it a shot.

const input = document.getElementById("simulate-keyboard-input");
const btn = document.getElementById("simulate-keyboard-btn");

function insertChars(inputElement, string) {
  inputElement.value += string.charAt(0);

  setTimeout(function() {
    insertChars(inputElement, string.slice(1));
  }, 300);
}

btn.addEventListener('click', (e) => {
    insertChars(input, "test me");
})

input.addEventListener("keydown", (e) => {
  console.log("keydown event: " + e.key);
});

input.addEventListener("keyup", (e) => {
  console.log("keyup event: " + e.key);
});

input.addEventListener("keypress", (e) => {
  console.log("keypress event: " + e.key);
});

Read-only

Perfect. It looks like my script is typing in the box. Before you celebrate, what is happening in the “Console” window?

Turns out, nothing is happening. There are no events being triggered despite us having listeners on them. So, we are not “simulating” events.

Unfortunately, what we can do with MouseEvents is not possible with KeyboardEvents due to browser security issues. We can get pretty far in making it either:

  1. Dispatch the correct events
  2. OR… emulate typing

Trusted Keyboard Events with Chrome Debugger

So, if all that’s not possible, how am I going to implement the functionality in Google Workspace Zoom Default allowing my users to input custom zoom levels?

Let’s explore a “workaround” by using the Chrome Debugger available to Chrome Extensions.

🚨 This only works with Chrome Extensions that have access to the chrome.debugger API 🚨

It turns out that Chrome Extensions can request access to the chrome.debugger API.

Note: As I have to ask for an elevated set of permissions to make this work, I will be releasing a separate extension containing this functionality. I don’t want to force people into using something with which they’re not comfortable.

To start, this approach has the content script message the Background Service Worker (BGSW). The BGSW then attaches the debugger, generates the necessary commands, and detaches the debugger.

It’s relatively simple. It also shows a nice banner on your browser that shows what is happening (like it should).

The code looks something like below. I use Plasmo to implement this messaging, but it can be implemented via standard Message passing. Checkout the PR for more context on the changes.

// `req` is the request sent from the content script to the BGSW
const target = { tabId: req.tabId };

chrome.debugger.attach(target, "1.0");

chrome.debugger.sendCommand(target, "Input.insertText", {
    text: req.body.zoomValue, // this value is sent from the content script
});

// The two events below have to be used together. I don't know why... but they do...
chrome.debugger.sendCommand(target, "Input.dispatchKeyEvent", {
    type: "rawKeyDown",
    code: "Enter",
    key: "Enter",
    macCharCode: 13,
    nativeVirtualKeyCode: 13,
    text: "\r",
    unmodifiedText: "\r",
    windowsVirtualKeyCode: 13,
});
chrome.debugger.sendCommand(target, "Input.dispatchKeyEvent", {
    type: "char",
    text: "\r",
});

chrome.debugger.detach(target);

I’ll hopefully be releasing the new Chrome Extension with this functionality soon 🤞

Until next time...