8 Commits

Author SHA1 Message Date
semantic-release-bot
3de4190ad7 chore(release): 1.10.2
## [1.10.2](https://github.com/obsqrbtz/goose-highlighter/compare/v1.10.1...v1.10.2) (2025-11-22)

### Bug Fixes

* do not highlight textareas. create badge with popup instead ([f800318](f800318e66))
* update badge when textarea text changes ([5d7766d](5d7766d5fd))
2025-11-22 05:41:02 +03:00
86b143f5a0 chore: refactor badge handling and highlight rendering for textareas 2025-11-22 05:29:06 +03:00
5d7766d5fd fix: update badge when textarea text changes 2025-11-22 05:19:46 +03:00
f800318e66 fix: do not highlight textareas. create badge with popup instead 2025-11-22 05:16:40 +03:00
semantic-release-bot
14ceafd04d chore(release): 1.10.1
## [1.10.1](https://github.com/obsqrbtz/goose-highlighter/compare/v1.10.0...v1.10.1) (2025-11-20)

### Bug Fixes

* add fake highlighting for textareas ([f3c999a](f3c999ad19))
* correct matches list buttons handling ([f895327](f895327eb0))
* go to textarea match ([fc4ef86](fc4ef8655d))
2025-11-20 22:47:21 +03:00
f895327eb0 fix: correct matches list buttons handling
fix: pressing on the match list item triggers jump to the current match
2025-11-20 22:46:58 +03:00
fc4ef8655d fix: go to textarea match 2025-11-20 22:35:21 +03:00
f3c999ad19 fix: add fake highlighting for textareas 2025-11-20 22:20:01 +03:00
4 changed files with 358 additions and 27 deletions

View File

@@ -1,3 +1,20 @@
## [1.10.2](https://github.com/obsqrbtz/goose-highlighter/compare/v1.10.1...v1.10.2) (2025-11-22)
### Bug Fixes
* do not highlight textareas. create badge with popup instead ([f800318](https://github.com/obsqrbtz/goose-highlighter/commit/f800318e66aba17eb95d60c3bfa44c5215989314))
* update badge when textarea text changes ([5d7766d](https://github.com/obsqrbtz/goose-highlighter/commit/5d7766d5fd3df0204f64eb15f938cb2a459897ad))
## [1.10.1](https://github.com/obsqrbtz/goose-highlighter/compare/v1.10.0...v1.10.1) (2025-11-20)
### Bug Fixes
* add fake highlighting for textareas ([f3c999a](https://github.com/obsqrbtz/goose-highlighter/commit/f3c999ad192d20ad4024261d8b3c8628de21382a))
* correct matches list buttons handling ([f895327](https://github.com/obsqrbtz/goose-highlighter/commit/f895327eb0efc325309fea02eb4030eb8301916a))
* go to textarea match ([fc4ef86](https://github.com/obsqrbtz/goose-highlighter/commit/fc4ef8655d0a4bce8ef6b67e45f5929e21c9b746))
# [1.10.0](https://github.com/obsqrbtz/goose-highlighter/compare/v1.9.3...v1.10.0) (2025-11-20) # [1.10.0](https://github.com/obsqrbtz/goose-highlighter/compare/v1.9.3...v1.10.0) (2025-11-20)

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.10.0", "version": "1.10.2",
"default_locale": "en", "default_locale": "en",
"permissions": [ "permissions": [
"scripting", "scripting",

View File

@@ -1,10 +1,80 @@
import { HighlightList, ActiveWord, CONSTANTS } from '../types.js'; import { HighlightList, ActiveWord, CONSTANTS } from '../types.js';
import { DOMUtils } from '../utils/DOMUtils.js'; import { DOMUtils } from '../utils/DOMUtils.js';
export class HighlightEngine { export class HighlightEngine {
private static escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
private static renderHighlighted(text: string, pattern: RegExp): string {
let html = '';
let lastIndex = 0;
pattern.lastIndex = 0;
let match;
while ((match = pattern.exec(text)) !== null) {
html += HighlightEngine.escapeHtml(text.substring(lastIndex, match.index));
html += `<mark>${HighlightEngine.escapeHtml(match[0])}</mark>`;
lastIndex = match.index + match[0].length;
}
html += HighlightEngine.escapeHtml(text.substring(lastIndex));
return html;
}
private static createOrUpdateBadge(input: HTMLTextAreaElement | HTMLInputElement, pattern: RegExp): void {
const text = input.value;
let matchCount = 0;
pattern.lastIndex = 0;
let match;
while ((match = pattern.exec(text)) !== null) {
matchCount++;
}
const oldBadge = input.parentElement?.querySelector('.goose-highlighter-textarea-badge');
if (oldBadge) oldBadge.remove();
if (matchCount > 0) {
const badge = document.createElement('div');
badge.className = 'goose-highlighter-textarea-badge';
badge.textContent = matchCount.toString();
badge.setAttribute('data-round', matchCount > 9 ? 'false' : 'true');
badge.style.position = 'absolute';
badge.style.left = '4px';
badge.style.top = '4px';
badge.style.zIndex = '10000';
const parent = input.parentElement;
if (parent && window.getComputedStyle(parent).position === 'static') {
parent.style.position = 'relative';
}
parent?.appendChild(badge);
badge.addEventListener('click', () => {
document.querySelectorAll('.goose-highlighter-textarea-popup').forEach(p => p.remove());
const popup = document.createElement('div');
popup.className = 'goose-highlighter-textarea-popup';
popup.innerHTML = `
<div class="gh-popup-titlebar">
<button class="gh-popup-close" title="Close">&times;</button>
</div>
<pre class="gh-popup-pre">${HighlightEngine.renderHighlighted(text, pattern)}</pre>
`;
document.body.appendChild(popup);
const closeBtn = popup.querySelector('.gh-popup-close');
closeBtn?.addEventListener('click', () => popup.remove());
});
}
}
private static attachBadgeListener(input: HTMLTextAreaElement | HTMLInputElement, pattern: RegExp): void {
const updateBadge = () => HighlightEngine.createOrUpdateBadge(input, pattern);
updateBadge();
input.removeEventListener('input', updateBadge);
input.addEventListener('input', updateBadge);
}
private _textareaMatchInfo: Array<{ input: HTMLTextAreaElement | HTMLInputElement; count: number; text: string }> = [];
private styleSheet: CSSStyleSheet | null = null; private styleSheet: CSSStyleSheet | null = null;
private highlights = new Map<string, Highlight>(); private highlights = new Map<string, Highlight>();
private highlightsByWord = new Map<string, Range[]>(); private highlightsByWord = new Map<string, Range[]>();
private textareaMatchesByWord = new Map<string, Array<{ input: HTMLTextAreaElement | HTMLInputElement; position: number }>>();
private observer: MutationObserver; private observer: MutationObserver;
private isHighlighting = false; private isHighlighting = false;
private currentMatchCase = false; private currentMatchCase = false;
@@ -12,7 +82,6 @@ export class HighlightEngine {
constructor(private onUpdate: () => void) { constructor(private onUpdate: () => void) {
this.observer = new MutationObserver(DOMUtils.debounce((mutations: MutationRecord[]) => { this.observer = new MutationObserver(DOMUtils.debounce((mutations: MutationRecord[]) => {
if (this.isHighlighting) return; if (this.isHighlighting) return;
const hasContentChanges = mutations.some((mutation: MutationRecord) => { const hasContentChanges = mutations.some((mutation: MutationRecord) => {
if (mutation.type !== 'childList') return false; if (mutation.type !== 'childList') return false;
const allNodes = [...Array.from(mutation.addedNodes), ...Array.from(mutation.removedNodes)]; const allNodes = [...Array.from(mutation.addedNodes), ...Array.from(mutation.removedNodes)];
@@ -22,7 +91,6 @@ export class HighlightEngine {
return false; return false;
}); });
}); });
if (hasContentChanges) { if (hasContentChanges) {
this.onUpdate(); this.onUpdate();
} }
@@ -64,6 +132,7 @@ export class HighlightEngine {
this.clearHighlightsInternal(); this.clearHighlightsInternal();
} }
private getTextNodes(): Text[] { private getTextNodes(): Text[] {
const textNodes: Text[] = []; const textNodes: Text[] = [];
const walker = document.createTreeWalker( const walker = document.createTreeWalker(
@@ -71,7 +140,7 @@ export class HighlightEngine {
NodeFilter.SHOW_TEXT, NodeFilter.SHOW_TEXT,
{ {
acceptNode: (node: Text) => { acceptNode: (node: Text) => {
if (node.parentNode && ['SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME'].includes(node.parentNode.nodeName)) { if (node.parentNode && ['SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME', 'TEXTAREA', 'INPUT'].includes(node.parentNode.nodeName)) {
return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_REJECT;
} }
if (!node.nodeValue?.trim()) { if (!node.nodeValue?.trim()) {
@@ -148,6 +217,7 @@ export class HighlightEngine {
const rangesByStyle = new Map<number, Range[]>(); const rangesByStyle = new Map<number, Range[]>();
this.highlightsByWord.clear(); this.highlightsByWord.clear();
this.textareaMatchesByWord.clear();
for (const node of textNodes) { for (const node of textNodes) {
if (!node.nodeValue) continue; if (!node.nodeValue) continue;
@@ -184,6 +254,8 @@ export class HighlightEngine {
this.highlights.set(highlightName, highlight); this.highlights.set(highlightName, highlight);
CSS.highlights.set(highlightName, highlight); CSS.highlights.set(highlightName, highlight);
} }
this.highlightTextareas(pattern, styleMap, activeWords);
} catch (e) { } catch (e) {
console.error('Regex error:', e); console.error('Regex error:', e);
} }
@@ -192,13 +264,154 @@ export class HighlightEngine {
this.isHighlighting = false; this.isHighlighting = false;
} }
private highlightTextareas(pattern: RegExp, styleMap: Map<string, number>, activeWords: ActiveWord[]): void {
if (!document.getElementById('gh-textarea-badge-style')) {
const style = document.createElement('style');
style.id = 'gh-textarea-badge-style';
style.textContent = `
:root {
--gh-badge-accent: #ec9c23;
--gh-badge-text: #000;
--gh-badge-border: #2d2d2d;
--gh-popup-bg: #161616;
--gh-popup-border: #ec9c23;
--gh-popup-radius: 10px;
--gh-popup-shadow: 0 4px 12px rgba(0,0,0,0.4);
}
.goose-highlighter-textarea-badge {
position: absolute !important;
left: 4px !important;
top: 4px !important;
background: var(--gh-badge-accent);
color: var(--gh-badge-text);
font-family: inherit;
font-weight: bold;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
cursor: pointer;
z-index: 10000;
padding: 0 10px;
height: 22px;
min-width: 22px;
border-radius: 12px;
border: 2px solid var(--gh-badge-border);
transition: box-shadow 0.2s, background 0.2s;
}
.goose-highlighter-textarea-badge[data-round="true"] {
border-radius: 50%;
padding: 0;
min-width: 22px;
}
.goose-highlighter-textarea-badge:hover {
box-shadow: 0 4px 12px rgba(236,156,35,0.25);
background: #ffb84d;
}
.goose-highlighter-textarea-popup {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
background: var(--gh-popup-bg);
border: 2px solid var(--gh-popup-border);
border-radius: var(--gh-popup-radius);
box-shadow: var(--gh-popup-shadow);
padding: 0 0 20px 0;
z-index: 10001;
max-width: 420px;
max-height: 70vh;
overflow: auto;
color: #e8e8e8;
font-family: 'Inter', 'Segoe UI', sans-serif;
}
.gh-popup-titlebar {
display: flex;
align-items: center;
justify-content: flex-end;
background: #191919;
border-top-left-radius: var(--gh-popup-radius);
border-top-right-radius: var(--gh-popup-radius);
height: 32px;
padding: 0 8px 0 0;
border-bottom: 1px solid #222;
position: relative;
}
.gh-popup-close {
background: none;
color: #e8e8e8;
border: none;
font-size: 22px;
font-weight: bold;
cursor: pointer;
padding: 0 6px;
border-radius: 4px;
transition: background 0.2s, color 0.2s;
z-index: 10002;
line-height: 1;
}
.gh-popup-close:hover {
background: #ec9c23;
color: #222;
}
.goose-highlighter-textarea-popup::-webkit-scrollbar {
width: 10px;
background: #222;
border-radius: 8px;
}
.goose-highlighter-textarea-popup::-webkit-scrollbar-thumb {
background: var(--gh-badge-accent);
border-radius: 8px;
border: 2px solid #222;
}
.goose-highlighter-textarea-popup::-webkit-scrollbar-thumb:hover {
background: #ffb84d;
}
.goose-highlighter-textarea-popup {
scrollbar-width: thin;
scrollbar-color: var(--gh-badge-accent) #222;
}
.gh-popup-pre {
background: #222;
border-radius: 7px;
padding: 10px 12px;
border: 1px solid #333;
color: #e8e8e8;
font-size: 15px;
font-family: inherit;
margin: 16px 16px 0 16px;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: break-word;
}
.gh-popup-pre mark {
background: none;
color: #ec9c23;
font-weight: bold;
border-radius: 0;
padding: 0;
}
`;
document.head.appendChild(style);
}
const textareas = document.querySelectorAll('textarea, input[type="text"], input[type="search"], input[type="email"], input[type="url"]');
this._textareaMatchInfo = [];
document.querySelectorAll('.goose-highlighter-textarea-badge').forEach(badge => badge.remove());
for (const element of Array.from(textareas)) {
const input = element as HTMLTextAreaElement | HTMLInputElement;
HighlightEngine.attachBadgeListener(input, pattern);
}
}
private clearHighlightsInternal(): void { private clearHighlightsInternal(): void {
for (const name of this.highlights.keys()) { for (const name of this.highlights.keys()) {
CSS.highlights.delete(name); CSS.highlights.delete(name);
} }
this.highlights.clear(); this.highlights.clear();
this.highlightsByWord.clear(); this.highlightsByWord.clear();
this.textareaMatchesByWord.clear();
if (this.styleSheet && this.styleSheet.cssRules.length > 0) { if (this.styleSheet && this.styleSheet.cssRules.length > 0) {
while (this.styleSheet.cssRules.length > 0) { while (this.styleSheet.cssRules.length > 0) {
this.styleSheet.deleteRule(0); this.styleSheet.deleteRule(0);
@@ -212,11 +425,14 @@ export class HighlightEngine {
for (const activeWord of activeWords) { for (const activeWord of activeWords) {
const lookup = this.currentMatchCase ? activeWord.text : activeWord.text.toLowerCase(); const lookup = this.currentMatchCase ? activeWord.text : activeWord.text.toLowerCase();
const ranges = this.highlightsByWord.get(lookup); const ranges = this.highlightsByWord.get(lookup);
const textareaMatches = this.textareaMatchesByWord.get(lookup);
if (ranges && ranges.length > 0 && !seen.has(lookup)) { const totalCount = (ranges?.length || 0) + (textareaMatches?.length || 0);
if (totalCount > 0 && !seen.has(lookup)) {
seen.set(lookup, { seen.set(lookup, {
word: activeWord.text, word: activeWord.text,
count: ranges.length, count: totalCount,
background: activeWord.background, background: activeWord.background,
foreground: activeWord.foreground foreground: activeWord.foreground
}); });
@@ -228,18 +444,36 @@ export class HighlightEngine {
scrollToHighlight(word: string, index: number): void { scrollToHighlight(word: string, index: number): void {
const lookup = this.currentMatchCase ? word : word.toLowerCase(); const lookup = this.currentMatchCase ? word : word.toLowerCase();
const ranges = this.highlightsByWord.get(lookup); const ranges = this.highlightsByWord.get(lookup) || [];
const textareaMatches = this.textareaMatchesByWord.get(lookup) || [];
if (!ranges || ranges.length === 0) return; const totalMatches = ranges.length + textareaMatches.length;
if (totalMatches === 0) return;
const targetIndex = Math.min(index, ranges.length - 1); const targetIndex = Math.min(index, totalMatches - 1);
const range = ranges[targetIndex];
if (!range) return;
try { try {
const rect = range.getBoundingClientRect(); if (targetIndex >= ranges.length) {
const textareaIndex = targetIndex - ranges.length;
const textareaMatch = textareaMatches[textareaIndex];
this.scrollToTextareaMatch(textareaMatch.input, textareaMatch.position, word.length);
return;
}
const range = ranges[targetIndex];
if (!range) return;
// First, scroll any scrollable containers
const element = range.commonAncestorContainer.nodeType === Node.TEXT_NODE
? range.commonAncestorContainer.parentElement
: range.commonAncestorContainer as Element;
if (element) {
this.scrollIntoViewInContainers(element);
}
// Then scroll the main window
const rect = range.getBoundingClientRect();
const absoluteTop = window.pageYOffset + rect.top; const absoluteTop = window.pageYOffset + rect.top;
const middle = absoluteTop - (window.innerHeight / 2) + (rect.height / 2); const middle = absoluteTop - (window.innerHeight / 2) + (rect.height / 2);
@@ -267,6 +501,81 @@ export class HighlightEngine {
} }
} }
private scrollToTextareaMatch(input: HTMLTextAreaElement | HTMLInputElement, position: number, wordLength: number): void {
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
if (input.tagName === 'TEXTAREA') {
const textarea = input as HTMLTextAreaElement;
const text = textarea.value;
const beforeText = text.substring(0, position);
const lines = beforeText.split('\n');
const lineNumber = lines.length - 1;
const computedStyle = window.getComputedStyle(textarea);
const lineHeight = parseFloat(computedStyle.lineHeight) || parseFloat(computedStyle.fontSize) * 1.2;
const targetScrollTop = lineNumber * lineHeight - (textarea.clientHeight / 2);
textarea.scrollTop = Math.max(0, targetScrollTop);
}
input.focus();
input.setSelectionRange(position, position + wordLength);
}
private getTextPosition(overlay: HTMLElement, targetMark: HTMLElement): number {
let position = 0;
const walker = document.createTreeWalker(overlay, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
let node: Node | null;
while ((node = walker.nextNode())) {
if (node === targetMark) {
return position;
}
if (node.nodeType === Node.TEXT_NODE) {
position += node.textContent?.length || 0;
}
}
return position;
}
private scrollIntoViewInContainers(element: Element): void {
let current: Element | null = element;
while (current && current !== document.body) {
const parent: HTMLElement | null = current.parentElement;
if (!parent) break;
const parentStyle = window.getComputedStyle(parent);
const isScrollable = (
(parentStyle.overflow === 'auto' || parentStyle.overflow === 'scroll' ||
parentStyle.overflowY === 'auto' || parentStyle.overflowY === 'scroll' ||
parentStyle.overflowX === 'auto' || parentStyle.overflowX === 'scroll') &&
(parent.scrollHeight > parent.clientHeight || parent.scrollWidth > parent.clientWidth)
);
if (isScrollable) {
const parentRect = parent.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
if (elementRect.top < parentRect.top) {
parent.scrollTop -= (parentRect.top - elementRect.top) + 20;
} else if (elementRect.bottom > parentRect.bottom) {
parent.scrollTop += (elementRect.bottom - parentRect.bottom) + 20;
}
if (elementRect.left < parentRect.left) {
parent.scrollLeft -= (parentRect.left - elementRect.left) + 20;
} else if (elementRect.right > parentRect.right) {
parent.scrollLeft += (elementRect.right - parentRect.right) + 20;
}
}
current = parent;
}
}
stopObserving(): void { stopObserving(): void {
this.observer.disconnect(); this.observer.disconnect();
} }

View File

@@ -339,12 +339,17 @@ export class PopupController {
const word = item.dataset.word; const word = item.dataset.word;
if (!word) return; if (!word) return;
if (target.classList.contains('highlight-prev')) { const button = target.closest('button');
if (button?.classList.contains('highlight-prev')) {
e.stopPropagation();
await this.navigateHighlight(word, -1); await this.navigateHighlight(word, -1);
} else if (target.classList.contains('highlight-next')) { } else if (button?.classList.contains('highlight-next')) {
e.stopPropagation();
await this.navigateHighlight(word, 1); await this.navigateHighlight(word, 1);
} else { } else if (!button) {
await this.jumpToHighlight(word, 0); const currentIndex = this.highlightIndices.get(word) || 0;
await this.jumpToHighlight(word, currentIndex);
} }
}); });
} }