Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 0 additions & 11 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -647,14 +647,3 @@ 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: 2 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,17 +87,12 @@ 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
## Selection and copy

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".
Select code in the viewer as you would in any editor, dragging in either direction. Copy (Cmd/Ctrl+C or the context menu) puts the exact text on the clipboard, including parts scrolled out of view, and never includes line numbers, the blame label or the truncation label on very long lines. Cmd/Ctrl+A selects the whole file while focus is in the code view. The blame label stays out of the way while you drag or have text selected.

## Markdown

Expand Down
35 changes: 0 additions & 35 deletions packaging/macos/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,6 @@ 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 @@ -241,9 +240,6 @@ 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 @@ -255,37 +251,6 @@ 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: 2 additions & 91 deletions src/client/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,9 @@ 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, 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 { isContextMenuOpen } from "./menu.ts";
import { SearchView } from "./search.ts";
import { copyImageKey, normSideView, searchKey, type SideView } from "./keys.ts";
import { normSideView, searchKey, type SideView } from "./keys.ts";

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

Expand Down Expand Up @@ -228,7 +222,6 @@ 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 @@ -320,12 +313,6 @@ 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 @@ -351,7 +338,6 @@ 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 @@ -524,74 +510,6 @@ 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 @@ -679,13 +597,6 @@ 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