feat(theseus/screenshot): 0.5.0 — sidebar-first editor, direct save/copy, sounds
Two problems the old editor kept hitting:
- __pending drain race: opening the editor a second time (refresh, back-and-
forth navigation) found the storage entry already consumed and bailed to
a blank canvas silently.
- Cross-origin img loading: editor.html at file:///…/addons/screenshot/
loading a scratch PNG at file:///…/addons-data/ counts as cross-origin
under Chromium's file-URL policy; setting crossOrigin="anonymous" made
the load fail outright.
Rebuilt editor v2:
- Load path is idempotent: silentmode.invoke("getBytes", {name}) → addon
reads the scratch file and returns a data URL. No __pending drain, no
cross-origin trickery — data: URLs are same-origin and never taint the
canvas, so getImageData / toBlob keep working.
- Two-canvas model (#base + #over, over is pointer-events:none) so live
previews don't cost a full re-composite per mousemove.
- Tools: cursor, arrow, rect, ellipse, pen, text. 6 swatches, 3 widths,
undo / redo (25-deep). Copy + Save at the top bar. Back and Maximize
buttons in the same top bar so navigation controls stay reachable when
the toolbar wraps at narrow widths.
- Keyboard: A/R/O/P/T select tool, Esc = cursor, Ctrl+Z/Shift+Z undo/redo,
Ctrl+S save, Ctrl+C copy.
- Toast surface for save/copy/error feedback.
Sidebar panel gains a direct raw-save path so the user can copy or save the
capture without entering the editor:
- Two-row actions: [Copy] [Save] on top, [Discard] [Edit] below.
- Copy uses navigator.clipboard.write(ClipboardItem); Save uses
<a download> with a Blob URL — same path Chromium's will-download
tracker already handles, so the file lands in Downloads and the chip
updates like any other save.
Inline "clear all" confirmation replaces the native confirm() — the old
system-modal opened over the tab area (out of the sidebar's visual
context) and looked like Windows 95. Now a compact red strip appears
under the Recent header with Cancel / Delete buttons.
Sounds + a sound-on/off toggle in both surfaces:
- Web Audio oscillator-synthesized (no .wav shipped): shutter click on
capture, two-tone bloop on copy, descending pair on discard/back,
ascending triad on save.
- Preference stored in silentmode.storage under "soundOn" (default on),
shared between the panel and the editor.
Simplifications:
- Dropped the addon's "arm" onMessage handler (superseded by getBytes).
- Manifest capabilities: sidebar-panel + capture-tab (no open-tab,
no toolbar-menu).
Bundled but not shipped — parent session handles the OTA sign + push.
This commit is contained in:
parent
a65dc0a153
commit
81d276f655
6 changed files with 733 additions and 768 deletions
|
|
@ -1,8 +1,8 @@
|
||||||
{
|
{
|
||||||
"id": "screenshot",
|
"id": "screenshot",
|
||||||
"name": "Screenshot",
|
"name": "Screenshot",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"description": "Capture the current tab — visible viewport, full page, or a rectangle you draw. Preview + full editor (crop, annotate, redact, 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 (arrow, rect, ellipse, pen, text, 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": "📸",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
/* Screenshot editor — full-tab page. Matches the Theseus dark aesthetic
|
/* Screenshot editor v2 — sidebar-embedded. Compact but not cramped; scales
|
||||||
from the sidebar. Light-mode swap is symmetric. */
|
the canvas to fit whatever width the sidebar has, up to full window when
|
||||||
|
the user hits the maximize button in the topbar. */
|
||||||
:root { color-scheme: light dark;
|
:root { color-scheme: light dark;
|
||||||
--bg:#0e131c; --panel:#141a24; --panel2:#191f2b; --line:rgba(255,255,255,.09);
|
--bg:#0e131c; --panel:#141a24; --panel2:#191f2b; --line:rgba(255,255,255,.09);
|
||||||
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d;
|
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d;
|
||||||
--danger:#ff5b5b; --board:#0a0d13; }
|
--danger:#ff5b5b; --board:#0a0d13; }
|
||||||
@media (prefers-color-scheme: light) {
|
@media (prefers-color-scheme: light) {
|
||||||
:root { --bg:#f8faff; --panel:#ffffff; --panel2:#eff3fb; --line:rgba(0,0,0,.10);
|
:root { --bg:#f8faff; --panel:#ffffff; --panel2:#eff3fb; --line:rgba(0,0,0,.10);
|
||||||
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; --acid: #0AC18E;
|
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; --acid:#0AC18E;
|
||||||
--board:#dde3ee; }
|
--board:#dde3ee; }
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
|
@ -15,76 +16,97 @@ 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; overflow: hidden; }
|
display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
|
||||||
/* Toolbar ---------------------------------------------------------------- */
|
.topbar, .toolbar { display: flex; align-items: center; gap: 4px; padding: 6px 8px;
|
||||||
.toolbar { display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
|
border-bottom: 1px solid var(--line); background: var(--panel);
|
||||||
padding: 8px 10px; border-bottom: 1px solid var(--line);
|
user-select: none; flex-wrap: wrap; }
|
||||||
background: var(--panel); user-select: none;
|
.topbar .name { color: var(--dim); font-size: 11.5px; overflow: hidden;
|
||||||
font: 12px ui-monospace, "Cascadia Code", Consolas, monospace; }
|
text-overflow: ellipsis; white-space: nowrap; max-width: 200px; margin: 0 4px; }
|
||||||
.toolbar .name { color: var(--dim); font-size: 11.5px; margin-right: 4px; overflow: hidden;
|
.topbar .spacer, .toolbar .spacer { flex: 1; }
|
||||||
text-overflow: ellipsis; white-space: nowrap; max-width: 240px; }
|
|
||||||
.toolbar .sep { width: 1px; height: 22px; background: var(--line); margin: 0 4px; }
|
.btn, .tool {
|
||||||
.toolbar .spacer { flex: 1; }
|
border: 1px solid var(--line); background: var(--panel2); color: var(--ink);
|
||||||
.tool { border: 1px solid var(--line); background: var(--panel2); color: var(--ink);
|
|
||||||
width: 30px; height: 30px; border-radius: 6px; cursor: pointer;
|
width: 30px; height: 30px; border-radius: 6px; cursor: pointer;
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
font: inherit; padding: 0; transition: background .1s, border-color .1s; }
|
padding: 0; transition: background 100ms, border-color 100ms;
|
||||||
.tool:hover { border-color: rgb(from var(--acid) r g b / .5); }
|
font: inherit; line-height: 0;
|
||||||
.tool.active { background: rgb(from var(--acid) r g b / .15); border-color: rgb(from var(--acid) r g b / .7); color: var(--acid); }
|
}
|
||||||
.tool:disabled { opacity: .4; cursor: default; }
|
.btn:hover, .tool:hover { border-color: rgb(from var(--acid) r g b / .5); }
|
||||||
.tool svg { width: 16px; height: 16px; display: block; }
|
.btn:disabled, .tool:disabled { opacity: .4; cursor: default; }
|
||||||
.tool.wide { width: auto; padding: 0 10px; gap: 6px; }
|
.btn:disabled:hover, .tool:disabled:hover { border-color: var(--line); }
|
||||||
.tool.danger { color: var(--danger); }
|
.tool.active {
|
||||||
.tool.danger:hover { border-color: rgba(255,91,91,.5); }
|
background: rgb(from var(--acid) r g b / .15);
|
||||||
|
border-color: rgb(from var(--acid) r g b / .7);
|
||||||
|
color: var(--acid);
|
||||||
|
}
|
||||||
|
.btn svg, .tool svg { width: 16px; height: 16px; display: block; }
|
||||||
|
.btn.wide {
|
||||||
|
width: auto; padding: 0 10px; gap: 6px; line-height: 1;
|
||||||
|
}
|
||||||
|
.btn.wide span { font-size: 12px; }
|
||||||
|
.btn.primary {
|
||||||
|
background: var(--acid); color: #101418; border-color: transparent; font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn.primary:hover { filter: brightness(1.06); border-color: transparent; }
|
||||||
|
|
||||||
.swatch { width: 22px; height: 22px; border-radius: 50%; padding: 0;
|
.sep { width: 1px; height: 20px; background: var(--line); margin: 0 3px; }
|
||||||
border: 2px solid var(--line); cursor: pointer; }
|
|
||||||
.swatch.active { border-color: var(--acid); outline: 1px solid rgb(from var(--acid) r g b / .35); outline-offset: 1px; }
|
|
||||||
|
|
||||||
.width { border: 1px solid var(--line); background: var(--panel2); color: var(--ink);
|
.swatch {
|
||||||
|
width: 22px; height: 22px; border-radius: 50%; padding: 0;
|
||||||
|
border: 2px solid var(--line); cursor: pointer;
|
||||||
|
}
|
||||||
|
.swatch.active {
|
||||||
|
border-color: var(--acid);
|
||||||
|
outline: 1px solid rgb(from var(--acid) r g b / .35); outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.width {
|
||||||
|
border: 1px solid var(--line); background: var(--panel2); color: var(--ink);
|
||||||
width: 30px; height: 30px; border-radius: 6px; cursor: pointer;
|
width: 30px; height: 30px; border-radius: 6px; cursor: pointer;
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
display: inline-flex; align-items: center; justify-content: center; padding: 0;
|
||||||
padding: 0; }
|
}
|
||||||
.width.active { border-color: rgb(from var(--acid) r g b / .7); background: rgb(from var(--acid) r g b / .15); }
|
.width.active {
|
||||||
|
border-color: rgb(from var(--acid) r g b / .7);
|
||||||
|
background: rgb(from var(--acid) r g b / .15);
|
||||||
|
}
|
||||||
.width .dot { border-radius: 50%; background: currentColor; color: var(--ink); }
|
.width .dot { border-radius: 50%; background: currentColor; color: var(--ink); }
|
||||||
.width.active .dot { color: var(--acid); }
|
.width.active .dot { color: var(--acid); }
|
||||||
|
|
||||||
|
/* Canvas board -------------------------------------------------------- */
|
||||||
|
.board {
|
||||||
|
flex: 1; overflow: auto; background: var(--board);
|
||||||
|
display: flex; align-items: flex-start; justify-content: center;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.empty { color: var(--dim); font-size: 13px; padding: 40px 20px; text-align: center;
|
||||||
|
display: flex; align-items: center; justify-content: center; height: 100%; }
|
||||||
|
.empty.err { color: var(--danger); }
|
||||||
|
|
||||||
|
.stage { position: relative; transform-origin: top left;
|
||||||
|
box-shadow: 0 2px 12px rgba(0,0,0,.35); background: #fff; }
|
||||||
|
.stage canvas { display: block; position: absolute; left: 0; top: 0; }
|
||||||
|
.stage #base { position: static; }
|
||||||
|
.stage #over { pointer-events: none; }
|
||||||
|
.stage[data-tool="arrow"] { cursor: crosshair; }
|
||||||
|
.stage[data-tool="rect"] { cursor: crosshair; }
|
||||||
|
.stage[data-tool="ellipse"]{ cursor: crosshair; }
|
||||||
|
.stage[data-tool="pen"] { cursor: crosshair; }
|
||||||
|
.stage[data-tool="text"] { cursor: text; }
|
||||||
|
|
||||||
.text-input {
|
.text-input {
|
||||||
position: absolute; z-index: 10;
|
position: absolute; z-index: 10;
|
||||||
background: rgba(20,26,36,.92); color: var(--ink); border: 1px solid var(--acid);
|
background: rgba(20,26,36,.92); color: var(--ink);
|
||||||
padding: 2px 6px; font: 15px/1.2 system-ui, sans-serif; outline: none;
|
border: 1px solid var(--acid); padding: 2px 6px;
|
||||||
border-radius: 3px; min-width: 80px;
|
font: 15px/1.2 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
outline: none; border-radius: 3px; min-width: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Canvas board ----------------------------------------------------------- */
|
.toast {
|
||||||
.board { flex: 1; overflow: auto; background: var(--board);
|
position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%) translateY(20px);
|
||||||
display: flex; align-items: flex-start; justify-content: center;
|
padding: 8px 14px; border-radius: 6px; background: rgba(11,14,20,.94);
|
||||||
padding: 20px; }
|
color: var(--ink); font-size: 12.5px; border: 1px solid var(--line);
|
||||||
.stage { position: relative; box-shadow: 0 0 0 1px var(--line), 0 8px 30px rgba(0,0,0,.35); }
|
opacity: 0; pointer-events: none; transition: opacity 180ms, transform 180ms;
|
||||||
.stage canvas { display: block; background: #fff; }
|
z-index: 100; max-width: 80vw; text-align: center;
|
||||||
.stage canvas.draw { position: absolute; left: 0; top: 0; }
|
}
|
||||||
.stage canvas.overlay { position: absolute; left: 0; top: 0; pointer-events: none; }
|
.toast.on { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||||
|
.toast.err { border-color: rgba(255,91,91,.5); color: var(--danger); }
|
||||||
/* When a drawing tool is active, put the drawing canvas above overlay */
|
</style>
|
||||||
.stage[data-tool="select"] canvas.draw { cursor: default; }
|
|
||||||
.stage[data-tool="crop"] canvas.draw { cursor: crosshair; }
|
|
||||||
.stage[data-tool="rect"] canvas.draw,
|
|
||||||
.stage[data-tool="ellipse"] canvas.draw,
|
|
||||||
.stage[data-tool="arrow"] canvas.draw,
|
|
||||||
.stage[data-tool="blur"] canvas.draw { cursor: crosshair; }
|
|
||||||
.stage[data-tool="pen"] canvas.draw { cursor: crosshair; }
|
|
||||||
.stage[data-tool="text"] canvas.draw { cursor: text; }
|
|
||||||
|
|
||||||
.hint { position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%);
|
|
||||||
background: rgba(11,14,20,.9); border: 1px solid var(--line);
|
|
||||||
color: var(--ink); padding: 6px 10px; border-radius: 6px;
|
|
||||||
font-size: 12px; pointer-events: none; opacity: 0; transition: opacity .15s; }
|
|
||||||
.hint.on { opacity: 1; }
|
|
||||||
|
|
||||||
.toast { position: fixed; right: 16px; bottom: 16px;
|
|
||||||
background: rgb(from var(--acid) r g b / .15); color: var(--acid);
|
|
||||||
border: 1px solid rgb(from var(--acid) r g b / .5);
|
|
||||||
padding: 8px 12px; border-radius: 6px; font-size: 12.5px;
|
|
||||||
opacity: 0; transform: translateY(6px);
|
|
||||||
transition: opacity .2s, transform .2s; pointer-events: none; }
|
|
||||||
.toast.on { opacity: 1; transform: translateY(0); }
|
|
||||||
.toast.err { color: #ffb0b0; background: rgba(255,60,60,.15); border-color: rgba(255,60,60,.5); }
|
|
||||||
|
|
|
||||||
|
|
@ -6,117 +6,82 @@
|
||||||
<link rel="stylesheet" href="editor.css">
|
<link rel="stylesheet" href="editor.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="toolbar" id="toolbar">
|
<div class="topbar">
|
||||||
<button class="tool" id="back" title="Back to sidebar panel">
|
<button class="btn" id="back" title="Back to sidebar panel">
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M10 3l-5 5 5 5"/></svg>
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M10 3L5 8l5 5"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="tool" id="toggle-max" title="Expand the sidebar to full window / restore">
|
<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>
|
<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>
|
||||||
|
<button class="btn" id="toggle-sound" title="Toggle sound"></button>
|
||||||
<span class="name" id="name">screenshot</span>
|
<span class="name" id="name">screenshot</span>
|
||||||
<span class="sep"></span>
|
<span class="spacer"></span>
|
||||||
|
<button class="btn wide" id="copy" title="Copy PNG to clipboard (Ctrl+C)">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="4" y="4" width="9" height="10"/><path d="M3 12V3h9"/></svg>
|
||||||
|
<span>Copy</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn wide primary" id="save" title="Save PNG (Ctrl+S)">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v9M4 7l4 4 4-4M2 14h12"/></svg>
|
||||||
|
<span>Save</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tool selector. Each button carries data-tool; buttons for the
|
<div class="toolbar">
|
||||||
drawing shapes and crop go here. -->
|
<button class="tool active" data-tool="select" title="Select (no tool)">
|
||||||
<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>
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 2l10 5-4 1-1 4z"/></svg>
|
|
||||||
</button>
|
</button>
|
||||||
<button class="tool" data-tool="crop" title="Crop">
|
<button class="tool" data-tool="arrow" title="Arrow (A)">
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 1v11h11M1 4h11v11"/></svg>
|
<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>
|
||||||
<button class="tool" data-tool="arrow" title="Arrow">
|
<button class="tool" data-tool="rect" title="Rectangle (R)">
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M3 13L13 3M13 3H8M13 3v5"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="tool" data-tool="rect" title="Rectangle">
|
|
||||||
<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>
|
<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>
|
||||||
<button class="tool" data-tool="ellipse" title="Ellipse">
|
<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>
|
<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>
|
||||||
<button class="tool" data-tool="pen" title="Pen (freehand)">
|
<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"><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>
|
<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>
|
||||||
<button class="tool" data-tool="text" title="Text label">
|
<button class="tool" data-tool="text" title="Text (T)">
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3h10M8 3v10M6 13h4"/></svg>
|
<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">
|
|
||||||
<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>
|
</button>
|
||||||
|
|
||||||
<span class="sep"></span>
|
<span class="sep"></span>
|
||||||
|
|
||||||
<!-- Palette -->
|
<button class="swatch active" data-color="#ff3b30" style="background:#ff3b30" title="Red"></button>
|
||||||
<button class="swatch active" data-color="var(--acid)" style="background:var(--acid)" title="Acid"></button>
|
<button class="swatch" data-color="#d6ff3d" style="background:#d6ff3d" title="Acid"></button>
|
||||||
<button class="swatch" data-color="#ff3b30" style="background:#ff3b30" title="Red"></button>
|
|
||||||
<button class="swatch" data-color="#ff9500" style="background:#ff9500" title="Orange"></button>
|
|
||||||
<button class="swatch" data-color="#ffcc00" style="background:#ffcc00" title="Yellow"></button>
|
<button class="swatch" data-color="#ffcc00" style="background:#ffcc00" title="Yellow"></button>
|
||||||
<button class="swatch" data-color="#0a84ff" style="background:#0a84ff" title="Blue"></button>
|
<button class="swatch" data-color="#0aa8ff" style="background:#0aa8ff" title="Blue"></button>
|
||||||
<button class="swatch" data-color="#bf5af2" style="background:#bf5af2" title="Purple"></button>
|
|
||||||
<button class="swatch" data-color="#ffffff" style="background:#ffffff" title="White"></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>
|
<button class="swatch" data-color="#000000" style="background:#000000" title="Black"></button>
|
||||||
|
|
||||||
<span class="sep"></span>
|
<span class="sep"></span>
|
||||||
|
|
||||||
<!-- Stroke width -->
|
<button class="width" data-width="3" title="Thin"><span class="dot" style="width:4px;height:4px"></span></button>
|
||||||
<button class="width" data-width="2" title="Thin">
|
<button class="width active" data-width="5" title="Medium"><span class="dot" style="width:7px;height:7px"></span></button>
|
||||||
<span class="dot" style="width:4px;height:4px"></span>
|
<button class="width" data-width="9" title="Thick"><span class="dot" style="width:11px;height:11px"></span></button>
|
||||||
</button>
|
|
||||||
<button class="width active" data-width="4" title="Medium">
|
|
||||||
<span class="dot" style="width:7px;height:7px"></span>
|
|
||||||
</button>
|
|
||||||
<button class="width" data-width="8" title="Thick">
|
|
||||||
<span class="dot" style="width:10px;height:10px"></span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<span class="sep"></span>
|
<span class="sep"></span>
|
||||||
|
|
||||||
<button class="tool" id="undo" title="Undo (Ctrl+Z)" disabled>
|
<button class="btn" id="undo" title="Undo (Ctrl+Z)" disabled>
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" 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>
|
<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>
|
||||||
<button class="tool" id="redo" title="Redo (Ctrl+Shift+Z)" disabled>
|
<button class="btn" id="redo" title="Redo (Ctrl+Shift+Z)" disabled>
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" 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>
|
<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>
|
|
||||||
|
|
||||||
<span class="spacer"></span>
|
|
||||||
|
|
||||||
<button class="tool wide" id="apply-crop" title="Apply the current crop rectangle" hidden>
|
|
||||||
<span>Apply crop</span>
|
|
||||||
</button>
|
|
||||||
<button class="tool wide" id="cancel-crop" title="Cancel crop" hidden>
|
|
||||||
<span>Cancel</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="tool wide" id="copy" title="Copy PNG to clipboard">
|
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="4" y="4" width="9" height="10"/><path d="M3 12V3h9"/></svg>
|
|
||||||
<span>Copy</span>
|
|
||||||
</button>
|
|
||||||
<button class="tool wide" id="save" title="Save PNG">
|
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v9M4 7l4 4 4-4M2 14h12"/></svg>
|
|
||||||
<span>Save</span>
|
|
||||||
</button>
|
|
||||||
<button class="tool wide" id="discard" title="Discard and close this tab (Esc)">
|
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>
|
|
||||||
<span>Discard</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="board" id="board">
|
<div class="board" id="board">
|
||||||
<div class="stage" id="stage" data-tool="select">
|
<div class="empty" id="empty">
|
||||||
<!-- committed pixels (the "real" image after every applied edit) -->
|
<div>Loading capture…</div>
|
||||||
<canvas id="committed"></canvas>
|
</div>
|
||||||
<!-- live preview during a drag (arrow / rect / etc.) -->
|
<div class="stage" id="stage" hidden>
|
||||||
<canvas id="draw" class="draw"></canvas>
|
<canvas id="base"></canvas>
|
||||||
<!-- crop / blur selection rectangle chrome -->
|
<canvas id="over"></canvas>
|
||||||
<canvas id="overlay" class="overlay"></canvas>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hint" id="hint"></div>
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
<a id="download-link" style="display:none"></a>
|
<a id="download-link" style="display:none"></a>
|
||||||
|
|
||||||
<script src="editor.js"></script>
|
<script src="editor.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -1,218 +1,218 @@
|
||||||
// Screenshot editor. Three stacked canvases:
|
// Screenshot editor v2. Lives inside the sidebar view; no separate tab.
|
||||||
// #committed — pristine bitmap after every applied edit
|
|
||||||
// #draw — receives pointer events; hosts the live preview during a drag
|
|
||||||
// #overlay — the crop/blur selection chrome (dashed rect, dim mask)
|
|
||||||
//
|
//
|
||||||
// Undo/redo is snapshot-based for correctness over cleverness: each committed
|
// Load path (idempotent — this is the key change from v1's __pending drain):
|
||||||
// edit pushes an ImageData onto an undo stack. Redo stack is cleared as soon
|
// editor.html?name=<scratchfile.png>
|
||||||
// as a new edit lands. Memory footprint is width * height * 4 * (stack depth);
|
// → silentmode.invoke("getBytes", {name})
|
||||||
// for a 1920x1080 image at depth 20 that's ~170 MB, so we cap the stack.
|
// → addon reads scratch/<name>.png from disk and returns
|
||||||
|
// { name, dataUrl:"data:image/png;base64,…", bytes, at, mode }
|
||||||
const UNDO_MAX = 25;
|
// → 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 $ = (id) => document.getElementById(id);
|
||||||
const committed = $("committed");
|
const UNDO_MAX = 25;
|
||||||
const draw = $("draw");
|
|
||||||
const overlay = $("overlay");
|
// ---- audio -----------------------------------------------------------
|
||||||
|
// Kept in sync with the sidebar panel: preference stored under
|
||||||
|
// silentmode.storage as "soundOn" (default true), synthesized on the fly
|
||||||
|
// so we don't ship any .wav in the tarball.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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 playCopy() { _tone(900, 0.06, "sine", 0.15, 0); _tone(1400, 0.08, "sine", 0.15, 0.05); }
|
||||||
|
function playDiscard() { _tone(500, 0.06, "sine", 0.18, 0); _tone(280, 0.10, "sine", 0.14, 0.05); }
|
||||||
|
function playShutter() { _tone(1500, 0.05, "square", 0.20, 0); _tone(600, 0.06, "square", 0.14, 0.03); }
|
||||||
|
|
||||||
|
const base = $("base");
|
||||||
|
const over = $("over");
|
||||||
const stage = $("stage");
|
const stage = $("stage");
|
||||||
const board = $("board");
|
const board = $("board");
|
||||||
|
const empty = $("empty");
|
||||||
const nameEl = $("name");
|
const nameEl = $("name");
|
||||||
const undoBtn = $("undo");
|
const undoBtn = $("undo");
|
||||||
const redoBtn = $("redo");
|
const redoBtn = $("redo");
|
||||||
const applyCropBtn = $("apply-crop");
|
|
||||||
const cancelCropBtn = $("cancel-crop");
|
|
||||||
const hintEl = $("hint");
|
|
||||||
const toastEl = $("toast");
|
const toastEl = $("toast");
|
||||||
|
const dl = $("download-link");
|
||||||
|
|
||||||
const cctx = committed.getContext("2d");
|
const bctx = base.getContext("2d");
|
||||||
const dctx = draw.getContext("2d");
|
const octx = over.getContext("2d");
|
||||||
const octx = overlay.getContext("2d");
|
|
||||||
|
|
||||||
// URL params tell us what to load and (optionally) which tool to preselect.
|
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const srcUrl = params.get("src") || "";
|
const NAME = params.get("name") || "";
|
||||||
const baseName = params.get("name") || "screenshot.png";
|
|
||||||
const initialTool = params.get("tool") || "";
|
|
||||||
nameEl.textContent = baseName;
|
|
||||||
document.title = baseName + " — editor";
|
|
||||||
|
|
||||||
let state = {
|
let state = {
|
||||||
tool: "select",
|
tool: "select",
|
||||||
color: "#d6ff3d",
|
color: "#ff3b30",
|
||||||
width: 4,
|
width: 5,
|
||||||
dragging: false,
|
drag: null, // {x0,y0,x,y}
|
||||||
start: null, // {x,y} in canvas coords (not CSS pixels)
|
pen: null, // [{x,y}, …]
|
||||||
end: null,
|
textInput: null, // {x, y, el}
|
||||||
path: null, // pen points
|
|
||||||
cropRect: null, // {x,y,w,h} in canvas coords
|
|
||||||
textInput: null, // {x,y, el}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let undo = []; // ImageData
|
let undo = [];
|
||||||
let redo = [];
|
let redo = [];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// -- utilities --------------------------------------------------------------
|
||||||
// Loading
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function toast(msg, err) {
|
function toast(msg, err) {
|
||||||
toastEl.textContent = msg;
|
toastEl.textContent = msg;
|
||||||
toastEl.classList.toggle("err", !!err);
|
toastEl.classList.toggle("err", !!err);
|
||||||
toastEl.classList.add("on");
|
toastEl.classList.add("on");
|
||||||
clearTimeout(toast._t);
|
clearTimeout(toast._t);
|
||||||
toast._t = setTimeout(() => toastEl.classList.remove("on"), 1600);
|
toast._t = setTimeout(() => toastEl.classList.remove("on"), 1800);
|
||||||
}
|
}
|
||||||
|
|
||||||
function showHint(msg) {
|
function updateUndoRedo() {
|
||||||
hintEl.textContent = msg || "";
|
undoBtn.disabled = undo.length <= 1; // baseline snapshot always at index 0
|
||||||
hintEl.classList.toggle("on", !!msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateUndoButtons() {
|
|
||||||
undoBtn.disabled = undo.length <= 1; // one entry = the base image
|
|
||||||
redoBtn.disabled = redo.length === 0;
|
redoBtn.disabled = redo.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pushSnapshot() {
|
function pushSnapshot() {
|
||||||
try {
|
try {
|
||||||
const snap = cctx.getImageData(0, 0, committed.width, committed.height);
|
const snap = bctx.getImageData(0, 0, base.width, base.height);
|
||||||
undo.push(snap);
|
undo.push(snap);
|
||||||
if (undo.length > UNDO_MAX) undo.splice(0, undo.length - UNDO_MAX);
|
if (undo.length > UNDO_MAX) undo.shift();
|
||||||
redo.length = 0;
|
redo = [];
|
||||||
updateUndoButtons();
|
updateUndoRedo();
|
||||||
} catch (e) { console.warn("snapshot failed:", e); }
|
} catch (e) {
|
||||||
|
console.warn("snapshot failed:", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreSnapshot(snap) {
|
function restoreSnapshot(snap) {
|
||||||
if (!snap) return;
|
if (!snap) return;
|
||||||
// Resize canvases to match the snapshot (crop is destructive to size).
|
if (base.width !== snap.width || base.height !== snap.height) {
|
||||||
if (committed.width !== snap.width || committed.height !== snap.height) {
|
|
||||||
sizeCanvases(snap.width, snap.height);
|
sizeCanvases(snap.width, snap.height);
|
||||||
}
|
}
|
||||||
cctx.putImageData(snap, 0, 0);
|
bctx.putImageData(snap, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sizeCanvases(w, h) {
|
function sizeCanvases(w, h) {
|
||||||
for (const c of [committed, draw, overlay]) {
|
for (const c of [base, over]) {
|
||||||
c.width = w;
|
c.width = w;
|
||||||
c.height = h;
|
c.height = h;
|
||||||
// Match CSS size so 1 canvas px = 1 CSS px unless the board scales it.
|
|
||||||
c.style.width = w + "px";
|
c.style.width = w + "px";
|
||||||
c.style.height = h + "px";
|
c.style.height = h + "px";
|
||||||
}
|
}
|
||||||
|
stage.style.width = w + "px";
|
||||||
|
stage.style.height = h + "px";
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadImage(url) {
|
// 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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.crossOrigin = "anonymous";
|
|
||||||
img.onload = () => resolve(img);
|
img.onload = () => resolve(img);
|
||||||
img.onerror = (e) => reject(new Error("failed to load image"));
|
img.onerror = () => reject(new Error("image decode failed"));
|
||||||
img.src = url;
|
// 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() {
|
async function init() {
|
||||||
// Preferred source: a pending capture handed to us through addon storage
|
document.title = NAME ? NAME + " — editor" : "Screenshot editor";
|
||||||
// (the toolbar-menu capture path). A file:// src won't load — Chromium
|
nameEl.textContent = NAME || "screenshot";
|
||||||
// treats the editor at file:///…/addons/screenshot/editor.html as a
|
if (!NAME) {
|
||||||
// different origin from the scratch PNG at file:///…/addons-data/…,
|
empty.classList.add("err");
|
||||||
// so <img> quietly errors. Storage-based delivery sidesteps the origin
|
empty.innerHTML = "<div>Missing <code>?name</code> — open a capture from the Screenshot sidebar.</div>";
|
||||||
// entirely.
|
return;
|
||||||
let dataUrl = null;
|
|
||||||
let toolFromPending = "";
|
|
||||||
let pendingName = baseName;
|
|
||||||
try {
|
|
||||||
if (window.silentmode?.storage) {
|
|
||||||
const pending = await window.silentmode.storage.get("__pending", null);
|
|
||||||
if (pending && pending.dataUrl) {
|
|
||||||
dataUrl = pending.dataUrl;
|
|
||||||
toolFromPending = pending.tool || "";
|
|
||||||
if (pending.name) { pendingName = pending.name; nameEl.textContent = pending.name; document.title = pending.name + " — editor"; }
|
|
||||||
// Clear so a later editor open (e.g. "recent captures") doesn't
|
|
||||||
// accidentally reload the same capture.
|
|
||||||
await window.silentmode.storage.set("__pending", null);
|
|
||||||
}
|
}
|
||||||
}
|
let res;
|
||||||
} catch (e) { console.warn("editor: storage read failed:", e); }
|
try { res = await loadFromAddon(NAME); }
|
||||||
// Fallback path — retained so the "openRecent" flow (which currently
|
catch (e) {
|
||||||
// still passes ?src=file://) keeps working after we teach it to use
|
console.warn("editor load failed:", e);
|
||||||
// storage too.
|
empty.classList.add("err");
|
||||||
if (!dataUrl) {
|
empty.innerHTML = `<div>Couldn't load capture: ${(e && e.message || e)}</div>`;
|
||||||
if (!srcUrl) { toast("No capture to edit", true); return; }
|
return;
|
||||||
dataUrl = srcUrl;
|
|
||||||
}
|
}
|
||||||
let img;
|
let img;
|
||||||
try { img = await loadImage(dataUrl); }
|
try { img = await loadImage(res.dataUrl); }
|
||||||
catch (e) { toast("Couldn't load capture: " + e.message, true); return; }
|
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);
|
sizeCanvases(img.naturalWidth, img.naturalHeight);
|
||||||
cctx.drawImage(img, 0, 0);
|
bctx.drawImage(img, 0, 0);
|
||||||
undo = [];
|
undo = [];
|
||||||
redo = [];
|
redo = [];
|
||||||
pushSnapshot(); // baseline so the very first edit is undoable
|
pushSnapshot();
|
||||||
updateUndoButtons();
|
empty.hidden = true;
|
||||||
|
stage.hidden = false;
|
||||||
fitBoard();
|
fitBoard();
|
||||||
const t = initialTool || toolFromPending;
|
|
||||||
if (t) setTool(t);
|
|
||||||
}
|
}
|
||||||
|
window.addEventListener("resize", () => { if (!stage.hidden) fitBoard(); });
|
||||||
|
|
||||||
// Scale the stage to fit within the board when the image is bigger than
|
// -- tool selection --------------------------------------------------------
|
||||||
// the viewport, so users see the whole shot without scrolling. We scale
|
|
||||||
// visually (CSS transform); drawing math still uses natural canvas
|
|
||||||
// coordinates.
|
|
||||||
let stageScale = 1;
|
|
||||||
function fitBoard() {
|
|
||||||
const availW = board.clientWidth - 40;
|
|
||||||
const availH = board.clientHeight - 40;
|
|
||||||
const s = Math.min(1, availW / committed.width, availH / committed.height);
|
|
||||||
stageScale = s > 0 ? s : 1;
|
|
||||||
stage.style.transform = `scale(${stageScale})`;
|
|
||||||
stage.style.transformOrigin = "top left";
|
|
||||||
// Reserve room so the scaled stage isn't clipped by the flex layout.
|
|
||||||
stage.style.width = (committed.width * stageScale) + "px";
|
|
||||||
stage.style.height = (committed.height * stageScale) + "px";
|
|
||||||
// Undo the reservation on the inner canvases — they must stay at natural
|
|
||||||
// size so the transform can scale them uniformly.
|
|
||||||
for (const c of [committed, draw, overlay]) {
|
|
||||||
c.style.width = committed.width + "px";
|
|
||||||
c.style.height = committed.height + "px";
|
|
||||||
}
|
|
||||||
// Keep the "reserved" outer wrapper's natural children visible.
|
|
||||||
stage.style.position = "relative";
|
|
||||||
committed.style.position = "static";
|
|
||||||
}
|
|
||||||
window.addEventListener("resize", () => fitBoard());
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tool selection
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function setTool(name) {
|
function setTool(name) {
|
||||||
state.tool = name;
|
state.tool = name;
|
||||||
stage.dataset.tool = name;
|
stage.dataset.tool = name;
|
||||||
for (const b of document.querySelectorAll(".tool[data-tool]")) {
|
for (const b of document.querySelectorAll(".tool")) {
|
||||||
b.classList.toggle("active", b.dataset.tool === name);
|
b.classList.toggle("active", b.dataset.tool === name);
|
||||||
}
|
}
|
||||||
// Crop has a two-step commit; show its buttons when relevant.
|
cancelTextInput();
|
||||||
const isCrop = name === "crop";
|
|
||||||
applyCropBtn.hidden = !isCrop || !state.cropRect;
|
|
||||||
cancelCropBtn.hidden = !isCrop || !state.cropRect;
|
|
||||||
if (!isCrop) { state.cropRect = null; clearOverlay(); }
|
|
||||||
clearDraw();
|
|
||||||
const hints = {
|
|
||||||
crop: "Drag a rectangle, then Apply crop",
|
|
||||||
arrow: "Drag to draw an arrow",
|
|
||||||
rect: "Drag to draw a rectangle",
|
|
||||||
ellipse: "Drag to draw an ellipse",
|
|
||||||
pen: "Draw freehand",
|
|
||||||
text: "Click to place a label",
|
|
||||||
blur: "Drag a rectangle to pixelate",
|
|
||||||
select: "",
|
|
||||||
};
|
|
||||||
showHint(hints[name] || "");
|
|
||||||
}
|
}
|
||||||
for (const b of document.querySelectorAll(".tool[data-tool]")) {
|
for (const b of document.querySelectorAll(".tool")) {
|
||||||
b.addEventListener("click", () => setTool(b.dataset.tool));
|
b.addEventListener("click", () => setTool(b.dataset.tool));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const b of document.querySelectorAll(".swatch")) {
|
for (const b of document.querySelectorAll(".swatch")) {
|
||||||
b.addEventListener("click", () => {
|
b.addEventListener("click", () => {
|
||||||
state.color = b.dataset.color;
|
state.color = b.dataset.color;
|
||||||
|
|
@ -221,401 +221,264 @@ for (const b of document.querySelectorAll(".swatch")) {
|
||||||
}
|
}
|
||||||
for (const b of document.querySelectorAll(".width")) {
|
for (const b of document.querySelectorAll(".width")) {
|
||||||
b.addEventListener("click", () => {
|
b.addEventListener("click", () => {
|
||||||
state.width = Number(b.dataset.width);
|
state.width = Number(b.dataset.width) || 5;
|
||||||
for (const x of document.querySelectorAll(".width")) x.classList.toggle("active", x === b);
|
for (const x of document.querySelectorAll(".width")) x.classList.toggle("active", x === b);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// -- drawing ---------------------------------------------------------------
|
||||||
// Drawing primitives on ANY 2D context — used for both the live preview
|
function clearOver() { octx.clearRect(0, 0, over.width, over.height); }
|
||||||
// and the committed bake. Coordinates are in natural canvas px.
|
|
||||||
// ---------------------------------------------------------------------------
|
function strokeStyle() {
|
||||||
function drawArrow(ctx, x1, y1, x2, y2, color, width) {
|
octx.strokeStyle = state.color;
|
||||||
ctx.save();
|
octx.fillStyle = state.color;
|
||||||
ctx.strokeStyle = color; ctx.fillStyle = color;
|
octx.lineWidth = state.width;
|
||||||
ctx.lineWidth = width; ctx.lineCap = "round"; ctx.lineJoin = "round";
|
octx.lineCap = "round";
|
||||||
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
|
octx.lineJoin = "round";
|
||||||
const dx = x2 - x1, dy = y2 - y1;
|
|
||||||
const len = Math.hypot(dx, dy) || 1;
|
|
||||||
const head = Math.max(10, width * 3);
|
|
||||||
const ux = dx / len, uy = dy / len;
|
|
||||||
const px = -uy, py = ux;
|
|
||||||
const tipX = x2, tipY = y2;
|
|
||||||
const baseX = x2 - ux * head, baseY = y2 - uy * head;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(tipX, tipY);
|
|
||||||
ctx.lineTo(baseX + px * head * 0.5, baseY + py * head * 0.5);
|
|
||||||
ctx.lineTo(baseX - px * head * 0.5, baseY - py * head * 0.5);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fill();
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
function drawRect(ctx, x, y, w, h, color, width) {
|
|
||||||
ctx.save();
|
|
||||||
ctx.strokeStyle = color; ctx.lineWidth = width;
|
|
||||||
// Half-pixel offset for crisp 1px lines is not worth the branching at
|
|
||||||
// small width; the visible fuzz is negligible past width 2.
|
|
||||||
ctx.strokeRect(x, y, w, h);
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
function drawEllipse(ctx, x, y, w, h, color, width) {
|
|
||||||
ctx.save();
|
|
||||||
ctx.strokeStyle = color; ctx.lineWidth = width;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.ellipse(x + w / 2, y + h / 2, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
function drawPen(ctx, points, color, width) {
|
|
||||||
if (!points || points.length < 2) return;
|
|
||||||
ctx.save();
|
|
||||||
ctx.strokeStyle = color; ctx.lineWidth = width;
|
|
||||||
ctx.lineCap = "round"; ctx.lineJoin = "round";
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(points[0].x, points[0].y);
|
|
||||||
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
function drawTextLabel(ctx, x, y, text, color) {
|
|
||||||
if (!text) return;
|
|
||||||
ctx.save();
|
|
||||||
ctx.font = `600 18px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`;
|
|
||||||
ctx.textBaseline = "top";
|
|
||||||
const metrics = ctx.measureText(text);
|
|
||||||
const w = Math.ceil(metrics.width) + 8, h = 22;
|
|
||||||
// Backdrop for legibility over any background.
|
|
||||||
ctx.fillStyle = "rgba(0,0,0,.65)";
|
|
||||||
ctx.fillRect(x - 4, y - 2, w, h);
|
|
||||||
ctx.fillStyle = color;
|
|
||||||
ctx.fillText(text, x, y);
|
|
||||||
ctx.restore();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mosaic pixelation: sample the region into a small offscreen, then draw
|
function drawArrow(a, b) {
|
||||||
// back at full size with imageSmoothingEnabled off so each sample lands as
|
strokeStyle();
|
||||||
// a chunky square. Block size scales with stroke width for a "coarser /
|
octx.beginPath();
|
||||||
// finer" knob on the same tool.
|
octx.moveTo(a.x, a.y); octx.lineTo(b.x, b.y); octx.stroke();
|
||||||
function applyMosaic(ctx, x, y, w, h, width) {
|
// arrowhead
|
||||||
if (w <= 0 || h <= 0) return;
|
const dx = b.x - a.x, dy = b.y - a.y;
|
||||||
const block = Math.max(6, Math.min(40, width * 3));
|
const len = Math.hypot(dx, dy);
|
||||||
const sw = Math.max(1, Math.round(w / block));
|
if (len < 1) return;
|
||||||
const sh = Math.max(1, Math.round(h / block));
|
const head = Math.max(10, state.width * 3);
|
||||||
const tmp = document.createElement("canvas");
|
const ang = Math.atan2(dy, dx);
|
||||||
tmp.width = sw; tmp.height = sh;
|
const spread = Math.PI / 6;
|
||||||
const tctx = tmp.getContext("2d");
|
octx.beginPath();
|
||||||
tctx.imageSmoothingEnabled = false;
|
octx.moveTo(b.x, b.y);
|
||||||
tctx.drawImage(ctx.canvas, x, y, w, h, 0, 0, sw, sh);
|
octx.lineTo(b.x - head * Math.cos(ang - spread), b.y - head * Math.sin(ang - spread));
|
||||||
ctx.save();
|
octx.moveTo(b.x, b.y);
|
||||||
ctx.imageSmoothingEnabled = false;
|
octx.lineTo(b.x - head * Math.cos(ang + spread), b.y - head * Math.sin(ang + spread));
|
||||||
ctx.drawImage(tmp, 0, 0, sw, sh, x, y, w, h);
|
octx.stroke();
|
||||||
ctx.restore();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearDraw() { dctx.clearRect(0, 0, draw.width, draw.height); }
|
function drawRect(a, b) {
|
||||||
function clearOverlay() { octx.clearRect(0, 0, overlay.width, overlay.height); }
|
strokeStyle();
|
||||||
|
|
||||||
function drawSelectionChrome(rect) {
|
|
||||||
clearOverlay();
|
|
||||||
if (!rect) return;
|
|
||||||
// Dim the surrounding area so the crop rect stands out.
|
|
||||||
octx.save();
|
|
||||||
octx.fillStyle = "rgba(0,0,0,.45)";
|
|
||||||
octx.fillRect(0, 0, overlay.width, overlay.height);
|
|
||||||
octx.clearRect(rect.x, rect.y, rect.w, rect.h);
|
|
||||||
octx.strokeStyle = "#d6ff3d";
|
|
||||||
octx.lineWidth = 1.5;
|
|
||||||
octx.setLineDash([6, 4]);
|
|
||||||
octx.strokeRect(rect.x + 0.5, rect.y + 0.5, rect.w - 1, rect.h - 1);
|
|
||||||
octx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Pointer wiring
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function pointerToCanvas(ev) {
|
|
||||||
const r = draw.getBoundingClientRect();
|
|
||||||
// r.width / draw.width gives us CSS px per canvas px, i.e. our current
|
|
||||||
// stageScale — computing it from the rect keeps us honest even if the
|
|
||||||
// fit-to-board math ever drifts.
|
|
||||||
const sx = draw.width / r.width;
|
|
||||||
const sy = draw.height / r.height;
|
|
||||||
return { x: (ev.clientX - r.left) * sx, y: (ev.clientY - r.top) * sy };
|
|
||||||
}
|
|
||||||
|
|
||||||
function normRect(a, b) {
|
|
||||||
const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y);
|
const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y);
|
||||||
const w = Math.abs(a.x - b.x), h = Math.abs(a.y - b.y);
|
const w = Math.abs(b.x - a.x), h = Math.abs(b.y - a.y);
|
||||||
return { x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h) };
|
octx.strokeRect(x, y, w, h);
|
||||||
}
|
}
|
||||||
|
|
||||||
draw.addEventListener("pointerdown", (ev) => {
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 === "select") return;
|
||||||
if (state.tool === "text") {
|
if (state.tool === "text") { openTextInput(pointToCanvas(ev)); return; }
|
||||||
beginText(ev);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
draw.setPointerCapture(ev.pointerId);
|
if (!state.drag) return;
|
||||||
state.dragging = true;
|
state.drag.x = p.x; state.drag.y = p.y;
|
||||||
state.start = pointerToCanvas(ev);
|
clearOver();
|
||||||
state.end = state.start;
|
const a = { x: state.drag.x0, y: state.drag.y0 }, b = { x: p.x, y: p.y };
|
||||||
if (state.tool === "pen") state.path = [state.start];
|
if (state.tool === "arrow") drawArrow(a, b);
|
||||||
|
else if (state.tool === "rect") drawRect(a, b);
|
||||||
|
else if (state.tool === "ellipse") drawEllipse(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; return; }
|
||||||
|
commitOver();
|
||||||
|
state.drag = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
draw.addEventListener("pointermove", (ev) => {
|
// -- text tool -----------------------------------------------------------
|
||||||
if (!state.dragging) return;
|
function openTextInput(pt) {
|
||||||
state.end = pointerToCanvas(ev);
|
cancelTextInput();
|
||||||
if (state.tool === "pen") {
|
const el = document.createElement("input");
|
||||||
state.path.push(state.end);
|
el.type = "text";
|
||||||
// Live-render the whole path each move; simpler than incremental and
|
el.className = "text-input";
|
||||||
// fine at freehand cadence.
|
el.placeholder = "text…";
|
||||||
clearDraw();
|
el.style.color = state.color;
|
||||||
drawPen(dctx, state.path, state.color, state.width);
|
el.style.font = `${Math.max(14, state.width * 4)}px system-ui, -apple-system, Segoe UI, Roboto, sans-serif`;
|
||||||
return;
|
const rect = base.getBoundingClientRect();
|
||||||
}
|
const scale = stage._scale || 1;
|
||||||
const r = normRect(state.start, state.end);
|
const cssX = pt.x * scale + rect.left;
|
||||||
if (state.tool === "crop" || state.tool === "blur") {
|
const cssY = pt.y * scale + rect.top;
|
||||||
drawSelectionChrome(r);
|
el.style.left = cssX + "px";
|
||||||
return;
|
el.style.top = (cssY - 14) + "px";
|
||||||
}
|
document.body.appendChild(el);
|
||||||
clearDraw();
|
el.focus();
|
||||||
if (state.tool === "arrow") drawArrow(dctx, state.start.x, state.start.y, state.end.x, state.end.y, state.color, state.width);
|
state.textInput = { x: pt.x, y: pt.y, el };
|
||||||
if (state.tool === "rect") drawRect(dctx, r.x, r.y, r.w, r.h, state.color, state.width);
|
el.addEventListener("keydown", (ev) => {
|
||||||
if (state.tool === "ellipse") drawEllipse(dctx, r.x, r.y, r.w, r.h, state.color, state.width);
|
if (ev.key === "Enter") { commitTextInput(); ev.preventDefault(); }
|
||||||
});
|
else if (ev.key === "Escape") { cancelTextInput(); ev.preventDefault(); }
|
||||||
|
|
||||||
draw.addEventListener("pointerup", (ev) => {
|
|
||||||
if (!state.dragging) return;
|
|
||||||
state.dragging = false;
|
|
||||||
try { draw.releasePointerCapture(ev.pointerId); } catch {}
|
|
||||||
const r = normRect(state.start, state.end);
|
|
||||||
if (state.tool === "pen") {
|
|
||||||
if (state.path && state.path.length > 1) {
|
|
||||||
drawPen(cctx, state.path, state.color, state.width);
|
|
||||||
pushSnapshot();
|
|
||||||
}
|
|
||||||
state.path = null;
|
|
||||||
clearDraw();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (state.tool === "arrow") {
|
|
||||||
if (Math.hypot(state.end.x - state.start.x, state.end.y - state.start.y) > 3) {
|
|
||||||
drawArrow(cctx, state.start.x, state.start.y, state.end.x, state.end.y, state.color, state.width);
|
|
||||||
pushSnapshot();
|
|
||||||
}
|
|
||||||
clearDraw();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (state.tool === "rect" || state.tool === "ellipse") {
|
|
||||||
if (r.w > 3 && r.h > 3) {
|
|
||||||
(state.tool === "rect" ? drawRect : drawEllipse)(cctx, r.x, r.y, r.w, r.h, state.color, state.width);
|
|
||||||
pushSnapshot();
|
|
||||||
}
|
|
||||||
clearDraw();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (state.tool === "blur") {
|
|
||||||
if (r.w > 3 && r.h > 3) {
|
|
||||||
applyMosaic(cctx, r.x, r.y, r.w, r.h, state.width);
|
|
||||||
pushSnapshot();
|
|
||||||
}
|
|
||||||
clearOverlay();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (state.tool === "crop") {
|
|
||||||
if (r.w > 3 && r.h > 3) {
|
|
||||||
state.cropRect = r;
|
|
||||||
applyCropBtn.hidden = false;
|
|
||||||
cancelCropBtn.hidden = false;
|
|
||||||
} else {
|
|
||||||
state.cropRect = null;
|
|
||||||
clearOverlay();
|
|
||||||
applyCropBtn.hidden = true;
|
|
||||||
cancelCropBtn.hidden = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Escape unwinds progressively: in-flight text placement → in-flight
|
|
||||||
// crop rectangle → whole editor (drops the screenshot and closes the tab).
|
|
||||||
window.addEventListener("keydown", (ev) => {
|
|
||||||
if (ev.key === "Escape") {
|
|
||||||
if (state.textInput) { cancelText(); ev.preventDefault(); return; }
|
|
||||||
if (state.tool === "crop" && state.cropRect) { state.cropRect = null; clearOverlay(); applyCropBtn.hidden = true; cancelCropBtn.hidden = true; return; }
|
|
||||||
discard(); ev.preventDefault(); return;
|
|
||||||
}
|
|
||||||
// Undo / redo shortcuts.
|
|
||||||
const meta = ev.ctrlKey || ev.metaKey;
|
|
||||||
if (meta && !ev.shiftKey && ev.key.toLowerCase() === "z") { doUndo(); ev.preventDefault(); return; }
|
|
||||||
if (meta && ev.shiftKey && ev.key.toLowerCase() === "z") { doRedo(); ev.preventDefault(); return; }
|
|
||||||
if (meta && ev.key.toLowerCase() === "y") { doRedo(); ev.preventDefault(); return; }
|
|
||||||
if (meta && ev.key.toLowerCase() === "s") { save(); ev.preventDefault(); return; }
|
|
||||||
if (meta && ev.key.toLowerCase() === "c" && !state.textInput) { copy(); ev.preventDefault(); return; }
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Text tool: click places an input; blur/Enter commits, Escape cancels.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function beginText(ev) {
|
|
||||||
if (state.textInput) commitText();
|
|
||||||
const p = pointerToCanvas(ev);
|
|
||||||
const inp = document.createElement("input");
|
|
||||||
inp.type = "text";
|
|
||||||
inp.className = "text-input";
|
|
||||||
inp.placeholder = "text";
|
|
||||||
// Place using viewport coords — body isn't positioned, so absolute
|
|
||||||
// left/top match clientX/Y as long as the board isn't scrolled.
|
|
||||||
inp.style.left = (ev.clientX + board.scrollLeft) + "px";
|
|
||||||
inp.style.top = (ev.clientY + board.scrollTop) + "px";
|
|
||||||
inp.style.color = state.color;
|
|
||||||
document.body.appendChild(inp);
|
|
||||||
inp.focus();
|
|
||||||
state.textInput = { x: p.x, y: p.y, el: inp, color: state.color };
|
|
||||||
inp.addEventListener("keydown", (e) => {
|
|
||||||
if (e.key === "Enter") { e.preventDefault(); commitText(); }
|
|
||||||
else if (e.key === "Escape") { e.preventDefault(); cancelText(); }
|
|
||||||
});
|
});
|
||||||
inp.addEventListener("blur", () => setTimeout(commitText, 0));
|
el.addEventListener("blur", commitTextInput);
|
||||||
}
|
}
|
||||||
function commitText() {
|
function commitTextInput() {
|
||||||
const t = state.textInput; if (!t) return;
|
const ti = state.textInput;
|
||||||
const text = t.el.value.trim();
|
if (!ti) return;
|
||||||
t.el.remove();
|
const value = ti.el.value.trim();
|
||||||
|
ti.el.remove();
|
||||||
state.textInput = null;
|
state.textInput = null;
|
||||||
if (!text) return;
|
if (!value) return;
|
||||||
drawTextLabel(cctx, t.x, t.y, text, t.color);
|
const size = Math.max(14, state.width * 4);
|
||||||
|
bctx.fillStyle = state.color;
|
||||||
|
bctx.font = `${size}px system-ui, -apple-system, Segoe UI, Roboto, sans-serif`;
|
||||||
|
bctx.textBaseline = "alphabetic";
|
||||||
|
bctx.fillText(value, ti.x, ti.y);
|
||||||
pushSnapshot();
|
pushSnapshot();
|
||||||
}
|
}
|
||||||
function cancelText() {
|
function cancelTextInput() {
|
||||||
const t = state.textInput; if (!t) return;
|
if (!state.textInput) return;
|
||||||
t.el.remove();
|
state.textInput.el.remove();
|
||||||
state.textInput = null;
|
state.textInput = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// -- undo / redo -------------------------------------------------------
|
||||||
// Undo / redo
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function doUndo() {
|
function doUndo() {
|
||||||
if (undo.length <= 1) return;
|
if (undo.length <= 1) return;
|
||||||
const cur = undo.pop();
|
const cur = undo.pop();
|
||||||
redo.push(cur);
|
redo.push(cur);
|
||||||
const prev = undo[undo.length - 1];
|
restoreSnapshot(undo[undo.length - 1]);
|
||||||
restoreSnapshot(prev);
|
updateUndoRedo();
|
||||||
clearDraw(); clearOverlay();
|
|
||||||
state.cropRect = null;
|
|
||||||
applyCropBtn.hidden = true;
|
|
||||||
cancelCropBtn.hidden = true;
|
|
||||||
updateUndoButtons();
|
|
||||||
}
|
}
|
||||||
function doRedo() {
|
function doRedo() {
|
||||||
if (!redo.length) return;
|
|
||||||
const snap = redo.pop();
|
const snap = redo.pop();
|
||||||
|
if (!snap) return;
|
||||||
undo.push(snap);
|
undo.push(snap);
|
||||||
restoreSnapshot(snap);
|
restoreSnapshot(snap);
|
||||||
updateUndoButtons();
|
updateUndoRedo();
|
||||||
}
|
}
|
||||||
undoBtn.addEventListener("click", doUndo);
|
undoBtn.addEventListener("click", doUndo);
|
||||||
redoBtn.addEventListener("click", doRedo);
|
redoBtn.addEventListener("click", doRedo);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// -- save / copy ------------------------------------------------------
|
||||||
// Crop apply
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
applyCropBtn.addEventListener("click", () => {
|
|
||||||
const r = state.cropRect; if (!r) return;
|
|
||||||
// Clamp to canvas.
|
|
||||||
const x = Math.max(0, r.x), y = Math.max(0, r.y);
|
|
||||||
const w = Math.min(r.w, committed.width - x);
|
|
||||||
const h = Math.min(r.h, committed.height - y);
|
|
||||||
if (w <= 0 || h <= 0) { toast("Crop out of bounds", true); return; }
|
|
||||||
const tmp = document.createElement("canvas");
|
|
||||||
tmp.width = w; tmp.height = h;
|
|
||||||
tmp.getContext("2d").drawImage(committed, x, y, w, h, 0, 0, w, h);
|
|
||||||
sizeCanvases(w, h);
|
|
||||||
cctx.drawImage(tmp, 0, 0);
|
|
||||||
state.cropRect = null;
|
|
||||||
clearOverlay(); clearDraw();
|
|
||||||
applyCropBtn.hidden = true;
|
|
||||||
cancelCropBtn.hidden = true;
|
|
||||||
pushSnapshot();
|
|
||||||
fitBoard();
|
|
||||||
});
|
|
||||||
cancelCropBtn.addEventListener("click", () => {
|
|
||||||
state.cropRect = null;
|
|
||||||
clearOverlay();
|
|
||||||
applyCropBtn.hidden = true;
|
|
||||||
cancelCropBtn.hidden = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Save & copy
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function canvasBlob() {
|
function canvasBlob() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) =>
|
||||||
committed.toBlob((b) => b ? resolve(b) : reject(new Error("toBlob returned null")), "image/png");
|
base.toBlob((b) => b ? resolve(b) : reject(new Error("toBlob returned null")), "image/png"));
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
try {
|
try {
|
||||||
const blob = await canvasBlob();
|
const blob = await canvasBlob();
|
||||||
// Chromium's will-download listener catches this via the download attr;
|
|
||||||
// no separate capability needed.
|
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = $("download-link");
|
dl.href = url;
|
||||||
a.href = url;
|
dl.download = NAME || "screenshot.png";
|
||||||
a.download = baseName || "screenshot.png";
|
dl.click();
|
||||||
a.click();
|
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||||
// Blob URLs are cheap but leak; release once the browser has had a beat
|
playSave();
|
||||||
// to start the download.
|
toast(`Saved ${dl.download} (${(blob.size / 1024).toFixed(1)} KB)`);
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 4000);
|
|
||||||
toast(`Saved ${baseName} (${(blob.size / 1024).toFixed(1)} KB)`);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast("Save failed: " + e.message, true);
|
toast("Save failed: " + (e && e.message || e), true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copy() {
|
async function copy() {
|
||||||
try {
|
try {
|
||||||
const blob = await canvasBlob();
|
const blob = await canvasBlob();
|
||||||
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
|
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
|
||||||
|
playCopy();
|
||||||
toast("Copied to clipboard");
|
toast("Copied to clipboard");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast("Copy failed: " + e.message, true);
|
toast("Copy failed: " + (e && e.message || e), true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$("save").addEventListener("click", save);
|
$("save").addEventListener("click", save);
|
||||||
$("copy").addEventListener("click", copy);
|
$("copy").addEventListener("click", copy);
|
||||||
$("discard").addEventListener("click", discard);
|
|
||||||
|
|
||||||
// Back button — navigate the sidebar view back to panel.html. Same
|
// -- top-bar navigation ----------------------------------------------
|
||||||
// webContents, so it's just a location swap; no IPC needed.
|
$("back").addEventListener("click", () => { playDiscard(); location.href = "panel.html"; });
|
||||||
const backBtn = $("back");
|
|
||||||
if (backBtn) backBtn.addEventListener("click", () => { location.href = "panel.html"; });
|
// 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();
|
||||||
|
});
|
||||||
|
|
||||||
// Maximize / restore — asks the sidebar host to widen its view to the full
|
|
||||||
// window and back. Icon reflects state via silentmode.sidebar.onMaxChange.
|
|
||||||
const maxBtn = $("toggle-max");
|
const maxBtn = $("toggle-max");
|
||||||
if (maxBtn && window.silentmode?.sidebar) {
|
async function paintMaxIcon(isMax) {
|
||||||
maxBtn.addEventListener("click", async () => {
|
|
||||||
try { await window.silentmode.sidebar.toggleMax(); }
|
|
||||||
catch (e) { console.warn("toggleMax failed:", e); }
|
|
||||||
});
|
|
||||||
const paint = (isMax) => {
|
|
||||||
maxBtn.title = isMax ? "Restore sidebar width" : "Expand the sidebar to full window";
|
maxBtn.title = isMax ? "Restore sidebar width" : "Expand the sidebar to full window";
|
||||||
maxBtn.innerHTML = isMax
|
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="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>';
|
: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 6V2h4M14 6V2h-4M2 10v4h4M14 10v4h-4"/></svg>';
|
||||||
};
|
}
|
||||||
window.silentmode.sidebar.onMaxChange(paint);
|
if (window.silentmode?.sidebar) {
|
||||||
window.silentmode.sidebar.isMax().then(paint).catch(() => {});
|
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(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop the working screenshot and go back to the sidebar panel. Used by
|
// -- keyboard shortcuts ---------------------------------------------
|
||||||
// the Discard button and the top-level Escape shortcut.
|
window.addEventListener("keydown", (ev) => {
|
||||||
async function discard() {
|
if (state.textInput) return;
|
||||||
try { if (window.silentmode?.storage) await window.silentmode.storage.set("__pending", null); } catch {}
|
const meta = ev.ctrlKey || ev.metaKey;
|
||||||
location.href = "panel.html";
|
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 (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 === "Escape") setTool("select");
|
||||||
|
});
|
||||||
|
|
||||||
init();
|
init();
|
||||||
|
|
|
||||||
|
|
@ -101,24 +101,10 @@ module.exports = {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// panel invokes "arm" right before it navigates itself to editor.html
|
// (The old "arm" handler that pre-armed a __pending storage entry is
|
||||||
// (in-sidebar navigation — SAME webContents, so the editor's storage-
|
// gone: editor.html now pulls its bytes via getBytes on load, so there
|
||||||
// based load path Just Works). We rewrite __pending fresh here so
|
// is nothing to hand off in advance. Idempotent — reopening the editor
|
||||||
// whichever capture is currently previewed becomes the one the editor
|
// in the sidebar just re-fetches the bytes.)
|
||||||
// draws, even if a previous edit session already drained the entry.
|
|
||||||
api.onMessage("arm", (payload) => {
|
|
||||||
const wantedName = payload && payload.name ? String(payload.name) : "";
|
|
||||||
let recent = api.storage.get("recent", []);
|
|
||||||
if (!Array.isArray(recent)) recent = [];
|
|
||||||
recent = pruneRecent(recent);
|
|
||||||
api.storage.set("recent", recent);
|
|
||||||
const hit = wantedName ? recent.find((r) => r.id === wantedName) : recent[0];
|
|
||||||
if (!hit) throw new Error("no capture to edit — take one first");
|
|
||||||
const dataUrl = readAsDataUrl(hit.path);
|
|
||||||
api.storage.set("__pending", { name: hit.name, dataUrl, at: Date.now() });
|
|
||||||
api.log(`arm → editor.html?name=${hit.name}`);
|
|
||||||
return { ok: true, name: hit.name };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Read the ring for a "recent captures" strip in the sidebar. Bytes are
|
// Read the ring for a "recent captures" strip in the sidebar. Bytes are
|
||||||
// reported; the actual images are pulled through "getBytes" on demand
|
// reported; the actual images are pulled through "getBytes" on demand
|
||||||
|
|
@ -155,6 +141,6 @@ module.exports = {
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
api.log("registered screenshot v0.3.0 sidebar panel");
|
api.log("registered screenshot v0.5.0 sidebar panel");
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -59,15 +59,33 @@
|
||||||
}
|
}
|
||||||
.meta { color: var(--dim); font-size: 11.5px; display: flex; justify-content: space-between; gap: 8px; }
|
.meta { color: var(--dim); font-size: 11.5px; display: flex; justify-content: space-between; gap: 8px; }
|
||||||
.meta .host { color: var(--mut); }
|
.meta .host { color: var(--mut); }
|
||||||
.actions { display: flex; gap: 6px; }
|
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.actions .row { display: flex; gap: 6px; flex: 1 1 100%; }
|
||||||
button.act {
|
button.act {
|
||||||
flex: 1; padding: 9px 10px; border: 1px solid var(--line); border-radius: 8px;
|
flex: 1; padding: 9px 8px; border: 1px solid var(--line); border-radius: 8px;
|
||||||
background: var(--btn); color: var(--ink); cursor: pointer; font: inherit;
|
background: var(--btn); color: var(--ink); cursor: pointer; font: inherit;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
button.act svg { width: 14px; height: 14px; display: block; }
|
||||||
button.act.primary { background: var(--acid); color: #101418; border-color: transparent; font-weight: 600; }
|
button.act.primary { background: var(--acid); color: #101418; border-color: transparent; font-weight: 600; }
|
||||||
button.act:hover:not(:disabled) { background: var(--btn-h); }
|
button.act:hover:not(:disabled) { background: var(--btn-h); }
|
||||||
button.act.primary:hover:not(:disabled) { filter: brightness(1.05); }
|
button.act.primary:hover:not(:disabled) { filter: brightness(1.05); background: var(--acid); }
|
||||||
button.act:disabled { opacity: .45; cursor: default; }
|
button.act:disabled { opacity: .45; cursor: default; }
|
||||||
|
.confirm {
|
||||||
|
display: none; gap: 6px; align-items: center;
|
||||||
|
padding: 8px 10px; border: 1px solid rgba(255,91,91,.40); border-radius: 8px;
|
||||||
|
background: rgba(255,91,91,.06); font-size: 12px;
|
||||||
|
}
|
||||||
|
.confirm.on { display: flex; }
|
||||||
|
.confirm .msg { flex: 1; color: var(--mut); }
|
||||||
|
.confirm button {
|
||||||
|
padding: 5px 10px; border: 1px solid var(--line); border-radius: 6px;
|
||||||
|
background: var(--btn); color: var(--ink); cursor: pointer; font: inherit;
|
||||||
|
}
|
||||||
|
.confirm button.yes { background: #ff5b5b; color: #101418; border-color: transparent; font-weight: 600; }
|
||||||
|
.confirm button:hover { background: var(--btn-h); }
|
||||||
|
.confirm button.yes:hover { filter: brightness(1.05); background: #ff5b5b; }
|
||||||
.status { color: var(--mut); font-size: 12px; min-height: 16px; }
|
.status { color: var(--mut); font-size: 12px; min-height: 16px; }
|
||||||
.status.err { color: var(--err); }
|
.status.err { color: var(--err); }
|
||||||
.status.ok { color: var(--acid); }
|
.status.ok { color: var(--acid); }
|
||||||
|
|
@ -91,6 +109,7 @@
|
||||||
<header>
|
<header>
|
||||||
<div class="t"><span class="em">📸</span> <span>Screenshot</span></div>
|
<div class="t"><span class="em">📸</span> <span>Screenshot</span></div>
|
||||||
<div class="m" id="hdr-status"></div>
|
<div class="m" id="hdr-status"></div>
|
||||||
|
<button class="max" id="btn-sound" title="Toggle sound" aria-label="Toggle sound"></button>
|
||||||
<button class="max" id="btn-max" title="Expand the sidebar to full window width" aria-label="Expand sidebar">
|
<button class="max" id="btn-max" title="Expand the sidebar to full window width" aria-label="Expand sidebar">
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
<path d="M2 6V2h4M14 6V2h-4M2 10v4h4M14 10v4h-4"/>
|
<path d="M2 6V2h4M14 6V2h-4M2 10v4h4M14 10v4h-4"/>
|
||||||
|
|
@ -121,8 +140,20 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
|
<div class="row">
|
||||||
|
<button class="act" id="btn-copy" disabled title="Copy the raw capture to the clipboard">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="4" y="4" width="9" height="10"/><path d="M3 12V3h9"/></svg>
|
||||||
|
<span>Copy</span>
|
||||||
|
</button>
|
||||||
|
<button class="act" id="btn-save" disabled title="Save the raw capture straight to Downloads">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v9M4 7l4 4 4-4M2 14h12"/></svg>
|
||||||
|
<span>Save</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
<button class="act" id="btn-discard" disabled>Discard</button>
|
<button class="act" id="btn-discard" disabled>Discard</button>
|
||||||
<button class="act primary" id="btn-edit" disabled title="Edit the capture — crop, annotate, redact, save">Edit</button>
|
<button class="act primary" id="btn-edit" disabled title="Open the annotate editor — arrow, rect, ellipse, pen, text, undo, save, copy">Edit</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="status" id="status"></div>
|
<div class="status" id="status"></div>
|
||||||
|
|
@ -132,7 +163,13 @@
|
||||||
<button class="clear" id="clear-recent" title="Delete every past capture">clear all</button>
|
<button class="clear" id="clear-recent" title="Delete every past capture">clear all</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="strip" id="recent-strip"></div>
|
<div class="strip" id="recent-strip"></div>
|
||||||
|
<div class="confirm" id="clear-confirm">
|
||||||
|
<span class="msg">Delete every past capture?</span>
|
||||||
|
<button id="clear-no">Cancel</button>
|
||||||
|
<button class="yes" id="clear-yes">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<a id="download-link" style="display:none"></a>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
@ -144,7 +181,63 @@
|
||||||
const metaHost = $("meta-host");
|
const metaHost = $("meta-host");
|
||||||
const btnEdit = $("btn-edit");
|
const btnEdit = $("btn-edit");
|
||||||
const btnDiscard = $("btn-discard");
|
const btnDiscard = $("btn-discard");
|
||||||
|
const btnCopy = $("btn-copy");
|
||||||
|
const btnSave = $("btn-save");
|
||||||
const btnMax = $("btn-max");
|
const btnMax = $("btn-max");
|
||||||
|
const btnSound = $("btn-sound");
|
||||||
|
const dl = $("download-link");
|
||||||
|
|
||||||
|
// ---- audio ----------------------------------------------------------
|
||||||
|
// Synthesized in-page so the tarball stays small — no wav shipped.
|
||||||
|
// Preference is stored under silentmode.storage so the editor sees the
|
||||||
|
// same setting when it loads (default on).
|
||||||
|
let soundOn = true;
|
||||||
|
let audioCtx = null;
|
||||||
|
function actx() {
|
||||||
|
if (!audioCtx) {
|
||||||
|
try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch {}
|
||||||
|
}
|
||||||
|
// A user gesture is needed to resume in some Chromium setups; the
|
||||||
|
// toggle button click satisfies that.
|
||||||
|
if (audioCtx && audioCtx.state === "suspended") { audioCtx.resume().catch(() => {}); }
|
||||||
|
return audioCtx;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
function playShutter() { tone(1500, 0.05, "square", 0.20, 0); tone(600, 0.06, "square", 0.14, 0.03); }
|
||||||
|
function playCopy() { tone(900, 0.06, "sine", 0.15, 0); tone(1400, 0.08, "sine", 0.15, 0.05); }
|
||||||
|
function playDiscard() { tone(500, 0.06, "sine", 0.18, 0); tone(280, 0.10, "sine", 0.14, 0.05); }
|
||||||
|
function paintSound() {
|
||||||
|
btnSound.title = soundOn ? "Sounds on — click to mute" : "Sounds off — click to enable";
|
||||||
|
btnSound.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; // default on
|
||||||
|
} catch {}
|
||||||
|
paintSound();
|
||||||
|
})();
|
||||||
|
btnSound.addEventListener("click", async () => {
|
||||||
|
soundOn = !soundOn;
|
||||||
|
paintSound();
|
||||||
|
try { await window.silentmode.storage.set("soundOn", soundOn); } catch {}
|
||||||
|
if (soundOn) playShutter(); // audible confirmation
|
||||||
|
});
|
||||||
const hdrStatus = $("hdr-status");
|
const hdrStatus = $("hdr-status");
|
||||||
const recentBox = $("recent");
|
const recentBox = $("recent");
|
||||||
const recentStrip = $("recent-strip");
|
const recentStrip = $("recent-strip");
|
||||||
|
|
@ -163,22 +256,16 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPreview() {
|
function renderPreview() {
|
||||||
if (!last || !last.dataUrl) {
|
const on = !!(last && last.dataUrl);
|
||||||
previewImg.hidden = true; previewImg.src = "";
|
previewImg.hidden = !on;
|
||||||
previewEmpty.hidden = false;
|
previewImg.src = on ? last.dataUrl : "";
|
||||||
metaSize.textContent = "—";
|
previewEmpty.hidden = on;
|
||||||
metaHost.textContent = "";
|
metaSize.textContent = on ? `${last.width}×${last.height} · ${fmt(last.bytes || 0)}` : "—";
|
||||||
btnEdit.disabled = true;
|
metaHost.textContent = on ? (last.host || "") : "";
|
||||||
btnDiscard.disabled = true;
|
btnEdit.disabled = !on;
|
||||||
return;
|
btnDiscard.disabled = !on;
|
||||||
}
|
btnCopy.disabled = !on;
|
||||||
previewImg.src = last.dataUrl;
|
btnSave.disabled = !on;
|
||||||
previewImg.hidden = false;
|
|
||||||
previewEmpty.hidden = true;
|
|
||||||
metaSize.textContent = `${last.width}×${last.height} · ${fmt(last.bytes || 0)}`;
|
|
||||||
metaHost.textContent = last.host || "";
|
|
||||||
btnEdit.disabled = false;
|
|
||||||
btnDiscard.disabled = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshRecent() {
|
async function refreshRecent() {
|
||||||
|
|
@ -234,6 +321,7 @@
|
||||||
} else if (res && res.dataUrl) {
|
} else if (res && res.dataUrl) {
|
||||||
last = res;
|
last = res;
|
||||||
renderPreview();
|
renderPreview();
|
||||||
|
playShutter();
|
||||||
setStatus("Captured. Click Edit to annotate.", "ok");
|
setStatus("Captured. Click Edit to annotate.", "ok");
|
||||||
refreshRecent();
|
refreshRecent();
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -250,24 +338,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doEdit() {
|
async function doEdit() {
|
||||||
if (!last || !last.dataUrl || busy) return;
|
if (!last || !last.name || busy) return;
|
||||||
busy = true;
|
// No IPC handshake — the editor pulls bytes on load via silentmode
|
||||||
btnEdit.disabled = true;
|
// .invoke("getBytes", {name}) directly. That's idempotent, so a refresh
|
||||||
hdrStatus.textContent = "opening editor…";
|
// or back-and-forth navigation always shows the image.
|
||||||
try {
|
location.href = "editor.html?name=" + encodeURIComponent(last.name);
|
||||||
// Arm __pending with the currently-previewed capture, then navigate
|
|
||||||
// this sidebar view to editor.html — same webContents, same preload,
|
|
||||||
// so the editor keeps talking to the add-on through silentmode.*.
|
|
||||||
const armed = await window.silentmode.invoke("arm", { name: last.name });
|
|
||||||
const name = (armed && armed.name) || last.name;
|
|
||||||
location.href = "editor.html?name=" + encodeURIComponent(name);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("arm failed:", e);
|
|
||||||
setStatus("Edit failed: " + (e && e.message || e), "err");
|
|
||||||
btnEdit.disabled = !last;
|
|
||||||
hdrStatus.textContent = "";
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleMax() {
|
async function toggleMax() {
|
||||||
|
|
@ -275,15 +350,69 @@
|
||||||
catch (e) { console.warn("toggleMax failed:", e); }
|
catch (e) { console.warn("toggleMax failed:", e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert a base64 data URL into a Blob without a fetch round-trip.
|
||||||
|
function dataUrlToBlob(dataUrl) {
|
||||||
|
const m = /^data:([^;,]+);base64,(.+)$/.exec(String(dataUrl || ""));
|
||||||
|
if (!m) throw new Error("expected base64 data URL");
|
||||||
|
const bin = atob(m[2]);
|
||||||
|
const buf = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
|
||||||
|
return new Blob([buf], { type: m[1] });
|
||||||
|
}
|
||||||
|
async function doCopy() {
|
||||||
|
if (!last || !last.dataUrl || busy) return;
|
||||||
|
try {
|
||||||
|
const blob = dataUrlToBlob(last.dataUrl);
|
||||||
|
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
|
||||||
|
playCopy();
|
||||||
|
setStatus("Copied to clipboard.", "ok");
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("copy failed:", e);
|
||||||
|
setStatus("Copy failed: " + (e && e.message || e), "err");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function doSave() {
|
||||||
|
if (!last || !last.dataUrl || busy) return;
|
||||||
|
try {
|
||||||
|
const blob = dataUrlToBlob(last.dataUrl);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
dl.href = url;
|
||||||
|
dl.download = last.name || "screenshot.png";
|
||||||
|
dl.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||||
|
setStatus(`Saved ${dl.download} (${(blob.size / 1024).toFixed(1)} KB)`, "ok");
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("save failed:", e);
|
||||||
|
setStatus("Save failed: " + (e && e.message || e), "err");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const b of document.querySelectorAll("button.mode")) {
|
for (const b of document.querySelectorAll("button.mode")) {
|
||||||
b.addEventListener("click", () => doCapture(b.dataset.mode));
|
b.addEventListener("click", () => doCapture(b.dataset.mode));
|
||||||
}
|
}
|
||||||
btnEdit.addEventListener("click", doEdit);
|
btnEdit.addEventListener("click", doEdit);
|
||||||
btnDiscard.addEventListener("click", () => { last = null; renderPreview(); setStatus(""); });
|
btnDiscard.addEventListener("click", () => { playDiscard(); last = null; renderPreview(); setStatus(""); });
|
||||||
|
btnCopy.addEventListener("click", doCopy);
|
||||||
|
btnSave.addEventListener("click", doSave);
|
||||||
btnMax.addEventListener("click", toggleMax);
|
btnMax.addEventListener("click", toggleMax);
|
||||||
clearBtn.addEventListener("click", async () => {
|
|
||||||
if (!confirm("Delete every past capture?")) return;
|
// Inline confirmation for "clear all" — the native confirm() looks like an
|
||||||
try { await window.silentmode.invoke("clearRecent", {}); await refreshRecent(); }
|
// OS-modal from the last decade AND opens over the tab area (out of the
|
||||||
|
// sidebar's visual context). Toggle a styled strip inside the sidebar so
|
||||||
|
// the destructive action stays where the user is looking.
|
||||||
|
const confirmBox = $("clear-confirm");
|
||||||
|
clearBtn.addEventListener("click", () => {
|
||||||
|
confirmBox.classList.add("on");
|
||||||
|
clearBtn.disabled = true;
|
||||||
|
});
|
||||||
|
$("clear-no").addEventListener("click", () => {
|
||||||
|
confirmBox.classList.remove("on");
|
||||||
|
clearBtn.disabled = false;
|
||||||
|
});
|
||||||
|
$("clear-yes").addEventListener("click", async () => {
|
||||||
|
confirmBox.classList.remove("on");
|
||||||
|
clearBtn.disabled = false;
|
||||||
|
try { await window.silentmode.invoke("clearRecent", {}); await refreshRecent(); setStatus("Cleared past captures.", "ok"); }
|
||||||
catch (e) { setStatus("Clear failed: " + (e && e.message || e), "err"); }
|
catch (e) { setStatus("Clear failed: " + (e && e.message || e), "err"); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue