// ==UserScript==
// @name WordGlance - Dictionary & Translation
// @namespace https://github.com/ShrekBytes
// @version 2.4.0
// @description Show instant dictionary definitions and translations for selected text with any language support
// @author ShrekBytes
// @icon https://github.com/ShrekBytes/WordGlance/raw/main/icon_128.png
// @match *://*/*
// @noframes
// @run-at document-end
// @license GPL-3.0
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @homepage https://github.com/ShrekBytes/WordGlance
// @support https://github.com/ShrekBytes/WordGlance/issues
// ==/UserScript==
(function() {
'use strict';
// Configuration - Easy to modify
const CONFIG = {
tooltipZIndex: 999999,
maxDefinitions: 9, // Maximum number of definitions to show (3 pages × 3 per page)
maxTranslations: 8, // Maximum number of translations to show (2 pages × 4 per page)
definitionsPerPage: 3, // Definitions per page for pagination
translationsPerPage: 4, // Translations per page for pagination (2x2 grid)
maxSynonyms: 6, // Maximum synonyms per word (used for display)
maxAntonyms: 6, // Maximum antonyms per word (used for display)
cacheSize: 100, // Maximum number of cached words
apiTimeout: 10000 // API request timeout in milliseconds
};
// Load settings from userscript storage
let targetLanguage = GM_getValue('wordglance-target-language', 'bn');
let sourceLanguage = GM_getValue('wordglance-source-language', 'auto');
let isDarkMode = GM_getValue('wordglance-dark-mode', false);
// Cache system for definitions and translations
const cache = {
definitions: new Map(),
translations: new Map()
};
// Load cache from storage
function loadCache() {
try {
const cachedDefinitions = GM_getValue('wordglance-cache-definitions', '{}');
const cachedTranslations = GM_getValue('wordglance-cache-translations', '{}');
// Validate JSON before parsing
if (typeof cachedDefinitions !== 'string' || typeof cachedTranslations !== 'string' ||
cachedDefinitions === '{}' && cachedTranslations === '{}') {
return; // Skip if both are empty defaults
}
const defData = JSON.parse(cachedDefinitions);
const transData = JSON.parse(cachedTranslations);
// Validate parsed data is object
if (typeof defData !== 'object' || typeof transData !== 'object') {
console.log('Invalid cache data format, starting fresh');
return;
}
// Convert back to Map and maintain order (most recent first)
Object.entries(defData).forEach(([key, value]) => {
cache.definitions.set(key, value);
});
Object.entries(transData).forEach(([key, value]) => {
cache.translations.set(key, value);
});
} catch (e) {
console.log('Failed to load cache:', e);
// Clear corrupted cache
GM_setValue('wordglance-cache-definitions', '{}');
GM_setValue('wordglance-cache-translations', '{}');
}
}
// Save cache to storage
function saveCache() {
try {
// Convert Maps to objects for storage
const defObj = Object.fromEntries(cache.definitions);
const transObj = Object.fromEntries(cache.translations);
GM_setValue('wordglance-cache-definitions', JSON.stringify(defObj));
GM_setValue('wordglance-cache-translations', JSON.stringify(transObj));
} catch (e) {
console.log('Failed to save cache:', e);
}
}
// Add to cache with LRU behavior
function addToCache(type, word, data) {
const targetCache = cache[type];
const cacheKey = type === 'translations' ? `${word}-${sourceLanguage}-${targetLanguage}` : word.toLowerCase();
// Remove if already exists (to update position)
if (targetCache.has(cacheKey)) {
targetCache.delete(cacheKey);
}
// Add to front
targetCache.set(cacheKey, data);
// Remove oldest if cache exceeds limit
if (targetCache.size > CONFIG.cacheSize) {
const firstKey = targetCache.keys().next().value;
targetCache.delete(firstKey);
}
// Save cache
saveCache();
}
// Get from cache
function getFromCache(type, word) {
const targetCache = cache[type];
const cacheKey = type === 'translations' ? `${word}-${sourceLanguage}-${targetLanguage}` : word.toLowerCase();
if (targetCache.has(cacheKey)) {
// Move to front (most recently used)
const data = targetCache.get(cacheKey);
targetCache.delete(cacheKey);
targetCache.set(cacheKey, data);
return data;
}
return null;
}
// Initialize cache
loadCache();
// Standardized error messages system
const ERROR_MESSAGES = {
NO_DEFINITION: 'Definition not found',
NO_TRANSLATION: 'Translation not available',
NETWORK_ERROR: 'Connection error - please try again',
API_TIMEOUT: 'Request timed out - please try again',
PARSE_ERROR: 'Unable to process response',
INVALID_INPUT: 'Please select a valid word or phrase',
LANGUAGE_ERROR: 'Language not supported'
};
// Helper function to create standardized error display
function createErrorMessage(errorType, details = '') {
const baseMessage = ERROR_MESSAGES[errorType] || 'Unknown error';
return details ? `${baseMessage}: ${details}` : baseMessage;
}
// Centralized language definitions to avoid duplication
const LANGUAGES = {
'auto': 'Auto-detect', // Only for source language
'en': 'English', 'bn': 'Bengali', 'es': 'Spanish', 'fr': 'French', 'de': 'German',
'it': 'Italian', 'pt': 'Portuguese', 'ru': 'Russian', 'ja': 'Japanese',
'ko': 'Korean', 'zh': 'Chinese', 'ar': 'Arabic', 'hi': 'Hindi',
'tr': 'Turkish', 'nl': 'Dutch', 'sv': 'Swedish', 'da': 'Danish',
'no': 'Norwegian', 'fi': 'Finnish', 'pl': 'Polish', 'cs': 'Czech',
'sk': 'Slovak', 'hu': 'Hungarian', 'ro': 'Romanian', 'bg': 'Bulgarian',
'hr': 'Croatian', 'sr': 'Serbian', 'sl': 'Slovenian', 'et': 'Estonian',
'lv': 'Latvian', 'lt': 'Lithuanian', 'uk': 'Ukrainian', 'el': 'Greek',
'he': 'Hebrew', 'th': 'Thai', 'vi': 'Vietnamese', 'id': 'Indonesian',
'ms': 'Malay', 'tl': 'Filipino', 'sw': 'Swahili', 'am': 'Amharic', 'zu': 'Zulu'
};
// Add custom styles
GM_addStyle(`
.wordglance-tooltip {
position: absolute;
background: #ffffff;
border: 1px solid #e0e0e0;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 16px;
max-width: 320px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.5;
z-index: ${CONFIG.tooltipZIndex};
display: none;
pointer-events: auto;
transition: opacity 0.3s ease, transform 0.3s ease; /* Slower transitions */
word-wrap: break-word;
overflow-wrap: break-word;
opacity: 0;
transform: translateY(-10px) scale(0.95);
will-change: transform, opacity; /* Optimize for animations */
}
.wordglance-tooltip.show {
opacity: 1;
transform: translateY(0) scale(1);
}
.wordglance-tooltip.dark-mode {
background: #1a1a1a;
border-color: #333333;
color: #e0e0e0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.wordglance-trigger-icon {
position: absolute;
background: #3498db;
color: white;
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
font-size: 12px;
cursor: pointer;
z-index: ${CONFIG.tooltipZIndex - 1};
display: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
transition: transform 0.25s ease, background-color 0.25s ease; /* Slower animations */
font-weight: bold;
opacity: 0;
transform: scale(0.8);
will-change: transform, opacity; /* Optimize for animations */
}
/* Larger button for mobile devices */
@media (hover: none) and (pointer: coarse) {
.wordglance-trigger-icon {
width: 32px;
height: 32px;
font-size: 16px;
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.3);
}
}
.wordglance-trigger-icon.show {
opacity: 1;
transform: scale(1);
}
.wordglance-trigger-icon:hover {
background: #2980b9;
transform: scale(1.1);
}
.wordglance-trigger-icon.dark-mode {
background: #ff6b6b;
}
.wordglance-trigger-icon.dark-mode:hover {
background: #ff5252;
}
.wordglance-tooltip .definition-section {
margin-bottom: 16px;
}
.wordglance-tooltip .translation-section {
margin-bottom: 0;
}
.wordglance-tooltip .section-title {
font-weight: 600;
color: #2c3e50;
margin-bottom: 8px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
display: flex;
justify-content: space-between;
align-items: center;
}
.wordglance-tooltip.dark-mode .section-title {
color: #cccccc;
}
.wordglance-tooltip .slider-controls {
display: flex;
gap: 4px;
align-items: center;
}
.wordglance-tooltip .slider-button {
background: none;
border: none;
border-radius: 3px;
width: 20px;
height: 20px;
font-size: 12px;
cursor: pointer;
color: #7f8c8d;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s; /* Faster transition */
}
.wordglance-tooltip .slider-button:hover {
color: #2c3e50;
}
.wordglance-tooltip .slider-button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.wordglance-tooltip .slider-button:disabled:hover {
color: inherit;
}
.wordglance-tooltip.dark-mode .slider-button {
color: #cccccc;
}
.wordglance-tooltip.dark-mode .slider-button:hover:not(:disabled) {
color: #ffffff;
}
.wordglance-tooltip .slider-info {
font-size: 11px;
color: #7f8c8d;
margin: 0 4px;
white-space: nowrap;
}
.wordglance-tooltip .content-container {
position: relative;
overflow: hidden; /* needed to hide non-active pages */
height: auto; /* will be set dynamically */
transition: height 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94); /* Slower, more natural easing */
will-change: height; /* Optimize for height animations */
}
.wordglance-tooltip .content-slider {
display: flex;
transition: transform 0.3s ease; /* Slower slider transitions */
width: 100%;
height: auto;
will-change: transform; /* Optimize for transform animations */
}
.wordglance-tooltip .content-page {
min-width: 100%;
max-width: 100%;
flex-shrink: 0;
word-wrap: break-word;
overflow-wrap: break-word;
box-sizing: border-box;
height: auto;
}
.wordglance-tooltip .definition-item {
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid #f8f9fa;
word-wrap: break-word;
overflow-wrap: break-word;
}
.wordglance-tooltip.dark-mode .definition-item {
border-bottom-color: #333333;
}
.wordglance-tooltip .definition-item:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.wordglance-tooltip .definition-text {
color: #2c3e50;
margin-bottom: 4px;
word-wrap: break-word;
overflow-wrap: break-word;
}
.wordglance-tooltip.dark-mode .definition-text {
color: #e0e0e0;
}
.wordglance-tooltip .translation-item {
margin-bottom: 4px;
word-wrap: break-word;
overflow-wrap: break-word;
}
.wordglance-tooltip .translation-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
min-height: 80px;
position: relative;
border-radius: 4px;
overflow: hidden;
}
.wordglance-tooltip .translation-grid::before {
content: '';
position: absolute;
top: 50%;
left: 10%;
right: 10%;
height: 2px;
background: #e0e0e0;
transform: translateY(-1px);
z-index: 3;
}
.wordglance-tooltip .translation-grid::after {
content: '';
position: absolute;
top: 5%;
bottom: 5%;
left: 50%;
width: 2px;
background: #e0e0e0;
transform: translateX(-1px);
z-index: 3;
}
.wordglance-tooltip.dark-mode .translation-grid::before,
.wordglance-tooltip.dark-mode .translation-grid::after {
background: #333333;
}
.wordglance-tooltip .translation-text {
color: #27ae60;
font-weight: 500;
font-size: 14px;
word-wrap: break-word;
overflow-wrap: break-word;
padding: 12px 8px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
min-height: 20px;
background: #ffffff;
position: relative;
z-index: 2;
}
.wordglance-tooltip.dark-mode .translation-text {
color: #4fc3f7;
background: #1a1a1a;
}
.wordglance-tooltip .part-of-speech {
color: #7f8c8d;
font-style: italic;
font-size: 12px;
margin-right: 8px;
}
.wordglance-tooltip.dark-mode .part-of-speech {
color: #cccccc;
}
.wordglance-tooltip .example {
font-style: italic;
color: #7f8c8d;
font-size: 12px;
margin-top: 4px;
word-wrap: break-word;
overflow-wrap: break-word;
}
.wordglance-tooltip.dark-mode .example {
color: #cccccc;
}
.wordglance-tooltip .synonyms-antonyms {
margin-top: 6px;
font-size: 12px;
word-wrap: break-word;
overflow-wrap: break-word;
}
.wordglance-tooltip .synonyms-antonyms-section {
margin-top: 12px;
padding-top: 0;
}
.wordglance-tooltip .synonyms,
.wordglance-tooltip .antonyms {
margin-top: 4px;
word-wrap: break-word;
overflow-wrap: break-word;
}
.wordglance-tooltip .synonyms-label,
.wordglance-tooltip .antonyms-label {
font-weight: 600;
color: #2c3e50;
word-wrap: break-word;
overflow-wrap: break-word;
}
.wordglance-tooltip.dark-mode .synonyms-label,
.wordglance-tooltip.dark-mode .antonyms-label {
color: #cccccc;
}
.wordglance-tooltip .synonyms-list,
.wordglance-tooltip .antonyms-list {
color: #7f8c8d;
font-style: italic;
word-wrap: break-word;
overflow-wrap: break-word;
}
.wordglance-tooltip.dark-mode .synonyms-list,
.wordglance-tooltip.dark-mode .antonyms-list {
color: #cccccc;
}
.wordglance-tooltip .loading {
color: #7f8c8d;
font-style: italic;
}
.wordglance-tooltip.dark-mode .loading {
color: #cccccc;
}
.wordglance-tooltip .error {
color: #e74c3c;
font-size: 13px;
}
.wordglance-tooltip.dark-mode .error {
color: #ff6b6b;
}
/* Settings Menu Styles - Matching tooltip design */
.wordglance-settings {
position: absolute;
background: #ffffff;
border: 1px solid #e0e0e0;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 16px;
max-width: 400px;
min-width: 350px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.4;
z-index: ${CONFIG.tooltipZIndex + 10};
display: none;
pointer-events: auto;
transition: background-color 0.3s, border-color 0.3s, color 0.3s;
word-wrap: break-word;
overflow-wrap: break-word;
}
.wordglance-settings.dark-mode {
background: #1a1a1a;
border-color: #333333;
color: #e0e0e0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.wordglance-settings .settings-header {
font-weight: 600;
font-size: 16px;
color: #2c3e50;
margin-bottom: 12px;
border-bottom: 1px solid #ecf0f1;
padding-bottom: 8px;
word-wrap: break-word;
overflow-wrap: break-word;
}
.wordglance-settings.dark-mode .settings-header {
color: #ffffff;
border-bottom-color: #444444;
}
.wordglance-settings .setting-section {
margin-bottom: 12px;
}
.wordglance-settings .setting-section:last-child {
margin-bottom: 0;
}
.wordglance-settings .section-title {
font-weight: 600;
color: #2c3e50;
margin-bottom: 8px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.wordglance-settings.dark-mode .section-title {
color: #cccccc;
}
.wordglance-settings .setting-item {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
padding: 8px 0;
}
.wordglance-settings .setting-item:last-child {
margin-bottom: 0;
}
.wordglance-settings .setting-label {
color: #2c3e50;
font-size: 14px;
word-wrap: break-word;
overflow-wrap: break-word;
}
.wordglance-settings.dark-mode .setting-label {
color: #e0e0e0;
}
.wordglance-settings .setting-tip {
font-size: 11px;
color: #7f8c8d;
margin-top: 2px;
font-style: italic;
}
.wordglance-settings.dark-mode .setting-tip {
color: #cccccc;
}
.wordglance-settings .toggle-switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
}
.wordglance-settings .toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.wordglance-settings .toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #bdc3c7;
transition: all 0.3s;
border-radius: 24px;
}
.wordglance-settings .toggle-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: all 0.3s;
border-radius: 50%;
}
.wordglance-settings .toggle-switch input:checked + .toggle-slider {
background-color: #3498db;
}
.wordglance-settings .toggle-switch input:checked + .toggle-slider:before {
transform: translateX(20px);
}
.wordglance-settings .language-selector {
background: #f7fafc;
border: 1px solid #e2e8f0;
border-radius: 4px;
padding: 5px 25px 5px 6px;
cursor: pointer;
font-size: 13px;
color: #2c3e50;
position: relative;
transition: all 0.3s;
min-width: 140px;
display: flex;
align-items: center;
justify-content: space-between;
}
.wordglance-settings .language-selector::after {
content: "▼";
position: absolute;
right: 8px;
font-size: 11px;
color: #7f8c8d;
pointer-events: none;
}
.wordglance-settings.dark-mode .language-selector::after {
color: #aaaaaa;
}
.wordglance-settings.dark-mode .language-selector {
background: #333333;
border-color: #666666;
color: #e0e0e0;
}
.wordglance-settings .language-selector:hover {
border-color: #3498db;
}
.wordglance-settings .language-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 4px;
margin-top: 2px;
max-height: 200px;
overflow-y: auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
display: none;
}
.wordglance-settings.dark-mode .language-dropdown {
background: #333333;
border-color: #666666;
}
.wordglance-settings .language-dropdown.open {
display: block;
}
.wordglance-settings .language-search {
width: 100%;
padding: 8px;
border: none;
border-bottom: 1px solid #e2e8f0;
font-size: 12px;
outline: none;
background: #f7fafc;
color: #2c3e50;
}
.wordglance-settings.dark-mode .language-search {
background: #2a2a2a;
border-bottom-color: #666666;
color: #e0e0e0;
}
.wordglance-settings .language-search::placeholder {
color: #7f8c8d;
font-style: italic;
}
.wordglance-settings.dark-mode .language-search::placeholder {
color: #cccccc;
}
.wordglance-settings .language-options {
max-height: 150px;
overflow-y: auto;
}
.wordglance-settings .language-option {
padding: 6px 8px;
cursor: pointer;
font-size: 13px;
color: #2c3e50;
border-bottom: 1px solid #f8f9fa;
transition: background-color 0.2s;
}
.wordglance-settings.dark-mode .language-option {
color: #e0e0e0;
border-bottom-color: #444444;
}
.wordglance-settings .language-option:last-child {
border-bottom: none;
}
.wordglance-settings .language-option:hover {
background-color: #ecf0f1;
}
.wordglance-settings.dark-mode .language-option:hover {
background-color: #444444;
}
.wordglance-settings .language-option.selected {
background-color: #3498db;
color: white;
}
.wordglance-settings .cache-button {
background: #e74c3c;
color: white;
border: none;
border-radius: 4px;
padding: 6px 12px;
cursor: pointer;
font-size: 12px;
transition: all 0.3s;
}
.wordglance-settings .cache-button:hover {
background: #c0392b;
}
.wordglance-settings .cache-info {
font-size: 11px;
color: #7f8c8d;
margin-top: 4px;
font-style: italic;
}
.wordglance-settings.dark-mode .cache-info {
color: #cccccc;
}
.wordglance-settings .close-button {
position: absolute;
top: 8px;
right: 8px;
background: none;
border: none;
font-size: 18px;
color: #95a5a6;
cursor: pointer;
padding: 4px;
line-height: 1;
}
.wordglance-settings .close-button:hover {
color: #2c3e50;
}
.wordglance-settings.dark-mode .close-button:hover {
color: #ffffff;
}
/* Credit Section Styles */
.wordglance-settings .credit-section {
border-top: 1px solid #e0e0e0;
padding-top: 20px;
margin-top: 15px;
text-align: center;
}
.wordglance-settings.dark-mode .credit-section {
border-top-color: #444444;
}
.wordglance-settings .help-link {
display: block;
margin-bottom: 12px;
color: #3498db;
text-decoration: none;
font-size: 14px;
transition: color 0.3s;
}
.wordglance-settings .help-link:hover {
color: #2980b9;
}
.wordglance-settings.dark-mode .help-link {
color: #5dade2;
}
.wordglance-settings.dark-mode .help-link:hover {
color: #85c1e9;
}
.wordglance-settings .credit-text {
font-size: 13px;
color: #7f8c8d;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
}
.wordglance-settings.dark-mode .credit-text {
color: #cccccc;
}
.wordglance-settings .credit-link {
color: #3498db;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 4px;
transition: color 0.3s;
}
.wordglance-settings .credit-link:hover {
color: #2980b9;
}
.wordglance-settings.dark-mode .credit-link {
color: #5dade2;
}
.wordglance-settings.dark-mode .credit-link:hover {
color: #85c1e9;
}
.wordglance-settings .github-icon {
width: 16px;
height: 16px;
vertical-align: middle;
}
/* Usage Statistics Styles */
.wordglance-settings .usage-circle {
width: 100px;
height: 100px;
border-radius: 50%;
border: 2px solid #3498db;
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #3498db;
font-weight: bold;
margin: 0 auto;
}
.wordglance-settings.dark-mode .usage-circle {
border-color: #ff6b6b;
color: #ff6b6b;
}
.wordglance-settings .usage-number {
font-size: 24px;
line-height: 1;
}
.wordglance-settings .usage-label {
font-size: 10px;
text-transform: lowercase;
letter-spacing: 0.3px;
margin-top: 4px;
}
`);
// Usage statistics tracking
let totalWordsLearned = GM_getValue('wordglance-total-words-learned', 0);
// Function to increment word learned counter
function incrementWordsLearned() {
totalWordsLearned++;
GM_setValue('wordglance-total-words-learned', totalWordsLearned);
// Update display if settings menu is open
const usageNumber = document.querySelector('.usage-number');
if (usageNumber) {
usageNumber.textContent = totalWordsLearned;
}
}
// DOM element cache to reduce repeated queries
let domCache = {
definitionSlider: null,
translationSlider: null,
translationTitle: null,
definitionInfo: null,
translationInfo: null,
definitionPrevBtn: null,
definitionNextBtn: null,
translationPrevBtn: null,
translationNextBtn: null
};
// Cache DOM elements when tooltip is created
function cacheDOMElements() {
if (!tooltip) return;
domCache.definitionSlider = tooltip.querySelector('.definition-slider');
domCache.translationSlider = tooltip.querySelector('.translation-slider');
domCache.translationTitle = tooltip.querySelector('.translation-section .section-title span');
domCache.definitionInfo = tooltip.querySelector('.definition-info');
domCache.translationInfo = tooltip.querySelector('.translation-info');
domCache.definitionPrevBtn = tooltip.querySelector('.definition-prev');
domCache.definitionNextBtn = tooltip.querySelector('.definition-next');
domCache.translationPrevBtn = tooltip.querySelector('.translation-prev');
domCache.translationNextBtn = tooltip.querySelector('.translation-next');
}
// Clear DOM cache when tooltip is removed
function clearDOMCache() {
// Remove transition event listeners to prevent memory leaks
if (tooltip) {
const defSlider = tooltip.querySelector('.definition-slider');
const transSlider = tooltip.querySelector('.translation-slider');
if (defSlider && defSlider._heightSyncHandler) {
defSlider.removeEventListener('transitionend', defSlider._heightSyncHandler);
delete defSlider._heightSyncHandler;
}
if (transSlider && transSlider._heightSyncHandler) {
transSlider.removeEventListener('transitionend', transSlider._heightSyncHandler);
delete transSlider._heightSyncHandler;
}
}
domCache = {
definitionSlider: null,
translationSlider: null,
translationTitle: null,
definitionInfo: null,
translationInfo: null,
definitionPrevBtn: null,
definitionNextBtn: null,
translationPrevBtn: null,
translationNextBtn: null
};
}
let tooltip = null;
let triggerIcon = null;
let settingsMenu = null;
let resizeTimeout = null; // Add missing variable
let currentSelection = '';
let selectionRange = null;
let definitionData = null;
let translationData = null;
let currentDefinitionPage = 0;
let currentTranslationPage = 0;
let definitionPageHeights = [];
let translationPageHeights = [];
let activeRequests = new Set(); // Track active API requests
// Register menu commands
GM_registerMenuCommand('⚙️ WordGlance Settings', showSettingsMenu);
// Create simple settings menu matching tooltip design
function createSettingsMenu() {
const menu = document.createElement('div');
menu.className = `wordglance-settings ${isDarkMode ? 'dark-mode' : ''}`;
menu.innerHTML = `
<button class="close-button">×</button>
<div class="settings-header">⚙️ Settings</div>
<div class="setting-section">
<div class="setting-item">
<div class="setting-label">Dark Mode</div>
<label class="toggle-switch">
<input type="checkbox" id="dark-mode-toggle" ${isDarkMode ? 'checked' : ''}>
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-item">
<div class="setting-label">
<div>From Language</div>
<div class="setting-tip">Use auto for best experience</div>
</div>
<div class="language-selector" id="source-language-selector">
<span class="language-text">${getLanguageName(sourceLanguage)}</span>
<div class="language-dropdown" id="source-language-dropdown">
<input type="text" class="language-search" id="source-language-search" placeholder="Search languages...">
<div class="language-options" id="source-language-options">
${generateSourceLanguageOptions()}
</div>
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-label">To Language</div>
<div class="language-selector" id="target-language-selector">
<span class="language-text">${getLanguageName(targetLanguage)}</span>
<div class="language-dropdown" id="target-language-dropdown">
<input type="text" class="language-search" id="target-language-search" placeholder="Search languages...">
<div class="language-options" id="target-language-options">
${generateTargetLanguageOptions()}
</div>
</div>
</div>
</div>
</div>
<div class="setting-section">
<div class="setting-item">
<div class="setting-label">
<div>Cache</div>
<div class="cache-info" id="cache-info">Loading...</div>
</div>
<button class="cache-button" id="clear-cache-btn">Clear</button>
</div>
</div>
<div class="setting-section">
<div class="usage-circle">
<div class="usage-number">${totalWordsLearned}</div>
<div class="usage-label">words learned</div>
</div>
</div>
<div class="credit-section">
<a href="https://github.com/ShrekBytes/WordGlance/issues" target="_blank" class="help-link">Need help?</a>
<div class="credit-text">
Made by
<a href="https://github.com/ShrekBytes/WordGlance" target="_blank" class="credit-link">
<svg class="github-icon" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
</svg>
ShrekBytes
</a>
</div>
</div>
`;
document.body.appendChild(menu);
// Update cache info
updateCacheInfo();
// Event listeners
setupSettingsEventListeners(menu);
return menu;
}
function generateSourceLanguageOptions() {
return Object.entries(LANGUAGES)
.map(([code, name]) =>
`<div class="language-option ${code === sourceLanguage ? 'selected' : ''}" data-code="${code}">
${name}
</div>`
).join('');
}
function generateTargetLanguageOptions() {
// Exclude 'auto' from target languages
return Object.entries(LANGUAGES)
.filter(([code]) => code !== 'auto')
.map(([code, name]) =>
`<div class="language-option ${code === targetLanguage ? 'selected' : ''}" data-code="${code}">
${name}
</div>`
).join('');
}
function setupSettingsEventListeners(menu) {
// Close button
const closeBtn = menu.querySelector('.close-button');
closeBtn.addEventListener('click', hideSettingsMenu);
// Dark mode toggle
const darkModeToggle = menu.querySelector('#dark-mode-toggle');
darkModeToggle.addEventListener('change', (e) => {
toggleDarkMode();
menu.className = `wordglance-settings ${isDarkMode ? 'dark-mode' : ''}`;
});
// Source language selector
setupLanguageSelector(menu, 'source', changeSourceLanguage);
// Target language selector
setupLanguageSelector(menu, 'target', changeTargetLanguage);
// Clear cache button
const clearCacheBtn = menu.querySelector('#clear-cache-btn');
clearCacheBtn.addEventListener('click', () => {
clearCacheWithConfirmation();
updateCacheInfo();
});
}
function setupLanguageSelector(menu, type, changeLanguageFunction) {
const prefix = type === 'source' ? 'source-' : 'target-';
const languageSelector = menu.querySelector(`#${prefix}language-selector`);
const languageDropdown = menu.querySelector(`#${prefix}language-dropdown`);
const languageSearch = menu.querySelector(`#${prefix}language-search`);
const languageOptions = menu.querySelector(`#${prefix}language-options`);
languageSelector.addEventListener('click', (e) => {
// Don't toggle if clicking on the dropdown itself
if (!languageDropdown.contains(e.target)) {
// Close other dropdowns first
const otherPrefix = type === 'source' ? 'target-' : 'source-';
const otherDropdown = menu.querySelector(`#${otherPrefix}language-dropdown`);
if (otherDropdown) {
otherDropdown.classList.remove('open');
const otherSearch = menu.querySelector(`#${otherPrefix}language-search`);
const otherOptions = menu.querySelector(`#${otherPrefix}language-options`);
if (otherSearch) otherSearch.value = '';
if (otherOptions) {
otherOptions.querySelectorAll('.language-option').forEach(opt =>
opt.style.display = 'block');
}
}
languageDropdown.classList.toggle('open');
if (languageDropdown.classList.contains('open')) {
// Focus search input when dropdown opens
setTimeout(() => languageSearch.focus(), 50);
}
}
});
// Language search functionality
languageSearch.addEventListener('input', (e) => {
const searchTerm = e.target.value.toLowerCase();
const options = languageOptions.querySelectorAll('.language-option');
options.forEach(option => {
const languageName = option.textContent.toLowerCase();
const languageCode = option.dataset.code.toLowerCase();
if (languageName.includes(searchTerm) || languageCode.includes(searchTerm)) {
option.style.display = 'block';
} else {
option.style.display = 'none';
}
});
});
// Prevent dropdown from closing when clicking inside it
languageDropdown.addEventListener('click', (e) => {
e.stopPropagation();
});
// Language options
languageOptions.querySelectorAll('.language-option').forEach(option => {
option.addEventListener('click', () => {
const newLanguage = option.dataset.code;
const currentLanguage = type === 'source' ? sourceLanguage : targetLanguage;
if (newLanguage !== currentLanguage) {
changeLanguageFunction(newLanguage);
// Update selector text
const languageText = languageSelector.querySelector('.language-text');
if (languageText) {
languageText.textContent = option.textContent;
}
// Update selected state
languageOptions.querySelectorAll('.language-option').forEach(opt =>
opt.classList.remove('selected'));
option.classList.add('selected');
}
// Clear search and close dropdown
languageSearch.value = '';
languageOptions.querySelectorAll('.language-option').forEach(opt =>
opt.style.display = 'block');
languageDropdown.classList.remove('open');
});
});
// Handle Escape key in search
languageSearch.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
languageDropdown.classList.remove('open');
languageSearch.value = '';
languageOptions.querySelectorAll('.language-option').forEach(opt =>
opt.style.display = 'block');
}
});
// Store cleanup function for this selector
if (!menu._cleanupFunctions) menu._cleanupFunctions = [];
menu._cleanupFunctions.push(() => {
// Remove event listeners would happen automatically when DOM elements are removed
// But we can clear any references
languageDropdown.classList.remove('open');
languageSearch.value = '';
});
}
function updateCacheInfo() {
const cacheInfo = document.querySelector('#cache-info');
if (cacheInfo) {
const defCount = cache.definitions.size;
const transCount = cache.translations.size;
cacheInfo.textContent = `${defCount + transCount} items`;
}
}
function showSettingsMenu() {
if (settingsMenu) {
hideSettingsMenu();
return;
}
// Hide tooltip if open
hideTooltip();
hideTriggerIcon();
settingsMenu = createSettingsMenu();
settingsMenu.style.display = 'block';
// Position like tooltip
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const rect = settingsMenu.getBoundingClientRect();
let left = (viewportWidth - rect.width) / 2;
let top = (viewportHeight - rect.height) / 2;
settingsMenu.style.left = left + window.scrollX + 'px';
settingsMenu.style.top = top + window.scrollY + 'px';
}
function hideSettingsMenu() {
if (settingsMenu) {
// Clean up any stored cleanup functions
if (settingsMenu._cleanupFunctions) {
settingsMenu._cleanupFunctions.forEach(cleanup => cleanup());
settingsMenu._cleanupFunctions = null;
}
settingsMenu.remove();
settingsMenu = null;
}
}
// Updated menu command functions
function toggleDarkMode() {
isDarkMode = !isDarkMode;
GM_setValue('wordglance-dark-mode', isDarkMode);
// Update all UI elements
if (tooltip) {
tooltip.className = `wordglance-tooltip ${isDarkMode ? 'dark-mode' : ''}`;
}
if (triggerIcon) {
triggerIcon.className = `wordglance-trigger-icon ${isDarkMode ? 'dark-mode' : ''}`;
}
if (settingsMenu) {
settingsMenu.className = `wordglance-settings ${isDarkMode ? 'dark-mode' : ''}`;
}
}
// Centralized language validation function
function validateLanguageCode(languageCode, isSource = true) {
// Check if language code exists
if (!LANGUAGES[languageCode]) {
return { valid: false, error: `Invalid language code: ${languageCode}` };
}
// Check auto-detect restrictions
if (!isSource && languageCode === 'auto') {
return { valid: false, error: 'Auto-detect not allowed for target language' };
}
return { valid: true, error: null };
}
// Consolidated language change function to reduce code duplication
function changeLanguage(type, newLanguage) {
const isSource = type === 'source';
const currentLanguage = isSource ? sourceLanguage : targetLanguage;
const languageKey = isSource ? 'wordglance-source-language' : 'wordglance-target-language';
// Use centralized validation
const validation = validateLanguageCode(newLanguage, isSource);
if (!validation.valid) {
console.warn(validation.error);
return;
}
if (currentLanguage === newLanguage) {
return;
}
// Update the language variable
if (isSource) {
sourceLanguage = newLanguage;
} else {
targetLanguage = newLanguage;
}
// Save to storage
GM_setValue(languageKey, newLanguage);
// Clear translation cache since language changed
cache.translations.clear();
GM_setValue('wordglance-cache-translations', '{}');
// Update tooltip if open
if (tooltip && tooltip.style.display !== 'none') {
const translationTitle = domCache.translationTitle || tooltip.querySelector('.translation-section .section-title span');
if (translationTitle) {
const sourceLabel = sourceLanguage === 'auto' ? 'Auto' : getLanguageName(sourceLanguage);
const targetLabel = getLanguageName(targetLanguage);
translationTitle.textContent = `${sourceLabel} → ${targetLabel}`;
// Reload translation with new language if we have current selection
if (currentSelection) {
reloadTranslationContent();
}
}
}
}
// Helper function to reload translation content (optimized with cached DOM elements)
function reloadTranslationContent() {
const translationSlider = domCache.translationSlider || tooltip?.querySelector('.translation-slider');
if (!translationSlider) return;
translationSlider.innerHTML = '<div class="content-page"><div class="translation-content loading">Loading...</div></div>';
// Set loading height
const container = tooltip.querySelector('.translation-section .content-container');
if (container) smoothHeightTransition(container, 80, true);
fetchTranslation(currentSelection)
.then(result => {
translationData = result;
updateTranslationDisplay(result);
currentTranslationPage = 0;
updateTranslationSlider(true);
// Smooth transition to final height with staggered timing
setTimeout(() => {
setContainerHeightFromCache('translation', false);
}, 150);
})
.catch(error => {
console.warn('Translation reload failed:', error);
translationSlider.innerHTML = `<div class="content-page"><span class="error">${error}</span></div>`;
translationPageHeights = [40];
currentTranslationPage = 0;
updateTranslationSlider(true);
// Smooth transition to error state height with delay
setTimeout(() => {
setContainerHeightFromCache('translation', false);
}, 150);
});
}
// Helper function to update translation display (optimized)
function updateTranslationDisplay(result) {
const translationSlider = domCache.translationSlider || tooltip?.querySelector('.translation-slider');
if (!translationSlider) return;
let pagesHtml = '';
result.pages.forEach((page, pageIndex) => {
let pageHtml = '<div class="content-page">';
pageHtml += '<div class="translation-grid">';
page.forEach(translation => {
pageHtml += `<div class="translation-text">${translation}</div>`;
});
const emptyCells = CONFIG.translationsPerPage - page.length;
for (let i = 0; i < emptyCells; i++) {
pageHtml += '<div class="translation-text" style="opacity: 0; pointer-events: none;"></div>';
}
pageHtml += '</div></div>';
pagesHtml += pageHtml;
});
translationSlider.innerHTML = pagesHtml;
translationPageHeights = Array.from(translationSlider.children).map(page => {
return measurePageHeight(page);
});
}
function changeSourceLanguage(newLanguage) {
changeLanguage('source', newLanguage);
}
function changeTargetLanguage(newLanguage) {
changeLanguage('target', newLanguage);
}
function clearCacheWithConfirmation() {
const confirmed = confirm('Are you sure you want to clear all cached data and reset word counter?\n\nThis will delete:\n• All cached definitions and translations\n• Words learned counter\n\nThis action cannot be undone.');
if (confirmed) {
cache.definitions.clear();
cache.translations.clear();
GM_setValue('wordglance-cache-definitions', '{}');
GM_setValue('wordglance-cache-translations', '{}');
// Reset word counter
totalWordsLearned = 0;
GM_setValue('wordglance-total-words-learned', 0);
// Update display if settings menu is open
const usageNumber = document.querySelector('.usage-number');
if (usageNumber) {
usageNumber.textContent = totalWordsLearned;
}
}
}
// Create trigger icon
function createTriggerIcon() {
const icon = document.createElement('button');
icon.className = `wordglance-trigger-icon ${isDarkMode ? 'dark-mode' : ''}`;
icon.innerHTML = '📖';
icon.title = 'Click to get definition and translation';
document.body.appendChild(icon);
return icon;
}
// Create tooltip element
function createTooltip() {
const tooltip = document.createElement('div');
tooltip.className = `wordglance-tooltip ${isDarkMode ? 'dark-mode' : ''}`;
tooltip.innerHTML = `
<div class="definition-section">
<div class="section-title">
<span class="word-title">Word</span>
<div class="slider-controls">
<button class="slider-button definition-prev">‹</button>
<span class="slider-info definition-info">1/1</span>
<button class="slider-button definition-next">›</button>
</div>
</div>
<div class="content-container">
<div class="content-slider definition-slider">
<div class="content-page">
<div class="definition-content loading">Loading...</div>
</div>
</div>
</div>
</div>
<div class="translation-section">
<div class="section-title">
<span class="translation-title">${sourceLanguage === 'auto' ? 'Auto' : getLanguageName(sourceLanguage)} → ${getLanguageName(targetLanguage)}</span>
<div class="slider-controls">
<button class="slider-button translation-prev">‹</button>
<span class="slider-info translation-info">1/1</span>
<button class="slider-button translation-next">›</button>
</div>
</div>
<div class="content-container">
<div class="content-slider translation-slider">
<div class="content-page">
<div class="translation-content loading">Loading...</div>
</div>
</div>
</div>
</div>
<div class="synonyms-antonyms-section" style="display: none;">
<div class="synonyms-antonyms-content"></div>
</div>
`;
// Add event listeners
tooltip.querySelector('.definition-prev').addEventListener('click', () => {
if (currentDefinitionPage > 0) {
currentDefinitionPage--;
updateDefinitionSlider();
}
});
tooltip.querySelector('.definition-next').addEventListener('click', () => {
if (definitionData && currentDefinitionPage < definitionData.pages.length - 1) {
currentDefinitionPage++;
updateDefinitionSlider();
}
});
tooltip.querySelector('.translation-prev').addEventListener('click', () => {
if (currentTranslationPage > 0) {
currentTranslationPage--;
updateTranslationSlider();
}
});
tooltip.querySelector('.translation-next').addEventListener('click', () => {
if (translationData && currentTranslationPage < translationData.pages.length - 1) {
currentTranslationPage++;
updateTranslationSlider();
}
});
document.body.appendChild(tooltip);
// Cache DOM elements for performance
cacheDOMElements();
// Attach transition listeners for dynamic height sync
attachTransitionHeightSync();
return tooltip;
}
// Update slider display and controls (optimized with cached DOM elements)
function updateDefinitionSlider(initial) {
if (!tooltip || !definitionData) return;
const slider = domCache.definitionSlider || tooltip.querySelector('.definition-slider');
const info = domCache.definitionInfo || tooltip.querySelector('.definition-info');
const prevBtn = domCache.definitionPrevBtn || tooltip.querySelector('.definition-prev');
const nextBtn = domCache.definitionNextBtn || tooltip.querySelector('.definition-next');
if (!slider || !info || !prevBtn || !nextBtn) return;
// Update slider position
slider.style.transform = `translateX(-${currentDefinitionPage * 100}%)`;
// Update info
info.textContent = `${currentDefinitionPage + 1}/${definitionData.pages.length}`;
// Update button states
prevBtn.disabled = currentDefinitionPage === 0;
nextBtn.disabled = currentDefinitionPage === definitionData.pages.length - 1;
setContainerHeightFromCache('definition', initial);
}
function updateTranslationSlider(initial) {
if (!tooltip || !translationData) return;
const slider = domCache.translationSlider || tooltip.querySelector('.translation-slider');
const info = domCache.translationInfo || tooltip.querySelector('.translation-info');
const prevBtn = domCache.translationPrevBtn || tooltip.querySelector('.translation-prev');
const nextBtn = domCache.translationNextBtn || tooltip.querySelector('.translation-next');
if (!slider || !info || !prevBtn || !nextBtn) return;
// Update slider position
slider.style.transform = `translateX(-${currentTranslationPage * 100}%)`;
// Update info
info.textContent = `${currentTranslationPage + 1}/${translationData.pages.length}`;
// Update button states
prevBtn.disabled = currentTranslationPage === 0;
nextBtn.disabled = currentTranslationPage === translationData.pages.length - 1;
setContainerHeightFromCache('translation', initial);
}
// Listen for transform transition end to re-adjust heights
function attachTransitionHeightSync() {
if (!tooltip) return;
const defSlider = tooltip.querySelector('.definition-slider');
const transSlider = tooltip.querySelector('.translation-slider');
const handler = (e) => {
if (e.propertyName === 'transform') {
setContainerHeightFromCache('definition');
setContainerHeightFromCache('translation');
}
};
// Store handlers for cleanup
if (defSlider) {
defSlider.addEventListener('transitionend', handler);
defSlider._heightSyncHandler = handler; // Store for removal
}
if (transSlider) {
transSlider.addEventListener('transitionend', handler);
transSlider._heightSyncHandler = handler; // Store for removal
}
}
// Dynamically adjust container height (fallback for missing cached heights)
function adjustSliderHeight(kind) {
if (!tooltip) return;
const container = tooltip.querySelector(`.${kind}-section .content-container`);
if (!container) return;
const slider = container.querySelector(`.${kind}-slider`);
if (!slider) return;
const index = kind === 'definition' ? currentDefinitionPage : currentTranslationPage;
const currentPage = slider.children[index];
if (!currentPage) return;
// Measure height without interfering with layout
const targetHeight = currentPage.scrollHeight || currentPage.offsetHeight;
if (targetHeight && Math.abs(container.clientHeight - targetHeight) > 2) {
smoothHeightTransition(container, targetHeight);
}
}
// Set container height from cached measurements
function setContainerHeightFromCache(kind, initial) {
if (!tooltip) return;
const container = tooltip.querySelector(`.${kind}-section .content-container`);
if (!container) return;
const heights = kind === 'definition' ? definitionPageHeights : translationPageHeights;
const index = kind === 'definition' ? currentDefinitionPage : currentTranslationPage;
const targetHeight = heights[index];
if (targetHeight) {
smoothHeightTransition(container, targetHeight, initial);
} else {
// Fallback to live measurement if cache is missing
adjustSliderHeight(kind);
}
}
// Window resize handler - recalculate current page heights
window.addEventListener('resize', () => {
if (!tooltip || tooltip.style.display === 'none') return;
if (resizeTimeout) {
clearTimeout(resizeTimeout);
}
resizeTimeout = setTimeout(() => {
definitionPageHeights[currentDefinitionPage] = recalcCurrentPageHeight('definition');
translationPageHeights[currentTranslationPage] = recalcCurrentPageHeight('translation');
setContainerHeightFromCache('definition');
setContainerHeightFromCache('translation');
}, 100); // Slightly longer for smoother batch updates
});
// Recalculate height for current page (used on window resize)
function recalcCurrentPageHeight(kind) {
if (!tooltip) return 0;
const container = tooltip.querySelector(`.${kind}-section .content-container`);
if (!container) return 0;
const slider = container.querySelector(`.${kind}-slider`);
if (!slider) return 0;
const index = kind === 'definition' ? currentDefinitionPage : currentTranslationPage;
const page = slider.children[index];
if (!page) return 0;
return measurePageHeight(page);
}
// Get language name for display
function getLanguageName(code) {
return LANGUAGES[code] || code.toUpperCase();
}
// Position trigger icon near selection
function positionTriggerIcon(icon, x, y) {
// Use requestAnimationFrame for smooth positioning
requestAnimationFrame(() => {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
('ontouchstart' in window) ||
(navigator.maxTouchPoints > 0);
// Button size depends on device type
const buttonSize = isMobile ? 32 : 24;
const halfButton = buttonSize / 2;
let left, top;
if (isMobile) {
// On mobile, position below selection to avoid native copy/paste popup
left = Math.max(10, Math.min(x - halfButton, viewportWidth - buttonSize - 10)); // Center horizontally, with bounds
top = y + 40; // Position below selection
// If there's no room below, try to the right side
if (top + buttonSize > viewportHeight - 50) {
left = Math.min(x + 20, viewportWidth - buttonSize - 10);
top = Math.max(10, y - halfButton); // Center vertically with selection
// If still no room, position to the left
if (left + buttonSize > viewportWidth - 10) {
left = Math.max(10, x - buttonSize - 20);
}
}
} else {
// Desktop positioning (original logic)
left = x + 10;
top = y - 30;
// Adjust if icon goes off screen
if (left + buttonSize > viewportWidth) {
left = x - buttonSize - 10;
}
if (top < 0) {
top = y + 10;
}
}
icon.style.left = left + window.scrollX + 'px';
icon.style.top = top + window.scrollY + 'px';
});
}
// Fetch definition from Free Dictionary API
function fetchDefinition(word) {
return new Promise((resolve, reject) => {
// Check cache first
const cached = getFromCache('definitions', word);
if (cached) {
resolve(cached);
return;
}
const requestId = `def-${word}-${Date.now()}`;
activeRequests.add(requestId);
GM_xmlhttpRequest({
method: 'GET',
url: `https://api.dictionaryapi.dev/api/v2/entries/en/${encodeURIComponent(word)}`,
onload: function(response) {
// Check if request is still active (not cancelled by newer request)
if (!activeRequests.has(requestId)) {
return; // Ignore outdated response
}
activeRequests.delete(requestId);
try {
if (response.status === 404) {
reject(createErrorMessage('NO_DEFINITION'));
return;
} else if (response.status !== 200) {
reject(createErrorMessage('NETWORK_ERROR'));
return;
}
const data = JSON.parse(response.responseText);
if (!data || !Array.isArray(data) || data.length === 0) {
reject(createErrorMessage('NO_DEFINITION'));
return;
}
const entry = data[0];
if (!entry || !entry.meanings || !Array.isArray(entry.meanings) || entry.meanings.length === 0) {
reject(createErrorMessage('NO_DEFINITION'));
return;
}
// Collect all definitions first
const definitions = [];
let allSynonyms = [];
let allAntonyms = [];
for (const meaning of entry.meanings) {
if (definitions.length >= CONFIG.maxDefinitions) break;
// Collect synonyms and antonyms from meaning level
const meaningSynonyms = meaning.synonyms || [];
const meaningAntonyms = meaning.antonyms || [];
allSynonyms.push(...meaningSynonyms);
allAntonyms.push(...meaningAntonyms);
for (const definition of meaning.definitions) {
if (definitions.length >= CONFIG.maxDefinitions) break;
// Collect synonyms and antonyms from definition level
const definitionSynonyms = definition.synonyms || [];
const definitionAntonyms = definition.antonyms || [];
allSynonyms.push(...definitionSynonyms);
allAntonyms.push(...definitionAntonyms);
definitions.push({
partOfSpeech: meaning.partOfSpeech,
definition: definition.definition,
example: definition.example
});
}
}
// Remove duplicates and limit synonyms/antonyms using CONFIG
const uniqueSynonyms = [...new Set(allSynonyms)].slice(0, CONFIG.maxSynonyms);
const uniqueAntonyms = [...new Set(allAntonyms)].slice(0, CONFIG.maxAntonyms);
// Group definitions into pages (CONFIG.definitionsPerPage per page)
const pages = [];
for (let i = 0; i < definitions.length; i += CONFIG.definitionsPerPage) {
const pageDefinitions = definitions.slice(i, i + CONFIG.definitionsPerPage);
pages.push(pageDefinitions);
}
const result = {
pages: pages,
synonyms: uniqueSynonyms,
antonyms: uniqueAntonyms
};
// Cache the result
addToCache('definitions', word, result);
resolve(result);
} catch (e) {
console.log('Definition parsing error:', e);
reject(createErrorMessage('PARSE_ERROR'));
}
},
onerror: function(error) {
activeRequests.delete(requestId);
reject(createErrorMessage('NETWORK_ERROR'));
},
ontimeout: function() {
activeRequests.delete(requestId);
reject(createErrorMessage('API_TIMEOUT'));
},
timeout: CONFIG.apiTimeout
});
});
}
// Fetch translation from Free Translate API
function fetchTranslation(text) {
return new Promise((resolve, reject) => {
// Check cache first
const cached = getFromCache('translations', text);
if (cached) {
resolve(cached);
return;
}
const requestId = `trans-${text}-${sourceLanguage}-${targetLanguage}-${Date.now()}`;
activeRequests.add(requestId);
// Build URL with source language parameter
let url = `https://ftapi.pythonanywhere.com/translate?dl=${targetLanguage}&text=${encodeURIComponent(text)}`;
if (sourceLanguage !== 'auto') {
url += `&sl=${sourceLanguage}`;
}
GM_xmlhttpRequest({
method: 'GET',
url: url,
onload: function(response) {
// Check if request is still active (not cancelled by newer request)
if (!activeRequests.has(requestId)) {
return; // Ignore outdated response
}
activeRequests.delete(requestId);
try {
if (response.status === 404) {
reject(createErrorMessage('NO_TRANSLATION'));
return;
} else if (response.status !== 200) {
reject(createErrorMessage('NETWORK_ERROR'));
return;
}
const data = JSON.parse(response.responseText);
if (data && data['destination-text']) {
const translations = [];
// Add primary translation
translations.push(data['destination-text']);
// Extract translations from all-translations array
if (data.translations && data.translations['all-translations']) {
const allTranslations = data.translations['all-translations'];
for (const translationGroup of allTranslations) {
if (Array.isArray(translationGroup) && translationGroup.length > 0) {
const mainTranslation = translationGroup[0];
// Add main translation if not already included and not the same as primary
if (mainTranslation &&
mainTranslation !== data['destination-text'] &&
!translations.includes(mainTranslation)) {
translations.push(mainTranslation);
}
// Stop if we have enough translations
if (translations.length >= CONFIG.maxTranslations) break;
}
}
}
// Add possible translations as fallback if we don't have enough
if (translations.length < CONFIG.maxTranslations &&
data.translations && data.translations['possible-translations']) {
const possibleTranslations = data.translations['possible-translations']
.filter(t => t && !translations.includes(t))
.slice(0, CONFIG.maxTranslations - translations.length);
translations.push(...possibleTranslations);
}
// Limit to maxTranslations
const limitedTranslations = translations.slice(0, CONFIG.maxTranslations);
// Group translations into pages (CONFIG.translationsPerPage per page)
const pages = [];
for (let i = 0; i < limitedTranslations.length; i += CONFIG.translationsPerPage) {
pages.push(limitedTranslations.slice(i, i + CONFIG.translationsPerPage));
}
const result = { pages: pages };
// Cache the result
addToCache('translations', text, result);
resolve(result);
} else {
// Check if it's a language detection issue
if (data && data.error && data.error.includes('language')) {
reject(createErrorMessage('LANGUAGE_ERROR'));
} else {
reject(createErrorMessage('NO_TRANSLATION'));
}
}
} catch (e) {
console.log('Translation parsing error:', e);
reject(createErrorMessage('PARSE_ERROR'));
}
},
onerror: function(error) {
activeRequests.delete(requestId);
reject(createErrorMessage('NETWORK_ERROR'));
},
ontimeout: function() {
activeRequests.delete(requestId);
reject(createErrorMessage('API_TIMEOUT'));
},
timeout: CONFIG.apiTimeout
});
});
}
// Smooth height transition helper
function smoothHeightTransition(container, targetHeight, immediate = false) {
if (!container) return;
if (immediate) {
// Disable transition temporarily for immediate changes
const prevTransition = container.style.transition;
container.style.transition = 'none';
container.style.height = targetHeight + 'px';
// Force reflow and restore transition
requestAnimationFrame(() => {
if (container) {
container.style.transition = prevTransition || 'height 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)';
}
});
} else {
container.style.height = targetHeight + 'px';
}
}
// Helper function to measure page height (reduces code duplication)
function measurePageHeight(page) {
const prevStyle = page.getAttribute('style') || '';
page.style.position = 'absolute';
page.style.visibility = 'hidden';
page.style.left = '-10000px';
page.style.top = '0';
page.style.height = 'auto';
const height = page.scrollHeight;
page.setAttribute('style', prevStyle);
return height;
}
// Cancel active requests to prevent race conditions
function cancelActiveRequests() {
activeRequests.clear();
}
// Show tooltip with content
function showTooltip(selectedText, x, y) {
// Cancel any active requests from previous selections
cancelActiveRequests();
if (!tooltip) {
tooltip = createTooltip();
}
// Reset page counters
currentDefinitionPage = 0;
currentTranslationPage = 0;
definitionData = null;
translationData = null;
definitionPageHeights = [];
translationPageHeights = [];
// Reset content
const definitionSlider = tooltip.querySelector('.definition-slider');
const translationSlider = tooltip.querySelector('.translation-slider');
const synonymsAntonymsSection = tooltip.querySelector('.synonyms-antonyms-section');
definitionSlider.innerHTML = '<div class="content-page"><div class="definition-content loading">Loading...</div></div>';
translationSlider.innerHTML = '<div class="content-page"><div class="translation-content loading">Loading...</div></div>';
// Hide synonyms/antonyms section initially
if (synonymsAntonymsSection) {
synonymsAntonymsSection.style.display = 'none';
}
// Reset slider controls
tooltip.querySelector('.definition-info').textContent = '1/1';
tooltip.querySelector('.translation-info').textContent = '1/1';
tooltip.querySelector('.definition-prev').disabled = true;
tooltip.querySelector('.definition-next').disabled = true;
tooltip.querySelector('.translation-prev').disabled = true;
tooltip.querySelector('.translation-next').disabled = true;
// Update the word title
const wordTitle = tooltip.querySelector('.word-title');
if (wordTitle) {
wordTitle.textContent = selectedText;
}
// Position and show tooltip
tooltip.style.display = 'block';
positionTooltip(tooltip, x, y);
// Set initial loading heights to prevent jumpy animations
const defLoadingContainer = tooltip.querySelector('.definition-section .content-container');
const transLoadingContainer = tooltip.querySelector('.translation-section .content-container');
if (defLoadingContainer) smoothHeightTransition(defLoadingContainer, 60, true); // Loading state height
if (transLoadingContainer) smoothHeightTransition(transLoadingContainer, 80, true); // Loading state height
// Trigger show animation on next frame
requestAnimationFrame(() => {
if (tooltip) {
tooltip.classList.add('show');
}
});
// Fetch definition
fetchDefinition(selectedText)
.then(result => {
definitionData = result;
const definitionSlider = tooltip.querySelector('.definition-slider');
const container = tooltip.querySelector('.definition-section .content-container');
// Create pages
let pagesHtml = '';
result.pages.forEach((page, pageIndex) => {
let pageHtml = '<div class="content-page">';
// Add definitions
page.forEach(def => {
pageHtml += `
<div class="definition-item">
<span class="part-of-speech">${def.partOfSpeech}</span>
<div class="definition-text">${def.definition}</div>
${def.example ? `<div class="example">"${def.example}"</div>` : ''}
</div>
`;
});
pageHtml += '</div>';
pagesHtml += pageHtml;
});
definitionSlider.innerHTML = pagesHtml;
// Handle synonyms and antonyms in separate section
const synonymsAntonymsSection = tooltip.querySelector('.synonyms-antonyms-section');
const synonymsAntonymsContent = tooltip.querySelector('.synonyms-antonyms-content');
if (result.synonyms.length > 0 || result.antonyms.length > 0) {
let synonymsAntonymsHtml = '<div class="synonyms-antonyms">';
if (result.synonyms.length > 0) {
synonymsAntonymsHtml += `
<div class="synonyms">
<span class="synonyms-label">Synonyms:</span>
<span class="synonyms-list">${result.synonyms.join(', ')}</span>
</div>
`;
}
if (result.antonyms.length > 0) {
synonymsAntonymsHtml += `
<div class="antonyms">
<span class="antonyms-label">Antonyms:</span>
<span class="antonyms-list">${result.antonyms.join(', ')}</span>
</div>
`;
}
synonymsAntonymsHtml += '</div>';
synonymsAntonymsContent.innerHTML = synonymsAntonymsHtml;
synonymsAntonymsSection.style.display = 'block';
} else {
synonymsAntonymsSection.style.display = 'none';
}
// Measure each page's natural height for caching
definitionPageHeights = Array.from(definitionSlider.children).map(page => {
return measurePageHeight(page);
});
// Track successful word lookup
incrementWordsLearned();
// Smooth transition to final height with slight delay for better visual flow
updateDefinitionSlider(true);
// Staggered transition for smoother feel
setTimeout(() => {
setContainerHeightFromCache('definition', false);
}, 50);
})
.catch(error => {
const definitionSlider = tooltip.querySelector('.definition-slider');
definitionSlider.innerHTML = `<div class="content-page"><span class="error">${error}</span></div>`;
definitionPageHeights = [40]; // Fixed height for error state
updateDefinitionSlider(true);
// Smooth transition to error state height with delay
setTimeout(() => {
setContainerHeightFromCache('definition', false);
}, 50);
// Hide synonyms/antonyms section on error
const synonymsAntonymsSection = tooltip.querySelector('.synonyms-antonyms-section');
if (synonymsAntonymsSection) {
synonymsAntonymsSection.style.display = 'none';
}
});
// Fetch translation
fetchTranslation(selectedText)
.then(result => {
translationData = result;
const translationSlider = tooltip.querySelector('.translation-slider');
const container = tooltip.querySelector('.translation-section .content-container');
// Create pages with grid layout
let pagesHtml = '';
result.pages.forEach((page, pageIndex) => {
let pageHtml = '<div class="content-page">';
pageHtml += '<div class="translation-grid">';
page.forEach(translation => {
pageHtml += `<div class="translation-text">${translation}</div>`;
});
// Fill empty grid cells if needed (for consistent 2x2 layout)
const emptyCells = CONFIG.translationsPerPage - page.length;
for (let i = 0; i < emptyCells; i++) {
pageHtml += '<div class="translation-text" style="opacity: 0; pointer-events: none;"></div>';
}
pageHtml += '</div>';
pageHtml += '</div>';
pagesHtml += pageHtml;
});
translationSlider.innerHTML = pagesHtml;
// Measure each page's natural height for caching
translationPageHeights = Array.from(translationSlider.children).map(page => {
return measurePageHeight(page);
});
// Smooth transition to final height with slight delay for better visual flow
updateTranslationSlider(true);
// Staggered transition for smoother feel
setTimeout(() => {
setContainerHeightFromCache('translation', false);
}, 100); // Slightly longer delay for translation to create nice flow
})
.catch(error => {
const translationSlider = tooltip.querySelector('.translation-slider');
translationSlider.innerHTML = `<div class="content-page"><span class="error">${error}</span></div>`;
translationPageHeights = [40]; // Fixed height for error state
updateTranslationSlider(true);
// Smooth transition to error state height with delay
setTimeout(() => {
setContainerHeightFromCache('translation', false);
}, 100);
});
}
// Position tooltip near selection
function positionTooltip(tooltip, x, y) {
// Use requestAnimationFrame for smooth positioning
requestAnimationFrame(() => {
const rect = tooltip.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Add comfortable margins from screen edges
const margin = 20;
const maxWidth = viewportWidth - (margin * 2);
const maxHeight = viewportHeight - (margin * 2);
let left = x + 10;
let top = y - rect.height - 10;
// Adjust if tooltip goes off screen horizontally
if (left + rect.width > viewportWidth - margin) {
left = x - rect.width - 10;
}
// Adjust if tooltip goes off screen vertically
if (top < margin) {
top = y + 20;
}
// Ensure tooltip stays within comfortable bounds (not snapped to edges)
left = Math.max(margin, Math.min(left, viewportWidth - rect.width - margin));
top = Math.max(margin, Math.min(top, viewportHeight - rect.height - margin));
tooltip.style.left = left + window.scrollX + 'px';
tooltip.style.top = top + window.scrollY + 'px';
});
}
// Hide tooltip
function hideTooltip() {
if (tooltip) {
tooltip.classList.remove('show');
// Hide completely after animation
setTimeout(() => {
if (tooltip && !tooltip.classList.contains('show')) {
tooltip.style.display = 'none';
// Clear DOM cache when tooltip is hidden
clearDOMCache();
}
}, 300); // Match new transition duration
}
}
function hideTriggerIcon() {
if (triggerIcon) {
triggerIcon.classList.remove('show');
// Hide completely after animation
setTimeout(() => {
if (triggerIcon && !triggerIcon.classList.contains('show')) {
triggerIcon.style.display = 'none';
}
}, 250); // Match new transition duration
}
}
// Show trigger icon on selection
function showTriggerIcon(x, y) {
if (!triggerIcon) {
triggerIcon = createTriggerIcon();
if (!triggerIcon) {
console.log('Failed to create trigger icon');
return;
}
// Add click event listener
triggerIcon.addEventListener('click', function(e) {
e.stopPropagation();
if (currentSelection) {
const rect = triggerIcon.getBoundingClientRect();
showTooltip(currentSelection, rect.left, rect.top);
}
});
}
// Position first, then show with animation
positionTriggerIcon(triggerIcon, x, y);
triggerIcon.style.display = 'block';
// Trigger animation on next frame
requestAnimationFrame(() => {
if (triggerIcon) {
triggerIcon.classList.add('show');
}
});
}
// Enhanced input sanitization function
function sanitizeAndValidateText(text) {
if (!text || typeof text !== 'string') {
return { valid: false, sanitized: '', error: createErrorMessage('INVALID_INPUT') };
}
// Basic sanitization
let sanitized = text.trim();
// Remove dangerous characters and control characters
sanitized = sanitized.replace(/[\x00-\x1F\x7F-\x9F]/g, ''); // Remove control characters
sanitized = sanitized.replace(/[<>'"&]/g, ''); // Remove HTML special characters
// Length validation
if (sanitized.length === 0) {
return { valid: false, sanitized: '', error: createErrorMessage('INVALID_INPUT') };
}
if (sanitized.length > 100) {
return { valid: false, sanitized: '', error: createErrorMessage('INVALID_INPUT') };
}
// Word count validation
const words = sanitized.split(/\s+/).filter(word => word.length > 0);
if (words.length > 5) {
return { valid: false, sanitized: '', error: createErrorMessage('INVALID_INPUT') };
}
// Content validation - allow Unicode letters, spaces, hyphens, apostrophes, dots, and common accented characters
if (!/^[\p{L}\s\-'\.]+$/u.test(sanitized)) {
return { valid: false, sanitized: '', error: createErrorMessage('INVALID_INPUT') };
}
// Exclude pure numbers
if (/^\d+$/.test(sanitized)) {
return { valid: false, sanitized: '', error: createErrorMessage('INVALID_INPUT') };
}
// Exclude pure punctuation
if (/^[^\p{L}]+$/u.test(sanitized)) {
return { valid: false, sanitized: '', error: createErrorMessage('INVALID_INPUT') };
}
return { valid: true, sanitized: sanitized, error: null };
}
// Handle text selection
function handleSelection() {
const selection = window.getSelection();
const selectedText = selection.toString();
// Enhanced input validation
const validation = sanitizeAndValidateText(selectedText);
if (validation.valid && validation.sanitized !== currentSelection) {
currentSelection = validation.sanitized;
// Store selection range for positioning
if (selection.rangeCount > 0) {
selectionRange = selection.getRangeAt(0);
}
// Show trigger icon immediately for better perceived performance
if (selectionRange) {
try {
const rect = selectionRange.getBoundingClientRect();
const x = rect.left + (rect.width / 2);
const y = rect.top;
showTriggerIcon(x, y);
} catch (e) {
console.log('Error positioning trigger icon:', e);
}
}
} else if (!selectedText || !validation.valid) {
currentSelection = '';
selectionRange = null;
hideTooltip();
hideTriggerIcon();
}
}
// Event listeners
document.addEventListener('mouseup', handleSelection);
document.addEventListener('keyup', handleSelection);
// Add mobile touch support
document.addEventListener('touchend', function(e) {
// Small delay to let selection stabilize on mobile
setTimeout(handleSelection, 100);
});
// Handle selection changes (for mobile long-press selections)
document.addEventListener('selectionchange', function() {
// Only handle if this is likely a user-initiated selection
if (document.hasFocus()) {
setTimeout(handleSelection, 150);
}
});
// Hide tooltip when clicking outside or pressing Esc
document.addEventListener('click', function(e) {
if (tooltip && !tooltip.contains(e.target) &&
triggerIcon && !triggerIcon.contains(e.target) &&
(!settingsMenu || !settingsMenu.contains(e.target)) &&
window.getSelection().toString().trim() === '') {
hideTooltip();
hideTriggerIcon();
}
// Close settings menu when clicking outside
if (settingsMenu && !settingsMenu.contains(e.target)) {
hideSettingsMenu();
}
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
if (settingsMenu) {
hideSettingsMenu();
} else {
hideTooltip();
hideTriggerIcon();
}
}
});
// Prevent tooltip from interfering with text selection
document.addEventListener('selectstart', function(e) {
if ((tooltip && tooltip.contains(e.target)) ||
(triggerIcon && triggerIcon.contains(e.target)) ||
(settingsMenu && settingsMenu.contains(e.target))) {
e.preventDefault();
}
});
// Cleanup function to remove event listeners and prevent memory leaks
function cleanup() {
// Cancel any active API requests
cancelActiveRequests();
// Clear timeouts
if (resizeTimeout) {
clearTimeout(resizeTimeout);
resizeTimeout = null;
}
// Clear DOM cache
clearDOMCache();
// Remove elements
if (tooltip) {
tooltip.remove();
tooltip = null;
}
if (triggerIcon) {
triggerIcon.remove();
triggerIcon = null;
}
if (settingsMenu) {
hideSettingsMenu();
}
// Save cache one final time
saveCache();
}
// Listen for page unload to cleanup
window.addEventListener('beforeunload', cleanup);
console.log('WordGlance userscript loaded! Select text and click the 📖 icon to see definitions and translations.');
})();