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.

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:
Select a function in your editor
Right-click → DotShare: 📸 CodeSnap
The CodeSnap panel opens beside your editor with a live preview
Adjust theme, font size, padding, line numbers — instant re-render
Click 🚀 Share → QuickPick: which platform?
The Composer opens with the image already attached
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.
VS Code Marketplace: marketplace.visualstudio.com/items?itemName=FreeRave.dotshare
Open VSX (VSCodium / Windsurf / Cursor): open-vsx.org/extension/freerave/dotshare
GitHub: github.com/kareem2099/DotShare
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



