// ==UserScript==
// @name Highlight Keywords
// @namespace HUSEIDON
// @version 1.0
// @description Highlights predefined keywords with Ctrl+Right-Click options to manage keywords.
// @icon https://raw.githubusercontent.com/huseidon/Highlight-Keywords-Userscript/refs/heads/huseidon/img/icon.svg
// @match *://*/*
// @grant GM_registerMenuCommand
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_listValues
// @grant GM_addStyle
// ==/UserScript==
(async function() {
'use strict';
// Retrieve the keywords from storage
async function getStoredKeywords() {
return await GM_getValue("keywords", []);
}
// Save keywords to storage
async function setStoredKeywords(keywords) {
await GM_setValue("keywords", keywords);
}
// Retrieve the highlight color from storage
async function getHighlightColor() {
return await GM_getValue("highlightColor", "#5ae31b");
}
// Save the highlight color to storage
async function setHighlightColor(color) {
await GM_setValue("highlightColor", color);
}
// Function to highlight keywords
async function THmo_doHighlight(el) {
let keywords = await getStoredKeywords();
let highlightColor = await getHighlightColor();
if (!keywords.length) return; // No keywords to highlight if empty
const rQuantifiers = /[-\/\\^$*+?.()|[\]{}]/g;
const keywordPattern = keywords.map(k => k.replace(rQuantifiers, '\\$&')).join('|');
const pat = new RegExp('(' + keywordPattern + ')', 'gi');
const span = document.createElement('span');
const snapElements = document.evaluate(
'.//text()[normalize-space() != "" ' +
'and not(ancestor::style) ' +
'and not(ancestor::script) ' +
'and not(ancestor::textarea) ' +
'and not(ancestor::code) ' +
'and not(ancestor::pre)]',
el, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null
);
if (!snapElements.snapshotItem(0)) return; // End execution if no text found
for (let i = 0, len = snapElements.snapshotLength; i < len; i++) {
const node = snapElements.snapshotItem(i);
if (pat.test(node.nodeValue)) {
if (node.className !== "THmo" && node.parentNode.className !== "THmo") {
const sp = span.cloneNode(true);
sp.innerHTML = node.nodeValue.replace(pat, `<span style="color: ${highlightColor}; font-weight: bold;" class="THmo">$1</span>`);
node.parentNode.replaceChild(sp, node);
}
}
}
}
// MutationObserver to catch dynamically added content
const THmo_MutOb = window.MutationObserver || window.WebKitMutationObserver;
if (THmo_MutOb) {
const observer = new THmo_MutOb(async function(mutationSet) {
for (let mutation of mutationSet) {
for (let i = 0; i < mutation.addedNodes.length; i++) {
if (mutation.addedNodes[i].nodeType === 1) {
await THmo_doHighlight(mutation.addedNodes[i]);
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
// Function to create a custom context menu
function createContextMenu(event, options) {
// Remove any existing menu
const oldMenu = document.getElementById("custom-context-menu");
if (oldMenu) {
oldMenu.remove();
}
// Create the new menu
const menu = document.createElement("div");
menu.id = "custom-context-menu";
menu.style.position = "absolute";
// Calculate the position ensuring the menu stays within viewport bounds
let menuTop = event.clientY + window.scrollY;
let menuLeft = event.clientX + window.scrollX;
// Adjust if menu goes beyond viewport bounds
if (menuTop + 150 > window.innerHeight + window.scrollY) { // Assuming the menu height is 150px
menuTop = window.innerHeight + window.scrollY - 150;
}
if (menuLeft + 150 > window.innerWidth + window.scrollX) { // Assuming the menu width is 150px
menuLeft = window.innerWidth + window.scrollX - 150;
}
menu.style.top = `${menuTop}px`;
menu.style.left = `${menuLeft}px`;
menu.style.backgroundColor = "#fff";
menu.style.border = "1px solid #ccc";
menu.style.zIndex = "1000";
menu.style.padding = "10px";
menu.style.boxShadow = "2px 2px 10px rgba(0,0,0,0.5)";
menu.style.fontSize = "14px";
// Add each option to the menu
options.forEach(option => {
const optionElement = document.createElement("div");
optionElement.textContent = option.label;
optionElement.style.padding = "5px";
optionElement.style.cursor = "pointer";
optionElement.onclick = option.action;
optionElement.onmouseover = () => optionElement.style.backgroundColor = "#eee";
optionElement.onmouseout = () => optionElement.style.backgroundColor = "#fff";
menu.appendChild(optionElement);
});
// Append the menu to the document
document.body.appendChild(menu);
// Remove menu on outside click
document.addEventListener("click", () => {
menu.remove();
}, { once: true });
}
// Ctrl + Right Click to show the custom context menu
document.addEventListener("contextmenu", async (event) => {
if (event.ctrlKey) { // Check if Ctrl key is pressed
event.preventDefault(); // Prevent default right-click menu
const selectedText = window.getSelection().toString().trim();
if (selectedText) {
createContextMenu(event, [
{
label: "Add Selected Text to Keywords",
action: async () => {
let keywords = await getStoredKeywords();
if (!keywords.includes(selectedText)) {
keywords.push(selectedText);
await setStoredKeywords(keywords);
alert(`Added "${selectedText}" to keywords.`);
await THmo_doHighlight(document.body);
} else {
alert(`"${selectedText}" is already a keyword.`);
}
}
},
{
label: "Remove Selected Text from Keywords",
action: async () => {
let keywords = await getStoredKeywords();
const index = keywords.indexOf(selectedText);
if (index > -1) {
keywords.splice(index, 1);
await setStoredKeywords(keywords);
alert(`Removed "${selectedText}" from keywords.`);
await THmo_doHighlight(document.body);
} else {
alert(`"${selectedText}" is not in the keyword list.`);
}
}
}
]);
}
}
});
// Registering the original menu commands
GM_registerMenuCommand("List Keywords", async () => {
let keywords = await getStoredKeywords();
alert(keywords.length > 0 ? `Keywords:\n${keywords.join("\n")}` : "No keywords found.");
});
GM_registerMenuCommand("Add Keyword", async () => {
let keywords = await getStoredKeywords();
let newKeyword = prompt("Enter a new keyword to add:");
if (newKeyword) {
newKeyword = newKeyword.trim();
if (!keywords.includes(newKeyword)) {
keywords.push(newKeyword);
await setStoredKeywords(keywords);
alert(`Keyword '${newKeyword}' added!`);
await THmo_doHighlight(document.body);
} else {
alert(`Keyword '${newKeyword}' already exists.`);
}
}
});
GM_registerMenuCommand("Remove Keyword", async () => {
let keywords = await getStoredKeywords();
let removeKeyword = prompt("Enter the keyword to remove:");
if (removeKeyword) {
removeKeyword = removeKeyword.trim();
const index = keywords.indexOf(removeKeyword);
if (index > -1) {
keywords.splice(index, 1);
await setStoredKeywords(keywords);
alert(`Keyword '${removeKeyword}' removed!`);
await THmo_doHighlight(document.body);
} else {
alert(`Keyword '${removeKeyword}' not found.`);
}
}
});
GM_registerMenuCommand("Export Keywords", async () => {
let keywords = await getStoredKeywords();
if (keywords.length === 0) {
alert("No keywords found to export.");
return;
}
const blob = new Blob([keywords.join("\n")], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "keywords.txt";
link.click();
URL.revokeObjectURL(url);
});
GM_registerMenuCommand("Import Keywords", async () => {
const input = document.createElement("input");
input.type = "file";
input.accept = ".txt";
input.addEventListener("change", async (event) => {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async function(e) {
const text = e.target.result;
const newKeywords = text.split("\n").map(k => k.trim()).filter(k => k);
let storedKeywords = await getStoredKeywords();
const combinedKeywords = [...new Set([...storedKeywords, ...newKeywords])];
await setStoredKeywords(combinedKeywords);
alert(`Imported ${newKeywords.length} keywords.`);
await THmo_doHighlight(document.body);
};
reader.readAsText(file);
});
input.click();
});
GM_registerMenuCommand("Remove All Keywords", async () => {
if (confirm("Are you sure you want to remove all keywords?")) {
await setStoredKeywords([]);
alert("All keywords removed.");
await THmo_doHighlight(document.body);
}
});
GM_registerMenuCommand("Change Highlight Color", async () => {
let currentColor = await getHighlightColor();
let newColor = prompt(`Enter a new highlight color (current: ${currentColor})`, currentColor);
if (newColor) {
newColor = newColor.trim();
await setHighlightColor(newColor);
alert(`Highlight color changed to ${newColor}!`);
}
});
// Initial highlighting run
await THmo_doHighlight(document.body);
// Custom CSS for context menu
GM_addStyle(`
#custom-context-menu {
font-family: Arial, sans-serif;
border-radius: 5px;
}
#custom-context-menu div:hover {
background-color: #f0f0f0;
}
`);
})();