| 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 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 | */ |
| 59 | async 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 | */ |
| 100 | async 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 | */ |
| 127 | async 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 | |
| 171 | const Shot = { cropShot, copyPickedShot, SHOT_PAD }; |
| 172 | |
| 173 | if (typeof module !== "undefined" && module.exports) { |
| 174 | module.exports = Shot; |
| 175 | } |