Rolling every user report from the 0.6.3 rollout into one bundle: Sounds — the Web-Audio synth palette matches the metaphor now: - Screenshot: Polaroid shutter — sharp metallic tick + curtain-close click chained to a film-advance whir (band-passed noise sweeping 900→400 Hz). - Copy: printer "chika-chika-chika" — three descending percussive noise bursts pinned by short sine ticks. Reads as a print-head sweep. - Discard: paper crumple — three overlapping band-limited noise beds with per-sample random-amplitude crackle, descending centre freq. No more descending sine "boop". - Save: soft "photo dispensing" hiss (Polaroid ejects) + a small click. - Both the panel and editor share the design so nothing sounds different depending on which surface fired it. UI polish: - Discard button now carries a trash-can icon so it's obviously not the same as the close-sidebar X (they both used to be plain X's). - Toolbar drawing tools centre themselves via a new .tool-cluster wrapper (flex:1 1 auto, justify-content:center); the Copy/Save actions stay right-anchored via margin-left:auto on their own tgroup. Fixes the maximized-sidebar case where the drawing groups all crowded the left with a big empty gap before Copy/Save on the right. - Filename moves out of the topbar into a dedicated footer strip under the canvas board, alongside a new "Open in folder" button. The topbar is now flex-wrap:nowrap and holds only fixed-width window controls, so a long filename can never push discard / sound / max / close onto a second row (the filename ellipsises instead). - "Open in folder" invokes a new "openFolder" addon message that calls Electron's shell.showItemInFolder() to open the OS file explorer with the specific scratch PNG highlighted (falls back to shell.openPath() on the scratch dir when no capture is named). Version bump so the OTA update endpoint picks it up on the next tick.
763 lines
28 KiB
JavaScript
763 lines
28 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();
|
|
}
|
|
|
|
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 === "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);
|
|
const el = document.createElement("input");
|
|
el.type = "text";
|
|
el.className = "text-input";
|
|
el.placeholder = "type, then Enter";
|
|
el.autocomplete = "off";
|
|
el.setAttribute("autocorrect", "off");
|
|
el.setAttribute("spellcheck", "false");
|
|
// 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)";
|
|
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 input's own pointerdown / mousedown / click bubble to
|
|
// the canvas — otherwise every click-through re-fires openTextInput on
|
|
// the base and spawns duplicate boxes.
|
|
const swallow = (ev) => ev.stopPropagation();
|
|
for (const t of ["pointerdown", "mousedown", "click"]) el.addEventListener(t, swallow);
|
|
el.addEventListener("keydown", (ev) => {
|
|
if (ev.key === "Enter") { 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";
|
|
bctx.fillText(value, ti.x, ti.y);
|
|
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 === "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();
|