add qr-clipboard extension
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
// QR Clipboard
|
||||
//
|
||||
// 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 Clipboard] No QR codes found.");
|
||||
setStatus("No QR codes found in the visible area.", "muted");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[QR Clipboard] 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());
|
||||
})();
|
||||
Reference in New Issue
Block a user