anvilsign in

collin/browser-terminal-extension

1// Cropping a picked element out of a tab screenshot and putting it on the
2// system clipboard.
3//
4// Why the clipboard rather than a file: the daemon runs on the same machine as
5// the browser, so the PNG the browser copies is the PNG an agent running in the
6// pty reads back when it sees a ^V. That gives us an image handoff without the
7// extension ever writing to disk or the daemon growing a file-upload path.
8//
9// Loaded by both the sidebar (a document) and the background worker (no DOM),
10// so nothing here may touch `document` outside a function that only the sidebar
11// calls. `createImageBitmap`, `OffscreenCanvas` and `btoa` exist in both.
12
13/** Padding around the element's box, in CSS pixels. Enough to see its edge. */
14const SHOT_PAD = 4;
15
16/** @param {string} b64 */
17function base64ToBytes(b64) {
18 const bin = atob(b64);
19 const out = new Uint8Array(bin.length);
20 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
21 return out;
22}
23
24/** @param {Uint8Array} bytes */
25function bytesToBase64(bytes) {
26 // Chunked: String.fromCharCode blows the argument limit on a whole PNG.
27 let s = "";
28 const CHUNK = 0x8000;
29 for (let i = 0; i < bytes.length; i += CHUNK) {
30 s += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
31 }
32 return btoa(s);
33}
34
35/**
36 * A `data:` URL is decoded by hand rather than with `fetch`, because a page's
37 * CSP can forbid fetching one and this code also runs injected into pages.
38 *
39 * @param {string} dataUrl
40 */
41function dataUrlBytes(dataUrl) {
42 const comma = dataUrl.indexOf(",");
43 if (comma < 0) throw new Error("not a data URL");
44 return base64ToBytes(dataUrl.slice(comma + 1));
45}
46
47/**
48 * Cut the element's box out of a full-viewport screenshot.
49 *
50 * The scale factor comes from the captured image's own width rather than
51 * devicePixelRatio: browser zoom, HiDPI and Chrome's own capture downscaling
52 * all move the two apart, and the ratio the image reports is the true one.
53 *
54 * @param {string} dataUrl PNG data URL of the visible tab
55 * @param {TbPickedRect} rect element box in CSS pixels, viewport-relative
56 * @param {{ w: number; h: number }} viewport CSS pixel size of the viewport
57 * @returns {Promise<Uint8Array>} PNG bytes
58 */
59async function cropShot(dataUrl, rect, viewport) {
60 const bmp = await createImageBitmap(new Blob([dataUrlBytes(dataUrl)], { type: "image/png" }));
61 const scale = viewport.w > 0 ? bmp.width / viewport.w : 1;
62
63 // Clamp to the viewport: an element can hang off the edge, and drawImage
64 // silently produces transparent pixels for the part that isn't there.
65 const left = Math.max(0, rect.x - SHOT_PAD);
66 const top = Math.max(0, rect.y - SHOT_PAD);
67 const right = Math.min(viewport.w, rect.x + rect.width + SHOT_PAD);
68 const bottom = Math.min(viewport.h, rect.y + rect.height + SHOT_PAD);
69
70 const sx = Math.round(left * scale);
71 const sy = Math.round(top * scale);
72 const sw = Math.round((right - left) * scale);
73 const sh = Math.round((bottom - top) * scale);
74 if (sw < 1 || sh < 1) throw new Error("element is not visible in the viewport");
75
76 const canvas = new OffscreenCanvas(sw, sh);
77 const ctx = canvas.getContext("2d");
78 if (!ctx) throw new Error("no 2d context");
79 ctx.drawImage(bmp, sx, sy, sw, sh, 0, 0, sw, sh);
80 bmp.close();
81
82 const blob = await canvas.convertToBlob({ type: "image/png" });
83 return new Uint8Array(await blob.arrayBuffer());
84}
85
86/**
87 * Put a PNG on the clipboard from inside the page.
88 *
89 * Serialised into the tab by executeScript, so it must be self-contained. It
90 * runs in the isolated world — page script can neither see it nor tamper with
91 * the `navigator.clipboard` it calls.
92 *
93 * The page is where this has to happen: the async clipboard API refuses to
94 * write from a document that isn't focused, and after you click an element the
95 * focused document is the page, not the side panel.
96 *
97 * @param {string} b64 base64 PNG
98 * @returns {Promise<true | string>} true, or the failure to report
99 */
100async function tbCopyPngInPage(b64) {
101 try {
102 const bin = atob(b64);
103 const bytes = new Uint8Array(bin.length);
104 for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
105 const blob = new Blob([bytes], { type: "image/png" });
106 await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
107 return true;
108 } catch (e) {
109 return e instanceof Error ? e.message : String(e);
110 }
111}
112
113/**
114 * Screenshot the picked element and leave it on the system clipboard.
115 *
116 * Three routes, in order of how little they can go wrong:
117 * 1. Firefox's `clipboard.setImageData`, which is an extension API and so
118 * needs neither a focused document nor a content script;
119 * 2. the calling document, when it happens to hold focus;
120 * 3. the page, via injection — the usual case right after a pick.
121 *
122 * @param {TbExtensionApi} api
123 * @param {number | undefined} tabId
124 * @param {TbPicked} picked
125 * @returns {Promise<true | string>} true, or why not
126 */
127async function copyPickedShot(api, tabId, picked) {
128 if (!picked.rect || !picked.viewport) return "no element geometry";
129 let png;
130 try {
131 const dataUrl = await api.tabs.captureVisibleTab({ format: "png" });
132 png = await cropShot(dataUrl, picked.rect, picked.viewport);
133 } catch (e) {
134 return e instanceof Error ? e.message : String(e);
135 }
136
137 if (api.clipboard?.setImageData) {
138 try {
139 // Firefox wants a plain ArrayBuffer, and a subarray's buffer may be larger.
140 const buf = /** @type {ArrayBuffer} */ (
141 png.buffer.slice(png.byteOffset, png.byteOffset + png.byteLength)
142 );
143 await api.clipboard.setImageData(buf, "png");
144 return true;
145 } catch (e) {
146 // Fall through — the injected route may still work.
147 }
148 }
149
150 const b64 = bytesToBase64(png);
151
152 if (typeof document !== "undefined" && document.hasFocus()) {
153 const direct = await tbCopyPngInPage(b64);
154 if (direct === true) return true;
155 }
156
157 if (tabId == null) return "no tab to copy from";
158 try {
159 const results = await api.scripting.executeScript({
160 target: { tabId },
161 func: tbCopyPngInPage,
162 args: [b64],
163 });
164 const r = results?.[0]?.result;
165 return r === true ? true : String(r ?? "clipboard write did not run");
166 } catch (e) {
167 return e instanceof Error ? e.message : String(e);
168 }
169}
170
171const Shot = { cropShot, copyPickedShot, SHOT_PAD };
172
173if (typeof module !== "undefined" && module.exports) {
174 module.exports = Shot;
175}