Files
qr-clip/popup.js
T
2026-06-09 22:06:58 -04:00

248 lines
7.5 KiB
JavaScript

// QR Clip
//
// The popup shows a persistent log of every QR code ever decoded.
// Clicking "Scan visible tab" captures the viewport, decodes all QR codes,
// appends them to the log, and copies the LAST one to the clipboard.
//
// Decode strategy: jsQR finds ONE code per scan, so we loop — decode,
// white-out the detected region, re-scan — until nothing is found.
const api = typeof browser !== "undefined" ? browser : chrome;
const STORAGE_KEY = "qrLog";
const MAX_ENTRIES = 500;
const statusEl = document.getElementById("status");
const logEl = document.getElementById("log");
const scanBtn = document.getElementById("scan");
const clearBtn = document.getElementById("clear");
function setStatus(text, cls) {
statusEl.textContent = text || "";
statusEl.className = cls || "";
}
// --- storage ---------------------------------------------------------------
async function getLog() {
const stored = await api.storage.local.get(STORAGE_KEY);
return Array.isArray(stored[STORAGE_KEY]) ? stored[STORAGE_KEY] : [];
}
// entries: newest-first (entries[0] is the last decoded code of the scan).
async function appendEntries(entries) {
const log = await getLog();
const result = log.slice(); // existing log, newest-first
let added = 0;
let lastSkipped = false; // was the last decoded code (entries[0]) a duplicate?
// Walk oldest→newest so the last decoded code ends up at the front.
// Skip any code that matches the current front (the most recent entry),
// keeping the existing entry rather than re-adding it.
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
const isDuplicate = result[0] && result[0].text === entry.text;
if (i === 0) lastSkipped = !!isDuplicate;
if (isDuplicate) continue;
result.unshift(entry);
added++;
}
const next = result.slice(0, MAX_ENTRIES); // cap total size
await api.storage.local.set({ [STORAGE_KEY]: next });
return { log: next, added, lastSkipped };
}
async function clearLog() {
await api.storage.local.remove(STORAGE_KEY);
}
// --- rendering -------------------------------------------------------------
function renderLog(log) {
logEl.replaceChildren();
if (!log.length) {
const li = document.createElement("li");
li.className = "empty";
li.textContent = "No QR codes logged yet.";
logEl.appendChild(li);
return;
}
log.forEach((entry, index) => {
const li = document.createElement("li");
const body = document.createElement("div");
body.className = "entry-body";
const text = document.createElement("div");
text.className = "entry-text";
text.textContent = entry.text; // textContent: QR payloads are untrusted
body.appendChild(text);
const meta = document.createElement("div");
meta.className = "entry-meta";
const when = new Date(entry.time).toLocaleString();
meta.textContent = entry.url ? `${when} · ${entry.url}` : when;
body.appendChild(meta);
li.appendChild(body);
const del = document.createElement("button");
del.className = "entry-del";
del.textContent = "✕";
del.title = "Delete this entry";
del.addEventListener("click", () => deleteEntry(index));
li.appendChild(del);
logEl.appendChild(li);
});
}
// --- decoding --------------------------------------------------------------
function dataUrlToImageData(dataUrl) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
ctx.drawImage(img, 0, 0);
resolve(ctx.getImageData(0, 0, canvas.width, canvas.height));
};
img.onerror = () => reject(new Error("Failed to load captured image"));
img.src = dataUrl;
});
}
// Paint a code's bounding box white so the next jsQR pass can't see it.
function maskRegion(imageData, location) {
const xs = [
location.topLeftCorner.x,
location.topRightCorner.x,
location.bottomLeftCorner.x,
location.bottomRightCorner.x,
];
const ys = [
location.topLeftCorner.y,
location.topRightCorner.y,
location.bottomLeftCorner.y,
location.bottomRightCorner.y,
];
const minX = Math.max(0, Math.floor(Math.min(...xs)));
const maxX = Math.min(imageData.width, Math.ceil(Math.max(...xs)));
const minY = Math.max(0, Math.floor(Math.min(...ys)));
const maxY = Math.min(imageData.height, Math.ceil(Math.max(...ys)));
const data = imageData.data;
for (let y = minY; y < maxY; y++) {
for (let x = minX; x < maxX; x++) {
const i = (y * imageData.width + x) * 4;
data[i] = data[i + 1] = data[i + 2] = 255;
data[i + 3] = 255;
}
}
}
function decodeAll(imageData) {
const found = [];
const MAX_PASSES = 20; // safety cap against pathological loops
for (let pass = 0; pass < MAX_PASSES; pass++) {
const code = jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: "attemptBoth",
});
if (!code || !code.location) break;
found.push(code.data);
maskRegion(imageData, code.location);
}
return found;
}
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (e) {
const ta = document.createElement("textarea");
ta.value = text;
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand("copy");
ta.remove();
return ok;
}
}
// --- actions ---------------------------------------------------------------
async function scan() {
scanBtn.disabled = true;
setStatus("Scanning visible tab…", "muted");
try {
const [tab] = await api.tabs.query({ active: true, currentWindow: true });
const dataUrl = await api.tabs.captureVisibleTab();
const imageData = await dataUrlToImageData(dataUrl);
const codes = decodeAll(imageData);
if (codes.length === 0) {
console.log("[QR Clip] No QR codes found.");
setStatus("No QR codes found in the visible area.", "muted");
return;
}
console.log(`[QR Clip] Decoded ${codes.length} code(s):`, codes);
const now = new Date().toISOString();
const url = tab ? tab.url : "";
// Newest first within this scan too, so the LAST decoded sits on top.
const entries = codes
.map((text) => ({ text, time: now, url }))
.reverse();
const { log, added, lastSkipped } = await appendEntries(entries);
renderLog(log);
const last = codes[codes.length - 1];
const copied = await copyToClipboard(last);
const skipped = codes.length - added;
let msg = `Found ${codes.length} code(s)`;
if (skipped) msg += `, ${skipped} duplicate(s) skipped`;
msg += ".";
if (!copied) {
msg += " Clipboard write failed.";
} else if (!lastSkipped) {
msg += " Last one copied to clipboard.";
}
setStatus(msg, copied ? "ok" : "err");
} catch (e) {
setStatus("Could not scan this tab: " + e.message, "err");
} finally {
scanBtn.disabled = false;
}
}
async function deleteEntry(index) {
const log = await getLog();
log.splice(index, 1);
await api.storage.local.set({ [STORAGE_KEY]: log });
renderLog(log);
setStatus("Entry deleted.", "muted");
}
async function clear() {
await clearLog();
renderLog([]);
setStatus("Log cleared.", "muted");
}
// --- init ------------------------------------------------------------------
scanBtn.addEventListener("click", scan);
clearBtn.addEventListener("click", clear);
(async () => {
renderLog(await getLog());
})();