ChatGPT Text Highlighter

Highlight any portion of questions and answers with multiple color options

Size

13.5 KB

Version

1.1.3

Created

Oct 23, 2025

Updated

22 days ago

1// ==UserScript==
2// @name		ChatGPT Text Highlighter
3// @description		Highlight any portion of questions and answers with multiple color options
4// @version		1.1.3
5// @match		https://*.chatgpt.com/*
6// @icon		https://cdn.oaistatic.com/assets/favicon-l4nq08hd.svg
7// ==/UserScript==
8(function() {
9    'use strict';
10
11    // Available highlight colors
12    const COLORS = [
13        { name: 'Yellow', value: '#ffeb3b', text: '#000000' },
14        { name: 'Green', value: '#4caf50', text: '#ffffff' },
15        { name: 'Blue', value: '#2196f3', text: '#ffffff' },
16        { name: 'Pink', value: '#e91e63', text: '#ffffff' },
17        { name: 'Orange', value: '#ff9800', text: '#000000' },
18        { name: 'Purple', value: '#9c27b0', text: '#ffffff' }
19    ];
20
21    let selectedColor = COLORS[0]; // Default to yellow
22    let colorPicker = null;
23
24    // Initialize the extension
25    async function init() {
26        console.log('ChatGPT Text Highlighter initialized');
27        
28        // Load saved highlights
29        await loadHighlights();
30        
31        // Create color picker UI
32        createColorPicker();
33        
34        // Listen for text selection
35        document.addEventListener('mouseup', handleTextSelection);
36        
37        // Observe DOM changes to reapply highlights
38        observeDOMChanges();
39    }
40
41    // Create floating color picker
42    function createColorPicker() {
43        colorPicker = document.createElement('div');
44        colorPicker.id = 'chatgpt-highlighter-picker';
45        colorPicker.style.cssText = `
46            position: absolute;
47            display: none;
48            background: white;
49            border: 2px solid #333;
50            border-radius: 8px;
51            padding: 10px;
52            box-shadow: 0 4px 20px rgba(0,0,0,0.3);
53            z-index: 999999;
54            display: flex;
55            gap: 8px;
56            flex-wrap: wrap;
57            width: 220px;
58        `;
59
60        // Add color buttons
61        COLORS.forEach(color => {
62            const btn = document.createElement('button');
63            btn.style.cssText = `
64                width: 36px;
65                height: 36px;
66                border: 2px solid #333;
67                border-radius: 6px;
68                cursor: pointer;
69                background-color: ${color.value};
70                transition: transform 0.2s;
71                flex-shrink: 0;
72            `;
73            btn.title = color.name;
74            btn.addEventListener('mousedown', (e) => {
75                e.preventDefault();
76                e.stopPropagation();
77            });
78            btn.addEventListener('click', (e) => {
79                e.preventDefault();
80                e.stopPropagation();
81                selectedColor = color;
82                applyHighlight();
83            });
84            btn.addEventListener('mouseenter', () => {
85                btn.style.transform = 'scale(1.15)';
86                btn.style.borderColor = '#000';
87            });
88            btn.addEventListener('mouseleave', () => {
89                btn.style.transform = 'scale(1)';
90                btn.style.borderColor = '#333';
91            });
92            colorPicker.appendChild(btn);
93        });
94
95        // Add remove highlight button
96        const removeBtn = document.createElement('button');
97        removeBtn.textContent = '✕ Remove';
98        removeBtn.style.cssText = `
99            width: 100%;
100            margin-top: 4px;
101            padding: 8px;
102            border: 2px solid #333;
103            border-radius: 6px;
104            cursor: pointer;
105            background: #f5f5f5;
106            font-size: 13px;
107            font-weight: 600;
108            color: #333;
109        `;
110        removeBtn.addEventListener('mousedown', (e) => {
111            e.preventDefault();
112            e.stopPropagation();
113        });
114        removeBtn.addEventListener('click', (e) => {
115            e.preventDefault();
116            e.stopPropagation();
117            removeHighlight();
118        });
119        colorPicker.appendChild(removeBtn);
120
121        document.body.appendChild(colorPicker);
122
123        // Hide picker when clicking outside
124        document.addEventListener('mousedown', (e) => {
125            if (!colorPicker.contains(e.target) && colorPicker.style.display === 'flex') {
126                colorPicker.style.display = 'none';
127            }
128        });
129    }
130
131    // Handle text selection
132    function handleTextSelection(e) {
133        const selection = window.getSelection();
134        const selectedText = selection.toString().trim();
135
136        console.log('Text selected:', selectedText.length, 'characters');
137
138        if (selectedText.length > 0) {
139            // Check if selection is within a message
140            const range = selection.getRangeAt(0);
141            const container = range.commonAncestorContainer;
142            console.log('Container node type:', container.nodeType);
143            
144            const messageElement = container.nodeType === 3 
145                ? container.parentElement.closest('[data-message-author-role]')
146                : container.closest('[data-message-author-role]');
147
148            console.log('Message element found:', !!messageElement);
149            
150            if (messageElement) {
151                console.log('Message role:', messageElement.getAttribute('data-message-author-role'));
152                
153                // Show color picker near selection
154                const rect = range.getBoundingClientRect();
155                console.log('Selection rect:', rect.left, rect.top, rect.bottom);
156                
157                // Prevent the click from hiding the picker
158                e.stopPropagation();
159                
160                colorPicker.style.display = 'flex';
161                colorPicker.style.left = `${rect.left + window.scrollX}px`;
162                colorPicker.style.top = `${rect.bottom + window.scrollY + 5}px`;
163                
164                console.log('Color picker positioned at:', colorPicker.style.left, colorPicker.style.top);
165                
166                // Store current selection
167                colorPicker.dataset.selectionData = JSON.stringify({
168                    text: selectedText,
169                    startOffset: getTextOffset(messageElement, range.startContainer, range.startOffset),
170                    endOffset: getTextOffset(messageElement, range.endContainer, range.endOffset),
171                    messageId: getMessageId(messageElement)
172                });
173            }
174        } else {
175            // Only hide if clicking outside the color picker
176            if (!colorPicker.contains(e.target)) {
177                colorPicker.style.display = 'none';
178            }
179        }
180    }
181
182    // Apply highlight to selected text
183    async function applyHighlight() {
184        const selectionData = JSON.parse(colorPicker.dataset.selectionData || '{}');
185        if (!selectionData.text) return;
186
187        // Save highlight
188        const highlights = await GM.getValue('chatgpt_highlights', []);
189        highlights.push({
190            ...selectionData,
191            color: selectedColor.value,
192            textColor: selectedColor.text,
193            timestamp: Date.now()
194        });
195        await GM.setValue('chatgpt_highlights', highlights);
196
197        // Reapply all highlights
198        await loadHighlights();
199
200        // Hide picker
201        colorPicker.style.display = 'none';
202        window.getSelection().removeAllRanges();
203    }
204
205    // Remove highlight from selected text
206    async function removeHighlight() {
207        const selectionData = JSON.parse(colorPicker.dataset.selectionData || '{}');
208        if (!selectionData.text) return;
209
210        // Remove matching highlights
211        let highlights = await GM.getValue('chatgpt_highlights', []);
212        highlights = highlights.filter(h => 
213            !(h.messageId === selectionData.messageId && 
214              h.startOffset === selectionData.startOffset && 
215              h.endOffset === selectionData.endOffset)
216        );
217        await GM.setValue('chatgpt_highlights', highlights);
218
219        // Reapply remaining highlights
220        await loadHighlights();
221
222        // Hide picker
223        colorPicker.style.display = 'none';
224        window.getSelection().removeAllRanges();
225    }
226
227    // Load and apply saved highlights
228    async function loadHighlights() {
229        // Remove existing highlights
230        document.querySelectorAll('.chatgpt-highlight').forEach(el => {
231            const parent = el.parentNode;
232            parent.replaceChild(document.createTextNode(el.textContent), el);
233            parent.normalize();
234        });
235
236        const highlights = await GM.getValue('chatgpt_highlights', []);
237        
238        // Apply each highlight
239        highlights.forEach(highlight => {
240            const messageElement = document.querySelector(`[data-message-id="${highlight.messageId}"]`);
241            if (messageElement) {
242                applyHighlightToElement(messageElement, highlight);
243            }
244        });
245    }
246
247    // Apply highlight to specific element
248    function applyHighlightToElement(messageElement, highlight) {
249        const walker = document.createTreeWalker(
250            messageElement,
251            NodeFilter.SHOW_TEXT,
252            null,
253            false
254        );
255
256        let currentOffset = 0;
257        let startNode = null;
258        let startNodeOffset = 0;
259        let endNode = null;
260        let endNodeOffset = 0;
261
262        // Find start and end nodes
263        while (walker.nextNode()) {
264            const node = walker.currentNode;
265            const nodeLength = node.textContent.length;
266
267            if (!startNode && currentOffset + nodeLength > highlight.startOffset) {
268                startNode = node;
269                startNodeOffset = highlight.startOffset - currentOffset;
270            }
271
272            if (currentOffset + nodeLength >= highlight.endOffset) {
273                endNode = node;
274                endNodeOffset = highlight.endOffset - currentOffset;
275                break;
276            }
277
278            currentOffset += nodeLength;
279        }
280
281        if (startNode && endNode) {
282            const range = document.createRange();
283            range.setStart(startNode, startNodeOffset);
284            range.setEnd(endNode, endNodeOffset);
285
286            const span = document.createElement('span');
287            span.className = 'chatgpt-highlight';
288            span.style.cssText = `
289                background-color: ${highlight.color};
290                color: ${highlight.textColor};
291                padding: 2px 0;
292                border-radius: 2px;
293            `;
294
295            try {
296                range.surroundContents(span);
297            } catch (e) {
298                console.log('Could not apply highlight:', e);
299            }
300        }
301    }
302
303    // Get text offset within message element
304    function getTextOffset(messageElement, node, offset) {
305        const walker = document.createTreeWalker(
306            messageElement,
307            NodeFilter.SHOW_TEXT,
308            null,
309            false
310        );
311
312        let totalOffset = 0;
313        while (walker.nextNode()) {
314            if (walker.currentNode === node) {
315                return totalOffset + offset;
316            }
317            totalOffset += walker.currentNode.textContent.length;
318        }
319        return totalOffset;
320    }
321
322    // Get unique message ID
323    function getMessageId(messageElement) {
324        // Try to get existing data-message-id
325        let messageId = messageElement.getAttribute('data-message-id');
326        
327        if (!messageId) {
328            // Generate unique ID based on content and position
329            const role = messageElement.getAttribute('data-message-author-role');
330            const text = messageElement.textContent.substring(0, 50);
331            messageId = `${role}-${btoa(text).substring(0, 20)}-${Date.now()}`;
332            messageElement.setAttribute('data-message-id', messageId);
333        }
334        
335        return messageId;
336    }
337
338    // Observe DOM changes to reapply highlights
339    function observeDOMChanges() {
340        let isReapplying = false;
341        
342        const observer = new MutationObserver(debounce(async () => {
343            // Prevent reapplying if already in progress
344            if (isReapplying) return;
345            
346            // Check if highlights need to be reapplied
347            const highlights = await GM.getValue('chatgpt_highlights', []);
348            if (highlights.length === 0) return;
349            
350            // Check if any message is missing highlights
351            let needsReapply = false;
352            for (const highlight of highlights) {
353                const messageElement = document.querySelector(`[data-message-id="${highlight.messageId}"]`);
354                if (messageElement) {
355                    const hasHighlight = messageElement.querySelector('.chatgpt-highlight');
356                    if (!hasHighlight) {
357                        needsReapply = true;
358                        break;
359                    }
360                }
361            }
362            
363            if (needsReapply) {
364                isReapplying = true;
365                await loadHighlights();
366                isReapplying = false;
367            }
368        }, 2000));
369
370        observer.observe(document.body, {
371            childList: true,
372            subtree: true
373        });
374    }
375
376    // Debounce helper
377    function debounce(func, wait) {
378        let timeout;
379        return function executedFunction(...args) {
380            const later = () => {
381                clearTimeout(timeout);
382                func(...args);
383            };
384            clearTimeout(timeout);
385            timeout = setTimeout(later, wait);
386        };
387    }
388
389    // Start the extension
390    if (document.readyState === 'loading') {
391        document.addEventListener('DOMContentLoaded', init);
392    } else {
393        init();
394    }
395})();
ChatGPT Text Highlighter | Robomonkey