add qr-clipboard extension
This commit is contained in:
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "QR Clipboard",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Screenshot the visible tab, decode any QR codes, and copy the result to the clipboard.",
|
||||||
|
|
||||||
|
"permissions": [
|
||||||
|
"activeTab",
|
||||||
|
"clipboardWrite",
|
||||||
|
"storage"
|
||||||
|
],
|
||||||
|
|
||||||
|
"action": {
|
||||||
|
"default_title": "Scan visible tab for QR codes",
|
||||||
|
"default_popup": "popup.html"
|
||||||
|
},
|
||||||
|
|
||||||
|
"browser_specific_settings": {
|
||||||
|
"gecko": {
|
||||||
|
"id": "qr-clipboard@timothykim",
|
||||||
|
"strict_min_version": "115.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font: 13px system-ui, sans-serif;
|
||||||
|
width: 320px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
header h1 {
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
padding: 4px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#status {
|
||||||
|
min-height: 16px;
|
||||||
|
margin: 6px 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.muted { color: #777; }
|
||||||
|
.ok { color: #137333; }
|
||||||
|
.err { color: #c5221f; }
|
||||||
|
|
||||||
|
#log {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
#log li {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-start;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
.entry-body { flex: 1; min-width: 0; }
|
||||||
|
.entry-del {
|
||||||
|
flex: none;
|
||||||
|
padding: 2px 6px;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: #c5221f;
|
||||||
|
}
|
||||||
|
.entry-text {
|
||||||
|
word-break: break-all;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.entry-meta {
|
||||||
|
color: #888;
|
||||||
|
font-size: 11px;
|
||||||
|
margin-top: 2px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.empty { color: #999; padding: 12px 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>QR Clipboard</h1>
|
||||||
|
<button id="scan">Scan</button>
|
||||||
|
<button id="clear">Clear All</button>
|
||||||
|
</header>
|
||||||
|
<div id="status" class="muted"></div>
|
||||||
|
<ul id="log"></ul>
|
||||||
|
|
||||||
|
<!-- jsQR exposes a global `jsQR` (UMD). Must load before popup.js. -->
|
||||||
|
<script src="vendor/jsQR.js"></script>
|
||||||
|
<script src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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());
|
||||||
|
})();
|
||||||
Vendored
+10102
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user