Scatta coverage
100%
ran
ran, but a branch never did
never ran
options.js
100%lines
100%branches
100%functions
| 1 | 6× | // Scatta settings page. The popup reads these from chrome.storage. |
| 2 | 6× | |
| 3 | 6× | const DEFAULTS = { defaultMode: "visible", autoClose: false, autoCloseDelay: 2 }; |
| 4 | 6× | |
| 5 | 6× | const defaultModeSelect = document.getElementById("default-mode"); |
| 6 | 6× | const autoCloseToggle = document.getElementById("auto-close"); |
| 7 | 6× | const delaySelect = document.getElementById("auto-close-delay"); |
| 8 | 6× | const delayRow = document.getElementById("delay-row"); |
| 9 | 6× | const savedEl = document.getElementById("saved"); |
| 10 | 6× | |
| 11 | 6× | // The delay only matters when auto-close is on. |
| 12 | 9× | function updateDelayState() { |
| 13 | 9× | delaySelect.disabled = !autoCloseToggle.checked; |
| 14 | 9× | delayRow.className = autoCloseToggle.checked ? "row" : "row disabled"; |
| 15 | 9× | } |
| 16 | 6× | |
| 17 | 6× | chrome.storage.local.get(DEFAULTS).then(({ defaultMode, autoClose, autoCloseDelay }) => { |
| 18 | 6× | defaultModeSelect.value = defaultMode; |
| 19 | 6× | autoCloseToggle.checked = autoClose; |
| 20 | 6× | delaySelect.value = String(autoCloseDelay); |
| 21 | 6× | updateDelayState(); |
| 22 | 6× | }); |
| 23 | 6× | |
| 24 | 3× | async function save() { |
| 25 | 3× | updateDelayState(); |
| 26 | 3× | await chrome.storage.local.set({ |
| 27 | 3× | defaultMode: defaultModeSelect.value, |
| 28 | 3× | autoClose: autoCloseToggle.checked, |
| 29 | 3× | autoCloseDelay: Number(delaySelect.value) |
| 30 | 3× | }); |
| 31 | 3× | savedEl.textContent = "Saved."; |
| 32 | 3× | } |
| 33 | 6× | |
| 34 | 6× | defaultModeSelect.addEventListener("change", save); |
| 35 | 6× | autoCloseToggle.addEventListener("change", save); |
| 36 | 6× | delaySelect.addEventListener("change", save); |
| 37 | |
popup.js
100%lines
97.5%branches
96.9%functions
| 1 | 44× | // Scatta: copies a screenshot of the current tab to the clipboard. |
| 2 | 44× | |
| 3 | 44× | const fullBtn = document.getElementById("full"); |
| 4 | 44× | const visibleBtn = document.getElementById("visible"); |
| 5 | 44× | const statusEl = document.getElementById("status"); |
| 6 | 44× | const saveToggle = document.getElementById("save-file"); |
| 7 | 44× | const toggleHint = document.getElementById("toggle-hint"); |
| 8 | 44× | const settingsLink = document.getElementById("settings"); |
| 9 | 44× | |
| 10 | 44× | // Pages taller than this (in image pixels) get a gentle warning, |
| 11 | 44× | // because big images are shrunk when pasted into a chat. |
| 12 | 44× | const TALL_WARNING_PX = 5000; |
| 13 | 44× | |
| 14 | 44× | // Set on the settings page (options.html). |
| 15 | 44× | const settings = { autoClose: false, autoCloseDelay: 2 }; |
| 16 | 44× | let closeTimer = null; |
| 17 | 44× | |
| 18 | 45× | function updateLabels() { |
| 19 | 45× | const save = saveToggle.checked; |
| 20 | 45× | document.querySelectorAll(".action").forEach((el) => { |
| 21 | 90× | el.textContent = save ? "Save" : "Copy"; |
| 22 | 45× | }); |
| 23 | 45× | toggleHint.textContent = save ? "On: saves a PNG to Downloads" : "Off: copies to the clipboard"; |
| 24 | 45× | } |
| 25 | 44× | |
| 26 | 44× | // Makes full page the first, highlighted button, so Enter takes it. |
| 27 | 1× | function useFullPageAsDefault() { |
| 28 | 1× | visibleBtn.before(fullBtn); |
| 29 | 1× | fullBtn.className = "btn primary"; |
| 30 | 1× | visibleBtn.className = "btn"; |
| 31 | 1× | fullBtn.focus(); |
| 32 | 1× | } |
| 33 | 44× | |
| 34 | 44× | chrome.storage.local.get({ saveFile: false, defaultMode: "visible", ...settings }).then(({ saveFile, defaultMode, autoClose, autoCloseDelay }) => { |
| 35 | 44× | saveToggle.checked = saveFile; |
| 36 | 44× | if (defaultMode === "full") useFullPageAsDefault(); |
| 37 | 44× | settings.autoClose = autoClose; |
| 38 | 44× | settings.autoCloseDelay = autoCloseDelay; |
| 39 | 44× | updateLabels(); |
| 40 | 44× | }); |
| 41 | 44× | |
| 42 | 44× | settingsLink.addEventListener("click", (e) => { |
| 43 | 1× | e.preventDefault(); |
| 44 | 1× | chrome.runtime.openOptionsPage(); |
| 45 | 44× | }); |
| 46 | 44× | |
| 47 | 44× | // Any click or key press means the user wants to do something else, |
| 48 | 44× | // so don't close the popup on them. |
| 49 | 26× | function cancelClose() { |
| 50 | 26× | clearTimeout(closeTimer); |
| 51 | 26× | closeTimer = null; |
| 52 | 26× | } |
| 53 | 44× | document.addEventListener("pointerdown", cancelClose); |
| 54 | 44× | document.addEventListener("keydown", cancelClose); |
| 55 | 44× | |
| 56 | 44× | saveToggle.addEventListener("change", () => { |
| 57 | 1× | chrome.storage.local.set({ saveFile: saveToggle.checked }); |
| 58 | 1× | updateLabels(); |
| 59 | 1× | setStatus(""); |
| 60 | 44× | }); |
| 61 | 44× | |
| 62 | 42× | function setStatus(text, kind = "") { |
| 63 | 42× | statusEl.textContent = text; |
| 64 | 42× | statusEl.className = kind; |
| 65 | 42× | } |
| 66 | 44× | |
| 67 | 40× | function setBusy(busy) { |
| 68 | 40× | fullBtn.disabled = busy; |
| 69 | 40× | visibleBtn.disabled = busy; |
| 70 | 40× | } |
| 71 | 44× | |
| 72 | 20× | async function getActiveTab() { |
| 73 | 20× | const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); |
| 74 | 20× | if (!tab) throw new Error("No active tab found."); |
| 75 | 19× | return tab; |
| 76 | 20× | } |
| 77 | 44× | |
| 78 | 18× | function base64ToPngBlob(base64) { |
| 79 | 18× | const bytes = atob(base64); |
| 80 | 18× | const arr = new Uint8Array(bytes.length); |
| 81 | 18× | for (let i = 0; i < bytes.length; i++) arr[i] = bytes.charCodeAt(i); |
| 82 | 18× | return new Blob([arr], { type: "image/png" }); |
| 83 | 18× | } |
| 84 | 44× | |
| 85 | 13× | async function copyBlob(blob) { |
| 86 | 13× | await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]); |
| 87 | 13× | } |
| 88 | 44× | |
| 89 | 7× | function makeFileName(tab) { |
| 90 | 7× | let host = "page"; |
| 91 | 7× | try { host = new URL(tab.url).hostname.replace(/^www\./, ""); } catch (_) {} |
| 92 | 7× | const d = new Date(); |
| 93 | 7× | const pad = (n) => String(n).padStart(2, "0"); |
| 94 | 7× | const stamp = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}_${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`; |
| 95 | 7× | return `scatta/${host}_${stamp}.png`; |
| 96 | 7× | } |
| 97 | 44× | |
| 98 | 4× | async function saveBlob(blob, tab) { |
| 99 | 4× | const url = URL.createObjectURL(blob); |
| 100 | 4× | try { |
| 101 | 4× | return await chrome.downloads.download({ url, filename: makeFileName(tab), saveAs: false }); |
| 102 | 4× | } finally { |
| 103 | 4× | // Give Chrome time to read the file before freeing it. |
| 104 | 4× | setTimeout(() => URL.revokeObjectURL(url), 60000); |
| 105 | 4× | } |
| 106 | 4× | } |
| 107 | 44× | |
| 108 | 44× | // Resolves with the download's final state ("complete" or "interrupted"). |
| 109 | 44× | // Closing the popup frees the image, so a big file must finish first. |
| 110 | 2× | function waitForDownload(id) { |
| 111 | 2× | return new Promise((resolve) => { |
| 112 | 2× | const finish = (state) => { |
| 113 | 2× | chrome.downloads.onChanged.removeListener(onChanged); |
| 114 | 2× | resolve(state); |
| 115 | 2× | }; |
| 116 | 2× | const onChanged = (delta) => { |
| 117 | 1× | if (delta.id === id && delta.state && delta.state.current !== "in_progress") { |
| 118 | 1× | finish(delta.state.current); |
| 119 | 1× | } |
| 120 | 2× | }; |
| 121 | 2× | chrome.downloads.onChanged.addListener(onChanged); |
| 122 | 2× | // It may already be done before the listener was added. |
| 123 | 2× | chrome.downloads.search({ id }).then(([item]) => { |
| 124 | 2× | if (item && item.state !== "in_progress") finish(item.state); |
| 125 | 2× | }); |
| 126 | 2× | }); |
| 127 | 2× | } |
| 128 | 44× | |
| 129 | 5× | async function closeWhenDone(downloadId) { |
| 130 | 5× | if (downloadId !== undefined) { |
| 131 | 2× | const state = await waitForDownload(downloadId); |
| 132 | 2× | if (state !== "complete") { |
| 133 | 1× | setStatus(friendlyError("Download interrupted"), "error"); |
| 134 | 1× | return; |
| 135 | 1× | } |
| 136 | 2× | } |
| 137 | 4× | cancelClose(); |
| 138 | 4× | closeTimer = setTimeout(() => window.close(), settings.autoCloseDelay * 1000); |
| 139 | 5× | } |
| 140 | 44× | |
| 141 | 14× | async function describeSize(blob) { |
| 142 | 14× | const img = await createImageBitmap(blob); |
| 143 | 14× | const size = { width: img.width, height: img.height }; |
| 144 | 14× | img.close(); |
| 145 | 14× | return size; |
| 146 | 14× | } |
| 147 | 44× | |
| 148 | 44× | // Runs in the page. Works out how tall the page would be if nothing |
| 149 | 44× | // scrolled inside its own box (like an app with a fixed sidebar). |
| 150 | 44× | const MEASURE_SCRIPT = `(() => { |
| 151 | 44× | const vh = window.innerHeight; |
| 152 | 44× | const vw = window.innerWidth; |
| 153 | 44× | let extra = 0; |
| 154 | 44× | for (const el of document.querySelectorAll("*")) { |
| 155 | 44× | if (el === document.documentElement || el === document.body) continue; |
| 156 | 44× | const style = getComputedStyle(el); |
| 157 | 44× | if (!/(auto|scroll)/.test(style.overflowY)) continue; |
| 158 | 44× | const hidden = el.scrollHeight - el.clientHeight; |
| 159 | 44× | if (hidden <= 0) continue; |
| 160 | 44× | // Only count boxes that are a real part of the layout, not small widgets. |
| 161 | 44× | if (el.clientHeight < vh * 0.4) continue; |
| 162 | 44× | extra = Math.max(extra, hidden); |
| 163 | 44× | } |
| 164 | 44× | const docHeight = Math.max( |
| 165 | 44× | document.documentElement.scrollHeight, |
| 166 | 44× | document.body ? document.body.scrollHeight : 0 |
| 167 | 44× | ); |
| 168 | 44× | return JSON.stringify({ width: vw, height: Math.max(docHeight, vh + extra) }); |
| 169 | 44× | })()`; |
| 170 | 44× | |
| 171 | 44× | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| 172 | 44× | |
| 173 | 12× | async function measurePage(target) { |
| 174 | 12× | const res = await chrome.debugger.sendCommand(target, "Runtime.evaluate", { |
| 175 | 12× | expression: MEASURE_SCRIPT, |
| 176 | 12× | returnByValue: true |
| 177 | 12× | }); |
| 178 | 12× | return JSON.parse(res.result.value); |
| 179 | 12× | } |
| 180 | 44× | |
| 181 | 44× | // Full page: makes the browser window "pretend" to be as tall as the |
| 182 | 44× | // whole page, takes the screenshot, then puts the window back. |
| 183 | 7× | async function captureFullPage(tab) { |
| 184 | 7× | const target = { tabId: tab.id }; |
| 185 | 7× | await chrome.debugger.attach(target, "1.3"); |
| 186 | 6× | let overridden = false; |
| 187 | 6× | try { |
| 188 | 6× | let { width, height } = await measurePage(target); |
| 189 | 6× | |
| 190 | 6× | await chrome.debugger.sendCommand(target, "Emulation.setDeviceMetricsOverride", { |
| 191 | 6× | width, height, deviceScaleFactor: 0, mobile: false |
| 192 | 6× | }); |
| 193 | 6× | overridden = true; |
| 194 | 6× | await sleep(400); // let the page lay itself out at the new size |
| 195 | 6× | |
| 196 | 6× | // Measure again: some pages grow once they have more room. |
| 197 | 6× | const after = await measurePage(target); |
| 198 | 7× | if (after.height > height) { |
| 199 | 1× | height = after.height; |
| 200 | 1× | await chrome.debugger.sendCommand(target, "Emulation.setDeviceMetricsOverride", { |
| 201 | 1× | width, height, deviceScaleFactor: 0, mobile: false |
| 202 | 1× | }); |
| 203 | 1× | await sleep(400); |
| 204 | 1× | } |
| 205 | 6× | |
| 206 | 6× | const result = await chrome.debugger.sendCommand(target, "Page.captureScreenshot", { |
| 207 | 6× | format: "png", |
| 208 | 6× | captureBeyondViewport: true, |
| 209 | 6× | clip: { x: 0, y: 0, width, height, scale: 1 } |
| 210 | 6× | }); |
| 211 | 6× | return base64ToPngBlob(result.data); |
| 212 | 6× | } finally { |
| 213 | 6× | if (overridden) { |
| 214 | 6× | try { await chrome.debugger.sendCommand(target, "Emulation.clearDeviceMetricsOverride"); } catch (_) {} |
| 215 | 6× | } |
| 216 | 6× | // Always detach, so the yellow "debugging" bar goes away. |
| 217 | 6× | try { await chrome.debugger.detach(target); } catch (_) { /* already detached */ } |
| 218 | 6× | } |
| 219 | 7× | } |
| 220 | 44× | |
| 221 | 44× | // Visible area: a normal screenshot of what is on screen. |
| 222 | 12× | async function captureVisible(tab) { |
| 223 | 12× | const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: "png" }); |
| 224 | 11× | return base64ToPngBlob(dataUrl.split(",")[1]); |
| 225 | 12× | } |
| 226 | 44× | |
| 227 | 14× | function friendlyError(err) { |
| 228 | 14× | const msg = String(err && err.message ? err.message : err); |
| 229 | 14× | if (/chrome:\/\/|cannot access|Cannot attach|chrome-extension/i.test(msg)) { |
| 230 | 3× | return "Chrome doesn't allow screenshots of this page (for example chrome:// pages or the Web Store)."; |
| 231 | 3× | } |
| 232 | 14× | if (/Another debugger/i.test(msg)) { |
| 233 | 2× | return "DevTools or another tool is already debugging this tab. Close DevTools and try again."; |
| 234 | 2× | } |
| 235 | 14× | if (/download/i.test(msg)) { |
| 236 | 3× | return "Couldn't save the file. Check Chrome's download settings and try again."; |
| 237 | 3× | } |
| 238 | 14× | if (/Document is not focused|clipboard/i.test(msg)) { |
| 239 | 3× | return "Couldn't copy to the clipboard. Click the Scatta icon again and retry."; |
| 240 | 3× | } |
| 241 | 3× | return "Something went wrong: " + msg; |
| 242 | 14× | } |
| 243 | 44× | |
| 244 | 20× | async function run(mode) { |
| 245 | 20× | cancelClose(); |
| 246 | 20× | setBusy(true); |
| 247 | 20× | setStatus(mode === "full" ? "Capturing the full page..." : "Capturing the visible area..."); |
| 248 | 20× | let closeAfter = false; |
| 249 | 20× | let downloadId; |
| 250 | 20× | try { |
| 251 | 20× | const tab = await getActiveTab(); |
| 252 | 20× | const blob = mode === "full" ? await captureFullPage(tab) : await captureVisible(tab); |
| 253 | 11× | |
| 254 | 11× | const saving = saveToggle.checked; |
| 255 | 20× | if (saving) { |
| 256 | 4× | downloadId = await saveBlob(blob, tab); |
| 257 | 20× | } else { |
| 258 | 13× | // Bring focus back to the popup before writing to the clipboard. |
| 259 | 13× | window.focus(); |
| 260 | 13× | await copyBlob(blob); |
| 261 | 11× | } |
| 262 | 14× | |
| 263 | 14× | const { width, height } = await describeSize(blob); |
| 264 | 14× | const done = saving |
| 265 | 20× | ? `Saved to Downloads/scatta (${width} × ${height} px).` |
| 266 | 20× | : `Copied (${width} × ${height} px). Ready to paste.`; |
| 267 | 20× | if (mode === "full" && height > TALL_WARNING_PX) { |
| 268 | 2× | setStatus( |
| 269 | 2× | `${done} This is a long page, so small text may be hard to read in a chat. Try the visible area for detail.`, |
| 270 | 2× | "warn" |
| 271 | 2× | ); |
| 272 | 20× | } else { |
| 273 | 12× | setStatus(done, "ok"); |
| 274 | 12× | // Only close on a plain success: warnings and errors need reading. |
| 275 | 12× | closeAfter = settings.autoClose; |
| 276 | 12× | } |
| 277 | 20× | } catch (err) { |
| 278 | 6× | console.error(err); |
| 279 | 6× | setStatus(friendlyError(err), "error"); |
| 280 | 20× | } finally { |
| 281 | 20× | setBusy(false); |
| 282 | 20× | } |
| 283 | 20× | if (closeAfter) await closeWhenDone(downloadId); |
| 284 | 20× | } |
| 285 | 44× | |
| 286 | 44× | fullBtn.addEventListener("click", () => run("full")); |
| 287 | 44× | visibleBtn.addEventListener("click", () => run("visible")); |
| 288 | |