DEV Community

Cover image for Building Text Annotation Chrome Extensions with XPath Selection Tracking
Jay Malli
Jay Malli

Posted on

Building Text Annotation Chrome Extensions with XPath Selection Tracking

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 🎨
Enter fullscreen mode Exit fullscreen mode

πŸ“¦ 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"
  }
}
Enter fullscreen mode Exit fullscreen mode

🎨 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;
}
Enter fullscreen mode Exit fullscreen mode

✨ 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)}`;
}
Enter fullscreen mode Exit fullscreen mode

πŸ—ΊοΈ 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
}
Enter fullscreen mode Exit fullscreen mode

🎯 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);
}
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ 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();
}
Enter fullscreen mode Exit fullscreen mode

🎨 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);
}
Enter fullscreen mode Exit fullscreen mode

🎯 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');
}
Enter fullscreen mode Exit fullscreen mode

🎨 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>
Enter fullscreen mode Exit fullscreen mode

πŸ”§ 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();
Enter fullscreen mode Exit fullscreen mode

⚑ 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;
}
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ 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:

πŸ”— Find me on LinkedIn

Top comments (0)