screenshot 0.6.5: line tool + resizable text box

Two annotation-editor asks:

- Line tool: same drag flow as the arrow, no arrowhead. New toolbar
  button between arrow and rect, shortcut L.
- Text box is resizable: swapped the single-line input for a textarea
  with resize:both and a drag corner. Enter still commits, Shift+Enter
  inserts a newline, blur commits. Multi-line rendering steps the
  fillText baseline by 1.15x the font size per line so the baked
  pixels match the live layout.

The textarea swallows its own pointer events so drag-resizing the
corner doesn't leak to the canvas underneath.
This commit is contained in:
Local Dev 2026-09-18 01:02:56 +02:00
parent ed323713d7
commit cd098bc9f3
5 changed files with 50 additions and 13 deletions

View file

@ -1,8 +1,8 @@
{
"id": "screenshot",
"name": "Screenshot",
"version": "0.6.4",
"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.",
"version": "0.6.5",
"description": "Capture the current tab — visible viewport, full page, or a rectangle you draw. Preview + annotate editor (crop, arrow, line, rect, ellipse, pen, resizable 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": "📸",
"main": "index.js",

View file

@ -130,6 +130,7 @@ body { background: var(--bg); color: var(--ink);
.stage #base { position: static; }
.stage #over { pointer-events: none; }
.stage[data-tool="arrow"] { cursor: crosshair; }
.stage[data-tool="line"] { cursor: crosshair; }
.stage[data-tool="rect"] { cursor: crosshair; }
.stage[data-tool="ellipse"]{ cursor: crosshair; }
.stage[data-tool="pen"] { cursor: crosshair; }
@ -149,7 +150,12 @@ body { background: var(--bg); color: var(--ink);
background: rgba(20,26,36,.92); color: var(--ink);
border: 1px solid var(--acid); padding: 2px 6px;
font: 15px/1.2 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
outline: none; border-radius: 3px; min-width: 80px;
outline: none; border-radius: 3px;
/* Textarea variant corner drag-to-resize is on (see resize:both in
JS). These floors keep the box visible even if the user resizes it
down to almost nothing. */
min-width: 80px; min-height: 24px;
overflow: auto;
}
.toast {

View file

@ -59,6 +59,9 @@
<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="line" title="Line (L)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 13L13 3"/></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>

View file

@ -392,6 +392,14 @@ function drawArrow(a, b) {
octx.stroke();
}
// Plain line — same drag flow as arrow, minus the head. Useful for
// underlines / separators / crossing-out without pointing at anything.
function drawLine(a, b) {
strokeStyle();
octx.beginPath();
octx.moveTo(a.x, a.y); octx.lineTo(b.x, b.y); octx.stroke();
}
function drawRect(a, b) {
strokeStyle();
const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y);
@ -492,6 +500,7 @@ base.addEventListener("pointermove", (ev) => {
state.drag.x = p.x; state.drag.y = p.y;
const a = { x: state.drag.x0, y: state.drag.y0 }, b = { x: p.x, y: p.y };
if (state.tool === "arrow") { clearOver(); drawArrow(a, b); }
else if (state.tool === "line") { clearOver(); drawLine(a, b); }
else if (state.tool === "rect") { clearOver(); drawRect(a, b); }
else if (state.tool === "ellipse") { clearOver(); drawEllipse(a, b); }
else if (state.tool === "crop"
@ -539,13 +548,16 @@ base.addEventListener("pointerup", (ev) => {
function openTextInput(pt) {
cancelTextInput();
const size = Math.max(16, state.width * 4);
const el = document.createElement("input");
el.type = "text";
// Textarea (not input) so the user can drag the corner to resize the
// box, and so text can wrap / span multiple lines. Enter commits;
// Shift+Enter inserts a newline.
const el = document.createElement("textarea");
el.className = "text-input";
el.placeholder = "type, then Enter";
el.placeholder = "type, then Enter (Shift+Enter for newline · drag corner to resize)";
el.autocomplete = "off";
el.setAttribute("autocorrect", "off");
el.setAttribute("spellcheck", "false");
el.rows = 1;
// Visual weight — a bright acid halo around the box + the actual
// ink colour on the text itself, so users see something clearly
// happened when they clicked.
@ -555,6 +567,9 @@ function openTextInput(pt) {
el.style.background = "rgba(11,14,20,0.94)";
el.style.border = "2px solid var(--acid, #d6ff3d)";
el.style.boxShadow = "0 0 0 3px rgba(214,255,61,.25), 0 4px 14px rgba(0,0,0,.45)";
el.style.resize = "both";
el.style.overflow = "auto";
el.style.whiteSpace = "pre";
const rect = base.getBoundingClientRect();
const scale = stage._scale || 1;
const cssX = pt.x * scale + rect.left;
@ -569,13 +584,18 @@ function openTextInput(pt) {
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.
// Don't let the box's own pointer events bubble to the canvas —
// otherwise every click-through re-fires openTextInput on the base and
// spawns duplicate boxes, and drag-to-resize on the corner handle would
// start a canvas drag on the tool underneath.
const swallow = (ev) => ev.stopPropagation();
for (const t of ["pointerdown", "mousedown", "click"]) el.addEventListener(t, swallow);
for (const t of ["pointerdown", "mousedown", "click", "pointerup", "pointermove"]) {
el.addEventListener(t, swallow);
}
el.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") { commitTextInput(); ev.preventDefault(); ev.stopPropagation(); }
if (ev.key === "Enter" && !ev.shiftKey) {
commitTextInput(); ev.preventDefault(); ev.stopPropagation();
}
else if (ev.key === "Escape") { cancelTextInput(); ev.preventDefault(); ev.stopPropagation(); }
// Every other key stays inside the input — belt against a stray
// document-level keydown handler stealing focus.
@ -594,7 +614,14 @@ function commitTextInput() {
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);
// Multi-line rendering — one fillText per line, stepping down by the
// font's line height so the visible box's layout matches the baked-in
// pixels.
const lineH = ti.size * 1.15;
const lines = value.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
bctx.fillText(lines[i], ti.x, ti.y + i * lineH);
}
pushSnapshot();
}
function cancelTextInput() {
@ -751,6 +778,7 @@ window.addEventListener("keydown", (ev) => {
if (ev.key === "Escape") { cancelCrop(); ev.preventDefault(); return; }
}
if (ev.key === "a") setTool("arrow");
else if (ev.key === "l") setTool("line");
else if (ev.key === "r") setTool("rect");
else if (ev.key === "o") setTool("ellipse");
else if (ev.key === "p") setTool("pen");

View file

@ -172,7 +172,7 @@
</div>
<div class="row">
<button class="act" id="btn-discard" disabled>Discard</button>
<button class="act primary" id="btn-edit" disabled title="Open the annotate editor — arrow, rect, ellipse, pen, text, undo, save, copy">Edit</button>
<button class="act primary" id="btn-edit" disabled title="Open the annotate editor — arrow, line, rect, ellipse, pen, resizable text, blur, undo, save, copy">Edit</button>
</div>
</div>