DEV Community

Cover image for React + Vite: OpenAI ChatGPT Placeholder Chrome Extension
SeongKuk Han
SeongKuk Han

Posted on

React + Vite: OpenAI ChatGPT Placeholder Chrome Extension

Preview


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

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 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

Item Update Modal

I created a project using Vite, and I used MUI 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.

manifest.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"]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode
  • 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/

chrome.d.ts

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

namespace chrome {
  namespace custom {
    type PlaceholderListItem = {
      id: string;
      title: string;
      placeholder: string;
      active: boolean;
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

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. 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

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 };
Enter fullscreen mode Exit fullscreen mode

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

Save to the Chrome Storage Example

chrome.storage.local.set({
      placeholderList: JSON.stringify(placeholderList),
    });
Enter fullscreen mode Exit fullscreen mode

Retrieve from the Chrome Storage Example

chrome.storage.local
      .get(['placeholderList', 'available'])
      .then((result) => {
        // result.placeholderList or result.available
      });
Enter fullscreen mode Exit fullscreen mode

content.ts

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();
Enter fullscreen mode Exit fullscreen mode
const globalData: {
  placeholderList: chrome.custom.PlaceholderListItem[];
  available: boolean;
} = {
  placeholderList: [],
  available: false,
};
Enter fullscreen mode Exit fullscreen mode

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

Promise.all([loadStorageData(), loadHandlers(), startChatDetectionLoop()]);
Enter fullscreen mode Exit fullscreen mode

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

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

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'];
Enter fullscreen mode Exit fullscreen mode

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.

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;
          }
        }
      }
    });
Enter fullscreen mode Exit fullscreen mode

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.

   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();
      }
Enter fullscreen mode Exit fullscreen mode

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

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`,
      },
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

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.
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!

Top comments (0)