From 15694195d692c2f109430fb158c74d1573f616d2 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Mon, 7 Sep 2026 00:56:57 +0200 Subject: [PATCH] feat(theseus/screenshot): full-tab editor + toolbar-menu + open-tab capabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- addon-tab-preload.js | 16 + addons-host.js | 102 ++++- bundled-addons/screenshot/addon.json | 15 +- bundled-addons/screenshot/editor.html | 111 +++++ bundled-addons/screenshot/editor.js | 559 ++++++++++++++++++++++++++ bundled-addons/screenshot/index.js | 146 +++++-- bundled-addons/screenshot/panel.html | 230 ----------- main.js | 63 ++- preload.js | 3 + 9 files changed, 966 insertions(+), 279 deletions(-) create mode 100644 addon-tab-preload.js create mode 100644 bundled-addons/screenshot/editor.html create mode 100644 bundled-addons/screenshot/editor.js delete mode 100644 bundled-addons/screenshot/panel.html diff --git a/addon-tab-preload.js b/addon-tab-preload.js new file mode 100644 index 0000000..9057eb4 --- /dev/null +++ b/addon-tab-preload.js @@ -0,0 +1,16 @@ +// Preload for full-tab pages opened by api.openTab("", {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); }), +}); diff --git a/addons-host.js b/addons-host.js index 2827ce7..46f5e37 100644 --- a/addons-host.js +++ b/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. ":///" 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. + 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 @@ -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"); - if (!this._openTab) throw new Error("openTab unavailable (host not wired)"); - this._openTab(u, manifest.id); + // 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(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 }; diff --git a/bundled-addons/screenshot/addon.json b/bundled-addons/screenshot/addon.json index eaee531..b0024c3 100644 --- a/bundled-addons/screenshot/addon.json +++ b/bundled-addons/screenshot/addon.json @@ -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": "✂️" } + ] + } } diff --git a/bundled-addons/screenshot/editor.html b/bundled-addons/screenshot/editor.html new file mode 100644 index 0000000..870d6b1 --- /dev/null +++ b/bundled-addons/screenshot/editor.html @@ -0,0 +1,111 @@ + + + + +Screenshot editor + + + +
+ screenshot + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + + + + +
+
+ +
+
+ + + + + + diff --git a/bundled-addons/screenshot/editor.js b/bundled-addons/screenshot/editor.js new file mode 100644 index 0000000..867f5e3 --- /dev/null +++ b/bundled-addons/screenshot/editor.js @@ -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(); diff --git a/bundled-addons/screenshot/index.js b/bundled-addons/screenshot/index.js index 6940bba..c9c7d1e 100644 --- a/bundled-addons/screenshot/index.js +++ b/bundled-addons/screenshot/index.js @@ -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=&name=…&tool=… as a full Theseus tab. +// The editor loads the PNG onto a and, when the user hits Save, +// downloads the modified image through Chromium's normal 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 /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 /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)"); }, }; diff --git a/bundled-addons/screenshot/panel.html b/bundled-addons/screenshot/panel.html deleted file mode 100644 index d8a76a8..0000000 --- a/bundled-addons/screenshot/panel.html +++ /dev/null @@ -1,230 +0,0 @@ - - - - -Screenshot - - - -
-
📸 Screenshot
-
-
-
-
- - - -
- -
- No capture yet. Pick a mode above. - -
- -
- - -
- -
- Format: - - -
- -
- - -
- -
-
- - - - diff --git a/main.js b/main.js index ad1a66b..3b0c865 100644 --- a/main.js +++ b/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 diff --git a/preload.js b/preload.js index b9f8df6..6efd216 100644 --- a/preload.js +++ b/preload.js @@ -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), });