| 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. */ |
| 14 | const SHOT_PAD = 4; |
| 15 | |
| 16 | /** @param {string} b64 */ |
| 17 | function 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 */ |
| 25 | function 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 | */ |
| 41 | function 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 | */ |
| 62 | async 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 | */ |
| 114 | async 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 | */ |
| 136 | async 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 | */ |
| 165 | async 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 | |
| 215 | const Shot = { cropShot, copyPickedShot, SHOT_PAD }; |
| 216 | |
| 217 | if (typeof module !== "undefined" && module.exports) { |
| 218 | module.exports = Shot; |
| 219 | } |