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
2026-09-07 00:56:57 +02:00
|
|
|
// 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() {
|
2026-09-08 12:57:46 +02:00
|
|
|
// 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 <img> 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;
|
|
|
|
|
}
|
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
2026-09-07 00:56:57 +02:00
|
|
|
let img;
|
2026-09-08 12:57:46 +02:00
|
|
|
try { img = await loadImage(dataUrl); }
|
|
|
|
|
catch (e) { toast("Couldn't load capture: " + e.message, true); return; }
|
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
2026-09-07 00:56:57 +02:00
|
|
|
sizeCanvases(img.naturalWidth, img.naturalHeight);
|
|
|
|
|
cctx.drawImage(img, 0, 0);
|
|
|
|
|
undo = [];
|
|
|
|
|
redo = [];
|
|
|
|
|
pushSnapshot(); // baseline so the very first edit is undoable
|
|
|
|
|
updateUndoButtons();
|
|
|
|
|
fitBoard();
|
2026-09-08 12:57:46 +02:00
|
|
|
const t = initialTool || toolFromPending;
|
|
|
|
|
if (t) setTool(t);
|
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
2026-09-07 00:56:57 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-08 02:27:36 +02:00
|
|
|
// Escape unwinds progressively: in-flight text placement → in-flight
|
|
|
|
|
// crop rectangle → whole editor (drops the screenshot and closes the tab).
|
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
2026-09-07 00:56:57 +02:00
|
|
|
window.addEventListener("keydown", (ev) => {
|
|
|
|
|
if (ev.key === "Escape") {
|
|
|
|
|
if (state.textInput) { cancelText(); ev.preventDefault(); return; }
|
2026-09-08 02:27:36 +02:00
|
|
|
if (state.tool === "crop" && state.cropRect) { state.cropRect = null; clearOverlay(); applyCropBtn.hidden = true; cancelCropBtn.hidden = true; return; }
|
|
|
|
|
discard(); ev.preventDefault(); return;
|
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
2026-09-07 00:56:57 +02:00
|
|
|
}
|
|
|
|
|
// 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);
|
2026-09-08 02:27:36 +02:00
|
|
|
$("discard").addEventListener("click", discard);
|
|
|
|
|
|
|
|
|
|
// Drop the working screenshot and close the editor tab. Used by the
|
|
|
|
|
// Discard button and the top-level Escape shortcut. window.close() on a
|
|
|
|
|
// tab opened via api.openTab lands as the tab's close request; Theseus's
|
|
|
|
|
// tab manager honors it just like a normal cross-origin window.close().
|
|
|
|
|
async function discard() {
|
|
|
|
|
// Preferred path: ask main to close this tab. window.close() on a page
|
|
|
|
|
// Chromium didn't open via script is a no-op by default, and we don't
|
|
|
|
|
// want the editor stuck if the user has changed nothing to save.
|
|
|
|
|
try {
|
|
|
|
|
if (window.silentmode?.closeTab) {
|
|
|
|
|
const ok = await window.silentmode.closeTab();
|
|
|
|
|
if (ok) return;
|
|
|
|
|
}
|
|
|
|
|
} catch { /* fall through */ }
|
|
|
|
|
// Fallbacks: window.close() (works when the tab was opened via JS), then
|
|
|
|
|
// a hard navigation so at minimum the stale screenshot is gone.
|
|
|
|
|
try { window.close(); } catch {}
|
|
|
|
|
location.replace("about:blank");
|
|
|
|
|
}
|
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
2026-09-07 00:56:57 +02:00
|
|
|
|
|
|
|
|
init();
|