diff --git a/bundled-addons/screenshot/addon.json b/bundled-addons/screenshot/addon.json new file mode 100644 index 0000000..eaee531 --- /dev/null +++ b/bundled-addons/screenshot/addon.json @@ -0,0 +1,10 @@ +{ + "id": "screenshot", + "name": "Screenshot", + "version": "0.1.0", + "description": "Capture the current tab — visible viewport, entire scrollable page, or a rectangle you draw. Saves to Downloads as PNG (or JPEG for smaller files).", + "author": "Silent Mode", + "icon": "📸", + "main": "index.js", + "capabilities": ["sidebar-panel", "capture-tab"] +} diff --git a/bundled-addons/screenshot/index.js b/bundled-addons/screenshot/index.js new file mode 100644 index 0000000..6940bba --- /dev/null +++ b/bundled-addons/screenshot/index.js @@ -0,0 +1,49 @@ +// Screenshot — capture the active tab. The panel drives everything through +// two messages ("capture" and "save"); main-side capture-tab does the actual +// pixel work. This module is a thin router. +const fs = require("node:fs"); +const path = require("node:path"); + +module.exports = { + activate(api) { + api.registerSidebarPanel({ + id: "main", + title: "Screenshot", + icon: "📸", + page: "panel.html", + }); + + // Region-select overlay source, loaded once at activation. The panel + // triggers region mode; main injects this into the tab and awaits the + // rect it resolves with. Keeping the DOM code in a sibling file (not a + // JS string in main) lets someone edit the overlay UX without touching + // the browser core. + let overlaySource = ""; + try { overlaySource = fs.readFileSync(path.join(api.folder, "panel-preload.js"), "utf8"); } + catch (e) { api.log("panel-preload.js not readable:", e?.message); } + + api.onMessage("capture", async (payload) => { + const mode = String(payload?.mode || "visible"); + const format = payload?.format === "jpeg" ? "jpeg" : "png"; + const quality = Number(payload?.quality) || 90; + const opts = { mode, format, quality }; + if (mode === "region") opts.overlaySource = overlaySource; + return api.captureTab(opts); + }); + + api.onMessage("save", async (payload) => { + const dataUrl = String(payload?.dataUrl || ""); + const host = String(payload?.host || "tab"); + const format = payload?.format === "jpeg" ? "jpeg" : "png"; + const ext = format === "jpeg" ? "jpg" : "png"; + // ISO date, but with the colons Windows won't accept in filenames swapped + // out. Second precision is enough; the filename is human-oriented. + const iso = new Date().toISOString().replace(/[:.]/g, "-").replace(/-\d{3}Z$/, "Z"); + const safeHost = (host || "tab").replace(/[^a-z0-9._-]+/gi, "_") || "tab"; + const filename = `theseus-screenshot-${safeHost}-${iso}.${ext}`; + return api.saveCapture({ dataUrl, filename }); + }); + + api.log("registered screenshot panel"); + }, +}; diff --git a/bundled-addons/screenshot/panel-preload.js b/bundled-addons/screenshot/panel-preload.js new file mode 100644 index 0000000..2fd9407 --- /dev/null +++ b/bundled-addons/screenshot/panel-preload.js @@ -0,0 +1,115 @@ +// Region-select overlay. Injected into the target tab by main.js via +// wc.executeJavaScript(); the last expression is what executeJavaScript +// resolves with, so the whole file is an IIFE that returns a Promise for +// {x, y, w, h} — or null when the user cancels. +// +// Coordinates are CSS pixels relative to the viewport (not the document), +// which is exactly what WebContents.capturePage(rect) wants. +// +// Runs in the page's world, so it shares globals with the page. That's +// acceptable here: the overlay lives for a few seconds while the user +// drags, then removes itself and its listeners. +(() => { + return new Promise((resolve) => { + // If a previous overlay is still up (double-click on the panel button), + // tear it down before installing a fresh one. + const prev = document.getElementById("__theseus_screenshot_overlay__"); + if (prev) prev.remove(); + + const overlay = document.createElement("div"); + overlay.id = "__theseus_screenshot_overlay__"; + overlay.style.cssText = [ + "position:fixed", "inset:0", "z-index:2147483647", + "cursor:crosshair", + "background:rgba(11,14,20,0.35)", + "user-select:none", "-webkit-user-select:none", + ].join(";"); + + const box = document.createElement("div"); + box.style.cssText = [ + "position:absolute", "left:0", "top:0", "width:0", "height:0", + "border:1px solid #d6ff3d", + "box-shadow:0 0 0 9999px rgba(11,14,20,0.35)", + "background:transparent", + "pointer-events:none", + ].join(";"); + box.hidden = true; + overlay.appendChild(box); + + const hint = document.createElement("div"); + hint.textContent = "Drag to select a region — Esc to cancel"; + hint.style.cssText = [ + "position:absolute", "top:12px", "left:50%", "transform:translateX(-50%)", + "padding:6px 12px", "border-radius:999px", + "background:rgba(11,14,20,0.92)", "color:#e7eaf1", + "font:12px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif", + "border:1px solid rgba(214,255,61,0.30)", "pointer-events:none", + ].join(";"); + overlay.appendChild(hint); + + document.documentElement.appendChild(overlay); + + let startX = 0, startY = 0, dragging = false; + let curX = 0, curY = 0; + + const dpr = window.devicePixelRatio || 1; + + function updateBox(x, y, w, h) { + box.style.left = x + "px"; + box.style.top = y + "px"; + box.style.width = w + "px"; + box.style.height = h + "px"; + box.hidden = false; + } + + function finish(result) { + cleanup(); + resolve(result); + } + + function cleanup() { + overlay.removeEventListener("mousedown", onDown, true); + window.removeEventListener("mousemove", onMove, true); + window.removeEventListener("mouseup", onUp, true); + window.removeEventListener("keydown", onKey, true); + overlay.removeEventListener("contextmenu", onCtx, true); + try { overlay.remove(); } catch {} + } + + function onDown(e) { + if (e.button !== 0) return; + e.preventDefault(); e.stopPropagation(); + dragging = true; + startX = e.clientX; startY = e.clientY; + curX = startX; curY = startY; + updateBox(startX, startY, 0, 0); + } + function onMove(e) { + if (!dragging) return; + curX = e.clientX; curY = e.clientY; + const x = Math.min(startX, curX), y = Math.min(startY, curY); + const w = Math.abs(curX - startX), h = Math.abs(curY - startY); + updateBox(x, y, w, h); + } + function onUp(e) { + if (!dragging) return; + dragging = false; + const x = Math.min(startX, curX), y = Math.min(startY, curY); + const w = Math.abs(curX - startX), h = Math.abs(curY - startY); + // A click-with-no-drag is treated as cancel — capturing a 0px region + // isn't useful, and it lets the user bail without hunting for Esc. + if (w < 4 || h < 4) { finish(null); return; } + finish({ x, y, w, h, dpr }); + } + function onKey(e) { + if (e.key === "Escape") { e.preventDefault(); finish(null); } + } + function onCtx(e) { e.preventDefault(); finish(null); } + + overlay.addEventListener("mousedown", onDown, true); + window.addEventListener("mousemove", onMove, true); + window.addEventListener("mouseup", onUp, true); + window.addEventListener("keydown", onKey, true); + overlay.addEventListener("contextmenu", onCtx, true); + }); +})() diff --git a/bundled-addons/screenshot/panel.html b/bundled-addons/screenshot/panel.html new file mode 100644 index 0000000..d8a76a8 --- /dev/null +++ b/bundled-addons/screenshot/panel.html @@ -0,0 +1,230 @@ + + + + +Screenshot + + + +
+
📸 Screenshot
+
+
+
+
+ + + +
+ +
+ No capture yet. Pick a mode above. + +
+ +
+ + +
+ +
+ Format: + + +
+ +
+ + +
+ +
+
+ + + +