11 Commits

Author SHA1 Message Date
71216cbcd9 ci: update manifest in publish workflow 2025-10-08 17:54:06 +03:00
f292bd7149 nit: added footer with version and github. 2025-10-08 16:32:04 +03:00
584ced252f ci: publish on version tag push 2025-10-08 16:26:32 +03:00
semantic-release-bot
ff5752da84 chore(release): 1.8.3
## [1.8.3](https://github.com/obsqrbtz/goose-highlighter/compare/v1.8.2...v1.8.3) (2025-10-08)

### Bug Fixes

* stop observing when highlightting is disabled ([d7c8dbb](d7c8dbb5f0))
2025-10-08 16:11:52 +03:00
d7c8dbb5f0 fix: stop observing when highlightting is disabled 2025-10-08 16:11:25 +03:00
semantic-release-bot
bc02d0fb77 chore(release): 1.8.2
## [1.8.2](https://github.com/obsqrbtz/goose-highlighter/compare/v1.8.1...v1.8.2) (2025-10-08)

### Bug Fixes

* do not call save() on all keypresses in textboxes ([687d7c9](687d7c9e62))
* do not save anything in list settings section until presses the apply button ([0734bf3](0734bf3308))
2025-10-08 14:33:46 +03:00
0734bf3308 fix: do not save anything in list settings section until presses the apply button 2025-10-08 14:33:28 +03:00
687d7c9e62 fix: do not call save() on all keypresses in textboxes 2025-10-08 14:11:08 +03:00
58d48be6e4 chore: refactor 2025-10-08 13:53:47 +03:00
00d2cc592a ci: exclude scripts dir from release package 2025-10-08 11:42:37 +03:00
a6bc14ac76 docs: update readme 2025-10-08 11:34:33 +03:00
22 changed files with 1269 additions and 892 deletions

View File

@@ -2,7 +2,8 @@ name: Publish Chrome Extension
on: on:
push: push:
branches: [ main ] tags:
- 'v*'
jobs: jobs:
publish: publish:
@@ -32,9 +33,12 @@ jobs:
id: version id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Update manifest version
run: node scripts/update-manifest-version.js ${{ steps.version.outputs.VERSION }}
- name: Create zip package - name: Create zip package
run: | run: |
zip -r goose-highlighter.zip . -x '*.git*' 'node_modules/*' 'src/*' 'versioning.md' '.releaserc.json' 'package.json' 'package-lock.json' 'README.md' 'tsconfig.json' 'eslint.config.mjs' zip -r goose-highlighter.zip . -x '*.git*' 'node_modules/*' 'src/*' 'scripts/*' 'versioning.md' '.releaserc.json' 'package.json' 'package-lock.json' 'README.md' 'tsconfig.json' 'eslint.config.mjs'
- name: Install webstore upload CLI - name: Install webstore upload CLI
run: npm install -g chrome-webstore-upload-cli run: npm install -g chrome-webstore-upload-cli

View File

@@ -39,7 +39,7 @@ jobs:
- name: Create extension zip - name: Create extension zip
run: | run: |
zip -r goose-highlighter-${{ steps.version.outputs.VERSION }}.zip . \ zip -r goose-highlighter-${{ steps.version.outputs.VERSION }}.zip . \
-x '*.git*' 'node_modules/*' 'src/*' 'versioning.md' '.releaserc.json' \ -x '*.git*' 'node_modules/*' 'src/*' 'scripts/*' 'versioning.md' '.releaserc.json' \
'package.json' 'package-lock.json' 'README.md' 'tsconfig.json' \ 'package.json' 'package-lock.json' 'README.md' 'tsconfig.json' \
'eslint.config.mjs' '.github/*' 'CHANGELOG.md' 'PRIVACY.MD' 'eslint.config.mjs' '.github/*' 'CHANGELOG.md' 'PRIVACY.MD'
@@ -57,7 +57,7 @@ jobs:
# Copy all files except excluded ones to temp directory # Copy all files except excluded ones to temp directory
rsync -av --exclude='.git*' --exclude='node_modules' --exclude='src' \ rsync -av --exclude='.git*' --exclude='node_modules' --exclude='src' \
--exclude='versioning.md' --exclude='.releaserc.json' \ --exclude='scripts' --exclude='versioning.md' --exclude='.releaserc.json' \
--exclude='package.json' --exclude='package-lock.json' \ --exclude='package.json' --exclude='package-lock.json' \
--exclude='README.md' --exclude='tsconfig.json' \ --exclude='README.md' --exclude='tsconfig.json' \
--exclude='eslint.config.mjs' --exclude='.github' \ --exclude='eslint.config.mjs' --exclude='.github' \

3
.gitignore vendored
View File

@@ -1,3 +1,6 @@
node_modules node_modules
dist dist
# Auto-generated files
src/content-standalone.ts

View File

@@ -1,3 +1,18 @@
## [1.8.3](https://github.com/obsqrbtz/goose-highlighter/compare/v1.8.2...v1.8.3) (2025-10-08)
### Bug Fixes
* stop observing when highlightting is disabled ([d7c8dbb](https://github.com/obsqrbtz/goose-highlighter/commit/d7c8dbb5f0011afe83739841218aa737794074e3))
## [1.8.2](https://github.com/obsqrbtz/goose-highlighter/compare/v1.8.1...v1.8.2) (2025-10-08)
### Bug Fixes
* do not call save() on all keypresses in textboxes ([687d7c9](https://github.com/obsqrbtz/goose-highlighter/commit/687d7c9e62f0f282ce73e86cdc62aaf275c9dafe))
* do not save anything in list settings section until presses the apply button ([0734bf3](https://github.com/obsqrbtz/goose-highlighter/commit/0734bf330824c60f0d5c4784e99660b9e652efd6))
# [1.8.0](https://github.com/obsqrbtz/goose-highlighter/compare/v1.7.2...v1.8.0) (2025-10-07) # [1.8.0](https://github.com/obsqrbtz/goose-highlighter/compare/v1.7.2...v1.8.0) (2025-10-07)

View File

@@ -1,16 +1,58 @@
# Goose Highlighter # Goose Highlighter
Goose Highlighter is a browser extension that allows you to highlight custom words and phrases on any webpage. Organize your highlights into lists, customize their appearance, and toggle highlighting or theme modes with ease. Goose Highlighter is a browser extension that allows you to highlight words on any webpage.
## Features ## Features
- **Multiple Highlight Lists:** Organize words into separate lists. - **Multiple Highlight Lists:** Organize words into separate lists.
- **Custom Colors:** Set background and foreground for each list and individual word. - **Custom Colors:** Set background and foreground for each list or individual word.
- **Bulk Add:** Paste multiple words at once. - **Bulk Add:** Paste multiple words at once.
- **Enable/Disable:** Toggle highlighting globally, per list, or per word. - **Enable/Disable:** Toggle highlighting globally, per list, or per word.
- **Site Exceptions:** Add websites to an exceptions list to disable highlighting on specific sites. - **Site Exceptions:** Add specific websites to an exceptions list to disable highlighting there.
- **Import/Export:** Backup or share your highlight lists and exceptions as JSON files. - **Import/Export:** Backup or share your highlight lists and exceptions as JSON files.
## Install ## Install
- go to [Chrome Web Store page](https://chromewebstore.google.com/detail/goose-highlighter/kdoehicejfnccbmecpkfjlbljpfogoep) and choose `Add to chrome`. ### From Chrome Web Store (Recommended)
- Go to [Chrome Web Store page](https://chromewebstore.google.com/detail/goose-highlighter/kdoehicejfnccbmecpkfjlbljpfogoep) and choose `Add to chrome`.
### Manual Installation
#### Option 1: Install from CRX File (Releases)
1. **Download:** Get the latest `.crx` file from the [Releases section](https://github.com/obsqrbtz/goose-highlighter/releases)
2. **Install in Chrome:**
- Open Chrome and go to `chrome://extensions/`
- Enable "Developer mode" (toggle in top-right corner)
- Drag and drop the `.crx` file onto the extensions page
- Click "Add extension" when prompted
#### Option 2: Install from ZIP File (Releases)
1. **Download:** Get the latest `.zip` file from the [Releases section](https://github.com/obsqrbtz/goose-highlighter/releases)
2. **Extract:** Unzip the downloaded file to a folder of your choice
3. **Load in Chrome:**
- Open Chrome and go to `chrome://extensions/`
- Enable "Developer mode" (toggle in top-right corner)
- Click "Load unpacked" button
- Select the extracted folder containing the extension files
#### Option 3: Build from Source
1. **Prerequisites:** Node.js 20+ and npm
2. **Clone the repository:**
```bash
git clone https://github.com/obsqrbtz/goose-highlighter.git
cd goose-highlighter
```
3. **Install dependencies:**
```bash
npm install
```
4. **Build the extension:**
```bash
npm run build
```
5. **Load in Chrome:**
- Open Chrome and go to `chrome://extensions/`
- Enable "Developer mode" (toggle in top-right corner)
- Click "Load unpacked" button
- Select the entire `goose-highlighter` folder (not the `dist` folder)

View File

@@ -2,7 +2,7 @@
"manifest_version": 3, "manifest_version": 3,
"name": "__MSG_extension_name__", "name": "__MSG_extension_name__",
"description": "__MSG_extension_description__", "description": "__MSG_extension_description__",
"version": "1.8.0", "version": "1.8.3",
"default_locale": "en", "default_locale": "en",
"permissions": [ "permissions": [
"scripting", "scripting",
@@ -17,7 +17,8 @@
"default_icon": "icons/icon128.png" "default_icon": "icons/icon128.png"
}, },
"background": { "background": {
"service_worker": "dist/background.js" "service_worker": "dist/background.js",
"type": "module"
}, },
"icons": { "icons": {
"48": "icons/icon48.png", "48": "icons/icon48.png",

View File

@@ -12,7 +12,7 @@
"typescript": "^5.6.0" "typescript": "^5.6.0"
}, },
"scripts": { "scripts": {
"build": "tsc", "build": "node scripts/build-content-standalone.js && tsc",
"watch": "tsc --watch", "watch": "tsc --watch",
"clean": "rimraf dist", "clean": "rimraf dist",
"rebuild": "npm run clean && npm run build", "rebuild": "npm run clean && npm run build",

View File

@@ -613,3 +613,39 @@ body::-webkit-scrollbar-corner,
.exception-remove:hover { .exception-remove:hover {
background: #d00030; background: #d00030;
} }
/* Footer Styles */
.footer {
margin-top: 16px;
padding: 12px 16px;
border-top: 1px solid var(--input-border);
background: var(--section-bg);
}
.footer-content {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8em;
color: var(--text-color);
opacity: 0.7;
}
.version {
font-weight: 500;
}
.github-link {
display: flex;
align-items: center;
gap: 4px;
color: var(--text-color);
text-decoration: none;
opacity: 0.7;
transition: opacity 0.2s ease, color 0.2s ease;
}
.github-link:hover {
opacity: 1;
color: var(--accent);
}

View File

@@ -90,6 +90,7 @@
<span data-i18n="enable_highlight">Enable Highlighting</span> <span data-i18n="enable_highlight">Enable Highlighting</span>
<input type="checkbox" class="switch" id="listActive" /> <input type="checkbox" class="switch" id="listActive" />
</label> </label>
<button id="applyListSettingsBtn"><i class="fa-solid fa-check"></i> <span data-i18n="apply">Apply</span></button>
</div> </div>
</div> </div>
@@ -148,7 +149,17 @@
</div> </div>
</div> </div>
<script src="../dist/popup/popup.js"></script> <footer class="footer">
<div class="footer-content">
<span class="version">v<span id="version-number">...</span></span>
<a href="https://github.com/obsqrbtz/goose-highlighter" target="_blank" class="github-link">
<i class="fa-brands fa-github"></i>
<span>GitHub</span>
</a>
</div>
</footer>
<script type="module" src="../dist/popup/popup.js"></script>
</body> </body>
</html> </html>

View File

@@ -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');

View File

@@ -2,7 +2,7 @@ const fs = require('fs');
const version = process.argv[2]; const version = process.argv[2];
if (!version) { if (!version) {
console.log(' No version passed, skipping manifest update'); console.log('No version passed, skipping manifest update');
process.exit(0); process.exit(0);
} }
@@ -10,4 +10,4 @@ const manifest = JSON.parse(fs.readFileSync('manifest.json', 'utf-8'));
manifest.version = version; manifest.version = version;
fs.writeFileSync('manifest.json', JSON.stringify(manifest, null, 2)); fs.writeFileSync('manifest.json', JSON.stringify(manifest, null, 2));
console.log(`Updated manifest.json to version ${version}`); console.log(`Updated manifest.json to version ${version}`);

View File

@@ -1,20 +1,36 @@
// Handle tab updates to inject content script import { StorageService } from './services/StorageService.js';
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)) { class BackgroundService {
chrome.scripting.executeScript({ constructor() {
target: { tabId }, this.initialize();
files: ['dist/main.js'] }
}).catch((err: unknown) => {
console.warn('Injection failed:', err); 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 private setupInstallListener(): void {
chrome.runtime.onInstalled.addListener((): void => { chrome.runtime.onInstalled.addListener(async (): Promise<void> => {
chrome.storage.local.get(['exceptionsList'], (result: any) => { const data = await StorageService.get(['exceptionsList']);
if (!result.exceptionsList) { if (!data.exceptionsList) {
chrome.storage.local.set({ exceptionsList: [] }); await StorageService.update('exceptionsList', []);
} }
}); });
}); }
}
new BackgroundService();

View File

@@ -0,0 +1,115 @@
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;
private isProcessing = false;
constructor() {
this.highlightEngine = new HighlightEngine(() => this.processHighlights());
this.initialize();
}
private async initialize(): Promise<void> {
await this.loadSettings();
this.setupMessageListener();
this.setupScrollHandler();
this.processHighlights();
}
private async loadSettings(): Promise<void> {
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<void> {
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<void> {
const data = await StorageService.get(['exceptionsList']);
this.exceptionsList = data.exceptionsList || [];
this.isCurrentSiteException = this.checkCurrentSiteException();
this.processHighlights();
}
private processHighlights(): void {
if (this.isProcessing) return;
this.isProcessing = true;
try {
if (!this.isGlobalHighlightEnabled || this.isCurrentSiteException) {
this.highlightEngine.clearHighlights();
this.highlightEngine.stopObserving();
return;
}
this.highlightEngine.highlight(this.lists, this.matchCase, this.matchWhole);
} finally {
this.isProcessing = false;
}
}
}

View File

@@ -0,0 +1,195 @@
import { HighlightList, ActiveWord } from '../types.js';
import { DOMUtils } from '../utils/DOMUtils.js';
export class HighlightEngine {
private styleSheet: CSSStyleSheet | null = null;
private wordStyleMap = new Map<string, string>();
private observer: MutationObserver;
constructor(private onUpdate: () => void) {
this.observer = new MutationObserver(DOMUtils.debounce((mutations: MutationRecord[]) => {
const hasRelevantChanges = mutations.some((mutation: MutationRecord) => {
if (mutation.target instanceof Element && mutation.target.hasAttribute('data-gh')) {
return false;
}
const addedNodes = Array.from(mutation.addedNodes);
const removedNodes = Array.from(mutation.removedNodes);
const isOurChange = [...addedNodes, ...removedNodes].some(node =>
node instanceof Element && (node.hasAttribute('data-gh') || node.querySelector('[data-gh]'))
);
return !isOurChange;
});
if (hasRelevantChanges) {
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<string, string>();
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 {
this.observer.disconnect();
this.clearHighlightsInternal();
}
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.clearHighlightsInternal();
const activeWords = this.extractActiveWords(lists);
if (activeWords.length === 0) {
this.startObserving();
return;
}
this.updateWordStyles(activeWords);
const wordMap = new Map<string, ActiveWord>();
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 = `(?:(?<!\\p{L})|^)(${wordsPattern})(?:(?!\\p{L})|$)`;
}
try {
const pattern = new RegExp(`(${wordsPattern})`, flags);
const textNodes = this.getTextNodes();
for (const node of textNodes) {
if (!node.nodeValue || !pattern.test(node.nodeValue)) continue;
const span = document.createElement('span');
span.innerHTML = node.nodeValue.replace(pattern, (match) => {
const lookup = matchCase ? match : match.toLowerCase();
const className = this.wordStyleMap.get(lookup) || 'highlighted-word-0';
return `<span data-gh class="${className}">${match}</span>`;
});
node.parentNode?.replaceChild(span, node);
}
} catch (e) {
console.error('Regex error:', e);
}
this.startObserving();
}
private clearHighlightsInternal(): 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();
}
});
if (this.styleSheet && this.styleSheet.cssRules.length > 0) {
while (this.styleSheet.cssRules.length > 0) {
this.styleSheet.deleteRule(0);
}
}
}
stopObserving(): void {
this.observer.disconnect();
}
private startObserving(): void {
this.observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true,
// Don't observe attribute changes to avoid triggering on our own style changes
attributes: false
});
}
destroy(): void {
this.observer.disconnect();
this.clearHighlights();
}
}

View File

@@ -1,205 +1,3 @@
// @ts-nocheck import { ContentScript } from './content/ContentScript.js';
let currentLists = [];
let isGlobalHighlightEnabled = true;
let exceptionsList = [];
let isCurrentSiteException = false;
let matchCase = false;
let matchWhole = false;
let styleSheet = null;
let wordStyleMap = new Map();
function escapeRegex(s) { new ContentScript();
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 = `(?:(?<!\\p{L})|^)(${wordsPattern})(?:(?!\\p{L})|$)`;
}
try {
const pattern = new RegExp(`(${wordsPattern})`, flags);
for (const node of textNodes) {
if (!node.nodeValue || !pattern.test(node.nodeValue)) continue;
const span = document.createElement('span');
span.innerHTML = node.nodeValue.replace(pattern, match => {
const lookup = matchCase ? match : match.toLowerCase();
const className = wordStyleMap.get(lookup) || 'highlighted-word-0';
return `<span data-gh class="${className}">${match}</span>`;
});
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);

View File

@@ -0,0 +1,638 @@
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<number>();
private globalHighlightEnabled = true;
private wordSearchQuery = '';
private matchCaseEnabled = false;
private matchWholeEnabled = false;
private exceptionsList: string[] = [];
private currentTabHost = '';
private sectionStates: Record<string, boolean> = {};
async initialize(): Promise<void> {
await this.loadData();
await this.getCurrentTab();
this.loadSectionStates();
this.initializeSectionStates();
this.setupEventListeners();
this.render();
}
private async loadData(): Promise<void> {
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<void> {
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;
listSelect.addEventListener('change', () => {
this.selectedCheckboxes.clear();
this.currentListIndex = +listSelect.value;
this.renderWords();
this.updateListForm();
});
// Apply button for list settings
document.getElementById('applyListSettingsBtn')?.addEventListener('click', () => {
this.applyListSettings();
});
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.bgEdit ?? target.dataset.fgEdit ?? -1);
if (index === -1) return;
const word = this.lists[this.currentListIndex].words[index];
if (target.dataset.bgEdit != null) word.background = target.value;
if (target.dataset.fgEdit != null) word.foreground = target.value;
this.save();
});
wordList.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const target = e.target as HTMLInputElement;
const index = +(target.dataset.wordEdit ?? -1);
if (index === -1) return;
const word = this.lists[this.currentListIndex].words[index];
if (target.dataset.wordEdit != null) {
word.wordStr = 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 applyListSettings(): void {
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;
this.lists[this.currentListIndex].name = listName.value;
this.lists[this.currentListIndex].background = listBg.value;
this.lists[this.currentListIndex].foreground = listFg.value;
this.lists[this.currentListIndex].active = listActive.checked;
this.save();
}
private async save(): Promise<void> {
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<void> {
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) =>
`<option value="${index}">${DOMUtils.escapeHtml(list.name)}</option>`
).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 = `
<input type="checkbox" class="word-checkbox" data-index="${realIndex}" ${this.selectedCheckboxes.has(realIndex) ? 'checked' : ''}>
<input type="text" value="${DOMUtils.escapeHtml(word.wordStr)}" data-word-edit="${realIndex}" style="flex-grow: 1; min-width: 0; padding: 4px 8px; border-radius: 4px; border: 1px solid var(--input-border); background-color: var(--input-bg); color: var(--text-color);">
<input type="color" value="${word.background || list.background}" data-bg-edit="${realIndex}" style="width: 24px; height: 24px; flex-shrink: 0;">
<input type="color" value="${word.foreground || list.foreground}" data-fg-edit="${realIndex}" style="width: 24px; height: 24px; flex-shrink: 0;">
<label class="word-active" style="display: flex; align-items: center; gap: 4px; flex-shrink: 0;">
<input type="checkbox" ${word.active !== false ? 'checked' : ''} data-active-edit="${realIndex}" class="switch">
</label>
`;
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 = '<div class="exception-item">No exceptions</div>';
return;
}
container.innerHTML = this.exceptionsList.map(domain =>
`<div class="exception-item">
<span class="exception-domain">${DOMUtils.escapeHtml(domain)}</span>
<button class="exception-remove" data-domain="${DOMUtils.escapeHtml(domain)}">${chrome.i18n.getMessage('remove') || 'Remove'}</button>
</div>`
).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;
}
}

View File

@@ -1,638 +1,31 @@
// @ts-nocheck import { PopupController } from './PopupController.js';
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 = {};
function loadSectionStates() { function localizePage(): void {
const saved = localStorage.getItem('goose-highlighter-section-states'); const elements = document.querySelectorAll('[data-i18n]');
if (saved) { elements.forEach(element => {
try { const message = (element as HTMLElement).dataset.i18n!;
sectionStates = JSON.parse(saved); const localizedText = chrome.i18n.getMessage(message);
} catch { if (localizedText) {
sectionStates = {}; if (element.tagName === 'INPUT' && (element as HTMLInputElement).hasAttribute('placeholder')) {
} (element as HTMLInputElement).placeholder = localizedText;
} } else {
} element.textContent = localizedText;
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 ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
'\'': '&#39;'
})[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' });
} }
} }
}); });
} }
async function updateGlobalToggleState() { function displayVersion(): void {
await chrome.storage.local.set({ globalHighlightEnabled: globalHighlightEnabled }); const manifest = chrome.runtime.getManifest();
chrome.tabs.query({}, function (tabs) { const versionElement = document.getElementById('version-number');
for (let tab of tabs) { if (versionElement && manifest.version) {
if (tab.id) { versionElement.textContent = manifest.version;
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) =>
`<option value="${index}">${escapeHtml(list.name)}</option>`
).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() { document.addEventListener('DOMContentLoaded', async () => {
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 = '<div class="exception-item">No exceptions</div>';
return;
}
container.innerHTML = exceptionsList.map(domain =>
`<div class="exception-item">
<span class="exception-domain">${escapeHtml(domain)}</span>
<button class="exception-remove" data-domain="${escapeHtml(domain)}">${chrome.i18n.getMessage('remove')}</button>
</div>`
).join('');
}
document.addEventListener('DOMContentLoaded', () => {
initializeSectionStates();
localizePage(); localizePage();
displayVersion();
// Add event listeners for collapse toggles const controller = new PopupController();
document.querySelectorAll('.collapse-toggle').forEach(button => { await controller.initialize();
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();
}); });

View File

@@ -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);
}
}

View File

@@ -0,0 +1,25 @@
import { StorageData, DEFAULT_STORAGE } from '../types.js';
export class StorageService {
static async get<K extends keyof StorageData>(keys: K[]): Promise<Pick<StorageData, K>>;
static async get(): Promise<StorageData>;
static async get(keys?: (keyof StorageData)[]): Promise<any> {
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<StorageData>): Promise<void> {
return chrome.storage.local.set(data);
}
static async update<K extends keyof StorageData>(key: K, value: StorageData[K]): Promise<void> {
return this.set({ [key]: value } as Partial<StorageData>);
}
}

View File

@@ -1,5 +1,3 @@
// Type definitions for the Goose Highlighter extension
export interface HighlightWord { export interface HighlightWord {
wordStr: string; wordStr: string;
background: string; background: string;
@@ -37,31 +35,12 @@ export interface MessageData {
matchWhole?: boolean; matchWhole?: boolean;
} }
export interface SectionStates {
[sectionName: string]: boolean;
}
export interface ExportData { export interface ExportData {
lists: HighlightList[]; lists: HighlightList[];
exceptionsList: string[]; exceptionsList: string[];
} }
// DOM element selectors used in popup export const DEFAULT_STORAGE: StorageData = {
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<StorageData> = {
lists: [], lists: [],
globalHighlightEnabled: true, globalHighlightEnabled: true,
matchCaseEnabled: false, matchCaseEnabled: false,
@@ -69,7 +48,8 @@ export const DEFAULT_STORAGE: Partial<StorageData> = {
exceptionsList: [] exceptionsList: []
}; };
// Constants export const CONSTANTS = {
export const WORD_ITEM_HEIGHT = 32; WORD_ITEM_HEIGHT: 32,
export const DEBOUNCE_DELAY = 300; DEBOUNCE_DELAY: 300,
export const SCROLL_THROTTLE = 16; // ~60fps SCROLL_THROTTLE: 16
} as const;

41
src/utils/DOMUtils.ts Normal file
View File

@@ -0,0 +1,41 @@
export class DOMUtils {
static escapeHtml(str: string): string {
const escapeMap: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
};
return str.replace(/[&<>"']/g, (match) => escapeMap[match]);
}
static escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
static debounce<T extends (...args: any[]) => void>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: number;
return (...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = window.setTimeout(() => func(...args), wait);
};
}
static throttle<T extends (...args: any[]) => void>(
func: T,
limit: number
): (...args: Parameters<T>) => void {
let inThrottle: boolean;
return (...args: Parameters<T>) => {
if (!inThrottle) {
func(...args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
}

View File

@@ -1,7 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
"module": "CommonJS", "module": "ES2022",
"lib": ["ES2022", "DOM"], "lib": ["ES2022", "DOM"],
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src", "rootDir": "./src",