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