UpToDate Text Highlighter (Cmd/Ctrl Only)

Floating toolbar to highlight and save text selections with colored markers (stable restore + Cmd/Ctrl triggers highlight)

Size

45.1 KB

Version

1.2.3

Created

Jan 23, 2026

Updated

12 days ago

1// ==UserScript==
2// @name		UpToDate Text Highlighter (Cmd/Ctrl Only)
3// @description		Floating toolbar to highlight and save text selections with colored markers (stable restore + Cmd/Ctrl triggers highlight)
4// @version		1.2.3
5// @match		https://www.uptodate.com/*
6// @match		https://www-uptodate-com.ezproxy.is.cuni.cz/*
7// @icon		https://www-uptodate-com.ezproxy.is.cuni.cz/app/utd-menu/utd-icon-redesign.png
8// @grant		none
9// ==/UserScript==
10(function () {
11    'use strict';
12
13    const COLORS = {
14        yellow: '#FFFF00',
15        green: '#90EE90',
16        pink: '#FFB6C1',
17        blue: '#ADD8E6',
18        orange: '#FFD580'
19    };
20
21    const STORAGE_KEY_PREFIX = 'uptodate_highlights_v2_';
22    const CONTEXT_LEN = 60;
23    const MAX_RESTORE_PASSES = 4;
24
25    let selectedColor = 'yellow';
26    let isRestoring = false;
27    let restorePasses = 0;
28
29    // Hotkey state: highlight on *release* of Cmd/Ctrl, only if no other key was used while it was held
30    let modIsDown = false;
31    let modUsedWithOtherKey = false;
32    let showStats = false;
33    
34    // Timer state
35    let timerInterval = null;
36    let startTime = null;
37    let elapsedTime = 0;
38    let isTimerRunning = false;
39
40    function getPageKey() {
41        return STORAGE_KEY_PREFIX + location.pathname + location.search;
42    }
43
44    function safeClosest(el, selector) {
45        if (!el || el.nodeType !== 1) return null;
46        return el.closest(selector);
47    }
48
49    function generateId() {
50        return 'hl_' + Date.now() + '_' + Math.random().toString(36).slice(2, 10);
51    }
52
53    function debounce(fn, wait) {
54        let t;
55        return (...args) => {
56            clearTimeout(t);
57            t = setTimeout(() => fn(...args), wait);
58        };
59    }
60
61    function normalizeText(s) {
62        return (s || '').replace(/\s+/g, ' ').trim();
63    }
64
65    function isEditableTarget(target) {
66        if (!target) return false;
67        const el = target.nodeType === 1 ? target : target.parentElement;
68        if (!el) return false;
69        const tag = (el.tagName || '').toLowerCase();
70        if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
71        if (el.isContentEditable) return true;
72        if (safeClosest(el, '[contenteditable=\'true\']')) return true;
73        return false;
74    }
75
76    function isModKeyEvent(e) {
77    // Mac: Meta (Command). Win/Linux: Control.
78        return e.key === 'Meta' || e.key === 'Control';
79    }
80
81    function getTextNodes() {
82        const walker = document.createTreeWalker(
83            document.body,
84            NodeFilter.SHOW_TEXT,
85            {
86                acceptNode(node) {
87                    const p = node.parentElement;
88                    if (!p) return NodeFilter.FILTER_REJECT;
89                    if (safeClosest(p, '#highlighter-toolbar')) return NodeFilter.FILTER_REJECT;
90                    if (p.classList && p.classList.contains('text-highlight')) return NodeFilter.FILTER_REJECT;
91                    const tag = p.tagName ? p.tagName.toLowerCase() : '';
92                    if (tag === 'script' || tag === 'style' || tag === 'noscript') return NodeFilter.FILTER_REJECT;
93                    if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
94                    return NodeFilter.FILTER_ACCEPT;
95                },
96            },
97            false
98        );
99
100        const nodes = [];
101        let n;
102        while ((n = walker.nextNode())) nodes.push(n);
103        return nodes;
104    }
105
106    function buildSelectorFromRange(range) {
107        const exact = normalizeText(range.toString());
108        if (!exact) return null;
109
110        let startNode = range.startContainer;
111        let prefix = '';
112        let suffix = '';
113
114        if (startNode && startNode.nodeType === Node.TEXT_NODE) {
115            const full = startNode.nodeValue || '';
116            const start = range.startOffset;
117            const preRaw = full.slice(Math.max(0, start - CONTEXT_LEN), start);
118            prefix = normalizeText(preRaw).slice(-CONTEXT_LEN);
119
120            if (range.endContainer === startNode) {
121                const end = range.endOffset;
122                const sufRaw = full.slice(end, Math.min(full.length, end + CONTEXT_LEN));
123                suffix = normalizeText(sufRaw).slice(0, CONTEXT_LEN);
124            }
125        }
126
127        return { exact, prefix, suffix };
128    }
129
130    function scoreMatch(nodeText, idx, exact, prefix, suffix) {
131        const before = normalizeText(nodeText.slice(Math.max(0, idx - CONTEXT_LEN), idx));
132        const after = normalizeText(
133            nodeText.slice(idx + exact.length, Math.min(nodeText.length, idx + exact.length + CONTEXT_LEN))
134        );
135
136        let score = 0;
137        if (prefix) {
138            const tail = prefix.slice(-Math.min(30, prefix.length));
139            if (tail && before.includes(tail)) score += 3;
140        }
141        if (suffix) {
142            const head = suffix.slice(0, Math.min(30, suffix.length));
143            if (head && after.includes(head)) score += 3;
144        }
145        score += Math.min(4, Math.floor(exact.length / 30));
146        return score;
147    }
148
149    function findBestMatch(exact, prefix, suffix) {
150        const nodes = getTextNodes();
151        const normalizedExact = normalizeText(exact);
152        if (!normalizedExact) return null;
153
154        let best = null;
155
156        for (const node of nodes) {
157            const raw = node.nodeValue || '';
158            const probe = normalizedExact.slice(0, Math.min(12, normalizedExact.length));
159            if (!raw.includes(probe)) continue;
160
161            let from = 0;
162            while (from < raw.length) {
163                const idx = raw.indexOf(probe, from);
164                if (idx === -1) break;
165
166                const rawIdx = raw.indexOf(normalizedExact, Math.max(0, idx - 20));
167                if (rawIdx !== -1) {
168                    const sc = scoreMatch(raw, rawIdx, normalizedExact, prefix, suffix);
169                    if (!best || sc > best.score) best = { node, index: rawIdx, score: sc };
170                } else {
171                    const normNode = normalizeText(raw);
172                    const normIdx = normNode.indexOf(normalizedExact);
173                    if (normIdx !== -1) {
174                        let rawPos = 0;
175                        let normPos = 0;
176                        while (rawPos < raw.length && normPos < normIdx) {
177                            const ch = raw[rawPos];
178                            if (/\s/.test(ch)) {
179                                while (rawPos < raw.length && /\s/.test(raw[rawPos])) rawPos++;
180                                if (normPos < normNode.length && normNode[normPos] === ' ') normPos++;
181                            } else {
182                                rawPos++;
183                                normPos++;
184                            }
185                        }
186                        const approxIdx = rawPos;
187                        const sc = scoreMatch(raw, approxIdx, normalizedExact, prefix, suffix) - 1;
188                        if (!best || sc > best.score) best = { node, index: approxIdx, score: sc };
189                    }
190                }
191
192                from = idx + probe.length;
193            }
194        }
195
196        return best;
197    }
198
199    function addStyles() {
200        const style = document.createElement('style');
201        style.textContent = `
202#highlighter-toolbar{
203  position:fixed; top:50%; right:20px; transform:translateY(-50%);
204  background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);
205  border-radius:12px; padding:10px;
206  box-shadow:0 4px 20px rgba(0,0,0,.3);
207  z-index:999999;
208  font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
209  user-select:none; min-width:60px;
210  backdrop-filter:blur(10px);
211}
212#highlighter-toolbar .toolbar-header{
213  display:flex; justify-content:space-between; align-items:center;
214  margin-bottom:8px; padding-bottom:6px;
215  border-bottom:1px solid rgba(255,255,255,.2);
216}
217#highlighter-toolbar .toolbar-title{
218  color:#fff; font-size:10px; font-weight:600;
219  letter-spacing:.5px; text-transform:uppercase;
220}
221#highlighter-toolbar .toolbar-drag-handle{
222  background:none; border:none; color:rgba(255,255,255,.75);
223  cursor:move; font-size:14px; padding:0; line-height:1;
224}
225#highlighter-toolbar .timer-section{
226  background:rgba(255,255,255,.15); border-radius:8px;
227  padding:8px; margin-bottom:8px; text-align:center;
228}
229#highlighter-toolbar .timer-display{
230  color:#fff; font-size:16px; font-weight:700;
231  font-family:'Courier New',monospace; margin-bottom:6px;
232  letter-spacing:1px;
233}
234#highlighter-toolbar .timer-controls{
235  display:flex; gap:4px; justify-content:center;
236}
237#highlighter-toolbar .timer-btn{
238  width:28px; height:28px; border:none; border-radius:50%;
239  cursor:pointer; background:rgba(255,255,255,.9);
240  font-size:12px; display:flex; align-items:center;
241  justify-content:center; transition:transform .15s ease;
242}
243#highlighter-toolbar .timer-btn:hover{
244  transform:scale(1.1); background:#fff;
245}
246#highlighter-toolbar .toolbar-buttons{
247  display:flex; flex-direction:column; gap:8px; align-items:center;
248}
249#highlighter-toolbar .highlight-btn, #highlighter-toolbar .clear-btn{
250  width:36px; height:36px; border:none; border-radius:50%;
251  cursor:pointer; transition:transform .15s ease, box-shadow .15s ease;
252  display:flex; align-items:center; justify-content:center;
253  background:#fff; box-shadow:0 2px 8px rgba(0,0,0,.15);
254}
255#highlighter-toolbar .highlight-btn:hover, #highlighter-toolbar .clear-btn:hover{
256  transform:scale(1.08); box-shadow:0 4px 12px rgba(0,0,0,.25);
257}
258#highlighter-toolbar .highlight-btn:active, #highlighter-toolbar .clear-btn:active{
259  transform:scale(.97);
260}
261#highlighter-toolbar .highlight-btn.active{
262  box-shadow:0 0 0 3px rgba(102,126,234,.55);
263}
264#highlighter-toolbar .color-circle{
265  width:24px; height:24px; border-radius:50%;
266  border:2px solid rgba(0,0,0,.1);
267}
268#highlighter-toolbar .stats-btn, #highlighter-toolbar .export-btn{
269  width:36px; height:36px; border:none; border-radius:50%;
270  cursor:pointer; transition:transform .15s ease, box-shadow .15s ease;
271  display:flex; align-items:center; justify-content:center;
272  background:#fff; box-shadow:0 2px 8px rgba(0,0,0,.15);
273  font-size:16px;
274}
275#highlighter-toolbar .stats-btn:hover, #highlighter-toolbar .export-btn:hover{
276  transform:scale(1.08); box-shadow:0 4px 12px rgba(0,0,0,.25);
277}
278#highlighter-toolbar .clear-btn{ background:#ff4757; }
279#highlighter-toolbar .clear-icon{
280  color:#fff; font-size:18px; font-weight:700; line-height:1;
281}
282
283.text-highlight{ padding:2px 0; border-radius:2px; cursor:pointer; }
284.text-highlight:hover{ opacity:.85; }
285.text-highlight.yellow{ background:${COLORS.yellow}; }
286.text-highlight.green{ background:${COLORS.green}; }
287.text-highlight.pink{ background:${COLORS.pink}; }
288.text-highlight.blue{ background:${COLORS.blue}; }
289.text-highlight.orange{ background:${COLORS.orange}; }
290
291/* Stats Modal */
292#stats-modal{
293  position:fixed; top:50%; left:50%; transform:translate(-50%,-50%);
294  background:#fff; border-radius:16px; padding:24px;
295  box-shadow:0 8px 32px rgba(0,0,0,.3);
296  z-index:1000000; min-width:400px; max-width:600px;
297  font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
298}
299#stats-modal .modal-header{
300  display:flex; justify-content:space-between; align-items:center;
301  margin-bottom:20px; padding-bottom:12px;
302  border-bottom:2px solid #f0f0f0;
303}
304#stats-modal .modal-title{
305  font-size:20px; font-weight:700; color:#333;
306}
307#stats-modal .modal-close{
308  background:none; border:none; font-size:24px;
309  cursor:pointer; color:#999; padding:0;
310}
311#stats-modal .stat-item{
312  display:flex; justify-content:space-between; align-items:center;
313  padding:12px; margin:8px 0; border-radius:8px;
314  background:#f8f9fa;
315}
316#stats-modal .stat-label{
317  font-size:14px; color:#666; display:flex; align-items:center; gap:8px;
318}
319#stats-modal .stat-value{
320  font-size:18px; font-weight:700; color:#333;
321}
322#stats-modal .color-indicator{
323  width:16px; height:16px; border-radius:50%;
324  display:inline-block; border:2px solid rgba(0,0,0,.1);
325}
326#stats-modal .progress-bar{
327  width:100%; height:8px; background:#e0e0e0;
328  border-radius:4px; overflow:hidden; margin-top:8px;
329}
330#stats-modal .progress-fill{
331  height:100%; background:linear-gradient(90deg,#667eea,#764ba2);
332  transition:width .3s ease;
333}
334.modal-overlay{
335  position:fixed; top:0; left:0; right:0; bottom:0;
336  background:rgba(0,0,0,.5); z-index:999999;
337}
338
339/* Tooltip */
340.highlight-tooltip{
341  position:fixed; background:#fff; border-radius:8px;
342  box-shadow:0 4px 16px rgba(0,0,0,.2);
343  padding:12px; z-index:1000001;
344  font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
345  max-width:300px;
346}
347.highlight-tooltip .tooltip-text{
348  font-size:13px; color:#333; margin-bottom:8px;
349  line-height:1.4;
350}
351.highlight-tooltip .tooltip-actions{
352  display:flex; gap:8px; justify-content:flex-end;
353}
354.highlight-tooltip .tooltip-btn{
355  background:#f0f0f0; border:none; border-radius:4px;
356  padding:6px 12px; cursor:pointer; font-size:14px;
357  transition:background .2s;
358}
359.highlight-tooltip .tooltip-btn:hover{
360  background:#e0e0e0;
361}
362`;
363        document.head.appendChild(style);
364    }
365
366    function createToolbar() {
367        const toolbar = document.createElement('div');
368        toolbar.id = 'highlighter-toolbar';
369        toolbar.innerHTML = `
370  <div class="toolbar-header">
371    <span class="toolbar-title">Highlighter</span>
372    <button class="toolbar-drag-handle" title="Drag">⋮⋮</button>
373  </div>
374  <div class="timer-section">
375    <div class="timer-display">00:00:00</div>
376    <div class="timer-controls">
377      <button class="timer-btn start-btn" title="Start Timer">▶️</button>
378      <button class="timer-btn pause-btn" title="Pause Timer" style="display:none;">⏸️</button>
379      <button class="timer-btn reset-btn" title="Reset Timer">🔄</button>
380    </div>
381  </div>
382  <div class="toolbar-buttons">
383    <button class="highlight-btn active" data-color="yellow" title="Yellow (Important)"><div class="color-circle" style="background:${COLORS.yellow}"></div></button>
384    <button class="highlight-btn" data-color="green" title="Green (Definitions)"><div class="color-circle" style="background:${COLORS.green}"></div></button>
385    <button class="highlight-btn" data-color="pink" title="Pink (Key Points)"><div class="color-circle" style="background:${COLORS.pink}"></div></button>
386    <button class="highlight-btn" data-color="blue" title="Blue (Examples)"><div class="color-circle" style="background:${COLORS.blue}"></div></button>
387    <button class="highlight-btn" data-color="orange" title="Orange (Questions)"><div class="color-circle" style="background:${COLORS.orange}"></div></button>
388    <button class="stats-btn" title="Show Statistics">📊</button>
389    <button class="export-btn" title="Export Highlights">💾</button>
390    <button class="import-btn" title="Import Highlights">📥</button>
391    <button class="clear-btn" title="Clear All"></button>
392  </div>
393`;
394        document.body.appendChild(toolbar);
395        return toolbar;
396    }
397
398    function makeDraggable(toolbar) {
399        let isDragging = false;
400        let startX = 0, startY = 0;
401        let startLeft = 0, startTop = 0;
402
403        toolbar.addEventListener('mousedown', (e) => {
404            if (e.button !== 0) return;
405            if (e.target.closest('.highlight-btn') || e.target.closest('.clear-btn')) return;
406
407            const rect = toolbar.getBoundingClientRect();
408            startX = e.clientX;
409            startY = e.clientY;
410            startLeft = rect.left;
411            startTop = rect.top;
412            isDragging = true;
413
414            toolbar.style.right = 'auto';
415            toolbar.style.transform = 'none';
416            toolbar.style.left = rect.left + 'px';
417            toolbar.style.top = rect.top + 'px';
418            e.preventDefault();
419        });
420
421        document.addEventListener('mousemove', (e) => {
422            if (!isDragging) return;
423            const dx = e.clientX - startX;
424            const dy = e.clientY - startY;
425            toolbar.style.left = startLeft + dx + 'px';
426            toolbar.style.top = startTop + dy + 'px';
427        });
428
429        document.addEventListener('mouseup', () => {
430            isDragging = false;
431        });
432    }
433
434    function saveHighlights() {
435        const els = document.querySelectorAll('.text-highlight[data-highlight-id]');
436        const items = [];
437
438        els.forEach((el) => {
439            const id = el.getAttribute('data-highlight-id');
440            const color = el.classList.contains('yellow') ? 'yellow' :
441                el.classList.contains('green') ? 'green' :
442                    el.classList.contains('pink') ? 'pink' :
443                        el.classList.contains('blue') ? 'blue' :
444                            el.classList.contains('orange') ? 'orange' : 'yellow';
445
446            const exact = normalizeText(el.textContent);
447            const prefix = el.getAttribute('data-prefix') || '';
448            const suffix = el.getAttribute('data-suffix') || '';
449
450            items.push({ id, color, exact, prefix, suffix, url: location.href, ts: Date.now() });
451        });
452
453        try {
454            localStorage.setItem(getPageKey(), JSON.stringify(items));
455        } catch (e) {
456            console.error('saveHighlights failed', e);
457        }
458    }
459
460    function loadHighlights() {
461        try {
462            const raw = localStorage.getItem(getPageKey());
463            if (!raw) return [];
464            const arr = JSON.parse(raw);
465            return Array.isArray(arr) ? arr : [];
466        } catch (e) {
467            console.error('loadHighlights failed', e);
468            return [];
469        }
470    }
471
472    function wrapRange(range, color, id, prefix, suffix) {
473        const span = document.createElement('span');
474        span.className = `text-highlight ${color}`;
475        span.setAttribute('data-highlight-id', id);
476        span.setAttribute('data-prefix', prefix || '');
477        span.setAttribute('data-suffix', suffix || '');
478
479        try {
480            range.surroundContents(span);
481        } catch {
482            const frag = range.extractContents();
483            span.appendChild(frag);
484            range.insertNode(span);
485        }
486    }
487
488    function highlightSelection(color) {
489        const sel = window.getSelection();
490        if (!sel || sel.rangeCount === 0) return;
491
492        const range = sel.getRangeAt(0);
493        if (range.collapsed) return;
494
495        const common = range.commonAncestorContainer;
496        const commonEl = common.nodeType === Node.ELEMENT_NODE ? common : common.parentElement;
497
498        if (safeClosest(commonEl, '#highlighter-toolbar')) return;
499        if (safeClosest(commonEl, '.text-highlight')) return;
500
501        const selector = buildSelectorFromRange(range);
502        if (!selector) return;
503
504        const id = generateId();
505
506        try {
507            wrapRange(range, color, id, selector.prefix, selector.suffix);
508            sel.removeAllRanges();
509            saveHighlights();
510        } catch (e) {
511            console.error('highlightSelection failed', e);
512            alert('Could not highlight this selection. Try selecting text within a simpler block.');
513        }
514    }
515
516    function removeHighlightSpan(span) {
517        const parent = span.parentNode;
518        if (!parent) return;
519        while (span.firstChild) parent.insertBefore(span.firstChild, span);
520        parent.removeChild(span);
521    }
522
523    function clearAllHighlights() {
524        const spans = document.querySelectorAll('.text-highlight[data-highlight-id]');
525        if (spans.length === 0) return;
526
527        if (!confirm(`Clear all ${spans.length} highlights on this page?`)) return;
528
529        spans.forEach(removeHighlightSpan);
530        localStorage.removeItem(getPageKey());
531    }
532
533    function setupPerHighlightRemoval() {
534    // Alt+Click deletes one highlight
535        document.addEventListener('click', (e) => {
536            const span = e.target && e.target.closest ? e.target.closest('.text-highlight[data-highlight-id]') : null;
537            if (!span) return;
538            if (!e.altKey) return;
539
540            e.preventDefault();
541            removeHighlightSpan(span);
542            saveHighlights();
543        });
544        
545        // Click on highlight to show note tooltip
546        document.addEventListener('click', (e) => {
547            const span = e.target && e.target.closest ? e.target.closest('.text-highlight[data-highlight-id]') : null;
548            if (!span || e.altKey) return;
549            
550            showHighlightTooltip(span, e);
551        });
552    }
553
554    // Timer functions
555    function formatTime(seconds) {
556        const hrs = Math.floor(seconds / 3600);
557        const mins = Math.floor((seconds % 3600) / 60);
558        const secs = seconds % 60;
559        return `${String(hrs).padStart(2, '0')}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
560    }
561
562    function updateTimerDisplay() {
563        const display = document.querySelector('.timer-display');
564        if (display) {
565            display.textContent = formatTime(elapsedTime);
566        }
567    }
568
569    function startTimer() {
570        if (isTimerRunning) return;
571        
572        isTimerRunning = true;
573        startTime = Date.now() - (elapsedTime * 1000);
574        
575        timerInterval = setInterval(() => {
576            elapsedTime = Math.floor((Date.now() - startTime) / 1000);
577            updateTimerDisplay();
578            
579            // Save timer state
580            try {
581                localStorage.setItem(getPageKey() + '_timer', JSON.stringify({
582                    elapsed: elapsedTime,
583                    running: true,
584                    startTime: startTime
585                }));
586            } catch (e) {
587                console.error('Failed to save timer state:', e);
588            }
589        }, 1000);
590        
591        document.querySelector('.start-btn').style.display = 'none';
592        document.querySelector('.pause-btn').style.display = 'flex';
593        
594        console.log('Timer started');
595    }
596
597    function pauseTimer() {
598        if (!isTimerRunning) return;
599        
600        isTimerRunning = false;
601        clearInterval(timerInterval);
602        
603        document.querySelector('.start-btn').style.display = 'flex';
604        document.querySelector('.pause-btn').style.display = 'none';
605        
606        // Save timer state
607        try {
608            localStorage.setItem(getPageKey() + '_timer', JSON.stringify({
609                elapsed: elapsedTime,
610                running: false
611            }));
612        } catch (e) {
613            console.error('Failed to save timer state:', e);
614        }
615        
616        console.log('Timer paused at:', formatTime(elapsedTime));
617    }
618
619    function resetTimer() {
620        isTimerRunning = false;
621        clearInterval(timerInterval);
622        elapsedTime = 0;
623        startTime = null;
624        
625        updateTimerDisplay();
626        
627        document.querySelector('.start-btn').style.display = 'flex';
628        document.querySelector('.pause-btn').style.display = 'none';
629        
630        // Clear timer state
631        try {
632            localStorage.removeItem(getPageKey() + '_timer');
633        } catch (e) {
634            console.error('Failed to clear timer state:', e);
635        }
636        
637        console.log('Timer reset');
638    }
639
640    function restoreTimerState() {
641        try {
642            const timerData = localStorage.getItem(getPageKey() + '_timer');
643            if (!timerData) return;
644            
645            const data = JSON.parse(timerData);
646            elapsedTime = data.elapsed || 0;
647            
648            updateTimerDisplay();
649            
650            if (data.running && data.startTime) {
651                // Continue timer from where it left off
652                const timeSinceStart = Math.floor((Date.now() - data.startTime) / 1000);
653                elapsedTime = timeSinceStart;
654                startTimer();
655            }
656            
657            console.log('Timer state restored:', formatTime(elapsedTime));
658        } catch (e) {
659            console.error('Failed to restore timer state:', e);
660        }
661    }
662
663    // Show tooltip with highlight info
664    function showHighlightTooltip(span, event) {
665        const existingTooltip = document.querySelector('.highlight-tooltip');
666        if (existingTooltip) existingTooltip.remove();
667        
668        const tooltip = document.createElement('div');
669        tooltip.className = 'highlight-tooltip';
670        tooltip.innerHTML = `
671            <div class="tooltip-content">
672                <div class="tooltip-text">"${span.textContent.substring(0, 100)}${span.textContent.length > 100 ? '...' : ''}"</div>
673                <div class="tooltip-actions">
674                    <button class="tooltip-btn delete-btn" title="Delete (Alt+Click)">🗑️</button>
675                    <button class="tooltip-btn copy-btn" title="Copy Text">📋</button>
676                </div>
677            </div>
678        `;
679        
680        document.body.appendChild(tooltip);
681        
682        const rect = span.getBoundingClientRect();
683        tooltip.style.left = rect.left + 'px';
684        tooltip.style.top = (rect.bottom + 10) + 'px';
685        
686        // Delete button
687        tooltip.querySelector('.delete-btn').addEventListener('click', () => {
688            removeHighlightSpan(span);
689            saveHighlights();
690            tooltip.remove();
691        });
692        
693        // Copy button
694        tooltip.querySelector('.copy-btn').addEventListener('click', () => {
695            navigator.clipboard.writeText(span.textContent);
696            const btn = tooltip.querySelector('.copy-btn');
697            btn.textContent = '✓';
698            setTimeout(() => btn.textContent = '📋', 1000);
699        });
700        
701        // Close on click outside
702        setTimeout(() => {
703            document.addEventListener('click', function closeTooltip(e) {
704                if (!tooltip.contains(e.target) && !span.contains(e.target)) {
705                    tooltip.remove();
706                    document.removeEventListener('click', closeTooltip);
707                }
708            });
709        }, 100);
710    }
711
712    // Show statistics modal
713    function showStatistics() {
714        const highlights = loadHighlights();
715        
716        const colorCounts = {
717            yellow: 0,
718            green: 0,
719            pink: 0,
720            blue: 0,
721            orange: 0
722        };
723        
724        highlights.forEach(h => {
725            if (colorCounts[h.color] !== undefined) {
726                colorCounts[h.color]++;
727            }
728        });
729        
730        const total = highlights.length;
731        const totalWords = highlights.reduce((sum, h) => sum + (h.exact || h.text || '').split(' ').length, 0);
732        const avgWordsPerHighlight = total > 0 ? Math.round(totalWords / total) : 0;
733        
734        const overlay = document.createElement('div');
735        overlay.className = 'modal-overlay';
736        
737        const modal = document.createElement('div');
738        modal.id = 'stats-modal';
739        modal.innerHTML = `
740            <div class="modal-header">
741                <div class="modal-title">📊 Highlight Statistics</div>
742                <button class="modal-close">×</button>
743            </div>
744            <div class="modal-body">
745                <div class="stat-item">
746                    <div class="stat-label">⏱️ Study Time</div>
747                    <div class="stat-value">${formatTime(elapsedTime)}</div>
748                </div>
749                <div class="stat-item">
750                    <div class="stat-label">📝 Total Highlights</div>
751                    <div class="stat-value">${total}</div>
752                </div>
753                <div class="stat-item">
754                    <div class="stat-label">📖 Total Words Highlighted</div>
755                    <div class="stat-value">${totalWords}</div>
756                </div>
757                <div class="stat-item">
758                    <div class="stat-label">📏 Avg Words per Highlight</div>
759                    <div class="stat-value">${avgWordsPerHighlight}</div>
760                </div>
761                <div class="stat-item">
762                    <div class="stat-label">
763                        <span class="color-indicator" style="background:${COLORS.yellow}"></span>
764                        Important (Yellow)
765                    </div>
766                    <div class="stat-value">${colorCounts.yellow}</div>
767                </div>
768                <div class="stat-item">
769                    <div class="stat-label">
770                        <span class="color-indicator" style="background:${COLORS.green}"></span>
771                        Definitions (Green)
772                    </div>
773                    <div class="stat-value">${colorCounts.green}</div>
774                </div>
775                <div class="stat-item">
776                    <div class="stat-label">
777                        <span class="color-indicator" style="background:${COLORS.pink}"></span>
778                        Key Points (Pink)
779                    </div>
780                    <div class="stat-value">${colorCounts.pink}</div>
781                </div>
782                <div class="stat-item">
783                    <div class="stat-label">
784                        <span class="color-indicator" style="background:${COLORS.blue}"></span>
785                        Examples (Blue)
786                    </div>
787                    <div class="stat-value">${colorCounts.blue}</div>
788                </div>
789                <div class="stat-item">
790                    <div class="stat-label">
791                        <span class="color-indicator" style="background:${COLORS.orange}"></span>
792                        Questions (Orange)
793                    </div>
794                    <div class="stat-value">${colorCounts.orange}</div>
795                </div>
796                <div class="stat-item">
797                    <div class="stat-label">🎯 Study Progress</div>
798                    <div class="stat-value">${Math.round((total / 50) * 100)}%</div>
799                </div>
800                <div class="progress-bar">
801                    <div class="progress-fill" style="width:${Math.min(100, (total / 50) * 100)}%"></div>
802                </div>
803                <div class="stat-item" style="margin-top:16px; background:#e8f5e9;">
804                    <div class="stat-label">💪 Highlights per Minute</div>
805                    <div class="stat-value">${elapsedTime > 0 ? ((total / elapsedTime) * 60).toFixed(1) : '0.0'}</div>
806                </div>
807            </div>
808        `;
809        
810        document.body.appendChild(overlay);
811        document.body.appendChild(modal);
812        
813        modal.querySelector('.modal-close').addEventListener('click', () => {
814            modal.remove();
815            overlay.remove();
816        });
817        
818        overlay.addEventListener('click', () => {
819            modal.remove();
820            overlay.remove();
821        });
822    }
823
824    // Export highlights to text file
825    function exportHighlights() {
826        const highlights = loadHighlights();
827        
828        if (highlights.length === 0) {
829            alert('No highlights to export!');
830            return;
831        }
832        
833        let exportText = 'UpToDate Highlights Export\n';
834        exportText += `Page: ${location.href}\n`;
835        exportText += `Date: ${new Date().toLocaleString()}\n`;
836        exportText += `Total Highlights: ${highlights.length}\n`;
837        exportText += `Study Time: ${formatTime(elapsedTime)}\n\n`;
838        exportText += `${'='.repeat(60)}\n\n`;
839        
840        const colorLabels = {
841            yellow: '⭐ IMPORTANT',
842            green: '📗 DEFINITION',
843            pink: '🔑 KEY POINT',
844            blue: '💡 EXAMPLE',
845            orange: '❓ QUESTION'
846        };
847        
848        highlights.forEach((h, idx) => {
849            const label = colorLabels[h.color] || h.color.toUpperCase();
850            exportText += `${idx + 1}. [${label}]\n`;
851            exportText += `"${h.exact || h.text}"\n\n`;
852        });
853        
854        const blob = new Blob([exportText], { type: 'text/plain' });
855        const url = URL.createObjectURL(blob);
856        const a = document.createElement('a');
857        a.href = url;
858        a.download = `uptodate-highlights-${Date.now()}.txt`;
859        a.click();
860        URL.revokeObjectURL(url);
861        
862        console.log('Highlights exported successfully!');
863    }
864
865    // Import highlights from file
866    function importHighlights() {
867        const input = document.createElement('input');
868        input.type = 'file';
869        input.accept = '.json';
870        
871        input.addEventListener('change', (e) => {
872            const file = e.target.files[0];
873            if (!file) return;
874            
875            const reader = new FileReader();
876            reader.onload = (event) => {
877                try {
878                    const imported = JSON.parse(event.target.result);
879                    
880                    if (!Array.isArray(imported)) {
881                        alert('Invalid file format!');
882                        return;
883                    }
884                    
885                    // Merge with existing highlights
886                    const existing = loadHighlights();
887                    const merged = [...existing, ...imported];
888                    
889                    localStorage.setItem(getPageKey(), JSON.stringify(merged));
890                    
891                    // Restore the imported highlights
892                    restoreHighlights();
893                    
894                    alert(`Imported ${imported.length} highlights successfully!`);
895                    console.log('Highlights imported:', imported.length);
896                } catch (err) {
897                    console.error('Failed to import highlights:', err);
898                    alert('Failed to import file. Make sure it\'s a valid JSON file.');
899                }
900            };
901            
902            reader.readAsText(file);
903        });
904        
905        input.click();
906    }
907
908    // Find text in document with context matching
909    function findTextWithContext(text, contextBefore, contextAfter) {
910        if (!text || text.length === 0) {
911            console.log('Empty text provided');
912            return null;
913        }
914        
915        console.log('Searching for text:', text.substring(0, 50) + '...');
916        
917        const walker = document.createTreeWalker(
918            document.body,
919            NodeFilter.SHOW_TEXT,
920            {
921                acceptNode: function(node) {
922                    // Skip toolbar and already highlighted text
923                    const parent = node.parentElement;
924                    if (!parent) return NodeFilter.FILTER_REJECT;
925                    if (parent.closest('#highlighter-toolbar')) {
926                        return NodeFilter.FILTER_REJECT;
927                    }
928                    if (parent.classList.contains('text-highlight')) {
929                        return NodeFilter.FILTER_REJECT;
930                    }
931                    // Skip script and style tags
932                    const tagName = parent.tagName ? parent.tagName.toLowerCase() : '';
933                    if (tagName === 'script' || tagName === 'style' || tagName === 'noscript') {
934                        return NodeFilter.FILTER_REJECT;
935                    }
936                    return NodeFilter.FILTER_ACCEPT;
937                }
938            },
939            false
940        );
941
942        let node;
943        const matches = [];
944        
945        while (node = walker.nextNode()) {
946            const nodeText = node.textContent;
947            if (!nodeText) continue;
948            
949            let index = nodeText.indexOf(text);
950            
951            while (index !== -1) {
952                // Check context match
953                const beforeMatch = nodeText.substring(Math.max(0, index - CONTEXT_LEN), index);
954                const afterMatch = nodeText.substring(index + text.length, Math.min(nodeText.length, index + text.length + CONTEXT_LEN));
955                
956                // Calculate match score
957                let score = 1; // Base score for finding the text
958                
959                if (contextBefore && contextBefore.length > 0) {
960                    const contextEnd = contextBefore.substring(Math.max(0, contextBefore.length - 20));
961                    if (beforeMatch.includes(contextEnd)) {
962                        score += 3;
963                    }
964                }
965                
966                if (contextAfter && contextAfter.length > 0) {
967                    const contextStart = contextAfter.substring(0, Math.min(20, contextAfter.length));
968                    if (afterMatch.includes(contextStart)) {
969                        score += 3;
970                    }
971                }
972                
973                matches.push({
974                    node: node,
975                    index: index,
976                    score: score
977                });
978                
979                // Look for next occurrence
980                index = nodeText.indexOf(text, index + 1);
981            }
982        }
983        
984        // Return best match
985        if (matches.length > 0) {
986            matches.sort((a, b) => b.score - a.score);
987            console.log('Found', matches.length, 'matches, best score:', matches[0].score);
988            return matches[0];
989        }
990        
991        console.log('No matches found for text');
992        return null;
993    }
994
995    // Restore highlights from localStorage
996    function restoreHighlights() {
997        try {
998            const pageKey = getPageKey();
999            const saved = localStorage.getItem(pageKey);
1000            
1001            if (!saved) {
1002                console.log('No saved highlights found for this page');
1003                return;
1004            }
1005
1006            const highlights = JSON.parse(saved);
1007            console.log('Trying to restore', highlights.length, 'highlights...');
1008
1009            let restoredCount = 0;
1010            
1011            highlights.forEach((highlight, idx) => {
1012                try {
1013                    // Check if already restored
1014                    if (document.querySelector(`[data-highlight-id="${highlight.id}"]`)) {
1015                        console.log('Highlight', idx + 1, 'already restored:', highlight.id);
1016                        restoredCount++;
1017                        return;
1018                    }
1019                    
1020                    // Find text with context
1021                    const match = findTextWithContext(
1022                        highlight.text,
1023                        highlight.contextBefore || '',
1024                        highlight.contextAfter || ''
1025                    );
1026                    
1027                    if (match) {
1028                        const range = document.createRange();
1029                        range.setStart(match.node, match.index);
1030                        range.setEnd(match.node, match.index + highlight.text.length);
1031                        
1032                        const span = document.createElement('span');
1033                        span.className = `text-highlight ${highlight.color}`;
1034                        span.setAttribute('data-highlight-id', highlight.id);
1035                        
1036                        try {
1037                            range.surroundContents(span);
1038                            restoredCount++;
1039                            console.log('✓ Restored highlight', idx + 1, ':', highlight.text.substring(0, 30) + '...');
1040                        } catch (e) {
1041                            console.warn('Failed to surround contents, trying alternative method:', e.message);
1042                            // Try alternative method
1043                            try {
1044                                const fragment = range.extractContents();
1045                                span.appendChild(fragment);
1046                                range.insertNode(span);
1047                                restoredCount++;
1048                                console.log('✓ Restored highlight', idx + 1, '(alternative method)');
1049                            } catch (e2) {
1050                                console.error('Alternative method also failed:', e2.message);
1051                            }
1052                        }
1053                    } else {
1054                        console.warn('✗ Could not find text for highlight', idx + 1, ':', highlight.text.substring(0, 30) + '...');
1055                    }
1056                } catch (e) {
1057                    console.error('Failed to restore highlight', idx + 1, ':', e);
1058                }
1059            });
1060            
1061            console.log('=== Restoration complete:', restoredCount, 'of', highlights.length, 'highlights restored ===');
1062        } catch (e) {
1063            console.error('Failed to restore highlights:', e);
1064        }
1065    }
1066
1067    // Setup MutationObserver to watch for dynamic content
1068    function setupContentObserver() {
1069        console.log('Setting up MutationObserver for dynamic content');
1070        
1071        let isRestoring = false;
1072        
1073        const debouncedRestore = debounce(() => {
1074            if (isRestoring) {
1075                console.log('Restoration already in progress, skipping...');
1076                return;
1077            }
1078            isRestoring = true;
1079            console.log('Content changed, attempting to restore highlights');
1080            restoreHighlights();
1081            setTimeout(() => {
1082                isRestoring = false;
1083            }, 1000);
1084        }, 2000);
1085
1086        const observer = new MutationObserver((mutations) => {
1087            let shouldRestore = false;
1088            
1089            for (const mutation of mutations) {
1090                if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
1091                    // Check if added nodes contain significant text content
1092                    for (const node of mutation.addedNodes) {
1093                        if (node.nodeType === Node.ELEMENT_NODE) {
1094                            // Only trigger for substantial content changes
1095                            if (node.textContent && node.textContent.length > 100) {
1096                                shouldRestore = true;
1097                                break;
1098                            }
1099                        }
1100                    }
1101                }
1102                if (shouldRestore) break;
1103            }
1104            
1105            if (shouldRestore) {
1106                debouncedRestore();
1107            }
1108        });
1109
1110        // Observe the main content area
1111        const contentArea = document.body;
1112        if (contentArea) {
1113            observer.observe(contentArea, {
1114                childList: true,
1115                subtree: true
1116            });
1117            console.log('MutationObserver started');
1118        }
1119    }
1120
1121    // Handle keyboard shortcuts
1122    function handleKeyDown(e) {
1123        // Check if Command (Mac) or Ctrl (Windows/Linux) is pressed
1124        // But NOT if it's combined with other keys (like Cmd+C, Cmd+V, etc.)
1125        if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) {
1126            const selection = window.getSelection();
1127            
1128            // Check if there's selected text
1129            if (selection && !selection.isCollapsed && selection.toString().trim().length > 0) {
1130                // Don't highlight if selection is in toolbar or already highlighted
1131                try {
1132                    const range = selection.getRangeAt(0);
1133                    const container = range.startContainer;
1134                    const parentElement = container.nodeType === Node.TEXT_NODE ? container.parentElement : container;
1135                    
1136                    if (!parentElement) return;
1137                    
1138                    if (parentElement.closest('#highlighter-toolbar') || parentElement.classList.contains('text-highlight')) {
1139                        return;
1140                    }
1141                    
1142                    // Only prevent default if we're actually going to highlight
1143                    // Check if it's just Cmd/Ctrl alone (no other key)
1144                    if (e.key === 'Meta' || e.key === 'Control') {
1145                        e.preventDefault();
1146                        highlightSelection(selectedColor);
1147                        console.log('Text highlighted with keyboard shortcut');
1148                    }
1149                } catch (err) {
1150                    console.error('Error in keyboard handler:', err);
1151                }
1152            }
1153        }
1154    }
1155
1156    function init() {
1157        addStyles();
1158        const toolbar = createToolbar();
1159        makeDraggable(toolbar);
1160
1161        const highlightButtons = toolbar.querySelectorAll('.highlight-btn');
1162        highlightButtons.forEach((btn) => {
1163            btn.addEventListener('click', () => {
1164                const color = btn.getAttribute('data-color');
1165                selectedColor = color;
1166
1167                highlightButtons.forEach((b) => b.classList.remove('active'));
1168                btn.classList.add('active');
1169
1170                const sel = window.getSelection();
1171                if (sel && !sel.isCollapsed) highlightSelection(color);
1172            });
1173        });
1174
1175        toolbar.querySelector('.clear-btn').addEventListener('click', clearAllHighlights);
1176
1177        setupPerHighlightRemoval();
1178        document.addEventListener('keydown', handleKeyDown);
1179        setupContentObserver();
1180        
1181        // Stats button
1182        toolbar.querySelector('.stats-btn').addEventListener('click', showStatistics);
1183        
1184        // Export button
1185        toolbar.querySelector('.export-btn').addEventListener('click', exportHighlights);
1186        
1187        // Import button
1188        toolbar.querySelector('.import-btn').addEventListener('click', importHighlights);
1189        
1190        // Timer buttons
1191        toolbar.querySelector('.start-btn').addEventListener('click', startTimer);
1192        toolbar.querySelector('.pause-btn').addEventListener('click', pauseTimer);
1193        toolbar.querySelector('.reset-btn').addEventListener('click', resetTimer);
1194        
1195        // Restore timer state
1196        restoreTimerState();
1197
1198        restoreHighlights();
1199        setTimeout(restoreHighlights, 1500);
1200        setTimeout(restoreHighlights, 4000);
1201        setTimeout(restoreHighlights, 8000);
1202    }
1203
1204    if (document.readyState === 'loading') {
1205        document.addEventListener('DOMContentLoaded', init);
1206    } else {
1207        init();
1208    }
1209})();
UpToDate Text Highlighter (Cmd/Ctrl Only) | Robomonkey