跪拜 Guibai
← Back to the summary

Build Your Own Chrome Extension: A Complete Walkthrough for a Text Snipping Tool

I came across a great quote in an article and wanted to save it. Copy, switch to Notes, paste, switch back, copy the link again... back and forth several times, sometimes the page would even be lost when I switched back.

I ended up writing a small plugin myself: select text on a webpage, a button pops up next to it, click it and it's saved. Today I'll walk you through the whole process, and also explain what a Chrome extension actually is.


Original Article

Mo Yuan Shu Shi / How to Write a Browser Plugin Yourself?


What is a Chrome Extension?

The AdBlock, translation plugins, and password managers you use daily are essentially Chrome Extensions.

Think of it this way:

Chrome  = Phone OS
Plugin  = Third-party App
Webpage = Interface within a certain App

JavaScript in a webpage can only mess around within the current page—modify the DOM, send requests, all restricted by the same-origin policy. Plugins are different. Chrome opens a special backdoor for them: they can inject scripts across websites, read and write storage, listen for tab switches, and even intercept network requests.

In short, it adds functionality to the browser itself, not to a specific website.


Compared to Regular Webpage JS, What's the Advantage?

Feature Webpage JS Chrome Plugin
How many websites can it run on? Just the current one Any that match
Does it persist after closing the page? No Background can be persistent
Does it have its own interface? Can only modify the page Has its own Popup window
Where is data stored? localStorage, tied to the website chrome.storage, managed by the plugin

For a cross-website tool like "selection saver", using a plugin is more proper than a Greasemonkey script, and more reliable than a bookmarklet—data follows the plugin, not a specific domain.


Four Core Components, Get to Know Them

A Chrome plugin usually consists of four parts. You don't have to use all of them, but you need to know what each does:

manifest.json   → ID card. Name, version, permissions, entry points, all here
Background      → Backend. Listens for browser events, runs even when the plugin panel is closed
Content Script  → Page agent. Injected into webpages, can touch the DOM
Popup           → Small panel. The window that pops up when you click the toolbar icon

They communicate via message passing, roughly like this:

User clicks a button in Popup
    ↓
Popup tells Background (or directly contacts Content Script)
    ↓
Content Script acts on the page

The basic version uses three parts: manifest + Content Script + Popup. Background can wait—we'll bring it in when we add right-click saving.


What Are We Building?

In one sentence: Select text → Click save → Click icon to see the list.

Here's what it looks like:

1. Select text on any webpage, a blue "Save" button appears nearby
2. Click it, the text, source title, link, and time are all saved
3. Click the toolbar icon, a small panel pops up, listing all saved items
4. You can click the link to go back to the original article, or delete items

When you're reading an article, select a sentence and a button appears; click the toolbar icon, the panel pops up showing your saved list. Not fancy, but enough.

Let's start coding. I'll put this plugin in a chrome-plugins directory—I plan to make a collection of plugins, this is the first one, more will come. You can follow along and create a subfolder quote-saver.


Step 1: Create the Folder

chrome-plugins/
└── quote-saver/
    ├── manifest.json
    ├── background.js      # Create later when adding right-click save
    ├── content.js
    ├── popup.html
    ├── popup.js
    └── icons/
        └── icon128.png   # Optional, 128×128 png

Find a 128×128 image for icon128.png and put it in; it's not essential for functionality. If you don't include the icon field in manifest, it will still run, just with a default puzzle piece icon in the toolbar. background.js will be created later when we add right-click saving, so don't create it yet.


Step 2: manifest.json

Every plugin must have this file. Chrome relies on it to know who you are and what permissions you need.

{
  "manifest_version": 3,
  "name": "Selection Saver",
  "version": "1.0.0",
  "description": "Select text on a webpage, save it with one click",
  "permissions": ["storage"],
  "action": {
    "default_popup": "popup.html",
    "default_title": "My Saved Items",
    "default_icon": {
      "128": "icons/icon128.png"
    }
  },
  "icons": {
    "128": "icons/icon128.png"
  },
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"],
      "run_at": "document_idle"
    }
  ]
}

A few fields to remember:


Step 3: content.js — Text Selection and Save Button

quotes-saver-3.png

The Content Script runs inside the webpage and can manipulate the DOM, but it's isolated from the page's own JS variables—variables you define in content.js are inaccessible to the page's JS, and vice versa. Perfect for us to attach a floating button without interfering with the website's own code.

let saveBtn = null;

document.addEventListener('mouseup', () => {
  const text = window.getSelection().toString().trim();

  if (text.length < 2) {
    removeBtn();
    return;
  }

  showBtn(text);
});

function showBtn(text) {
  removeBtn();

  const range = window.getSelection().getRangeAt(0);
  const rect = range.getBoundingClientRect();

  // Prevent the button from going off-screen
  const btnLeft = Math.min(rect.right + 8, window.innerWidth - 80);
  const btnTop = Math.min(rect.bottom + 8, window.innerHeight - 40);

  saveBtn = document.createElement('button');
  saveBtn.textContent = 'Save';
  saveBtn.style.cssText = `
    position: fixed;
    left: ${btnLeft}px;
    top: ${btnTop}px;
    z-index: 2147483647;
    padding: 4px 10px;
    font-size: 13px;
    border: none;
    border-radius: 4px;
    background: #1a73e8;
    color: #fff;
    cursor: pointer;
    box-shadow: 0 2px 6px rgba(0,0,0,.2);
  `;

  // Without this line, clicking the button would first deselect the text
  saveBtn.addEventListener('mousedown', (e) => e.preventDefault());

  saveBtn.addEventListener('click', async () => {
    await saveQuote(text);
    removeBtn();
    showToast('Saved');
  });

  document.body.appendChild(saveBtn);
}

function removeBtn() {
  if (saveBtn) {
    saveBtn.remove();
    saveBtn = null;
  }
}

async function saveQuote(text) {
  const { quotes = [] } = await chrome.storage.local.get('quotes');

  quotes.unshift({
    id: Date.now(),
    text,
    title: document.title,
    url: location.href,
    time: new Date().toLocaleString()
  });

  await chrome.storage.local.set({ quotes: quotes.slice(0, 100) });
}

function showToast(msg) {
  const toast = document.createElement('div');
  toast.textContent = msg;
  toast.style.cssText = `
    position: fixed; top: 20px; right: 20px; z-index: 2147483647;
    background: #333; color: #fff; padding: 8px 16px;
    border-radius: 4px; font-size: 14px;
  `;
  document.body.appendChild(toast);
  setTimeout(() => toast.remove(), 1500);
}

// When the page scrolls, the button position would drift, so just remove it
document.addEventListener('scroll', removeBtn, true);

It listens for mouseup to see if text is selected, and if so, draws a button next to the selection. Click it, and the text, title, link, and time are all stuffed into chrome.storage.local. It stores a maximum of 100 items—should be enough; if not, you can change it.


Step 4: popup.html — Saved Items List

quotes-saver-4.png

The small window that pops up when you click the toolbar icon is just a regular HTML page; you can style it however you want.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { width: 360px; max-height: 480px; font-family: system-ui, sans-serif; font-size: 13px; }
    header { padding: 12px 14px; border-bottom: 1px solid #eee; font-weight: 600; }
    #list { overflow-y: auto; max-height: 420px; }
    .item { padding: 10px 14px; border-bottom: 1px solid #f0f0f0; }
    .item:hover { background: #fafafa; }
    .text { color: #222; line-height: 1.5; margin-bottom: 6px;
            display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
    .meta { color: #888; font-size: 12px; }
    .meta a { color: #1a73e8; text-decoration: none; }
    .del { float: right; color: #c00; cursor: pointer; border: none; background: none; font-size: 12px; }
    .empty { padding: 40px; text-align: center; color: #999; }
  </style>
</head>
<body>
  <header>My Saved Items</header>
  <div id="list"></div>
  <script src="popup.js"></script>
</body>
</html>

Step 5: popup.js — Read List, Delete Items

const listEl = document.getElementById('list');

async function render() {
  const { quotes = [] } = await chrome.storage.local.get('quotes');

  if (quotes.length === 0) {
    listEl.innerHTML = '<div class="empty">No saved items yet<br>Try selecting text on a webpage</div>';
    return;
  }

  listEl.innerHTML = quotes.map(q => `
    <div class="item" data-id="${q.id}">
      <div class="text">${escapeHtml(q.text)}</div>
      <div class="meta">
        <a href="${q.url.startsWith('http') ? q.url : '#'}" target="_blank">${escapeHtml(q.title)}</a>
        · ${q.time}
        <button class="del" data-id="${q.id}">Delete</button>
      </div>
    </div>
  `).join('');
}

function escapeHtml(str) {
  const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
  return String(str).replace(/[&<>"']/g, c => map[c]);
}

listEl.addEventListener('click', async (e) => {
  if (!e.target.classList.contains('del')) return;

  const id = Number(e.target.dataset.id);
  const { quotes = [] } = await chrome.storage.local.get('quotes');
  await chrome.storage.local.set({ quotes: quotes.filter(q => q.id !== id) });
  render();
});

render();

The Content Script writes data in, the Popup reads it out, both using the same chrome.storage.local with the key 'quotes'. As long as they match, it works.

By the way, we're using local here, so data only exists on the current computer. If you want to see your saved items on another computer, change local to sync—but sync only has a 100KB quota, so it's easy to exceed if you save a lot of text-heavy items. local is more stable for larger amounts of text.


Step 6: Install into Chrome

Once the code is written, you don't need to publish it; you can run it locally:

  1. Type chrome://extensions/ in the address bar and press Enter (for Edge use edge://extensions/, for Brave use brave://extensions/, it's the same)
  2. Turn on "Developer mode" in the top right corner
  3. Click "Load unpacked" → select the chrome-plugins/quote-saver folder
  4. The icon appears in the toolbar, done

Try it out on an article: select text → click "Save" → then click the icon to see the list. Try deleting an item and see if the list updates. If everything works, your first plugin is complete.

quotes-saver-1.png


Pitfalls I Encountered During Debugging

After modifying the code, remember to refresh in two places: click the refresh button on the extensions management page, and also refresh any already-open webpages. The Content Script is injected when the page loads; if you don't refresh the page, it won't update.

View Popup logs    → Right-click the icon, "Inspect popup"
View Content Script → Normal webpage F12, find content.js in Sources

There's a very common error:

Extension context invalidated

The extension was just reloaded, so the Content Script in the old page is invalid. Just refresh the page. The first time I saw this, I thought I had a bug and spent ages debugging.

Another issue: you wrote to storage but the Popup can't see it—it's probably because the key names don't match, or the Popup didn't call render() when it opened.


Let's Add Two More Features

The basic version works, but after using it, two things felt a bit off: first, some websites' own JS swallows the mouseup event, so the selection button never appears; second, if you want to switch browsers, there's no way to export your saved data.

Let's fix both.

Add Right-Click Save

quotes-saver-2.png

Earlier I said Background could wait; now it's time. Right-click menus must be registered by Background; Content Script can't do it.

Add two lines to manifest:

{
  "permissions": ["storage", "contextMenus"],
  "background": {
    "service_worker": "background.js"
  }
}

contextMenus is the permission for right-click menus. background.service_worker tells Chrome which file to run in the background. In MV3, Background is called a Service Worker—the name clashes with web page Service Workers, but they're not the same thing, so don't confuse them.

The complete manifest now looks like this:

{
  "manifest_version": 3,
  "name": "Selection Saver",
  "version": "1.1.0",
  "description": "Select text on a webpage, save it with one click",
  "permissions": ["storage", "contextMenus"],
  "action": {
    "default_popup": "popup.html",
    "default_title": "My Saved Items",
    "default_icon": {
      "128": "icons/icon128.png"
    }
  },
  "icons": {
    "128": "icons/icon128.png"
  },
  "background": {
    "service_worker": "background.js"
  },
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"],
      "run_at": "document_idle"
    }
  ]
}

Create background.js:

// Create right-click menu on installation
chrome.runtime.onInstalled.addListener(() => {
  chrome.contextMenus.create({
    id: 'save-quote',
    title: 'Save this quote',
    contexts: ['selection']
  });
});

// Right-click menu click handler
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
  if (info.menuItemId !== 'save-quote') return;

  const text = info.selectionText.trim();
  if (!text) return;

  const { quotes = [] } = await chrome.storage.local.get('quotes');

  quotes.unshift({
    id: Date.now(),
    text,
    title: tab.title,
    url: tab.url,
    time: new Date().toLocaleString()
  });

  await chrome.storage.local.set({ quotes: quotes.slice(0, 100) });

  // Tell the current page's content script to show a notification
  // Some pages (chrome://, PDF viewers) don't have content scripts injected, so sendMessage's Promise will reject
  // Can't use try-catch—Promise rejection is async, try-catch only catches sync errors. Use .catch() instead
  chrome.tabs.sendMessage(tab.id, { action: 'saved' }).catch(() => {
    // Data is saved, just can't show notification, no big deal
  });
});

The logic is straightforward: when the plugin is installed, register a right-click menu that only appears when text is selected. When clicked, info.selectionText is the selected text, tab.title and tab.url are the current page's title and URL—Background can access them directly without needing the Content Script to fetch them.

The last line chrome.tabs.sendMessage sends a message to the current page's Content Script to show a "Saved" notification. But some pages (like chrome:// pages, PDF viewers) don't have Content Scripts injected, so the message goes nowhere. In MV3, sendMessage returns a Promise; if no one receives it, the Promise rejects—but Promise rejection is async, so try-catch can't catch it; you need .catch() to handle it. The data is already saved; it just can't show a notification, no big deal.

Add a few lines to the Content Script. Append this to the end of content.js:

// After right-click save, background sends a message, show a notification
chrome.runtime.onMessage.addListener((msg) => {
  if (msg.action === 'saved') {
    showToast('Saved');
  }
});

This is the "message passing" mentioned earlier—Background does its work, then sends a message for the Content Script to show a notification on the page. showToast was already written, so we just reuse it.

Reload the plugin, select text and right-click—"Save this quote" appears. Click it, and "Saved" pops up in the top right corner, just like the selection button.

Export to JSON

This doesn't require changes to manifest, only the popup. Add an "Export JSON" button to the panel header; clicking it downloads all saved items.

Modify the header in popup.html to add a button:

<header>
  <span>My Saved Items</span>
  <button id="export">Export JSON</button>
</header>

Add a few lines of CSS to align the button to the right:

header { display: flex; justify-content: space-between; align-items: center; }
#export { font-size: 12px; font-weight: 400; color: #1a73e8;
          border: 1px solid #1a73e8; border-radius: 4px; padding: 3px 10px;
          cursor: pointer; background: none; }
#export:hover { background: #f0f6ff; }

Add the export logic to popup.js:

document.getElementById('export').addEventListener('click', async () => {
  const { quotes = [] } = await chrome.storage.local.get('quotes');
  if (quotes.length === 0) return;

  const blob = new Blob([JSON.stringify(quotes, null, 2)], { type: 'application/json' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = `quotes-${new Date().toISOString().slice(0, 10)}.json`;
  a.click();
  URL.revokeObjectURL(url);
});

Standard routine: read storage, JSON.stringify into a string, stuff it into a Blob, trigger a download with <a download>. The filename includes the current date, so multiple exports won't overwrite each other.

quotes-saver-5.png


Look Back at the Four Core Components

Now that all features are done, let's revisit the four core components. Each one has been put to use:

Component Corresponding File What It Does
manifest manifest.json Declares permissions, registers Content Script, Popup, and Background
Content Script content.js Draws button on selection, saves data, receives right-click messages and shows notifications
Popup popup.html + popup.js Displays list, deletes items, exports JSON
Background background.js Registers right-click menu, saves data on right-click

Two data paths:

Selection save: select text → content.js writes to storage → click icon → popup.js reads storage and renders
Right-click save: right-click → background.js writes to storage → sends message → content.js shows notification

If You Want to Publish to the Chrome Web Store

Loading locally only works for you. If you want others to install it, you need to publish it to the Chrome Web Store. The process isn't complicated, but there are a few pitfalls.

First, package it. You don't need to zip it; Chrome's extensions management page has a built-in tool:

chrome://extensions/ → "Pack extension"
Extension root directory → Select your quote-saver folder
Private key file → Leave blank the first time; Chrome will generate a .pem file

Click it, and two files will be generated in the same directory: quote-saver.crx (the plugin package) and quote-saver.pem (the private key). Keep the .pem file safe—you'll need the same private key to sign future updates. If you lose it, you'll have to create a new extension ID, which means a completely new plugin.

Then, submit it. Go to the Chrome Web Store Developer Dashboard and log in with your Google account:

  1. Pay a $5 one-time registration fee (credit card or Google Pay; some Chinese cards may not work)
  2. Click "Add new item" → upload a zip file (note: the store requires a zip, not a crx—just zip the quote-saver folder directly)
  3. Fill in the store information: screenshots, description, category, privacy policy
  4. Submit for review

Review can take a day or two, or up to a couple of weeks. Common reasons for rejection: requesting too many permissions (e.g., requesting tabs when you only need storage), a too-brief description, or screenshots that don't show the actual interface.

To be honest, if you're only using it yourself or sharing it with a few friends, there's no need to publish it at all. Just zip the quote-saver folder and send it to someone; they can unzip it and use "Load unpacked"—saving $5 and avoiding the review wait.


Final Thoughts

One manifest.json tells Chrome who you are, one content.js goes into webpages to do the work, one popup provides an interface for users, and one background.js handles right-click menu tasks in the background. With these four parts together, a not-so-basic plugin is born.

The code is all in chrome-plugins/quote-saver; just copy it and it will run. Modify the styles, add a search feature, and it becomes your own tool—there's something genuinely addictive about "building a little thing for yourself."