Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a819947
feat(selection): pure model range, text extraction and restore plan
jkyberneees Oct 29, 2026
07c27e3
feat(codeview): keep selection across re-renders, file-wide select-al…
jkyberneees Oct 29, 2026
915827b
fix(codeview): keep the full range when the restore's own selectionch…
jkyberneees Oct 29, 2026
6f2aabe
feat(image): pure code-image layout with theme colours, tabs and size…
jkyberneees Oct 29, 2026
dda8f24
fix(image): clamp range columns and keep tab stops on partial lines
jkyberneees Oct 29, 2026
39f6131
feat(image): copy code selection as PNG with clipboard, native and do…
jkyberneees Oct 29, 2026
dcdda36
fix(image): call clipboard.write inside the gesture and preload the font
jkyberneees Oct 29, 2026
9d7e141
feat(markdown): copy rendered markdown selection as PNG via SVG snapshot
jkyberneees Oct 29, 2026
aa0915c
fix(markdown): embed fonts, size to the selection and inline images i…
jkyberneees Oct 29, 2026
35f13b1
fix(markdown): prune snapshot to the range without index drift; all f…
jkyberneees Oct 29, 2026
9e69713
feat(copy): context menu, palette command and shortcut for Copy as Image
jkyberneees Oct 29, 2026
1b2419c
fix(copy): keep selection on right-click, palette snapshot, skip text…
jkyberneees Oct 29, 2026
1731044
fix(codeview): keep the selection on ctrl+click so the context menu o…
jkyberneees Oct 29, 2026
c79b99a
fix(codeview): drop stale ranges, keep active line on context clicks,…
jkyberneees Oct 29, 2026
ca9bb3a
fix(codeview): only selections and clicks outside the view drop the r…
jkyberneees Oct 29, 2026
97d4efc
fix(copy): capture the selection when the menu opens; tested font fac…
jkyberneees Oct 29, 2026
da4bc20
fix(copy): menu Copy uses the captured selection; captured Range is a…
jkyberneees Oct 29, 2026
5374911
feat(macos): native snapshot fallback when the WebKit canvas is tainted
jkyberneees Oct 29, 2026
894e016
fix(macos): tag snapshot requests, reject non-finite rects, cancel la…
jkyberneees Oct 29, 2026
977106a
fix(macos): each snapshot timeout cancels its own request; supersede …
jkyberneees Oct 29, 2026
0a1a5bc
fix(macos): only the request that hid the selection may restore it
jkyberneees Oct 29, 2026
f030a86
test(codeview): strip tags until stable in the row parser helper
jkyberneees Oct 29, 2026
3076c1e
test(codeview): drop stray '<' after stripping tags in the row parser…
jkyberneees Oct 29, 2026
b69a099
test(codeview): read row text by skipping tags instead of regex strip…
jkyberneees Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -647,3 +647,14 @@ and no new endpoint.
5. Fenced code with `lexLine` highlighting, size caps, info-string allowlist.
6. Images (relative via blob, remote placeholder), size gate with "Render anyway", palette command, README and
SECURITY notes, fuzz test.

## 15. Copy as Image

The code view keeps its selection as a model range (line/column), so Select All, copy and image export cover lines
that are not in the DOM and never include decorations (gutter, "more characters"). **Copy as Image** (right-click
menu with a selection, palette command `selection.copyImage`, Cmd/Ctrl+Shift+C; not in the terminal) lays the range
out as canvas draw ops (`imagelayout.ts`, pure) in the theme colours and writes a PNG; a Markdown preview selection
is serialised into an SVG `foreignObject` snapshot (`mdsnap.ts`). The ClipboardItem is created synchronously inside
the gesture with a promise of the blob. Fallbacks: macOS app posts `copyimage` to the native bridge, no image
clipboard downloads the PNG, a render failure copies plain text; selections over 20,000 rows or 4,000 columns, or whose canvas would exceed 16M pixels even at scale 1, are refused (the scale drops from 2 to fit). Toasts reuse the
app toast.
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,18 @@ Closing the app window quits codeviewer. Settings live in `~/.config/codeviewer/
| ⌘W · ⌘⇧[ ⌘⇧] | Close tab · switch tabs |
| ⌘= ⌘- ⌘0 | Font size |
| ⌘⇧G · ⌥F5 / ⌥⇧F5 | Changed files · next / previous change in file |
| ⌘⇧C / Ctrl+Shift+C | Copy the selection as an image (PNG) |
| ⌘-click in terminal | Open `path:line` or URL |
| Left ⌥ | Meta in the terminal (right ⌥ still types special characters) |

## Copy as Image

Select code (or text in a Markdown preview) and choose **Copy as Image** from the right-click menu, the command
palette (*Copy Selection as Image*), or press ⌘⇧C / Ctrl+Shift+C. Code is drawn with the current theme's syntax
colours (no line numbers); a preview selection is rendered as it looks on screen. If the clipboard does not accept
images the PNG is downloaded instead; if rendering fails the plain text is copied and a message says so. Very large
selections are refused with "Selection too large for an image".

## Markdown

`.md` and `.markdown` files open as a rendered document; the **View source** button (or the command
Expand Down
35 changes: 35 additions & 0 deletions packaging/macos/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ final class AppWebView: WKWebView {
}

final class ViewerWindow: NSObject, NSWindowDelegate, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler {
private var cancelledSnaps = Set<Int>()
let window: NSWindow
let webView: AppWebView
let server: Server
Expand Down Expand Up @@ -240,6 +241,9 @@ final class ViewerWindow: NSObject, NSWindowDelegate, WKNavigationDelegate, WKUI
// Messages from the page:
// {t: "theme", dark, bg} sync the window appearance
// {t: "drag"} mouse-down on empty title bar: move the window
// {t: "copyimage", png} PNG (base64) to put on the general pasteboard
// {t: "snapshotrect", id, x, y, w, h} snapshot a viewport rect (CSS px) to the pasteboard; replies window.__cvSnap(id, ok)
// {t: "snapshotcancel", id} timed out: a late snapshot for id is dropped
// {t: "dblclick"} double-click on the title bar: system zoom/minimize action
func userContentController(_ ucc: WKUserContentController, didReceive message: WKScriptMessage) {
guard let body = message.body as? [String: Any], let t = body["t"] as? String else { return }
Expand All @@ -251,6 +255,37 @@ final class ViewerWindow: NSObject, NSWindowDelegate, WKNavigationDelegate, WKUI
(NSApp.delegate as? AppDelegate)?.relaunch()
return
}
if t == "copyimage" {
if let b64 = body["png"] as? String, let data = Data(base64Encoded: b64) {
let pb = NSPasteboard.general
pb.clearContents()
pb.setData(data, forType: .png)
}
return
}
if t == "snapshotcancel" {
if let id = (body["id"] as? NSNumber)?.intValue { cancelledSnaps.insert(id) }
return
}
if t == "snapshotrect" {
guard let id = (body["id"] as? NSNumber)?.intValue else { return }
func num(_ k: String) -> Double? { (body[k] as? NSNumber)?.doubleValue }
func reply(_ ok: Bool) { webView.evaluateJavaScript("window.__cvSnap && window.__cvSnap(\(id), \(ok))", completionHandler: nil) }
guard let x = num("x"), let y = num("y"), let w = num("w"), let h = num("h"),
x.isFinite, y.isFinite, w.isFinite, h.isFinite, w > 0, h > 0 else { return reply(false) }
let cfg = WKSnapshotConfiguration()
cfg.rect = CGRect(x: x, y: y, width: w, height: h)
webView.takeSnapshot(with: cfg) { [weak self] image, _ in
// A cancelled (timed out) request must not touch the pasteboard or reply.
if self?.cancelledSnaps.remove(id) != nil { return }
guard let image = image, let tiff = image.tiffRepresentation,
let png = NSBitmapImageRep(data: tiff)?.representation(using: .png, properties: [:]) else { return reply(false) }
let pb = NSPasteboard.general
pb.clearContents()
reply(pb.setData(png, forType: .png))
}
return
}
if t == "dblclick" {
switch UserDefaults.standard.string(forKey: "AppleActionOnDoubleClick") {
case "Minimize": window.performMiniaturize(nil)
Expand Down
93 changes: 91 additions & 2 deletions src/client/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,15 @@ import { icons } from "./icons.ts";
import { GitState, relativeTime } from "./git.ts";
import { Updates } from "./updates.ts";
import { debounce, dragSplitter, esc, formatBytes, h, IS_MAC, MOD, store } from "./util.ts";
import { isContextMenuOpen } from "./menu.ts";
import { isContextMenuOpen, showContextMenu } from "./menu.ts";
import { bindEditorSelectionMenu, readSelection, snapshotText, copyImageCommand, selectionSnapshot, type SelectionHost } from "./copyentry.ts";
import { copyCodeImage, copyPngOrSnapshot, MSG_DOWNLOADED, MSG_FAILED, MSG_NO_SELECTION, MSG_OK } from "./copyimage.ts";
import { browserDeps, fragmentFromRange, markdownSnapDeps, snapshotDeps } from "./imagedom.ts";
import { snapshotMarkdown } from "./mdsnap.ts";
import type { ImageTheme } from "./imagelayout.ts";
import type { SelRange } from "./selection.ts";
import { SearchView } from "./search.ts";
import { normSideView, searchKey, type SideView } from "./keys.ts";
import { copyImageKey, normSideView, searchKey, type SideView } from "./keys.ts";

type ThemeName = "dark" | "light" | "system";

Expand Down Expand Up @@ -222,6 +228,7 @@ class App {
if (t) this.trackRecent(t.path);
};
this.editor.onStatus = (s) => this.renderStatus(s);
bindEditorSelectionMenu(this.editor, this.selectionHost, showContextMenu);
this.editor.onOpenLink = async (p, hash) => {
const type = await api.stat(p);
if (type === "file") {
Expand Down Expand Up @@ -313,6 +320,12 @@ class App {
this.palette.onOpenFile = (p, line) => void this.openFile(p, { pin: true, line });
this.palette.onGoToLine = (n) => this.editor.goToLine(n);
this.palette.commands = this.commands();
// Opening the palette moves focus into its input; remember what was selected so its command still has it.
const openPalette = this.palette.open.bind(this.palette);
this.palette.open = (prefix) => {
this.paletteSel.take();
openPalette(prefix);
};

window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => this.applyTheme());
this.applyLayout();
Expand All @@ -338,6 +351,7 @@ class App {
c("search.next", "Next Search Result", () => this.search.step(1), "F4"),
c("search.prev", "Previous Search Result", () => this.search.step(-1), "⇧F4"),
c("view.explorer", "Show Explorer", () => this.setSideView("explorer")),
copyImageCommand({ copyImage: () => this.copyImage(this.paletteSel.value()) }, IS_MAC ? "⌘⇧C" : "Ctrl+Shift+C"),
markdownToggleCommand({ isMarkdownTab: () => this.editor.isMarkdownTab(), toggleMarkdown: () => this.editor.toggleMarkdown() }),
c("file.find", "Find in File", () => this.editor.openFind(), `${MOD}F`),
c("file.line", "Go to Line…", () => this.palette.open(":"), IS_MAC ? "⌃G" : "Ctrl+G"),
Expand Down Expand Up @@ -510,6 +524,74 @@ class App {

private saveLayout = debounce(() => store.set("layout", { ...this.L, v: LAYOUT_VERSION }), 200);

// ───────────────────────────── copy as image ─────────────────────────────

private readSel = () => readSelection<SelRange, Range>(this.editor.active);

private paletteSel = selectionSnapshot<{ code?: SelRange; md?: Range }>(this.readSel);

private selectionHost: SelectionHost = {
capture: () => this.readSel(),
hasSelection: (snap) => {
if (snap !== undefined) return snap !== null;
const t = this.editor.active;
return t?.md ? !!t.md.selectionRange() : !!t?.code?.selection;
},
copy: (snap) => {
const s = (snap ?? this.readSel()) as { code?: SelRange; md?: Range } | null;
const t = snapshotText(this.editor.active, s);
if (t) void navigator.clipboard?.writeText(t).catch(() => {});
},
copyImage: (snap) => this.copyImage((snap as { code?: SelRange; md?: Range } | null | undefined) ?? null),
};

private imageTheme(): ImageTheme {
const tokenColors: Record<string, string> = {};
for (const t of "kscnftpovmhaxl") tokenColors[t] = cssVar(`--sx-${t}`);
const font = "JetBrains Mono";
return {
bg: cssVar("--bg-editor"),
fg: cssVar("--fg"),
tokenColors,
tokenStyles: { c: { italic: true }, h: { bold: true }, b: { bold: true }, i: { italic: true } },
font,
size: this.L.codeSize,
};
}

/** Must run synchronously from the click/keydown handler: nothing is awaited before the clipboard write. */
private copyImage(snap: { code?: SelRange; md?: Range } | null = null) {
const tab = this.editor.active;
this.paletteSel.clear();
const notify = (m: string) => this.notify(m);
const theme = this.imageTheme();
const deps = browserDeps({ font: theme.font, size: theme.size, bridge: nativeBridge(), notify });
if (tab?.md) {
const range = snap?.md ?? tab.md.selectionRange();
if (!range) return notify(MSG_NO_SELECTION);
const text = range.toString();
const png = fragmentFromRange(range, tab.md.el, cssVar("--bg-editor"))
.then((f) => snapshotMarkdown(markdownSnapDeps, f))
.then((r) => {
if (!(r instanceof Blob)) throw new Error("snapshot failed");
return r;
});
const rects = Array.from(range.getClientRects());
const vp = { width: window.innerWidth, height: window.innerHeight };
void copyPngOrSnapshot({ ...deps, ...snapshotDeps(nativeBridge()) }, png, rects, vp).then(async (res) => {
if (res === "failed") {
await Promise.resolve(deps.copyText(text)).catch(() => {});
return notify(MSG_FAILED);
}
notify(res === "downloaded" ? MSG_DOWNLOADED : MSG_OK);
});
return;
}
const input = tab?.code?.imageInput(snap?.code);
if (!input) return notify(MSG_NO_SELECTION);
void copyCodeImage(deps, { ...input, theme, dpr: window.devicePixelRatio || 1 });
}

private notify(msg: string) {
this.toast.textContent = msg;
this.toast.hidden = false;
Expand Down Expand Up @@ -597,6 +679,13 @@ class App {
const k = e.key.toLowerCase();
const inTerm = (e.target as HTMLElement).classList?.contains("term-input");
let handled = true;
if (copyImageKey(e, { isMac: IS_MAC, inTerm: !!inTerm })) {
// Runs synchronously inside the keydown so the clipboard write keeps the user gesture.
this.copyImage();
e.preventDefault();
e.stopPropagation();
return;
}
const sk = searchKey(e, { isMac: IS_MAC, inTerm: !!inTerm });
if (sk === "search") this.showSearch();
else if (sk === "next" || sk === "prev") this.search.step(sk === "next" ? 1 : -1);
Expand Down
Loading
Loading