AMBOSS Multi-Color Highlighter

Add multiple color options for text highlighting on AMBOSS

Size

19.2 KB

Version

1.1.2

Created

Nov 7, 2025

Updated

about 1 month ago

1// ==UserScript==
2// @name		AMBOSS Multi-Color Highlighter
3// @description		Add multiple color options for text highlighting on AMBOSS
4// @version		1.1.2
5// @match		https://*.next.amboss.com/*
6// @icon		https://next.amboss.com/us/static/assets/86b15308e0846555.png
7// @grant		GM.getValue
8// @grant		GM.setValue
9// ==/UserScript==
10(function() {
11    'use strict';
12
13    // Available highlight colors
14    const COLORS = {
15        blue: { name: 'Blue', color: 'rgba(173, 216, 230, 0.6)', darkColor: 'rgba(100, 149, 237, 0.4)' },
16        yellow: { name: 'Yellow', color: 'rgba(255, 255, 153, 0.6)', darkColor: 'rgba(255, 215, 0, 0.4)' },
17        green: { name: 'Green', color: 'rgba(144, 238, 144, 0.6)', darkColor: 'rgba(60, 179, 113, 0.4)' },
18        pink: { name: 'Pink', color: 'rgba(255, 182, 193, 0.6)', darkColor: 'rgba(255, 105, 180, 0.4)' },
19        orange: { name: 'Orange', color: 'rgba(255, 200, 124, 0.6)', darkColor: 'rgba(255, 140, 0, 0.4)' },
20        purple: { name: 'Purple', color: 'rgba(221, 160, 221, 0.6)', darkColor: 'rgba(147, 112, 219, 0.4)' }
21    };
22
23    let colorPickerMenu = null;
24    let currentSelection = null;
25
26    // Initialize the extension
27    async function init() {
28        console.log('AMBOSS Multi-Color Highlighter: Initializing...');
29        
30        // Add custom styles
31        addStyles();
32        
33        // Create color picker menu
34        createColorPickerMenu();
35        
36        // Listen for text selection
37        document.addEventListener('mouseup', handleTextSelection);
38        document.addEventListener('selectionchange', handleSelectionChange);
39        
40        // Apply saved highlights
41        await applySavedHighlights();
42        
43        console.log('AMBOSS Multi-Color Highlighter: Ready!');
44    }
45
46    // Add custom CSS styles
47    function addStyles() {
48        const style = document.createElement('style');
49        style.textContent = `
50            .rm-color-picker-menu {
51                position: absolute;
52                background: white;
53                border: 2px solid #333;
54                border-radius: 8px;
55                padding: 8px;
56                display: none;
57                z-index: 10000;
58                box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
59                gap: 6px;
60                flex-wrap: wrap;
61                width: 200px;
62            }
63            
64            .rm-color-picker-menu.visible {
65                display: flex;
66            }
67            
68            .rm-color-btn {
69                width: 36px;
70                height: 36px;
71                border: 2px solid #333;
72                border-radius: 6px;
73                cursor: pointer;
74                transition: transform 0.2s, box-shadow 0.2s;
75                position: relative;
76            }
77            
78            .rm-color-btn:hover {
79                transform: scale(1.15);
80                box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
81            }
82            
83            .rm-color-btn:active {
84                transform: scale(0.95);
85            }
86            
87            .rm-color-label {
88                position: absolute;
89                bottom: -20px;
90                left: 50%;
91                transform: translateX(-50%);
92                font-size: 10px;
93                white-space: nowrap;
94                color: #333;
95                font-weight: bold;
96                opacity: 0;
97                transition: opacity 0.2s;
98                pointer-events: none;
99            }
100            
101            .rm-color-btn:hover .rm-color-label {
102                opacity: 1;
103            }
104            
105            .rm-remove-btn {
106                width: 100%;
107                height: 32px;
108                background: #ff4444;
109                color: white;
110                border: none;
111                border-radius: 6px;
112                cursor: pointer;
113                font-weight: bold;
114                font-size: 12px;
115                margin-top: 4px;
116                transition: background 0.2s;
117            }
118            
119            .rm-remove-btn:hover {
120                background: #cc0000;
121            }
122            
123            .rm-highlight {
124                position: relative;
125                cursor: pointer;
126            }
127            
128            .rm-highlight:hover::after {
129                content: '✏️';
130                position: absolute;
131                right: -18px;
132                top: -2px;
133                font-size: 14px;
134            }
135        `;
136        document.head.appendChild(style);
137    }
138
139    // Create the color picker menu
140    function createColorPickerMenu() {
141        colorPickerMenu = document.createElement('div');
142        colorPickerMenu.className = 'rm-color-picker-menu';
143        
144        // Prevent menu from losing selection when clicking
145        colorPickerMenu.addEventListener('mousedown', (e) => {
146            e.preventDefault();
147        });
148        
149        // Add color buttons
150        Object.entries(COLORS).forEach(([key, colorData]) => {
151            const btn = document.createElement('div');
152            btn.className = 'rm-color-btn';
153            btn.style.backgroundColor = colorData.color;
154            btn.dataset.color = key;
155            
156            const label = document.createElement('div');
157            label.className = 'rm-color-label';
158            label.textContent = colorData.name;
159            btn.appendChild(label);
160            
161            btn.addEventListener('click', () => applyHighlight(key));
162            colorPickerMenu.appendChild(btn);
163        });
164        
165        // Add remove highlight button
166        const removeBtn = document.createElement('button');
167        removeBtn.className = 'rm-remove-btn';
168        removeBtn.textContent = 'Remove Highlight';
169        removeBtn.addEventListener('click', removeHighlight);
170        colorPickerMenu.appendChild(removeBtn);
171        
172        document.body.appendChild(colorPickerMenu);
173    }
174
175    // Handle text selection
176    function handleTextSelection(e) {
177        const selection = window.getSelection();
178        const selectedText = selection.toString().trim();
179        
180        if (selectedText.length > 0) {
181            currentSelection = {
182                text: selectedText,
183                range: selection.getRangeAt(0).cloneRange()
184            };
185            
186            // Position and show the color picker
187            showColorPicker(e.pageX, e.pageY);
188        } else {
189            // Check if clicking on an existing highlight
190            const highlight = e.target.closest('.rm-highlight');
191            if (highlight) {
192                console.log('Clicked on highlight:', highlight);
193                showColorPickerForHighlight(highlight, e.pageX, e.pageY);
194            } else {
195                hideColorPicker();
196            }
197        }
198    }
199
200    // Handle selection changes
201    function handleSelectionChange() {
202        const selection = window.getSelection();
203        if (selection.toString().trim().length === 0 && !colorPickerMenu.matches(':hover')) {
204            setTimeout(() => {
205                if (!colorPickerMenu.matches(':hover')) {
206                    hideColorPicker();
207                }
208            }, 100);
209        }
210    }
211
212    // Show color picker at position
213    function showColorPicker(x, y) {
214        colorPickerMenu.style.left = `${x}px`;
215        colorPickerMenu.style.top = `${y + 10}px`;
216        colorPickerMenu.classList.add('visible');
217        
218        // Adjust position if off screen
219        setTimeout(() => {
220            const rect = colorPickerMenu.getBoundingClientRect();
221            if (rect.right > window.innerWidth) {
222                colorPickerMenu.style.left = `${window.innerWidth - rect.width - 10}px`;
223            }
224            if (rect.bottom > window.innerHeight) {
225                colorPickerMenu.style.top = `${y - rect.height - 10}px`;
226            }
227        }, 0);
228    }
229
230    // Show color picker for existing highlight
231    function showColorPickerForHighlight(highlight, x, y) {
232        currentSelection = { highlightElement: highlight };
233        showColorPicker(x, y);
234    }
235
236    // Hide color picker
237    function hideColorPicker() {
238        colorPickerMenu.classList.remove('visible');
239        currentSelection = null;
240    }
241
242    // Apply highlight with selected color
243    async function applyHighlight(colorKey) {
244        console.log('applyHighlight called with color:', colorKey);
245        console.log('currentSelection:', currentSelection);
246        
247        if (!currentSelection) return;
248        
249        const colorData = COLORS[colorKey];
250        const isDarkMode = document.documentElement.classList.contains('isDarkmodeActive');
251        const bgColor = isDarkMode ? colorData.darkColor : colorData.color;
252        
253        console.log('Applying color:', bgColor);
254        
255        if (currentSelection.highlightElement) {
256            // Update existing highlight
257            const highlight = currentSelection.highlightElement;
258            highlight.style.backgroundColor = bgColor;
259            highlight.dataset.color = colorKey;
260            
261            console.log('Updated existing highlight');
262            await saveHighlight(highlight);
263        } else if (currentSelection.range) {
264            // Create new highlight
265            const span = document.createElement('span');
266            span.className = 'rm-highlight';
267            span.style.backgroundColor = bgColor;
268            span.dataset.color = colorKey;
269            
270            console.log('Creating new highlight span');
271            
272            try {
273                currentSelection.range.surroundContents(span);
274                console.log('Successfully wrapped text with highlight');
275                await saveHighlight(span);
276            } catch (e) {
277                console.error('Failed to apply highlight:', e);
278                // Fallback: try to wrap the selection differently
279                try {
280                    const contents = currentSelection.range.extractContents();
281                    span.appendChild(contents);
282                    currentSelection.range.insertNode(span);
283                    console.log('Applied highlight using fallback method');
284                    await saveHighlight(span);
285                } catch (e2) {
286                    console.error('Fallback highlight also failed:', e2);
287                }
288            }
289        }
290        
291        hideColorPicker();
292        window.getSelection().removeAllRanges();
293    }
294
295    // Remove highlight
296    async function removeHighlight() {
297        console.log('removeHighlight called');
298        console.log('currentSelection:', currentSelection);
299        
300        if (!currentSelection || !currentSelection.highlightElement) {
301            console.log('No highlight element to remove');
302            return;
303        }
304        
305        const highlight = currentSelection.highlightElement;
306        const parent = highlight.parentNode;
307        
308        console.log('Removing highlight element:', highlight);
309        
310        // Move children out of the highlight span
311        while (highlight.firstChild) {
312            parent.insertBefore(highlight.firstChild, highlight);
313        }
314        parent.removeChild(highlight);
315        
316        console.log('Highlight element removed from DOM');
317        
318        // Remove from storage
319        await deleteHighlight(highlight);
320        
321        hideColorPicker();
322    }
323
324    // Save highlight to storage
325    async function saveHighlight(highlightElement) {
326        try {
327            const pageUrl = window.location.href;
328            const highlights = await GM.getValue('highlights', {});
329            
330            // Create page-specific storage
331            if (!highlights[pageUrl]) {
332                highlights[pageUrl] = {};
333            }
334            
335            const id = generateHighlightId();
336            
337            highlights[pageUrl][id] = {
338                text: highlightElement.textContent,
339                color: highlightElement.dataset.color,
340                timestamp: Date.now()
341            };
342            
343            highlightElement.dataset.highlightId = id;
344            await GM.setValue('highlights', highlights);
345            console.log('Highlight saved:', id, 'for page:', pageUrl);
346        } catch (e) {
347            console.error('Failed to save highlight:', e);
348        }
349    }
350
351    // Delete highlight from storage
352    async function deleteHighlight(highlightElement) {
353        try {
354            const pageUrl = window.location.href;
355            const highlights = await GM.getValue('highlights', {});
356            const id = highlightElement.dataset.highlightId;
357            
358            if (id && highlights[pageUrl] && highlights[pageUrl][id]) {
359                delete highlights[pageUrl][id];
360                await GM.setValue('highlights', highlights);
361                console.log('Highlight deleted:', id);
362            }
363        } catch (e) {
364            console.error('Failed to delete highlight:', e);
365        }
366    }
367
368    // Apply saved highlights on page load
369    async function applySavedHighlights() {
370        try {
371            const pageUrl = window.location.href;
372            const highlights = await GM.getValue('highlights', {});
373            const pageHighlights = highlights[pageUrl] || {};
374            
375            console.log('Applying saved highlights:', Object.keys(pageHighlights).length);
376            
377            // Wait for content to load
378            await new Promise(resolve => setTimeout(resolve, 1000));
379            
380            for (const [id, data] of Object.entries(pageHighlights)) {
381                try {
382                    // Search for the text in the page
383                    findAndHighlightText(data.text, data.color, id);
384                } catch (e) {
385                    console.error('Failed to apply highlight:', id, e);
386                }
387            }
388        } catch (e) {
389            console.error('Failed to load highlights:', e);
390        }
391    }
392
393    // Find and highlight text in the page
394    function findAndHighlightText(text, colorKey, id) {
395        const isDarkMode = document.documentElement.classList.contains('isDarkmodeActive');
396        const colorData = COLORS[colorKey];
397        const bgColor = isDarkMode ? colorData.darkColor : colorData.color;
398        
399        // Search in the main content area
400        const contentArea = document.querySelector('main, article, .content, body');
401        if (!contentArea) return;
402        
403        const walker = document.createTreeWalker(
404            contentArea,
405            NodeFilter.SHOW_TEXT,
406            {
407                acceptNode: function(node) {
408                    // Skip if already highlighted
409                    if (node.parentElement && node.parentElement.classList.contains('rm-highlight')) {
410                        return NodeFilter.FILTER_REJECT;
411                    }
412                    // Skip script and style tags
413                    if (node.parentElement && (node.parentElement.tagName === 'SCRIPT' || node.parentElement.tagName === 'STYLE')) {
414                        return NodeFilter.FILTER_REJECT;
415                    }
416                    return NodeFilter.FILTER_ACCEPT;
417                }
418            },
419            false
420        );
421        
422        const textNodes = [];
423        let node;
424        while (node = walker.nextNode()) {
425            if (node.textContent.includes(text)) {
426                textNodes.push(node);
427            }
428        }
429        
430        // Only highlight the first occurrence
431        if (textNodes.length > 0) {
432            const textNode = textNodes[0];
433            const index = textNode.textContent.indexOf(text);
434            if (index !== -1) {
435                const range = document.createRange();
436                range.setStart(textNode, index);
437                range.setEnd(textNode, index + text.length);
438                
439                const span = document.createElement('span');
440                span.className = 'rm-highlight';
441                span.style.backgroundColor = bgColor;
442                span.dataset.color = colorKey;
443                span.dataset.highlightId = id;
444                
445                try {
446                    range.surroundContents(span);
447                    console.log('Restored highlight:', id);
448                } catch (e) {
449                    console.log('Could not restore highlight:', e);
450                }
451            }
452        }
453    }
454
455    // Wrap specific text in an element with highlight
456    function wrapTextInElement(element, text, colorKey, id) {
457        const isDarkMode = document.documentElement.classList.contains('isDarkmodeActive');
458        const colorData = COLORS[colorKey];
459        const bgColor = isDarkMode ? colorData.darkColor : colorData.color;
460        
461        const walker = document.createTreeWalker(
462            element,
463            NodeFilter.SHOW_TEXT,
464            null,
465            false
466        );
467        
468        const textNodes = [];
469        let node;
470        while (node = walker.nextNode()) {
471            if (node.textContent.includes(text)) {
472                textNodes.push(node);
473            }
474        }
475        
476        textNodes.forEach(textNode => {
477            const index = textNode.textContent.indexOf(text);
478            if (index !== -1) {
479                const range = document.createRange();
480                range.setStart(textNode, index);
481                range.setEnd(textNode, index + text.length);
482                
483                const span = document.createElement('span');
484                span.className = 'rm-highlight';
485                span.style.backgroundColor = bgColor;
486                span.dataset.color = colorKey;
487                span.dataset.highlightId = id;
488                
489                try {
490                    range.surroundContents(span);
491                } catch (e) {
492                    // Text might already be wrapped or have complex structure
493                    console.log('Could not wrap text:', e);
494                }
495            }
496        });
497    }
498
499    // Generate unique ID for highlight
500    function generateHighlightId() {
501        return `hl_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
502    }
503
504    // Get XPath of element
505    function getXPath(element) {
506        if (element.id) {
507            return `//*[@id="${element.id}"]`;
508        }
509        
510        const parts = [];
511        while (element && element.nodeType === Node.ELEMENT_NODE) {
512            let index = 0;
513            let sibling = element.previousSibling;
514            
515            while (sibling) {
516                if (sibling.nodeType === Node.ELEMENT_NODE && sibling.nodeName === element.nodeName) {
517                    index++;
518                }
519                sibling = sibling.previousSibling;
520            }
521            
522            const tagName = element.nodeName.toLowerCase();
523            const pathIndex = index > 0 ? `[${index + 1}]` : '';
524            parts.unshift(tagName + pathIndex);
525            
526            element = element.parentNode;
527        }
528        
529        return parts.length ? '/' + parts.join('/') : '';
530    }
531
532    // Get element by XPath
533    function getElementByXPath(xpath) {
534        try {
535            const result = document.evaluate(
536                xpath,
537                document,
538                null,
539                XPathResult.FIRST_ORDERED_NODE_TYPE,
540                null
541            );
542            return result.singleNodeValue;
543        } catch (e) {
544            console.error('XPath evaluation failed:', e);
545            return null;
546        }
547    }
548
549    // Wait for page to be ready
550    if (document.readyState === 'loading') {
551        document.addEventListener('DOMContentLoaded', init);
552    } else {
553        init();
554    }
555})();
AMBOSS Multi-Color Highlighter | Robomonkey