Ever tried to build a highlighter extension and hit this problem: "How do I remember where the user highlighted text?" π€
Saving "Hello World" isn't enough β the same text might appear 100 times on a page. You need coordinates in the DOM, and those coordinates need to survive page reloads. πΎ
XPath is the answer. Let me show you how to build a production-ready annotation system. β¨
π¨ The Challenge: Text Has No Permanent Address
Think of a webpage like a huge book π. When you highlight text, you can't just save "page 47" β what if someone adds a new chapter? Page numbers shift!
What You Actually Need:
- π Start position - where the selection begins
- π End position - where it ends
- π³ Common ancestor - the closest parent containing both points
- πΎ Storage format - that survives page changes
The problem: CSS selectors and element IDs change. Text content moves. How do you create a "bookmark" that works reliably? π―
π― What We're Building
A Chrome extension that does all this:
- β Lets users highlight any text on any page
- β Stores highlights using stable XPaths
- β Restores highlights on page reload
- β Works across different visits
- β Supports notes and multiple colors
ποΈ Architecture Overview:
User selects text with mouse π±οΈ
β
Generate XPath coordinates (start, end, ancestor) π
β
Store in chrome.storage with offset info πΎ
β
On page load, read storage π
β
Resolve XPaths β Recreate selection β Apply highlight π¨
π¦ Setting Up the Extension
π manifest.json
{
"manifest_version": 3,
"name": "Smart Highlighter",
"version": "1.0.0",
"description": "Highlight text with persistent XPath storage",
"permissions": ["storage", "activeTab"],
"content_scripts": [{
"matches": ["<all_urls>"],
"js": ["content.js"],
"css": ["highlights.css"],
"run_at": "document_idle"
}],
"action": {
"default_popup": "popup.html"
}
}
π¨ highlights.css
.xpath-highlight {
background-color: rgba(255, 255, 0, 0.4);
border-bottom: 2px solid #ffeb3b;
cursor: pointer;
transition: background-color 0.2s;
}
.xpath-highlight:hover {
background-color: rgba(255, 255, 0, 0.6);
}
.xpath-highlight-note {
background-color: rgba(76, 175, 80, 0.3);
border-bottom: 2px solid #4caf50;
}
.xpath-highlight-important {
background-color: rgba(244, 67, 54, 0.3);
border-bottom: 2px solid #f44336;
}
β¨ Core Feature: Capturing Text Selections
This is where the magic happens! When users select text, we capture the exact DOM coordinates:
// content.js
import {
getXPathForSelection,
resolveXPath,
getXPathForNode
} from 'dom-xpath-toolkit';
// Listen for text selection
document.addEventListener('mouseup', handleTextSelection);
async function handleTextSelection(e) {
// Ignore if clicking on existing highlight
if (e.target.classList.contains('xpath-highlight')) {
return;
}
const selection = window.getSelection();
// Need actual text selected
if (!selection || selection.isCollapsed) {
return;
}
const selectedText = selection.toString().trim();
if (selectedText.length < 3) {
return; // Too short to be meaningful
}
try {
// This is the magic! β¨
const selectionData = getXPathForSelection();
if (!selectionData) {
console.warn('Could not capture selection data');
return;
}
console.log('β
Captured selection:', {
text: selectedText,
startXPath: selectionData.startXPath,
endXPath: selectionData.endXPath,
ancestorXPath: selectionData.ancestorXPath,
startOffset: selectionData.startOffset,
endOffset: selectionData.endOffset
});
// Store the highlight
await saveHighlight({
id: generateId(),
url: window.location.href,
text: selectedText,
timestamp: Date.now(),
...selectionData
});
// Apply visual highlight immediately
applyHighlight(selectionData, 'xpath-highlight');
// Clear selection
selection.removeAllRanges();
showToast('β
Highlight saved!');
} catch (error) {
console.error('β Failed to save highlight:', error);
showToast('β Failed to save highlight');
}
}
function generateId() {
return `highlight_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
πΊοΈ Understanding the Selection Data
Let's break down what getXPathForSelection() returns:
{
startXPath: '//*[@id="content"]/p[1]/text()[1]', // π Where selection starts
startOffset: 15, // π’ Character position in start node
endXPath: '//*[@id="content"]/p[1]/text()[1]', // π Where selection ends
endOffset: 43, // π’ Character position in end node
ancestorXPath: '//*[@id="content"]/p[1]', // π³ Closest common parent
selectedText: 'this is the selected text' // π The actual text
}
π― Think of it like GPS coordinates:
- XPath = Street address π
- Offset = House number on that street π’
- Ancestor = The neighborhood ποΈ
This combination gives you an exact, reproducible location in the DOM! π
πΎ Storing Highlights Persistently
async function saveHighlight(highlight) {
// Get existing highlights for this URL
const storageKey = `highlights_${hashUrl(window.location.href)}`;
const result = await chrome.storage.local.get(storageKey);
const highlights = result[storageKey] || [];
// Add new highlight
highlights.push(highlight);
// Save back to storage
await chrome.storage.local.set({
[storageKey]: highlights
});
console.log(`πΎ Saved highlight ${highlight.id}`);
}
// Create URL hash for storage key
function hashUrl(url) {
// Simple hash function
let hash = 0;
for (let i = 0; i < url.length; i++) {
const char = url.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash).toString(36);
}
π‘ Why hash the URL? Chrome storage keys have length limits. Hashing keeps keys short and consistent! π
π Restoring Highlights on Page Load
This is where XPath shines! β¨ We can recreate the exact selection from storage:
// Run on page load
async function restoreHighlights() {
const storageKey = `highlights_${hashUrl(window.location.href)}`;
const result = await chrome.storage.local.get(storageKey);
const highlights = result[storageKey] || [];
console.log(`π Restoring ${highlights.length} highlights...`);
for (const highlight of highlights) {
try {
applyHighlight(highlight, 'xpath-highlight');
} catch (error) {
console.warn(`β οΈ Failed to restore highlight ${highlight.id}:`, error);
}
}
console.log('β
Highlights restored!');
}
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', restoreHighlights);
} else {
restoreHighlights();
}
π¨ The Magic: Applying Highlights with XPath
Here's how we turn XPath coordinates back into visual highlights:
function applyHighlight(highlightData, className) {
const { startXPath, startOffset, endXPath, endOffset } = highlightData;
// Resolve XPaths to actual DOM nodes
const startNode = resolveXPath(startXPath);
const endNode = resolveXPath(endXPath);
if (!startNode || !endNode) {
throw new Error('β Could not resolve XPath nodes');
}
// Create a Range (native browser API)
const range = document.createRange();
try {
// Set range boundaries using XPath nodes + offsets
range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset);
// Wrap selection in a <mark> element
const mark = document.createElement('mark');
mark.className = className;
mark.dataset.highlightId = highlightData.id;
// Surround the range with our mark element
range.surroundContents(mark);
// Add click handler for notes/deletion
mark.addEventListener('click', (e) => {
handleHighlightClick(highlightData.id, e);
});
} catch (error) {
// Fallback for complex selections spanning multiple nodes
applyHighlightComplex(range, className, highlightData.id);
}
}
// Fallback for selections that span multiple nodes
function applyHighlightComplex(range, className, highlightId) {
// Extract contents
const fragment = range.extractContents();
// Wrap in mark
const mark = document.createElement('mark');
mark.className = className;
mark.dataset.highlightId = highlightId;
mark.appendChild(fragment);
// Insert back
range.insertNode(mark);
}
π― Key concept: We use the browser's native Range API with XPath-resolved nodes to recreate the exact selection!
π Adding Note-Taking Features
Let's make highlights interactive with right-click menus:
function handleHighlightClick(highlightId, event) {
event.stopPropagation();
const menu = createContextMenu(highlightId, event.clientX, event.clientY);
document.body.appendChild(menu);
}
function createContextMenu(highlightId, x, y) {
const menu = document.createElement('div');
menu.className = 'highlight-menu';
menu.style.cssText = `
position: fixed;
left: ${x}px;
top: ${y}px;
background: white;
border: 1px solid #ccc;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 10000;
padding: 8px;
`;
menu.innerHTML = `
<button class="menu-btn" data-action="note">π Add Note</button>
<button class="menu-btn" data-action="color">π¨ Change Color</button>
<button class="menu-btn" data-action="delete">ποΈ Delete</button>
`;
menu.addEventListener('click', async (e) => {
const action = e.target.dataset.action;
switch (action) {
case 'note':
await addNoteToHighlight(highlightId);
break;
case 'color':
await changeHighlightColor(highlightId);
break;
case 'delete':
await deleteHighlight(highlightId);
break;
}
menu.remove();
});
// Close menu when clicking elsewhere
setTimeout(() => {
document.addEventListener('click', () => menu.remove(), { once: true });
}, 0);
return menu;
}
async function addNoteToHighlight(highlightId) {
const note = prompt('π Add a note to this highlight:');
if (!note) return;
const storageKey = `highlights_${hashUrl(window.location.href)}`;
const result = await chrome.storage.local.get(storageKey);
const highlights = result[storageKey] || [];
const highlight = highlights.find(h => h.id === highlightId);
if (highlight) {
highlight.note = note;
highlight.updatedAt = Date.now();
await chrome.storage.local.set({ [storageKey]: highlights });
// Update UI
const mark = document.querySelector(`[data-highlight-id="${highlightId}"]`);
if (mark) {
mark.classList.add('has-note');
mark.title = note;
}
showToast('β
Note added!');
}
}
async function deleteHighlight(highlightId) {
if (!confirm('ποΈ Delete this highlight?')) return;
const storageKey = `highlights_${hashUrl(window.location.href)}`;
const result = await chrome.storage.local.get(storageKey);
let highlights = result[storageKey] || [];
// Remove from storage
highlights = highlights.filter(h => h.id !== highlightId);
await chrome.storage.local.set({ [storageKey]: highlights });
// Remove from DOM
const mark = document.querySelector(`[data-highlight-id="${highlightId}"]`);
if (mark) {
const parent = mark.parentNode;
while (mark.firstChild) {
parent.insertBefore(mark.firstChild, mark);
}
mark.remove();
}
showToast('β
Highlight deleted');
}
π¨ Building the Popup UI
Let users manage all their highlights from the extension popup:
π popup.html
<!DOCTYPE html>
<html>
<head>
<style>
body {
width: 350px;
padding: 16px;
font-family: system-ui;
}
.highlight-item {
padding: 12px;
margin-bottom: 8px;
background: #f5f5f5;
border-radius: 8px;
border-left: 4px solid #ffeb3b;
cursor: pointer;
transition: background 0.2s;
}
.highlight-item:hover {
background: #e0e0e0;
}
.highlight-text {
font-size: 13px;
color: #333;
margin-bottom: 4px;
}
.highlight-meta {
font-size: 11px;
color: #666;
}
#export-btn {
width: 100%;
padding: 10px;
background: #667eea;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
}
</style>
</head>
<body>
<h2>π Your Highlights</h2>
<div id="highlights-list"></div>
<button id="export-btn">π€ Export All</button>
<script src="popup.js"></script>
</body>
</html>
π§ popup.js
async function loadHighlights() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const url = tab.url;
const storageKey = `highlights_${hashUrl(url)}`;
const result = await chrome.storage.local.get(storageKey);
const highlights = result[storageKey] || [];
const container = document.getElementById('highlights-list');
if (highlights.length === 0) {
container.innerHTML = '<p>π No highlights on this page yet.</p>';
return;
}
container.innerHTML = highlights.map(h => `
<div class="highlight-item" data-id="${h.id}">
<div class="highlight-text">"${h.text}"</div>
<div class="highlight-meta">
π
${new Date(h.timestamp).toLocaleDateString()}
${h.note ? `β’ π ${h.note}` : ''}
</div>
</div>
`).join('');
}
document.getElementById('export-btn').addEventListener('click', async () => {
// Export all highlights as JSON
const result = await chrome.storage.local.get(null);
const blob = new Blob([JSON.stringify(result, null, 2)], {
type: 'application/json'
});
const url = URL.createObjectURL(blob);
await chrome.downloads.download({
url,
filename: 'highlights-export.json'
});
alert('β
Highlights exported!');
});
loadHighlights();
β‘ Performance Optimization Tips
1οΈβ£ Lazy Restoration
Don't restore all highlights immediatelyβload visible ones first:
async function restoreHighlightsLazy() {
const highlights = await getHighlightsForPage();
// Restore above-the-fold immediately
const visible = highlights.filter(h => isInViewport(h.ancestorXPath));
visible.forEach(h => applyHighlight(h, 'xpath-highlight'));
// Restore others on scroll
const remaining = highlights.filter(h => !isInViewport(h.ancestorXPath));
document.addEventListener('scroll', () => {
remaining.forEach(h => {
if (isInViewport(h.ancestorXPath)) {
applyHighlight(h, 'xpath-highlight');
}
});
}, { passive: true });
}
function isInViewport(xpath) {
const element = resolveXPath(xpath);
if (!element) return false;
const rect = element.getBoundingClientRect();
return rect.top < window.innerHeight && rect.bottom > 0;
}
π‘ Pro tip: Only render what users can see! This dramatically improves performance on pages with many highlights. π
π Resources & Next Steps
π¦ Tools & Documentation:
π¬ Let's Connect!
Built something cool with text selection? Share it in the comments below! I'd love to see what you're creating. π
Found this helpful? Connect with me on LinkedIn for more Chrome extension tips and tricks:
Top comments (0)