React + Vite: OpenAI ChatGPT Placeholder Chrome Extension
Tags: react, typescript, extensions, webdev
Published: 2023-07-16 23:33:07

# Preview

<video width="320" height="240" controls>
  <source src="https://github.com/hsk-kr/openai-chatgpt-placeholder/assets/57546034/61b73192-10bf-42e2-8fb6-46fa48e1b44d" type="video/mp4">
</video>

---

# Why I decided to make the extension

These days, I have been using ChatGPT for language learning purposes. I wanted to generate a result in the same way, this sort of situation happened at some point. In order to get a result that I expected. I needed to type the same sentence on every prompt.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/zsrw85zleotd8dthais8.png)

I thought that if there was a program that could automatically insert text, it would be really useful for me, therefore I decided to develop a chrome extension.

---

# How it works

It consists of two parts: UI and content_scripts. 

> [Content scripts](https://developer.chrome.com/docs/extensions/mv3/content_scripts/) are files that run in the context of web pages. By using the standard Document Object Model (DOM), they are able to read details of the web pages the browser visits, make changes to them, and pass information to their parent extension.

## UI

![Item List](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/mf8ne26xz604i6dshl5y.png)

![Item Update Modal](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/2tqdi8r7hs3an0tf9jta.png)

I created a project using `Vite`, and I used [MUI](https://mui.com/) to design the extension. 

A user can do the followings:

- Add a new item.
- Update an item.
- Delete an item.
- Toggle an item to use or not.

An item has `title` and `placeholder`.
If the title of the active chat on ChatGPT includes the `title`, the extension will insert the corresponding `placeholder` text into the textarea element on the web page.

## Content Script

There is a loop that executes every 500ms.

1) Find an item that has a title that is a part of the active chat's title.
2) Get the text from the textarea element on the web page.
3) If the text is empty, insert the placeholder text of the matched item into the textarea element.

---

# Source Code

I am going to share a part of important code but not all. You can check it up on [my repository](https://github.com/hsk-kr/openai-chatgpt-placeholder).

## manifest.json

```json
{
  "name": "OpenAI ChatGPT Placeholder",
  "description": "OpenAI ChatGPT Placeholder. This extension automatically inserts placeholder text for you!",
  "version": "1.0.0",
  "manifest_version": 3,
  "action": {
    "default_popup": "index.html",
    "default_title": "Open the popup"
  },
  "icons": {
    "16": "icon16.png",
    "32": "icon32.png",
    "48": "icon32.png",
    "128": "icon128.png"
  },
  "permissions": ["storage"],
  "content_scripts": [
    {
      "matches": ["https://chat.openai.com/*"],
      "js": ["./assets/content.js"]
    }
  ]
}
```

- `name`: The name property (required) is a short, plain text string (maximum of 45 characters) that identifies the extension.
- `version`: One to four dot-separated integers identifying the version of this extension.
- `manifest_version`: An integer specifying the version of the manifest file format your package requires. This key is required. 
- `action.default_popup`: the html code showing when the popup is open.
- `action.default_title`: OpenAI ChatGPT Placeholder
- `icons`: icons that represent the extension or theme.
- `permissions`: contain items from a list of known strings (such as "geolocation"). This extension uses a `storage` permission to store data in the storage to share between UI and content script.
- `content_scripts.matches`: Specifies which pages this content script will be injected into. I appended 
- `content_scripts.js`: The list of JavaScript files to be injected into matching pages.

Reference: [https://developer.chrome.com/docs/extensions/mv3/manifest/](https://developer.chrome.com/docs/extensions/mv3/manifest/)

## chrome.d.ts

```typescript
/* eslint-disable @typescript-eslint/no-unused-vars */
/// <reference types="chrome" />

namespace chrome {
  namespace custom {
    type PlaceholderListItem = {
      id: string;
      title: string;
      placeholder: string;
      active: boolean;
    };
  }
}
```

Actually, I find it a bit awkward to explicitly define the type in the chrome package. Initially, my plan was to share data through [message passing](https://developer.chrome.com/docs/extensions/mv3/messaging/). However, during the development process, I switched to using the `chrome.storage` for data sharing. 

I left the explicit type declaration in the example as an example that extends a third-party type.

## globalContext.ts

```typescript
import {
  Dispatch,
  ReactNode,
  SetStateAction,
  useCallback,
  useEffect,
  useState,
} from 'react';
import { flushSync } from 'react-dom';
import { v4 as uuidv4 } from 'uuid';
import { createContext } from 'react';

interface GlobalContextType {
  placeholderList: chrome.custom.PlaceholderListItem[];
  setPlaceholderList: Dispatch<
    SetStateAction<chrome.custom.PlaceholderListItem[]>
  >;
  addNewPlaceholderListItem: VoidFunction;
  removePlaceholderListItem: (id: string) => void;
  togglePlaceholderListItemActive: (id: string, active: boolean) => void;
  loading: boolean;
  available: boolean;
  setAvailable: Dispatch<SetStateAction<boolean>>;
}

const GlobalContext = createContext<GlobalContextType>({} as GlobalContextType);

const GlobalContextProvider = ({ children }: { children: ReactNode }) => {
  const [loading, setLoading] = useState(true);
  const [placeholderList, setPlaceholderList] = useState<
    chrome.custom.PlaceholderListItem[]
  >([]);
  const [available, setAvailable] = useState<boolean>(false);

  useEffect(() => {
    if (!chrome?.storage?.local || loading) return;

    // eslint-disable-next-line @typescript-eslint/no-floating-promises
    chrome.storage.local.set({
      placeholderList: JSON.stringify(placeholderList),
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [placeholderList]);

  useEffect(() => {
    if (!chrome?.storage?.local || loading) return;

    // eslint-disable-next-line @typescript-eslint/no-floating-promises
    chrome.storage.local.set({ available: JSON.stringify(available) });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [available]);

  useEffect(() => {
    if (!chrome?.storage?.local) return;

    // eslint-disable-next-line @typescript-eslint/no-floating-promises
    chrome.storage.local
      .get(['placeholderList', 'available'])
      .then((result) => {
        try {
          flushSync(() => {
            // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
            setPlaceholderList(JSON.parse(result.placeholderList));
            // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
            setAvailable(result.available === 'true' || false);
          });
        } catch (e) {
          flushSync(() => {
            setPlaceholderList([]);
          });
          console.error(e);
        } finally {
          setLoading(false);
        }
      });
  }, []);

  const addNewPlaceholderListItem = useCallback(() => {
    setPlaceholderList((prevPlaceholderList) =>
      prevPlaceholderList.concat({
        id: uuidv4(),
        title: 'New Item',
        placeholder: '',
        active: false,
      })
    );
  }, []);

  const removePlaceholderListItem = useCallback((id: string) => {
    setPlaceholderList((prevPlaceholderList) =>
      prevPlaceholderList.filter((item) => item.id !== id)
    );
  }, []);

  const togglePlaceholderListItemActive = useCallback(
    (id: string, active: boolean) => {
      setPlaceholderList((prevPlaceholderList) =>
        prevPlaceholderList.map((item) => {
          if (item.id === id) {
            item.active = active;
          }

          return item;
        })
      );
    },
    []
  );

  const value = {
    placeholderList,
    setPlaceholderList,
    addNewPlaceholderListItem,
    removePlaceholderListItem,
    togglePlaceholderListItemActive,
    loading,
    available,
    setAvailable,
  };

  return (
    <GlobalContext.Provider value={value}>{children}</GlobalContext.Provider>
  );
};

export { GlobalContext, GlobalContextProvider };
```

When the data is changing, `useEffect` catches the change and reflects it in the storage.

**Save to the Chrome Storage Example**
```typescript
chrome.storage.local.set({
      placeholderList: JSON.stringify(placeholderList),
    });
```

**Retrieve from the Chrome Storage Example**
```typescript
chrome.storage.local
      .get(['placeholderList', 'available'])
      .then((result) => {
        // result.placeholderList or result.available
      });
```

## content.ts

```typescript
const INTERVAL = 500;
const globalData: {
  placeholderList: chrome.custom.PlaceholderListItem[];
  available: boolean;
} = {
  placeholderList: [],
  available: false,
};

function isTextboxEmpty() {
  const textareaElmt = document.querySelector('textarea');
  return !textareaElmt || textareaElmt.value.length === 0;
}

function scrollToTheBottom() {
  const textareaElmt = document.querySelector('textarea');
  if (!textareaElmt) {
    console.error(`textarea can't be found.`);
    return;
  }

  textareaElmt.scrollTop = textareaElmt.scrollHeight;
}

function focusOnTextbox() {
  const textareaElmt = document.querySelector('textarea');
  if (!textareaElmt) {
    console.error(`textarea can't be found.`);
    return;
  }

  textareaElmt.focus();
}

function setTextbox(value: string) {
  const textareaElmt = document.querySelector('textarea');
  if (!textareaElmt) {
    console.error(`textarea can't be found.`);
    return;
  }

  textareaElmt.value = value;
  // It fires the height resizing event of the input element, the value doesn't matter.
  textareaElmt.style.height = '1px';
}

function isResponseGenerating() {
  return document.querySelector('.result-streaming') !== null;
}

function getActiveChatTitle({ lowercase }: { lowercase?: boolean }) {
  let activeTitle = '';

  try {
    const titleElmtList = document.querySelectorAll<HTMLLIElement>('nav li');

    for (const titleElmt of titleElmtList) {
      // If the length of buttons is greater than zero, it considers it active.
      if (titleElmt.querySelectorAll('button').length > 0) {
        activeTitle = titleElmt.innerText;
        break;
      }
    }
  } catch (e) {
    console.error(e);
  }

  return lowercase ? activeTitle.toLocaleLowerCase() : activeTitle;
}

function init() {
  const startChatDetectionLoop = () => {
    setInterval(() => {
      if (!globalData.available || !isTextboxEmpty() || isResponseGenerating())
        return;

      const title = getActiveChatTitle({
        lowercase: true,
      });
      if (!title) return;

      for (const placeholder of globalData.placeholderList) {
        if (
          !placeholder.active ||
          !title.includes(placeholder.title.toLocaleLowerCase())
        ) {
          continue;
        }

        setTextbox(placeholder.placeholder);
        scrollToTheBottom();
        focusOnTextbox();
      }
    }, INTERVAL);
  };

  const loadHandlers = () => {
    chrome.storage.onChanged.addListener((changes) => {
      for (const [key, { newValue }] of Object.entries(changes)) {
        switch (key) {
          case 'placeholderList': {
            globalData.placeholderList = JSON.parse(
              newValue as string
            ) as chrome.custom.PlaceholderListItem[];
            break;
          }
          case 'available': {
            globalData.available = JSON.parse(newValue as string) as boolean;
            break;
          }
        }
      }
    });
  };

  const loadStorageData = async () => {
    const dataMap = await chrome.storage.local.get([
      'placeholderList',
      'available',
    ]);
    globalData.placeholderList = JSON.parse(
      dataMap['placeholderList'] as string
    ) as (typeof globalData)['placeholderList'];
    globalData.available = JSON.parse(
      dataMap['available'] as string
    ) as (typeof globalData)['available'];
  };

  // eslint-disable-next-line @typescript-eslint/no-floating-promises
  Promise.all([loadStorageData(), loadHandlers(), startChatDetectionLoop()]);
}

init();
```

```typescript
const globalData: {
  placeholderList: chrome.custom.PlaceholderListItem[];
  available: boolean;
} = {
  placeholderList: [],
  available: false,
};
```

Since all the code is written in the same file, I defined the data at the top of the file.

```typescript
Promise.all([loadStorageData(), loadHandlers(), startChatDetectionLoop()]);
```

There are three functions that the main(init) function executes.

**loadStorageData**: Retrieve data from the chrome Storage and assign the value to the `globalData`.

```typescript
const dataMap = await chrome.storage.local.get([
      'placeholderList',
      'available',
    ]);
    globalData.placeholderList = JSON.parse(
      dataMap['placeholderList'] as string
    ) as (typeof globalData)['placeholderList'];
    globalData.available = JSON.parse(
      dataMap['available'] as string
    ) as (typeof globalData)['available'];
```

**loadHandler**: You can detect changes in Chrome Storage using the event `chrome.storage.onChanged`. The `loadHandler`  function registers the event and reflects it in the variable `globalData`.

```typescript
chrome.storage.onChanged.addListener((changes) => {
      for (const [key, { newValue }] of Object.entries(changes)) {
        switch (key) {
          case 'placeholderList': {
            globalData.placeholderList = JSON.parse(
              newValue as string
            ) as chrome.custom.PlaceholderListItem[];
            break;
          }
          case 'available': {
            globalData.available = JSON.parse(newValue as string) as boolean;
            break;
          }
        }
      }
    });
```

**startChatDetectionLoop**: The function executes the main logic every 500ms, which inserts a placeholder text into the textarea if the active chat matches one of the items added by the user.

```typescript
   if (!globalData.available || !isTextboxEmpty() || isResponseGenerating())
        return;

      const title = getActiveChatTitle({
        lowercase: true,
      });
      if (!title) return;

      for (const placeholder of globalData.placeholderList) {
        if (
          !placeholder.active ||
          !title.includes(placeholder.title.toLocaleLowerCase())
        ) {
          continue;
        }

        setTextbox(placeholder.placeholder);
        scrollToTheBottom();
        focusOnTextbox();
      }
```

I found that when `ChatGPT` is generating the answer, I am unable to write text in the textarea. An `isResponseGenerating` function is used to determine whether the response is currently being generated or not.

## vite.config.ts

```typescript
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  build: {
    rollupOptions: {
      input: {
        main: "./index.html",
        content: "./src/content/content.ts",
      },
      output: {
        entryFileNames: `assets/[name].js`,
      },
    },
  },
});
```

`content.ts` isn't built without additional configuration. It starts from `index.html` and it doesn't build `content.ts` file since the file isn't part of the `App` code.

I needed to set multiple entry points and I used the `rollupOptions` option in the `vite.config.ts` file. There are two inputs `main` and `content`. You can find more details about the `rollupOptions` [here].(https://rollupjs.org/configuration-options/).

---

# Wrap Up

You can check all the code in the [Github Repository](https://github.com/hsk-kr/openai-chatgpt-placeholder).
As I keep maintaining the code, the source code in the repository can be different from the code here.

Creating a chrome extension wasn't as tough as I expected. I absolutely enjoyed it. 

The chrome extension is currently being reviewed by the Chrome Web Store, but I'm not sure if it will pass successfully. Even if it is not published in the store, I'm already very satisfied with using the extension myself. It's really useful for me.

Thank you for reading the post and I hope you found it helpful.

Happy Coding!