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.
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
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"]
}
]
}
-
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 astorage
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;
};
}
}
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 };
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),
});
Retrieve from the Chrome Storage Example
chrome.storage.local
.get(['placeholderList', 'available'])
.then((result) => {
// result.placeholderList or result.available
});
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();
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.
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
.
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
.
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.
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
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.
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)