DEV Community

Cover image for My favorite screenshot extension vanished, so I built my own
ENKNOT|TS Dev Lab
ENKNOT|TS Dev Lab

Posted on

My favorite screenshot extension vanished, so I built my own

Start dragging, push past the bottom of the window, and the page scrolls along with you. Let go, and you get a single PNG of a region that was never on screen all at once.

The extension I relied on went away

For a long time, GoFullPage was just part of the furniture. Two jobs, mostly:

  • Screenshotting server maintenance logs for my records (logs are tall)
  • Capturing a whole page to send to a client

Then one day it stopped working for me. I never dug into exactly what happened — only that something quietly useful wasn't there anymore, with no way to know if it was coming back.

The logs, meanwhile, kept being tall. The clients kept needing pages.

So: fine. I'll build one.

From here to there

It's called Koko Soko (ココソコ). Koko is "here" in Japanese and soko is "there" — you point at one, then the other, and it takes everything in between.

  • Click the toolbar icon and choose Select Area
  • Drag out the region you want
  • It scrolls through your selection, captures it in tiles, and stitches the tiles into one PNG

The auto-scroll at the edges is the entire reason I built this instead of reaching for another full-page tool. You can select a region far larger than one screenful. Capturing and stitching both happen inside the browser; no image is sent anywhere.

There's a Full Page mode too — top to bottom, the thing I'd been using GoFullPage for.

  • Chrome Web Store:

    ココソコ - Koko Soko - Chrome Web Store

    ココからソコまで、範囲を指定してスクロールごとスクショ / Capture anything from here to there — select a range and shoot the whole scroll.

    favicon chromewebstore.google.com

How it's put together

TypeScript throughout, in three folders:

  • src/background.ts — the service worker. Calls chrome.tabs.captureVisibleTab, throttles it, retries it. Nothing else.
  • src/content/ — the selection overlay, coordinate math, tiled capture, canvas stitching, hiding fixed elements while shooting
  • src/shared/ — utilities shared between background and popup

The only permissions are activeTab and scripting. No standing read/write access to every site you visit.

One build note that's easy to miss: Vite transpiles, it doesn't type-check. A green build tells you nothing about your types. So tsconfig.json sets "noEmit": true and type checking lives in its own script (tsc --noEmit) that I run before committing.

Two things along the way made me stop and go "ah, that's why."

The box that flew away

The first version had a great bug: scroll while you're dragging, and the selection box shoots off across the page.

I was storing the mouse event's coordinates as-is.

// nope
let start = { x: event.clientX, y: event.clientY };
Enter fullscreen mode Exit fullscreen mode

clientX / clientY answer the question "where is this on screen right now" — origin at the top-left of the viewport. Which means they quietly change meaning the instant you scroll. Click 100px from the top, scroll down 200px, and "100px from the top of the screen" now points at a completely different paragraph.

What I actually needed was the position within the document — anchored to the top of the page, unmoved by scrolling. The conversion is just the scroll offset:

// where it appears on screen -> where it lives in the document
toContent(clientX: number, clientY: number): Point {
  return { x: clientX + scrollX, y: clientY + scrollY }
}

// where it lives in the document -> where it appears on screen
toClient(x: number, y: number): Point {
  return { x: x - scrollX, y: y - scrollY }
}
Enter fullscreen mode Exit fullscreen mode

And then one rule, which is really the whole fix: store the selection in content coordinates, convert to client coordinates only at paint time.

function render(): void {
  if (state.kind !== "selecting") return;

  const startClient = state.target.toClient(state.start.x, state.start.y);
  const currentClient = state.target.toClient(state.current.x, state.current.y);

  overlay.setRect({
    top: Math.min(startClient.y, currentClient.y),
    left: Math.min(startClient.x, currentClient.x),
    width: Math.abs(startClient.x - currentClient.x),
    height: Math.abs(startClient.y - currentClient.y),
  });
}
Enter fullscreen mode Exit fullscreen mode

(The Math.min / Math.abs pair is what stops a bottom-right-to-top-left drag from producing a rectangle with negative width.)

That early return is quietly doing double duty. State is a discriminated union — { kind: "idle" } | { kind: "selecting"; start: Point; ... } | { kind: "capturing" } — so once you've bailed out on everything that isn't selecting, TypeScript lets you read state.start. With one flat type full of optional fields, reading selection coordinates mid-capture would have been a runtime bug instead of a red squiggle.

The second click

This one took me the longest.

Koko Soko runs on activeTab, injecting its content script at the moment you click the icon:

chrome.scripting.executeScript({
  target: { tabId }, // which tab
  files: ["content.js"], // what to run
});
Enter fullscreen mode Exit fullscreen mode

That's how you avoid ever holding "read and change everything on every site you visit" — better for store review, and better for the permission dialog people actually read.

And then the second click on the same tab turned the console red.

Uncaught SyntaxError: Identifier 'WindowScrollTarget' has already been declared
Enter fullscreen mode Exit fullscreen mode

What was actually going on

A content script runs in what Chrome calls an isolated world:

An isolated world is a private execution environment that isn't accessible to the page or other extensions.

Private from the page, sure. Not private from me, sixty seconds ago:

  • First injection → class WindowScrollTarget declared at the top level of that world
  • Second injection → same declaration, same scope
  • Collision

A normal page wipes its scope clean on reload. Injection doesn't reload anything, so the first run's leftovers are still sitting there when the second run walks in.

Worth noticing that this is a SyntaxError, not a runtime error. The engine parses the whole file before running a line of it, spots the duplicate name, and throws the file out. You can't try/catch your way out of that — the catch block is inside the file that never ran. Which is why the fix ended up in the build config rather than the code.

Fix one: an IIFE

Emit the content script — and only the content script — as an IIFE.

// vite.content.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      input: { content: resolve(__dirname, "src/content/index.ts") },
      output: {
        entryFileNames: "[name].js",
        format: "iife", // <- this
      },
    },
    outDir: "dist",
    emptyOutDir: false, // don't wipe the background/popup output
  },
});
Enter fullscreen mode Exit fullscreen mode

The bundle comes out wrapped:

(function () {
  "use strict";
  class WindowScrollTarget {
    /* ... */
  }
})();
Enter fullscreen mode Exit fullscreen mode

A function that runs itself the moment it's defined. Everything declared inside exists only inside. Inject it ten times and you get ten separate scopes that have never met each other, and nothing at all lands in the global one. background.ts and popup.ts stay ordinary ES modules.

Fix two: a flag on window

The IIFE stops the collision, but not the execution. Ten scopes means ten passes through the same initialization — ten sets of event listeners hanging off one shared DOM, and one drag firing every one of them.

So I left a marker:

if (!window.__kokosoko__) {
  window.__kokosoko__ = true;

  // everything below runs on the first injection only
  let state: State = { kind: "idle" };
  const overlay = createOverlay();
  // ...register listeners
}
Enter fullscreen mode Exit fullscreen mode

It's tempting to say this works because window is shared. It isn't — the isolated world gets its own window, and window.__kokosoko__ is invisible to the page's own JavaScript.

It works because window outlives the injection. The IIFE scope is built from scratch on every click and remembers nothing; the world's window stays put until you navigate away. Longevity is the only property being used here.

(TypeScript will object that Window has no such member, which declare global and interface declaration merging settle in about four lines.)

So: the IIFE prevents name collisions, the flag prevents duplicate side effects. Two problems wearing one costume.

The seam I left open

Slack, Notion and friends scroll a container rather than the page itself. Koko Soko doesn't handle those yet — the whole thing is built around window.scrollTo().

It is, however, built around it behind an interface:

export interface ScrollTarget {
  getScroll(): Point;
  scrollTo(y: number): void;
  getMaxScrollY(): number;
  getWindowRect(): Rect;
  toContent(clientX: number, clientY: number): Point;
  toClient(x: number, y: number): Point;
  scrollToShowContentAt(y: number): void;
}
Enter fullscreen mode Exit fullscreen mode

There's exactly one implementation today (WindowScrollTarget). Adding an ElementScrollTarget should leave the capture loop and the coordinate math untouched. Premature abstraction is a genuine sin, but when you already know the second implementation is coming, leaving the seam open is just reading ahead.

Wrapping up

This started with something I depended on disappearing, which is an irritating way to start anything. It ended with coordinate systems, a SyntaxError I'd never seen in my life, and a build setting that turned out to be the fix for a bug I'd been trying to solve in code.

If the selection overlay felt suspiciously smooth to write, that's because I'd already built one for ShoText, an extension that OCRs a region of the screen onto your clipboard. Half of this project was me quietly copying myself.


If any of that sounds useful, Koko-chan and Soko-chan are available for work.

ココソコ - Koko Soko - Chrome Web Store

ココからソコまで、範囲を指定してスクロールごとスクショ / Capture anything from here to there — select a range and shoot the whole scroll.

favicon chromewebstore.google.com

Top comments (0)