feat(theseus/screenshot): 0.6.3 — per-tile delete, no Select button, text tool halo, right-anchor panel controls
Four issues from the user's report on 0.6.2:
- Recent captures had a global "clear all" but no way to drop a single
screenshot. Each tile now grows a small × button (visible on hover;
drops in behind the thumbnail preview so it never obstructs the
content). Clicking the × invokes clearRecent({name}) and removes both
the ring entry and the scratch PNG on disk. Bubble-guarded so the ×
click doesn't also trigger the tile's "load into preview" handler.
- Select tool button removed — clicking it did nothing visible, so users
read it as broken. The internal "select" mode still exists as the
no-tool state; you get back to it now by clicking the same drawing
tool a second time (toggle-off) or hitting Escape. The active-drawing-
tool button flips its border when armed.
- Text tool made unmistakable: input paints with a 2 px acid border, a
glowing acid halo, dark background, and the visible ink colour on the
text itself. Focus attempt is three-layered (sync, rAF, timer) to
outrun any Chromium build that drops the mid-pointer-event focus. Non-
Enter/Escape keys get stopPropagation so a stray document listener
can't steal the focus mid-typing.
- Panel header's sound / max / close cluster kept nudging inward when
the status text was empty. The parent's `justify-content: space-
between` distributed the row unevenly. Force-anchor the cluster with
`#btn-sound { margin-left: auto }` so the three window-control icons
hug the right edge regardless of what fills the middle.
Bundled but not shipped separately — parent session signs and pushes.
This commit is contained in:
parent
744574ba96
commit
8eda0433d7
4 changed files with 76 additions and 18 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"id": "screenshot",
|
"id": "screenshot",
|
||||||
"name": "Screenshot",
|
"name": "Screenshot",
|
||||||
"version": "0.6.2",
|
"version": "0.6.3",
|
||||||
"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.",
|
"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",
|
"author": "Silent Mode",
|
||||||
"icon": "📸",
|
"icon": "📸",
|
||||||
|
|
|
||||||
|
|
@ -44,9 +44,6 @@
|
||||||
history) instead of one dot spilling to a lonely second row. -->
|
history) instead of one dot spilling to a lonely second row. -->
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="tgroup" role="group" aria-label="Tools">
|
<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)">
|
<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>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -298,8 +298,14 @@ function cancelCrop() {
|
||||||
clearOver();
|
clearOver();
|
||||||
updateCropUI();
|
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")) {
|
for (const b of document.querySelectorAll(".tool")) {
|
||||||
b.addEventListener("click", () => setTool(b.dataset.tool));
|
b.addEventListener("click", () => {
|
||||||
|
setTool(state.tool === b.dataset.tool ? "select" : b.dataset.tool);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
for (const b of document.querySelectorAll(".swatch")) {
|
for (const b of document.querySelectorAll(".swatch")) {
|
||||||
b.addEventListener("click", () => {
|
b.addEventListener("click", () => {
|
||||||
|
|
@ -494,10 +500,19 @@ function openTextInput(pt) {
|
||||||
const el = document.createElement("input");
|
const el = document.createElement("input");
|
||||||
el.type = "text";
|
el.type = "text";
|
||||||
el.className = "text-input";
|
el.className = "text-input";
|
||||||
el.placeholder = "text…";
|
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.color = state.color;
|
||||||
el.style.font = `${size}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";
|
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 rect = base.getBoundingClientRect();
|
||||||
const scale = stage._scale || 1;
|
const scale = stage._scale || 1;
|
||||||
const cssX = pt.x * scale + rect.left;
|
const cssX = pt.x * scale + rect.left;
|
||||||
|
|
@ -505,19 +520,24 @@ function openTextInput(pt) {
|
||||||
el.style.left = cssX + "px";
|
el.style.left = cssX + "px";
|
||||||
el.style.top = (cssY - size) + "px";
|
el.style.top = (cssY - size) + "px";
|
||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
// Focus after a paint so Chromium reliably picks it up (the input goes
|
// Three-way focus attempt — Chromium is racy about focusing an element
|
||||||
// from `display:absolute` to laid-out; focus() called synchronously
|
// that appeared mid-pointer-event. Synchronous focus() first (works on
|
||||||
// right after appendChild races that in some builds).
|
// most builds), then a paint tick, then a short timer as belt-and-braces.
|
||||||
|
el.focus();
|
||||||
requestAnimationFrame(() => 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 };
|
state.textInput = { x: pt.x, y: pt.y, size, el };
|
||||||
// Don't let the input's own pointerdown / mousedown / click bubble to
|
// Don't let the input's own pointerdown / mousedown / click bubble to
|
||||||
// the canvas — otherwise every keystroke click-through re-fires
|
// the canvas — otherwise every click-through re-fires openTextInput on
|
||||||
// openTextInput on the base and spawns duplicate boxes.
|
// the base and spawns duplicate boxes.
|
||||||
const swallow = (ev) => ev.stopPropagation();
|
const swallow = (ev) => ev.stopPropagation();
|
||||||
for (const t of ["pointerdown", "mousedown", "click"]) el.addEventListener(t, swallow);
|
for (const t of ["pointerdown", "mousedown", "click"]) el.addEventListener(t, swallow);
|
||||||
el.addEventListener("keydown", (ev) => {
|
el.addEventListener("keydown", (ev) => {
|
||||||
if (ev.key === "Enter") { commitTextInput(); ev.preventDefault(); ev.stopPropagation(); }
|
if (ev.key === "Enter") { commitTextInput(); ev.preventDefault(); ev.stopPropagation(); }
|
||||||
else if (ev.key === "Escape") { cancelTextInput(); 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);
|
el.addEventListener("blur", commitTextInput);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,16 @@
|
||||||
body { background: var(--bg); color: var(--ink);
|
body { background: var(--bg); color: var(--ink);
|
||||||
font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
display: flex; flex-direction: column; }
|
display: flex; flex-direction: column; }
|
||||||
header { display: flex; align-items: center; justify-content: space-between;
|
header { display: flex; align-items: center;
|
||||||
padding: 10px 14px; border-bottom: 1px solid var(--line);
|
padding: 10px 14px; border-bottom: 1px solid var(--line);
|
||||||
background: var(--panel); gap: 8px; }
|
background: var(--panel); gap: 8px; }
|
||||||
header .t { font-weight: 600; display: flex; gap: 8px; align-items: center; min-width: 0; }
|
header .t { font-weight: 600; display: flex; gap: 8px; align-items: center; min-width: 0; }
|
||||||
header .t .em { font-size: 15px; }
|
header .t .em { font-size: 15px; }
|
||||||
header .m { color: var(--dim); font-size: 11.5px; min-height: 15px; flex: 1; text-align: right; }
|
header .m { color: var(--dim); font-size: 11.5px; min-height: 15px; flex: 1; text-align: right; }
|
||||||
|
/* Force the sound / max / close cluster to hug the right edge no matter
|
||||||
|
what else fills the header. justify-content on the parent kept nudging
|
||||||
|
them mid-row when the status text was empty. */
|
||||||
|
header #btn-sound { margin-left: auto; }
|
||||||
header .max {
|
header .max {
|
||||||
border: 1px solid var(--line); border-radius: 6px; background: var(--btn);
|
border: 1px solid var(--line); border-radius: 6px; background: var(--btn);
|
||||||
color: var(--ink); cursor: pointer; padding: 4px 6px; font: inherit; line-height: 0;
|
color: var(--ink); cursor: pointer; padding: 4px 6px; font: inherit; line-height: 0;
|
||||||
|
|
@ -102,6 +106,16 @@
|
||||||
}
|
}
|
||||||
.recent .tile img { width: 100%; height: 100%; object-fit: cover; background: #fff; }
|
.recent .tile img { width: 100%; height: 100%; object-fit: cover; background: #fff; }
|
||||||
.recent .tile:hover { border-color: var(--acid); }
|
.recent .tile:hover { border-color: var(--acid); }
|
||||||
|
.recent .tile .tdel {
|
||||||
|
position: absolute; top: 2px; right: 2px;
|
||||||
|
width: 18px; height: 18px; line-height: 16px; text-align: center;
|
||||||
|
background: rgba(11,14,20,.85); color: #fff; border-radius: 50%;
|
||||||
|
font-size: 14px; font-weight: 700; cursor: pointer;
|
||||||
|
opacity: 0; transition: opacity 120ms, background 120ms, color 120ms;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.recent .tile:hover .tdel { opacity: 1; }
|
||||||
|
.recent .tile .tdel:hover { background: #ff5b5b; color: #101418; }
|
||||||
.recent .clear { background: none; border: 0; color: var(--dim); font: inherit; cursor: pointer; text-transform: none; letter-spacing: 0; }
|
.recent .clear { background: none; border: 0; color: var(--dim); font: inherit; cursor: pointer; text-transform: none; letter-spacing: 0; }
|
||||||
.recent .clear:hover { color: var(--err); }
|
.recent .clear:hover { color: var(--err); }
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -319,16 +333,27 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
recentBox.classList.add("on");
|
recentBox.classList.add("on");
|
||||||
recentStrip.innerHTML = list.map((r) =>
|
recentStrip.innerHTML = list.map((r) => {
|
||||||
`<div class="tile" data-name="${r.name.replace(/"/g,""")}" title="${r.name.replace(/"/g,""")} · ${fmt(r.bytes || 0)}"></div>`
|
const nameAttr = r.name.replace(/"/g, """);
|
||||||
).join("");
|
return `<div class="tile" data-name="${nameAttr}" title="${nameAttr} · ${fmt(r.bytes || 0)}">`
|
||||||
|
+ `<span class="tdel" data-del="${nameAttr}" title="Delete this capture">×</span>`
|
||||||
|
+ `</div>`;
|
||||||
|
}).join("");
|
||||||
for (const tile of recentStrip.querySelectorAll(".tile")) {
|
for (const tile of recentStrip.querySelectorAll(".tile")) {
|
||||||
const name = tile.dataset.name;
|
const name = tile.dataset.name;
|
||||||
try {
|
try {
|
||||||
const b = await window.silentmode.invoke("getBytes", { name });
|
const b = await window.silentmode.invoke("getBytes", { name });
|
||||||
if (b && b.dataUrl) tile.innerHTML = `<img src="${b.dataUrl}" alt="">`;
|
if (b && b.dataUrl) {
|
||||||
|
// Keep the delete button; drop it back on top of the image.
|
||||||
|
const del = tile.querySelector(".tdel");
|
||||||
|
tile.innerHTML = `<img src="${b.dataUrl}" alt="">`;
|
||||||
|
if (del) tile.appendChild(del);
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
tile.addEventListener("click", async () => {
|
tile.addEventListener("click", async (ev) => {
|
||||||
|
// The per-tile X sits inside the tile; don't count its click as
|
||||||
|
// a tile click.
|
||||||
|
if (ev.target && ev.target.classList.contains("tdel")) return;
|
||||||
try {
|
try {
|
||||||
const b = await window.silentmode.invoke("getBytes", { name });
|
const b = await window.silentmode.invoke("getBytes", { name });
|
||||||
if (!b) return;
|
if (!b) return;
|
||||||
|
|
@ -343,6 +368,22 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Per-tile delete — no confirmation strip (single-item destroy is
|
||||||
|
// small enough to be safe with just the X; the sweeping "clear all"
|
||||||
|
// still gates on the inline red confirmation).
|
||||||
|
for (const del of recentStrip.querySelectorAll(".tdel")) {
|
||||||
|
del.addEventListener("click", async (ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
const nm = del.dataset.del;
|
||||||
|
try {
|
||||||
|
await window.silentmode.invoke("clearRecent", { name: nm });
|
||||||
|
if (last && last.name === nm) { last = null; renderPreview(); }
|
||||||
|
await refreshRecent();
|
||||||
|
} catch (e) {
|
||||||
|
setStatus("Delete failed: " + (e && e.message || e), "err");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("refreshRecent failed:", e);
|
console.warn("refreshRecent failed:", e);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue