Four user reports from the 0.6.0 rollout: - Text tool never committed. openTextInput placed the box correctly but a couple of Chromium quirks stopped a normal type-Enter cycle: focus() called synchronously right after appendChild lost the race in some builds, and the input's own mousedown / click was bubbling through to #base and re-firing openTextInput on every subsequent keystroke click-through, so what looked like "nothing happens" was actually "a new empty box spawned on top of the last one every time". Now: focus after requestAnimationFrame, contain pointerdown / mousedown / click inside the input so they don't bubble to the canvas, track the font size on the state so commit uses the same one openTextInput measured against, and preventDefault on the base pointerdown so Chromium doesn't reset focus back to <body>. - Toolbar wrapped one dot at a time when the sidebar was narrow (a lonely thin/medium/thick width would jump to a second row while the swatches stayed above it). Toolbar items are now wrapped in `<div class="tgroup">` per category — tools / swatches / widths / undo-redo — with `flex: 0 0 auto`, so a whole row wraps as a unit and lands cleanly under the previous one. `gap: 10px / row-gap: 6px` keeps the visual grouping obvious. - No way to close the sidebar without hunting for the dock icon. Added an X button in the top-right of both the sidebar panel and the editor toolbar. Both wire through a new `silentmode.sidebar.close()` preload method that calls the existing `sidebar-close` IPC. - Tightened the pointerdown text branch so preventDefault + explicit focus-after-frame make the click-through races impossible. Bundled but not shipped separately — parent session signs and pushes.
673 lines
25 KiB
JavaScript
673 lines
25 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);
|
|
}
|
|
// A short filtered noise burst — the mechanical component of the shutter
|
|
// click and (paired lower down) the "paper feed" texture of the print
|
|
// sound. `bp` is the band-pass centre frequency, `q` how tight the band.
|
|
function _noise(dur, vol, bp, 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(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);
|
|
}
|
|
// Photoshoot: two clicks — mirror-slap up, mirror-slap down. A short
|
|
// high-band noise burst is the metallic ping, a low-band burst is the
|
|
// mechanical thud, and a very short low tone fills in the body of the
|
|
// shutter closing.
|
|
function playShutter() {
|
|
_noise(0.03, 0.28, 4200, 6, 0); // first click, up
|
|
_tone(180, 0.04, "square", 0.10, 0.005);
|
|
_noise(0.04, 0.32, 3600, 5, 0.08); // second click, down
|
|
_tone(140, 0.05, "square", 0.12, 0.09);
|
|
}
|
|
// Copy: two-chirp "printer feed" — matches Firefox's Easy Screenshot copy
|
|
// feedback (staccato ascending pair, no residual hum).
|
|
function playCopy() {
|
|
_noise(0.02, 0.14, 3200, 8, 0);
|
|
_tone(1400, 0.05, "sine", 0.14, 0);
|
|
_noise(0.02, 0.14, 4200, 8, 0.06);
|
|
_tone(2000, 0.06, "sine", 0.14, 0.06);
|
|
}
|
|
function playSave() { _tone(700, 0.06, "sine", 0.16, 0); _tone(1100, 0.08, "sine", 0.16, 0.05); _tone(1500, 0.09, "sine", 0.16, 0.10); }
|
|
function playDiscard() { _tone(500, 0.06, "sine", 0.18, 0); _tone(280, 0.10, "sine", 0.14, 0.05); }
|
|
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();
|
|
}
|
|
for (const b of document.querySelectorAll(".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) || 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 = "text…";
|
|
el.style.color = state.color;
|
|
el.style.font = `${size}px system-ui, -apple-system, Segoe UI, Roboto, sans-serif`;
|
|
el.style.lineHeight = "1.15";
|
|
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);
|
|
// Focus after a paint so Chromium reliably picks it up (the input goes
|
|
// from `display:absolute` to laid-out; focus() called synchronously
|
|
// right after appendChild races that in some builds).
|
|
requestAnimationFrame(() => el.focus());
|
|
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 keystroke 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(); }
|
|
});
|
|
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", () => { playDiscard(); 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();
|