feat(theseus/screenshot): 0.6.1 — text tool fix, category wrap, sidebar close X

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.
This commit is contained in:
Local Dev 2026-09-09 10:55:21 +02:00
parent 35ec8a3904
commit bf6bcfade2
6 changed files with 126 additions and 59 deletions

View file

@ -1,7 +1,7 @@
{
"id": "screenshot",
"name": "Screenshot",
"version": "0.6.0",
"version": "0.6.1",
"description": "Capture the current tab — visible viewport, full page, or a rectangle you draw. Preview + annotate editor (crop, arrow, rect, ellipse, pen, text, blur/mosaic redaction, undo, copy, save) live inside the sidebar. Expand the sidebar to full window for a canvas-sized editor.",
"author": "Silent Mode",
"icon": "📸",

View file

@ -25,8 +25,15 @@ body { background: var(--bg); color: var(--ink);
user-select: none; flex-wrap: wrap; }
/* Older versions grouped the drawing tools in the middle of the bar keep
the drawing / swatch / width / undo cluster centred while the top-bar
(back / max / sound / name save / copy) stays edge-anchored. */
.toolbar { justify-content: center; }
(back / name save / copy / sound / max / close) stays edge-anchored. */
.toolbar { justify-content: center; gap: 10px; row-gap: 6px; }
/* Each visible group of tools wraps as a unit when the sidebar gets
narrow, the whole swatch row moves to the next line instead of a
single dot spilling by itself. */
.tgroup { display: inline-flex; align-items: center; gap: 4px; flex: 0 0 auto; }
/* Close-sidebar button — reads as danger without shouting. */
#close-sidebar:hover { border-color: rgba(255,91,91,.55); color: var(--danger); }
.topbar .name { color: var(--dim); font-size: 11.5px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; max-width: 200px; margin: 0 4px; }
.topbar .spacer, .toolbar .spacer { flex: 1; }

View file

@ -37,57 +37,66 @@
<button class="btn" id="toggle-max" title="Expand the sidebar to full window / restore">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 6V2h4M14 6V2h-4M2 10v4h4M14 10v4h-4"/></svg>
</button>
<button class="btn" id="close-sidebar" title="Close the sidebar">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>
</button>
</div>
<!-- Toolbar rows are grouped so that when the sidebar is too narrow to
hold everything, whole categories wrap together (tools stay with
tools, swatches with swatches, widths with widths, history with
history) instead of one dot spilling to a lonely second row. -->
<div class="toolbar">
<button class="tool active" data-tool="select" title="Select (no tool)">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M3 2l10 5-4 1-1 4z"/></svg>
</button>
<button class="tool" data-tool="crop" title="Crop (C)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 1v11h11M1 4h11v11"/></svg>
</button>
<button class="tool" data-tool="arrow" title="Arrow (A)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 13L13 3M13 3H8M13 3v5"/></svg>
</button>
<button class="tool" data-tool="rect" title="Rectangle (R)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2.5" y="3.5" width="11" height="9"/></svg>
</button>
<button class="tool" data-tool="ellipse" title="Ellipse (O)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><ellipse cx="8" cy="8" rx="5.5" ry="4"/></svg>
</button>
<button class="tool" data-tool="pen" title="Pen (P)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 14c1-3 3-6 6-8s5-3 6-3l-3 4c-2 1-4 3-6 4s-2 2-3 3z"/></svg>
</button>
<button class="tool" data-tool="text" title="Text (T)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M3 3h10M8 3v10M6 13h4"/></svg>
</button>
<button class="tool" data-tool="blur" title="Blur / mosaic redaction (B)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2.5" y="2.5" width="11" height="11"/><path d="M5 6h1M8 6h1M11 6h1M5 9h1M8 9h1M11 9h1M5 12h1M8 12h1M11 12h1"/></svg>
</button>
<div class="tgroup" role="group" aria-label="Tools">
<button class="tool active" data-tool="select" title="Select (no tool)">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M3 2l10 5-4 1-1 4z"/></svg>
</button>
<button class="tool" data-tool="crop" title="Crop (C)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 1v11h11M1 4h11v11"/></svg>
</button>
<button class="tool" data-tool="arrow" title="Arrow (A)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 13L13 3M13 3H8M13 3v5"/></svg>
</button>
<button class="tool" data-tool="rect" title="Rectangle (R)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2.5" y="3.5" width="11" height="9"/></svg>
</button>
<button class="tool" data-tool="ellipse" title="Ellipse (O)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><ellipse cx="8" cy="8" rx="5.5" ry="4"/></svg>
</button>
<button class="tool" data-tool="pen" title="Pen (P)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 14c1-3 3-6 6-8s5-3 6-3l-3 4c-2 1-4 3-6 4s-2 2-3 3z"/></svg>
</button>
<button class="tool" data-tool="text" title="Text (T)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M3 3h10M8 3v10M6 13h4"/></svg>
</button>
<button class="tool" data-tool="blur" title="Blur / mosaic redaction (B)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2.5" y="2.5" width="11" height="11"/><path d="M5 6h1M8 6h1M11 6h1M5 9h1M8 9h1M11 9h1M5 12h1M8 12h1M11 12h1"/></svg>
</button>
</div>
<span class="sep"></span>
<div class="tgroup" role="group" aria-label="Colours">
<button class="swatch active" data-color="#ff3b30" style="background:#ff3b30" title="Red"></button>
<button class="swatch" data-color="#d6ff3d" style="background:#d6ff3d" title="Acid"></button>
<button class="swatch" data-color="#ffcc00" style="background:#ffcc00" title="Yellow"></button>
<button class="swatch" data-color="#0aa8ff" style="background:#0aa8ff" title="Blue"></button>
<button class="swatch" data-color="#ffffff" style="background:#ffffff" title="White"></button>
<button class="swatch" data-color="#000000" style="background:#000000" title="Black"></button>
</div>
<button class="swatch active" data-color="#ff3b30" style="background:#ff3b30" title="Red"></button>
<button class="swatch" data-color="#d6ff3d" style="background:#d6ff3d" title="Acid"></button>
<button class="swatch" data-color="#ffcc00" style="background:#ffcc00" title="Yellow"></button>
<button class="swatch" data-color="#0aa8ff" style="background:#0aa8ff" title="Blue"></button>
<button class="swatch" data-color="#ffffff" style="background:#ffffff" title="White"></button>
<button class="swatch" data-color="#000000" style="background:#000000" title="Black"></button>
<div class="tgroup" role="group" aria-label="Widths">
<button class="width" data-width="3" title="Thin"><span class="dot" style="width:4px;height:4px"></span></button>
<button class="width active" data-width="5" title="Medium"><span class="dot" style="width:7px;height:7px"></span></button>
<button class="width" data-width="9" title="Thick"><span class="dot" style="width:11px;height:11px"></span></button>
</div>
<span class="sep"></span>
<button class="width" data-width="3" title="Thin"><span class="dot" style="width:4px;height:4px"></span></button>
<button class="width active" data-width="5" title="Medium"><span class="dot" style="width:7px;height:7px"></span></button>
<button class="width" data-width="9" title="Thick"><span class="dot" style="width:11px;height:11px"></span></button>
<span class="sep"></span>
<button class="btn" id="undo" title="Undo (Ctrl+Z)" disabled>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M3 8c0-3 2-5 5-5s5 2 5 5-2 5-5 5"/><path d="M6 5L3 8l3 3"/></svg>
</button>
<button class="btn" id="redo" title="Redo (Ctrl+Shift+Z)" disabled>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M13 8c0-3-2-5-5-5S3 5 3 8s2 5 5 5"/><path d="M10 5l3 3-3 3"/></svg>
</button>
<div class="tgroup" role="group" aria-label="History">
<button class="btn" id="undo" title="Undo (Ctrl+Z)" disabled>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M3 8c0-3 2-5 5-5s5 2 5 5-2 5-5 5"/><path d="M6 5L3 8l3 3"/></svg>
</button>
<button class="btn" id="redo" title="Redo (Ctrl+Shift+Z)" disabled>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M13 8c0-3-2-5-5-5S3 5 3 8s2 5 5 5"/><path d="M10 5l3 3-3 3"/></svg>
</button>
</div>
</div>
<div class="board" id="board">

View file

@ -422,7 +422,11 @@ function commitOver() {
over.style.pointerEvents = "none"; // draw layer never blocks input; base receives
base.addEventListener("pointerdown", (ev) => {
if (state.tool === "select") return;
if (state.tool === "text") { openTextInput(pointToCanvas(ev)); 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]; }
@ -475,39 +479,58 @@ base.addEventListener("pointerup", (ev) => {
});
// -- 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 = `${Math.max(14, state.width * 4)}px system-ui, -apple-system, Segoe UI, Roboto, sans-serif`;
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 - 14) + "px";
el.style.top = (cssY - size) + "px";
document.body.appendChild(el);
el.focus();
state.textInput = { x: pt.x, y: pt.y, 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(); }
else if (ev.key === "Escape") { cancelTextInput(); ev.preventDefault(); }
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.trim();
const value = ti.el.value;
const trimmed = value.trim();
ti.el.remove();
state.textInput = null;
if (!value) return;
const size = Math.max(14, state.width * 4);
if (!trimmed) return;
bctx.fillStyle = state.color;
bctx.font = `${size}px system-ui, -apple-system, Segoe UI, Roboto, sans-serif`;
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();
@ -575,6 +598,16 @@ 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");

View file

@ -30,6 +30,7 @@
}
header .max:hover { background: var(--btn-h); }
header .max svg { width: 14px; height: 14px; display: block; }
header .max.close:hover { border-color: rgba(255,91,91,.55); color: var(--err); }
main { flex: 1; overflow-y: auto; padding: 12px 14px; display: flex; flex-direction: column; gap: 12px; }
.modes { display: flex; gap: 6px; }
button.mode {
@ -115,6 +116,11 @@
<path d="M2 6V2h4M14 6V2h-4M2 10v4h4M14 10v4h-4"/>
</svg>
</button>
<button class="max close" id="btn-close" title="Close the sidebar" aria-label="Close sidebar">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
<path d="M4 4l8 8M12 4l-8 8"/>
</svg>
</button>
</header>
<main>
<div class="modes">
@ -431,6 +437,14 @@
btnCopy.addEventListener("click", doCopy);
btnSave.addEventListener("click", doSave);
btnMax.addEventListener("click", toggleMax);
document.getElementById("btn-close").addEventListener("click", async () => {
// Explicit close from the sidebar itself — same IPC the toolbar dock
// icon toggles. No need to un-maximize first; setSidebar(false) on the
// main side flips visibility and the maximize state is remembered so
// the next open lands where it left off.
try { await window.silentmode.sidebar.close(); }
catch (e) { setStatus("Close failed: " + (e && e.message || e), "err"); }
});
// Inline confirmation for "clear all" — the native confirm() looks like an
// OS-modal from the last decade AND opens over the tab area (out of the

View file

@ -27,6 +27,10 @@ contextBridge.exposeInMainWorld("silentmode", {
toggleMax:() => ipcRenderer.invoke("sidebar-toggle-max"),
isMax: () => ipcRenderer.invoke("sidebar-is-max"),
onMaxChange: (cb) => ipcRenderer.on("sidebar-max-change", (_e, max) => cb(!!max)),
// Close the sidebar entirely. Same IPC the toolbar dock icon toggles
// — the panel/editor can offer an explicit X button so users don't
// have to hunt for the dock icon just to hide the panel.
close: () => ipcRenderer.invoke("sidebar-close"),
},
});