diff --git a/.gitignore b/.gitignore index 35ccbe5..e45450a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ node_modules -dist \ No newline at end of file +dist + +# Auto-generated files +src/content-standalone.ts \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3f836a2 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "kiroAgent.configureMCP": "Enabled" +} \ No newline at end of file diff --git a/manifest.json b/manifest.json index 6680455..c003b98 100644 --- a/manifest.json +++ b/manifest.json @@ -17,8 +17,10 @@ "default_icon": "icons/icon128.png" }, "background": { - "service_worker": "dist/background.js" + "service_worker": "dist/background.js", + "type": "module" }, + "icons": { "48": "icons/icon48.png", "128": "icons/icon128.png" diff --git a/package.json b/package.json index 5753006..9d010fd 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,10 @@ "typescript": "^5.6.0" }, "scripts": { - "build": "tsc", + "build": "node scripts/build-content-standalone.js && tsc", "watch": "tsc --watch", "clean": "rimraf dist", "rebuild": "npm run clean && npm run build", "prepare": "npm run build && node scripts/update-manifest-version.js" } -} +} \ No newline at end of file diff --git a/popup/popup.html b/popup/popup.html index b3f4a61..5e2e619 100644 --- a/popup/popup.html +++ b/popup/popup.html @@ -148,7 +148,7 @@ - + \ No newline at end of file diff --git a/scripts/build-content-standalone.js b/scripts/build-content-standalone.js new file mode 100644 index 0000000..753a56a --- /dev/null +++ b/scripts/build-content-standalone.js @@ -0,0 +1,39 @@ +const fs = require('fs'); + +const typesContent = fs.readFileSync('src/types.ts', 'utf8'); +const domUtilsContent = fs.readFileSync('src/utils/DOMUtils.ts', 'utf8'); +const storageServiceContent = fs.readFileSync('src/services/StorageService.ts', 'utf8'); +const messageServiceContent = fs.readFileSync('src/services/MessageService.ts', 'utf8'); +const highlightEngineContent = fs.readFileSync('src/content/HighlightEngine.ts', 'utf8'); +const contentScriptContent = fs.readFileSync('src/content/ContentScript.ts', 'utf8'); +const mainContent = fs.readFileSync('src/main.ts', 'utf8'); + +function extractDefinitions(content, filename) { + // Remove import statements + let cleaned = content.replace(/^import\s+.*?;?\s*$/gm, ''); + + // Remove export keywords but keep the definitions + cleaned = cleaned.replace(/^export\s+/gm, ''); + + // Add a comment header + cleaned = `// === ${filename} ===\n${cleaned}\n`; + + return cleaned; +} + +// Extract and combine all definitions +const combinedContent = `// Auto-generated standalone content script +// Do not edit this file directly - edit the source files and rebuild + +${extractDefinitions(typesContent, 'types.ts')} +${extractDefinitions(domUtilsContent, 'utils/DOMUtils.ts')} +${extractDefinitions(storageServiceContent, 'services/StorageService.ts')} +${extractDefinitions(messageServiceContent, 'services/MessageService.ts')} +${extractDefinitions(highlightEngineContent, 'content/HighlightEngine.ts')} +${extractDefinitions(contentScriptContent, 'content/ContentScript.ts')} +${extractDefinitions(mainContent, 'main.ts')} +`; + +fs.writeFileSync('src/content-standalone.ts', combinedContent); + +console.log('Generated standalone content script'); \ No newline at end of file diff --git a/scripts/update-manifest-version.js b/scripts/update-manifest-version.js index ce40522..6e885cd 100644 --- a/scripts/update-manifest-version.js +++ b/scripts/update-manifest-version.js @@ -2,7 +2,7 @@ const fs = require('fs'); const version = process.argv[2]; if (!version) { - console.log('ℹ️ No version passed, skipping manifest update'); + console.log('No version passed, skipping manifest update'); process.exit(0); } @@ -10,4 +10,4 @@ const manifest = JSON.parse(fs.readFileSync('manifest.json', 'utf-8')); manifest.version = version; fs.writeFileSync('manifest.json', JSON.stringify(manifest, null, 2)); -console.log(`✅ Updated manifest.json to version ${version}`); \ No newline at end of file +console.log(`Updated manifest.json to version ${version}`); \ No newline at end of file diff --git a/src/background.ts b/src/background.ts index 5dadf64..bc942e4 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,20 +1,36 @@ -// Handle tab updates to inject content script -chrome.tabs.onUpdated.addListener((tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab): void => { - if (changeInfo.status === 'complete' && tab.url && /^https?:/.test(tab.url)) { - chrome.scripting.executeScript({ - target: { tabId }, - files: ['dist/main.js'] - }).catch((err: unknown) => { - console.warn('Injection failed:', err); +import { StorageService } from './services/StorageService.js'; + +class BackgroundService { + constructor() { + this.initialize(); + } + + private initialize(): void { + this.setupTabUpdateListener(); + this.setupInstallListener(); + } + + private setupTabUpdateListener(): void { + chrome.tabs.onUpdated.addListener((tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab): void => { + if (changeInfo.status === 'complete' && tab.url && /^https?:/.test(tab.url)) { + chrome.scripting.executeScript({ + target: { tabId }, + files: ['dist/content-standalone.js'] + }).catch((err: unknown) => { + console.warn('Injection failed:', err); + }); + } }); } -}); -// Initialize storage on extension install -chrome.runtime.onInstalled.addListener((): void => { - chrome.storage.local.get(['exceptionsList'], (result: any) => { - if (!result.exceptionsList) { - chrome.storage.local.set({ exceptionsList: [] }); - } - }); -}); \ No newline at end of file + private setupInstallListener(): void { + chrome.runtime.onInstalled.addListener(async (): Promise => { + const data = await StorageService.get(['exceptionsList']); + if (!data.exceptionsList) { + await StorageService.update('exceptionsList', []); + } + }); + } +} + +new BackgroundService(); \ No newline at end of file diff --git a/src/content/ContentScript.ts b/src/content/ContentScript.ts new file mode 100644 index 0000000..2beda51 --- /dev/null +++ b/src/content/ContentScript.ts @@ -0,0 +1,106 @@ +import { HighlightList, MessageData } from '../types.js'; +import { StorageService } from '../services/StorageService.js'; +import { MessageService } from '../services/MessageService.js'; +import { HighlightEngine } from './HighlightEngine.js'; +import { DOMUtils } from '../utils/DOMUtils.js'; + +export class ContentScript { + private lists: HighlightList[] = []; + private isGlobalHighlightEnabled = true; + private exceptionsList: string[] = []; + private isCurrentSiteException = false; + private matchCase = false; + private matchWhole = false; + private highlightEngine: HighlightEngine; + + constructor() { + this.highlightEngine = new HighlightEngine(() => this.processHighlights()); + this.initialize(); + } + + private async initialize(): Promise { + await this.loadSettings(); + this.setupMessageListener(); + this.setupScrollHandler(); + this.processHighlights(); + } + + private async loadSettings(): Promise { + const data = await StorageService.get([ + 'lists', + 'globalHighlightEnabled', + 'matchCaseEnabled', + 'matchWholeEnabled', + 'exceptionsList' + ]); + + this.lists = data.lists || []; + this.isGlobalHighlightEnabled = data.globalHighlightEnabled ?? true; + this.matchCase = data.matchCaseEnabled ?? false; + this.matchWhole = data.matchWholeEnabled ?? false; + this.exceptionsList = data.exceptionsList || []; + this.isCurrentSiteException = this.checkCurrentSiteException(); + } + + private checkCurrentSiteException(): boolean { + const currentHostname = window.location.hostname; + return this.exceptionsList.includes(currentHostname); + } + + private setupMessageListener(): void { + MessageService.onMessage((message: MessageData) => { + switch (message.type) { + case 'WORD_LIST_UPDATED': + this.handleWordListUpdate(); + break; + case 'GLOBAL_TOGGLE_UPDATED': + this.handleGlobalToggleUpdate(message.enabled!); + break; + case 'MATCH_OPTIONS_UPDATED': + this.handleMatchOptionsUpdate(message.matchCase!, message.matchWhole!); + break; + case 'EXCEPTIONS_LIST_UPDATED': + this.handleExceptionsUpdate(); + break; + } + }); + } + + private setupScrollHandler(): void { + const debouncedProcess = DOMUtils.debounce(() => this.processHighlights(), 300); + window.addEventListener('scroll', debouncedProcess); + } + + private async handleWordListUpdate(): Promise { + const data = await StorageService.get(['lists']); + this.lists = data.lists || []; + this.processHighlights(); + } + + private handleGlobalToggleUpdate(enabled: boolean): void { + this.isGlobalHighlightEnabled = enabled; + this.processHighlights(); + } + + private handleMatchOptionsUpdate(matchCase: boolean, matchWhole: boolean): void { + this.matchCase = matchCase; + this.matchWhole = matchWhole; + this.processHighlights(); + } + + private async handleExceptionsUpdate(): Promise { + const data = await StorageService.get(['exceptionsList']); + this.exceptionsList = data.exceptionsList || []; + this.isCurrentSiteException = this.checkCurrentSiteException(); + this.processHighlights(); + } + + private processHighlights(): void { + if (!this.isGlobalHighlightEnabled || this.isCurrentSiteException) { + this.highlightEngine.clearHighlights(); + return; + } + + this.highlightEngine.highlight(this.lists, this.matchCase, this.matchWhole); + } +} \ No newline at end of file diff --git a/src/content/HighlightEngine.ts b/src/content/HighlightEngine.ts new file mode 100644 index 0000000..c482e8b --- /dev/null +++ b/src/content/HighlightEngine.ts @@ -0,0 +1,161 @@ +import { HighlightList, ActiveWord } from '../types.js'; +import { DOMUtils } from '../utils/DOMUtils.js'; + +export class HighlightEngine { + private styleSheet: CSSStyleSheet | null = null; + private wordStyleMap = new Map(); + private observer: MutationObserver; + + constructor(private onUpdate: () => void) { + this.observer = new MutationObserver(DOMUtils.debounce(onUpdate, 300)); + } + + private initializeStyleSheet(): void { + if (!this.styleSheet) { + const style = document.createElement('style'); + style.id = 'goose-highlighter-styles'; + document.head.appendChild(style); + this.styleSheet = style.sheet!; + } + } + + private updateWordStyles(activeWords: ActiveWord[]): void { + this.initializeStyleSheet(); + + while (this.styleSheet!.cssRules.length > 0) { + this.styleSheet!.deleteRule(0); + } + + this.wordStyleMap.clear(); + const uniqueStyles = new Map(); + + for (const word of activeWords) { + const styleKey = `${word.background}-${word.foreground}`; + if (!uniqueStyles.has(styleKey)) { + const className = `highlighted-word-${uniqueStyles.size}`; + uniqueStyles.set(styleKey, className); + + const rule = `.${className} { background: ${word.background}; color: ${word.foreground}; padding: 0 2px; }`; + this.styleSheet!.insertRule(rule, this.styleSheet!.cssRules.length); + } + + const lookup = word.text; + this.wordStyleMap.set(lookup, uniqueStyles.get(styleKey)!); + } + } + + clearHighlights(): void { + const highlightedElements = document.querySelectorAll('[data-gh]'); + highlightedElements.forEach(element => { + const parent = element.parentNode; + if (parent) { + parent.replaceChild(document.createTextNode(element.textContent || ''), element); + parent.normalize(); + } + }); + } + + private getTextNodes(): Text[] { + const textNodes: Text[] = []; + const walker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_TEXT, + { + acceptNode: (node: Text) => { + if (node.parentNode && (node.parentNode as Element).hasAttribute('data-gh')) { + return NodeFilter.FILTER_REJECT; + } + if (node.parentNode && ['SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME'].includes(node.parentNode.nodeName)) { + return NodeFilter.FILTER_REJECT; + } + if (!node.nodeValue?.trim()) { + return NodeFilter.FILTER_SKIP; + } + return NodeFilter.FILTER_ACCEPT; + } + } + ); + + while (walker.nextNode()) { + textNodes.push(walker.currentNode as Text); + } + return textNodes; + } + + private extractActiveWords(lists: HighlightList[]): ActiveWord[] { + const activeWords: ActiveWord[] = []; + for (const list of lists) { + if (!list.active) continue; + for (const word of list.words) { + if (!word.active) continue; + activeWords.push({ + text: word.wordStr, + background: word.background || list.background, + foreground: word.foreground || list.foreground + }); + } + } + return activeWords; + } + + highlight(lists: HighlightList[], matchCase: boolean, matchWhole: boolean): void { + this.observer.disconnect(); + this.clearHighlights(); + + const activeWords = this.extractActiveWords(lists); + if (activeWords.length === 0) { + this.startObserving(); + return; + } + + this.updateWordStyles(activeWords); + + const wordMap = new Map(); + for (const word of activeWords) { + const key = matchCase ? word.text : word.text.toLowerCase(); + wordMap.set(key, word); + } + + const flags = matchCase ? 'gu' : 'giu'; + let wordsPattern = Array.from(wordMap.keys()).map(DOMUtils.escapeRegex).join('|'); + + if (matchWhole) { + wordsPattern = `(?:(? { + const lookup = matchCase ? match : match.toLowerCase(); + const className = this.wordStyleMap.get(lookup) || 'highlighted-word-0'; + return `${match}`; + }); + + node.parentNode?.replaceChild(span, node); + } + } catch (e) { + console.error('Regex error:', e); + } + + this.startObserving(); + } + + private startObserving(): void { + this.observer.observe(document.body, { + childList: true, + subtree: true, + characterData: true + }); + } + + destroy(): void { + this.observer.disconnect(); + this.clearHighlights(); + } +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index af299c9..73e8ad3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,205 +1,3 @@ -// @ts-nocheck -let currentLists = []; -let isGlobalHighlightEnabled = true; -let exceptionsList = []; -let isCurrentSiteException = false; -let matchCase = false; -let matchWhole = false; -let styleSheet = null; -let wordStyleMap = new Map(); +import { ContentScript } from './content/ContentScript.js'; -function escapeRegex(s) { - return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function isCurrentSiteInExceptions() { - const currentHostname = window.location.hostname; - return exceptionsList.includes(currentHostname); -} - -function initializeStyleSheet() { - if (!styleSheet) { - const style = document.createElement('style'); - style.id = 'goose-highlighter-styles'; - document.head.appendChild(style); - styleSheet = style.sheet; - } -} - -function updateWordStyles(activeWords) { - initializeStyleSheet(); - - while (styleSheet.cssRules.length > 0) { - styleSheet.deleteRule(0); - } - - wordStyleMap.clear(); - const uniqueStyles = new Map(); - - for (const word of activeWords) { - const styleKey = `${word.background}-${word.foreground}`; - if (!uniqueStyles.has(styleKey)) { - const className = `highlighted-word-${uniqueStyles.size}`; - uniqueStyles.set(styleKey, className); - - const rule = `.${className} { background: ${word.background}; color: ${word.foreground}; padding: 0 2px; }`; - styleSheet.insertRule(rule, styleSheet.cssRules.length); - } - - const lookup = matchCase ? word.text : word.text.toLowerCase(); - wordStyleMap.set(lookup, uniqueStyles.get(styleKey)); - } -} - -function clearHighlights() { - const highlightedElements = document.querySelectorAll('[data-gh]'); - for (const element of highlightedElements) { - const parent = element.parentNode; - if (parent) { - parent.replaceChild(document.createTextNode(element.textContent), element); - parent.normalize(); - } - } -} - - -function processNodes() { - observer.disconnect(); - clearHighlights(); - - if (!isGlobalHighlightEnabled || isCurrentSiteException) { - observer.observe(document.body, { - childList: true, - subtree: true, - characterData: true - }); - return; - } - - const textNodes = []; - const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, { - acceptNode: node => { - if (node.parentNode && node.parentNode.hasAttribute('data-gh')) return NodeFilter.FILTER_REJECT; - if (node.parentNode && ['SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME'].includes(node.parentNode.nodeName)) return NodeFilter.FILTER_REJECT; - if (!node.nodeValue.trim()) return NodeFilter.FILTER_SKIP; - return NodeFilter.FILTER_ACCEPT; - } - }); - - while (walker.nextNode()) textNodes.push(walker.currentNode); - - const activeWords = []; - for (const list of currentLists) { - if (!list.active) continue; - for (const word of list.words) { - if (!word.active) continue; - activeWords.push({ - text: word.wordStr, - background: word.background || list.background, - foreground: word.foreground || list.foreground - }); - } - } - -if (activeWords.length > 0) { - updateWordStyles(activeWords); - - const wordMap = new Map(); - for (const word of activeWords) { - wordMap.set(matchCase ? word.text : word.text.toLowerCase(), word); - } - - let flags = matchCase ? 'gu' : 'giu'; - let wordsPattern = Array.from(wordMap.keys()).map(escapeRegex).join('|'); - - if (matchWhole) { - wordsPattern = `(?:(? { - const lookup = matchCase ? match : match.toLowerCase(); - const className = wordStyleMap.get(lookup) || 'highlighted-word-0'; - return `${match}`; - }); - - node.parentNode.replaceChild(span, node); - } - } catch (e) { - console.error('Regex error:', e); - } -} - - observer.observe(document.body, { - childList: true, - subtree: true, - characterData: true - }); -} - -const debouncedProcessNodes = debounce(processNodes, 300); - -function setListsAndUpdate(lists) { - currentLists = lists; - debouncedProcessNodes(); -} - -function debounce(func, wait) { - let timeout; - return function () { - const context = this, args = arguments; - clearTimeout(timeout); - timeout = setTimeout(() => func.apply(context, args), wait); - }; -} - -// Initial highlight on load -chrome.storage.local.get(['lists', 'globalHighlightEnabled', 'matchCaseEnabled', 'matchWholeEnabled', 'exceptionsList'], ({ lists, globalHighlightEnabled, matchCaseEnabled, matchWholeEnabled, exceptionsList: exceptions }) => { - if (Array.isArray(lists)) setListsAndUpdate(lists); - if (globalHighlightEnabled !== undefined) { - isGlobalHighlightEnabled = globalHighlightEnabled; - } - matchCase = !!matchCaseEnabled; - matchWhole = !!matchWholeEnabled; - exceptionsList = Array.isArray(exceptions) ? exceptions : []; - isCurrentSiteException = isCurrentSiteInExceptions(); - processNodes(); -}); - -// Listen for updates from the popup and re-apply highlights -chrome.runtime.onMessage.addListener((message) => { - if (message.type === 'WORD_LIST_UPDATED') { - chrome.storage.local.get('lists', ({ lists }) => { - if (Array.isArray(lists)) setListsAndUpdate(lists); - }); - } else if (message.type === 'GLOBAL_TOGGLE_UPDATED') { - isGlobalHighlightEnabled = message.enabled; - processNodes(); - } else if (message.type === 'MATCH_OPTIONS_UPDATED') { - matchCase = !!message.matchCase; - matchWhole = !!message.matchWhole; - processNodes(); - } else if (message.type === 'EXCEPTIONS_LIST_UPDATED') { - chrome.storage.local.get('exceptionsList', ({ exceptionsList: exceptions }) => { - exceptionsList = Array.isArray(exceptions) ? exceptions : []; - isCurrentSiteException = isCurrentSiteInExceptions(); - processNodes(); - }); - } -}); - -// Set up observer and scroll handler -const observer = new MutationObserver(debouncedProcessNodes); -observer.observe(document.body, { - childList: true, - subtree: true, - characterData: true -}); - -window.addEventListener('scroll', debouncedProcessNodes); +new ContentScript(); \ No newline at end of file diff --git a/src/popup/PopupController.ts b/src/popup/PopupController.ts new file mode 100644 index 0000000..77c80a8 --- /dev/null +++ b/src/popup/PopupController.ts @@ -0,0 +1,628 @@ +import { HighlightList, HighlightWord, ExportData } from '../types.js'; +import { StorageService } from '../services/StorageService.js'; +import { MessageService } from '../services/MessageService.js'; +import { DOMUtils } from '../utils/DOMUtils.js'; + +export class PopupController { + private lists: HighlightList[] = []; + private currentListIndex = 0; + private selectedCheckboxes = new Set(); + private globalHighlightEnabled = true; + private wordSearchQuery = ''; + private matchCaseEnabled = false; + private matchWholeEnabled = false; + private exceptionsList: string[] = []; + private currentTabHost = ''; + private sectionStates: Record = {}; + + async initialize(): Promise { + await this.loadData(); + await this.getCurrentTab(); + this.loadSectionStates(); + this.initializeSectionStates(); + this.setupEventListeners(); + this.render(); + } + + private async loadData(): Promise { + const data = await StorageService.get(); + this.lists = data.lists || []; + this.globalHighlightEnabled = data.globalHighlightEnabled ?? true; + this.matchCaseEnabled = data.matchCaseEnabled ?? false; + this.matchWholeEnabled = data.matchWholeEnabled ?? false; + this.exceptionsList = data.exceptionsList || []; + + if (this.lists.length === 0) { + this.lists.push({ + id: Date.now(), + name: chrome.i18n.getMessage('default_list_name') || 'Default List', + background: '#ffff00', + foreground: '#000000', + active: true, + words: [] + }); + } + } + + private async getCurrentTab(): Promise { + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (tab?.url) { + const url = new URL(tab.url); + this.currentTabHost = url.hostname; + } + } catch (e) { + console.warn('Could not get current tab:', e); + } + } + + private loadSectionStates(): void { + const saved = localStorage.getItem('goose-highlighter-section-states'); + if (saved) { + try { + this.sectionStates = JSON.parse(saved); + } catch { + this.sectionStates = {}; + } + } + } + + private saveSectionStates(): void { + localStorage.setItem('goose-highlighter-section-states', JSON.stringify(this.sectionStates)); + } + + private initializeSectionStates(): void { + Object.keys(this.sectionStates).forEach(sectionName => { + const section = document.querySelector(`[data-section="${sectionName}"]`); + if (section && this.sectionStates[sectionName]) { + section.classList.add('collapsed'); + } + }); + } + + private setupEventListeners(): void { + this.setupSectionToggles(); + this.setupListManagement(); + this.setupWordManagement(); + this.setupSettings(); + this.setupExceptions(); + this.setupImportExport(); + this.setupTheme(); + } + + private setupSectionToggles(): void { + document.querySelectorAll('.collapse-toggle').forEach(button => { + button.addEventListener('click', (e) => { + e.stopPropagation(); + const targetSection = (button as HTMLElement).getAttribute('data-target'); + if (targetSection) this.toggleSection(targetSection); + }); + }); + + document.querySelectorAll('.section-header').forEach(header => { + header.addEventListener('click', (e) => { + if ((e.target as HTMLElement).tagName === 'BUTTON' || + (e.target as HTMLElement).tagName === 'INPUT' || + (e.target as HTMLElement).closest('button')) { + return; + } + const section = (header as HTMLElement).closest('.section'); + const sectionName = section?.getAttribute('data-section'); + if (sectionName) this.toggleSection(sectionName); + }); + }); + } + + private toggleSection(sectionName: string): void { + const section = document.querySelector(`[data-section="${sectionName}"]`); + if (!section) return; + + const isCollapsed = section.classList.contains('collapsed'); + + if (isCollapsed) { + section.classList.remove('collapsed'); + this.sectionStates[sectionName] = false; + } else { + section.classList.add('collapsed'); + this.sectionStates[sectionName] = true; + } + + this.saveSectionStates(); + } + + private setupListManagement(): void { + const listSelect = document.getElementById('listSelect') as HTMLSelectElement; + const listName = document.getElementById('listName') as HTMLInputElement; + const listBg = document.getElementById('listBg') as HTMLInputElement; + const listFg = document.getElementById('listFg') as HTMLInputElement; + const listActive = document.getElementById('listActive') as HTMLInputElement; + + listSelect.addEventListener('change', () => { + this.selectedCheckboxes.clear(); + this.currentListIndex = +listSelect.value; + this.renderWords(); + this.updateListForm(); + }); + + listName.addEventListener('input', () => { + this.lists[this.currentListIndex].name = listName.value; + this.save(); + }); + + listBg.addEventListener('input', () => { + this.lists[this.currentListIndex].background = listBg.value; + this.save(); + }); + + listFg.addEventListener('input', () => { + this.lists[this.currentListIndex].foreground = listFg.value; + this.save(); + }); + + listActive.addEventListener('change', () => { + this.lists[this.currentListIndex].active = listActive.checked; + this.save(); + }); + + document.getElementById('newListBtn')?.addEventListener('click', () => { + this.lists.push({ + id: Date.now(), + name: chrome.i18n.getMessage('new_list_name') || 'New List', + background: '#ffff00', + foreground: '#000000', + active: true, + words: [] + }); + this.currentListIndex = this.lists.length - 1; + this.save(); + }); + + document.getElementById('deleteListBtn')?.addEventListener('click', () => { + if (confirm(chrome.i18n.getMessage('confirm_delete_list') || 'Delete this list?')) { + this.lists.splice(this.currentListIndex, 1); + this.currentListIndex = Math.max(0, this.currentListIndex - 1); + this.save(); + } + }); + } + + private setupWordManagement(): void { + const bulkPaste = document.getElementById('bulkPaste') as HTMLTextAreaElement; + const wordList = document.getElementById('wordList') as HTMLDivElement; + const wordSearch = document.getElementById('wordSearch') as HTMLInputElement; + + document.getElementById('addWordsBtn')?.addEventListener('click', () => { + const words = bulkPaste.value.split(/\n+/).map(w => w.trim()).filter(Boolean); + const list = this.lists[this.currentListIndex]; + for (const w of words) { + list.words.push({ + wordStr: w, + background: '', + foreground: '', + active: true + }); + } + bulkPaste.value = ''; + this.save(); + }); + + this.setupWordListEvents(wordList); + this.setupWordSelection(); + + wordSearch.addEventListener('input', (e) => { + this.wordSearchQuery = (e.target as HTMLInputElement).value; + this.renderWords(); + }); + } + + private setupWordListEvents(wordList: HTMLDivElement): void { + wordList.addEventListener('change', (e) => { + const target = e.target as HTMLInputElement; + if (target.type === 'checkbox') { + if (target.dataset.index != null) { + const index = +target.dataset.index; + if (target.checked) { + this.selectedCheckboxes.add(index); + } else { + this.selectedCheckboxes.delete(index); + } + this.renderWords(); + } else if (target.dataset.activeEdit != null) { + const index = +target.dataset.activeEdit; + this.lists[this.currentListIndex].words[index].active = target.checked; + this.save(); + } + } + }); + + wordList.addEventListener('input', (e) => { + const target = e.target as HTMLInputElement; + const index = +(target.dataset.wordEdit ?? target.dataset.bgEdit ?? target.dataset.fgEdit ?? -1); + if (index === -1) return; + + const word = this.lists[this.currentListIndex].words[index]; + if (target.dataset.wordEdit != null) word.wordStr = target.value; + if (target.dataset.bgEdit != null) word.background = target.value; + if (target.dataset.fgEdit != null) word.foreground = target.value; + + this.save(); + }); + + let scrollTimeout: number; + wordList.addEventListener('scroll', () => { + if (scrollTimeout) return; + scrollTimeout = window.setTimeout(() => { + requestAnimationFrame(() => this.renderWords()); + scrollTimeout = 0; + }, 16); + }); + } + + private setupWordSelection(): void { + document.getElementById('selectAllBtn')?.addEventListener('click', () => { + const list = this.lists[this.currentListIndex]; + list.words.forEach((_, index) => { + this.selectedCheckboxes.add(index); + }); + this.renderWords(); + }); + + document.getElementById('deselectAllBtn')?.addEventListener('click', () => { + this.selectedCheckboxes.clear(); + this.renderWords(); + }); + + document.getElementById('deleteSelectedBtn')?.addEventListener('click', () => { + if (confirm(chrome.i18n.getMessage('confirm_delete_words') || 'Delete selected words?')) { + const list = this.lists[this.currentListIndex]; + const toDelete = Array.from(this.selectedCheckboxes); + this.lists[this.currentListIndex].words = list.words.filter((_, i) => !toDelete.includes(i)); + this.selectedCheckboxes.clear(); + this.save(); + this.renderWords(); + } + }); + + document.getElementById('enableSelectedBtn')?.addEventListener('click', () => { + const list = this.lists[this.currentListIndex]; + this.selectedCheckboxes.forEach(index => { + list.words[index].active = true; + }); + this.save(); + this.renderWords(); + }); + + document.getElementById('disableSelectedBtn')?.addEventListener('click', () => { + const list = this.lists[this.currentListIndex]; + this.selectedCheckboxes.forEach(index => { + list.words[index].active = false; + }); + this.save(); + this.renderWords(); + }); + } + + private setupSettings(): void { + const globalToggle = document.getElementById('globalHighlightToggle') as HTMLInputElement; + const matchCase = document.getElementById('matchCase') as HTMLInputElement; + const matchWhole = document.getElementById('matchWhole') as HTMLInputElement; + + globalToggle.addEventListener('change', () => { + this.globalHighlightEnabled = globalToggle.checked; + this.updateGlobalToggleState(); + }); + + matchCase.addEventListener('change', () => { + this.matchCaseEnabled = matchCase.checked; + this.save(); + }); + + matchWhole.addEventListener('change', () => { + this.matchWholeEnabled = matchWhole.checked; + this.save(); + }); + } + + private setupExceptions(): void { + document.getElementById('toggleExceptionBtn')?.addEventListener('click', () => { + if (!this.currentTabHost) return; + + const isException = this.exceptionsList.includes(this.currentTabHost); + + if (isException) { + this.exceptionsList = this.exceptionsList.filter(domain => domain !== this.currentTabHost); + } else { + this.exceptionsList.push(this.currentTabHost); + } + + this.updateExceptionButton(); + this.renderExceptions(); + this.save(); + }); + + document.getElementById('manageExceptionsBtn')?.addEventListener('click', () => { + const panel = document.getElementById('exceptionsPanel'); + if (panel) { + const isVisible = panel.style.display !== 'none'; + panel.style.display = isVisible ? 'none' : 'block'; + } + }); + + document.getElementById('clearExceptionsBtn')?.addEventListener('click', () => { + if (confirm(chrome.i18n.getMessage('confirm_clear_exceptions') || 'Clear all exceptions?')) { + this.exceptionsList = []; + this.updateExceptionButton(); + this.renderExceptions(); + this.save(); + } + }); + + document.getElementById('exceptionsList')?.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + if (target.classList.contains('exception-remove')) { + const domain = target.dataset.domain!; + this.exceptionsList = this.exceptionsList.filter(d => d !== domain); + this.updateExceptionButton(); + this.renderExceptions(); + this.save(); + } + }); + } + + private setupImportExport(): void { + const importInput = document.getElementById('importInput') as HTMLInputElement; + + document.getElementById('exportBtn')?.addEventListener('click', () => { + const exportData: ExportData = { + lists: this.lists, + exceptionsList: this.exceptionsList + }; + const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'highlight-lists.json'; + a.click(); + URL.revokeObjectURL(url); + }); + + document.getElementById('importBtn')?.addEventListener('click', () => { + importInput.click(); + }); + + importInput.addEventListener('change', (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (e) => { + try { + const data = JSON.parse(e.target?.result as string); + + if (Array.isArray(data)) { + this.lists = data; + } else if (data && typeof data === 'object') { + if (Array.isArray(data.lists)) { + this.lists = data.lists; + } + if (Array.isArray(data.exceptionsList)) { + this.exceptionsList = data.exceptionsList; + } + } + + this.currentListIndex = 0; + this.updateExceptionButton(); + this.renderExceptions(); + this.save(); + } catch (err) { + alert(chrome.i18n.getMessage('invalid_json_error') + ': ' + (err as Error).message); + } + }; + reader.readAsText(file); + }); + } + + private setupTheme(): void { + const toggle = document.getElementById('themeToggle') as HTMLInputElement; + const body = document.body; + + const savedTheme = localStorage.getItem('theme'); + if (savedTheme === 'light') { + body.classList.remove('dark'); + body.classList.add('light'); + toggle.checked = false; + } else { + body.classList.add('dark'); + body.classList.remove('light'); + toggle.checked = true; + } + + toggle.addEventListener('change', () => { + if (toggle.checked) { + body.classList.add('dark'); + body.classList.remove('light'); + localStorage.setItem('theme', 'dark'); + } else { + body.classList.remove('dark'); + body.classList.add('light'); + localStorage.setItem('theme', 'light'); + } + }); + } + + private async save(): Promise { + await StorageService.set({ + lists: this.lists, + globalHighlightEnabled: this.globalHighlightEnabled, + matchCaseEnabled: this.matchCaseEnabled, + matchWholeEnabled: this.matchWholeEnabled, + exceptionsList: this.exceptionsList + }); + + this.render(); + MessageService.sendToAllTabs({ type: 'WORD_LIST_UPDATED' }); + MessageService.sendToAllTabs({ + type: 'GLOBAL_TOGGLE_UPDATED', + enabled: this.globalHighlightEnabled + }); + MessageService.sendToAllTabs({ + type: 'MATCH_OPTIONS_UPDATED', + matchCase: this.matchCaseEnabled, + matchWhole: this.matchWholeEnabled + }); + MessageService.sendToAllTabs({ type: 'EXCEPTIONS_LIST_UPDATED' }); + } + + private async updateGlobalToggleState(): Promise { + await StorageService.update('globalHighlightEnabled', this.globalHighlightEnabled); + MessageService.sendToAllTabs({ + type: 'GLOBAL_TOGGLE_UPDATED', + enabled: this.globalHighlightEnabled + }); + } + + private render(): void { + this.renderLists(); + this.renderWords(); + this.renderExceptions(); + this.updateExceptionButton(); + this.updateFormValues(); + } + + private renderLists(): void { + const listSelect = document.getElementById('listSelect') as HTMLSelectElement; + listSelect.innerHTML = this.lists.map((list, index) => + `` + ).join(''); + listSelect.value = this.currentListIndex.toString(); + this.updateListForm(); + } + + private updateListForm(): void { + const list = this.lists[this.currentListIndex]; + (document.getElementById('listName') as HTMLInputElement).value = list.name; + (document.getElementById('listBg') as HTMLInputElement).value = list.background; + (document.getElementById('listFg') as HTMLInputElement).value = list.foreground; + (document.getElementById('listActive') as HTMLInputElement).checked = list.active; + } + + private renderWords(): void { + const list = this.lists[this.currentListIndex]; + const wordList = document.getElementById('wordList') as HTMLDivElement; + + let filteredWords = list.words; + if (this.wordSearchQuery.trim()) { + const q = this.wordSearchQuery.trim().toLowerCase(); + filteredWords = list.words.filter(w => w.wordStr.toLowerCase().includes(q)); + } + + const itemHeight = 32; + const containerHeight = wordList.clientHeight; + const scrollTop = wordList.scrollTop; + const startIndex = Math.floor(scrollTop / itemHeight); + const endIndex = Math.min( + startIndex + Math.ceil(containerHeight / itemHeight) + 2, + filteredWords.length + ); + + wordList.innerHTML = ''; + + const spacer = document.createElement('div'); + spacer.style.position = 'relative'; + spacer.style.height = `${filteredWords.length * itemHeight}px`; + spacer.style.width = '100%'; + + for (let i = startIndex; i < endIndex; i++) { + const w = filteredWords[i]; + if (!w) continue; + + const realIndex = list.words.indexOf(w); + const container = this.createWordItem(w, realIndex, i, itemHeight); + spacer.appendChild(container); + } + + wordList.appendChild(spacer); + + const wordCount = document.getElementById('wordCount'); + if (wordCount) { + wordCount.textContent = filteredWords.length.toString(); + } + } + + private createWordItem(word: HighlightWord, realIndex: number, displayIndex: number, itemHeight: number): HTMLDivElement { + const container = document.createElement('div'); + container.style.cssText = ` + height: ${itemHeight}px; + position: absolute; + top: ${displayIndex * itemHeight}px; + width: calc(100% - 8px); + left: 4px; + right: 4px; + display: flex; + align-items: center; + gap: 6px; + padding: 0 4px; + box-sizing: border-box; + background: var(--highlight-tag); + border: 1px solid var(--highlight-tag-border); + `; + + const list = this.lists[this.currentListIndex]; + + container.innerHTML = ` + + + + + + `; + + return container; + } + + private updateExceptionButton(): void { + const toggleBtn = document.getElementById('toggleExceptionBtn'); + const btnText = document.getElementById('exceptionBtnText'); + + if (!toggleBtn || !btnText || !this.currentTabHost) return; + + const isException = this.exceptionsList.includes(this.currentTabHost); + + if (isException) { + btnText.textContent = chrome.i18n.getMessage('remove_exception') || 'Remove from Exceptions'; + toggleBtn.className = 'danger'; + const icon = toggleBtn.querySelector('i'); + if (icon) icon.className = 'fa-solid fa-check'; + } else { + btnText.textContent = chrome.i18n.getMessage('add_exception') || 'Add to Exceptions'; + toggleBtn.className = ''; + const icon = toggleBtn.querySelector('i'); + if (icon) icon.className = 'fa-solid fa-ban'; + } + } + + private renderExceptions(): void { + const container = document.getElementById('exceptionsList'); + if (!container) return; + + if (this.exceptionsList.length === 0) { + container.innerHTML = '
No exceptions
'; + return; + } + + container.innerHTML = this.exceptionsList.map(domain => + `
+ ${DOMUtils.escapeHtml(domain)} + +
` + ).join(''); + } + + private updateFormValues(): void { + (document.getElementById('globalHighlightToggle') as HTMLInputElement).checked = this.globalHighlightEnabled; + (document.getElementById('matchCase') as HTMLInputElement).checked = this.matchCaseEnabled; + (document.getElementById('matchWhole') as HTMLInputElement).checked = this.matchWholeEnabled; + } +} \ No newline at end of file diff --git a/src/popup/popup.ts b/src/popup/popup.ts index f25d08b..45edc5e 100644 --- a/src/popup/popup.ts +++ b/src/popup/popup.ts @@ -1,638 +1,22 @@ -// @ts-nocheck -const listSelect = document.getElementById('listSelect'); -const listName = document.getElementById('listName'); -const listBg = document.getElementById('listBg'); -const listFg = document.getElementById('listFg'); -const listActive = document.getElementById('listActive'); -const bulkPaste = document.getElementById('bulkPaste'); -const wordList = document.getElementById('wordList'); -const importInput = document.getElementById('importInput'); -const matchCase = document.getElementById('matchCase'); -const matchWhole = document.getElementById('matchWhole'); -let lists = []; -let currentListIndex = 0; -let selectedCheckboxes = new Set(); -let globalHighlightEnabled = true; -let wordSearchQuery = ''; -let matchCaseEnabled = false; -let matchWholeEnabled = false; -let exceptionsList = []; -let currentTabHost = ''; -let sectionStates = {}; +import { PopupController } from './PopupController.js'; -function loadSectionStates() { - const saved = localStorage.getItem('goose-highlighter-section-states'); - if (saved) { - try { - sectionStates = JSON.parse(saved); - } catch { - sectionStates = {}; - } - } -} - -function saveSectionStates() { - localStorage.setItem('goose-highlighter-section-states', JSON.stringify(sectionStates)); -} - -function toggleSection(sectionName) { - const section = document.querySelector(`[data-section="${sectionName}"]`); - if (!section) return; - - const isCollapsed = section.classList.contains('collapsed'); - - if (isCollapsed) { - section.classList.remove('collapsed'); - sectionStates[sectionName] = false; - } else { - section.classList.add('collapsed'); - sectionStates[sectionName] = true; - } - - saveSectionStates(); -} - -function initializeSectionStates() { - loadSectionStates(); - - // Apply saved states - Object.keys(sectionStates).forEach(sectionName => { - const section = document.querySelector(`[data-section="${sectionName}"]`); - if (section && sectionStates[sectionName]) { - section.classList.add('collapsed'); - } - }); -} - -function escapeHtml(str) { - return str.replace(/[&<>"']/g, function (m) { - return ({ - '&': '&', - '<': '<', - '>': '>', - '"': '"', - '\'': ''' - })[m]; - }); -} - -async function save() { - await chrome.storage.local.set({ - lists: lists, - globalHighlightEnabled: globalHighlightEnabled, - matchCaseEnabled, - matchWholeEnabled, - exceptionsList - }); - renderLists(); - renderWords(); - - chrome.tabs.query({}, function (tabs) { - for (let tab of tabs) { - if (tab.id) { - chrome.tabs.sendMessage(tab.id, { type: 'WORD_LIST_UPDATED' }); - chrome.tabs.sendMessage(tab.id, { - type: 'GLOBAL_TOGGLE_UPDATED', - enabled: globalHighlightEnabled - }); - chrome.tabs.sendMessage(tab.id, { - type: 'MATCH_OPTIONS_UPDATED', - matchCase: matchCaseEnabled, - matchWhole: matchWholeEnabled - }); - chrome.tabs.sendMessage(tab.id, { type: 'EXCEPTIONS_LIST_UPDATED' }); +function localizePage(): void { + const elements = document.querySelectorAll('[data-i18n]'); + elements.forEach(element => { + const message = (element as HTMLElement).dataset.i18n!; + const localizedText = chrome.i18n.getMessage(message); + if (localizedText) { + if (element.tagName === 'INPUT' && (element as HTMLInputElement).hasAttribute('placeholder')) { + (element as HTMLInputElement).placeholder = localizedText; + } else { + element.textContent = localizedText; } } }); } -async function updateGlobalToggleState() { - await chrome.storage.local.set({ globalHighlightEnabled: globalHighlightEnabled }); - chrome.tabs.query({}, function (tabs) { - for (let tab of tabs) { - if (tab.id) { - chrome.tabs.sendMessage(tab.id, { - type: 'GLOBAL_TOGGLE_UPDATED', - enabled: globalHighlightEnabled - }); - } - } - }); -} - -async function load() { - const res = await chrome.storage.local.get({ - lists: [], - globalHighlightEnabled: true, - matchCaseEnabled: false, - matchWholeEnabled: false, - exceptionsList: [] - }); - lists = res.lists; - globalHighlightEnabled = res.globalHighlightEnabled !== false; - matchCaseEnabled = !!res.matchCaseEnabled; - matchWholeEnabled = !!res.matchWholeEnabled; - exceptionsList = res.exceptionsList || []; - matchCase.checked = matchCaseEnabled; - matchWhole.checked = matchWholeEnabled; - - try { - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); - if (tab && tab.url) { - const url = new URL(tab.url); - currentTabHost = url.hostname; - updateExceptionButton(); - } - } catch (e) { - console.warn('Could not get current tab:', e); - } - - if (!lists.length) { - lists.push({ - id: Date.now(), - name: chrome.i18n.getMessage('default_list_name'), - background: '#ffff00', - foreground: '#000000', - active: true, - words: [] - }); - } - renderLists(); - renderWords(); - renderExceptions(); - - document.getElementById('globalHighlightToggle').checked = globalHighlightEnabled; -} - -function renderLists() { - listSelect.innerHTML = lists.map((list, index) => - `` - ).join(''); - listSelect.value = currentListIndex; - updateListForm(); -} - -function updateListForm() { - const list = lists[currentListIndex]; - listName.value = list.name; - listBg.value = list.background; - listFg.value = list.foreground; - listActive.checked = list.active; -} - -function renderWords() { - const list = lists[currentListIndex]; - - let filteredWords = list.words; - if (wordSearchQuery.trim()) { - const q = wordSearchQuery.trim().toLowerCase(); - filteredWords = list.words.filter(w => w.wordStr.toLowerCase().includes(q)); - } - - const itemHeight = 32; - const containerHeight = wordList.clientHeight; - const scrollTop = wordList.scrollTop; - const startIndex = Math.floor(scrollTop / itemHeight); - const endIndex = Math.min( - startIndex + Math.ceil(containerHeight / itemHeight) + 2, - filteredWords.length - ); - - wordList.innerHTML = ''; - - const spacer = document.createElement('div'); - spacer.style.position = 'relative'; - spacer.style.height = `${filteredWords.length * itemHeight}px`; - spacer.style.width = '100%'; - - for (let i = startIndex; i < endIndex; i++) { - const w = filteredWords[i]; - if (!w) continue; - const container = document.createElement('div'); - container.style.height = `${itemHeight}px`; - container.style.position = 'absolute'; - container.style.top = `${i * itemHeight}px`; - container.style.width = 'calc(100% - 8px)'; - container.style.left = '4px'; - container.style.right = '4px'; - container.style.display = 'flex'; - container.style.alignItems = 'center'; - container.style.gap = '6px'; - container.style.padding = '0 4px'; - container.style.boxSizing = 'border-box'; - container.style.background = 'var(--highlight-tag)'; - container.style.border = '1px solid var(--highlight-tag-border)'; - - const realIndex = list.words.indexOf(w); - - const cbSelect = document.createElement('input'); - cbSelect.type = 'checkbox'; - cbSelect.className = 'word-checkbox'; - cbSelect.dataset.index = realIndex; - if (selectedCheckboxes.has(realIndex)) { - cbSelect.checked = true; - } - - const inputWord = document.createElement('input'); - inputWord.type = 'text'; - inputWord.value = w.wordStr; - inputWord.dataset.wordEdit = realIndex; - inputWord.style.flexGrow = '1'; - inputWord.style.minWidth = '0'; - inputWord.style.padding = '4px 8px'; - inputWord.style.borderRadius = '4px'; - inputWord.style.border = '1px solid var(--input-border)'; - inputWord.style.backgroundColor = 'var(--input-bg)'; - inputWord.style.color = 'var(--text-color)'; - - const inputBg = document.createElement('input'); - inputBg.type = 'color'; - inputBg.value = w.background || list.background; - inputBg.dataset.bgEdit = realIndex; - inputBg.style.width = '24px'; - inputBg.style.height = '24px'; - inputBg.style.flexShrink = '0'; - - const inputFg = document.createElement('input'); - inputFg.type = 'color'; - inputFg.value = w.foreground || list.foreground; - inputFg.dataset.fgEdit = realIndex; - inputFg.style.width = '24px'; - inputFg.style.height = '24px'; - inputFg.style.flexShrink = '0'; - - const activeContainer = document.createElement('label'); - activeContainer.className = 'word-active'; - activeContainer.style.display = 'flex'; - activeContainer.style.alignItems = 'center'; - activeContainer.style.gap = '4px'; - activeContainer.style.flexShrink = '0'; - - const cbActive = document.createElement('input'); - cbActive.type = 'checkbox'; - cbActive.checked = w.active !== false; - cbActive.dataset.activeEdit = realIndex; - cbActive.className = 'switch'; - - activeContainer.appendChild(cbActive); - - container.appendChild(cbSelect); - container.appendChild(inputWord); - container.appendChild(inputBg); - container.appendChild(inputFg); - container.appendChild(activeContainer); - - spacer.appendChild(container); - } - - wordList.appendChild(spacer); - - const wordCount = document.getElementById('wordCount'); - if (wordCount) { - wordCount.textContent = filteredWords.length; - } -} - -function updateExceptionButton() { - const toggleBtn = document.getElementById('toggleExceptionBtn'); - const btnText = document.getElementById('exceptionBtnText'); - - if (!toggleBtn || !btnText || !currentTabHost) return; - - const isException = exceptionsList.includes(currentTabHost); - - if (isException) { - btnText.textContent = chrome.i18n.getMessage('remove_exception') || 'Remove from Exceptions'; - toggleBtn.className = 'danger'; - toggleBtn.querySelector('i').className = 'fa-solid fa-check'; - } else { - btnText.textContent = chrome.i18n.getMessage('add_exception') || 'Add to Exceptions'; - toggleBtn.className = ''; - toggleBtn.querySelector('i').className = 'fa-solid fa-ban'; - } -} - -function renderExceptions() { - const container = document.getElementById('exceptionsList'); - if (!container) return; - - if (exceptionsList.length === 0) { - container.innerHTML = '
No exceptions
'; - return; - } - - container.innerHTML = exceptionsList.map(domain => - `
- ${escapeHtml(domain)} - -
` - ).join(''); -} - -document.addEventListener('DOMContentLoaded', () => { - initializeSectionStates(); +document.addEventListener('DOMContentLoaded', async () => { localizePage(); - - // Add event listeners for collapse toggles - document.querySelectorAll('.collapse-toggle').forEach(button => { - button.addEventListener('click', (e) => { - e.stopPropagation(); - const targetSection = button.getAttribute('data-target'); - toggleSection(targetSection); - }); - }); - - // Also allow clicking section headers to toggle - document.querySelectorAll('.section-header').forEach(header => { - header.addEventListener('click', (e) => { - // Don't toggle if clicking on a button or input within the header - if (e.target.tagName === 'BUTTON' || e.target.tagName === 'INPUT' || e.target.closest('button')) { - return; - } - const section = header.closest('.section'); - const sectionName = section.getAttribute('data-section'); - if (sectionName) { - toggleSection(sectionName); - } - }); - }); - - document.getElementById('selectAllBtn').onclick = () => { - const list = lists[currentListIndex]; - list.words.forEach((_, index) => { - selectedCheckboxes.add(index); - }); - renderWords(); - }; - - document.getElementById('globalHighlightToggle').addEventListener('change', function () { - globalHighlightEnabled = this.checked; - updateGlobalToggleState(); - }); - - wordList.addEventListener('change', e => { - if (e.target.type === 'checkbox') { - if (e.target.dataset.index != null) { - if (e.target.checked) { - selectedCheckboxes.add(+e.target.dataset.index); - } else { - selectedCheckboxes.delete(+e.target.dataset.index); - } - renderWords(); - } else if (e.target.dataset.activeEdit != null) { - lists[currentListIndex].words[e.target.dataset.activeEdit].active = e.target.checked; - save(); - } - } - }); - - let scrollTimeout; - wordList.addEventListener('scroll', () => { - if (scrollTimeout) { - return; - } - scrollTimeout = setTimeout(() => { - requestAnimationFrame(renderWords); - scrollTimeout = null; - }, 16); // ~60fps - }); - - listSelect.onchange = () => { - selectedCheckboxes.clear(); - currentListIndex = +listSelect.value; - renderWords(); - updateListForm(); - }; - - document.getElementById('newListBtn').onclick = () => { - lists.push({ - id: Date.now(), - name: chrome.i18n.getMessage('new_list_name'), - background: '#ffff00', - foreground: '#000000', - active: true, - words: [] - }); - currentListIndex = lists.length - 1; - save(); - }; - - document.getElementById('deleteListBtn').onclick = () => { - if (confirm(chrome.i18n.getMessage('confirm_delete_list'))) { - lists.splice(currentListIndex, 1); - currentListIndex = Math.max(0, currentListIndex - 1); - save(); - } - }; - - listName.oninput = () => { lists[currentListIndex].name = listName.value; save(); }; - listBg.oninput = () => { lists[currentListIndex].background = listBg.value; save(); }; - listFg.oninput = () => { lists[currentListIndex].foreground = listFg.value; save(); }; - listActive.onchange = () => { lists[currentListIndex].active = listActive.checked; save(); }; - - document.getElementById('addWordsBtn').onclick = () => { - const words = bulkPaste.value.split(/\n+/).map(w => w.trim()).filter(Boolean); - const list = lists[currentListIndex]; - for (const w of words) list.words.push({ wordStr: w, background: '', foreground: '', active: true }); - bulkPaste.value = ''; - save(); - }; - - document.getElementById('deleteSelectedBtn').onclick = () => { - if (confirm(chrome.i18n.getMessage('confirm_delete_words'))) { - const list = lists[currentListIndex]; - const toDelete = Array.from(selectedCheckboxes); - lists[currentListIndex].words = list.words.filter((_, i) => !toDelete.includes(i)); - selectedCheckboxes.clear(); - save(); - renderWords(); - } - }; - - document.getElementById('disableSelectedBtn').onclick = () => { - const list = lists[currentListIndex]; - selectedCheckboxes.forEach(index => { - list.words[index].active = false; - }); - save(); - renderWords(); - }; - - document.getElementById('enableSelectedBtn').onclick = () => { - const list = lists[currentListIndex]; - selectedCheckboxes.forEach(index => { - list.words[index].active = true; - }); - save(); - renderWords(); - }; - - wordList.addEventListener('input', e => { - const index = e.target.dataset.wordEdit ?? e.target.dataset.bgEdit ?? e.target.dataset.fgEdit; - if (index == null) return; - - const word = lists[currentListIndex].words[index]; - if (e.target.dataset.wordEdit != null) word.wordStr = e.target.value; - if (e.target.dataset.bgEdit != null) word.background = e.target.value; - if (e.target.dataset.fgEdit != null) word.foreground = e.target.value; - - save(); - }); - - const exportBtn = document.getElementById('exportBtn'); - exportBtn.onclick = () => { - const exportData = { - lists: lists, - exceptionsList: exceptionsList - }; - const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'highlight-lists.json'; - a.click(); - URL.revokeObjectURL(url); - }; - - const importBtn = document.getElementById('importBtn'); - importBtn.onclick = () => importInput.click(); - - importInput.onchange = e => { - const file = e.target.files[0]; - if (!file) return; - const reader = new FileReader(); - reader.onload = e => { - try { - const data = JSON.parse(e.target.result); - - if (Array.isArray(data)) { - // Old format - just lists - lists = data; - } else if (data && typeof data === 'object') { - // New format - object with lists and exceptions - if (Array.isArray(data.lists)) { - lists = data.lists; - } - if (Array.isArray(data.exceptionsList)) { - exceptionsList = data.exceptionsList; - } - } - - currentListIndex = 0; - updateExceptionButton(); - renderExceptions(); - save(); - } catch (err) { - alert(chrome.i18n.getMessage('invalid_json_error:' + err.message)); - } - }; - reader.readAsText(file); - }; - - function localizePage() { - const elements = document.querySelectorAll('[data-i18n]'); - elements.forEach(element => { - const message = element.dataset.i18n; - const localizedText = chrome.i18n.getMessage(message); - if (localizedText) { - if (element.tagName === 'INPUT' && element.hasAttribute('placeholder')) { - element.placeholder = localizedText; - } else { - element.textContent = localizedText; - } - } - }); - } - - const toggle = document.getElementById('themeToggle'); - const body = document.body; - - const savedTheme = localStorage.getItem('theme'); - if (savedTheme === 'light') { - body.classList.remove('dark'); - body.classList.add('light'); - toggle.checked = false; - } else { - body.classList.add('dark'); - body.classList.remove('light'); - toggle.checked = true; - } - - toggle.addEventListener('change', () => { - if (toggle.checked) { - body.classList.add('dark'); - body.classList.remove('light'); - localStorage.setItem('theme', 'dark'); - } else { - body.classList.remove('dark'); - body.classList.add('light'); - localStorage.setItem('theme', 'light'); - } - }); - - document.getElementById('deselectAllBtn').onclick = () => { - selectedCheckboxes.clear(); - renderWords(); - }; - - const wordSearch = document.getElementById('wordSearch'); - wordSearch.addEventListener('input', (e) => { - wordSearchQuery = e.target.value; - renderWords(); - }); - - matchCase.addEventListener('change', () => { - matchCaseEnabled = matchCase.checked; - save(); - }); - matchWhole.addEventListener('change', () => { - matchWholeEnabled = matchWhole.checked; - save(); - }); - - document.getElementById('toggleExceptionBtn').addEventListener('click', () => { - if (!currentTabHost) return; - - const isException = exceptionsList.includes(currentTabHost); - - if (isException) { - exceptionsList = exceptionsList.filter(domain => domain !== currentTabHost); - } else { - exceptionsList.push(currentTabHost); - } - - updateExceptionButton(); - renderExceptions(); - save(); - }); - - document.getElementById('manageExceptionsBtn').addEventListener('click', () => { - const panel = document.getElementById('exceptionsPanel'); - if (panel.style.display === 'none') { - panel.style.display = 'block'; - } else { - panel.style.display = 'none'; - } - }); - - document.getElementById('clearExceptionsBtn').addEventListener('click', () => { - if (confirm(chrome.i18n.getMessage('confirm_clear_exceptions') || 'Clear all exceptions?')) { - exceptionsList = []; - updateExceptionButton(); - renderExceptions(); - save(); - } - }); - - document.getElementById('exceptionsList').addEventListener('click', (e) => { - if (e.target.classList.contains('exception-remove')) { - const domain = e.target.dataset.domain; - exceptionsList = exceptionsList.filter(d => d !== domain); - updateExceptionButton(); - renderExceptions(); - save(); - } - }); - - load(); -}); + const controller = new PopupController(); + await controller.initialize(); +}); \ No newline at end of file diff --git a/src/services/MessageService.ts b/src/services/MessageService.ts new file mode 100644 index 0000000..93fe198 --- /dev/null +++ b/src/services/MessageService.ts @@ -0,0 +1,25 @@ +import { MessageData } from '../types.js'; + +export class MessageService { + static sendToAllTabs(message: MessageData): void { + chrome.tabs.query({}, (tabs) => { + tabs.forEach(tab => { + if (tab.id) { + chrome.tabs.sendMessage(tab.id, message).catch(() => { + // Ignore errors for tabs that can't receive messages + }); + } + }); + }); + } + + static sendToTab(tabId: number, message: MessageData): void { + chrome.tabs.sendMessage(tabId, message).catch(() => { + // Ignore errors for tabs that can't receive messages + }); + } + + static onMessage(callback: (message: MessageData) => void): void { + chrome.runtime.onMessage.addListener(callback); + } +} \ No newline at end of file diff --git a/src/services/StorageService.ts b/src/services/StorageService.ts new file mode 100644 index 0000000..fcf00e8 --- /dev/null +++ b/src/services/StorageService.ts @@ -0,0 +1,25 @@ +import { StorageData, DEFAULT_STORAGE } from '../types.js'; + +export class StorageService { + static async get(keys: K[]): Promise>; + static async get(): Promise; + static async get(keys?: (keyof StorageData)[]): Promise { + const defaults = DEFAULT_STORAGE; + if (keys) { + const keyDefaults: any = {}; + keys.forEach(key => { + keyDefaults[key] = defaults[key]; + }); + return chrome.storage.local.get(keyDefaults); + } + return chrome.storage.local.get(defaults); + } + + static async set(data: Partial): Promise { + return chrome.storage.local.set(data); + } + + static async update(key: K, value: StorageData[K]): Promise { + return this.set({ [key]: value } as Partial); + } +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 4dcf082..d4c5994 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,3 @@ -// Type definitions for the Goose Highlighter extension - export interface HighlightWord { wordStr: string; background: string; @@ -37,31 +35,12 @@ export interface MessageData { matchWhole?: boolean; } -export interface SectionStates { - [sectionName: string]: boolean; -} - export interface ExportData { lists: HighlightList[]; exceptionsList: string[]; } -// DOM element selectors used in popup -export interface PopupElements { - listSelect: HTMLSelectElement; - listName: HTMLInputElement; - listBg: HTMLInputElement; - listFg: HTMLInputElement; - listActive: HTMLInputElement; - bulkPaste: HTMLTextAreaElement; - wordList: HTMLDivElement; - importInput: HTMLInputElement; - matchCase: HTMLInputElement; - matchWhole: HTMLInputElement; -} - -// Default storage values -export const DEFAULT_STORAGE: Partial = { +export const DEFAULT_STORAGE: StorageData = { lists: [], globalHighlightEnabled: true, matchCaseEnabled: false, @@ -69,7 +48,8 @@ export const DEFAULT_STORAGE: Partial = { exceptionsList: [] }; -// Constants -export const WORD_ITEM_HEIGHT = 32; -export const DEBOUNCE_DELAY = 300; -export const SCROLL_THROTTLE = 16; // ~60fps \ No newline at end of file +export const CONSTANTS = { + WORD_ITEM_HEIGHT: 32, + DEBOUNCE_DELAY: 300, + SCROLL_THROTTLE: 16 +} as const; \ No newline at end of file diff --git a/src/utils/DOMUtils.ts b/src/utils/DOMUtils.ts new file mode 100644 index 0000000..59db4ba --- /dev/null +++ b/src/utils/DOMUtils.ts @@ -0,0 +1,41 @@ +export class DOMUtils { + static escapeHtml(str: string): string { + const escapeMap: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return str.replace(/[&<>"']/g, (match) => escapeMap[match]); + } + + static escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + static debounce void>( + func: T, + wait: number + ): (...args: Parameters) => void { + let timeout: number; + return (...args: Parameters) => { + clearTimeout(timeout); + timeout = window.setTimeout(() => func(...args), wait); + }; + } + + static throttle void>( + func: T, + limit: number + ): (...args: Parameters) => void { + let inThrottle: boolean; + return (...args: Parameters) => { + if (!inThrottle) { + func(...args); + inThrottle = true; + setTimeout(() => inThrottle = false, limit); + } + }; + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 138a4e1..60e3105 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2022", - "module": "CommonJS", + "module": "ES2022", "lib": ["ES2022", "DOM"], "outDir": "./dist", "rootDir": "./src",