|
|
|
@@ -5,9 +5,12 @@ export class HighlightEngine {
|
|
|
|
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;
|
|
|
|
|
|
|
|
private textareaOverlays = new Map<HTMLTextAreaElement | HTMLInputElement, HTMLElement>();
|
|
|
|
|
|
|
|
private resizeObserver: ResizeObserver;
|
|
|
|
|
|
|
|
|
|
|
|
constructor(private onUpdate: () => void) {
|
|
|
|
constructor(private onUpdate: () => void) {
|
|
|
|
this.observer = new MutationObserver(DOMUtils.debounce((mutations: MutationRecord[]) => {
|
|
|
|
this.observer = new MutationObserver(DOMUtils.debounce((mutations: MutationRecord[]) => {
|
|
|
|
@@ -27,6 +30,16 @@ export class HighlightEngine {
|
|
|
|
this.onUpdate();
|
|
|
|
this.onUpdate();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}, CONSTANTS.DEBOUNCE_DELAY));
|
|
|
|
}, CONSTANTS.DEBOUNCE_DELAY));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
this.resizeObserver = new ResizeObserver((entries) => {
|
|
|
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
|
|
|
const input = entry.target as HTMLTextAreaElement | HTMLInputElement;
|
|
|
|
|
|
|
|
const overlay = this.textareaOverlays.get(input);
|
|
|
|
|
|
|
|
if (overlay) {
|
|
|
|
|
|
|
|
this.updateOverlayPosition(input, overlay);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private initializeStyleSheet(): void {
|
|
|
|
private initializeStyleSheet(): void {
|
|
|
|
@@ -62,6 +75,23 @@ export class HighlightEngine {
|
|
|
|
clearHighlights(): void {
|
|
|
|
clearHighlights(): void {
|
|
|
|
this.observer.disconnect();
|
|
|
|
this.observer.disconnect();
|
|
|
|
this.clearHighlightsInternal();
|
|
|
|
this.clearHighlightsInternal();
|
|
|
|
|
|
|
|
this.clearTextareaOverlays();
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private clearTextareaOverlays(): void {
|
|
|
|
|
|
|
|
for (const [input, overlay] of this.textareaOverlays.entries()) {
|
|
|
|
|
|
|
|
this.resizeObserver.unobserve(input);
|
|
|
|
|
|
|
|
overlay.remove();
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
this.textareaOverlays.clear();
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private updateOverlayPosition(input: HTMLTextAreaElement | HTMLInputElement, overlay: HTMLElement): void {
|
|
|
|
|
|
|
|
const rect = input.getBoundingClientRect();
|
|
|
|
|
|
|
|
overlay.style.width = `${input.clientWidth}px`;
|
|
|
|
|
|
|
|
overlay.style.height = `${input.clientHeight}px`;
|
|
|
|
|
|
|
|
overlay.style.top = `${rect.top + window.scrollY}px`;
|
|
|
|
|
|
|
|
overlay.style.left = `${rect.left + window.scrollX}px`;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private getTextNodes(): Text[] {
|
|
|
|
private getTextNodes(): Text[] {
|
|
|
|
@@ -71,7 +101,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()) {
|
|
|
|
@@ -112,6 +142,7 @@ export class HighlightEngine {
|
|
|
|
|
|
|
|
|
|
|
|
this.observer.disconnect();
|
|
|
|
this.observer.disconnect();
|
|
|
|
this.clearHighlightsInternal();
|
|
|
|
this.clearHighlightsInternal();
|
|
|
|
|
|
|
|
this.clearTextareaOverlays();
|
|
|
|
|
|
|
|
|
|
|
|
const activeWords = this.extractActiveWords(lists);
|
|
|
|
const activeWords = this.extractActiveWords(lists);
|
|
|
|
if (activeWords.length === 0) {
|
|
|
|
if (activeWords.length === 0) {
|
|
|
|
@@ -148,6 +179,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 +216,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,12 +226,121 @@ export class HighlightEngine {
|
|
|
|
this.isHighlighting = false;
|
|
|
|
this.isHighlighting = false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private highlightTextareas(pattern: RegExp, styleMap: Map<string, number>, activeWords: ActiveWord[]): void {
|
|
|
|
|
|
|
|
const textareas = document.querySelectorAll('textarea, input[type="text"], input[type="search"], input[type="email"], input[type="url"]');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
for (const element of Array.from(textareas)) {
|
|
|
|
|
|
|
|
const input = element as HTMLTextAreaElement | HTMLInputElement;
|
|
|
|
|
|
|
|
const text = input.value;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (!text) continue;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const matches: Array<{ start: number; end: number; background: string; foreground: string }> = [];
|
|
|
|
|
|
|
|
pattern.lastIndex = 0;
|
|
|
|
|
|
|
|
let match;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
while ((match = pattern.exec(text)) !== null) {
|
|
|
|
|
|
|
|
const lookup = this.currentMatchCase ? match[0] : match[0].toLowerCase();
|
|
|
|
|
|
|
|
const styleIdx = styleMap.get(lookup);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (styleIdx !== undefined) {
|
|
|
|
|
|
|
|
const activeWord = activeWords.find(w =>
|
|
|
|
|
|
|
|
(this.currentMatchCase ? w.text : w.text.toLowerCase()) === lookup
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
if (activeWord) {
|
|
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
|
|
start: match.index,
|
|
|
|
|
|
|
|
end: match.index + match[0].length,
|
|
|
|
|
|
|
|
background: activeWord.background,
|
|
|
|
|
|
|
|
foreground: activeWord.foreground
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Track textarea matches for navigation
|
|
|
|
|
|
|
|
if (!this.textareaMatchesByWord.has(lookup)) {
|
|
|
|
|
|
|
|
this.textareaMatchesByWord.set(lookup, []);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
this.textareaMatchesByWord.get(lookup)!.push({
|
|
|
|
|
|
|
|
input,
|
|
|
|
|
|
|
|
position: match.index
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (matches.length > 0) {
|
|
|
|
|
|
|
|
this.createTextareaOverlay(input, text, matches);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private createTextareaOverlay(input: HTMLTextAreaElement | HTMLInputElement, text: string, matches: Array<{ start: number; end: number; background: string; foreground: string }>): void {
|
|
|
|
|
|
|
|
const overlay = document.createElement('div');
|
|
|
|
|
|
|
|
overlay.className = 'goose-highlighter-textarea-overlay';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const computedStyle = window.getComputedStyle(input);
|
|
|
|
|
|
|
|
const styles = [
|
|
|
|
|
|
|
|
'font-family', 'font-size', 'font-weight', 'font-style',
|
|
|
|
|
|
|
|
'line-height', 'letter-spacing', 'word-spacing',
|
|
|
|
|
|
|
|
'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
|
|
|
|
|
|
|
|
'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width',
|
|
|
|
|
|
|
|
'white-space', 'word-wrap', 'overflow-wrap'
|
|
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
overlay.style.position = 'absolute';
|
|
|
|
|
|
|
|
overlay.style.pointerEvents = 'none';
|
|
|
|
|
|
|
|
overlay.style.color = 'transparent';
|
|
|
|
|
|
|
|
overlay.style.overflow = 'hidden';
|
|
|
|
|
|
|
|
overlay.style.whiteSpace = input.tagName === 'TEXTAREA' ? 'pre-wrap' : 'pre';
|
|
|
|
|
|
|
|
overlay.style.overflowWrap = 'break-word';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
for (const prop of styles) {
|
|
|
|
|
|
|
|
overlay.style.setProperty(prop, computedStyle.getPropertyValue(prop));
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
this.updateOverlayPosition(input, overlay);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let html = '';
|
|
|
|
|
|
|
|
let lastIndex = 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
for (const match of matches) {
|
|
|
|
|
|
|
|
html += this.escapeHtml(text.substring(lastIndex, match.start));
|
|
|
|
|
|
|
|
html += `<mark style="background-color: ${match.background}; color: ${match.foreground}; padding: 0; margin: 0;">${this.escapeHtml(text.substring(match.start, match.end))}</mark>`;
|
|
|
|
|
|
|
|
lastIndex = match.end;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
html += this.escapeHtml(text.substring(lastIndex));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
overlay.innerHTML = html;
|
|
|
|
|
|
|
|
document.body.appendChild(overlay);
|
|
|
|
|
|
|
|
this.textareaOverlays.set(input, overlay);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
this.resizeObserver.observe(input);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const updateOverlay = () => {
|
|
|
|
|
|
|
|
this.resizeObserver.unobserve(input);
|
|
|
|
|
|
|
|
overlay.remove();
|
|
|
|
|
|
|
|
this.textareaOverlays.delete(input);
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
input.addEventListener('input', updateOverlay, { once: true });
|
|
|
|
|
|
|
|
input.addEventListener('scroll', () => {
|
|
|
|
|
|
|
|
overlay.scrollTop = input.scrollTop;
|
|
|
|
|
|
|
|
overlay.scrollLeft = input.scrollLeft;
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private escapeHtml(text: string): string {
|
|
|
|
|
|
|
|
const div = document.createElement('div');
|
|
|
|
|
|
|
|
div.textContent = text;
|
|
|
|
|
|
|
|
return div.innerHTML;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) {
|
|
|
|
@@ -212,11 +355,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 +374,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 +431,102 @@ 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);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const overlay = this.textareaOverlays.get(input);
|
|
|
|
|
|
|
|
if (overlay) {
|
|
|
|
|
|
|
|
const marks = overlay.querySelectorAll('mark');
|
|
|
|
|
|
|
|
for (const mark of Array.from(marks)) {
|
|
|
|
|
|
|
|
const markElement = mark as HTMLElement;
|
|
|
|
|
|
|
|
const markText = markElement.textContent || '';
|
|
|
|
|
|
|
|
const markStart = this.getTextPosition(overlay, markElement);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (markStart === position) {
|
|
|
|
|
|
|
|
const originalBackground = markElement.style.backgroundColor;
|
|
|
|
|
|
|
|
markElement.style.backgroundColor = 'rgba(255, 165, 0, 0.8)';
|
|
|
|
|
|
|
|
markElement.style.boxShadow = '0 0 10px 3px rgba(255, 165, 0, 0.8)';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
|
|
markElement.style.backgroundColor = originalBackground;
|
|
|
|
|
|
|
|
markElement.style.boxShadow = '';
|
|
|
|
|
|
|
|
}, 600);
|
|
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@@ -281,6 +541,8 @@ export class HighlightEngine {
|
|
|
|
|
|
|
|
|
|
|
|
destroy(): void {
|
|
|
|
destroy(): void {
|
|
|
|
this.observer.disconnect();
|
|
|
|
this.observer.disconnect();
|
|
|
|
|
|
|
|
this.resizeObserver.disconnect();
|
|
|
|
this.clearHighlights();
|
|
|
|
this.clearHighlights();
|
|
|
|
|
|
|
|
this.clearTextareaOverlays();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|