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