C.W.K.
Stream
Lesson 06 of 06 · published

ClipDeck Save Clip — CRUD-C Lands

~15 min · clipdeck, crud, selection, messaging, storage, hotkey

Level 0Extension Curious
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Track 3 ends with the first letter of CRUD ticking off. Content script captures the user's selection, ships it to the service worker, the SW writes it to storage, the popup re-renders. One loop, one slice of real work. The rest of ClipDeck is shaped variations on this template."

The Clip Schema

Before any code, agree on the shape. A clip is:

  • id — string. Generated at save time, the simplest correct generator is crypto.randomUUID() (Chrome 92+). The id is the primary key for the U and D operations in Track 7.
  • text — string. The selected text, trimmed.
  • url — string. location.href at save time.
  • title — string. document.title at save time. Lets the popup show "From Wikipedia — Service Worker" instead of a raw URL.
  • savedAt — number. Date.now() milliseconds. Used for sorting newest-first and for the relative-time display.

Stored under clips in chrome.storage.local as a plain array. Five fields, no nested shapes, all JSON-clonable. Track 7's update flow will mutate text; delete removes by id. Track 4's side panel will sort/filter the same array.

The Three Triggers

Three reasonable ways to start a save, each picking up steam in a later track:

  • Floating button — Lesson 4 already wired one. Visible on every page; cheap to discover.
  • Keyboard shortcut — this lesson's primary trigger. Declared in the manifest under commands; the SW receives the command, sends a message to the active tab's content script, content script reads the selection and ships it.
  • Context menu — Track 5 covers this. Right-click on a selection → "Save to ClipDeck."

Real ClipDeck users will probably keep the keyboard shortcut as primary (Ctrl+Shift+K by default; user-rebindable) and the context menu as the discoverability fallback. The floating button graduates to a per-site opt-in toggle later.

The End-to-End Flow

  1. User selects text on a page and hits Ctrl+Shift+K.
  2. Chrome fires chrome.commands.onCommand in the SW with command name save-clip.
  3. SW asks the active tab's content script: chrome.tabs.sendMessage(activeTab.id, { type: 'captureSelection' }).
  4. Content script reads window.getSelection().toString(), gathers URL and title, responds with the payload.
  5. SW receives the response, builds a clip object, reads existing clips from storage, appends, writes back.
  6. chrome.storage.onChanged fires; the popup (if open) and the side panel (Track 4) re-render.

Each hop is small and testable. If the popup never shows the clip, walk the chain backward: check storage in DevTools, then SW logs, then content script logs.

What Goes Wrong

  • Selection lost on toolbar interaction. Clicking the toolbar collapses the popup and sometimes clears the selection. Hotkey + content-script capture avoids this; popup-driven "Save current selection" buttons need the content script to capture before the popup opens.
  • Restricted pages. chrome://, the Chrome Web Store, and view-source: URLs reject content-script injection. Wrap the SW's send-to-content-script call in a try/catch and surface a friendly "This page doesn't allow ClipDeck" message.
  • Empty selection. Content script should respond with a clear empty payload ({ ok: false, reason: 'no-selection' }) and let the SW decide whether to toast or silently drop.
One clip, one loop. Selection in the content script, message to the SW, write to storage, re-render via onChanged. Every later ClipDeck feature is this loop with different verbs.
Test on Wikipedia first. Wikipedia is the friendliest content-script target — no SPA reshuffles, no aggressive CSP, no selection-stealing handlers. Get the full Ctrl+Shift+K → popup-shows-clip loop working on a Wikipedia article before touching SPAs (Gmail, Twitter/X) or sites with sensitive content (banking, password managers — keep those excluded).

Code

manifest.json — version 0.5.0 with commands.save-clip declared·json
{
  "manifest_version": 3,
  "name": "ClipDeck",
  "version": "0.5.0",
  "action": { "default_popup": "popup.html" },
  "background": { "service_worker": "background.js" },
  "permissions": ["storage", "tabs", "scripting", "activeTab"],
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "exclude_matches": ["https://*.bank.com/*", "https://accounts.google.com/*"],
      "js": ["content.js"],
      "run_at": "document_idle"
    }
  ],
  "commands": {
    "save-clip": {
      "suggested_key": { "default": "Ctrl+Shift+K", "mac": "Command+Shift+K" },
      "description": "Save the current text selection to ClipDeck"
    }
  },
  "icons": { "16": "icons/16.png", "48": "icons/48.png", "128": "icons/128.png" }
}
background.js — hotkey + message handler both produce clips·javascript
// background.js — bind hotkey, message content script, write storage
chrome.commands.onCommand.addListener(async (command) => {
  if (command !== "save-clip") return;
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  if (!tab?.id) return;

  let response;
  try {
    response = await chrome.tabs.sendMessage(tab.id, { type: "captureSelection" });
  } catch (err) {
    console.warn("[ClipDeck SW] no content script on this page:", err.message);
    return;
  }
  if (!response?.ok || !response.payload?.text) return;

  const clip = {
    id: crypto.randomUUID(),
    text: response.payload.text.trim(),
    url: response.payload.url,
    title: response.payload.title,
    savedAt: Date.now(),
  };

  const { clips = [] } = await chrome.storage.local.get("clips");
  await chrome.storage.local.set({ clips: [clip, ...clips] });
  console.log("[ClipDeck SW] saved clip", clip.id, "now", clips.length + 1, "total");
});

// Already-existing message handler path: button or popup-driven saves come
// through chrome.runtime.onMessage with {type:'saveClip', payload:{...}}.
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message?.type !== "saveClip") return;
  (async () => {
    const clip = {
      id: crypto.randomUUID(),
      text: message.payload.text.trim(),
      url: message.payload.url,
      title: message.payload.title ?? "",
      savedAt: Date.now(),
    };
    const { clips = [] } = await chrome.storage.local.get("clips");
    await chrome.storage.local.set({ clips: [clip, ...clips] });
    sendResponse({ ok: true, id: clip.id });
  })();
  return true;
});
content.js — captureSelection responder, sentinel-shaped·javascript
// content.js — respond to captureSelection with current selection payload
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message?.type !== "captureSelection") return;
  const selection = window.getSelection();
  const text = selection?.toString() ?? "";
  if (!text.trim()) {
    sendResponse({ ok: false, reason: "no-selection" });
    return;
  }
  sendResponse({
    ok: true,
    payload: {
      text,
      url: location.href,
      title: document.title,
    },
  });
});

// (Lesson 4's MutationObserver button code can stay — it provides a visible
// alternative trigger that hits the SW via chrome.runtime.sendMessage.)

External links

Exercise

Bump clipdeck/manifest.json to the first code block (version 0.5.0, with commands.save-clip). Update clipdeck/background.js with the second code block. Update clipdeck/content.js to add the captureSelection responder from the third code block. Reload the extension. Open a Wikipedia article on Service Workers. Select a paragraph. Press Ctrl+Shift+K (Cmd+Shift+K on Mac). Open the SW DevTools console — you should see [ClipDeck SW] saved clip <uuid> now 1 total. Open the SW DevTools Application tab → Extension Storage → 'local' → 'clips' to see the array with one entry. In clipdeck/popup.html / popup.js, add a list that renders clips (one row per clip showing the first 80 characters and the title). Save another two clips. Confirm the popup updates without manual refresh — that is chrome.storage.onChanged doing its job from Lesson 4's pattern.
Hint
If Ctrl+Shift+K does nothing, check chrome://extensions → Keyboard shortcuts (link at the top) — Chrome reserves some combos and silently drops yours. Pick a different key and reload. If the SW logs no content script on this page, you're on a Chrome-internal URL or one of your exclude_matches patterns — try a different real-web page. If sendResponse returns immediately but the storage write never appears, you forgot return true in the message listener (the silent-timeout trap from Track 2 Lesson 5). The crypto.randomUUID() call requires Chrome 92+; on older Chrome use a small fallback like Date.now() + '-' + Math.random().toString(36).slice(2).

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.