Skip to content
Closed
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
17 changes: 10 additions & 7 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -651,10 +651,13 @@ and no new endpoint.
## 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.
that are not in the DOM; line numbers never join a mouse selection and never appear in the image. **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. `imageframe.ts` wraps it in a
snippet card: a backdrop, a rounded window with window dots, the file name as the title and a shadow, always at 2x
(the scale drops only to fit the pixel cap). A Markdown preview selection is serialised into an SVG
`foreignObject` snapshot (`mdsnap.ts`) and gets the same frame; so does the macOS native snapshot, which posts
`copyimage` to the bridge. The result goes to the clipboard only: the ClipboardItem is created synchronously inside
the gesture with a promise of the blob, and nothing else is tried. Any failure shows a clear error
toast. Selections over 20,000 rows or 4,000 columns, or whose framed canvas would exceed 16M pixels even at scale 1,
are refused ("Selection too large for an image").
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,12 @@ Closing the app window quits codeviewer. Settings live in `~/.config/codeviewer/
## 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".
palette (*Copy Selection as Image*), or press ⌘⇧C / Ctrl+Shift+C. The image goes to the clipboard only and is
never written to disk. It is a share-ready snippet card: a backdrop, a rounded window with three window dots, the
file name as the title, a soft shadow, rendered at 2x. Code uses the current theme's syntax colours; line numbers
never join a selection, so they are never in the image. Markdown selections and the macOS app's native snapshot get
the same frame. If the image cannot be put on the clipboard, a clear error says so. Very large selections are
refused with "Selection too large for an image".

## Markdown

Expand Down
10 changes: 4 additions & 6 deletions packaging/macos/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ final class ViewerWindow: NSObject, NSWindowDelegate, WKNavigationDelegate, WKUI
// {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: "snapshotrect", id, x, y, w, h} snapshot a viewport rect (CSS px) and return it; replies window.__cvSnap(id, ok, pngBase64); the pasteboard is written by copyimage
// {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) {
Expand Down Expand Up @@ -270,19 +270,17 @@ final class ViewerWindow: NSObject, NSWindowDelegate, WKNavigationDelegate, WKUI
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) }
func reply(_ ok: Bool, _ b64: String = "") { webView.evaluateJavaScript("window.__cvSnap && window.__cvSnap(\(id), \(ok), \"\(b64)\")", 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.
// A cancelled (timed out) request must not 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))
reply(true, png.base64EncodedString())
}
return
}
Expand Down
30 changes: 13 additions & 17 deletions src/client/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ 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 { copyCodeImage, copyPngOrSnapshot, messageFor, MSG_NO_SELECTION } from "./copyimage.ts";
import { browserDeps, fragmentFromRange, framePng, markdownSnapDeps, snapshotDeps } from "./imagedom.ts";
import { snapshotMarkdown } from "./mdsnap.ts";
import type { ImageTheme } from "./imagelayout.ts";
import type { SelRange } from "./selection.ts";
Expand Down Expand Up @@ -552,6 +552,8 @@ class App {
return {
bg: cssVar("--bg-editor"),
fg: cssVar("--fg"),
muted: cssVar("--fg-muted"),
accent: cssVar("--accent"),
tokenColors,
tokenStyles: { c: { italic: true }, h: { bold: true }, b: { bold: true }, i: { italic: true } },
font,
Expand All @@ -569,27 +571,21 @@ class App {
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 title = tab.path ?? "";
const measure = deps.measure!;
const png = fragmentFromRange(range, tab.md.el, cssVar("--bg-editor")).then(async (f) => {
const r = await snapshotMarkdown(markdownSnapDeps, f);
if (!(r instanceof Blob)) throw new Error("snapshot failed");
return framePng(r, f.width, f.height, title, theme, measure);
});
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);
});
void copyPngOrSnapshot({ ...deps, ...snapshotDeps(nativeBridge()), compose: (p, r) => framePng(p, r.w, r.h, title, theme, measure) }, png, rects, vp).then((res) => notify(messageFor(res)));
return;
}
const input = tab?.code?.imageInput(snap?.code);
if (!input) return notify(MSG_NO_SELECTION);
void copyCodeImage(deps, { ...input, theme, dpr: window.devicePixelRatio || 1 });
void copyCodeImage(deps, { ...input, theme, title: tab?.path, dpr: window.devicePixelRatio || 1 });
}

private notify(msg: string) {
Expand Down
52 changes: 48 additions & 4 deletions src/client/codeview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ export class CodeView {
private hunks: Hunk[] = [];
private gitMarks: Uint8Array = new Uint8Array(0);
private blame: { line: number; html: string } | null = null;
/** Primary button held on a code line (drag-selection in progress); the blame label waits for mouseup. */
private pressed = false;
/** Line whose row currently carries the .blame span (-1 = none); render() rebuilds it with the markup. */
private shownBlame = -1;
private peek: HTMLElement | null = null;

// find state
Expand Down Expand Up @@ -142,6 +146,7 @@ export class CodeView {
}
});
this.linesEl.addEventListener("mousedown", (e) => {
this.pressed = false; // a new press supersedes a hold whose mouseup was lost
const ln = (e.target as HTMLElement).closest<HTMLElement>(".ln");
if (!ln) return;
const n = Number(ln.dataset.n);
Expand All @@ -153,8 +158,19 @@ export class CodeView {
this.togglePeek(n, ln);
return;
}
this.pressed = true;
this.setActiveLine(n);
});
const release = () => {
if (!this.pressed) return;
this.pressed = false;
this.syncBlame();
};
document.addEventListener("mouseup", release);
window.addEventListener("blur", release);
document.addEventListener("mousemove", (e) => {
if (this.pressed && e.buttons === 0) release(); // the button was released outside the page
});
this.scroller.addEventListener("scroll", () => this.closePeek(), { passive: true });
this.scroller.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "a") {
Expand All @@ -167,7 +183,10 @@ export class CodeView {
e.preventDefault();
e.clipboardData?.setData("text/plain", this.selectedText());
});
document.addEventListener("selectionchange", () => this.readDomSelection());
document.addEventListener("selectionchange", () => {
this.readDomSelection();
this.syncBlame(); // a selection hides the label only while it exists
});
// A primary press outside the lines (not on the context menu) makes the model range stale.
document.addEventListener("mousedown", (e) => {
const t = e.target as HTMLElement | null;
Expand Down Expand Up @@ -370,7 +389,26 @@ export class CodeView {
/** Inline blame annotation shown at the end of the active line. */
setBlame(line: number, html: string | null) {
this.blame = html === null ? null : { line, html };
this.schedule();
this.syncBlame();
}

private blameVisible(i: number): boolean {
return !!this.blame && this.blame.line === i && !this.pressed && window.getSelection?.()?.isCollapsed !== false;
}

private rowEl(i: number): HTMLElement | null {
return this.linesEl.querySelector<HTMLElement>?.(`.ln[data-n="${i}"]`) ?? null;
}

/** Patches the single .blame span in place; rows are never rebuilt (that would break a drag-selection). */
private syncBlame() {
if (this.shownBlame >= 0) this.rowEl(this.shownBlame)?.querySelector?.(".blame")?.remove();
this.shownBlame = -1;
if (!this.blame || !this.blameVisible(this.blame.line)) return;
const row = this.rowEl(this.blame.line);
if (!row) return;
row.insertAdjacentHTML("beforeend", `<span class="blame">${this.blame.html}</span>`);
this.shownBlame = this.blame.line;
}

private hunkAt(line: number): Hunk | undefined {
Expand Down Expand Up @@ -481,11 +519,13 @@ export class CodeView {
cls += " flash";
style = ` style="animation-delay:${-(now - this.flash.at) | 0}ms"`;
}
const blame = this.blame && this.blame.line === i ? `<span class="blame">${this.blame.html}</span>` : "";
const blame = this.blameVisible(i) ? `<span class="blame">${this.blame.html}</span>` : "";
html += `<div class="${cls}" data-n="${i}"${style}><span class="g">${i + 1}</span><span class="tx">${this.lineHtml(i)}</span>${blame}</div>`;
}
this.linesEl.style.transform = `translateY(${first * this.lh}px)`;
this.linesEl.innerHTML = html;
const bl = this.blame?.line ?? -1;
this.shownBlame = bl >= first && bl < last && this.blameVisible(bl) ? bl : -1;
this.restoreSelection();
}

Expand Down Expand Up @@ -539,9 +579,13 @@ export class CodeView {
}

setActiveLine(n: number) {
const old = this.activeLine;
this.activeLine = n;
this.onActiveLine(n);
this.schedule();
if (old !== n) {
if (old >= 0) this.rowEl(old)?.classList?.remove?.("active");
this.rowEl(n)?.classList?.add?.("active");
}
}

/** Scrolls so that 0-based `line` is visible (centered when far away). */
Expand Down
Loading
Loading