Skip to main content

Command Palette

Search for a command to run...

I Built a Code Screenshot Tool Inside My VS Code Extension — Here's How It Works (DotShare v3.4.0)

Zero native dependencies. Offline-first. Baked into your social sharing workflow.

Updated
9 min read
I Built a Code Screenshot Tool Inside My VS Code Extension — Here's How It Works (DotShare v3.4.0)
F
Architect and Creator of the DotUniverse—a massive open-source ecosystem dedicated to revolutionizing the Developer Experience (DX). With a specialized portfolio featuring over 20+ tools including DotShare, DotCommand, DotScramble, and DotGhostBoard, I focus on building high-performance, secure, and streamlined workflows for modern engineers. My mission is to empower the developer community by crafting modular tools that solve real-world problems. On this blog, I share deep dives into software architecture, cybersecurity insights, AI integration, and the journey of scaling a comprehensive suite of developer tools from the ground up.

You know that moment when you write a function you're genuinely proud of, and you want to share it on LinkedIn or Bluesky — but plain text just… doesn't do it justice?

I've been there every week.

I'm Kareem (FreeRave), founder of DotSuite — a suite of privacy-first Linux tools and VS Code extensions. DotShare is my VS Code extension for sharing content across LinkedIn, X, Bluesky, Reddit, Dev.to, Medium, Telegram, and more — all without leaving the editor.

Today I'm shipping v3.4.0, and the headline feature is CodeSnap: a code-to-image tool built entirely inside the extension's WebView, using nothing but HTML Canvas and Highlight.js.

No node-canvas. No sharp. No native binaries. Zero extra dependencies.

Let me walk you through how it works and why I built it this way.

https://www.youtube.com/watch?v=XqwQVM-s0Po


The Problem: Code Screenshots in VS Code Are Painful

The existing solutions are either:

  • External web apps (Carbon, Ray.so) — you copy code, tab out, paste, configure, download, come back, attach. Five steps too many.

  • Other VS Code extensions (CodeSnap, Polacode) — great tools, but they don't integrate with a sharing workflow. You screenshot, then you still have to open your composer manually.

I wanted the whole loop inside one tool:

Select code → 📸 Snap → Pick platform → Composer opens with image attached

That's it. No context switching.


The Architecture Decision: Why HTML Canvas?

When I started building this, the "obvious" choice was node-canvas — the Node.js port of the HTML Canvas API. But I ran into three hard problems immediately:

1. Native binaries are a Marketplace nightmare. node-canvas requires node-gyp and compiles native C++ addons. On VS Code Marketplace, extensions with native binaries are flagged, slow to install, and break on ARM Macs and certain Linux distros.

2. sharp is 10MB+ of compiled code. For a feature that renders a static image, that's an absurd payload.

3. VS Code already ships a full browser engine (Electron). The WebView is a Chromium tab. It has a GPU-accelerated Canvas API, a full DOM parser, and font rendering that matches what the user actually sees on screen. Why fight it?

So the decision was: render in the WebView, export PNG from there, and ship it back to the extension host.

The flow looks like this:

┌─────────────────────┐       loadCode        ┌─────────────────────┐
│   Extension Host    │ ──────────────────────▶│   WebView (Canvas)  │
│   (Node.js)         │                        │   (Chromium)        │
│                     │◀── snapReady (base64) ─│                     │
│  CodeSnapPanel.ts   │                        │  codesnap.html      │
│  MediaService.ts    │                        │  hljs + Canvas API  │
└─────────────────────┘                        └─────────────────────┘
         │
         ▼
    Saves to disk
    QuickPick: which platform?
    Opens Composer with image attached

CodeSnapService: Reading the Editor

The first piece is pure Node.js — no VS Code UI involved yet. CodeSnapService.capture() reads the active editor and returns everything the renderer needs:

// src/services/CodeSnapService.ts

export interface CodeSnapData {
    code: string;
    language: string;  // resolved to HL.js alias
    fileName: string;
    lineStart: number;
    lineEnd: number;
    hasSelection: boolean;
}

public static capture(): CodeSnapData | null {
    const editor = vscode.window.activeTextEditor;
    if (!editor) return null;

    const doc       = editor.document;
    const selection = editor.selection;
    const hasSelection = !selection.isEmpty;

    let code = hasSelection
        ? doc.getText(selection)
        : doc.getText();

    // Tabs break canvas rendering — convert to spaces first
    code = code.replace(/\t/g, '    ');

    // Strip common leading indent so the image doesn't waste space
    code = CodeSnapService._stripCommonIndent(code);

    return {
        code,
        language:  CodeSnapService._resolveLanguage(doc.languageId, doc.fileName),
        fileName:  path.basename(doc.fileName),
        lineStart: hasSelection ? selection.start.line + 1 : 1,
        lineEnd:   hasSelection ? selection.end.line   + 1 : doc.lineCount,
        hasSelection,
    };
}

Two details worth calling out:

Tab conversion — HTML Canvas has inconsistent \t rendering across platforms. Converting to 4 spaces before we even touch the canvas eliminates an entire class of alignment bugs.

Common indent stripping — if you select a deeply nested function, the raw text has 16 spaces of leading indent on every line. _stripCommonIndent finds the minimum indent across all non-empty lines and removes it. The rendered image uses the full canvas width instead of leaving most of it empty.

private static _stripCommonIndent(code: string): string {
    const lines = code.split('\n');
    const minIndent = Math.min(
        ...lines
            .filter(l => l.trim().length > 0)
            .map(l => l.match(/^(\s*)/)?.[1].length ?? 0)
    );
    if (minIndent === 0) return code;
    return lines.map(l => l.slice(minIndent)).join('\n').trimEnd();
}

The Canvas Renderer

The canvas rendering runs entirely in the WebView. Here's the core loop.

Step 1: Measure Before You Paint

const tmp    = document.createElement('canvas');
const tmpCtx = tmp.getContext('2d');
tmpCtx.font  = `${fontSize}px 'JetBrains Mono', 'Fira Code', Consolas, monospace`;

const lines    = data.code.split('\n');
const maxCodeW = Math.max(...lines.map(l => tmpCtx.measureText(l).width));

const innerW = Math.ceil(maxCodeW + lineNumWidth) + padding * 2;
const innerH = lines.length * LINE_H + padding * 2 + TITLE_H;

We use a throwaway canvas just to measure text width before the real canvas exists. This lets us size the output to exactly fit the content — no hardcoded 800px width.

Step 2: 2x Resolution for Retina

const cv = document.createElement('canvas');
cv.width  = canvasW * 2;   // physical pixels
cv.height = canvasH * 2;
cv.style.width  = canvasW + 'px';   // CSS pixels
cv.style.height = canvasH + 'px';
const ctx = cv.getContext('2d');
ctx.scale(2, 2);  // all our coordinates stay in CSS pixels

The canvas is 2× the display size but we scale the context by 2× before drawing. The exported PNG is full Retina resolution.

Step 3: Syntax Highlighting via HL.js

HL.js gives us highlighted HTML like:

<span class="hljs-keyword">const</span>
<span class="hljs-title function_">greet</span>
<span class="hljs-punctuation">(</span>
<span class="hljs-params">name</span>
<span class="hljs-punctuation">)</span>

We parse that HTML into a flat token list, then paint each token with its color:

function parseHljsHtml(html, defaultColor) {
    const out    = [];
    const parser = new DOMParser();
    const doc    = parser.parseFromString(`<pre>${html}</pre>`, 'text/html');
    flattenNode(doc.querySelector('pre'), defaultColor, out);
    return out;
}

function flattenNode(node, inheritColor, out) {
    if (node.nodeType === Node.TEXT_NODE) {
        if (node.textContent) out.push({ text: node.textContent, color: inheritColor });
        return;
    }
    if (node.nodeType === Node.ELEMENT_NODE) {
        const cls   = (node.className || '').trim();
        const color = activePalette[cls] ?? inheritColor;
        node.childNodes.forEach(c => flattenNode(c, color, out));
    }
}

Then we split by newlines and paint token by token:

lineSegs.forEach((segs, i) => {
    const y = codeY + i * LINE_H;

    if (showLines) {
        ctx.fillStyle = 'rgba(200,200,220,.22)';
        ctx.textAlign = 'right';
        ctx.fillText(String(data.lineStart + i), lineNumX, y);
        ctx.textAlign = 'left';
    }

    let x = codeX;
    for (const seg of segs) {
        if (!seg.text) continue;
        ctx.fillStyle = seg.color;
        ctx.fillText(seg.text, x, y);
        x += ctx.measureText(seg.text).width;
    }
});

Each token is measured and positioned individually — that's what makes the colors align exactly with the text.


The Race Condition I Fixed

The tricky part wasn't the canvas rendering — it was the integration between CodeSnap and the Composer.

The original approach was setTimeout:

// ❌ Old approach — fragile
vscode.commands.executeCommand('dotshare.openFullWebview', 'post', { platform });
setTimeout(() => {
    DotShareWebView.postMessage({ command: 'mediaAttached', mediaFiles: [{ ... }] });
}, 800);  // hope 800ms is enough...

This breaks on slow machines, on cold starts, and when the Composer is loading a saved draft.

The fix is a proper handshake. The Composer fires webviewReady when it mounts:

// app.ts (Composer WebView)
function onReady() {
    enableDragAndDrop(get<HTMLTextAreaElement>('post-text'));
    enableDragAndDrop(get<HTMLTextAreaElement>('blog-body'));
    send('webviewReady');  // ← "I'm alive, send me stuff"
}

if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', onReady, { once: true });
} else {
    onReady();
}

The extension host catches it and atomically delivers any pending snap via a FIFO queue:

// No double-delivery, handles rapid double-snap edge case
private _pendingSnaps: Array<{ filePath: string; fileName: string }> = [];

public static consumePendingSnap(): { filePath: string; fileName: string } | null {
    return CodeSnapPanel._instance?._pendingSnaps.shift() ?? null;
}

No race condition. No magic number timeouts. The image attaches the instant the Composer is ready — whether that's 200ms or 3 seconds.


Offline-First: No CDN

The VS Code webview CSP blocks external CDN requests by default — and that's correct behavior. I vendor all HL.js assets locally:

media/webview/vendor/
  highlight.min.js
  styles/
    atom-one-dark.min.css
    github-dark.min.css
    monokai.min.css
    dracula.min.css
    nord.min.css
    vs2015.min.css
    tokyo-night-dark.min.css
    github.min.css
    catppuccin-mocha.min.css

The _buildHtml() method resolves all of these to webview.asWebviewUri() paths and injects them as an XSS-safe JSON map:

html = html.replace(/\{\{THEME_CSS_MAP\}\}/g,
    JSON.stringify(themeCssMap)
        .replace(/</g, '\\u003c')
        .replace(/>/g, '\\u003e')
);

Works completely offline. No network requests. No CDN downtime issues.


The Result

Here's the full workflow:

  1. Select a function in your editor

  2. Right-click → DotShare: 📸 CodeSnap

  3. The CodeSnap panel opens beside your editor with a live preview

  4. Adjust theme, font size, padding, line numbers — instant re-render

  5. Click 🚀 Share → QuickPick: which platform?

  6. The Composer opens with the image already attached

  7. Write your caption, hit send

9 themes ship completely free: Atom One Dark, GitHub Dark, GitHub Light, Monokai, Dracula, Nord, VS2015, Tokyo Night, Catppuccin Mocha.


Install DotShare

The extension is free and open source.


What's Next

CodeSnap is v1 — there's plenty left to build:

  • Custom fonts: let users point to their own monospace font

  • Gradient backgrounds: mesh gradients instead of solid BG color

  • Multiple files: side-by-side code panels in one image

  • Animated GIF export: show code being written, line by line

If any of these sound useful — or if you hit a bug — open an issue on GitHub or drop a comment below.

And if you ship a post using CodeSnap, tag me. I want to see what you're building. 🚀


— Kareem (FreeRave), founder of DotSuite