// ==UserScript==
// @name Text Highlighter
// @namespace https://beiduofen.top/
// @version 0.2
// @description Highlight text on webpages and save to IndexedDB
// @author Steper Lin
// @match *://*/*
// @grant none
// ==/UserScript==
(function() {
'use strict';
// Constants
const DB_NAME = 'HighlighterDB';
const DB_VERSION = 1;
const STORE_NAME = 'highlights';
const HIGHLIGHT_CLASS = 'tampermonkey-highlight';
const MARKER_ID = 'text-marker-button';
// Variables
let db;
let currentSelection = null;
let markerButton = null;
// Initialize the database
function initDB() {
return new Promise((resolve, reject) => {
console.log('Initializing IndexedDB database:', DB_NAME);
// Close any existing database connection
if (db) {
console.log('Closing existing database connection');
db.close();
db = null;
}
// Open the database
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = function(event) {
console.log('Database upgrade needed, creating object store');
const database = event.target.result;
// Create the object store if it doesn't exist
if (!database.objectStoreNames.contains(STORE_NAME)) {
const store = database.createObjectStore(STORE_NAME, { keyPath: 'id' });
console.log('Created object store:', STORE_NAME);
// Create indexes for faster lookups
store.createIndex('url', 'url', { unique: false });
store.createIndex('timestamp', 'timestamp', { unique: false });
console.log('Created indexes for url and timestamp');
} else {
console.log('Object store already exists');
}
};
request.onsuccess = function(event) {
db = event.target.result;
console.log('Database initialized successfully');
// Set up error handling for the database connection
db.onerror = function(event) {
console.error('Database error:', event.target.error);
};
// Verify the database is working by checking the object store
try {
const transaction = db.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
console.log('Database verification successful');
// Get the count of highlights
const countRequest = store.count();
countRequest.onsuccess = function() {
console.log(`Database contains ${countRequest.result} highlights`);
};
} catch (error) {
console.error('Database verification failed:', error);
// Try to recreate the database
db.close();
indexedDB.deleteDatabase(DB_NAME);
setTimeout(() => {
const retryRequest = indexedDB.open(DB_NAME, DB_VERSION + 1);
retryRequest.onupgradeneeded = function(event) {
const database = event.target.result;
const store = database.createObjectStore(STORE_NAME, { keyPath: 'id' });
store.createIndex('url', 'url', { unique: false });
store.createIndex('timestamp', 'timestamp', { unique: false });
console.log('Recreated database after verification failure');
};
retryRequest.onsuccess = function(event) {
db = event.target.result;
console.log('Database recreated successfully');
resolve(db);
};
retryRequest.onerror = function(event) {
console.error('Error recreating database:', event.target.error);
reject(event.target.error);
};
}, 100);
return;
}
resolve(db);
};
request.onerror = function(event) {
console.error('Error initializing database:', event.target.error);
// Try to use localStorage as a fallback
console.log('Attempting to use localStorage as fallback');
try {
// Create a mock IndexedDB API using localStorage
db = {
usingLocalStorage: true,
getAll: function() {
try {
const data = localStorage.getItem(`${DB_NAME}_${STORE_NAME}`);
return data ? JSON.parse(data) : [];
} catch (e) {
console.error('Error reading from localStorage:', e);
return [];
}
},
add: function(item) {
try {
const data = this.getAll();
item.id = item.id || `highlight-${Date.now()}-${Math.floor(Math.random() * 1000000)}`;
data.push(item);
localStorage.setItem(`${DB_NAME}_${STORE_NAME}`, JSON.stringify(data));
return item.id;
} catch (e) {
console.error('Error writing to localStorage:', e);
throw e;
}
}
};
console.log('Using localStorage fallback');
resolve(db);
} catch (fallbackError) {
console.error('Fallback to localStorage failed:', fallbackError);
reject(event.target.error);
}
};
// Handle blocked events (another connection is blocking this one)
request.onblocked = function(event) {
console.warn('Database connection blocked, please close other tabs with this application');
alert('Please close other tabs with the Text Highlighter to allow database access');
};
});
}
// Save highlight to IndexedDB
function saveHighlight(highlight) {
return new Promise((resolve, reject) => {
console.log('Saving highlight to database:', highlight);
// Make sure we have a database connection
if (!db) {
console.error('No database connection available');
reject(new Error('No database connection available'));
return;
}
// Handle localStorage fallback
if (db.usingLocalStorage) {
try {
const id = db.add(highlight);
console.log('Highlight saved to localStorage with ID:', id);
resolve(id);
} catch (error) {
console.error('Error saving to localStorage:', error);
reject(error);
}
return;
}
// Normal IndexedDB operation
try {
const transaction = db.transaction([STORE_NAME], 'readwrite');
transaction.oncomplete = function() {
console.log('Transaction completed successfully');
};
transaction.onerror = function(event) {
console.error('Transaction error:', event.target.error);
reject(event.target.error);
};
const store = transaction.objectStore(STORE_NAME);
// Make sure the highlight has an ID
if (!highlight.id) {
highlight.id = generateHighlightId();
}
// Add timestamp if not present
if (!highlight.timestamp) {
highlight.timestamp = Date.now();
}
const request = store.add(highlight);
request.onsuccess = function(event) {
console.log('Highlight saved successfully with ID:', event.target.result);
resolve(event.target.result);
};
request.onerror = function(event) {
console.error('Error saving highlight:', event.target.error);
// Try to update if the item already exists
if (event.target.error.name === 'ConstraintError') {
console.log('Highlight already exists, trying to update');
const updateRequest = store.put(highlight);
updateRequest.onsuccess = function(event) {
console.log('Highlight updated successfully');
resolve(highlight.id);
};
updateRequest.onerror = function(event) {
console.error('Error updating highlight:', event.target.error);
reject(event.target.error);
};
} else {
reject(event.target.error);
}
};
} catch (error) {
console.error('Error in saveHighlight:', error);
reject(error);
}
});
}
// Get all highlights from IndexedDB
function getAllHighlights() {
return new Promise((resolve, reject) => {
console.log('Getting all highlights from database');
// Make sure we have a database connection
if (!db) {
console.error('No database connection available');
reject(new Error('No database connection available'));
return;
}
// Handle localStorage fallback
if (db.usingLocalStorage) {
try {
const highlights = db.getAll();
console.log('Retrieved highlights from localStorage:', highlights);
resolve(highlights || []);
} catch (error) {
console.error('Error getting highlights from localStorage:', error);
reject(error);
}
return;
}
// Normal IndexedDB operation
try {
const transaction = db.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
// Try to use the url index if we're looking for highlights for the current page
const currentUrl = window.location.href.split('#')[0].split('?')[0];
let request;
try {
// Check if the index exists
const urlIndex = store.index('url');
console.log('Using URL index for faster retrieval');
// Get all highlights for all URLs (we'll filter later)
request = store.getAll();
} catch (indexError) {
console.warn('URL index not available, falling back to getAll()');
request = store.getAll();
}
request.onsuccess = function(event) {
const highlights = event.target.result || [];
console.log(`Retrieved ${highlights.length} highlights from database`);
resolve(highlights);
};
request.onerror = function(event) {
console.error('Error getting highlights:', event.target.error);
reject(event.target.error);
};
} catch (error) {
console.error('Error in getAllHighlights:', error);
reject(error);
}
});
}
// Create a marker button
function createMarkerButton() {
// Remove existing button if it exists
const existingButton = document.getElementById(MARKER_ID);
if (existingButton) {
document.body.removeChild(existingButton);
}
// Create new button
const button = document.createElement('div');
button.id = MARKER_ID;
button.innerHTML = '🖌️';
button.style.position = 'absolute';
button.style.zIndex = '9999';
button.style.backgroundColor = '#ffff00';
button.style.border = '1px solid #ccc';
button.style.borderRadius = '50%';
button.style.width = '30px';
button.style.height = '30px';
button.style.display = 'none'; // Start hidden
button.style.justifyContent = 'center';
button.style.alignItems = 'center';
button.style.cursor = 'pointer';
button.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';
button.style.fontSize = '16px';
button.style.transition = 'transform 0.2s';
button.addEventListener('mouseover', () => {
button.style.transform = 'scale(1.1)';
});
button.addEventListener('mouseout', () => {
button.style.transform = 'scale(1)';
});
// Use mousedown instead of click to prevent selection loss
button.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
handleMarkerClick(e);
});
document.body.appendChild(button);
return button;
}
// Position the marker button near the selection
function positionMarkerButton(selection) {
if (!markerButton) {
markerButton = createMarkerButton();
}
try {
if (selection.rangeCount === 0) {
console.error('No range in selection');
return;
}
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
// Position the button near the end of the selection
markerButton.style.left = `${window.scrollX + rect.right + 5}px`;
markerButton.style.top = `${window.scrollY + rect.top - 5}px`;
// Make sure the button is visible
markerButton.style.display = 'flex';
// Make sure the button is within viewport
const buttonRect = markerButton.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
if (buttonRect.right > viewportWidth) {
// If button is off the right edge, move it to the left
markerButton.style.left = `${window.scrollX + viewportWidth - buttonRect.width - 10}px`;
}
if (buttonRect.bottom > viewportHeight) {
// If button is off the bottom edge, move it up
markerButton.style.top = `${window.scrollY + viewportHeight - buttonRect.height - 10}px`;
}
if (buttonRect.top < 0) {
// If button is off the top edge, move it down
markerButton.style.top = `${window.scrollY + 10}px`;
}
if (buttonRect.left < 0) {
// If button is off the left edge, move it right
markerButton.style.left = `${window.scrollX + 10}px`;
}
} catch (error) {
console.error('Error positioning marker button:', error);
}
}
// Hide the marker button
function hideMarkerButton() {
if (markerButton) {
markerButton.style.display = 'none';
}
}
// Handle marker button click
function handleMarkerClick(event) {
// Prevent the default action and stop propagation
if (event) {
event.preventDefault();
event.stopPropagation();
}
if (currentSelection) {
// Clone the selection to ensure it doesn't get lost
const selectionText = currentSelection.toString();
// Highlight the selection
highlightSelection(currentSelection);
// Hide the marker button
hideMarkerButton();
// Clear the current selection
currentSelection = null;
// Show a brief notification
showNotification(`Highlighted: "${selectionText.substring(0, 20)}${selectionText.length > 20 ? '...' : ''}"`);
}
return false;
}
// Show a brief notification
function showNotification(message, duration = 2000) {
const notification = document.createElement('div');
notification.textContent = message;
notification.style.position = 'fixed';
notification.style.bottom = '10px';
notification.style.right = '10px';
notification.style.backgroundColor = 'rgba(255, 255, 0, 0.8)';
notification.style.padding = '5px 10px';
notification.style.borderRadius = '5px';
notification.style.zIndex = '10000';
notification.style.fontSize = '12px';
notification.style.fontWeight = 'bold';
notification.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';
document.body.appendChild(notification);
// Remove the notification after the specified duration
setTimeout(() => {
if (document.body.contains(notification)) {
document.body.removeChild(notification);
}
}, duration);
}
// Generate a unique ID for the highlight
function generateHighlightId() {
return `highlight-${Date.now()}-${Math.floor(Math.random() * 1000000)}`;
}
// Highlight the selected text
function highlightSelection(selection) {
try {
if (!selection || selection.rangeCount === 0) {
return;
}
const range = selection.getRangeAt(0);
const highlightId = generateHighlightId();
// Method 1: Simple approach for single node selections
if (range.startContainer === range.endContainer) {
// Create a span element to wrap the selected text
const highlightSpan = document.createElement('span');
highlightSpan.className = HIGHLIGHT_CLASS;
highlightSpan.dataset.highlightId = highlightId;
highlightSpan.style.backgroundColor = 'yellow';
highlightSpan.style.color = 'black';
try {
range.surroundContents(highlightSpan);
// Save the highlight to IndexedDB
const highlightData = {
id: highlightId,
text: highlightSpan.textContent,
url: window.location.href,
path: getXPath(highlightSpan),
timestamp: Date.now()
};
saveHighlight(highlightData);
return;
} catch (error) {
// Fall through to method 2
}
}
// Method 2: More complex approach for multi-node selections
// Create a document fragment from the selection
const fragment = range.cloneContents();
// Create a temporary div to hold the fragment
const tempDiv = document.createElement('div');
tempDiv.appendChild(fragment);
// Wrap the content with highlight spans
const wrappedHTML = `<span class="${HIGHLIGHT_CLASS}" data-highlight-id="${highlightId}" style="background-color: yellow; color: black;">${tempDiv.innerHTML}</span>`;
// Delete the original content
range.deleteContents();
// Insert the new highlighted content
const tempElement = document.createElement('div');
tempElement.innerHTML = wrappedHTML;
// Insert each child of the temp element
while (tempElement.firstChild) {
range.insertNode(tempElement.firstChild);
}
// Save the highlight to IndexedDB
const highlightData = {
id: highlightId,
text: selection.toString(),
url: window.location.href,
selectionHTML: wrappedHTML,
timestamp: Date.now()
};
saveHighlight(highlightData);
} catch (error) {
console.error('Error in highlightSelection function:', error);
}
}
// Get XPath for an element
function getXPath(element) {
if (element.id !== '') {
return `//*[@id="${element.id}"]`;
}
if (element === document.body) {
return '/html/body';
}
try {
let ix = 0;
const siblings = element.parentNode.childNodes;
for (let i = 0; i < siblings.length; i++) {
const sibling = siblings[i];
if (sibling === element) {
const parentXPath = getXPath(element.parentNode);
return `${parentXPath}/${element.tagName.toLowerCase()}[${ix + 1}]`;
}
if (sibling.nodeType === 1 && sibling.tagName === element.tagName) {
ix++;
}
}
return null;
} catch (error) {
console.error('Error generating XPath:', error);
return null;
}
}
// Find element by XPath
function getElementByXPath(path) {
try {
return document.evaluate(path, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
} catch (error) {
console.error('Error finding element by XPath:', error);
return null;
}
}
// Apply highlights from IndexedDB
async function applyHighlights() {
try {
console.log('Applying highlights from IndexedDB');
const highlights = await getAllHighlights();
console.log('Retrieved highlights from database:', highlights);
// Filter highlights for the current page
const currentUrl = window.location.href;
console.log('Current URL:', currentUrl);
// Match by URL without hash or query parameters for better matching
const currentUrlBase = currentUrl.split('#')[0].split('?')[0];
console.log('Base URL for matching:', currentUrlBase);
const pageHighlights = highlights.filter(h => {
const highlightUrl = h.url.split('#')[0].split('?')[0];
return highlightUrl === currentUrlBase;
});
console.log(`Found ${pageHighlights.length} highlights for this page:`, pageHighlights);
if (pageHighlights.length === 0) {
console.log('No highlights to apply');
return;
}
// Wait for the page to be fully loaded
if (document.readyState !== 'complete') {
console.log('Page not fully loaded, waiting...');
await new Promise(resolve => {
window.addEventListener('load', resolve);
// Fallback if load event already fired
setTimeout(resolve, 1000);
});
console.log('Page loaded, proceeding with highlight application');
}
// Apply each highlight
let appliedCount = 0;
for (const highlight of pageHighlights) {
try {
console.log('Applying highlight:', highlight);
let applied = false;
// Method 1: If we have a path, try to find the element by XPath
if (highlight.path) {
console.log('Trying to apply using XPath:', highlight.path);
const element = getElementByXPath(highlight.path);
if (element) {
console.log('Found element by XPath:', element);
// Create a highlight span
const highlightSpan = document.createElement('span');
highlightSpan.className = HIGHLIGHT_CLASS;
highlightSpan.dataset.highlightId = highlight.id;
highlightSpan.style.backgroundColor = 'yellow';
highlightSpan.style.color = 'black';
// Replace the text with the highlighted version
const textNode = document.createTextNode(element.textContent);
element.innerHTML = '';
element.appendChild(textNode);
try {
// Wrap the text with the highlight span
const range = document.createRange();
range.selectNodeContents(element);
range.surroundContents(highlightSpan);
console.log('Successfully applied highlight using XPath');
applied = true;
appliedCount++;
continue; // Success, move to next highlight
} catch (error) {
console.error('Error applying highlight with surroundContents:', error);
// Continue to next method
}
} else {
console.warn('Element not found by XPath');
}
}
// Method 2: If we have text, search for it in the document
if (highlight.text && !applied) {
console.log('Trying to apply using text search:', highlight.text);
// Search for the text in the document
const textToFind = highlight.text;
if (!textToFind || textToFind.length < 5) {
console.warn('Text too short to reliably find:', textToFind);
continue;
}
// Create a TreeWalker to iterate through text nodes
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
null,
false
);
let node;
let found = false;
while ((node = walker.nextNode())) {
if (node.nodeValue.includes(textToFind)) {
console.log('Found text in node:', node);
// Create a range around the text
const range = document.createRange();
const startIndex = node.nodeValue.indexOf(textToFind);
range.setStart(node, startIndex);
range.setEnd(node, startIndex + textToFind.length);
// Create a highlight span
const highlightSpan = document.createElement('span');
highlightSpan.className = HIGHLIGHT_CLASS;
highlightSpan.dataset.highlightId = highlight.id;
highlightSpan.style.backgroundColor = 'yellow';
highlightSpan.style.color = 'black';
try {
// Extract the content and wrap it in the highlight span
const fragment = range.extractContents();
highlightSpan.appendChild(fragment);
range.insertNode(highlightSpan);
console.log('Successfully applied highlight using text search');
found = true;
applied = true;
appliedCount++;
break;
} catch (error) {
console.error('Error applying highlight with text search:', error);
// Continue to next node
}
}
}
if (!found) {
console.warn('Could not find text in document:', textToFind);
}
}
// Method 3: If we have selectionHTML, use that
if (highlight.selectionHTML && !applied) {
console.log('Trying to apply using selectionHTML');
// Create a temporary element with the highlight HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = highlight.selectionHTML;
// Extract the text content
const textToFind = tempDiv.textContent.trim();
if (!textToFind || textToFind.length < 5) {
console.warn('HTML content too short to reliably find');
continue;
}
console.log('Searching for HTML content text:', textToFind);
// Create a TreeWalker to iterate through text nodes
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
null,
false
);
let node;
let found = false;
while ((node = walker.nextNode())) {
if (node.nodeValue.includes(textToFind)) {
console.log('Found HTML content text in node:', node);
// Create a range around the text
const range = document.createRange();
const startIndex = node.nodeValue.indexOf(textToFind);
range.setStart(node, startIndex);
range.setEnd(node, startIndex + textToFind.length);
// Get the highlight span from the temp div
const highlightSpan = tempDiv.querySelector(`.${HIGHLIGHT_CLASS}`) || tempDiv.firstChild;
try {
// Delete the original content and insert the highlight
range.deleteContents();
range.insertNode(highlightSpan);
console.log('Successfully applied highlight using HTML content');
found = true;
applied = true;
appliedCount++;
break;
} catch (error) {
console.error('Error applying highlight with HTML content:', error);
// Continue to next node
}
}
}
if (!found) {
console.warn('Could not find HTML content in document');
}
}
if (!applied) {
console.warn(`Could not apply highlight ${highlight.id} using any method`);
}
} catch (error) {
console.error(`Error applying highlight ${highlight.id}:`, error);
}
}
// Show notification if highlights were applied
if (appliedCount > 0) {
console.log(`Successfully applied ${appliedCount} highlights`);
showNotification(`Applied ${appliedCount} highlight${appliedCount === 1 ? '' : 's'}`);
} else if (pageHighlights.length > 0) {
console.warn('Failed to apply any highlights');
showNotification('Failed to apply highlights', 3000);
}
} catch (error) {
console.error('Error applying highlights:', error);
}
}
// Handle text selection
function handleSelection(event) {
// Short delay to allow selection to complete
setTimeout(() => {
const selection = window.getSelection();
const selectionText = selection.toString().trim();
if (selectionText !== '') {
// Store the selection
currentSelection = selection;
// Position and show the marker button
positionMarkerButton(selection);
} else {
// Only hide the marker if the click wasn't on the marker itself
if (event && event.target && event.target.id !== MARKER_ID) {
currentSelection = null;
hideMarkerButton();
}
}
}, 10); // Small delay to ensure selection is complete
}
// Add CSS styles
function addStyles() {
const style = document.createElement('style');
style.textContent = `
.${HIGHLIGHT_CLASS} {
background-color: yellow;
color: black;
}
`;
document.head.appendChild(style);
}
// Initialize the script
async function init() {
try {
console.log('Initializing Text Highlighter script');
// Add styles first
addStyles();
console.log('Styles added');
// Create the marker button early
markerButton = createMarkerButton();
console.log('Marker button initialized');
// Initialize the database
await initDB();
console.log('Database initialized successfully');
// Wait for the DOM to be fully loaded
if (document.readyState !== 'complete' && document.readyState !== 'interactive') {
console.log('Waiting for DOM to be ready...');
await new Promise(resolve => {
document.addEventListener('DOMContentLoaded', resolve);
// Fallback if event already fired
setTimeout(resolve, 1000);
});
console.log('DOM is ready');
}
// Apply existing highlights with a slight delay to ensure DOM is fully processed
console.log('Scheduling highlight application');
setTimeout(async () => {
try {
await applyHighlights();
console.log('Highlights applied');
} catch (error) {
console.error('Error applying highlights:', error);
}
}, 500);
// Listen for text selection events
document.addEventListener('mouseup', handleSelection);
document.addEventListener('mousedown', (e) => {
// If clicking on the marker button, prevent default to keep selection
if (e.target && e.target.id === MARKER_ID) {
e.preventDefault();
e.stopPropagation();
}
});
document.addEventListener('selectionchange', () => {
const selection = window.getSelection();
if (selection && selection.toString().trim() !== '') {
currentSelection = selection;
}
});
console.log('Event listeners added');
// Add mutation observer to handle dynamic content
const observer = new MutationObserver((mutations) => {
// If significant DOM changes occurred, reapply highlights
const significantChanges = mutations.some(mutation =>
mutation.type === 'childList' && mutation.addedNodes.length > 3);
if (significantChanges) {
console.log('Significant DOM changes detected, reapplying highlights');
setTimeout(() => applyHighlights(), 500);
}
});
// Start observing the document with the configured parameters
observer.observe(document.body, { childList: true, subtree: true });
console.log('Mutation observer started');
// Show initialization notification
showNotification('Text Highlighter Active', 2000);
console.log('Text Highlighter initialized successfully');
} catch (error) {
console.error('Error initializing Text Highlighter:', error);
// Try to recover
try {
if (!db) {
console.log('Attempting to recover by reinitializing database');
await initDB();
}
if (!markerButton) {
console.log('Recreating marker button');
markerButton = createMarkerButton();
}
} catch (recoveryError) {
console.error('Recovery failed:', recoveryError);
}
}
}
// Start the script
init();
})();
01-31
6万+
