Two annotation-editor asks: - Line tool: same drag flow as the arrow, no arrowhead. New toolbar button between arrow and rect, shortcut L. - Text box is resizable: swapped the single-line input for a textarea with resize:both and a drag corner. Enter still commits, Shift+Enter inserts a newline, blur commits. Multi-line rendering steps the fillText baseline by 1.15x the font size per line so the baked pixels match the live layout. The textarea swallows its own pointer events so drag-resizing the corner doesn't leak to the canvas underneath.
791 lines
29 KiB
JavaScript
791 lines
29 KiB
JavaScript
// Screenshot editor v2. Lives inside the sidebar view; no separate tab.
|
|
//
|
|
// Load path (idempotent — this is the key change from v1's __pending drain):
|
|
// editor.html?name=<scratchfile.png>
|
|
// → silentmode.invoke("getBytes", {name})
|
|
// → addon reads scratch/<name>.png from disk and returns
|
|
// { name, dataUrl:"data:image/png;base64,…", bytes, at, mode }
|
|
// → we set an <img> src to that data URL and draw it onto #base
|
|
//
|
|
// Two canvases: #base holds the committed image; #over is a preview layer
|
|
// that hosts the live drag preview for each drawing tool. On mouseup the
|
|
// tool commits its result by drawing #over onto #base, clearing #over,
|
|
// and pushing a fresh ImageData snapshot onto the undo stack.
|
|
//
|
|
// No cross-origin img loading (data: URLs are same-origin in Chromium), so
|
|
// the canvas never gets tainted — getImageData / toBlob keep working.
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
const UNDO_MAX = 25;
|
|
|
|
// ---- audio -----------------------------------------------------------
|
|
// Same synth engine as the sidebar panel: preference stored under
|
|
// silentmode.storage as "soundOn" (default true), no .wav ships in the
|
|
// tarball. Photoshoot-style shutter for capture, quick "printer chirp"
|
|
// for copy — modeled on Firefox's Screenshots feedback tones.
|
|
let soundOn = true;
|
|
let _audio = null;
|
|
function _actx() {
|
|
if (!_audio) { try { _audio = new (window.AudioContext || window.webkitAudioContext)(); } catch {} }
|
|
if (_audio && _audio.state === "suspended") _audio.resume().catch(() => {});
|
|
return _audio;
|
|
}
|
|
function _tone(freq, dur, type, vol, at) {
|
|
if (!soundOn) return;
|
|
const c = _actx(); if (!c) return;
|
|
const t0 = c.currentTime + (at || 0);
|
|
const osc = c.createOscillator();
|
|
const g = c.createGain();
|
|
osc.type = type || "sine";
|
|
osc.frequency.setValueAtTime(freq, t0);
|
|
g.gain.setValueAtTime(vol || 0.15, t0);
|
|
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
|
osc.connect(g); g.connect(c.destination);
|
|
osc.start(t0);
|
|
osc.stop(t0 + dur + 0.02);
|
|
}
|
|
// Filtered noise burst. `bp` = band-pass centre freq, `q` = sharpness of
|
|
// the band. Optional `pan` gives a random-amplitude micro-modulation
|
|
// texture — used by the paper-crumple pass.
|
|
function _noise(dur, vol, bp, q, at, texture) {
|
|
if (!soundOn) return;
|
|
const c = _actx(); if (!c) return;
|
|
const t0 = c.currentTime + (at || 0);
|
|
const n = Math.max(1, Math.floor(c.sampleRate * dur));
|
|
const buf = c.createBuffer(1, n, c.sampleRate);
|
|
const d = buf.getChannelData(0);
|
|
for (let i = 0; i < n; i++) {
|
|
let s = Math.random() * 2 - 1;
|
|
if (texture) s *= 0.4 + Math.random() * 0.6; // crackle
|
|
d[i] = s;
|
|
}
|
|
const src = c.createBufferSource(); src.buffer = buf;
|
|
const flt = c.createBiquadFilter(); flt.type = "bandpass";
|
|
flt.frequency.setValueAtTime(bp || 2000, t0);
|
|
flt.Q.setValueAtTime(q || 4, t0);
|
|
const g = c.createGain();
|
|
g.gain.setValueAtTime(vol || 0.2, t0);
|
|
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
|
src.connect(flt); flt.connect(g); g.connect(c.destination);
|
|
src.start(t0);
|
|
src.stop(t0 + dur + 0.02);
|
|
}
|
|
// Slowly-sweeping band-pass noise — used as the film-advance "whir" tail
|
|
// under the shutter click.
|
|
function _sweep(dur, vol, fromHz, toHz, q, at) {
|
|
if (!soundOn) return;
|
|
const c = _actx(); if (!c) return;
|
|
const t0 = c.currentTime + (at || 0);
|
|
const n = Math.max(1, Math.floor(c.sampleRate * dur));
|
|
const buf = c.createBuffer(1, n, c.sampleRate);
|
|
const d = buf.getChannelData(0);
|
|
for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1;
|
|
const src = c.createBufferSource(); src.buffer = buf;
|
|
const flt = c.createBiquadFilter(); flt.type = "bandpass";
|
|
flt.frequency.setValueAtTime(fromHz, t0);
|
|
flt.frequency.exponentialRampToValueAtTime(toHz, t0 + dur);
|
|
flt.Q.setValueAtTime(q || 8, t0);
|
|
const g = c.createGain();
|
|
g.gain.setValueAtTime(0.0001, t0);
|
|
g.gain.exponentialRampToValueAtTime(vol, t0 + dur * 0.2);
|
|
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
|
src.connect(flt); flt.connect(g); g.connect(c.destination);
|
|
src.start(t0);
|
|
src.stop(t0 + dur + 0.02);
|
|
}
|
|
// Polaroid: sharp mechanical click (shutter mirror + curtain) followed by
|
|
// a short film-advance whir. Reads unmistakably as "camera taking a
|
|
// picture" rather than a UI beep.
|
|
function playShutter() {
|
|
_noise(0.02, 0.32, 5200, 8, 0); // sharp metallic tick
|
|
_tone(160, 0.03, "square", 0.14, 0.003); // mirror thud
|
|
_noise(0.03, 0.24, 3200, 5, 0.03); // curtain close
|
|
_tone(120, 0.04, "square", 0.10, 0.035);
|
|
_sweep(0.28, 0.10, 900, 400, 12, 0.07); // film-advance whir tail
|
|
}
|
|
// Printer chika-chika-chika — three descending percussive bursts modelled
|
|
// on a print head sweeping across a page. Fast and unmistakably "action
|
|
// happened", no residual hum.
|
|
function playCopy() {
|
|
_noise(0.028, 0.22, 3800, 9, 0.00);
|
|
_tone(1200, 0.03, "sine", 0.10, 0.00);
|
|
_noise(0.028, 0.22, 3200, 9, 0.06);
|
|
_tone(1000, 0.03, "sine", 0.10, 0.06);
|
|
_noise(0.028, 0.22, 2600, 9, 0.12);
|
|
_tone(800, 0.03, "sine", 0.10, 0.12);
|
|
}
|
|
// Photo dispensing — a soft pneumatic hiss with a small final click, the
|
|
// way a Polaroid ejects its print.
|
|
function playSave() {
|
|
_sweep(0.18, 0.14, 3800, 1600, 3, 0);
|
|
_noise(0.02, 0.20, 2400, 8, 0.18);
|
|
_tone(900, 0.04, "sine", 0.12, 0.20);
|
|
}
|
|
// Paper crumple — a long band-limited noise with texture crackle and a
|
|
// descending centre frequency, reading as "a page being scrunched".
|
|
function playDiscard() {
|
|
_noise(0.16, 0.28, 2600, 3, 0.00, true); // main body
|
|
_noise(0.12, 0.22, 1800, 3, 0.06, true); // trailing scrunch
|
|
_noise(0.08, 0.16, 1200, 3, 0.12, true);
|
|
}
|
|
function playCropApply() { _tone(1200, 0.05, "sine", 0.14, 0); _tone(1600, 0.07, "sine", 0.14, 0.04); }
|
|
|
|
const base = $("base");
|
|
const over = $("over");
|
|
const stage = $("stage");
|
|
const board = $("board");
|
|
const empty = $("empty");
|
|
const nameEl = $("name");
|
|
const undoBtn = $("undo");
|
|
const redoBtn = $("redo");
|
|
const toastEl = $("toast");
|
|
const dl = $("download-link");
|
|
|
|
const bctx = base.getContext("2d");
|
|
const octx = over.getContext("2d");
|
|
|
|
const params = new URLSearchParams(location.search);
|
|
const NAME = params.get("name") || "";
|
|
|
|
let state = {
|
|
tool: "select",
|
|
color: "#ff3b30",
|
|
width: 5,
|
|
drag: null, // {x0,y0,x,y}
|
|
pen: null, // [{x,y}, …]
|
|
textInput: null, // {x, y, el}
|
|
cropRect: null, // committed-crop rectangle in canvas coords, {x,y,w,h}
|
|
};
|
|
|
|
let undo = [];
|
|
let redo = [];
|
|
|
|
// -- utilities --------------------------------------------------------------
|
|
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"), 1800);
|
|
}
|
|
|
|
function updateUndoRedo() {
|
|
undoBtn.disabled = undo.length <= 1; // baseline snapshot always at index 0
|
|
redoBtn.disabled = redo.length === 0;
|
|
}
|
|
|
|
function pushSnapshot() {
|
|
try {
|
|
const snap = bctx.getImageData(0, 0, base.width, base.height);
|
|
undo.push(snap);
|
|
if (undo.length > UNDO_MAX) undo.shift();
|
|
redo = [];
|
|
updateUndoRedo();
|
|
} catch (e) {
|
|
console.warn("snapshot failed:", e);
|
|
}
|
|
}
|
|
|
|
function restoreSnapshot(snap) {
|
|
if (!snap) return;
|
|
if (base.width !== snap.width || base.height !== snap.height) {
|
|
sizeCanvases(snap.width, snap.height);
|
|
}
|
|
bctx.putImageData(snap, 0, 0);
|
|
}
|
|
|
|
function sizeCanvases(w, h) {
|
|
for (const c of [base, over]) {
|
|
c.width = w;
|
|
c.height = h;
|
|
c.style.width = w + "px";
|
|
c.style.height = h + "px";
|
|
}
|
|
stage.style.width = w + "px";
|
|
stage.style.height = h + "px";
|
|
}
|
|
|
|
// Scale the stage down to fit inside the board when the image is larger
|
|
// than the visible area. Purely visual — drawing math stays in natural px.
|
|
function fitBoard() {
|
|
const availW = Math.max(50, board.clientWidth - 24);
|
|
const availH = Math.max(50, board.clientHeight - 24);
|
|
const s = Math.min(1, availW / base.width, availH / base.height);
|
|
const scale = s > 0 && Number.isFinite(s) ? s : 1;
|
|
stage.style.transform = `scale(${scale})`;
|
|
stage.style.width = (base.width * scale) + "px";
|
|
stage.style.height = (base.height * scale) + "px";
|
|
stage._scale = scale;
|
|
}
|
|
|
|
// Convert a viewport-relative pointer event into natural canvas coords.
|
|
function pointToCanvas(ev) {
|
|
const rect = base.getBoundingClientRect();
|
|
const scale = stage._scale || 1;
|
|
const cssW = rect.width, cssH = rect.height;
|
|
const x = (ev.clientX - rect.left) * (base.width / (cssW || 1));
|
|
const y = (ev.clientY - rect.top) * (base.height / (cssH || 1));
|
|
return { x, y };
|
|
}
|
|
|
|
// -- loading ---------------------------------------------------------------
|
|
async function loadFromAddon(name) {
|
|
if (!window.silentmode?.invoke) {
|
|
throw new Error("silentmode API not available in this view");
|
|
}
|
|
const res = await window.silentmode.invoke("getBytes", { name });
|
|
if (!res || !res.dataUrl) throw new Error("addon returned no data URL");
|
|
return res;
|
|
}
|
|
|
|
function loadImage(dataUrl) {
|
|
return new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
img.onload = () => resolve(img);
|
|
img.onerror = () => reject(new Error("image decode failed"));
|
|
// NB: no crossOrigin — data: URLs are same-origin, and setting it
|
|
// to anonymous would require CORS headers that a data URL can't carry,
|
|
// which is precisely what tripped up v1.
|
|
img.src = dataUrl;
|
|
});
|
|
}
|
|
|
|
async function init() {
|
|
document.title = NAME ? NAME + " — editor" : "Screenshot editor";
|
|
nameEl.textContent = NAME || "screenshot";
|
|
if (!NAME) {
|
|
empty.classList.add("err");
|
|
empty.innerHTML = "<div>Missing <code>?name</code> — open a capture from the Screenshot sidebar.</div>";
|
|
return;
|
|
}
|
|
let res;
|
|
try { res = await loadFromAddon(NAME); }
|
|
catch (e) {
|
|
console.warn("editor load failed:", e);
|
|
empty.classList.add("err");
|
|
empty.innerHTML = `<div>Couldn't load capture: ${(e && e.message || e)}</div>`;
|
|
return;
|
|
}
|
|
let img;
|
|
try { img = await loadImage(res.dataUrl); }
|
|
catch (e) {
|
|
empty.classList.add("err");
|
|
empty.innerHTML = `<div>Couldn't decode capture bytes (${res.bytes || 0} B)</div>`;
|
|
return;
|
|
}
|
|
sizeCanvases(img.naturalWidth, img.naturalHeight);
|
|
bctx.drawImage(img, 0, 0);
|
|
undo = [];
|
|
redo = [];
|
|
pushSnapshot();
|
|
empty.hidden = true;
|
|
stage.hidden = false;
|
|
fitBoard();
|
|
}
|
|
window.addEventListener("resize", () => { if (!stage.hidden) fitBoard(); });
|
|
|
|
// -- tool selection --------------------------------------------------------
|
|
function setTool(name) {
|
|
// Leaving crop mode with an unapplied marquee drops it.
|
|
if (state.tool === "crop" && name !== "crop" && state.cropRect) {
|
|
state.cropRect = null; clearOver();
|
|
}
|
|
state.tool = name;
|
|
stage.dataset.tool = name;
|
|
for (const b of document.querySelectorAll(".tool")) {
|
|
b.classList.toggle("active", b.dataset.tool === name);
|
|
}
|
|
cancelTextInput();
|
|
updateCropUI();
|
|
}
|
|
|
|
// Show/hide the Apply crop / Cancel crop buttons in the top bar. Visible
|
|
// only while the tool is "crop" AND a rectangle has been drawn.
|
|
function updateCropUI() {
|
|
const on = state.tool === "crop" && !!state.cropRect;
|
|
const ac = document.getElementById("apply-crop");
|
|
const cc = document.getElementById("cancel-crop");
|
|
if (ac) ac.hidden = !on;
|
|
if (cc) cc.hidden = !on;
|
|
}
|
|
|
|
// Apply the crop: resize the base canvas to the rect's size, draw the
|
|
// cropped region onto it, then clear undo/redo (the coordinate system has
|
|
// changed — pre-crop snapshots would restore into the wrong dimensions).
|
|
// Baseline snapshot for the cropped canvas becomes the new floor.
|
|
function applyCrop() {
|
|
const r = state.cropRect;
|
|
if (!r) return;
|
|
const w = Math.max(1, Math.round(r.w));
|
|
const h = Math.max(1, Math.round(r.h));
|
|
const x = Math.max(0, Math.round(r.x));
|
|
const y = Math.max(0, Math.round(r.y));
|
|
const tmp = document.createElement("canvas");
|
|
tmp.width = w; tmp.height = h;
|
|
tmp.getContext("2d").drawImage(base, x, y, w, h, 0, 0, w, h);
|
|
sizeCanvases(w, h);
|
|
bctx.drawImage(tmp, 0, 0);
|
|
state.cropRect = null;
|
|
clearOver();
|
|
undo = [];
|
|
redo = [];
|
|
pushSnapshot();
|
|
fitBoard();
|
|
updateCropUI();
|
|
playCropApply();
|
|
setTool("select");
|
|
}
|
|
function cancelCrop() {
|
|
state.cropRect = null;
|
|
clearOver();
|
|
updateCropUI();
|
|
}
|
|
// Clicking an already-active tool toggles it off (back to "select" = no
|
|
// tool). The dedicated "Select" button used to live here but read as
|
|
// broken to users — clicking it did nothing visible. Toggle-off gives the
|
|
// same "put the pen down" affordance without a mystery button.
|
|
for (const b of document.querySelectorAll(".tool")) {
|
|
b.addEventListener("click", () => {
|
|
setTool(state.tool === b.dataset.tool ? "select" : 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) || 5;
|
|
for (const x of document.querySelectorAll(".width")) x.classList.toggle("active", x === b);
|
|
});
|
|
}
|
|
|
|
// -- drawing ---------------------------------------------------------------
|
|
function clearOver() { octx.clearRect(0, 0, over.width, over.height); }
|
|
|
|
function strokeStyle() {
|
|
octx.strokeStyle = state.color;
|
|
octx.fillStyle = state.color;
|
|
octx.lineWidth = state.width;
|
|
octx.lineCap = "round";
|
|
octx.lineJoin = "round";
|
|
}
|
|
|
|
function drawArrow(a, b) {
|
|
strokeStyle();
|
|
octx.beginPath();
|
|
octx.moveTo(a.x, a.y); octx.lineTo(b.x, b.y); octx.stroke();
|
|
// arrowhead
|
|
const dx = b.x - a.x, dy = b.y - a.y;
|
|
const len = Math.hypot(dx, dy);
|
|
if (len < 1) return;
|
|
const head = Math.max(10, state.width * 3);
|
|
const ang = Math.atan2(dy, dx);
|
|
const spread = Math.PI / 6;
|
|
octx.beginPath();
|
|
octx.moveTo(b.x, b.y);
|
|
octx.lineTo(b.x - head * Math.cos(ang - spread), b.y - head * Math.sin(ang - spread));
|
|
octx.moveTo(b.x, b.y);
|
|
octx.lineTo(b.x - head * Math.cos(ang + spread), b.y - head * Math.sin(ang + spread));
|
|
octx.stroke();
|
|
}
|
|
|
|
// Plain line — same drag flow as arrow, minus the head. Useful for
|
|
// underlines / separators / crossing-out without pointing at anything.
|
|
function drawLine(a, b) {
|
|
strokeStyle();
|
|
octx.beginPath();
|
|
octx.moveTo(a.x, a.y); octx.lineTo(b.x, b.y); octx.stroke();
|
|
}
|
|
|
|
function drawRect(a, b) {
|
|
strokeStyle();
|
|
const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y);
|
|
const w = Math.abs(b.x - a.x), h = Math.abs(b.y - a.y);
|
|
octx.strokeRect(x, y, w, h);
|
|
}
|
|
|
|
function drawEllipse(a, b) {
|
|
strokeStyle();
|
|
const cx = (a.x + b.x) / 2, cy = (a.y + b.y) / 2;
|
|
const rx = Math.abs(b.x - a.x) / 2, ry = Math.abs(b.y - a.y) / 2;
|
|
octx.beginPath();
|
|
octx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
|
|
octx.stroke();
|
|
}
|
|
|
|
function drawPen(points) {
|
|
if (!points || points.length < 2) return;
|
|
strokeStyle();
|
|
octx.beginPath();
|
|
octx.moveTo(points[0].x, points[0].y);
|
|
for (let i = 1; i < points.length; i++) octx.lineTo(points[i].x, points[i].y);
|
|
octx.stroke();
|
|
}
|
|
|
|
// Crop overlay — dim the area outside the rect, thin dashed border. This
|
|
// is a live preview during drag AND the settled marquee while apply-crop
|
|
// is pending.
|
|
function drawCropRect(a, b) {
|
|
const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y);
|
|
const w = Math.abs(b.x - a.x), h = Math.abs(b.y - a.y);
|
|
clearOver();
|
|
octx.save();
|
|
octx.fillStyle = "rgba(10,13,19,0.55)";
|
|
octx.fillRect(0, 0, over.width, over.height);
|
|
octx.clearRect(x, y, w, h);
|
|
octx.strokeStyle = "#d6ff3d";
|
|
octx.lineWidth = 1.5;
|
|
octx.setLineDash([6, 4]);
|
|
octx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);
|
|
octx.restore();
|
|
return { x, y, w, h };
|
|
}
|
|
|
|
// Mosaic redaction: downsample the selected region of #base to blocks of
|
|
// ~<blockSize> px, then paint the blocks back. The whole thing commits
|
|
// straight to #base (there's no live preview — the drag rect uses the
|
|
// crop-style marquee to show the target region).
|
|
function commitBlur(a, b) {
|
|
const x = Math.max(0, Math.min(a.x, b.x)) | 0;
|
|
const y = Math.max(0, Math.min(a.y, b.y)) | 0;
|
|
const w = Math.min(base.width - x, Math.abs(b.x - a.x) | 0);
|
|
const h = Math.min(base.height - y, Math.abs(b.y - a.y) | 0);
|
|
if (w < 4 || h < 4) return false;
|
|
const block = Math.max(6, Math.round(Math.min(w, h) / 12));
|
|
const tmp = document.createElement("canvas");
|
|
const scaleW = Math.max(1, Math.floor(w / block));
|
|
const scaleH = Math.max(1, Math.floor(h / block));
|
|
tmp.width = scaleW; tmp.height = scaleH;
|
|
const tctx = tmp.getContext("2d");
|
|
tctx.imageSmoothingEnabled = true;
|
|
tctx.drawImage(base, x, y, w, h, 0, 0, scaleW, scaleH);
|
|
bctx.imageSmoothingEnabled = false;
|
|
bctx.drawImage(tmp, 0, 0, scaleW, scaleH, x, y, w, h);
|
|
bctx.imageSmoothingEnabled = true;
|
|
return true;
|
|
}
|
|
|
|
function commitOver() {
|
|
bctx.drawImage(over, 0, 0);
|
|
clearOver();
|
|
pushSnapshot();
|
|
}
|
|
|
|
// -- input --------------------------------------------------------------
|
|
over.style.pointerEvents = "none"; // draw layer never blocks input; base receives
|
|
base.addEventListener("pointerdown", (ev) => {
|
|
if (state.tool === "select") return;
|
|
if (state.tool === "text") {
|
|
ev.preventDefault();
|
|
openTextInput(pointToCanvas(ev));
|
|
return;
|
|
}
|
|
base.setPointerCapture(ev.pointerId);
|
|
const p = pointToCanvas(ev);
|
|
if (state.tool === "pen") { state.pen = [p]; }
|
|
else { state.drag = { x0: p.x, y0: p.y, x: p.x, y: p.y }; }
|
|
});
|
|
base.addEventListener("pointermove", (ev) => {
|
|
const p = pointToCanvas(ev);
|
|
if (state.tool === "pen" && state.pen) {
|
|
state.pen.push(p);
|
|
clearOver();
|
|
drawPen(state.pen);
|
|
return;
|
|
}
|
|
if (!state.drag) return;
|
|
state.drag.x = p.x; state.drag.y = p.y;
|
|
const a = { x: state.drag.x0, y: state.drag.y0 }, b = { x: p.x, y: p.y };
|
|
if (state.tool === "arrow") { clearOver(); drawArrow(a, b); }
|
|
else if (state.tool === "line") { clearOver(); drawLine(a, b); }
|
|
else if (state.tool === "rect") { clearOver(); drawRect(a, b); }
|
|
else if (state.tool === "ellipse") { clearOver(); drawEllipse(a, b); }
|
|
else if (state.tool === "crop"
|
|
|| state.tool === "blur") { drawCropRect(a, b); }
|
|
});
|
|
base.addEventListener("pointerup", (ev) => {
|
|
try { base.releasePointerCapture(ev.pointerId); } catch {}
|
|
if (state.tool === "pen" && state.pen) {
|
|
if (state.pen.length >= 2) commitOver();
|
|
else clearOver();
|
|
state.pen = null;
|
|
return;
|
|
}
|
|
if (!state.drag) return;
|
|
const dx = state.drag.x - state.drag.x0, dy = state.drag.y - state.drag.y0;
|
|
if (Math.hypot(dx, dy) < 2) { clearOver(); state.drag = null; state.cropRect = null; updateCropUI(); return; }
|
|
const a = { x: state.drag.x0, y: state.drag.y0 }, b = { x: state.drag.x, y: state.drag.y };
|
|
if (state.tool === "crop") {
|
|
// Don't commit yet — the marquee stays up until the user hits Apply.
|
|
state.cropRect = drawCropRect(a, b);
|
|
state.drag = null;
|
|
updateCropUI();
|
|
return;
|
|
}
|
|
if (state.tool === "blur") {
|
|
if (commitBlur(a, b)) { clearOver(); pushSnapshot(); }
|
|
else clearOver();
|
|
state.drag = null;
|
|
return;
|
|
}
|
|
commitOver();
|
|
state.drag = null;
|
|
});
|
|
|
|
// -- text tool -----------------------------------------------------------
|
|
// A little <input> floats over the canvas at the click point; Enter commits
|
|
// as fillText, Escape drops. Chases a few browser quirks:
|
|
// - Focus after the DOM mutation, not before: some Chromium builds
|
|
// drop focus() when the element hasn't yet been laid out.
|
|
// - Contain the input's own pointer events so a mousedown inside it
|
|
// doesn't bubble to the canvas and immediately re-fire openTextInput,
|
|
// spawning a fresh empty box.
|
|
// - Track height instead of a hard-coded 14 px offset so tall fonts sit
|
|
// on the click's baseline rather than 14 px above it.
|
|
function openTextInput(pt) {
|
|
cancelTextInput();
|
|
const size = Math.max(16, state.width * 4);
|
|
// Textarea (not input) so the user can drag the corner to resize the
|
|
// box, and so text can wrap / span multiple lines. Enter commits;
|
|
// Shift+Enter inserts a newline.
|
|
const el = document.createElement("textarea");
|
|
el.className = "text-input";
|
|
el.placeholder = "type, then Enter (Shift+Enter for newline · drag corner to resize)";
|
|
el.autocomplete = "off";
|
|
el.setAttribute("autocorrect", "off");
|
|
el.setAttribute("spellcheck", "false");
|
|
el.rows = 1;
|
|
// Visual weight — a bright acid halo around the box + the actual
|
|
// ink colour on the text itself, so users see something clearly
|
|
// happened when they clicked.
|
|
el.style.color = state.color;
|
|
el.style.font = `${size}px system-ui, -apple-system, Segoe UI, Roboto, sans-serif`;
|
|
el.style.lineHeight = "1.15";
|
|
el.style.background = "rgba(11,14,20,0.94)";
|
|
el.style.border = "2px solid var(--acid, #d6ff3d)";
|
|
el.style.boxShadow = "0 0 0 3px rgba(214,255,61,.25), 0 4px 14px rgba(0,0,0,.45)";
|
|
el.style.resize = "both";
|
|
el.style.overflow = "auto";
|
|
el.style.whiteSpace = "pre";
|
|
const rect = base.getBoundingClientRect();
|
|
const scale = stage._scale || 1;
|
|
const cssX = pt.x * scale + rect.left;
|
|
const cssY = pt.y * scale + rect.top;
|
|
el.style.left = cssX + "px";
|
|
el.style.top = (cssY - size) + "px";
|
|
document.body.appendChild(el);
|
|
// Three-way focus attempt — Chromium is racy about focusing an element
|
|
// that appeared mid-pointer-event. Synchronous focus() first (works on
|
|
// most builds), then a paint tick, then a short timer as belt-and-braces.
|
|
el.focus();
|
|
requestAnimationFrame(() => el.focus());
|
|
setTimeout(() => { if (state.textInput && state.textInput.el === el) el.focus(); }, 30);
|
|
state.textInput = { x: pt.x, y: pt.y, size, el };
|
|
// Don't let the box's own pointer events bubble to the canvas —
|
|
// otherwise every click-through re-fires openTextInput on the base and
|
|
// spawns duplicate boxes, and drag-to-resize on the corner handle would
|
|
// start a canvas drag on the tool underneath.
|
|
const swallow = (ev) => ev.stopPropagation();
|
|
for (const t of ["pointerdown", "mousedown", "click", "pointerup", "pointermove"]) {
|
|
el.addEventListener(t, swallow);
|
|
}
|
|
el.addEventListener("keydown", (ev) => {
|
|
if (ev.key === "Enter" && !ev.shiftKey) {
|
|
commitTextInput(); ev.preventDefault(); ev.stopPropagation();
|
|
}
|
|
else if (ev.key === "Escape") { cancelTextInput(); ev.preventDefault(); ev.stopPropagation(); }
|
|
// Every other key stays inside the input — belt against a stray
|
|
// document-level keydown handler stealing focus.
|
|
else ev.stopPropagation();
|
|
});
|
|
el.addEventListener("blur", commitTextInput);
|
|
}
|
|
function commitTextInput() {
|
|
const ti = state.textInput;
|
|
if (!ti) return;
|
|
const value = ti.el.value;
|
|
const trimmed = value.trim();
|
|
ti.el.remove();
|
|
state.textInput = null;
|
|
if (!trimmed) return;
|
|
bctx.fillStyle = state.color;
|
|
bctx.font = `${ti.size}px system-ui, -apple-system, Segoe UI, Roboto, sans-serif`;
|
|
bctx.textBaseline = "alphabetic";
|
|
// Multi-line rendering — one fillText per line, stepping down by the
|
|
// font's line height so the visible box's layout matches the baked-in
|
|
// pixels.
|
|
const lineH = ti.size * 1.15;
|
|
const lines = value.split(/\r?\n/);
|
|
for (let i = 0; i < lines.length; i++) {
|
|
bctx.fillText(lines[i], ti.x, ti.y + i * lineH);
|
|
}
|
|
pushSnapshot();
|
|
}
|
|
function cancelTextInput() {
|
|
if (!state.textInput) return;
|
|
state.textInput.el.remove();
|
|
state.textInput = null;
|
|
}
|
|
|
|
// -- undo / redo -------------------------------------------------------
|
|
function doUndo() {
|
|
if (undo.length <= 1) return;
|
|
const cur = undo.pop();
|
|
redo.push(cur);
|
|
restoreSnapshot(undo[undo.length - 1]);
|
|
updateUndoRedo();
|
|
}
|
|
function doRedo() {
|
|
const snap = redo.pop();
|
|
if (!snap) return;
|
|
undo.push(snap);
|
|
restoreSnapshot(snap);
|
|
updateUndoRedo();
|
|
}
|
|
undoBtn.addEventListener("click", doUndo);
|
|
redoBtn.addEventListener("click", doRedo);
|
|
|
|
// -- save / copy ------------------------------------------------------
|
|
function canvasBlob() {
|
|
return new Promise((resolve, reject) =>
|
|
base.toBlob((b) => b ? resolve(b) : reject(new Error("toBlob returned null")), "image/png"));
|
|
}
|
|
async function save() {
|
|
try {
|
|
const blob = await canvasBlob();
|
|
const url = URL.createObjectURL(blob);
|
|
dl.href = url;
|
|
dl.download = NAME || "screenshot.png";
|
|
dl.click();
|
|
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
|
playSave();
|
|
toast(`Saved ${dl.download} (${(blob.size / 1024).toFixed(1)} KB)`);
|
|
} catch (e) {
|
|
toast("Save failed: " + (e && e.message || e), true);
|
|
}
|
|
}
|
|
async function copy() {
|
|
try {
|
|
const blob = await canvasBlob();
|
|
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
|
|
playCopy();
|
|
toast("Copied to clipboard");
|
|
} catch (e) {
|
|
toast("Copy failed: " + (e && e.message || e), true);
|
|
}
|
|
}
|
|
$("save").addEventListener("click", save);
|
|
$("copy").addEventListener("click", copy);
|
|
const applyCropBtn = document.getElementById("apply-crop");
|
|
const cancelCropBtn = document.getElementById("cancel-crop");
|
|
if (applyCropBtn) applyCropBtn.addEventListener("click", applyCrop);
|
|
if (cancelCropBtn) cancelCropBtn.addEventListener("click", cancelCrop);
|
|
|
|
// -- top-bar navigation ----------------------------------------------
|
|
$("back").addEventListener("click", () => { location.href = "panel.html"; });
|
|
|
|
// "Open in folder" — sends the current capture's name to the addon's
|
|
// openFolder handler, which calls shell.showItemInFolder() so the file
|
|
// explorer opens with the exact scratch PNG highlighted.
|
|
const openFolderBtn = document.getElementById("open-folder");
|
|
if (openFolderBtn) openFolderBtn.addEventListener("click", async () => {
|
|
try {
|
|
if (window.silentmode?.invoke) {
|
|
await window.silentmode.invoke("openFolder", { name: NAME || "" });
|
|
}
|
|
} catch (e) {
|
|
console.warn("open-folder failed:", e);
|
|
toast("Couldn't open folder: " + (e && e.message || e), true);
|
|
}
|
|
});
|
|
|
|
// Discard — throw away this capture entirely (delete it from the Recent
|
|
// ring) and return to the panel. Back is non-destructive; Discard is not.
|
|
const discardBtn = document.getElementById("discard");
|
|
if (discardBtn) discardBtn.addEventListener("click", async () => {
|
|
playDiscard();
|
|
try {
|
|
if (NAME && window.silentmode?.invoke) {
|
|
await window.silentmode.invoke("clearRecent", { name: NAME });
|
|
}
|
|
} catch (e) { console.warn("discard: clearRecent failed:", e); }
|
|
location.href = "panel.html";
|
|
});
|
|
|
|
// Close the sidebar entirely — same IPC the dock icon toggles. We stop
|
|
// on the panel side (see panel.html); the editor mirrors the affordance
|
|
// so users don't need to navigate back before hiding the addon.
|
|
const closeSbBtn = document.getElementById("close-sidebar");
|
|
if (closeSbBtn && window.silentmode?.sidebar) {
|
|
closeSbBtn.addEventListener("click", () => {
|
|
try { window.silentmode.sidebar.close(); } catch {}
|
|
});
|
|
}
|
|
|
|
// Sound-toggle button — mirrors the sidebar panel's, both persist via
|
|
// silentmode.storage so opening either surface reflects the same setting.
|
|
const soundBtn = $("toggle-sound");
|
|
function paintSoundIcon() {
|
|
if (!soundBtn) return;
|
|
soundBtn.title = soundOn ? "Sounds on — click to mute" : "Sounds off — click to enable";
|
|
soundBtn.innerHTML = soundOn
|
|
? '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 6v4h3l3.5 3V3L5.5 6z"/><path d="M11 5.5c1 .8 1 4.2 0 5"/><path d="M12.5 4c2 1.5 2 6.5 0 8"/></svg>'
|
|
: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 6v4h3l3.5 3V3L5.5 6z"/><path d="M11 5l4 6M15 5l-4 6"/></svg>';
|
|
}
|
|
(async () => {
|
|
try {
|
|
const v = await window.silentmode?.storage?.get("soundOn", true);
|
|
soundOn = v !== false;
|
|
} catch {}
|
|
paintSoundIcon();
|
|
})();
|
|
if (soundBtn) soundBtn.addEventListener("click", async () => {
|
|
soundOn = !soundOn;
|
|
paintSoundIcon();
|
|
try { await window.silentmode?.storage?.set("soundOn", soundOn); } catch {}
|
|
if (soundOn) playSave();
|
|
});
|
|
|
|
const maxBtn = $("toggle-max");
|
|
async function paintMaxIcon(isMax) {
|
|
maxBtn.title = isMax ? "Restore sidebar width" : "Expand the sidebar to full window";
|
|
maxBtn.innerHTML = isMax
|
|
? '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 2v4H2M10 2v4h4M6 14v-4H2M10 14v-4h4"/></svg>'
|
|
: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 6V2h4M14 6V2h-4M2 10v4h4M14 10v4h-4"/></svg>';
|
|
}
|
|
if (window.silentmode?.sidebar) {
|
|
maxBtn.addEventListener("click", async () => {
|
|
try { await window.silentmode.sidebar.toggleMax(); }
|
|
catch (e) { console.warn("toggleMax failed:", e); }
|
|
});
|
|
window.silentmode.sidebar.onMaxChange(paintMaxIcon);
|
|
window.silentmode.sidebar.isMax().then(paintMaxIcon).catch(() => {});
|
|
}
|
|
|
|
// -- keyboard shortcuts ---------------------------------------------
|
|
window.addEventListener("keydown", (ev) => {
|
|
if (state.textInput) return;
|
|
const meta = ev.ctrlKey || ev.metaKey;
|
|
if (meta && ev.key.toLowerCase() === "z" && !ev.shiftKey) { doUndo(); ev.preventDefault(); return; }
|
|
if (meta && ev.key.toLowerCase() === "z" && ev.shiftKey) { doRedo(); ev.preventDefault(); return; }
|
|
if (meta && ev.key.toLowerCase() === "s") { save(); ev.preventDefault(); return; }
|
|
if (meta && ev.key.toLowerCase() === "c") { copy(); ev.preventDefault(); return; }
|
|
if (state.tool === "crop" && state.cropRect) {
|
|
if (ev.key === "Enter") { applyCrop(); ev.preventDefault(); return; }
|
|
if (ev.key === "Escape") { cancelCrop(); ev.preventDefault(); return; }
|
|
}
|
|
if (ev.key === "a") setTool("arrow");
|
|
else if (ev.key === "l") setTool("line");
|
|
else if (ev.key === "r") setTool("rect");
|
|
else if (ev.key === "o") setTool("ellipse");
|
|
else if (ev.key === "p") setTool("pen");
|
|
else if (ev.key === "t") setTool("text");
|
|
else if (ev.key === "c") setTool("crop");
|
|
else if (ev.key === "b") setTool("blur");
|
|
else if (ev.key === "Escape") setTool("select");
|
|
});
|
|
|
|
init();
|