chore: add npm lockfile
@ -1,34 +1,8 @@
|
||||
const tabOverlayStatus = {};
|
||||
|
||||
chrome.action.onClicked.addListener(async (tab) => {
|
||||
const tabId = tab.id;
|
||||
if (!tab?.id) return
|
||||
|
||||
// First, always attempt to remove any existing overlay from this tab
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: () => {
|
||||
const iframe = document.getElementById("overlay-iframe");
|
||||
const controls = Array.from(document.querySelectorAll("div")).find(
|
||||
(el) =>
|
||||
el.textContent?.includes("Overlay Controls") &&
|
||||
el.style?.position === "fixed"
|
||||
);
|
||||
if (iframe) iframe.remove();
|
||||
if (controls) controls.remove();
|
||||
},
|
||||
});
|
||||
|
||||
// If the overlay was already active, we just removed it above
|
||||
if (tabOverlayStatus[tabId]) {
|
||||
tabOverlayStatus[tabId] = false;
|
||||
chrome.action.setIcon({ tabId, path: "icon-off.png" }); // optional
|
||||
} else {
|
||||
// If not active, inject the overlay
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
files: ["content.js"],
|
||||
});
|
||||
tabOverlayStatus[tabId] = true;
|
||||
chrome.action.setIcon({ tabId, path: "icon-on.png" }); // optional
|
||||
}
|
||||
});
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
files: ['scripts/overlay.js'],
|
||||
})
|
||||
})
|
||||
|
||||
5
docs/privacy-policy.md
Normal file
@ -0,0 +1,5 @@
|
||||
# Privacy Policy
|
||||
|
||||
Live Overlay does not collect, transmit, or sell personal data. Settings such as the last used URL and UI preferences are stored locally via `chrome.storage.local`. No data leaves your device.
|
||||
|
||||
Contact: support@example.com
|
||||
18
docs/release-checklist.md
Normal file
@ -0,0 +1,18 @@
|
||||
# Live Overlay – Release Checklist
|
||||
|
||||
## Pre-build
|
||||
- [ ] Run `npm run genicons` and confirm `icons/icon-16.png`, `icon-32.png`, `icon-48.png`, and `icon-128.png` are refreshed.
|
||||
- [ ] Verify inline SVG toolbar icons render correctly against light and dark web pages.
|
||||
- [ ] Ensure `manifest.json` lists only `"permissions": ["activeTab","scripting","storage"]` and no `host_permissions` or `<all_urls>`.
|
||||
|
||||
## QA & Validation
|
||||
- [ ] Load the unpacked extension in `chrome://extensions` (Developer Mode) without warnings.
|
||||
- [ ] Click the action icon to toggle the overlay on/off; repeat to confirm no duplicate roots remain.
|
||||
- [ ] Confirm drag, resize, click-through, opacity, invert, grid, and nudge controls function with keyboard focus rings.
|
||||
- [ ] Reload the tab and browser to ensure settings persist via `chrome.storage.local`.
|
||||
- [ ] Capture updated promo screenshots and store assets in 440×280 and 1400×560 resolutions.
|
||||
|
||||
## Submission
|
||||
- [ ] Update `CHANGELOG.md` with the release notes.
|
||||
- [ ] Review and update `docs/privacy-policy.md` contact details if needed.
|
||||
- [ ] Package the extension (`.zip`) with the refreshed icons and submit to the Chrome Web Store dashboard.
|
||||
12
icons/base.svg
Normal file
@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#6366f1" />
|
||||
<stop offset="100%" stop-color="#22d3ee" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="128" height="128" rx="24" fill="url(#g)" />
|
||||
<circle cx="64" cy="64" r="28" fill="#ffffff" opacity="0.92" />
|
||||
<circle cx="64" cy="64" r="10" fill="#0f172a" opacity="0.9" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 519 B |
11
icons/ui/adjustments-horizontal.svg
Normal file
@ -0,0 +1,11 @@
|
||||
<svg role="img" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 6.75h7.5" />
|
||||
<path d="M13.5 6.75H21" />
|
||||
<circle cx="10.5" cy="6.75" r="2.25" fill="none" />
|
||||
<path d="M3 12h4.5" />
|
||||
<path d="M12 12h9" />
|
||||
<circle cx="7.5" cy="12" r="2.25" fill="none" />
|
||||
<path d="M3 17.25h10.5" />
|
||||
<path d="M16.5 17.25H21" />
|
||||
<circle cx="13.5" cy="17.25" r="2.25" fill="none" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 510 B |
6
icons/ui/arrow-path.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg role="img" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4.5 9a7.5 7.5 0 0112.915-3.75H14.25" fill="none" />
|
||||
<path d="M17.415 5.25l2.335 2.25-2.335 2.25" fill="none" />
|
||||
<path d="M19.5 15a7.5 7.5 0 01-12.915 3.75H9.75" fill="none" />
|
||||
<path d="M6.585 18.75L4.25 16.5l2.335-2.25" fill="none" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 439 B |
10
icons/ui/arrows-pointing-in.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<svg role="img" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 4.5H4.5V9" />
|
||||
<path d="M10.5 7.5l-6 6" />
|
||||
<path d="M15 19.5h4.5V15" />
|
||||
<path d="M13.5 16.5l6-6" />
|
||||
<path d="M19.5 9V4.5H15" />
|
||||
<path d="M13.5 7.5l6 6" />
|
||||
<path d="M4.5 15v4.5H9" />
|
||||
<path d="M10.5 16.5l-6-6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 424 B |
10
icons/ui/arrows-pointing-out.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<svg role="img" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 4.5H4.5V9" />
|
||||
<path d="M4.5 4.5l6 6" />
|
||||
<path d="M15 19.5h4.5V15" />
|
||||
<path d="M19.5 19.5l-6-6" />
|
||||
<path d="M19.5 9V4.5H15" />
|
||||
<path d="M19.5 4.5l-6 6" />
|
||||
<path d="M4.5 15v4.5H9" />
|
||||
<path d="M4.5 19.5l6-6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 422 B |
8
icons/ui/arrows-up-down-left-right.svg
Normal file
@ -0,0 +1,8 @@
|
||||
<svg role="img" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3l3 3H9l3-3z" fill="none" />
|
||||
<path d="M12 21l3-3H9l3 3z" fill="none" />
|
||||
<path d="M3 12l3 3V9l-3 3z" fill="none" />
|
||||
<path d="M21 12l-3 3V9l3 3z" fill="none" />
|
||||
<path d="M12 4.5v15" />
|
||||
<path d="M4.5 12h15" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 418 B |
6
icons/ui/eye-slash.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg role="img" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3.75 5.25l16.5 13.5" fill="none" />
|
||||
<path d="M6.455 6.93C4.56 8.432 3.25 10.386 2.25 12c1.5 3 4.5 6 9.75 6 1.22 0 2.33-.13 3.326-.377" fill="none" />
|
||||
<path d="M17.59 14.862C20.231 13.332 21.75 12 21.75 12c-1.5-3-4.5-6-9.75-6-.96 0-1.875.09-2.746.257" fill="none" />
|
||||
<path d="M9.75 11.25A2.25 2.25 0 0012 13.5c.454 0 .876-.13 1.236-.356" fill="none" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 556 B |
4
icons/ui/eye.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg role="img" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2.25 12s3.75-6 9.75-6 9.75 6 9.75 6-3.75 6-9.75 6S2.25 12 2.25 12z" fill="none" />
|
||||
<circle cx="12" cy="12" r="3" fill="none" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 328 B |
6
icons/ui/hand.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg role="img" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8.25 11.25V6.75a1.5 1.5 0 013 0v3" fill="none" />
|
||||
<path d="M11.25 10.5V4.5a1.5 1.5 0 013 0v6" fill="none" />
|
||||
<path d="M14.25 10.5V6a1.5 1.5 0 013 0v6.75" fill="none" />
|
||||
<path d="M17.25 12.75v-1.5a1.5 1.5 0 013 0v4.5c0 3-2.25 4.5-5.25 4.5H12a4.5 4.5 0 01-4.5-4.5v-3a1.5 1.5 0 013 0v1.5" fill="none" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 505 B |
5
icons/ui/lock-closed.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg role="img" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="5.25" y="10.5" width="13.5" height="9" rx="2.25" ry="2.25" fill="none" />
|
||||
<path d="M8.25 10.5V8.25a3.75 3.75 0 017.5 0V10.5" fill="none" />
|
||||
<path d="M12 14.25v2.25" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 369 B |
5
icons/ui/lock-open.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg role="img" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="5.25" y="10.5" width="13.5" height="9" rx="2.25" ry="2.25" fill="none" />
|
||||
<path d="M8.25 10.5V8.25a3.75 3.75 0 017.5 0" fill="none" />
|
||||
<path d="M12 14.25v2.25" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 364 B |
3
icons/ui/moon.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg role="img" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9.75 4.5A7.5 7.5 0 0019.5 14.25 7.5 7.5 0 119.75 4.5z" fill="none" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 268 B |
5
icons/ui/question-mark-circle.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg role="img" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="9" fill="none" />
|
||||
<path d="M9.75 9.75a2.25 2.25 0 114.5 0c0 1.5-2.25 1.875-2.25 3.75" fill="none" />
|
||||
<circle cx="12" cy="17.25" r=".75" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 358 B |
6
icons/ui/squares-2x2.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg role="img" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="4.5" y="4.5" width="6" height="6" rx="1.5" ry="1.5" fill="none" />
|
||||
<rect x="13.5" y="4.5" width="6" height="6" rx="1.5" ry="1.5" fill="none" />
|
||||
<rect x="4.5" y="13.5" width="6" height="6" rx="1.5" ry="1.5" fill="none" />
|
||||
<rect x="13.5" y="13.5" width="6" height="6" rx="1.5" ry="1.5" fill="none" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 502 B |
@ -1,19 +1,22 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Live Site Overlay Tool",
|
||||
"version": "1.1",
|
||||
"version": "1.1.1",
|
||||
"description": "Overlay one live site over another for pixel-perfect comparison.",
|
||||
"permissions": ["scripting", "activeTab", "tabs", "storage"],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"permissions": ["activeTab", "scripting", "storage"],
|
||||
"action": {
|
||||
"default_title": "Toggle Overlay",
|
||||
"default_icon": {
|
||||
"16": "icon-on.png",
|
||||
"32": "icon-on.png",
|
||||
"48": "icon-on.png",
|
||||
"128": "icon-on.png"
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png"
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
}
|
||||
|
||||
13
package-lock.json
generated
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "liveoverlayextension",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "liveoverlayextension",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC"
|
||||
}
|
||||
}
|
||||
}
|
||||
17
package.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "liveoverlayextension",
|
||||
"version": "1.0.0",
|
||||
"description": "A Chrome extension for pixel-perfect visual comparison of two **live websites**. This tool is ideal for frontend developers, designers, and QA testers who need to overlay a staging or production site directly on top of another to validate layout accuracy, design drift, or content alignment — all in real time.",
|
||||
"main": "background.js",
|
||||
"directories": {
|
||||
"doc": "docs"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"genicons": "node tools/gen-icons.mjs"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs"
|
||||
}
|
||||
@ -1,173 +0,0 @@
|
||||
console.log("LiveOverlayExtension: content script loaded");
|
||||
|
||||
if (!document.getElementById("overlay-iframe")) {
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.src = "https://hiousastg.wpenginepowered.com/hole-in-one-insurance/";
|
||||
console.log(iframe.src);
|
||||
iframe.id = "overlay-iframe";
|
||||
iframe.style.position = "fixed";
|
||||
iframe.style.top = "0";
|
||||
iframe.style.left = "0";
|
||||
iframe.style.width = "100vw";
|
||||
iframe.style.height = "100vh";
|
||||
iframe.style.zIndex = "9999";
|
||||
iframe.style.pointerEvents = "none";
|
||||
iframe.style.opacity = "0.5";
|
||||
iframe.style.filter = "none";
|
||||
iframe.style.border = "none";
|
||||
iframe.style.overflow = "hidden";
|
||||
iframe.setAttribute("scrolling", "no");
|
||||
|
||||
const controls = document.createElement("div");
|
||||
controls.style.position = "fixed";
|
||||
controls.style.top = "10px";
|
||||
controls.style.right = "10px";
|
||||
controls.style.zIndex = "10001";
|
||||
controls.style.background = "rgba(255, 255, 255, 0.95)";
|
||||
controls.style.padding = "10px";
|
||||
controls.style.border = "1px solid #ccc";
|
||||
controls.style.borderRadius = "5px";
|
||||
controls.style.fontSize = "12px";
|
||||
controls.style.fontFamily = "sans-serif";
|
||||
controls.style.userSelect = "none";
|
||||
controls.style.pointerEvents = "auto"; // always allow control panel to be clickable
|
||||
|
||||
const dragHandle = document.createElement("div");
|
||||
dragHandle.textContent = "Overlay Controls";
|
||||
dragHandle.style.fontWeight = "bold";
|
||||
dragHandle.style.marginBottom = "8px";
|
||||
dragHandle.style.cursor = "move";
|
||||
dragHandle.style.userSelect = "none";
|
||||
controls.appendChild(dragHandle);
|
||||
|
||||
const sliderLabel = document.createElement("label");
|
||||
sliderLabel.textContent = "Opacity: ";
|
||||
const slider = document.createElement("input");
|
||||
slider.type = "range";
|
||||
slider.min = 0;
|
||||
slider.max = 100;
|
||||
slider.value = 50;
|
||||
slider.style.marginBottom = "5px";
|
||||
slider.oninput = (e) => {
|
||||
iframe.style.opacity = e.target.value / 100;
|
||||
};
|
||||
|
||||
const urlLabel = document.createElement("label");
|
||||
urlLabel.textContent = "Overlay URL: ";
|
||||
urlLabel.style.display = "block";
|
||||
const urlInput = document.createElement("input");
|
||||
urlInput.type = "text";
|
||||
urlInput.value = iframe.src;
|
||||
urlInput.style.width = "250px";
|
||||
urlInput.style.marginBottom = "5px";
|
||||
urlInput.onchange = () => {
|
||||
iframe.src = urlInput.value;
|
||||
};
|
||||
|
||||
const invertLabel = document.createElement("label");
|
||||
invertLabel.textContent = " Invert Colors";
|
||||
const invertCheckbox = document.createElement("input");
|
||||
invertCheckbox.type = "checkbox";
|
||||
invertCheckbox.style.marginLeft = "10px";
|
||||
invertCheckbox.onchange = () => {
|
||||
iframe.style.filter = invertCheckbox.checked ? "invert(1)" : "none";
|
||||
};
|
||||
invertLabel.appendChild(invertCheckbox);
|
||||
|
||||
const scrollLabel = document.createElement("label");
|
||||
scrollLabel.textContent = " Enable Scrolling";
|
||||
const scrollCheckbox = document.createElement("input");
|
||||
scrollCheckbox.type = "checkbox";
|
||||
scrollCheckbox.style.marginLeft = "10px";
|
||||
scrollCheckbox.checked = true;
|
||||
scrollCheckbox.onchange = () => {
|
||||
const value = scrollCheckbox.checked ? "auto" : "hidden";
|
||||
document.body.style.overflow = value;
|
||||
document.documentElement.style.overflow = value;
|
||||
};
|
||||
scrollLabel.appendChild(scrollCheckbox);
|
||||
|
||||
// Overlay Unlock Toggle
|
||||
const unlockButton = document.createElement("button");
|
||||
unlockButton.textContent = "🔓 Unlock Overlay";
|
||||
unlockButton.style.marginTop = "8px";
|
||||
unlockButton.style.padding = "6px";
|
||||
unlockButton.style.cursor = "pointer";
|
||||
unlockButton.style.background = "#444";
|
||||
unlockButton.style.color = "white";
|
||||
unlockButton.style.border = "1px solid #666";
|
||||
unlockButton.style.borderRadius = "4px";
|
||||
unlockButton.style.width = "100%";
|
||||
unlockButton.id = "unlock-overlay-button";
|
||||
controls.appendChild(unlockButton);
|
||||
|
||||
// Add controls
|
||||
controls.appendChild(scrollLabel);
|
||||
controls.appendChild(document.createElement("br"));
|
||||
controls.appendChild(sliderLabel);
|
||||
controls.appendChild(slider);
|
||||
controls.appendChild(document.createElement("br"));
|
||||
controls.appendChild(urlLabel);
|
||||
controls.appendChild(urlInput);
|
||||
controls.appendChild(document.createElement("br"));
|
||||
controls.appendChild(invertLabel);
|
||||
|
||||
document.body.appendChild(iframe);
|
||||
document.body.appendChild(controls);
|
||||
console.log("Overlay iframe and controls added");
|
||||
|
||||
// Drag logic
|
||||
let isDragging = false;
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
|
||||
dragHandle.addEventListener("mousedown", (e) => {
|
||||
isDragging = true;
|
||||
offsetX = e.clientX - controls.getBoundingClientRect().left;
|
||||
offsetY = e.clientY - controls.getBoundingClientRect().top;
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
document.addEventListener("mousemove", (e) => {
|
||||
if (isDragging) {
|
||||
controls.style.left = `${e.clientX - offsetX}px`;
|
||||
controls.style.top = `${e.clientY - offsetY}px`;
|
||||
controls.style.right = "auto";
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("mouseup", () => {
|
||||
isDragging = false;
|
||||
});
|
||||
|
||||
// Unlock toggle logic
|
||||
window.overlayUnlocked = false;
|
||||
|
||||
unlockButton.addEventListener("click", () => {
|
||||
if (!iframe) return;
|
||||
|
||||
window.overlayUnlocked = !window.overlayUnlocked;
|
||||
|
||||
if (window.overlayUnlocked) {
|
||||
iframe.style.pointerEvents = "auto";
|
||||
iframe.style.overflow = "auto";
|
||||
iframe.setAttribute("scrolling", "yes");
|
||||
|
||||
document.body.style.pointerEvents = "none";
|
||||
document.documentElement.style.pointerEvents = "none";
|
||||
controls.style.pointerEvents = "auto";
|
||||
|
||||
unlockButton.textContent = "🔒 Lock Overlay";
|
||||
} else {
|
||||
iframe.style.pointerEvents = "none";
|
||||
iframe.style.overflow = "hidden";
|
||||
iframe.setAttribute("scrolling", "no");
|
||||
|
||||
document.body.style.pointerEvents = "auto";
|
||||
document.documentElement.style.pointerEvents = "auto";
|
||||
controls.style.pointerEvents = "auto";
|
||||
|
||||
unlockButton.textContent = "🔓 Unlock Overlay";
|
||||
}
|
||||
});
|
||||
}
|
||||
854
scripts/overlay.js
Normal file
@ -0,0 +1,854 @@
|
||||
(async () => {
|
||||
const existing = document.getElementById('live-overlay-root')
|
||||
if (existing) {
|
||||
existing.remove()
|
||||
if (window.__liveOverlayCleanup) {
|
||||
try {
|
||||
window.__liveOverlayCleanup()
|
||||
} catch (err) {
|
||||
console.warn('LiveOverlay cleanup error', err)
|
||||
}
|
||||
delete window.__liveOverlayCleanup
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const DEFAULTS = {
|
||||
overlayUrl: '',
|
||||
opacity: 0.6,
|
||||
invertEnabled: false,
|
||||
locked: false,
|
||||
clickThrough: false,
|
||||
gridEnabled: false,
|
||||
position: { x: 48, y: 48 },
|
||||
size: { width: 1200, height: 800 },
|
||||
}
|
||||
|
||||
const iconNames = [
|
||||
'eye',
|
||||
'eye-slash',
|
||||
'lock-closed',
|
||||
'lock-open',
|
||||
'hand',
|
||||
'adjustments-horizontal',
|
||||
'moon',
|
||||
'arrows-pointing-out',
|
||||
'arrows-pointing-in',
|
||||
'arrows-up-down-left-right',
|
||||
'squares-2x2',
|
||||
'arrow-path',
|
||||
'question-mark-circle',
|
||||
]
|
||||
|
||||
const iconCache = {}
|
||||
|
||||
async function loadIcon(name) {
|
||||
if (iconCache[name]) return iconCache[name]
|
||||
const res = await fetch(chrome.runtime.getURL(`icons/ui/${name}.svg`))
|
||||
const text = await res.text()
|
||||
iconCache[name] = text
|
||||
return text
|
||||
}
|
||||
|
||||
await Promise.all(iconNames.map(loadIcon))
|
||||
|
||||
const stored = await new Promise((resolve) => {
|
||||
chrome.storage.local.get(DEFAULTS, (value) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
console.warn('LiveOverlay storage get error', chrome.runtime.lastError)
|
||||
resolve({ ...DEFAULTS })
|
||||
return
|
||||
}
|
||||
resolve(value)
|
||||
})
|
||||
})
|
||||
|
||||
const state = {
|
||||
...DEFAULTS,
|
||||
...stored,
|
||||
position: { ...DEFAULTS.position, ...(stored.position || {}) },
|
||||
size: { ...DEFAULTS.size, ...(stored.size || {}) },
|
||||
}
|
||||
|
||||
if (!state.overlayUrl) {
|
||||
state.overlayUrl = window.location.href
|
||||
}
|
||||
|
||||
let overlayVisible = true
|
||||
let fullscreen = false
|
||||
let savedState = { size: { ...state.size }, position: { ...state.position } }
|
||||
let currentFrameUrl = state.overlayUrl
|
||||
|
||||
const pendingSave = {}
|
||||
let saveTimer = null
|
||||
|
||||
function scheduleSave(partial) {
|
||||
Object.assign(pendingSave, partial)
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer)
|
||||
}
|
||||
saveTimer = setTimeout(() => {
|
||||
chrome.storage.local.set(pendingSave, () => {
|
||||
if (chrome.runtime.lastError) {
|
||||
console.warn('LiveOverlay storage set error', chrome.runtime.lastError)
|
||||
}
|
||||
})
|
||||
for (const key of Object.keys(pendingSave)) {
|
||||
delete pendingSave[key]
|
||||
}
|
||||
saveTimer = null
|
||||
}, 200)
|
||||
}
|
||||
|
||||
function setButtonIcon(button, iconName) {
|
||||
button.innerHTML = iconCache[iconName] || ''
|
||||
const svg = button.querySelector('svg')
|
||||
if (svg) {
|
||||
svg.setAttribute('aria-hidden', 'true')
|
||||
svg.setAttribute('focusable', 'false')
|
||||
svg.setAttribute('width', '20')
|
||||
svg.setAttribute('height', '20')
|
||||
svg.setAttribute('stroke', 'currentColor')
|
||||
if (!svg.getAttribute('fill')) {
|
||||
svg.setAttribute('fill', 'none')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createButton(label, options = {}) {
|
||||
const { toggle = true } = options
|
||||
const button = document.createElement('button')
|
||||
button.type = 'button'
|
||||
button.className = 'lo-btn lo-control'
|
||||
button.setAttribute('aria-label', label)
|
||||
button.title = label
|
||||
if (toggle) {
|
||||
button.setAttribute('aria-pressed', 'false')
|
||||
}
|
||||
return button
|
||||
}
|
||||
|
||||
function applyFrameOptions() {
|
||||
iframe.style.opacity = String(state.opacity)
|
||||
iframe.style.filter = state.invertEnabled ? 'invert(1)' : 'none'
|
||||
iframe.style.pointerEvents = state.clickThrough ? 'none' : 'auto'
|
||||
iframe.setAttribute('aria-hidden', overlayVisible ? 'false' : 'true')
|
||||
if (state.overlayUrl !== currentFrameUrl) {
|
||||
currentFrameUrl = state.overlayUrl
|
||||
iframe.src = state.overlayUrl
|
||||
}
|
||||
root.classList.toggle('lo-grid-on', !!state.gridEnabled)
|
||||
root.classList.toggle('lo-click-through', !!state.clickThrough)
|
||||
root.classList.toggle('lo-locked', !!state.locked)
|
||||
opacityValue.textContent = Math.round(state.opacity * 100) + '%'
|
||||
opacitySlider.value = String(Math.round(state.opacity * 100))
|
||||
opacitySlider.setAttribute('aria-valuenow', String(Math.round(state.opacity * 100)))
|
||||
urlInput.value = state.overlayUrl
|
||||
updateToggleIcons()
|
||||
}
|
||||
|
||||
function applySizeAndPosition() {
|
||||
const maxWidth = Math.max(document.documentElement.clientWidth, window.innerWidth)
|
||||
const maxHeight = Math.max(document.documentElement.clientHeight, window.innerHeight)
|
||||
if (!fullscreen) {
|
||||
state.size.width = Math.min(Math.max(state.size.width, 320), maxWidth)
|
||||
state.size.height = Math.min(Math.max(state.size.height, 240), maxHeight)
|
||||
const clampedWidth = Math.min(state.size.width, maxWidth)
|
||||
const clampedHeight = Math.min(state.size.height, maxHeight)
|
||||
state.position.x = Math.min(Math.max(state.position.x, 0), Math.max(0, maxWidth - clampedWidth))
|
||||
state.position.y = Math.min(Math.max(state.position.y, 0), Math.max(0, maxHeight - clampedHeight))
|
||||
}
|
||||
root.style.width = fullscreen ? '100vw' : `${state.size.width}px`
|
||||
root.style.height = fullscreen ? '100vh' : `${state.size.height}px`
|
||||
root.style.left = fullscreen ? '0' : `${state.position.x}px`
|
||||
root.style.top = fullscreen ? '0' : `${state.position.y}px`
|
||||
root.classList.toggle('lo-fullscreen', fullscreen)
|
||||
}
|
||||
|
||||
function adjustPosition(deltaX, deltaY) {
|
||||
if (state.locked || fullscreen) return
|
||||
state.position.x += deltaX
|
||||
state.position.y += deltaY
|
||||
applySizeAndPosition()
|
||||
scheduleSave({ position: { ...state.position } })
|
||||
if (!fullscreen) {
|
||||
savedState.position = { ...state.position }
|
||||
}
|
||||
}
|
||||
|
||||
function updateToggleIcons() {
|
||||
lockButton.classList.toggle('is-active', state.locked)
|
||||
lockButton.setAttribute('aria-pressed', String(state.locked))
|
||||
setButtonIcon(lockButton, state.locked ? 'lock-closed' : 'lock-open')
|
||||
|
||||
clickThroughButton.classList.toggle('is-active', state.clickThrough)
|
||||
clickThroughButton.setAttribute('aria-pressed', String(state.clickThrough))
|
||||
|
||||
invertButton.classList.toggle('is-active', state.invertEnabled)
|
||||
invertButton.setAttribute('aria-pressed', String(state.invertEnabled))
|
||||
|
||||
gridButton.classList.toggle('is-active', state.gridEnabled)
|
||||
gridButton.setAttribute('aria-pressed', String(state.gridEnabled))
|
||||
|
||||
toggleVisibilityButton.classList.toggle('is-active', overlayVisible)
|
||||
toggleVisibilityButton.setAttribute('aria-pressed', String(overlayVisible))
|
||||
setButtonIcon(toggleVisibilityButton, overlayVisible ? 'eye' : 'eye-slash')
|
||||
|
||||
resizeButton.classList.toggle('is-active', fullscreen)
|
||||
resizeButton.setAttribute('aria-pressed', String(fullscreen))
|
||||
setButtonIcon(resizeButton, fullscreen ? 'arrows-pointing-in' : 'arrows-pointing-out')
|
||||
}
|
||||
|
||||
const root = document.createElement('div')
|
||||
root.id = 'live-overlay-root'
|
||||
root.setAttribute('role', 'region')
|
||||
root.setAttribute('aria-label', 'Live overlay container')
|
||||
|
||||
const style = document.createElement('style')
|
||||
style.textContent = `
|
||||
#live-overlay-root {
|
||||
position: fixed;
|
||||
top: 48px;
|
||||
left: 48px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
color: #f8fafc;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 45px rgba(15, 23, 42, 0.35);
|
||||
z-index: 2147483647;
|
||||
border: 1px solid rgba(148, 163, 184, 0.45);
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
#live-overlay-root.lo-click-through iframe {
|
||||
pointer-events: none;
|
||||
}
|
||||
#live-overlay-root.lo-locked .lo-toolbar {
|
||||
cursor: default;
|
||||
}
|
||||
#live-overlay-root.lo-locked .lo-title {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
#live-overlay-root .lo-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.35);
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
position: relative;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 6px;
|
||||
}
|
||||
#live-overlay-root .lo-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
margin-right: 6px;
|
||||
cursor: move;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
#live-overlay-root .lo-toolbar button.lo-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
#live-overlay-root .lo-toolbar button.lo-btn:hover,
|
||||
#live-overlay-root .lo-toolbar button.lo-btn.is-active {
|
||||
background: rgba(96, 165, 250, 0.18);
|
||||
border-color: rgba(148, 197, 255, 0.4);
|
||||
}
|
||||
#live-overlay-root .lo-toolbar button.lo-btn:focus-visible {
|
||||
outline: 2px solid #22d3ee;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
#live-overlay-root .lo-toolbar button.lo-btn svg {
|
||||
pointer-events: none;
|
||||
}
|
||||
#live-overlay-root .lo-slider {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
#live-overlay-root .lo-slider input[type="range"] {
|
||||
width: 120px;
|
||||
accent-color: #6366f1;
|
||||
}
|
||||
#live-overlay-root .lo-slider span {
|
||||
font-size: 12px;
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
}
|
||||
#live-overlay-root .lo-url {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
}
|
||||
#live-overlay-root .lo-url input {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
background: rgba(15, 23, 42, 0.65);
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
#live-overlay-root .lo-url input:focus-visible {
|
||||
outline: 2px solid #22d3ee;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
#live-overlay-root .lo-frame {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
min-height: 160px;
|
||||
}
|
||||
#live-overlay-root.lo-fullscreen {
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
}
|
||||
#live-overlay-root.lo-fullscreen .lo-toolbar {
|
||||
border-radius: 0;
|
||||
}
|
||||
#live-overlay-root iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: #0f172a;
|
||||
}
|
||||
#live-overlay-root .lo-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image: linear-gradient(to right, rgba(148, 163, 184, 0.3) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(148, 163, 184, 0.3) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
#live-overlay-root.lo-grid-on .lo-grid {
|
||||
opacity: 1;
|
||||
}
|
||||
#live-overlay-root .lo-resize {
|
||||
position: absolute;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
border-right: 2px solid rgba(148, 197, 255, 0.8);
|
||||
border-bottom: 2px solid rgba(148, 197, 255, 0.8);
|
||||
border-radius: 2px;
|
||||
cursor: se-resize;
|
||||
pointer-events: auto;
|
||||
}
|
||||
#live-overlay-root.lo-locked .lo-resize {
|
||||
display: none;
|
||||
}
|
||||
#live-overlay-root .lo-nudge-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 10px;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
border: 1px solid rgba(148, 163, 184, 0.45);
|
||||
border-radius: 10px;
|
||||
padding: 8px;
|
||||
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.35);
|
||||
z-index: 2;
|
||||
}
|
||||
#live-overlay-root .lo-nudge-panel.is-open {
|
||||
display: flex;
|
||||
}
|
||||
#live-overlay-root .lo-nudge-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 28px);
|
||||
gap: 4px;
|
||||
justify-items: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
#live-overlay-root .lo-nudge-grid button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: rgba(96, 165, 250, 0.15);
|
||||
border: 1px solid transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
#live-overlay-root .lo-nudge-grid span {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: block;
|
||||
}
|
||||
#live-overlay-root .lo-nudge-grid button:hover {
|
||||
background: rgba(96, 165, 250, 0.25);
|
||||
}
|
||||
#live-overlay-root .lo-nudge-grid button:focus-visible {
|
||||
outline: 2px solid #22d3ee;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
#live-overlay-root .lo-nudge-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
|
||||
const toolbar = document.createElement('div')
|
||||
toolbar.className = 'lo-toolbar'
|
||||
toolbar.setAttribute('role', 'toolbar')
|
||||
toolbar.setAttribute('aria-label', 'Live overlay controls')
|
||||
|
||||
const title = document.createElement('div')
|
||||
title.className = 'lo-title'
|
||||
title.textContent = 'Live Overlay'
|
||||
toolbar.appendChild(title)
|
||||
|
||||
const toggleVisibilityButton = createButton('Toggle overlay visibility')
|
||||
setButtonIcon(toggleVisibilityButton, 'eye')
|
||||
|
||||
const lockButton = createButton('Toggle overlay lock')
|
||||
const clickThroughButton = createButton('Toggle click-through mode')
|
||||
setButtonIcon(clickThroughButton, 'hand')
|
||||
|
||||
const invertButton = createButton('Invert overlay colors')
|
||||
setButtonIcon(invertButton, 'moon')
|
||||
|
||||
const resizeButton = createButton('Toggle overlay size')
|
||||
setButtonIcon(resizeButton, 'arrows-pointing-out')
|
||||
|
||||
const nudgeWrap = document.createElement('div')
|
||||
nudgeWrap.className = 'lo-nudge-wrap'
|
||||
const nudgeButton = createButton('Open nudge controls')
|
||||
setButtonIcon(nudgeButton, 'arrows-up-down-left-right')
|
||||
|
||||
const gridButton = createButton('Toggle alignment grid')
|
||||
setButtonIcon(gridButton, 'squares-2x2')
|
||||
|
||||
const resetButton = createButton('Reset overlay settings', { toggle: false })
|
||||
setButtonIcon(resetButton, 'arrow-path')
|
||||
|
||||
const helpButton = createButton('Help and usage tips', { toggle: false })
|
||||
setButtonIcon(helpButton, 'question-mark-circle')
|
||||
|
||||
const sliderWrap = document.createElement('label')
|
||||
sliderWrap.className = 'lo-slider'
|
||||
sliderWrap.setAttribute('aria-label', 'Overlay opacity control')
|
||||
sliderWrap.title = 'Adjust overlay opacity'
|
||||
|
||||
const sliderIcon = document.createElement('span')
|
||||
sliderIcon.className = 'lo-slider-icon'
|
||||
sliderIcon.innerHTML = iconCache['adjustments-horizontal']
|
||||
const sliderIconSvg = sliderIcon.querySelector('svg')
|
||||
if (sliderIconSvg) {
|
||||
sliderIconSvg.setAttribute('aria-hidden', 'true')
|
||||
sliderIconSvg.setAttribute('focusable', 'false')
|
||||
sliderIconSvg.setAttribute('width', '20')
|
||||
sliderIconSvg.setAttribute('height', '20')
|
||||
sliderIconSvg.setAttribute('stroke', 'currentColor')
|
||||
if (!sliderIconSvg.getAttribute('fill')) {
|
||||
sliderIconSvg.setAttribute('fill', 'none')
|
||||
}
|
||||
}
|
||||
|
||||
const opacitySlider = document.createElement('input')
|
||||
opacitySlider.className = 'lo-control'
|
||||
opacitySlider.type = 'range'
|
||||
opacitySlider.min = '0'
|
||||
opacitySlider.max = '100'
|
||||
opacitySlider.step = '1'
|
||||
opacitySlider.value = String(Math.round(state.opacity * 100))
|
||||
opacitySlider.setAttribute('aria-valuemin', '0')
|
||||
opacitySlider.setAttribute('aria-valuemax', '100')
|
||||
opacitySlider.setAttribute('aria-valuenow', String(Math.round(state.opacity * 100)))
|
||||
opacitySlider.setAttribute('aria-label', 'Overlay opacity')
|
||||
|
||||
const opacityValue = document.createElement('span')
|
||||
opacityValue.textContent = Math.round(state.opacity * 100) + '%'
|
||||
|
||||
sliderWrap.appendChild(sliderIcon)
|
||||
sliderWrap.appendChild(opacitySlider)
|
||||
sliderWrap.appendChild(opacityValue)
|
||||
|
||||
const urlWrap = document.createElement('div')
|
||||
urlWrap.className = 'lo-url'
|
||||
|
||||
const urlInput = document.createElement('input')
|
||||
urlInput.type = 'url'
|
||||
urlInput.placeholder = 'https://overlay.example.com'
|
||||
urlInput.value = state.overlayUrl
|
||||
urlInput.setAttribute('aria-label', 'Overlay URL')
|
||||
|
||||
urlWrap.appendChild(urlInput)
|
||||
|
||||
toolbar.appendChild(toggleVisibilityButton)
|
||||
toolbar.appendChild(lockButton)
|
||||
toolbar.appendChild(clickThroughButton)
|
||||
toolbar.appendChild(invertButton)
|
||||
toolbar.appendChild(resizeButton)
|
||||
toolbar.appendChild(nudgeWrap)
|
||||
toolbar.appendChild(gridButton)
|
||||
toolbar.appendChild(resetButton)
|
||||
toolbar.appendChild(helpButton)
|
||||
toolbar.appendChild(sliderWrap)
|
||||
toolbar.appendChild(urlWrap)
|
||||
|
||||
const frameWrap = document.createElement('div')
|
||||
frameWrap.className = 'lo-frame'
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.src = currentFrameUrl
|
||||
iframe.title = 'Live overlay frame'
|
||||
iframe.allow = 'clipboard-read; clipboard-write'
|
||||
iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-pointer-lock')
|
||||
frameWrap.appendChild(iframe)
|
||||
|
||||
const gridOverlay = document.createElement('div')
|
||||
gridOverlay.className = 'lo-grid'
|
||||
frameWrap.appendChild(gridOverlay)
|
||||
|
||||
const resizeHandle = document.createElement('div')
|
||||
resizeHandle.className = 'lo-resize'
|
||||
frameWrap.appendChild(resizeHandle)
|
||||
|
||||
const nudgePanel = document.createElement('div')
|
||||
nudgePanel.className = 'lo-nudge-panel'
|
||||
const nudgeTitle = document.createElement('span')
|
||||
nudgeTitle.textContent = 'Nudge overlay'
|
||||
nudgeTitle.style.fontSize = '11px'
|
||||
nudgeTitle.style.opacity = '0.8'
|
||||
nudgePanel.appendChild(nudgeTitle)
|
||||
|
||||
const nudgeGrid = document.createElement('div')
|
||||
nudgeGrid.className = 'lo-nudge-grid'
|
||||
const nudgeButtons = [
|
||||
{ label: '', empty: true },
|
||||
{ label: '↑', dx: 0, dy: -1, aria: 'Nudge up 1px' },
|
||||
{ label: '', empty: true },
|
||||
{ label: '←', dx: -1, dy: 0, aria: 'Nudge left 1px' },
|
||||
{ label: 'Reset', dx: 0, dy: 0, reset: true, aria: 'Reset overlay position' },
|
||||
{ label: '→', dx: 1, dy: 0, aria: 'Nudge right 1px' },
|
||||
{ label: '', empty: true },
|
||||
{ label: '↓', dx: 0, dy: 1, aria: 'Nudge down 1px' },
|
||||
{ label: '', empty: true },
|
||||
]
|
||||
|
||||
nudgeButtons.forEach(({ label, dx, dy, reset, aria, empty }) => {
|
||||
if (empty) {
|
||||
const spacer = document.createElement('span')
|
||||
nudgeGrid.appendChild(spacer)
|
||||
return
|
||||
}
|
||||
const btn = document.createElement('button')
|
||||
btn.type = 'button'
|
||||
btn.textContent = label
|
||||
btn.setAttribute('aria-label', aria || label)
|
||||
btn.addEventListener('click', () => {
|
||||
if (reset) {
|
||||
state.position = { ...DEFAULTS.position }
|
||||
applySizeAndPosition()
|
||||
scheduleSave({ position: { ...state.position } })
|
||||
return
|
||||
}
|
||||
adjustPosition(dx, dy)
|
||||
})
|
||||
nudgeGrid.appendChild(btn)
|
||||
})
|
||||
|
||||
nudgePanel.appendChild(nudgeGrid)
|
||||
const nudgeHint = document.createElement('span')
|
||||
nudgeHint.textContent = 'Hold Shift for 10px steps'
|
||||
nudgeHint.style.fontSize = '10px'
|
||||
nudgeHint.style.opacity = '0.65'
|
||||
nudgeHint.style.textAlign = 'center'
|
||||
nudgePanel.appendChild(nudgeHint)
|
||||
nudgeWrap.appendChild(nudgeButton)
|
||||
nudgeWrap.appendChild(nudgePanel)
|
||||
|
||||
root.appendChild(toolbar)
|
||||
root.appendChild(frameWrap)
|
||||
|
||||
document.body.appendChild(root)
|
||||
|
||||
applySizeAndPosition()
|
||||
applyFrameOptions()
|
||||
updateToggleIcons()
|
||||
|
||||
let dragActive = false
|
||||
let dragStart = { x: 0, y: 0 }
|
||||
let origin = { x: 0, y: 0 }
|
||||
|
||||
function handlePointerDown(event) {
|
||||
if (state.locked || fullscreen) return
|
||||
if (event.button !== 0) return
|
||||
if (event.target.closest('.lo-control')) return
|
||||
dragActive = true
|
||||
dragStart = { x: event.clientX, y: event.clientY }
|
||||
origin = { ...state.position }
|
||||
title.setPointerCapture(event.pointerId)
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
function handlePointerMove(event) {
|
||||
if (!dragActive) return
|
||||
const deltaX = event.clientX - dragStart.x
|
||||
const deltaY = event.clientY - dragStart.y
|
||||
state.position.x = origin.x + deltaX
|
||||
state.position.y = origin.y + deltaY
|
||||
applySizeAndPosition()
|
||||
}
|
||||
|
||||
function handlePointerUp(event) {
|
||||
if (!dragActive) return
|
||||
dragActive = false
|
||||
title.releasePointerCapture(event.pointerId)
|
||||
scheduleSave({ position: { ...state.position } })
|
||||
}
|
||||
|
||||
title.addEventListener('pointerdown', handlePointerDown)
|
||||
title.addEventListener('pointermove', handlePointerMove)
|
||||
title.addEventListener('pointerup', handlePointerUp)
|
||||
title.addEventListener('pointercancel', handlePointerUp)
|
||||
|
||||
let resizing = false
|
||||
let resizeStart = { x: 0, y: 0 }
|
||||
let sizeOrigin = { width: 0, height: 0 }
|
||||
|
||||
resizeHandle.addEventListener('pointerdown', (event) => {
|
||||
if (state.locked || fullscreen) return
|
||||
resizing = true
|
||||
resizeStart = { x: event.clientX, y: event.clientY }
|
||||
sizeOrigin = { ...state.size }
|
||||
resizeHandle.setPointerCapture(event.pointerId)
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
resizeHandle.addEventListener('pointermove', (event) => {
|
||||
if (!resizing) return
|
||||
const deltaX = event.clientX - resizeStart.x
|
||||
const deltaY = event.clientY - resizeStart.y
|
||||
state.size.width = sizeOrigin.width + deltaX
|
||||
state.size.height = sizeOrigin.height + deltaY
|
||||
applySizeAndPosition()
|
||||
})
|
||||
|
||||
function finishResize(event) {
|
||||
if (!resizing) return
|
||||
resizing = false
|
||||
resizeHandle.releasePointerCapture(event.pointerId)
|
||||
scheduleSave({ size: { ...state.size } })
|
||||
if (!fullscreen) {
|
||||
savedState.size = { ...state.size }
|
||||
}
|
||||
}
|
||||
|
||||
resizeHandle.addEventListener('pointerup', finishResize)
|
||||
resizeHandle.addEventListener('pointercancel', finishResize)
|
||||
|
||||
toggleVisibilityButton.addEventListener('click', () => {
|
||||
overlayVisible = !overlayVisible
|
||||
frameWrap.style.display = overlayVisible ? 'block' : 'none'
|
||||
iframe.setAttribute('aria-hidden', overlayVisible ? 'false' : 'true')
|
||||
if (!overlayVisible) {
|
||||
nudgePanel.classList.remove('is-open')
|
||||
nudgeButton.classList.remove('is-active')
|
||||
nudgeButton.setAttribute('aria-pressed', 'false')
|
||||
}
|
||||
updateToggleIcons()
|
||||
})
|
||||
|
||||
lockButton.addEventListener('click', () => {
|
||||
state.locked = !state.locked
|
||||
applyFrameOptions()
|
||||
if (state.locked) {
|
||||
nudgePanel.classList.remove('is-open')
|
||||
nudgeButton.classList.remove('is-active')
|
||||
nudgeButton.setAttribute('aria-pressed', 'false')
|
||||
}
|
||||
scheduleSave({ locked: state.locked })
|
||||
})
|
||||
|
||||
clickThroughButton.addEventListener('click', () => {
|
||||
state.clickThrough = !state.clickThrough
|
||||
applyFrameOptions()
|
||||
scheduleSave({ clickThrough: state.clickThrough })
|
||||
})
|
||||
|
||||
invertButton.addEventListener('click', () => {
|
||||
state.invertEnabled = !state.invertEnabled
|
||||
applyFrameOptions()
|
||||
scheduleSave({ invertEnabled: state.invertEnabled })
|
||||
})
|
||||
|
||||
resizeButton.addEventListener('click', () => {
|
||||
fullscreen = !fullscreen
|
||||
if (fullscreen) {
|
||||
savedSize = { size: { ...state.size }, position: { ...state.position } }
|
||||
state.position = { x: 0, y: 0 }
|
||||
} else {
|
||||
state.size = { ...savedSize.size }
|
||||
state.position = { ...savedSize.position }
|
||||
}
|
||||
applySizeAndPosition()
|
||||
updateToggleIcons()
|
||||
scheduleSave({ size: { ...state.size }, position: { ...state.position } })
|
||||
if (!fullscreen) {
|
||||
savedState = { size: { ...state.size }, position: { ...state.position } }
|
||||
}
|
||||
})
|
||||
|
||||
nudgeButton.addEventListener('click', () => {
|
||||
if (state.locked) return
|
||||
const isOpen = !nudgePanel.classList.contains('is-open')
|
||||
nudgePanel.classList.toggle('is-open', isOpen)
|
||||
nudgeButton.classList.toggle('is-active', isOpen)
|
||||
nudgeButton.setAttribute('aria-pressed', String(isOpen))
|
||||
})
|
||||
|
||||
gridButton.addEventListener('click', () => {
|
||||
state.gridEnabled = !state.gridEnabled
|
||||
applyFrameOptions()
|
||||
scheduleSave({ gridEnabled: state.gridEnabled })
|
||||
})
|
||||
|
||||
resetButton.addEventListener('click', () => {
|
||||
Object.assign(state, JSON.parse(JSON.stringify(DEFAULTS)))
|
||||
state.overlayUrl = window.location.href
|
||||
currentFrameUrl = state.overlayUrl
|
||||
savedState = { size: { ...state.size }, position: { ...state.position } }
|
||||
overlayVisible = true
|
||||
fullscreen = false
|
||||
frameWrap.style.display = 'block'
|
||||
nudgePanel.classList.remove('is-open')
|
||||
nudgeButton.classList.remove('is-active')
|
||||
nudgeButton.setAttribute('aria-pressed', 'false')
|
||||
applySizeAndPosition()
|
||||
applyFrameOptions()
|
||||
updateToggleIcons()
|
||||
const resetPayload = JSON.parse(JSON.stringify({
|
||||
...DEFAULTS,
|
||||
overlayUrl: state.overlayUrl,
|
||||
position: { ...state.position },
|
||||
size: { ...state.size },
|
||||
}))
|
||||
scheduleSave(resetPayload)
|
||||
})
|
||||
|
||||
helpButton.addEventListener('click', () => {
|
||||
const message = [
|
||||
'Live Overlay tips:',
|
||||
'• Drag the title bar to reposition.',
|
||||
'• Use the resize corner or the expand button for full screen.',
|
||||
'• Toggle click-through mode to interact with the page below.',
|
||||
'• Settings persist automatically via chrome.storage.local.'
|
||||
].join('\n')
|
||||
alert(message)
|
||||
})
|
||||
|
||||
opacitySlider.addEventListener('input', () => {
|
||||
const value = Number(opacitySlider.value) / 100
|
||||
state.opacity = Math.min(Math.max(value, 0), 1)
|
||||
opacitySlider.setAttribute('aria-valuenow', String(Math.round(state.opacity * 100)))
|
||||
applyFrameOptions()
|
||||
})
|
||||
|
||||
opacitySlider.addEventListener('change', () => {
|
||||
scheduleSave({ opacity: state.opacity })
|
||||
})
|
||||
|
||||
urlInput.addEventListener('change', () => {
|
||||
let url = urlInput.value.trim()
|
||||
if (url && !/^https?:\/\//i.test(url)) {
|
||||
url = `https://${url}`
|
||||
urlInput.value = url
|
||||
}
|
||||
state.overlayUrl = url || window.location.href
|
||||
applyFrameOptions()
|
||||
scheduleSave({ overlayUrl: state.overlayUrl })
|
||||
})
|
||||
|
||||
urlInput.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
urlInput.dispatchEvent(new Event('change'))
|
||||
urlInput.blur()
|
||||
}
|
||||
})
|
||||
|
||||
function handleKeydown(event) {
|
||||
if (state.locked || fullscreen) return
|
||||
const step = event.shiftKey ? 10 : 1
|
||||
switch (event.key) {
|
||||
case 'ArrowUp':
|
||||
adjustPosition(0, -step)
|
||||
event.preventDefault()
|
||||
break
|
||||
case 'ArrowDown':
|
||||
adjustPosition(0, step)
|
||||
event.preventDefault()
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
adjustPosition(-step, 0)
|
||||
event.preventDefault()
|
||||
break
|
||||
case 'ArrowRight':
|
||||
adjustPosition(step, 0)
|
||||
event.preventDefault()
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeydown, true)
|
||||
|
||||
window.__liveOverlayCleanup = () => {
|
||||
window.removeEventListener('keydown', handleKeydown, true)
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer)
|
||||
saveTimer = null
|
||||
if (Object.keys(pendingSave).length > 0) {
|
||||
chrome.storage.local.set(pendingSave, () => {
|
||||
if (chrome.runtime.lastError) {
|
||||
console.warn('LiveOverlay storage set error', chrome.runtime.lastError)
|
||||
}
|
||||
})
|
||||
for (const key of Object.keys(pendingSave)) {
|
||||
delete pendingSave[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
if (style.isConnected) {
|
||||
style.remove()
|
||||
}
|
||||
}
|
||||
})()
|
||||
126
tools/gen-icons.mjs
Normal file
@ -0,0 +1,126 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { deflateSync } from 'node:zlib'
|
||||
|
||||
const ICON_DIR = path.resolve('icons')
|
||||
const BASE_SVG_PATH = path.join(ICON_DIR, 'base.svg')
|
||||
const SIZES = [128, 48, 32, 16]
|
||||
|
||||
const start = [0x63, 0x66, 0xf1]
|
||||
const end = [0x22, 0xd3, 0xee]
|
||||
const outerColor = [255, 255, 255]
|
||||
const innerColor = [15, 23, 42]
|
||||
|
||||
function ensureBaseSvg() {
|
||||
if (fs.existsSync(BASE_SVG_PATH)) return
|
||||
fs.mkdirSync(ICON_DIR, { recursive: true })
|
||||
const svg = `<?xml version="1.0" encoding="UTF-8"?>\n<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">\n <defs>\n <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">\n <stop offset="0%" stop-color="#6366f1" />\n <stop offset="100%" stop-color="#22d3ee" />\n </linearGradient>\n </defs>\n <rect width="128" height="128" rx="24" fill="url(#g)" />\n <circle cx="64" cy="64" r="28" fill="#ffffff" opacity="0.92" />\n <circle cx="64" cy="64" r="10" fill="#0f172a" opacity="0.9" />\n</svg>`
|
||||
fs.writeFileSync(BASE_SVG_PATH, svg)
|
||||
}
|
||||
|
||||
function gradientColor(x, y, size) {
|
||||
const denom = size > 1 ? size - 1 : 1
|
||||
const t = (x / denom + y / denom) / 2
|
||||
return start.map((s, i) => Math.round(s + (end[i] - s) * t))
|
||||
}
|
||||
|
||||
function blend(base, overlay, alpha) {
|
||||
return base.map((channel, i) => Math.round(channel * (1 - alpha) + overlay[i] * alpha))
|
||||
}
|
||||
|
||||
function buildCrcTable() {
|
||||
const table = new Uint32Array(256)
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n
|
||||
for (let k = 0; k < 8; k++) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
|
||||
}
|
||||
table[n] = c >>> 0
|
||||
}
|
||||
return table
|
||||
}
|
||||
|
||||
const CRC_TABLE = buildCrcTable()
|
||||
|
||||
function crc32(buffer) {
|
||||
let crc = 0xffffffff
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
crc = CRC_TABLE[(crc ^ buffer[i]) & 0xff] ^ (crc >>> 8)
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0
|
||||
}
|
||||
|
||||
function chunk(tag, data) {
|
||||
const tagBuffer = Buffer.from(tag, 'ascii')
|
||||
const lengthBuffer = Buffer.alloc(4)
|
||||
lengthBuffer.writeUInt32BE(data.length)
|
||||
const crcBuffer = Buffer.alloc(4)
|
||||
crcBuffer.writeUInt32BE(crc32(Buffer.concat([tagBuffer, data])))
|
||||
return Buffer.concat([lengthBuffer, tagBuffer, data, crcBuffer])
|
||||
}
|
||||
|
||||
function buildPng(size) {
|
||||
const width = size
|
||||
const height = size
|
||||
const scale = size / 128
|
||||
const outerRadius = 28 * scale
|
||||
const innerRadius = 10 * scale
|
||||
const cx = (size - 1) / 2
|
||||
const cy = (size - 1) / 2
|
||||
const stride = 1 + width * 4
|
||||
const raw = Buffer.alloc(stride * height)
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
const rowOffset = y * stride
|
||||
raw[rowOffset] = 0
|
||||
for (let x = 0; x < width; x++) {
|
||||
const baseColor = gradientColor(x, y, size)
|
||||
let color = baseColor
|
||||
const dx = x - cx
|
||||
const dy = y - cy
|
||||
const distance = Math.hypot(dx, dy)
|
||||
if (distance <= outerRadius) {
|
||||
color = blend(color, outerColor, 0.85)
|
||||
}
|
||||
if (distance <= innerRadius) {
|
||||
color = innerColor
|
||||
}
|
||||
const offset = rowOffset + 1 + x * 4
|
||||
raw[offset] = color[0]
|
||||
raw[offset + 1] = color[1]
|
||||
raw[offset + 2] = color[2]
|
||||
raw[offset + 3] = 255
|
||||
}
|
||||
}
|
||||
|
||||
const compressed = deflateSync(raw)
|
||||
const ihdr = Buffer.alloc(13)
|
||||
ihdr.writeUInt32BE(width, 0)
|
||||
ihdr.writeUInt32BE(height, 4)
|
||||
ihdr[8] = 8
|
||||
ihdr[9] = 6
|
||||
ihdr[10] = 0
|
||||
ihdr[11] = 0
|
||||
ihdr[12] = 0
|
||||
|
||||
const png = Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
chunk('IHDR', ihdr),
|
||||
chunk('IDAT', compressed),
|
||||
chunk('IEND', Buffer.alloc(0)),
|
||||
])
|
||||
|
||||
fs.mkdirSync(ICON_DIR, { recursive: true })
|
||||
const filePath = path.join(ICON_DIR, `icon-${size}.png`)
|
||||
fs.writeFileSync(filePath, png)
|
||||
console.log('Generated', filePath)
|
||||
}
|
||||
|
||||
function main() {
|
||||
ensureBaseSvg()
|
||||
for (const size of SIZES) {
|
||||
buildPng(size)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||