feat(theseus/screenshot): full-tab editor + toolbar-menu + open-tab capabilities
Reworks the screenshot addon into the flow the user asked for: the dock icon opens a small dropdown menu (Visible viewport / Full page / Region…) instead of the sidebar picker, and each capture opens a full browser tab hosting an editor. Two new addon-host capabilities land alongside: - toolbar-menu: the addon declares an icon + item list in its manifest; the chrome dock renders a button that, on click, opens a small menu and dispatches the selection to the addon via addon-menu-select IPC. - open-tab: api.openTab(path) opens a browser tab whose URL is the addon's local file. Origin-gated per addon; the editor uses a dedicated addon-tab-preload for its main → renderer bridge. Editor page (editor.html/js/css): - Crop, arrow, rectangle, circle, freehand pen, text, blur - Colour swatches (red / yellow / acid / white / black), 3 stroke widths - Undo/redo command stack, zoom controls - Save PNG (goes through the download pipeline, chip picks it up) - Copy to clipboard via ClipboardItem
This commit is contained in:
parent
c9106d4ede
commit
15694195d6
9 changed files with 966 additions and 279 deletions
16
addon-tab-preload.js
Normal file
16
addon-tab-preload.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// Preload for full-tab pages opened by api.openTab("<file>", {query}) — the
|
||||
// "open-tab" capability. Same window.silentmode surface as the sidebar
|
||||
// preload (storage / invoke / on) but WITHOUT the sidebar's picker strip and
|
||||
// resize grip, which have no place in a normal tab. Main derives the add-on
|
||||
// identity from the sender's file:// URL, so a page hosted anywhere else
|
||||
// gets nothing back from these handlers.
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
contextBridge.exposeInMainWorld("silentmode", {
|
||||
storage: {
|
||||
get: (key, fallback = null) => ipcRenderer.invoke("addon-storage-get", key, fallback),
|
||||
set: (key, value) => ipcRenderer.invoke("addon-storage-set", key, value),
|
||||
all: () => ipcRenderer.invoke("addon-storage-all"),
|
||||
},
|
||||
invoke: (msg, payload) => ipcRenderer.invoke("addon-msg", String(msg), payload),
|
||||
on: (msg, cb) => ipcRenderer.on("addon-event", (_e, name, payload) => { if (name === msg) cb(payload); }),
|
||||
});
|
||||
100
addons-host.js
100
addons-host.js
|
|
@ -37,7 +37,18 @@ const KNOWN_CAPABILITIES = new Set([
|
|||
// downloads pipeline. The add-on sees pixels of whatever the
|
||||
// current tab is showing, so this is the same trust bar as a
|
||||
// page-inject add-on that matches "*://*/*".
|
||||
// toolbar-menu: manifest["toolbar-menu"] = { title?, icon?, items:[{id,label,icon?}] }
|
||||
// — chrome renders a dropdown under the add-on's dock icon;
|
||||
// picking an item dispatches "menu-select" with {id} to the
|
||||
// add-on's onMessage("menu-select", …) handler.
|
||||
// open-tab: api.openTab(pathOrUrl, {query?}) — for a bare http(s) URL
|
||||
// this stays available without the capability (legacy).
|
||||
// Declaring "open-tab" additionally lets the add-on open
|
||||
// one of its OWN HTML files as a full Theseus tab, with a
|
||||
// lean preload so the page can keep talking to the add-on
|
||||
// via window.silentmode.invoke().
|
||||
"vault-derive", "page-inject", "approval-modal", "capture-tab",
|
||||
"toolbar-menu", "open-tab",
|
||||
]);
|
||||
|
||||
// Chrome-style match pattern → predicate. "<scheme>://<host>/<path>" where
|
||||
|
|
@ -99,13 +110,39 @@ function validateManifest(raw, folderName) {
|
|||
if (!origins.length) throw new Error(`addon "${id}": page-inject.origins must list at least one pattern`);
|
||||
pageInject = { preload, origins, matchers: origins.map(compileOriginPattern) };
|
||||
}
|
||||
return { id, name, version, description, author, icon, main, capabilities, pageInject };
|
||||
let toolbarMenu = null;
|
||||
if (capabilities.includes("toolbar-menu")) {
|
||||
const tm = m["toolbar-menu"];
|
||||
if (!tm || typeof tm !== "object") {
|
||||
throw new Error(`addon "${id}": "toolbar-menu" capability needs a "toolbar-menu" manifest block`);
|
||||
}
|
||||
const items = Array.isArray(tm.items) ? tm.items : [];
|
||||
if (!items.length) throw new Error(`addon "${id}": toolbar-menu.items must list at least one entry`);
|
||||
const seen = new Set();
|
||||
const cleanItems = items.map((it, idx) => {
|
||||
const iid = String(it && it.id || "").trim();
|
||||
if (!iid || !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(iid)) {
|
||||
throw new Error(`addon "${id}": toolbar-menu.items[${idx}].id is required and must match [a-z0-9._-]`);
|
||||
}
|
||||
if (seen.has(iid)) throw new Error(`addon "${id}": toolbar-menu.items[${idx}].id "${iid}" duplicates an earlier entry`);
|
||||
seen.add(iid);
|
||||
const label = String(it.label || iid);
|
||||
const itemIcon = it.icon == null ? "" : String(it.icon);
|
||||
return { id: iid, label, icon: itemIcon };
|
||||
});
|
||||
toolbarMenu = {
|
||||
title: tm.title == null ? name : String(tm.title),
|
||||
icon: tm.icon == null ? icon : String(tm.icon),
|
||||
items: cleanItems,
|
||||
};
|
||||
}
|
||||
return { id, name, version, description, author, icon, main, capabilities, pageInject, toolbarMenu };
|
||||
}
|
||||
|
||||
// Loader singleton. `discoverAndActivate(opts)` returns a snapshot the rest
|
||||
// of the app queries via `getActive()` / `getInstalled()`.
|
||||
class AddonHost {
|
||||
constructor({ addonsDir, dataDir, isDisabled, logger, setSessionProxy, vaultDerive, approvalModal, emitToPanel, hostRequire, hostImport, openTab, captureTab, saveCapture }) {
|
||||
constructor({ addonsDir, dataDir, isDisabled, logger, setSessionProxy, vaultDerive, approvalModal, emitToPanel, hostRequire, hostImport, openTab, openAddonTab, captureTab, saveCapture }) {
|
||||
this.addonsDir = addonsDir;
|
||||
this.dataDir = dataDir;
|
||||
this.isDisabled = isDisabled || (() => false);
|
||||
|
|
@ -125,6 +162,9 @@ class AddonHost {
|
|||
// Node; hostImport resolves them from the app tree and import()s them.
|
||||
this._hostImport = typeof hostImport === "function" ? hostImport : null;
|
||||
this._openTab = typeof openTab === "function" ? openTab : null;
|
||||
// open-tab: opens one of the add-on's own HTML files as a full Theseus tab.
|
||||
// Signature: (addonId, relPath, queryString) => Promise<void>.
|
||||
this._openAddonTab = typeof openAddonTab === "function" ? openAddonTab : null;
|
||||
// Session-proxy hook — injected by main so add-ons can swap the default
|
||||
// session's proxy rules (e.g. a "route everything through my VPS" add-on).
|
||||
// Signature: (rules: string | { proxyRules, proxyBypassRules }) => Promise<void>
|
||||
|
|
@ -290,12 +330,37 @@ class AddonHost {
|
|||
if (!this._hostImport) throw new Error(`api.import unavailable (host not wired)`);
|
||||
return this._hostImport(name);
|
||||
},
|
||||
// Open a URL in a new Theseus tab (http/https only).
|
||||
openTab: (url) => {
|
||||
const u = String(url || "");
|
||||
if (!/^https?:\/\//i.test(u)) throw new Error("openTab: http(s) URLs only");
|
||||
// Open a new Theseus tab. Two shapes:
|
||||
// - api.openTab("https://…") — no capability needed
|
||||
// - api.openTab("editor.html", { query: {...} }) — opens one of the
|
||||
// add-on's OWN files as a full tab; requires the "open-tab" cap.
|
||||
// Path is resolved inside the add-on folder and rejected if it
|
||||
// escapes it (path traversal). Query is URL-encoded. The page
|
||||
// loads under addon-tab-preload.js so window.silentmode.invoke()
|
||||
// reaches the same handlers as a sidebar panel — main gates by
|
||||
// sender URL so a page hosted anywhere else gets nothing back.
|
||||
openTab: (pathOrUrl, opts) => {
|
||||
const s = String(pathOrUrl || "");
|
||||
// Bare http(s) URL with no opts — legacy behaviour, unchanged.
|
||||
if (/^https?:\/\//i.test(s) && !opts) {
|
||||
if (!this._openTab) throw new Error("openTab unavailable (host not wired)");
|
||||
this._openTab(u, manifest.id);
|
||||
this._openTab(s, manifest.id);
|
||||
return;
|
||||
}
|
||||
if (!manifest.capabilities.includes("open-tab")) {
|
||||
throw new Error(`add-on "${manifest.id}" must declare the "open-tab" capability in addon.json to open its own files in a tab`);
|
||||
}
|
||||
if (!this._openAddonTab) throw new Error("openAddonTab unavailable (host not wired)");
|
||||
if (!s || path.isAbsolute(s) || s.includes("..")) {
|
||||
throw new Error(`openTab: path must be a relative file inside the add-on folder (got "${s}")`);
|
||||
}
|
||||
let qs = "";
|
||||
if (opts && opts.query && typeof opts.query === "object") {
|
||||
const usp = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(opts.query)) usp.append(String(k), String(v));
|
||||
qs = usp.toString();
|
||||
}
|
||||
return this._openAddonTab(manifest.id, s, qs);
|
||||
},
|
||||
// Panel ↔ activate() messaging. Panels (and, for page-inject add-ons,
|
||||
// injected page bridges) call into the add-on with a message name +
|
||||
|
|
@ -418,6 +483,7 @@ class AddonHost {
|
|||
error: error || null,
|
||||
})),
|
||||
sidebarPanels: this.getSidebarPanels(),
|
||||
toolbarMenus: this.getToolbarMenus(),
|
||||
};
|
||||
}
|
||||
getSidebarPanels() {
|
||||
|
|
@ -425,8 +491,28 @@ class AddonHost {
|
|||
for (const active of this._active.values()) out.push(...active.sidebarPanels);
|
||||
return out;
|
||||
}
|
||||
// Menu declarations from every active add-on that carries a toolbar-menu
|
||||
// manifest block. Chrome renders one dock button per entry, opens the
|
||||
// dropdown, then dispatches "menu-select" with the picked item id.
|
||||
getToolbarMenus() {
|
||||
const out = [];
|
||||
for (const active of this._active.values()) {
|
||||
const tm = active.manifest.toolbarMenu;
|
||||
if (!tm) continue;
|
||||
out.push({
|
||||
addonId: active.manifest.id,
|
||||
title: tm.title,
|
||||
icon: tm.icon,
|
||||
items: tm.items.map((it) => ({ id: it.id, label: it.label, icon: it.icon })),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
getInstalled() { return this._installed.slice(); }
|
||||
isActive(id) { return this._active.has(id); }
|
||||
// Absolute folder of an active add-on, or null. Public so main can resolve
|
||||
// add-on-relative paths (openAddonTab) without reaching into internals.
|
||||
folderOf(id) { const a = this._active.get(id); return a ? a.folder : null; }
|
||||
}
|
||||
|
||||
module.exports = { AddonHost, KNOWN_CAPABILITIES, validateManifest, compileOriginPattern };
|
||||
|
|
|
|||
|
|
@ -1,10 +1,19 @@
|
|||
{
|
||||
"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).",
|
||||
"version": "0.2.0",
|
||||
"description": "Capture the current tab — visible viewport, entire scrollable page, or a rectangle you draw. Pick a mode from a toolbar dropdown; the capture opens in a full-tab editor (crop, annotate, redact, save).",
|
||||
"author": "Silent Mode",
|
||||
"icon": "📸",
|
||||
"main": "index.js",
|
||||
"capabilities": ["sidebar-panel", "capture-tab"]
|
||||
"capabilities": ["toolbar-menu", "capture-tab", "open-tab"],
|
||||
"toolbar-menu": {
|
||||
"title": "Screenshot",
|
||||
"icon": "📸",
|
||||
"items": [
|
||||
{ "id": "visible", "label": "Visible viewport", "icon": "🖼️" },
|
||||
{ "id": "full", "label": "Full page", "icon": "📄" },
|
||||
{ "id": "region", "label": "Region…", "icon": "✂️" }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
111
bundled-addons/screenshot/editor.html
Normal file
111
bundled-addons/screenshot/editor.html
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Screenshot editor</title>
|
||||
<link rel="stylesheet" href="editor.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="toolbar" id="toolbar">
|
||||
<span class="name" id="name">screenshot</span>
|
||||
<span class="sep"></span>
|
||||
|
||||
<!-- Tool selector. Each button carries data-tool; buttons for the
|
||||
drawing shapes and crop go here. -->
|
||||
<button class="tool active" data-tool="select" title="Select / no tool">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 2l10 5-4 1-1 4z"/></svg>
|
||||
</button>
|
||||
<button class="tool" data-tool="crop" title="Crop">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 1v11h11M1 4h11v11"/></svg>
|
||||
</button>
|
||||
<button class="tool" data-tool="arrow" title="Arrow">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M3 13L13 3M13 3H8M13 3v5"/></svg>
|
||||
</button>
|
||||
<button class="tool" data-tool="rect" title="Rectangle">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2.5" y="3.5" width="11" height="9"/></svg>
|
||||
</button>
|
||||
<button class="tool" data-tool="ellipse" title="Ellipse">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><ellipse cx="8" cy="8" rx="5.5" ry="4"/></svg>
|
||||
</button>
|
||||
<button class="tool" data-tool="pen" title="Pen (freehand)">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2 14c1-3 3-6 6-8s5-3 6-3l-3 4c-2 1-4 3-6 4s-2 2-3 3z"/></svg>
|
||||
</button>
|
||||
<button class="tool" data-tool="text" title="Text label">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3h10M8 3v10M6 13h4"/></svg>
|
||||
</button>
|
||||
<button class="tool" data-tool="blur" title="Blur / mosaic redaction">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="2.5" y="2.5" width="11" height="11"/>
|
||||
<path d="M5 6h1M8 6h1M11 6h1M5 9h1M8 9h1M11 9h1M5 12h1M8 12h1M11 12h1"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<span class="sep"></span>
|
||||
|
||||
<!-- Palette -->
|
||||
<button class="swatch active" data-color="#d6ff3d" style="background:#d6ff3d" title="Acid"></button>
|
||||
<button class="swatch" data-color="#ff3b30" style="background:#ff3b30" title="Red"></button>
|
||||
<button class="swatch" data-color="#ffcc00" style="background:#ffcc00" title="Yellow"></button>
|
||||
<button class="swatch" data-color="#ffffff" style="background:#ffffff" title="White"></button>
|
||||
<button class="swatch" data-color="#000000" style="background:#000000" title="Black"></button>
|
||||
|
||||
<span class="sep"></span>
|
||||
|
||||
<!-- Stroke width -->
|
||||
<button class="width" data-width="2" title="Thin">
|
||||
<span class="dot" style="width:4px;height:4px"></span>
|
||||
</button>
|
||||
<button class="width active" data-width="4" title="Medium">
|
||||
<span class="dot" style="width:7px;height:7px"></span>
|
||||
</button>
|
||||
<button class="width" data-width="8" title="Thick">
|
||||
<span class="dot" style="width:10px;height:10px"></span>
|
||||
</button>
|
||||
|
||||
<span class="sep"></span>
|
||||
|
||||
<button class="tool" id="undo" title="Undo (Ctrl+Z)" disabled>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M3 8c0-3 2-5 5-5s5 2 5 5-2 5-5 5"/><path d="M6 5L3 8l3 3"/></svg>
|
||||
</button>
|
||||
<button class="tool" id="redo" title="Redo (Ctrl+Shift+Z)" disabled>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M13 8c0-3-2-5-5-5S3 5 3 8s2 5 5 5"/><path d="M10 5l3 3-3 3"/></svg>
|
||||
</button>
|
||||
|
||||
<span class="spacer"></span>
|
||||
|
||||
<button class="tool wide" id="apply-crop" title="Apply the current crop rectangle" hidden>
|
||||
<span>Apply crop</span>
|
||||
</button>
|
||||
<button class="tool wide" id="cancel-crop" title="Cancel crop" hidden>
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
|
||||
<button class="tool wide" id="copy" title="Copy PNG to clipboard">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="4" y="4" width="9" height="10"/><path d="M3 12V3h9"/></svg>
|
||||
<span>Copy</span>
|
||||
</button>
|
||||
<button class="tool wide" id="save" title="Save PNG">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v9M4 7l4 4 4-4M2 14h12"/></svg>
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="board" id="board">
|
||||
<div class="stage" id="stage" data-tool="select">
|
||||
<!-- committed pixels (the "real" image after every applied edit) -->
|
||||
<canvas id="committed"></canvas>
|
||||
<!-- live preview during a drag (arrow / rect / etc.) -->
|
||||
<canvas id="draw" class="draw"></canvas>
|
||||
<!-- crop / blur selection rectangle chrome -->
|
||||
<canvas id="overlay" class="overlay"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hint" id="hint"></div>
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<a id="download-link" style="display:none"></a>
|
||||
|
||||
<script src="editor.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
559
bundled-addons/screenshot/editor.js
Normal file
559
bundled-addons/screenshot/editor.js
Normal file
|
|
@ -0,0 +1,559 @@
|
|||
// Screenshot editor. Three stacked canvases:
|
||||
// #committed — pristine bitmap after every applied edit
|
||||
// #draw — receives pointer events; hosts the live preview during a drag
|
||||
// #overlay — the crop/blur selection chrome (dashed rect, dim mask)
|
||||
//
|
||||
// Undo/redo is snapshot-based for correctness over cleverness: each committed
|
||||
// edit pushes an ImageData onto an undo stack. Redo stack is cleared as soon
|
||||
// as a new edit lands. Memory footprint is width * height * 4 * (stack depth);
|
||||
// for a 1920x1080 image at depth 20 that's ~170 MB, so we cap the stack.
|
||||
|
||||
const UNDO_MAX = 25;
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const committed = $("committed");
|
||||
const draw = $("draw");
|
||||
const overlay = $("overlay");
|
||||
const stage = $("stage");
|
||||
const board = $("board");
|
||||
const nameEl = $("name");
|
||||
const undoBtn = $("undo");
|
||||
const redoBtn = $("redo");
|
||||
const applyCropBtn = $("apply-crop");
|
||||
const cancelCropBtn = $("cancel-crop");
|
||||
const hintEl = $("hint");
|
||||
const toastEl = $("toast");
|
||||
|
||||
const cctx = committed.getContext("2d");
|
||||
const dctx = draw.getContext("2d");
|
||||
const octx = overlay.getContext("2d");
|
||||
|
||||
// URL params tell us what to load and (optionally) which tool to preselect.
|
||||
const params = new URLSearchParams(location.search);
|
||||
const srcUrl = params.get("src") || "";
|
||||
const baseName = params.get("name") || "screenshot.png";
|
||||
const initialTool = params.get("tool") || "";
|
||||
nameEl.textContent = baseName;
|
||||
document.title = baseName + " — editor";
|
||||
|
||||
let state = {
|
||||
tool: "select",
|
||||
color: "#d6ff3d",
|
||||
width: 4,
|
||||
dragging: false,
|
||||
start: null, // {x,y} in canvas coords (not CSS pixels)
|
||||
end: null,
|
||||
path: null, // pen points
|
||||
cropRect: null, // {x,y,w,h} in canvas coords
|
||||
textInput: null, // {x,y, el}
|
||||
};
|
||||
|
||||
let undo = []; // ImageData
|
||||
let redo = [];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loading
|
||||
// ---------------------------------------------------------------------------
|
||||
function toast(msg, err) {
|
||||
toastEl.textContent = msg;
|
||||
toastEl.classList.toggle("err", !!err);
|
||||
toastEl.classList.add("on");
|
||||
clearTimeout(toast._t);
|
||||
toast._t = setTimeout(() => toastEl.classList.remove("on"), 1600);
|
||||
}
|
||||
|
||||
function showHint(msg) {
|
||||
hintEl.textContent = msg || "";
|
||||
hintEl.classList.toggle("on", !!msg);
|
||||
}
|
||||
|
||||
function updateUndoButtons() {
|
||||
undoBtn.disabled = undo.length <= 1; // one entry = the base image
|
||||
redoBtn.disabled = redo.length === 0;
|
||||
}
|
||||
|
||||
function pushSnapshot() {
|
||||
try {
|
||||
const snap = cctx.getImageData(0, 0, committed.width, committed.height);
|
||||
undo.push(snap);
|
||||
if (undo.length > UNDO_MAX) undo.splice(0, undo.length - UNDO_MAX);
|
||||
redo.length = 0;
|
||||
updateUndoButtons();
|
||||
} catch (e) { console.warn("snapshot failed:", e); }
|
||||
}
|
||||
|
||||
function restoreSnapshot(snap) {
|
||||
if (!snap) return;
|
||||
// Resize canvases to match the snapshot (crop is destructive to size).
|
||||
if (committed.width !== snap.width || committed.height !== snap.height) {
|
||||
sizeCanvases(snap.width, snap.height);
|
||||
}
|
||||
cctx.putImageData(snap, 0, 0);
|
||||
}
|
||||
|
||||
function sizeCanvases(w, h) {
|
||||
for (const c of [committed, draw, overlay]) {
|
||||
c.width = w;
|
||||
c.height = h;
|
||||
// Match CSS size so 1 canvas px = 1 CSS px unless the board scales it.
|
||||
c.style.width = w + "px";
|
||||
c.style.height = h + "px";
|
||||
}
|
||||
}
|
||||
|
||||
function loadImage(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (e) => reject(new Error("failed to load image"));
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (!srcUrl) { toast("Missing ?src=", true); return; }
|
||||
let img;
|
||||
try { img = await loadImage(srcUrl); }
|
||||
catch (e) { toast(e.message, true); return; }
|
||||
sizeCanvases(img.naturalWidth, img.naturalHeight);
|
||||
cctx.drawImage(img, 0, 0);
|
||||
undo = [];
|
||||
redo = [];
|
||||
pushSnapshot(); // baseline so the very first edit is undoable
|
||||
updateUndoButtons();
|
||||
fitBoard();
|
||||
if (initialTool) setTool(initialTool);
|
||||
}
|
||||
|
||||
// Scale the stage to fit within the board when the image is bigger than
|
||||
// the viewport, so users see the whole shot without scrolling. We scale
|
||||
// visually (CSS transform); drawing math still uses natural canvas
|
||||
// coordinates.
|
||||
let stageScale = 1;
|
||||
function fitBoard() {
|
||||
const availW = board.clientWidth - 40;
|
||||
const availH = board.clientHeight - 40;
|
||||
const s = Math.min(1, availW / committed.width, availH / committed.height);
|
||||
stageScale = s > 0 ? s : 1;
|
||||
stage.style.transform = `scale(${stageScale})`;
|
||||
stage.style.transformOrigin = "top left";
|
||||
// Reserve room so the scaled stage isn't clipped by the flex layout.
|
||||
stage.style.width = (committed.width * stageScale) + "px";
|
||||
stage.style.height = (committed.height * stageScale) + "px";
|
||||
// Undo the reservation on the inner canvases — they must stay at natural
|
||||
// size so the transform can scale them uniformly.
|
||||
for (const c of [committed, draw, overlay]) {
|
||||
c.style.width = committed.width + "px";
|
||||
c.style.height = committed.height + "px";
|
||||
}
|
||||
// Keep the "reserved" outer wrapper's natural children visible.
|
||||
stage.style.position = "relative";
|
||||
committed.style.position = "static";
|
||||
}
|
||||
window.addEventListener("resize", () => fitBoard());
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool selection
|
||||
// ---------------------------------------------------------------------------
|
||||
function setTool(name) {
|
||||
state.tool = name;
|
||||
stage.dataset.tool = name;
|
||||
for (const b of document.querySelectorAll(".tool[data-tool]")) {
|
||||
b.classList.toggle("active", b.dataset.tool === name);
|
||||
}
|
||||
// Crop has a two-step commit; show its buttons when relevant.
|
||||
const isCrop = name === "crop";
|
||||
applyCropBtn.hidden = !isCrop || !state.cropRect;
|
||||
cancelCropBtn.hidden = !isCrop || !state.cropRect;
|
||||
if (!isCrop) { state.cropRect = null; clearOverlay(); }
|
||||
clearDraw();
|
||||
const hints = {
|
||||
crop: "Drag a rectangle, then Apply crop",
|
||||
arrow: "Drag to draw an arrow",
|
||||
rect: "Drag to draw a rectangle",
|
||||
ellipse: "Drag to draw an ellipse",
|
||||
pen: "Draw freehand",
|
||||
text: "Click to place a label",
|
||||
blur: "Drag a rectangle to pixelate",
|
||||
select: "",
|
||||
};
|
||||
showHint(hints[name] || "");
|
||||
}
|
||||
for (const b of document.querySelectorAll(".tool[data-tool]")) {
|
||||
b.addEventListener("click", () => setTool(b.dataset.tool));
|
||||
}
|
||||
|
||||
for (const b of document.querySelectorAll(".swatch")) {
|
||||
b.addEventListener("click", () => {
|
||||
state.color = b.dataset.color;
|
||||
for (const x of document.querySelectorAll(".swatch")) x.classList.toggle("active", x === b);
|
||||
});
|
||||
}
|
||||
for (const b of document.querySelectorAll(".width")) {
|
||||
b.addEventListener("click", () => {
|
||||
state.width = Number(b.dataset.width);
|
||||
for (const x of document.querySelectorAll(".width")) x.classList.toggle("active", x === b);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drawing primitives on ANY 2D context — used for both the live preview
|
||||
// and the committed bake. Coordinates are in natural canvas px.
|
||||
// ---------------------------------------------------------------------------
|
||||
function drawArrow(ctx, x1, y1, x2, y2, color, width) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = color; ctx.fillStyle = color;
|
||||
ctx.lineWidth = width; ctx.lineCap = "round"; ctx.lineJoin = "round";
|
||||
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
|
||||
const dx = x2 - x1, dy = y2 - y1;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const head = Math.max(10, width * 3);
|
||||
const ux = dx / len, uy = dy / len;
|
||||
const px = -uy, py = ux;
|
||||
const tipX = x2, tipY = y2;
|
||||
const baseX = x2 - ux * head, baseY = y2 - uy * head;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tipX, tipY);
|
||||
ctx.lineTo(baseX + px * head * 0.5, baseY + py * head * 0.5);
|
||||
ctx.lineTo(baseX - px * head * 0.5, baseY - py * head * 0.5);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
function drawRect(ctx, x, y, w, h, color, width) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = color; ctx.lineWidth = width;
|
||||
// Half-pixel offset for crisp 1px lines is not worth the branching at
|
||||
// small width; the visible fuzz is negligible past width 2.
|
||||
ctx.strokeRect(x, y, w, h);
|
||||
ctx.restore();
|
||||
}
|
||||
function drawEllipse(ctx, x, y, w, h, color, width) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = color; ctx.lineWidth = width;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(x + w / 2, y + h / 2, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
function drawPen(ctx, points, color, width) {
|
||||
if (!points || points.length < 2) return;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = color; ctx.lineWidth = width;
|
||||
ctx.lineCap = "round"; ctx.lineJoin = "round";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[0].x, points[0].y);
|
||||
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
function drawTextLabel(ctx, x, y, text, color) {
|
||||
if (!text) return;
|
||||
ctx.save();
|
||||
ctx.font = `600 18px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`;
|
||||
ctx.textBaseline = "top";
|
||||
const metrics = ctx.measureText(text);
|
||||
const w = Math.ceil(metrics.width) + 8, h = 22;
|
||||
// Backdrop for legibility over any background.
|
||||
ctx.fillStyle = "rgba(0,0,0,.65)";
|
||||
ctx.fillRect(x - 4, y - 2, w, h);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(text, x, y);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Mosaic pixelation: sample the region into a small offscreen, then draw
|
||||
// back at full size with imageSmoothingEnabled off so each sample lands as
|
||||
// a chunky square. Block size scales with stroke width for a "coarser /
|
||||
// finer" knob on the same tool.
|
||||
function applyMosaic(ctx, x, y, w, h, width) {
|
||||
if (w <= 0 || h <= 0) return;
|
||||
const block = Math.max(6, Math.min(40, width * 3));
|
||||
const sw = Math.max(1, Math.round(w / block));
|
||||
const sh = Math.max(1, Math.round(h / block));
|
||||
const tmp = document.createElement("canvas");
|
||||
tmp.width = sw; tmp.height = sh;
|
||||
const tctx = tmp.getContext("2d");
|
||||
tctx.imageSmoothingEnabled = false;
|
||||
tctx.drawImage(ctx.canvas, x, y, w, h, 0, 0, sw, sh);
|
||||
ctx.save();
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(tmp, 0, 0, sw, sh, x, y, w, h);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function clearDraw() { dctx.clearRect(0, 0, draw.width, draw.height); }
|
||||
function clearOverlay() { octx.clearRect(0, 0, overlay.width, overlay.height); }
|
||||
|
||||
function drawSelectionChrome(rect) {
|
||||
clearOverlay();
|
||||
if (!rect) return;
|
||||
// Dim the surrounding area so the crop rect stands out.
|
||||
octx.save();
|
||||
octx.fillStyle = "rgba(0,0,0,.45)";
|
||||
octx.fillRect(0, 0, overlay.width, overlay.height);
|
||||
octx.clearRect(rect.x, rect.y, rect.w, rect.h);
|
||||
octx.strokeStyle = "#d6ff3d";
|
||||
octx.lineWidth = 1.5;
|
||||
octx.setLineDash([6, 4]);
|
||||
octx.strokeRect(rect.x + 0.5, rect.y + 0.5, rect.w - 1, rect.h - 1);
|
||||
octx.restore();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pointer wiring
|
||||
// ---------------------------------------------------------------------------
|
||||
function pointerToCanvas(ev) {
|
||||
const r = draw.getBoundingClientRect();
|
||||
// r.width / draw.width gives us CSS px per canvas px, i.e. our current
|
||||
// stageScale — computing it from the rect keeps us honest even if the
|
||||
// fit-to-board math ever drifts.
|
||||
const sx = draw.width / r.width;
|
||||
const sy = draw.height / r.height;
|
||||
return { x: (ev.clientX - r.left) * sx, y: (ev.clientY - r.top) * sy };
|
||||
}
|
||||
|
||||
function normRect(a, b) {
|
||||
const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y);
|
||||
const w = Math.abs(a.x - b.x), h = Math.abs(a.y - b.y);
|
||||
return { x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h) };
|
||||
}
|
||||
|
||||
draw.addEventListener("pointerdown", (ev) => {
|
||||
if (state.tool === "select") return;
|
||||
if (state.tool === "text") {
|
||||
beginText(ev);
|
||||
return;
|
||||
}
|
||||
draw.setPointerCapture(ev.pointerId);
|
||||
state.dragging = true;
|
||||
state.start = pointerToCanvas(ev);
|
||||
state.end = state.start;
|
||||
if (state.tool === "pen") state.path = [state.start];
|
||||
});
|
||||
|
||||
draw.addEventListener("pointermove", (ev) => {
|
||||
if (!state.dragging) return;
|
||||
state.end = pointerToCanvas(ev);
|
||||
if (state.tool === "pen") {
|
||||
state.path.push(state.end);
|
||||
// Live-render the whole path each move; simpler than incremental and
|
||||
// fine at freehand cadence.
|
||||
clearDraw();
|
||||
drawPen(dctx, state.path, state.color, state.width);
|
||||
return;
|
||||
}
|
||||
const r = normRect(state.start, state.end);
|
||||
if (state.tool === "crop" || state.tool === "blur") {
|
||||
drawSelectionChrome(r);
|
||||
return;
|
||||
}
|
||||
clearDraw();
|
||||
if (state.tool === "arrow") drawArrow(dctx, state.start.x, state.start.y, state.end.x, state.end.y, state.color, state.width);
|
||||
if (state.tool === "rect") drawRect(dctx, r.x, r.y, r.w, r.h, state.color, state.width);
|
||||
if (state.tool === "ellipse") drawEllipse(dctx, r.x, r.y, r.w, r.h, state.color, state.width);
|
||||
});
|
||||
|
||||
draw.addEventListener("pointerup", (ev) => {
|
||||
if (!state.dragging) return;
|
||||
state.dragging = false;
|
||||
try { draw.releasePointerCapture(ev.pointerId); } catch {}
|
||||
const r = normRect(state.start, state.end);
|
||||
if (state.tool === "pen") {
|
||||
if (state.path && state.path.length > 1) {
|
||||
drawPen(cctx, state.path, state.color, state.width);
|
||||
pushSnapshot();
|
||||
}
|
||||
state.path = null;
|
||||
clearDraw();
|
||||
return;
|
||||
}
|
||||
if (state.tool === "arrow") {
|
||||
if (Math.hypot(state.end.x - state.start.x, state.end.y - state.start.y) > 3) {
|
||||
drawArrow(cctx, state.start.x, state.start.y, state.end.x, state.end.y, state.color, state.width);
|
||||
pushSnapshot();
|
||||
}
|
||||
clearDraw();
|
||||
return;
|
||||
}
|
||||
if (state.tool === "rect" || state.tool === "ellipse") {
|
||||
if (r.w > 3 && r.h > 3) {
|
||||
(state.tool === "rect" ? drawRect : drawEllipse)(cctx, r.x, r.y, r.w, r.h, state.color, state.width);
|
||||
pushSnapshot();
|
||||
}
|
||||
clearDraw();
|
||||
return;
|
||||
}
|
||||
if (state.tool === "blur") {
|
||||
if (r.w > 3 && r.h > 3) {
|
||||
applyMosaic(cctx, r.x, r.y, r.w, r.h, state.width);
|
||||
pushSnapshot();
|
||||
}
|
||||
clearOverlay();
|
||||
return;
|
||||
}
|
||||
if (state.tool === "crop") {
|
||||
if (r.w > 3 && r.h > 3) {
|
||||
state.cropRect = r;
|
||||
applyCropBtn.hidden = false;
|
||||
cancelCropBtn.hidden = false;
|
||||
} else {
|
||||
state.cropRect = null;
|
||||
clearOverlay();
|
||||
applyCropBtn.hidden = true;
|
||||
cancelCropBtn.hidden = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Escape cancels an in-flight crop selection or a text placement.
|
||||
window.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Escape") {
|
||||
if (state.textInput) { cancelText(); ev.preventDefault(); return; }
|
||||
if (state.tool === "crop") { state.cropRect = null; clearOverlay(); applyCropBtn.hidden = true; cancelCropBtn.hidden = true; return; }
|
||||
}
|
||||
// Undo / redo shortcuts.
|
||||
const meta = ev.ctrlKey || ev.metaKey;
|
||||
if (meta && !ev.shiftKey && ev.key.toLowerCase() === "z") { doUndo(); ev.preventDefault(); return; }
|
||||
if (meta && ev.shiftKey && ev.key.toLowerCase() === "z") { doRedo(); ev.preventDefault(); return; }
|
||||
if (meta && ev.key.toLowerCase() === "y") { doRedo(); ev.preventDefault(); return; }
|
||||
if (meta && ev.key.toLowerCase() === "s") { save(); ev.preventDefault(); return; }
|
||||
if (meta && ev.key.toLowerCase() === "c" && !state.textInput) { copy(); ev.preventDefault(); return; }
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text tool: click places an input; blur/Enter commits, Escape cancels.
|
||||
// ---------------------------------------------------------------------------
|
||||
function beginText(ev) {
|
||||
if (state.textInput) commitText();
|
||||
const p = pointerToCanvas(ev);
|
||||
const inp = document.createElement("input");
|
||||
inp.type = "text";
|
||||
inp.className = "text-input";
|
||||
inp.placeholder = "text";
|
||||
// Place using viewport coords — body isn't positioned, so absolute
|
||||
// left/top match clientX/Y as long as the board isn't scrolled.
|
||||
inp.style.left = (ev.clientX + board.scrollLeft) + "px";
|
||||
inp.style.top = (ev.clientY + board.scrollTop) + "px";
|
||||
inp.style.color = state.color;
|
||||
document.body.appendChild(inp);
|
||||
inp.focus();
|
||||
state.textInput = { x: p.x, y: p.y, el: inp, color: state.color };
|
||||
inp.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); commitText(); }
|
||||
else if (e.key === "Escape") { e.preventDefault(); cancelText(); }
|
||||
});
|
||||
inp.addEventListener("blur", () => setTimeout(commitText, 0));
|
||||
}
|
||||
function commitText() {
|
||||
const t = state.textInput; if (!t) return;
|
||||
const text = t.el.value.trim();
|
||||
t.el.remove();
|
||||
state.textInput = null;
|
||||
if (!text) return;
|
||||
drawTextLabel(cctx, t.x, t.y, text, t.color);
|
||||
pushSnapshot();
|
||||
}
|
||||
function cancelText() {
|
||||
const t = state.textInput; if (!t) return;
|
||||
t.el.remove();
|
||||
state.textInput = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Undo / redo
|
||||
// ---------------------------------------------------------------------------
|
||||
function doUndo() {
|
||||
if (undo.length <= 1) return;
|
||||
const cur = undo.pop();
|
||||
redo.push(cur);
|
||||
const prev = undo[undo.length - 1];
|
||||
restoreSnapshot(prev);
|
||||
clearDraw(); clearOverlay();
|
||||
state.cropRect = null;
|
||||
applyCropBtn.hidden = true;
|
||||
cancelCropBtn.hidden = true;
|
||||
updateUndoButtons();
|
||||
}
|
||||
function doRedo() {
|
||||
if (!redo.length) return;
|
||||
const snap = redo.pop();
|
||||
undo.push(snap);
|
||||
restoreSnapshot(snap);
|
||||
updateUndoButtons();
|
||||
}
|
||||
undoBtn.addEventListener("click", doUndo);
|
||||
redoBtn.addEventListener("click", doRedo);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Crop apply
|
||||
// ---------------------------------------------------------------------------
|
||||
applyCropBtn.addEventListener("click", () => {
|
||||
const r = state.cropRect; if (!r) return;
|
||||
// Clamp to canvas.
|
||||
const x = Math.max(0, r.x), y = Math.max(0, r.y);
|
||||
const w = Math.min(r.w, committed.width - x);
|
||||
const h = Math.min(r.h, committed.height - y);
|
||||
if (w <= 0 || h <= 0) { toast("Crop out of bounds", true); return; }
|
||||
const tmp = document.createElement("canvas");
|
||||
tmp.width = w; tmp.height = h;
|
||||
tmp.getContext("2d").drawImage(committed, x, y, w, h, 0, 0, w, h);
|
||||
sizeCanvases(w, h);
|
||||
cctx.drawImage(tmp, 0, 0);
|
||||
state.cropRect = null;
|
||||
clearOverlay(); clearDraw();
|
||||
applyCropBtn.hidden = true;
|
||||
cancelCropBtn.hidden = true;
|
||||
pushSnapshot();
|
||||
fitBoard();
|
||||
});
|
||||
cancelCropBtn.addEventListener("click", () => {
|
||||
state.cropRect = null;
|
||||
clearOverlay();
|
||||
applyCropBtn.hidden = true;
|
||||
cancelCropBtn.hidden = true;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Save & copy
|
||||
// ---------------------------------------------------------------------------
|
||||
function canvasBlob() {
|
||||
return new Promise((resolve, reject) => {
|
||||
committed.toBlob((b) => b ? resolve(b) : reject(new Error("toBlob returned null")), "image/png");
|
||||
});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
const blob = await canvasBlob();
|
||||
// Chromium's will-download listener catches this via the download attr;
|
||||
// no separate capability needed.
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = $("download-link");
|
||||
a.href = url;
|
||||
a.download = baseName || "screenshot.png";
|
||||
a.click();
|
||||
// Blob URLs are cheap but leak; release once the browser has had a beat
|
||||
// to start the download.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 4000);
|
||||
toast(`Saved ${baseName} (${(blob.size / 1024).toFixed(1)} KB)`);
|
||||
} catch (e) {
|
||||
toast("Save failed: " + e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
const blob = await canvasBlob();
|
||||
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
|
||||
toast("Copied to clipboard");
|
||||
} catch (e) {
|
||||
toast("Copy failed: " + e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
$("save").addEventListener("click", save);
|
||||
$("copy").addEventListener("click", copy);
|
||||
|
||||
init();
|
||||
|
|
@ -1,49 +1,129 @@
|
|||
// 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.
|
||||
// Screenshot — capture the active tab, then hand the raw PNG to a full-tab
|
||||
// editor page (editor.html) for annotate / redact / crop / save. There is
|
||||
// no sidebar panel; the whole flow is toolbar dropdown → capture → editor tab.
|
||||
//
|
||||
// Flow: chrome dispatches "menu-select" with {id: "visible"|"full"|"region"}
|
||||
// → we call api.captureTab({mode, ...}) → write the raw PNG to a per-add-on
|
||||
// scratch dir → record it in api.storage under "recent" (small ring buffer)
|
||||
// → open editor.html?src=<file url>&name=…&tool=… as a full Theseus tab.
|
||||
// The editor loads the PNG onto a <canvas> and, when the user hits Save,
|
||||
// downloads the modified image through Chromium's normal <a download> path
|
||||
// (caught by Theseus's will-download tracker — no download capability needed).
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const MAX_RECENT = 6; // keep the last N in the ring; older files pruned
|
||||
const SCRATCH_DIR = "screenshot-scratch"; // sibling of the add-on's storage json
|
||||
|
||||
module.exports = {
|
||||
activate(api) {
|
||||
api.registerSidebarPanel({
|
||||
id: "main",
|
||||
title: "Screenshot",
|
||||
icon: "📸",
|
||||
page: "panel.html",
|
||||
});
|
||||
// Per-add-on scratch dir under <userData>/addons-data/. We don't touch
|
||||
// the add-on folder itself — that would confuse users editing their own
|
||||
// copy in the file browser. api.folder is <userData>/addons/screenshot/,
|
||||
// so dirname twice + "addons-data" gets us to the sibling of the storage
|
||||
// json api.storage writes.
|
||||
const dataParent = path.dirname(path.join(api.folder, ".."));
|
||||
const scratchDir = path.join(dataParent, "addons-data", SCRATCH_DIR);
|
||||
try { fs.mkdirSync(scratchDir, { recursive: true }); }
|
||||
catch (e) { api.log("scratch mkdir failed:", e?.message); }
|
||||
|
||||
// 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.
|
||||
// Region-select overlay source, loaded once at activation. Region mode
|
||||
// triggers this; main injects it 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 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);
|
||||
function fileUrl(abs) {
|
||||
return "file:///" + abs.replace(/\\/g, "/").replace(/^\/+/, "").replace(/#/g, "%23").replace(/\?/g, "%3F");
|
||||
}
|
||||
function nowStamp() {
|
||||
const d = new Date();
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
||||
}
|
||||
function pruneRecent(recent) {
|
||||
// Drop entries whose scratch file no longer exists so the ring doesn't
|
||||
// reference broken paths after a manual cleanup.
|
||||
const alive = recent.filter((r) => { try { fs.accessSync(r.path); return true; } catch { return false; } });
|
||||
// Cap size and delete the files we're about to forget.
|
||||
const trimmed = alive.slice(0, MAX_RECENT);
|
||||
const dropped = alive.slice(MAX_RECENT);
|
||||
for (const r of dropped) { try { fs.unlinkSync(r.path); } catch {} }
|
||||
return trimmed;
|
||||
}
|
||||
function writeScratch(dataUrl, name) {
|
||||
const m = /^data:image\/png;base64,(.+)$/.exec(String(dataUrl));
|
||||
if (!m) throw new Error("expected image/png data URL from capture");
|
||||
const buf = Buffer.from(m[1], "base64");
|
||||
const file = path.join(scratchDir, name);
|
||||
fs.writeFileSync(file, buf);
|
||||
return { path: file, bytes: buf.length };
|
||||
}
|
||||
|
||||
// Toolbar dropdown item click: chrome sends {id: "visible"|"full"|"region"}.
|
||||
api.onMessage("menu-select", async (payload) => {
|
||||
const id = String(payload && payload.id || "visible");
|
||||
if (id !== "visible" && id !== "full" && id !== "region") {
|
||||
throw new Error(`unknown menu item: ${id}`);
|
||||
}
|
||||
// capture-tab uses the same mode strings as our menu ids. Region mode
|
||||
// needs the overlay source so the user can draw the rect in-page.
|
||||
const opts = { mode: id, format: "png" };
|
||||
if (id === "region") opts.overlaySource = overlaySource;
|
||||
const cap = await api.captureTab(opts);
|
||||
if (cap.cancelled) { api.log(`region capture cancelled`); return { ok: false, cancelled: true }; }
|
||||
const name = `screenshot-${nowStamp()}${id === "full" ? "-fullpage" : id === "region" ? "-region" : ""}.png`;
|
||||
const written = writeScratch(cap.dataUrl, name);
|
||||
// Recent-captures ring buffer.
|
||||
let recent = api.storage.get("recent", []);
|
||||
if (!Array.isArray(recent)) recent = [];
|
||||
recent.unshift({ id: name, name, path: written.path, url: fileUrl(written.path), bytes: written.bytes, at: Date.now(), mode: id });
|
||||
recent = pruneRecent(recent);
|
||||
api.storage.set("recent", recent);
|
||||
api.log(`captured ${id} → ${name} (${written.bytes} bytes)`);
|
||||
// Region mode = land in the crop tool immediately so the user can trim
|
||||
// further if the freehand rect was rough.
|
||||
const tool = id === "region" ? "crop" : "";
|
||||
api.openTab("editor.html", { query: { src: fileUrl(written.path), name, tool } });
|
||||
return { ok: true, name };
|
||||
});
|
||||
|
||||
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 });
|
||||
// Read the ring for a future "recent captures" surface — the editor may
|
||||
// grow a "past captures" strip, and Settings can display them too. Not
|
||||
// wired to any UI in 0.2.0; kept so the ring is inspectable.
|
||||
api.onMessage("listRecent", () => {
|
||||
let recent = api.storage.get("recent", []);
|
||||
if (!Array.isArray(recent)) recent = [];
|
||||
recent = pruneRecent(recent);
|
||||
api.storage.set("recent", recent);
|
||||
return recent.map((r) => ({ id: r.id, name: r.name, url: r.url, bytes: r.bytes, at: r.at, mode: r.mode }));
|
||||
});
|
||||
|
||||
api.log("registered screenshot panel");
|
||||
api.onMessage("openRecent", ({ id } = {}) => {
|
||||
const recent = api.storage.get("recent", []) || [];
|
||||
const hit = recent.find((r) => r.id === id);
|
||||
if (!hit) throw new Error(`no recent capture "${id}"`);
|
||||
api.openTab("editor.html", { query: { src: hit.url, name: hit.name, tool: "" } });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
api.onMessage("clearRecent", ({ id } = {}) => {
|
||||
let recent = api.storage.get("recent", []) || [];
|
||||
if (id) {
|
||||
const hit = recent.find((r) => r.id === id);
|
||||
if (hit) { try { fs.unlinkSync(hit.path); } catch {} }
|
||||
recent = recent.filter((r) => r.id !== id);
|
||||
} else {
|
||||
for (const r of recent) { try { fs.unlinkSync(r.path); } catch {} }
|
||||
recent = [];
|
||||
}
|
||||
api.storage.set("recent", recent);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
api.log("registered screenshot toolbar-menu (visible / full / region)");
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,230 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Screenshot</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark;
|
||||
--bg:#0e131c; --panel:#141a24; --line:rgba(255,255,255,.09);
|
||||
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d;
|
||||
--btn:#1c2432; --btn-h:#242e40; }
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root { --bg:#f8faff; --panel:#ffffff; --line:rgba(0,0,0,.10);
|
||||
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5;
|
||||
--btn:#f0f3fa; --btn-h:#e3e8f2; }
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body { background: var(--bg); color: var(--ink);
|
||||
font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
display: flex; flex-direction: column; }
|
||||
header { display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 14px; border-bottom: 1px solid var(--line);
|
||||
background: var(--panel); }
|
||||
header .t { font-weight: 600; display: flex; gap: 8px; align-items: center; }
|
||||
header .t .em { font-size: 15px; }
|
||||
header .m { color: var(--dim); font-size: 11.5px; }
|
||||
main { flex: 1; overflow-y: auto; padding: 12px 14px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.row { display: flex; gap: 6px; }
|
||||
.row.stack { flex-direction: column; }
|
||||
button.mode {
|
||||
flex: 1; min-width: 0;
|
||||
padding: 10px 8px; border: 1px solid var(--line); border-radius: 8px;
|
||||
background: var(--btn); color: var(--ink); cursor: pointer;
|
||||
font: inherit; text-align: center;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||
transition: background 120ms;
|
||||
}
|
||||
button.mode:hover:not(:disabled) { background: var(--btn-h); }
|
||||
button.mode:disabled { opacity: .5; cursor: default; }
|
||||
button.mode .em { font-size: 18px; line-height: 1; }
|
||||
button.mode .lbl { font-size: 12px; }
|
||||
.prev-wrap {
|
||||
border: 1px dashed var(--line); border-radius: 8px;
|
||||
background: repeating-conic-gradient(rgba(255,255,255,.02) 0% 25%, transparent 0% 50%) 50%/16px 16px;
|
||||
padding: 6px; min-height: 120px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--dim); font-size: 12px;
|
||||
}
|
||||
.prev-wrap img { max-width: 100%; max-height: 320px; border-radius: 4px; display: block; }
|
||||
.format { display: flex; gap: 8px; align-items: center; font-size: 12px; color: var(--mut); }
|
||||
.format label { display: flex; gap: 4px; align-items: center; cursor: pointer; }
|
||||
.format input[type="radio"] { accent-color: var(--acid); }
|
||||
.actions { display: flex; gap: 6px; }
|
||||
button.act {
|
||||
flex: 1; padding: 9px 10px; border: 1px solid var(--line); border-radius: 8px;
|
||||
background: var(--btn); color: var(--ink); cursor: pointer; font: inherit;
|
||||
}
|
||||
button.act.primary { background: var(--acid); color: #101418; border-color: transparent; font-weight: 600; }
|
||||
button.act:hover:not(:disabled) { background: var(--btn-h); }
|
||||
button.act.primary:hover:not(:disabled) { filter: brightness(1.05); }
|
||||
button.act:disabled { opacity: .45; cursor: default; }
|
||||
.meta { color: var(--dim); font-size: 11.5px; display: flex; justify-content: space-between; gap: 8px; }
|
||||
.status { color: var(--mut); font-size: 12px; min-height: 16px; }
|
||||
.status.err { color: #ff9081; }
|
||||
.status.ok { color: var(--acid); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="t"><span class="em">📸</span> <span>Screenshot</span></div>
|
||||
<div class="m" id="hdr-status"></div>
|
||||
</header>
|
||||
<main>
|
||||
<div class="row">
|
||||
<button class="mode" data-mode="visible" title="Capture the visible part of the current tab">
|
||||
<span class="em">🖼️</span><span class="lbl">Visible</span>
|
||||
</button>
|
||||
<button class="mode" data-mode="full" title="Capture the entire scrollable page — fixed headers may repeat">
|
||||
<span class="em">📄</span><span class="lbl">Full page</span>
|
||||
</button>
|
||||
<button class="mode" data-mode="region" title="Drag a rectangle on the page to select what to capture">
|
||||
<span class="em">✂️</span><span class="lbl">Region</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="prev-wrap" id="prev-wrap">
|
||||
<span id="prev-empty">No capture yet. Pick a mode above.</span>
|
||||
<img id="prev-img" alt="" hidden>
|
||||
</div>
|
||||
|
||||
<div class="meta">
|
||||
<span id="meta-size">—</span>
|
||||
<span id="meta-host"></span>
|
||||
</div>
|
||||
|
||||
<div class="format">
|
||||
<span>Format:</span>
|
||||
<label><input type="radio" name="fmt" value="png" checked> PNG</label>
|
||||
<label><input type="radio" name="fmt" value="jpeg"> JPEG</label>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="act" id="btn-clear" disabled>Discard</button>
|
||||
<button class="act primary" id="btn-save" disabled>Save to Downloads</button>
|
||||
</div>
|
||||
|
||||
<div class="status" id="status"></div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const previewImg = $("prev-img");
|
||||
const previewEmpty = $("prev-empty");
|
||||
const status = $("status");
|
||||
const metaSize = $("meta-size");
|
||||
const metaHost = $("meta-host");
|
||||
const btnSave = $("btn-save");
|
||||
const btnClear = $("btn-clear");
|
||||
const hdrStatus = $("hdr-status");
|
||||
|
||||
let last = null; // { dataUrl, width, height, host, format }
|
||||
let busy = false;
|
||||
|
||||
const bytesFromDataUrl = (u) => {
|
||||
const i = u.indexOf(",");
|
||||
if (i < 0) return 0;
|
||||
// base64 length → decoded byte count.
|
||||
const b64 = u.slice(i + 1);
|
||||
return Math.floor(b64.length * 3 / 4) - (b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0);
|
||||
};
|
||||
const fmt = (n) => n >= 1e6 ? (n / 1e6).toFixed(1) + " MB" : n >= 1e3 ? (n / 1e3).toFixed(0) + " KB" : n + " B";
|
||||
|
||||
function currentFormat() {
|
||||
const el = document.querySelector('input[name="fmt"]:checked');
|
||||
return el && el.value === "jpeg" ? "jpeg" : "png";
|
||||
}
|
||||
|
||||
function setStatus(text, cls = "") {
|
||||
status.textContent = text || "";
|
||||
status.className = "status" + (cls ? " " + cls : "");
|
||||
}
|
||||
|
||||
function renderPreview() {
|
||||
if (!last || !last.dataUrl) {
|
||||
previewImg.hidden = true; previewImg.src = "";
|
||||
previewEmpty.hidden = false;
|
||||
metaSize.textContent = "—";
|
||||
metaHost.textContent = "";
|
||||
btnSave.disabled = true;
|
||||
btnClear.disabled = true;
|
||||
return;
|
||||
}
|
||||
previewImg.src = last.dataUrl;
|
||||
previewImg.hidden = false;
|
||||
previewEmpty.hidden = true;
|
||||
metaSize.textContent = `${last.width}×${last.height} · ${fmt(bytesFromDataUrl(last.dataUrl))}`;
|
||||
metaHost.textContent = last.host || "";
|
||||
btnSave.disabled = false;
|
||||
btnClear.disabled = false;
|
||||
}
|
||||
|
||||
async function doCapture(mode) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
hdrStatus.textContent = "capturing…";
|
||||
setStatus("");
|
||||
// For region mode the user has to interact with the page; encourage them.
|
||||
if (mode === "region") setStatus("Drag on the page. Esc to cancel.");
|
||||
// For full-page the layout flicker is visible; explain.
|
||||
else if (mode === "full") setStatus("Rendering full page — this can take a moment.");
|
||||
for (const b of document.querySelectorAll("button.mode")) b.disabled = true;
|
||||
try {
|
||||
const res = await window.silentmode.invoke("capture", { mode, format: currentFormat() });
|
||||
if (res && res.cancelled) {
|
||||
setStatus("Region capture cancelled.");
|
||||
} else if (res && res.dataUrl) {
|
||||
last = res;
|
||||
renderPreview();
|
||||
setStatus("Captured. Review, then save.", "ok");
|
||||
} else {
|
||||
setStatus("Nothing captured.", "err");
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("capture failed:", e);
|
||||
setStatus(String(e && e.message || e), "err");
|
||||
} finally {
|
||||
busy = false;
|
||||
hdrStatus.textContent = "";
|
||||
for (const b of document.querySelectorAll("button.mode")) b.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doSave() {
|
||||
if (!last || !last.dataUrl || busy) return;
|
||||
busy = true;
|
||||
btnSave.disabled = true;
|
||||
hdrStatus.textContent = "saving…";
|
||||
try {
|
||||
const res = await window.silentmode.invoke("save", {
|
||||
dataUrl: last.dataUrl,
|
||||
host: last.host,
|
||||
format: last.format,
|
||||
});
|
||||
setStatus("Saved: " + (res && res.savePath ? res.savePath : "Downloads"), "ok");
|
||||
} catch (e) {
|
||||
console.warn("save failed:", e);
|
||||
setStatus("Save failed: " + String(e && e.message || e), "err");
|
||||
} finally {
|
||||
busy = false;
|
||||
btnSave.disabled = !last;
|
||||
hdrStatus.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
for (const b of document.querySelectorAll("button.mode")) {
|
||||
b.addEventListener("click", () => doCapture(b.dataset.mode));
|
||||
}
|
||||
btnSave.addEventListener("click", doSave);
|
||||
btnClear.addEventListener("click", () => { last = null; renderPreview(); setStatus(""); });
|
||||
|
||||
// Changing format after a capture: reset preview so the meta size reflects
|
||||
// the format that will actually be saved next.
|
||||
for (const r of document.querySelectorAll('input[name="fmt"]')) {
|
||||
r.addEventListener("change", () => {
|
||||
if (last) setStatus("Format changed. Re-capture to see the new file size.");
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
63
main.js
63
main.js
|
|
@ -1384,6 +1384,27 @@ function initAddons() {
|
|||
hostRequire: (name) => require(name),
|
||||
hostImport: (name) => import(require("node:url").pathToFileURL(require.resolve(name)).href),
|
||||
openTab: (url) => { if (win) createTab(url); },
|
||||
// open-tab (addon-file variant): open one of the add-on's OWN files in a
|
||||
// full tab. The path is joined against the resolved add-on folder and
|
||||
// rejected if the result escapes it — belt-and-braces with the sanity
|
||||
// check the api wrapper already does. The tab uses addon-tab-preload so
|
||||
// window.silentmode.invoke() reaches the same handler surface as a
|
||||
// sidebar panel; the sender-URL gate on addon-msg then confines the
|
||||
// page to its own add-on's storage/handlers.
|
||||
openAddonTab: (addonId, relPath, queryString) => {
|
||||
if (!win) return;
|
||||
const folder = addonHost && addonHost.folderOf(addonId);
|
||||
if (!folder) throw new Error(`openAddonTab: no such active add-on "${addonId}"`);
|
||||
const base = path.resolve(folder);
|
||||
const abs = path.resolve(base, relPath);
|
||||
const norm = abs.replace(/\\/g, "/").toLowerCase();
|
||||
const baseNorm = base.replace(/\\/g, "/").toLowerCase();
|
||||
if (norm !== baseNorm && !norm.startsWith(baseNorm + "/")) {
|
||||
throw new Error(`openAddonTab: path "${relPath}" escapes add-on folder`);
|
||||
}
|
||||
if (!fs.existsSync(abs)) throw new Error(`openAddonTab: file not found: ${abs}`);
|
||||
createTab(null, { addonFile: { absPath: abs, query: queryString || "", addonId } });
|
||||
},
|
||||
// capture-tab: three modes.
|
||||
// visible — one WebContents.capturePage() of the current viewport.
|
||||
// full — temporarily grow the tab's WebContentsView to the page's
|
||||
|
|
@ -1672,13 +1693,13 @@ function setSidebar(show, panelId) {
|
|||
try { win.contentView.removeChildView(sidebar); win.contentView.addChildView(sidebar); } catch {}
|
||||
layout();
|
||||
try { sidebar.webContents.send("sidebar-visibility", true); } catch {}
|
||||
try { chrome?.webContents.send("sidebar-state", { visible: true, active: sidebarActivePanelId, panels }); } catch {}
|
||||
try { chrome?.webContents.send("sidebar-state", { visible: true, active: sidebarActivePanelId, panels, toolbarMenus: addonHost ? addonHost.getToolbarMenus() : [] }); } catch {}
|
||||
} else {
|
||||
sidebarVisible = false;
|
||||
sidebar.setVisible(false);
|
||||
layout();
|
||||
try { sidebar.webContents.send("sidebar-visibility", false); } catch {}
|
||||
try { chrome?.webContents.send("sidebar-state", { visible: false, active: sidebarActivePanelId, panels }); } catch {}
|
||||
try { chrome?.webContents.send("sidebar-state", { visible: false, active: sidebarActivePanelId, panels, toolbarMenus: addonHost ? addonHost.getToolbarMenus() : [] }); } catch {}
|
||||
}
|
||||
}
|
||||
function showPwFill(show, matches) {
|
||||
|
|
@ -1926,9 +1947,13 @@ function createTab(initial, opts = {}) {
|
|||
// trip its editable-cards state via IPC. IPC handlers reject any call
|
||||
// whose sender URL isn't our own home.html, so a third-party page sees
|
||||
// the API's shape but can't act through it.
|
||||
const view = new WebContentsView(opts.settings
|
||||
? { webPreferences: { preload: path.join(__dirname, "settings-preload.js") } }
|
||||
: { webPreferences: { preload: path.join(__dirname, "home-preload.js") } });
|
||||
// Preload picker: settings and add-on-file tabs each need their own IPC
|
||||
// surface; everything else gets home-preload (superset of a plain web
|
||||
// page's needs, plus the home-page card wiring).
|
||||
const preloadPath = opts.settings ? path.join(__dirname, "settings-preload.js")
|
||||
: opts.addonFile ? path.join(__dirname, "addon-tab-preload.js")
|
||||
: path.join(__dirname, "home-preload.js");
|
||||
const view = new WebContentsView({ webPreferences: { preload: preloadPath } });
|
||||
const wc = view.webContents;
|
||||
try { wc.setWebRTCIPHandlingPolicy(webrtcPolicy()); } catch {}
|
||||
try { wc.setBackgroundThrottling(settings.backgroundThrottle); } catch {}
|
||||
|
|
@ -2090,6 +2115,15 @@ function createTab(initial, opts = {}) {
|
|||
wc.loadFile("settings.html");
|
||||
if (id === activeId) pushNav(tab.prov);
|
||||
emitTabs();
|
||||
} else if (opts.addonFile) {
|
||||
// Same treatment as settings: leave the address bar empty (refreshTabUrl
|
||||
// skips file:// anyway), title arrives via page-title-updated. loadFile
|
||||
// takes the query as `search` (Node's url.format shape) without the ?.
|
||||
tab.prov = { host: "", kind: "home" };
|
||||
tab.addonId = opts.addonFile.addonId;
|
||||
wc.loadFile(opts.addonFile.absPath, opts.addonFile.query ? { search: opts.addonFile.query } : undefined);
|
||||
if (id === activeId) pushNav(tab.prov);
|
||||
emitTabs();
|
||||
} else if (initial) navigateTab(id, initial);
|
||||
else loadHome(id);
|
||||
return id;
|
||||
|
|
@ -2397,7 +2431,26 @@ ipcMain.handle("sidebar-state", () => ({
|
|||
visible: sidebarVisible,
|
||||
active: sidebarActivePanelId,
|
||||
panels: addonHost ? addonHost.getSidebarPanels() : [],
|
||||
toolbarMenus: addonHost ? addonHost.getToolbarMenus() : [],
|
||||
}));
|
||||
// Toolbar-menu click: chrome sends the {addonId, itemId} of the item the
|
||||
// user picked. Route to the add-on's registered "menu-select" handler. We
|
||||
// trust chrome as the sender (same convention as sidebar-toggle et al) —
|
||||
// it's the only WebContents we ever load chrome.html into.
|
||||
ipcMain.handle("addon-menu-select", async (e, addonId, itemId) => {
|
||||
if (!addonHost) throw new Error("addon host not ready");
|
||||
if (chrome && e.sender !== chrome.webContents) throw new Error("addon-menu-select: untrusted sender");
|
||||
const id = String(addonId || "");
|
||||
const iid = String(itemId || "");
|
||||
if (!id || !iid) throw new Error("addon-menu-select: addonId and itemId required");
|
||||
// Confirm the menu item was actually declared by this add-on — a rogue
|
||||
// renderer message can't invoke a handler with an item id the manifest
|
||||
// never listed.
|
||||
const menu = addonHost.getToolbarMenus().find((m) => m.addonId === id);
|
||||
if (!menu) throw new Error(`addon-menu-select: no toolbar menu for "${id}"`);
|
||||
if (!menu.items.find((it) => it.id === iid)) throw new Error(`addon-menu-select: item "${iid}" not declared by "${id}"`);
|
||||
return addonHost.dispatch(id, "menu-select", { id: iid }, { from: "toolbar-menu" });
|
||||
});
|
||||
// Read-side of Settings' Add-ons tab.
|
||||
ipcMain.handle("addons-list", () => addonHost ? addonHost.snapshot() : { installed: [], sidebarPanels: [] });
|
||||
// Toggle an add-on's enabled state. Discovery re-runs so newly-enabled
|
||||
|
|
|
|||
|
|
@ -69,4 +69,7 @@ contextBridge.exposeInMainWorld("theseus", {
|
|||
closeSidebar: () => ipcRenderer.invoke("sidebar-close"),
|
||||
sidebarState: () => ipcRenderer.invoke("sidebar-state"),
|
||||
onSidebarState: (cb) => ipcRenderer.on("sidebar-state", (_e, d) => cb(d)),
|
||||
// Toolbar-menu (dropdown from an add-on's dock icon): dispatch the picked
|
||||
// item id to the add-on's "menu-select" handler.
|
||||
addonMenuSelect: (addonId, itemId) => ipcRenderer.invoke("addon-menu-select", addonId, itemId),
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue