<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: ENKNOT｜TS Dev Lab</title>
    <description>The latest articles on DEV Community by ENKNOT｜TS Dev Lab (@enknot96).</description>
    <link>https://dev.to/enknot96</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4080108%2F53becbde-9975-49ac-87c3-27c84c600f9f.png</url>
      <title>DEV Community: ENKNOT｜TS Dev Lab</title>
      <link>https://dev.to/enknot96</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/enknot96"/>
    <language>en</language>
    <item>
      <title>My favorite screenshot extension vanished, so I built my own</title>
      <dc:creator>ENKNOT｜TS Dev Lab</dc:creator>
      <pubDate>Sat, 12 Sep 2026 02:17:19 +0000</pubDate>
      <link>https://dev.to/enknot96/my-favorite-screenshot-extension-vanished-so-i-built-my-own-58d5</link>
      <guid>https://dev.to/enknot96/my-favorite-screenshot-extension-vanished-so-i-built-my-own-58d5</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr5k4ygbkteswqcvlq53n.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr5k4ygbkteswqcvlq53n.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0m1jdh9vjev17ddwuy39.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0m1jdh9vjev17ddwuy39.gif" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The extension I relied on went away
&lt;/h2&gt;

&lt;p&gt;For a long time, &lt;a href="https://gofullpage.com/" rel="noopener noreferrer"&gt;GoFullPage&lt;/a&gt; was just part of the furniture. Two jobs, mostly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Screenshotting server maintenance logs for my records (logs are &lt;em&gt;tall&lt;/em&gt;)&lt;/li&gt;
&lt;li&gt;Capturing a whole page to send to a client&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The logs, meanwhile, kept being tall. The clients kept needing pages.&lt;/p&gt;

&lt;p&gt;So: fine. I'll build one.&lt;/p&gt;

&lt;h2&gt;
  
  
  From here to there
&lt;/h2&gt;

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

&lt;ul&gt;
&lt;li&gt;Click the toolbar icon and choose &lt;strong&gt;Select Area&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Drag out the region you want&lt;/li&gt;
&lt;li&gt;It scrolls through your selection, captures it in tiles, and stitches the tiles into one PNG&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;There's a &lt;strong&gt;Full Page&lt;/strong&gt; mode too — top to bottom, the thing I'd been using GoFullPage for.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faputf2zuitob2dizkg0d.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faputf2zuitob2dizkg0d.gif" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Chrome Web Store: 
&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://chromewebstore.google.com/detail/%E3%82%B3%E3%82%B3%E3%82%BD%E3%82%B3-koko-soko/hobllnakplgdhgkjdanilcgplbpanbcm" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Flh3.googleusercontent.com%2FsImf84ZhtHjb5fgBUh1cjG5D83OBxUMNhnvSxMDFvbsU1lGk1IfL8cm_xvVry8w8hpLtvTXylNU8gKa1olR_J23F1w%3Ds128-rj-sc0x00ffffff" height="auto" class="m-0"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://chromewebstore.google.com/detail/%E3%82%B3%E3%82%B3%E3%82%BD%E3%82%B3-koko-soko/hobllnakplgdhgkjdanilcgplbpanbcm" rel="noopener noreferrer" class="c-link"&gt;
            ココソコ - Koko Soko - Chrome Web Store
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            ココからソコまで、範囲を指定してスクロールごとスクショ / Capture anything from here to there — select a range and shoot the whole scroll.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fssl.gstatic.com%2Fchrome%2Fwebstore%2Fimages%2Ficon_48px.png"&gt;
          chromewebstore.google.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How it's put together
&lt;/h2&gt;

&lt;p&gt;TypeScript throughout, in three folders:&lt;/p&gt;

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

&lt;p&gt;The only permissions are &lt;code&gt;activeTab&lt;/code&gt; and &lt;code&gt;scripting&lt;/code&gt;. No standing read/write access to every site you visit.&lt;/p&gt;

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

&lt;p&gt;Two things along the way made me stop and go "ah, &lt;em&gt;that's&lt;/em&gt; why."&lt;/p&gt;

&lt;h2&gt;
  
  
  The box that flew away
&lt;/h2&gt;

&lt;p&gt;The first version had a great bug: &lt;strong&gt;scroll while you're dragging, and the selection box shoots off across the page.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I was storing the mouse event's coordinates as-is.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// nope&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientY&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;clientX&lt;/code&gt; / &lt;code&gt;clientY&lt;/code&gt; answer the question "where is this on screen &lt;em&gt;right now&lt;/em&gt;" — 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.&lt;/p&gt;

&lt;p&gt;What I actually needed was the position within the &lt;em&gt;document&lt;/em&gt; — anchored to the top of the page, unmoved by scrolling. The conversion is just the scroll offset:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// where it appears on screen -&amp;gt; where it lives in the document&lt;/span&gt;
&lt;span class="nf"&gt;toContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clientX&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clientY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Point&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;clientX&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;scrollX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;clientY&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;scrollY&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// where it lives in the document -&amp;gt; where it appears on screen&lt;/span&gt;
&lt;span class="nf"&gt;toClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Point&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;scrollX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;scrollY&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;And then one rule, which is really the whole fix: &lt;strong&gt;store the selection in content coordinates, convert to client coordinates only at paint time.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;selecting&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;startClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;currentClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;overlay&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setRect&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;startClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;currentClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;startClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;currentClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;startClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;currentClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;startClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;currentClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


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

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

&lt;p&gt;This one took me the longest.&lt;/p&gt;

&lt;p&gt;Koko Soko runs on &lt;code&gt;activeTab&lt;/code&gt;, injecting its content script at the moment you click the icon:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scripting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;executeScript&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;tabId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// which tab&lt;/span&gt;
  &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="c1"&gt;// what to run&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;And then the second click on the same tab turned the console red.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Uncaught SyntaxError: Identifier 'WindowScrollTarget' has already been declared
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  What was actually going on
&lt;/h3&gt;

&lt;p&gt;A content script runs in what Chrome calls an &lt;a href="https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts" rel="noopener noreferrer"&gt;isolated world&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;An isolated world is a private execution environment that isn't accessible to the page or other extensions.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Private from the page, sure. Not private from &lt;em&gt;me, sixty seconds ago&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;First injection → &lt;code&gt;class WindowScrollTarget&lt;/code&gt; declared at the top level of that world&lt;/li&gt;
&lt;li&gt;Second injection → same declaration, same scope&lt;/li&gt;
&lt;li&gt;Collision&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Worth noticing that this is a &lt;code&gt;SyntaxError&lt;/code&gt;, 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 &lt;code&gt;catch&lt;/code&gt; block is inside the file that never ran. Which is why the fix ended up in the build config rather than the code.&lt;/p&gt;
&lt;h3&gt;
  
  
  Fix one: an IIFE
&lt;/h3&gt;

&lt;p&gt;Emit the content script — and only the content script — as an IIFE.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// vite.content.config.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;rollupOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;__dirname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;src/content/index.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;entryFileNames&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[name].js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;iife&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// &amp;lt;- this&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;outDir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dist&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;emptyOutDir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// don't wipe the background/popup output&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The bundle comes out wrapped:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use strict&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WindowScrollTarget&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cm"&gt;/* ... */&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;})();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;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. &lt;code&gt;background.ts&lt;/code&gt; and &lt;code&gt;popup.ts&lt;/code&gt; stay ordinary ES modules.&lt;/p&gt;
&lt;h3&gt;
  
  
  Fix two: a flag on &lt;code&gt;window&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The IIFE stops the collision, but not the &lt;em&gt;execution&lt;/em&gt;. 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.&lt;/p&gt;

&lt;p&gt;So I left a marker:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__kokosoko__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__kokosoko__&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// everything below runs on the first injection only&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;State&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;idle&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;overlay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createOverlay&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="c1"&gt;// ...register listeners&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;blockquote&gt;
&lt;p&gt;It's tempting to say this works because &lt;code&gt;window&lt;/code&gt; is shared. It isn't — the isolated world gets its own &lt;code&gt;window&lt;/code&gt;, and &lt;code&gt;window.__kokosoko__&lt;/code&gt; is invisible to the page's own JavaScript.&lt;/p&gt;

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

&lt;p&gt;(TypeScript will object that &lt;code&gt;Window&lt;/code&gt; has no such member, which &lt;code&gt;declare global&lt;/code&gt; and interface declaration merging settle in about four lines.)&lt;/p&gt;

&lt;p&gt;So: &lt;strong&gt;the IIFE prevents name collisions, the flag prevents duplicate side effects.&lt;/strong&gt; Two problems wearing one costume.&lt;/p&gt;
&lt;h2&gt;
  
  
  The seam I left open
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;window.scrollTo()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It is, however, built around it &lt;em&gt;behind an interface&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ScrollTarget&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;getScroll&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;Point&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;scrollTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;getMaxScrollY&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;getWindowRect&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;Rect&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;toContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clientX&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clientY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Point&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;toClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Point&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;scrollToShowContentAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;There's exactly one implementation today (&lt;code&gt;WindowScrollTarget&lt;/code&gt;). Adding an &lt;code&gt;ElementScrollTarget&lt;/code&gt; 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.&lt;/p&gt;
&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;This started with something I depended on disappearing, which is an irritating way to start anything. It ended with coordinate systems, a &lt;code&gt;SyntaxError&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;If the selection overlay felt suspiciously smooth to write, that's because I'd already built one for &lt;strong&gt;ShoText&lt;/strong&gt;, an extension that OCRs a region of the screen onto your clipboard. Half of this project was me quietly copying myself.&lt;br&gt;
&lt;/p&gt;
&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/enknot96/i-built-a-chrome-extension-to-copy-text-from-youtube-videos-2cen" class="crayons-story__hidden-navigation-link"&gt;I Built a Chrome Extension to Copy Text from YouTube Videos📸&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/enknot96" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4080108%2F53becbde-9975-49ac-87c3-27c84c600f9f.png" alt="enknot96 profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/enknot96" class="crayons-story__secondary fw-medium m:hidden"&gt;
              ENKNOT｜TS Dev Lab
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                ENKNOT｜TS Dev Lab
                
                
              
              &lt;div id="story-author-preview-content-4452664" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/enknot96" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4080108%2F53becbde-9975-49ac-87c3-27c84c600f9f.png" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;ENKNOT｜TS Dev Lab&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/enknot96/i-built-a-chrome-extension-to-copy-text-from-youtube-videos-2cen" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Aug 25&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/enknot96/i-built-a-chrome-extension-to-copy-text-from-youtube-videos-2cen" id="article-link-4452664"&gt;
          I Built a Chrome Extension to Copy Text from YouTube Videos📸
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/typescript"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;typescript&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/javascript"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;javascript&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/developer"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;developer&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/japan"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;japan&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/enknot96/i-built-a-chrome-extension-to-copy-text-from-youtube-videos-2cen" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/exploding-head-daceb38d627e6ae9b730f36a1e390fca556a4289d5a41abb2c35068ad3e2c4b5.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;5&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/enknot96/i-built-a-chrome-extension-to-copy-text-from-youtube-videos-2cen#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              &lt;span class="hidden s:inline"&gt;Add&amp;nbsp;Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            4 min read
          &lt;/small&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;


&lt;/div&gt;
&lt;br&gt;


&lt;p&gt;If any of that sounds useful, Koko-chan and Soko-chan are available for work.&lt;br&gt;
&lt;/p&gt;
&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://chromewebstore.google.com/detail/%E3%82%B3%E3%82%B3%E3%82%BD%E3%82%B3-koko-soko/hobllnakplgdhgkjdanilcgplbpanbcm" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Flh3.googleusercontent.com%2FsImf84ZhtHjb5fgBUh1cjG5D83OBxUMNhnvSxMDFvbsU1lGk1IfL8cm_xvVry8w8hpLtvTXylNU8gKa1olR_J23F1w%3Ds128-rj-sc0x00ffffff" height="auto" class="m-0"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://chromewebstore.google.com/detail/%E3%82%B3%E3%82%B3%E3%82%BD%E3%82%B3-koko-soko/hobllnakplgdhgkjdanilcgplbpanbcm" rel="noopener noreferrer" class="c-link"&gt;
            ココソコ - Koko Soko - Chrome Web Store
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            ココからソコまで、範囲を指定してスクロールごとスクショ / Capture anything from here to there — select a range and shoot the whole scroll.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fssl.gstatic.com%2Fchrome%2Fwebstore%2Fimages%2Ficon_48px.png"&gt;
          chromewebstore.google.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


</description>
      <category>typescript</category>
      <category>javascript</category>
      <category>google</category>
      <category>developer</category>
    </item>
    <item>
      <title>I Built a Chrome Extension to Copy Text from YouTube Videos📸</title>
      <dc:creator>ENKNOT｜TS Dev Lab</dc:creator>
      <pubDate>Tue, 25 Aug 2026 10:23:18 +0000</pubDate>
      <link>https://dev.to/enknot96/i-built-a-chrome-extension-to-copy-text-from-youtube-videos-2cen</link>
      <guid>https://dev.to/enknot96/i-built-a-chrome-extension-to-copy-text-from-youtube-videos-2cen</guid>
      <description>&lt;h2&gt;
  
  
  Why I Built It
&lt;/h2&gt;

&lt;p&gt;When I was studying programming by following along with YouTube tutorials, there was one small but surprisingly annoying task I kept running into.&lt;/p&gt;

&lt;p&gt;The person in the video would say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Okay, let's move forward with this!"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But then I realized that none of the following were provided anywhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Code&lt;/li&gt;
&lt;li&gt;System prompts&lt;/li&gt;
&lt;li&gt;Other related resources&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvnwb9l36rbu3y1d5bllz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvnwb9l36rbu3y1d5bllz.png" alt="こういうやつ" width="800" height="473"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;"こういうやつ" means "this".&lt;/p&gt;

&lt;p&gt;So basically, I was thinking:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Come on, let me copy and paste it, teacher..."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So, I had no choice but to repeat the following process over and over:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Take a screenshot of the relevant part of the video.&lt;/li&gt;
&lt;li&gt;Upload it to Claude.&lt;/li&gt;
&lt;li&gt;Ask, "Can you transcribe the text in this image?"&lt;/li&gt;
&lt;li&gt;Copy and paste the result into VS Code.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;...and I had to do this again and again.&lt;/p&gt;

&lt;p&gt;Doing this every single time was just way too much of a hassle!&lt;/p&gt;

&lt;p&gt;I found myself thinking:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"It would be nice if I could just select part of the screen and copy the text right there."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That was what gave me the idea to try building this Chrome extension.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;It's a Chrome extension called &lt;strong&gt;"ShoText!"&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://i.giphy.com/media/7sgaOgwOkyV8YRrRjJ/giphy.gif" class="article-body-image-wrapper"&gt;&lt;img width="480" src="https://i.giphy.com/media/7sgaOgwOkyV8YRrRjJ/giphy.gif" height="220"&gt;&lt;/a&gt;&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://chromewebstore.google.com/detail/%E3%82%B7%E3%83%A7%E3%83%83%E3%83%86%E3%82%AD%EF%BC%81-shotext%EF%BC%81/gpfjbnfccfhfnbafdkadhckfnjdpfcpm" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Flh3.googleusercontent.com%2FYXvjMWn7qibk3FVwycNpLx3S8s4gCyQ8VuLyU0I8Kdfe4Sk-hNCGM-A3lpeUuB1box8_OwNLeFWL9wJ_FO7Pcp4dOi4%3Ds128-rj-sc0x00ffffff" height="128" class="m-0" width="128"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://chromewebstore.google.com/detail/%E3%82%B7%E3%83%A7%E3%83%83%E3%83%86%E3%82%AD%EF%BC%81-shotext%EF%BC%81/gpfjbnfccfhfnbafdkadhckfnjdpfcpm" rel="noopener noreferrer" class="c-link"&gt;
            ショッテキ！- ShoText！ - Chrome Web Store
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            範囲を選択してOCR、結果をクリップボードにコピー / Select an area, OCR it, and copy the text to your clipboard.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fssl.gstatic.com%2Fchrome%2Fwebstore%2Fimages%2Ficon_48px.png" width="48" height="48"&gt;
          chromewebstore.google.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Here's how it works:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Press &lt;code&gt;Alt+Shift+D&lt;/code&gt; and drag to select any area of the screen.&lt;/li&gt;
&lt;li&gt;The text within the selected area is automatically extracted using OCR.&lt;/li&gt;
&lt;li&gt;The recognized text is automatically copied to the clipboard.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's it.&lt;/p&gt;

&lt;p&gt;For OCR, I use &lt;a href="https://github.com/naptha/tesseract.js" rel="noopener noreferrer"&gt;Tesseract.js&lt;/a&gt;, the WebAssembly version of Tesseract, and everything is processed locally in the browser. Neither the image nor the OCR results are sent anywhere.&lt;/p&gt;

&lt;p&gt;Oh, and about the name &lt;strong&gt;"ShoText!"&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Taking a screenshot = sounds kind of like "shot!"&lt;/li&gt;
&lt;li&gt;文字 (text) = "text" in English, so I went with "Text"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Put them together, and you get &lt;strong&gt;"ShoText!"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I hope you'll give it some love.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxlmneug1rr2smtl3rrul.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxlmneug1rr2smtl3rrul.png" alt=" " width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Born from a One-Shot Prompt by Gemini&lt;/p&gt;

&lt;h2&gt;
  
  
  Tech Stack
&lt;/h2&gt;

&lt;p&gt;I built it as a monorepo using a pnpm workspace, with two packages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;packages/core&lt;/code&gt;

&lt;ul&gt;
&lt;li&gt;Common logic that is independent of the browser extension&lt;/li&gt;
&lt;li&gt;Region selection UI&lt;/li&gt;
&lt;li&gt;Image cropping&lt;/li&gt;
&lt;li&gt;Toast notifications&lt;/li&gt;
&lt;li&gt;And more&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;packages/local-ocr&lt;/code&gt;

&lt;ul&gt;
&lt;li&gt;The extension itself, using Tesseract.js for local OCR&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the build setup, I use Vite + &lt;a href="https://crxjs.dev/vite-plugin" rel="noopener noreferrer"&gt;@crxjs/vite-plugin&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In the following sections, I'd like to share a few "aha!" moments I ran into while building it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem of Extra Spaces Between Japanese Characters in OCR
&lt;/h2&gt;

&lt;p&gt;When I ran OCR on Japanese text, I ran into a strange problem: a half-width space was inserted between every character.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;シ ョ ッ テ キ ！
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;English worked just fine, so I wondered: why was this happening only with Japanese?&lt;/p&gt;

&lt;p&gt;After looking through issues in the official Tesseract repository and asking Claude about it, I found that the &lt;code&gt;preserve_interword_spaces&lt;/code&gt; setting was related to the problem.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Changed from 0 to 1&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setParameters&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;preserve_interword_spaces&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;But what exactly is &lt;code&gt;preserve_interword_spaces&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;I took a look at the Tesseract source code, and found this:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;int numSpaces = preserve_interword_spaces_ ? it_-&amp;gt;word()-&amp;gt;word-&amp;gt;space() : (words_appended &amp;gt; 0);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Hmm... I have absolutely no idea what that means.&lt;/p&gt;

&lt;p&gt;So, after digging through issues in the official Tesseract repository and asking Claude to explain it to me, I learned that this setting determines how many spaces should be inserted before a word when constructing the OCR result.&lt;/p&gt;

&lt;p&gt;With the default value (0), Tesseract doesn't take into account how much actual space there was between words in the image. Instead, it simply inserts one space between each word.&lt;/p&gt;

&lt;p&gt;Internally, Tesseract treats individual characters (or groups of characters) as a single "word." In Japanese text, the gap between adjacent characters is usually close to zero. However, with the default setting, Tesseract ignores the actual spacing and unconditionally inserts one space between each "word."&lt;/p&gt;

&lt;p&gt;That's what was causing the extra space between every Japanese character.&lt;/p&gt;

&lt;p&gt;By setting &lt;code&gt;preserve_interword_spaces: "1"&lt;/code&gt;, &lt;code&gt;numSpaces&lt;/code&gt; becomes &lt;code&gt;it_-&amp;gt;word()-&amp;gt;word-&amp;gt;space()&lt;/code&gt;, which means Tesseract uses the actual amount of spacing detected in the image.&lt;/p&gt;

&lt;p&gt;Since the spacing between Japanese characters is usually close to zero, this prevents the unwanted spaces from being inserted.&lt;/p&gt;

&lt;p&gt;There might still be some spacing depending on the font—for example, with a monospaced font, extra spaces might appear.&lt;/p&gt;
&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;I originally built this Chrome extension to eliminate the small but tedious task of taking screenshots of text from videos and asking Claude to transcribe them. But along the way, I learned a lot about things like the performance characteristics of different libraries, and the differences between OCR models.&lt;/p&gt;

&lt;p&gt;I'll keep working to improve my skills.&lt;/p&gt;

&lt;p&gt;If you get a chance, give it a try!&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://chromewebstore.google.com/detail/%E3%82%B7%E3%83%A7%E3%83%83%E3%83%86%E3%82%AD%EF%BC%81-shotext%EF%BC%81/gpfjbnfccfhfnbafdkadhckfnjdpfcpm" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Flh3.googleusercontent.com%2FYXvjMWn7qibk3FVwycNpLx3S8s4gCyQ8VuLyU0I8Kdfe4Sk-hNCGM-A3lpeUuB1box8_OwNLeFWL9wJ_FO7Pcp4dOi4%3Ds128-rj-sc0x00ffffff" height="128" class="m-0" width="128"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://chromewebstore.google.com/detail/%E3%82%B7%E3%83%A7%E3%83%83%E3%83%86%E3%82%AD%EF%BC%81-shotext%EF%BC%81/gpfjbnfccfhfnbafdkadhckfnjdpfcpm" rel="noopener noreferrer" class="c-link"&gt;
            ショッテキ！- ShoText！ - Chrome Web Store
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            範囲を選択してOCR、結果をクリップボードにコピー / Select an area, OCR it, and copy the text to your clipboard.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fssl.gstatic.com%2Fchrome%2Fwebstore%2Fimages%2Ficon_48px.png" width="48" height="48"&gt;
          chromewebstore.google.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;



</description>
      <category>typescript</category>
      <category>javascript</category>
      <category>developer</category>
      <category>japan</category>
    </item>
  </channel>
</rss>
