C.W.K.
Stream
Lesson 04 of 07 · published

Screenshot and Clipping — captureVisibleTab + Canvas Crop

~13 min · screenshot, captureVisibleTab, canvas, clipping

Level 0Extension Curious
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"chrome.tabs.captureVisibleTab gives you the whole tab as a dataURL. The Selection rect tells you which pixels matter. Lesson 4 is wiring those two together so every ClipDeck clip can carry a tightly-cropped screenshot of where it came from."

chrome.tabs.captureVisibleTab

The API is small. Two arguments:

  • windowId — optional. Captures the active tab of the given window; omit to capture the current window.
  • options — optional. { format: 'png' | 'jpeg', quality: 0..100 }. Default is PNG. JPEG with quality 80 is usually 5x smaller for screenshots of text-heavy pages.

Returns a data: URL. The capture covers only the currently-visible viewport — what the user sees. Off-screen content is not captured.

Permission: activeTab (after user gesture) OR host_permissions for the URL OR the tabs permission. ClipDeck has all three relevant ones at this point.

The Crop Workflow

Captured image is full-tab. ClipDeck wants the rect of the user's selection. The math:

  1. Get the selection's getBoundingClientRect() from the content script — viewport-relative coordinates.
  2. Get the device pixel ratio (window.devicePixelRatio). Captured images are at native resolution; the rect is in CSS pixels.
  3. SW receives the dataURL + rect + dpr. Load into an offscreen canvas. Draw cropped at the right pixel coordinates.
  4. Export the cropped canvas as another dataURL. Store with the clip.

Offscreen Document for Canvas

Service workers can't use the DOM, so they can't create a normal canvas. MV3 introduced chrome.offscreen — a hidden document the SW spawns when it needs DOM APIs (canvas, fetch with Blob handling, audio). The shape:

await chrome.offscreen.createDocument({
  url: 'offscreen.html',
  reasons: ['BLOBS'],
  justification: 'Crop captured tab screenshots for ClipDeck clips',
});

offscreen.html is a tiny page with offscreen.js that receives messages from the SW, does the canvas work, and posts the result back. After use, call chrome.offscreen.closeDocument() — keeping it open wastes a bit of memory.

The Crop Code

Inside the offscreen document:

const img = new Image();
img.src = dataURL;
await new Promise((r) => (img.onload = r));
const canvas = new OffscreenCanvas(
  Math.round(rect.width * dpr),
  Math.round(rect.height * dpr),
);
const ctx = canvas.getContext('2d');
ctx.drawImage(
  img,
  rect.left * dpr, rect.top * dpr,
  rect.width * dpr, rect.height * dpr,
  0, 0,
  rect.width * dpr, rect.height * dpr,
);
const blob = await canvas.convertToBlob({ type: 'image/png' });
const reader = new FileReader();
reader.readAsDataURL(blob);
await new Promise((r) => (reader.onload = r));
return reader.result; // the cropped dataURL

OffscreenCanvas is supported in the offscreen document and gives you a Worker-friendly canvas. The 9-argument drawImage form: sx, sy, sw, sh, dx, dy, dw, dh. Source coordinates come from the rect; destination starts at 0,0.

Size Budget

Each PNG screenshot of a typical text selection is ~10–50 KB. PNG is fine for text; JPEG quality 80 cuts another 30–50%. Store dataURLs directly in chrome.storage.local — the quota is around 10 MB, plenty for a few hundred screenshots if you keep them cropped. If clip counts grow, consider compressing aggressively, switching to JPEG, or letting the user opt-out of screenshots per clip.

What Goes in the Clip

Extend the clip schema:

{ id, text, url, title, savedAt, screenshot?: string /* dataURL */ }

The side panel renders the screenshot inline if present (small thumbnail; click to enlarge). The export flow includes it; the clipboard copy still only gets the text.

captureVisibleTab + Selection.rect + offscreen canvas = tightly-cropped, in-context screenshot of every clip. Cheap, optional, and keeps the original context that bare text loses.
captureVisibleTab and selections that are off-screen. If the user's selection spans past the visible viewport (long highlight on a long page), captureVisibleTab only captures what's visible. The cropped result will be incomplete. Two options: scroll-and-stitch (capture multiple frames as you scroll the selection into view, then composite) — heavy. Or refuse to screenshot when the rect height exceeds viewport, falling back to text-only — light. ClipDeck v1 takes the light path with a one-line note in the preview.

Code

manifest.json — add 'offscreen' to permissions·json
{
  "permissions": ["storage", "activeTab", "scripting", "sidePanel", "contextMenus", "commands", "alarms", "tabs", "offscreen"]
}
background.js — captureVisibleTab + delegate crop to offscreen·javascript
// background.js — capture, then crop via offscreen document
async function ensureOffscreen() {
  const existing = await chrome.offscreen.hasDocument();
  if (existing) return;
  await chrome.offscreen.createDocument({
    url: "offscreen.html",
    reasons: ["BLOBS"],
    justification: "Crop captured tab screenshots for ClipDeck clips",
  });
}

async function captureAndCrop(tab, rect, dpr) {
  if (rect.height > tab.height) return null; // off-screen — see warning
  const dataURL = await chrome.tabs.captureVisibleTab(tab.windowId, {
    format: "png",
  });
  await ensureOffscreen();
  const response = await chrome.runtime.sendMessage({
    type: "clipdeck:crop",
    payload: { dataURL, rect, dpr },
  });
  return response?.cropped ?? null;
}
offscreen.html — minimal stub for the cropping script·html
<!-- offscreen.html -->
<!doctype html>
<html>
  <head><meta charset="utf-8" /></head>
  <body>
    <script src="offscreen.js"></script>
  </body>
</html>
offscreen.js — OffscreenCanvas crops the captured image·javascript
// offscreen.js — receive crop request, return cropped dataURL
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message?.type !== "clipdeck:crop") return;
  (async () => {
    const { dataURL, rect, dpr } = message.payload;
    const img = new Image();
    img.src = dataURL;
    await new Promise((r) => (img.onload = r));
    const canvas = new OffscreenCanvas(
      Math.round(rect.width * dpr),
      Math.round(rect.height * dpr),
    );
    const ctx = canvas.getContext("2d");
    ctx.drawImage(
      img,
      rect.left * dpr, rect.top * dpr,
      rect.width * dpr, rect.height * dpr,
      0, 0,
      rect.width * dpr, rect.height * dpr,
    );
    const blob = await canvas.convertToBlob({ type: "image/png" });
    const reader = new FileReader();
    reader.readAsDataURL(blob);
    await new Promise((r) => (reader.onload = r));
    sendResponse({ cropped: reader.result });
  })();
  return true;
});

External links

Exercise

Add "offscreen" to clipdeck/manifest.json permissions. Create clipdeck/offscreen.html and clipdeck/offscreen.js using the third and fourth code blocks. Add the second code block (captureAndCrop) to clipdeck/background.js. Wire it into your save-clip flow: after capturing the selection text + rect from the content script, also call captureAndCrop(tab, rect, devicePixelRatio) and attach the result as a screenshot field on the new clip. Update the side panel to render the thumbnail next to each clip text. Reload, save a clip from a Wikipedia article, and confirm the side panel shows a small image of the highlighted region.
Hint
If chrome.tabs.captureVisibleTab errors with Cannot access contents of the page, your activeTab grant isn't current — the call must run after a fresh user gesture. Try moving the capture into the save-clip handler itself rather than deferring through several awaits. If the cropped image is offset or sized wrong, double-check the dpr multiplication — captured images are in physical pixels, the rect is in CSS pixels. devicePixelRatio is usually 1 or 2 on Macs, 1 on most Windows screens. If chrome.offscreen.createDocument throws Only a single offscreen document may be created, you forgot the hasDocument check — keep ensureOffscreen idempotent.

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.