248 lines
7.5 KiB
JavaScript
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());
|
|
})();
|