Highlight Keywords

Highlights predefined keywords with Ctrl+Right-Click options to manage keywords.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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;
    }
  `);

})();