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 size 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. It
53 * is read off the *height*, because a split view can only disagree about width
54 * — the two panes sit side by side and share the window's height.
55 *
56 * @param {string} dataUrl PNG data URL of the visible tab
57 * @param {TbPickedRect} rect element box in CSS pixels, viewport-relative
58 * @param {{ w: number; h: number }} viewport CSS pixel size of the viewport
59 * @param {TbPane} [pane] where the viewport sits in the window, for split views
60 * @returns {Promise<Uint8Array>} PNG bytes
61 */
62async function cropShot(dataUrl, rect, viewport, pane) {
63 const bmp = await createImageBitmap(new Blob([dataUrlBytes(dataUrl)], { type: "image/png" }));
64 const scale = viewport.h > 0 ? bmp.height / viewport.h : 1;
65
66 // A capture wider than the viewport is the whole window with the other half
67 // of a split view in it. The pick's pane is flush against one edge of that
68 // image, and its distance from the window's own left edge says which — the
69 // left pane starts at ~0, the right one hundreds of pixels in, so the
70 // half-width threshold holds even though zoom scales screenX and not `left`.
71 let offsetX = 0;
72 if (bmp.width - viewport.w * scale > 2 && pane?.screenX != null && pane.winLeft != null) {
73 const fromLeft = pane.screenX - pane.winLeft;
74 if (fromLeft > bmp.width / scale / 2) offsetX = bmp.width - viewport.w * scale;
75 }
76
77 // Clamp to the viewport: an element can hang off the edge, and drawImage
78 // silently produces transparent pixels for the part that isn't there.
79 const left = Math.max(0, rect.x - SHOT_PAD);
80 const top = Math.max(0, rect.y - SHOT_PAD);
81 const right = Math.min(viewport.w, rect.x + rect.width + SHOT_PAD);
82 const bottom = Math.min(viewport.h, rect.y + rect.height + SHOT_PAD);
83
84 const sx = Math.round(offsetX + left * scale);
85 const sy = Math.round(top * scale);
86 const sw = Math.round((right - left) * scale);
87 const sh = Math.round((bottom - top) * scale);
88 if (sw < 1 || sh < 1) throw new Error("element is not visible in the viewport");
89
90 const canvas = new OffscreenCanvas(sw, sh);
91 const ctx = canvas.getContext("2d");
92 if (!ctx) throw new Error("no 2d context");
93 ctx.drawImage(bmp, sx, sy, sw, sh, 0, 0, sw, sh);
94 bmp.close();
95
96 const blob = await canvas.convertToBlob({ type: "image/png" });
97 return new Uint8Array(await blob.arrayBuffer());
98}
99
100/**
101 * Put a PNG on the clipboard from inside the page.
102 *
103 * Serialised into the tab by executeScript, so it must be self-contained. It
104 * runs in the isolated world — page script can neither see it nor tamper with
105 * the `navigator.clipboard` it calls.
106 *
107 * The page is where this has to happen: the async clipboard API refuses to
108 * write from a document that isn't focused, and after you click an element the
109 * focused document is the page, not the side panel.
110 *
111 * @param {string} b64 base64 PNG
112 * @returns {Promise<true | string>} true, or the failure to report
113 */
114async function tbCopyPngInPage(b64) {
115 try {
116 const bin = atob(b64);
117 const bytes = new Uint8Array(bin.length);
118 for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
119 const blob = new Blob([bytes], { type: "image/png" });
120 await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
121 return true;
122 } catch (e) {
123 return e instanceof Error ? e.message : String(e);
124 }
125}
126
127/**
128 * Pair the pick's on-screen position with its window's, so cropShot can tell
129 * the two halves of a split view apart. Best-effort: without it the crop just
130 * assumes the capture covers the pane alone, which is what it does today.
131 *
132 * @param {TbExtensionApi} api
133 * @param {TbPicked} picked
134 * @returns {Promise<TbPane | undefined>}
135 */
136async function paneOf(api, picked) {
137 if (picked.screenX == null) return undefined;
138 try {
139 // The worker has no window of its own; the shortcut acted on the focused
140 // one, which is where the pick came from.
141 const win = typeof document !== "undefined"
142 ? await api.windows.getCurrent()
143 : await api.windows.getLastFocused();
144 if (win?.left == null) return undefined;
145 return { screenX: picked.screenX, winLeft: win.left };
146 } catch {
147 return undefined;
148 }
149}
150
151/**
152 * Screenshot the picked element and leave it on the system clipboard.
153 *
154 * Three routes, in order of how little they can go wrong:
155 * 1. Firefox's `clipboard.setImageData`, which is an extension API and so
156 * needs neither a focused document nor a content script;
157 * 2. the calling document, when it happens to hold focus;
158 * 3. the page, via injection — the usual case right after a pick.
159 *
160 * @param {TbExtensionApi} api
161 * @param {number | undefined} tabId
162 * @param {TbPicked} picked
163 * @returns {Promise<true | string>} true, or why not
164 */
165async function copyPickedShot(api, tabId, picked) {
166 if (!picked.rect || !picked.viewport) return "no element geometry";
167 let png;
168 try {
169 // captureVisibleTab takes no tab: it shoots whatever is *active*. In a
170 // split view the pick may well have landed in the other half, so activate
171 // the tab it came from first. Outside a split this is the tab that is
172 // already active and the call does nothing — except help the clipboard
173 // write below, which needs the page focused.
174 if (tabId != null) await api.tabs.update(tabId, { active: true });
175 const dataUrl = await api.tabs.captureVisibleTab({ format: "png" });
176 png = await cropShot(dataUrl, picked.rect, picked.viewport, await paneOf(api, picked));
177 } catch (e) {
178 return e instanceof Error ? e.message : String(e);
179 }
180
181 if (api.clipboard?.setImageData) {
182 try {
183 // Firefox wants a plain ArrayBuffer, and a subarray's buffer may be larger.
184 const buf = /** @type {ArrayBuffer} */ (
185 png.buffer.slice(png.byteOffset, png.byteOffset + png.byteLength)
186 );
187 await api.clipboard.setImageData(buf, "png");
188 return true;
189 } catch (e) {
190 // Fall through — the injected route may still work.
191 }
192 }
193
194 const b64 = bytesToBase64(png);
195
196 if (typeof document !== "undefined" && document.hasFocus()) {
197 const direct = await tbCopyPngInPage(b64);
198 if (direct === true) return true;
199 }
200
201 if (tabId == null) return "no tab to copy from";
202 try {
203 const results = await api.scripting.executeScript({
204 target: { tabId },
205 func: tbCopyPngInPage,
206 args: [b64],
207 });
208 const r = results?.[0]?.result;
209 return r === true ? true : String(r ?? "clipboard write did not run");
210 } catch (e) {
211 return e instanceof Error ? e.message : String(e);
212 }
213}
214
215const Shot = { cropShot, copyPickedShot, SHOT_PAD };
216
217if (typeof module !== "undefined" && module.exports) {
218 module.exports = Shot;
219}