// 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() {
// Preferred source: a pending capture handed to us through addon storage
// (the toolbar-menu capture path). A file:// src won't load — Chromium
// treats the editor at file:///…/addons/screenshot/editor.html as a
// different origin from the scratch PNG at file:///…/addons-data/…,
// so
quietly errors. Storage-based delivery sidesteps the origin
// entirely.
let dataUrl = null;
let toolFromPending = "";
let pendingName = baseName;
try {
if (window.silentmode?.storage) {
const pending = await window.silentmode.storage.get("__pending", null);
if (pending && pending.dataUrl) {
dataUrl = pending.dataUrl;
toolFromPending = pending.tool || "";
if (pending.name) { pendingName = pending.name; nameEl.textContent = pending.name; document.title = pending.name + " — editor"; }
// Clear so a later editor open (e.g. "recent captures") doesn't
// accidentally reload the same capture.
await window.silentmode.storage.set("__pending", null);
}
}
} catch (e) { console.warn("editor: storage read failed:", e); }
// Fallback path — retained so the "openRecent" flow (which currently
// still passes ?src=file://) keeps working after we teach it to use
// storage too.
if (!dataUrl) {
if (!srcUrl) { toast("No capture to edit", true); return; }
dataUrl = srcUrl;
}
let img;
try { img = await loadImage(dataUrl); }
catch (e) { toast("Couldn't load capture: " + 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();
const t = initialTool || toolFromPending;
if (t) setTool(t);
}
// 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 unwinds progressively: in-flight text placement → in-flight
// crop rectangle → whole editor (drops the screenshot and closes the tab).
window.addEventListener("keydown", (ev) => {
if (ev.key === "Escape") {
if (state.textInput) { cancelText(); ev.preventDefault(); return; }
if (state.tool === "crop" && state.cropRect) { state.cropRect = null; clearOverlay(); applyCropBtn.hidden = true; cancelCropBtn.hidden = true; return; }
discard(); ev.preventDefault(); 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);
$("discard").addEventListener("click", discard);
// Back button — navigate the sidebar view back to panel.html. Same
// webContents, so it's just a location swap; no IPC needed.
const backBtn = $("back");
if (backBtn) backBtn.addEventListener("click", () => { location.href = "panel.html"; });
// Maximize / restore — asks the sidebar host to widen its view to the full
// window and back. Icon reflects state via silentmode.sidebar.onMaxChange.
const maxBtn = $("toggle-max");
if (maxBtn && window.silentmode?.sidebar) {
maxBtn.addEventListener("click", async () => {
try { await window.silentmode.sidebar.toggleMax(); }
catch (e) { console.warn("toggleMax failed:", e); }
});
const paint = (isMax) => {
maxBtn.title = isMax ? "Restore sidebar width" : "Expand the sidebar to full window";
maxBtn.innerHTML = isMax
? ''
: '';
};
window.silentmode.sidebar.onMaxChange(paint);
window.silentmode.sidebar.isMax().then(paint).catch(() => {});
}
// Drop the working screenshot and go back to the sidebar panel. Used by
// the Discard button and the top-level Escape shortcut.
async function discard() {
try { if (window.silentmode?.storage) await window.silentmode.storage.set("__pending", null); } catch {}
location.href = "panel.html";
}
init();