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