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); }),
|
||||||
|
});
|
||||||
102
addons-host.js
102
addons-host.js
|
|
@ -37,7 +37,18 @@ const KNOWN_CAPABILITIES = new Set([
|
||||||
// downloads pipeline. The add-on sees pixels of whatever the
|
// downloads pipeline. The add-on sees pixels of whatever the
|
||||||
// current tab is showing, so this is the same trust bar as a
|
// current tab is showing, so this is the same trust bar as a
|
||||||
// page-inject add-on that matches "*://*/*".
|
// 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",
|
"vault-derive", "page-inject", "approval-modal", "capture-tab",
|
||||||
|
"toolbar-menu", "open-tab",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Chrome-style match pattern → predicate. "<scheme>://<host>/<path>" where
|
// 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`);
|
if (!origins.length) throw new Error(`addon "${id}": page-inject.origins must list at least one pattern`);
|
||||||
pageInject = { preload, origins, matchers: origins.map(compileOriginPattern) };
|
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
|
// Loader singleton. `discoverAndActivate(opts)` returns a snapshot the rest
|
||||||
// of the app queries via `getActive()` / `getInstalled()`.
|
// of the app queries via `getActive()` / `getInstalled()`.
|
||||||
class AddonHost {
|
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.addonsDir = addonsDir;
|
||||||
this.dataDir = dataDir;
|
this.dataDir = dataDir;
|
||||||
this.isDisabled = isDisabled || (() => false);
|
this.isDisabled = isDisabled || (() => false);
|
||||||
|
|
@ -125,6 +162,9 @@ class AddonHost {
|
||||||
// Node; hostImport resolves them from the app tree and import()s them.
|
// Node; hostImport resolves them from the app tree and import()s them.
|
||||||
this._hostImport = typeof hostImport === "function" ? hostImport : null;
|
this._hostImport = typeof hostImport === "function" ? hostImport : null;
|
||||||
this._openTab = typeof openTab === "function" ? openTab : 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-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).
|
// session's proxy rules (e.g. a "route everything through my VPS" add-on).
|
||||||
// Signature: (rules: string | { proxyRules, proxyBypassRules }) => Promise<void>
|
// 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)`);
|
if (!this._hostImport) throw new Error(`api.import unavailable (host not wired)`);
|
||||||
return this._hostImport(name);
|
return this._hostImport(name);
|
||||||
},
|
},
|
||||||
// Open a URL in a new Theseus tab (http/https only).
|
// Open a new Theseus tab. Two shapes:
|
||||||
openTab: (url) => {
|
// - api.openTab("https://…") — no capability needed
|
||||||
const u = String(url || "");
|
// - api.openTab("editor.html", { query: {...} }) — opens one of the
|
||||||
if (!/^https?:\/\//i.test(u)) throw new Error("openTab: http(s) URLs only");
|
// add-on's OWN files as a full tab; requires the "open-tab" cap.
|
||||||
if (!this._openTab) throw new Error("openTab unavailable (host not wired)");
|
// Path is resolved inside the add-on folder and rejected if it
|
||||||
this._openTab(u, manifest.id);
|
// 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(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,
|
// Panel ↔ activate() messaging. Panels (and, for page-inject add-ons,
|
||||||
// injected page bridges) call into the add-on with a message name +
|
// injected page bridges) call into the add-on with a message name +
|
||||||
|
|
@ -418,6 +483,7 @@ class AddonHost {
|
||||||
error: error || null,
|
error: error || null,
|
||||||
})),
|
})),
|
||||||
sidebarPanels: this.getSidebarPanels(),
|
sidebarPanels: this.getSidebarPanels(),
|
||||||
|
toolbarMenus: this.getToolbarMenus(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
getSidebarPanels() {
|
getSidebarPanels() {
|
||||||
|
|
@ -425,8 +491,28 @@ class AddonHost {
|
||||||
for (const active of this._active.values()) out.push(...active.sidebarPanels);
|
for (const active of this._active.values()) out.push(...active.sidebarPanels);
|
||||||
return out;
|
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(); }
|
getInstalled() { return this._installed.slice(); }
|
||||||
isActive(id) { return this._active.has(id); }
|
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 };
|
module.exports = { AddonHost, KNOWN_CAPABILITIES, validateManifest, compileOriginPattern };
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,19 @@
|
||||||
{
|
{
|
||||||
"id": "screenshot",
|
"id": "screenshot",
|
||||||
"name": "Screenshot",
|
"name": "Screenshot",
|
||||||
"version": "0.1.0",
|
"version": "0.2.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).",
|
"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",
|
"author": "Silent Mode",
|
||||||
"icon": "📸",
|
"icon": "📸",
|
||||||
"main": "index.js",
|
"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
|
// Screenshot — capture the active tab, then hand the raw PNG to a full-tab
|
||||||
// two messages ("capture" and "save"); main-side capture-tab does the actual
|
// editor page (editor.html) for annotate / redact / crop / save. There is
|
||||||
// pixel work. This module is a thin router.
|
// 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 fs = require("node:fs");
|
||||||
const path = require("node:path");
|
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 = {
|
module.exports = {
|
||||||
activate(api) {
|
activate(api) {
|
||||||
api.registerSidebarPanel({
|
// Per-add-on scratch dir under <userData>/addons-data/. We don't touch
|
||||||
id: "main",
|
// the add-on folder itself — that would confuse users editing their own
|
||||||
title: "Screenshot",
|
// copy in the file browser. api.folder is <userData>/addons/screenshot/,
|
||||||
icon: "📸",
|
// so dirname twice + "addons-data" gets us to the sibling of the storage
|
||||||
page: "panel.html",
|
// 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
|
// Region-select overlay source, loaded once at activation. Region mode
|
||||||
// triggers region mode; main injects this into the tab and awaits the
|
// triggers this; main injects it into the tab and awaits the rect it
|
||||||
// rect it resolves with. Keeping the DOM code in a sibling file (not a
|
// resolves with. Keeping the DOM code in a sibling file (not a JS string
|
||||||
// JS string in main) lets someone edit the overlay UX without touching
|
// in main) lets someone edit the overlay UX without touching browser core.
|
||||||
// the browser core.
|
|
||||||
let overlaySource = "";
|
let overlaySource = "";
|
||||||
try { overlaySource = fs.readFileSync(path.join(api.folder, "panel-preload.js"), "utf8"); }
|
try { overlaySource = fs.readFileSync(path.join(api.folder, "panel-preload.js"), "utf8"); }
|
||||||
catch (e) { api.log("panel-preload.js not readable:", e?.message); }
|
catch (e) { api.log("panel-preload.js not readable:", e?.message); }
|
||||||
|
|
||||||
api.onMessage("capture", async (payload) => {
|
function fileUrl(abs) {
|
||||||
const mode = String(payload?.mode || "visible");
|
return "file:///" + abs.replace(/\\/g, "/").replace(/^\/+/, "").replace(/#/g, "%23").replace(/\?/g, "%3F");
|
||||||
const format = payload?.format === "jpeg" ? "jpeg" : "png";
|
}
|
||||||
const quality = Number(payload?.quality) || 90;
|
function nowStamp() {
|
||||||
const opts = { mode, format, quality };
|
const d = new Date();
|
||||||
if (mode === "region") opts.overlaySource = overlaySource;
|
const pad = (n) => String(n).padStart(2, "0");
|
||||||
return api.captureTab(opts);
|
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) => {
|
// Read the ring for a future "recent captures" surface — the editor may
|
||||||
const dataUrl = String(payload?.dataUrl || "");
|
// grow a "past captures" strip, and Settings can display them too. Not
|
||||||
const host = String(payload?.host || "tab");
|
// wired to any UI in 0.2.0; kept so the ring is inspectable.
|
||||||
const format = payload?.format === "jpeg" ? "jpeg" : "png";
|
api.onMessage("listRecent", () => {
|
||||||
const ext = format === "jpeg" ? "jpg" : "png";
|
let recent = api.storage.get("recent", []);
|
||||||
// ISO date, but with the colons Windows won't accept in filenames swapped
|
if (!Array.isArray(recent)) recent = [];
|
||||||
// out. Second precision is enough; the filename is human-oriented.
|
recent = pruneRecent(recent);
|
||||||
const iso = new Date().toISOString().replace(/[:.]/g, "-").replace(/-\d{3}Z$/, "Z");
|
api.storage.set("recent", recent);
|
||||||
const safeHost = (host || "tab").replace(/[^a-z0-9._-]+/gi, "_") || "tab";
|
return recent.map((r) => ({ id: r.id, name: r.name, url: r.url, bytes: r.bytes, at: r.at, mode: r.mode }));
|
||||||
const filename = `theseus-screenshot-${safeHost}-${iso}.${ext}`;
|
|
||||||
return api.saveCapture({ dataUrl, filename });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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),
|
hostRequire: (name) => require(name),
|
||||||
hostImport: (name) => import(require("node:url").pathToFileURL(require.resolve(name)).href),
|
hostImport: (name) => import(require("node:url").pathToFileURL(require.resolve(name)).href),
|
||||||
openTab: (url) => { if (win) createTab(url); },
|
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.
|
// capture-tab: three modes.
|
||||||
// visible — one WebContents.capturePage() of the current viewport.
|
// visible — one WebContents.capturePage() of the current viewport.
|
||||||
// full — temporarily grow the tab's WebContentsView to the page's
|
// 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 {}
|
try { win.contentView.removeChildView(sidebar); win.contentView.addChildView(sidebar); } catch {}
|
||||||
layout();
|
layout();
|
||||||
try { sidebar.webContents.send("sidebar-visibility", true); } catch {}
|
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 {
|
} else {
|
||||||
sidebarVisible = false;
|
sidebarVisible = false;
|
||||||
sidebar.setVisible(false);
|
sidebar.setVisible(false);
|
||||||
layout();
|
layout();
|
||||||
try { sidebar.webContents.send("sidebar-visibility", false); } catch {}
|
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) {
|
function showPwFill(show, matches) {
|
||||||
|
|
@ -1926,9 +1947,13 @@ function createTab(initial, opts = {}) {
|
||||||
// trip its editable-cards state via IPC. IPC handlers reject any call
|
// 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
|
// 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.
|
// the API's shape but can't act through it.
|
||||||
const view = new WebContentsView(opts.settings
|
// Preload picker: settings and add-on-file tabs each need their own IPC
|
||||||
? { webPreferences: { preload: path.join(__dirname, "settings-preload.js") } }
|
// surface; everything else gets home-preload (superset of a plain web
|
||||||
: { webPreferences: { preload: path.join(__dirname, "home-preload.js") } });
|
// 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;
|
const wc = view.webContents;
|
||||||
try { wc.setWebRTCIPHandlingPolicy(webrtcPolicy()); } catch {}
|
try { wc.setWebRTCIPHandlingPolicy(webrtcPolicy()); } catch {}
|
||||||
try { wc.setBackgroundThrottling(settings.backgroundThrottle); } catch {}
|
try { wc.setBackgroundThrottling(settings.backgroundThrottle); } catch {}
|
||||||
|
|
@ -2090,6 +2115,15 @@ function createTab(initial, opts = {}) {
|
||||||
wc.loadFile("settings.html");
|
wc.loadFile("settings.html");
|
||||||
if (id === activeId) pushNav(tab.prov);
|
if (id === activeId) pushNav(tab.prov);
|
||||||
emitTabs();
|
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 if (initial) navigateTab(id, initial);
|
||||||
else loadHome(id);
|
else loadHome(id);
|
||||||
return id;
|
return id;
|
||||||
|
|
@ -2397,7 +2431,26 @@ ipcMain.handle("sidebar-state", () => ({
|
||||||
visible: sidebarVisible,
|
visible: sidebarVisible,
|
||||||
active: sidebarActivePanelId,
|
active: sidebarActivePanelId,
|
||||||
panels: addonHost ? addonHost.getSidebarPanels() : [],
|
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.
|
// Read-side of Settings' Add-ons tab.
|
||||||
ipcMain.handle("addons-list", () => addonHost ? addonHost.snapshot() : { installed: [], sidebarPanels: [] });
|
ipcMain.handle("addons-list", () => addonHost ? addonHost.snapshot() : { installed: [], sidebarPanels: [] });
|
||||||
// Toggle an add-on's enabled state. Discovery re-runs so newly-enabled
|
// 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"),
|
closeSidebar: () => ipcRenderer.invoke("sidebar-close"),
|
||||||
sidebarState: () => ipcRenderer.invoke("sidebar-state"),
|
sidebarState: () => ipcRenderer.invoke("sidebar-state"),
|
||||||
onSidebarState: (cb) => ipcRenderer.on("sidebar-state", (_e, d) => cb(d)),
|
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