Skip to content

Commit

Permalink
feat: Improve button identification and event handling with detailed …
Browse files Browse the repository at this point in the history
…logging and robust CSS selectors
  • Loading branch information
Symonovskyi committed Sep 13, 2024
1 parent b3b7813 commit d5f9c0c
Show file tree
Hide file tree
Showing 3 changed files with 223 additions and 70 deletions.
63 changes: 43 additions & 20 deletions js/background.js
Original file line number Diff line number Diff line change
@@ -1,45 +1,68 @@
// Global variable that keeps track of the extension state (enabled/disabled)
// Keeps track of the extension state (enabled/disabled).
let extensionEnabled = true;

// Function to be executed when the background script is loaded
/**
* Handles installation or update events for the extension.
* It sets the initial state and icon when the extension is installed or updated.
*/
chrome.runtime.onInstalled.addListener(() => {
// Set the icon when installing the extension
updateIcon();
});

// Handler for clicking the extension icon in the browser bar
/**
* Toggles the extension's state (enabled/disabled) when the action button is clicked.
* Updates the icon and notifies the content script about the state change.
*
* @param {Tab} tab - The current active tab when the extension icon is clicked.
*/
chrome.action.onClicked.addListener((tab) => {
// Invert extension state
extensionEnabled = !extensionEnabled;
// Update the icon and send a message to the content script
updateIcon().then(() => {
sendMessageToContentScript(tab);
}).catch(error => {
console.error('Error updating the icon:', error);
});

updateIcon()
.then(() => {
sendMessageToContentScript(tab);
})
.catch(error => {
console.error('Error updating the icon:', error);
});
});

// Function for sending a message to the content script
/**
* Sends the current state of the extension to the content script.
* Only sends messages if the tab URL contains "chat.openai.com" or "chatgpt.com".
*
* @param {Tab} tab - The current tab where the content script will receive the message.
*/
function sendMessageToContentScript(tab) {
if (tab.url && (tab.url.includes("chat.openai.com") || tab.url.includes("chatgpt.com"))) {
chrome.tabs.sendMessage(tab.id, {enabled: extensionEnabled});
chrome.tabs.sendMessage(tab.id, { enabled: extensionEnabled });
}
}

// Tab update handler
chrome.tabs.onUpdated.addListener(function(tabId, changeInfo, tab) {
/**
* Injects the content script into the page when the tab is updated.
* Only injects if the tab is on a supported domain (e.g., "chat.openai.com").
*
* @param {number} tabId - The ID of the tab being updated.
* @param {object} changeInfo - Information about the tab's state.
* @param {Tab} tab - The tab object for the updated tab.
*/
chrome.tabs.onUpdated.addListener(function (tabId, changeInfo, tab) {
if (changeInfo.status === 'complete' && tab.url && (tab.url.includes("chat.openai.com") || tab.url.includes("chatgpt.com"))) {
// Injecting content script if the extension is enabled
if (extensionEnabled) {
chrome.scripting.executeScript({
target: {tabId: tabId},
target: { tabId: tabId },
files: ["js/content.js"]
});
}
}
});

// Function for updating the extension icon
/**
* Updates the extension's icon based on its current state (enabled or disabled).
*
* @returns {Promise} - Resolves when the icon is updated or rejects if an error occurs.
*/
function updateIcon() {
return new Promise((resolve, reject) => {
if (!chrome.action) {
Expand All @@ -48,11 +71,11 @@ function updateIcon() {
return;
}

// Define the path to the icon depending on the extension state
// Determine the icon based on the state of the extension.
const iconSuffix = extensionEnabled ? "" : "_gray";
const iconPath = size => `../icons/icon${size}${iconSuffix}.png`;

// Set icon
// Update the extension's icon.
chrome.action.setIcon({
path: {
"16": iconPath(16),
Expand Down
228 changes: 179 additions & 49 deletions js/content.js
Original file line number Diff line number Diff line change
@@ -1,74 +1,204 @@
(function () {
// Check if this script has been executed before to avoid reinitialization
// Prevents script from running multiple times.
if (window.hasRun) {
return;
}
window.hasRun = true;

// Function for inserting a line break into a text field
function insertLineBreak(textarea) {
if (!textarea) return;
/**
* Selectors for identifying the "New Message" button.
* This covers various layouts or attributes of the button.
*/
const newMessageButtonSelectors = [
"button[data-testid='send-button']", // By 'data-testid' attribute
"button.mb-1.me-1.bg-black.text-white.rounded-full", // By multiple CSS classes
"div.flex.items-end button:last-child", // Last button in a flex container
"button > svg.icon-2xl", // Button with an SVG icon
"button[type='button'][data-testid='send-button']", // By type and 'data-testid' attributes
"button[data-testid='send-button'] svg.icon-2xl", // SVG icon inside the button
"button.bg-black.text-white.rounded-full", // Button by classes
"button[aria-label][data-testid='send-button']", // By aria-label and data-testid attributes
"button svg[width='32'][height='32'][viewBox='0 0 32 32']" // Specific SVG inside the button
];

// Get the current cursor position
const cursorPos = textarea.selectionStart;
const text = textarea.value;
/**
* Selectors for identifying the "Edit Message" button.
* Like `newMessageButtonSelectors`, it covers different possible layouts.
*/
const editMessageButtonSelectors = [
"button.btn.btn-primary", // By button's primary class
"button[as='button'].btn-primary", // By attribute and class
"button.btn.relative.btn-primary", // Relative positioning and primary class
"div.flex.justify-end > button.btn-primary", // Button inside a flex container
"button.btn-primary > div.flex.items-center.justify-center", // Button containing a flex div
"button[as='button'].btn.relative.btn-primary", // Attribute and classes combined
"div.flex.justify-end > button:last-child", // Last button in the container
"button.btn-primary[as='button']", // Primary class with specific attribute
"button.btn-primary[style*='background-color']", // Button styled by background color
"button.btn.btn-primary.relative" // Button by class names
];

// Insert a new line at the cursor position
textarea.value = `${text.slice(0, cursorPos)}\n${text.slice(cursorPos)}`;
/**
* Inserts a line break into a text field or contenteditable element.
* @param {HTMLElement} inputField - The active element for inserting the line break.
*/
function insertLineBreak(inputField) {
if (!inputField) return;

// Update the cursor position to be after the newly inserted line break
textarea.selectionStart = textarea.selectionEnd = cursorPos + 1;
// Handle textarea and input elements
if (inputField.tagName.toLowerCase() === 'textarea' || inputField.tagName.toLowerCase() === 'input') {
console.debug('Inserting a line break into an input or textarea.');

// Create and dispatch an 'input' event to ensure any related UI updates are triggered
const event = new Event('input', { bubbles: true });
textarea.dispatchEvent(event);
// Insert the line break at the cursor position
const cursorPos = inputField.selectionStart;
const text = inputField.value;

inputField.value = `${text.slice(0, cursorPos)}\n${text.slice(cursorPos)}`;
inputField.selectionStart = inputField.selectionEnd = cursorPos + 1;

// Trigger input event to ensure UI updates
const event = new Event('input', { bubbles: true });
inputField.dispatchEvent(event);

} else if (inputField.isContentEditable) {
// Handle contenteditable elements
console.debug('Inserting a line break into a contenteditable element.');

const selection = window.getSelection();
if (!selection.rangeCount) return;

const range = selection.getRangeAt(0);
const br = document.createElement("br");

// Insert the <br> tag at the cursor position
range.deleteContents();
range.insertNode(br);
range.setStartAfter(br);
range.setEndAfter(br);

// Update the cursor position
selection.removeAllRanges();
selection.addRange(range);
}
}

/**
* Searches for the closest "Send" or "Edit" button in parent containers.
* @param {HTMLElement} activeElement - The currently active element.
* @returns {HTMLElement|null} - The matched button or null if none is found.
*/
function findClosestButton(activeElement) {
let parent = activeElement;
const maxSearchLevels = 5;
let currentLevel = 0;

let matchingButton = null;
let buttonType = ''; // 'EditMessage' or 'NewMessage'
let totalMatches = 0;

console.debug('Searching for the closest button in parent elements.');

// Traverse up the DOM tree to find a button
while (parent && currentLevel < maxSearchLevels) {
currentLevel++;
console.debug(`Searching at level ${currentLevel}.`);

// Check for "Edit Message" buttons
editMessageButtonSelectors.forEach((selector, index) => {
const button = parent.querySelector(selector);
if (button) {
console.debug(`Found "Edit Message" button using selector ${selector}.`);
matchingButton = button;
buttonType = 'EditMessage';
totalMatches++;
}
});

// Check for "New Message" buttons
newMessageButtonSelectors.forEach((selector, index) => {
const button = parent.querySelector(selector);
if (button) {
console.debug(`Found "New Message" button using selector ${selector}.`);
matchingButton = button;
buttonType = 'NewMessage';
totalMatches++;
}
});

if (matchingButton) {
console.debug(`Found button with highest matches (${totalMatches}). Type: ${buttonType}`);
return matchingButton;
}

parent = parent.parentElement; // Move up to the parent element
}

console.warn('Button not found after checking parent elements.');
return null;
}

// Variable tracking the extension state (enabled/disabled)
let isEnabled = true;
/**
* Simulates a mouse click on the button by dispatching mouse events.
* @param {HTMLElement} button - The button element to be clicked.
*/
function simulateMouseClick(button) {
if (button) {
console.debug('Simulating mouse click on the button.');

const mousedownEvent = new MouseEvent('mousedown', {
bubbles: true,
cancelable: true,
view: window
});
const mouseupEvent = new MouseEvent('mouseup', {
bubbles: true,
cancelable: true,
view: window
});
const clickEvent = new MouseEvent('click', {
bubbles: true,
cancelable: true,
view: window
});

// Handler of messages from the background script
chrome.runtime.onMessage.addListener(function (request) {
if (request.enabled !== undefined) {
isEnabled = request.enabled;
// Dispatch events to simulate the mouse click
button.dispatchEvent(mousedownEvent);
button.dispatchEvent(mouseupEvent);
button.dispatchEvent(clickEvent);

console.debug("Mouse click simulation completed.");
} else {
console.warn("Button for click simulation not found.");
}
});
}

// Keystroke handler
/**
* Keydown event handler to intercept Enter and Ctrl+Enter key presses.
* - Inserts a line break if Enter is pressed without Ctrl/Alt.
* - Sends the message if Ctrl+Enter is pressed.
*/
document.addEventListener('keydown', function (event) {
if (!isEnabled) return;
if (event.key === 'Enter') {
const activeElement = document.activeElement;

if (event.key === 'Enter' && (event.target.tagName.toLowerCase() === 'input' || event.target.tagName.toLowerCase() === 'textarea')) {
event.stopImmediatePropagation();
// Insert a line break on Enter without Ctrl or Alt
if (!event.ctrlKey && !event.altKey) {
event.preventDefault();
console.debug("Inserting line break into active element.");
insertLineBreak(activeElement);
}

// If the Enter key is pressed and the extension is enabled
// Send the message on Ctrl+Enter
if (event.ctrlKey) {
event.preventDefault();
console.debug("Searching for the send button with Ctrl+Enter.");

// Search for the container with class .w-full or .flex-col, responsible for message editing
let container = event.target.closest('.w-full') || event.target.closest('.flex-col');
let primaryButtonInSameBlock = container ? container.querySelector('.btn-primary') : null;

// Search for the button to send a new message if the edit button is not found
let buttonToClick = primaryButtonInSameBlock
? primaryButtonInSameBlock
: container ? container.querySelector('button[data-testid="fruitjuice-send-button"]') : null;

// If the specific button is not found, fallback to the last svg button
if (!buttonToClick && container) {
let svgButtons = container.querySelectorAll('button:has(svg)');
if (svgButtons.length > 0) {
buttonToClick = svgButtons[svgButtons.length - 1];
}
const button = findClosestButton(activeElement);
if (button) {
simulateMouseClick(button);
} else {
console.warn("Message send button not found.");
}

if (buttonToClick) {
buttonToClick.click();
}
} else if (!event.altKey) {
event.preventDefault();
// Insert a line break in a text field
insertLineBreak(event.target);
}
}
}, true);
Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "__MSG_extensionName__",
"description": "__MSG_extensionDescription__",
"default_locale": "en",
"version": "2024.05.21",
"version": "2024.09.13",
"permissions": [
"activeTab",
"scripting"
Expand Down

0 comments on commit d5f9c0c

Please sign in to comment.