feat(base64-decoder): add base64 to file decoder web tool

Single-file browser tool that decodes a base64 string and saves the
result back out as a file. No server, no dependencies, nothing uploaded.

Input cleanup covers data URIs, URL-safe alphabet, missing padding,
line wrapping, percent-encoding and wrapping quotes. Type detection
runs magic bytes (~30 signatures), then the data URI's declared MIME,
then UTF-8 content sniffing for SVG/HTML/XML/JSON/PEM; unrecognised
payloads fall back to .bin with a hex dump. Images, audio, video, PDF
and text are previewed inline before download.
This commit is contained in:
2026-07-27 20:55:19 +01:00
parent fa0170daa9
commit 3ee7d7a3ac
3 changed files with 640 additions and 0 deletions
+8
View File
@@ -4,6 +4,14 @@ A collection of utility tools for development and testing.
## Tools ## Tools
### [Base64 → File](./base64-decoder/)
A single-file web tool that decodes a base64 string and saves it back out as a real file. Handles data URIs, URL-safe base64, missing padding and line-wrapped input; detects the file type from magic bytes and previews images, audio, video, PDFs and text before you download. Runs entirely in the browser — no server, no dependencies, nothing uploaded.
```bash
xdg-open base64-decoder/base64-decoder.html
```
### [Fake Media](./fake-media/) ### [Fake Media](./fake-media/)
Generates large collections of placeholder PNG images for testing without downloading real files. Supports a terminal UI and a non-interactive CLI mode. Configurable image sizes, folder structure, file counts, and naming prefixes — uses only Python's standard library. Generates large collections of placeholder PNG images for testing without downloading real files. Supports a terminal UI and a non-interactive CLI mode. Configurable image sizes, folder structure, file counts, and naming prefixes — uses only Python's standard library.
+60
View File
@@ -0,0 +1,60 @@
# Base64 → File
A single-file web tool that decodes a base64 string and saves the result back out as a real file. Useful for pulling attachments out of API responses, log dumps, JSON payloads, config blobs and `data:` URIs without hunting for a working online decoder — and without pasting anything sensitive into someone else's website.
## What it does
Open `base64-decoder.html` in any browser, paste the base64, and the decoded file appears on the right — type detected, previewed, ready to download.
Everything runs locally in the page. There is no server, no build step, no dependencies, and nothing is ever uploaded.
```bash
xdg-open base64-decoder/base64-decoder.html
```
## Input handling
The input is cleaned up before decoding, so most real-world paste jobs work as-is:
| Input | Handled |
|---|---|
| `data:image/png;base64,iVBORw0...` | Prefix stripped, MIME type read from the header |
| Line-wrapped / indented base64 | All whitespace removed |
| URL-safe base64 (`-` and `_`) | Converted to standard `+` and `/` |
| Missing `=` padding | Restored |
| Percent-encoded (`%2B`) | URL-decoded first |
| Wrapping quotes from a JSON value | Trimmed |
Invalid input gives a specific reason — the offending character, or a truncated length — rather than a generic failure.
## Type detection
The file type is worked out in three passes, in order of reliability:
1. **Magic bytes** — ~30 signatures covering PNG, JPEG, GIF, WEBP, BMP, ICO, PDF, ZIP, GZIP, BZ2, XZ, 7z, RAR, TAR, MP3, MP4, MKV, OGG, FLAC, WAV, AVI, SQLite, TTF/OTF/WOFF/WOFF2, ELF, EXE and Java class files
2. **Data URI header** — if the input was a `data:` URI, its declared MIME type supplies the extension
3. **Content sniffing** — payloads that decode as valid UTF-8 are checked for SVG, HTML, XML, JSON, PEM and shell scripts
Anything unrecognised falls back to `application/octet-stream` and a `.bin` extension. The suggested filename is always editable before saving.
## Preview
| Decoded type | Preview |
|---|---|
| Images | Rendered inline |
| Audio / video | Playable with controls |
| PDF | Embedded viewer |
| Text-based | Syntax-free text view, plus a **Copy text** button |
| Anything else | Hex dump of the first 192 bytes with an ASCII column |
## Shortcuts
- **Drag and drop** a `.txt` / `.b64` file onto the input to load its contents
- **Ctrl/Cmd + S** downloads the decoded file
- **Enter** in the filename field downloads immediately
## Requirements
Any modern browser. Works straight from `file://` — no web server needed.
The **Paste** button uses the clipboard API, which some browsers restrict on `file://` pages; if it does nothing, use Ctrl+V in the input instead.
+572
View File
@@ -0,0 +1,572 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Base64 &rarr; File</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
font-family:"Segoe UI",Calibri,Arial,sans-serif;
background:#0f1316;color:#c8d8ea;display:flex;flex-direction:column;overflow:hidden
}
/* ── Header ── */
.header{
flex-shrink:0;height:54px;display:flex;align-items:center;gap:14px;
padding:0 22px;
background:linear-gradient(135deg,#14191b 0%,#0e1e28 60%,#0b2020 100%);
border-bottom:2px solid transparent;
border-image:linear-gradient(to right,#3eafff,#2acc94) 1;
}
.brand-name{font-size:18px;font-weight:800;letter-spacing:.3px;color:#fff}
.brand-name em{
font-style:normal;
background:linear-gradient(90deg,#3eafff,#2acc94);
-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text
}
.brand-pipe{color:#2a3a3a;font-size:13px}
.brand-sub{font-size:12px;color:#4a6878}
.header-right{margin-left:auto;display:flex;align-items:center;gap:10px}
.stat-chip{
background:rgba(62,175,255,.08);border:1px solid rgba(62,175,255,.18);
border-radius:20px;padding:3px 12px;font-size:11px;color:#5a8898;white-space:nowrap
}
.stat-chip strong{color:#2acc94}
/* ── Layout ── */
.layout{display:flex;flex:1;min-height:0}
.pane{display:flex;flex-direction:column;min-width:0;min-height:0}
.pane-in{flex:1 1 50%;border-right:1px solid #1a2428}
.pane-out{flex:1 1 50%;background:#0c1014}
.pane-head{
flex-shrink:0;display:flex;align-items:center;gap:10px;
padding:9px 16px;border-bottom:1px solid #1a2428;background:#0c1014
}
.pane-out .pane-head{background:#0a0e11}
.pane-title{
font-size:9px;font-weight:700;letter-spacing:2px;
text-transform:uppercase;color:#2a4048
}
.pane-tools{margin-left:auto;display:flex;align-items:center;gap:6px}
/* ── Input ── */
.drop{position:relative;flex:1;display:flex;min-height:0}
textarea{
flex:1;resize:none;border:0;outline:0;padding:14px 16px;
background:#0f1316;color:#a8c4d8;
font-family:"Cascadia Mono",Consolas,"Courier New",monospace;
font-size:12px;line-height:1.55;word-break:break-all
}
textarea::placeholder{color:#2f4450}
.drop.over::after{
content:"Drop file to load its contents";
position:absolute;inset:8px;border:2px dashed #3eafff;border-radius:8px;
background:rgba(62,175,255,.07);display:flex;align-items:center;justify-content:center;
font-size:13px;color:#3eafff;pointer-events:none
}
/* ── Buttons ── */
button{
font-family:inherit;font-size:11px;cursor:pointer;
background:#141e24;color:#6a8898;border:1px solid #1e2c34;
border-radius:5px;padding:5px 12px;transition:background .1s,color .1s,border-color .1s
}
button:hover:not(:disabled){background:#18242c;color:#a0c0d0;border-color:#2a3c46}
button:disabled{opacity:.35;cursor:not-allowed}
button.primary{
background:linear-gradient(90deg,#3eafff,#2acc94);color:#08191f;
border:0;font-weight:700;padding:7px 18px
}
button.primary:hover:not(:disabled){filter:brightness(1.12)}
/* ── Output ── */
.out-body{flex:1;overflow-y:auto;min-height:0;padding:16px}
.empty{
height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;
gap:8px;color:#2f4450;font-size:13px;text-align:center;padding:20px
}
.empty .big{font-size:30px;opacity:.5}
.card{
background:#0f151a;border:1px solid #1a2428;border-radius:8px;
padding:14px 16px;margin-bottom:14px
}
.card.err{border-color:#5a2430;background:#1a1013}
.card.err .card-label{color:#e06a7a}
.card-label{
font-size:9px;font-weight:700;letter-spacing:2px;text-transform:uppercase;
color:#2a4048;margin-bottom:10px
}
.err-msg{font-size:13px;color:#e8a0aa;line-height:1.5}
.err-hint{font-size:11px;color:#8a5a64;margin-top:8px;line-height:1.5}
.meta{display:grid;grid-template-columns:auto 1fr;gap:7px 16px;font-size:12px}
.meta dt{color:#4a6878;white-space:nowrap}
.meta dd{color:#c8d8ea;word-break:break-all}
.meta dd code{
font-family:"Cascadia Mono",Consolas,monospace;font-size:11px;
color:#2acc94;background:#0a1418;border-radius:3px;padding:1px 5px
}
.save-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
input[type=text]{
flex:1;min-width:150px;font-family:"Cascadia Mono",Consolas,monospace;font-size:12px;
background:#0a0e11;color:#c8d8ea;border:1px solid #1e2c34;border-radius:5px;
padding:7px 10px;outline:0
}
input[type=text]:focus{border-color:#3eafff}
.preview img,.preview video{
max-width:100%;max-height:340px;display:block;margin:0 auto;
border-radius:5px;background:#0a0e11
}
.preview audio{width:100%}
.preview iframe{width:100%;height:420px;border:0;border-radius:5px;background:#fff}
.preview pre{
max-height:340px;overflow:auto;
font-family:"Cascadia Mono",Consolas,monospace;font-size:11.5px;line-height:1.55;
color:#a8c4d8;white-space:pre-wrap;word-break:break-word
}
.hex{
font-family:"Cascadia Mono",Consolas,monospace;font-size:11.5px;line-height:1.6;
color:#5a8898;white-space:pre;overflow-x:auto
}
.hex b{color:#2acc94;font-weight:400}
.hex i{color:#2a4048;font-style:normal}
@media (max-width:820px){
body{overflow:auto}
.layout{flex-direction:column}
.pane-in{border-right:0;border-bottom:1px solid #1a2428}
.pane-in .drop{min-height:200px}
}
</style>
</head>
<body>
<div class="header">
<span class="brand-name">Base<em>64</em> &rarr; File</span>
<span class="brand-pipe">|</span>
<span class="brand-sub">decode base64 and save it back out as a file</span>
<div class="header-right">
<span class="stat-chip">In <strong id="statIn">0</strong></span>
<span class="stat-chip">Out <strong id="statOut">0 B</strong></span>
</div>
</div>
<div class="layout">
<div class="pane pane-in">
<div class="pane-head">
<span class="pane-title">Base64 input</span>
<div class="pane-tools">
<button id="btnPaste">Paste</button>
<button id="btnLoad">Load file</button>
<button id="btnClear">Clear</button>
</div>
</div>
<div class="drop" id="drop">
<textarea id="input" spellcheck="false" autocomplete="off"
placeholder="Paste base64 here.&#10;&#10;Data URIs (data:image/png;base64,...), URL-safe base64, missing padding and&#10;wrapped lines are all handled automatically."></textarea>
</div>
</div>
<div class="pane pane-out">
<div class="pane-head">
<span class="pane-title">Decoded file</span>
<div class="pane-tools">
<button id="btnCopy" disabled>Copy text</button>
<button class="primary" id="btnSave" disabled>Download</button>
</div>
</div>
<div class="out-body" id="out">
<div class="empty">
<div class="big">&#8681;</div>
<div>Paste or drop some base64 to get started</div>
</div>
</div>
</div>
</div>
<input type="file" id="filePicker" hidden>
<script>
(function(){
"use strict";
var input = document.getElementById('input');
var out = document.getElementById('out');
var drop = document.getElementById('drop');
var filePicker = document.getElementById('filePicker');
var statIn = document.getElementById('statIn');
var statOut = document.getElementById('statOut');
var btnSave = document.getElementById('btnSave');
var btnCopy = document.getElementById('btnCopy');
var state = { bytes:null, name:'', mime:'', text:null, url:null };
/* ── Magic-byte signatures ──────────────────────────────────────────
Checked in order; first match wins. `at` is the byte offset of the
signature, `sig` is a byte array (null = wildcard for that byte). */
var SIGS = [
{ ext:'png', mime:'image/png', at:0, sig:[0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A] },
{ ext:'jpg', mime:'image/jpeg', at:0, sig:[0xFF,0xD8,0xFF] },
{ ext:'gif', mime:'image/gif', at:0, sig:[0x47,0x49,0x46,0x38] },
{ ext:'webp', mime:'image/webp', at:8, sig:[0x57,0x45,0x42,0x50] },
{ ext:'bmp', mime:'image/bmp', at:0, sig:[0x42,0x4D] },
{ ext:'ico', mime:'image/x-icon', at:0, sig:[0x00,0x00,0x01,0x00] },
{ ext:'pdf', mime:'application/pdf', at:0, sig:[0x25,0x50,0x44,0x46] },
{ ext:'wav', mime:'audio/wav', at:8, sig:[0x57,0x41,0x56,0x45] },
{ ext:'avi', mime:'video/x-msvideo', at:8, sig:[0x41,0x56,0x49,0x20] },
{ ext:'mp4', mime:'video/mp4', at:4, sig:[0x66,0x74,0x79,0x70] },
{ ext:'mkv', mime:'video/x-matroska', at:0, sig:[0x1A,0x45,0xDF,0xA3] },
{ ext:'ogg', mime:'audio/ogg', at:0, sig:[0x4F,0x67,0x67,0x53] },
{ ext:'flac', mime:'audio/flac', at:0, sig:[0x66,0x4C,0x61,0x43] },
{ ext:'mp3', mime:'audio/mpeg', at:0, sig:[0x49,0x44,0x33] },
{ ext:'mp3', mime:'audio/mpeg', at:0, sig:[0xFF,0xFB] },
{ ext:'zip', mime:'application/zip', at:0, sig:[0x50,0x4B,0x03,0x04] },
{ ext:'gz', mime:'application/gzip', at:0, sig:[0x1F,0x8B] },
{ ext:'bz2', mime:'application/x-bzip2', at:0, sig:[0x42,0x5A,0x68] },
{ ext:'xz', mime:'application/x-xz', at:0, sig:[0xFD,0x37,0x7A,0x58,0x5A,0x00] },
{ ext:'7z', mime:'application/x-7z-compressed', at:0, sig:[0x37,0x7A,0xBC,0xAF,0x27,0x1C] },
{ ext:'rar', mime:'application/vnd.rar', at:0, sig:[0x52,0x61,0x72,0x21,0x1A,0x07] },
{ ext:'tar', mime:'application/x-tar', at:257, sig:[0x75,0x73,0x74,0x61,0x72] },
{ ext:'sqlite', mime:'application/vnd.sqlite3', at:0,
sig:[0x53,0x51,0x4C,0x69,0x74,0x65,0x20,0x66,0x6F,0x72,0x6D,0x61,0x74,0x20,0x33,0x00] },
{ ext:'ttf', mime:'font/ttf', at:0, sig:[0x00,0x01,0x00,0x00,0x00] },
{ ext:'otf', mime:'font/otf', at:0, sig:[0x4F,0x54,0x54,0x4F] },
{ ext:'woff', mime:'font/woff', at:0, sig:[0x77,0x4F,0x46,0x46] },
{ ext:'woff2',mime:'font/woff2', at:0, sig:[0x77,0x4F,0x46,0x32] },
{ ext:'exe', mime:'application/vnd.microsoft.portable-executable', at:0, sig:[0x4D,0x5A] },
{ ext:'elf', mime:'application/x-elf', at:0, sig:[0x7F,0x45,0x4C,0x46] },
{ ext:'class',mime:'application/java-vm', at:0, sig:[0xCA,0xFE,0xBA,0xBE] }
];
function matches(bytes, s){
if (bytes.length < s.at + s.sig.length) return false;
for (var i = 0; i < s.sig.length; i++){
if (s.sig[i] !== null && bytes[s.at + i] !== s.sig[i]) return false;
}
return true;
}
function sniff(bytes){
for (var i = 0; i < SIGS.length; i++){
if (matches(bytes, SIGS[i])) return { ext:SIGS[i].ext, mime:SIGS[i].mime, sure:true };
}
return null;
}
/* Text-based formats have no magic bytes, so classify by content once
we know the payload decodes as valid UTF-8. */
function sniffText(txt){
var t = txt.replace(/^/, '').trimStart();
var head = t.slice(0, 400).toLowerCase();
if (/^<svg[\s>]/.test(head) || (head.indexOf('<?xml') === 0 && head.indexOf('<svg') > -1))
return { ext:'svg', mime:'image/svg+xml' };
if (head.indexOf('<!doctype html') === 0 || head.indexOf('<html') === 0)
return { ext:'html', mime:'text/html' };
if (head.indexOf('<?xml') === 0)
return { ext:'xml', mime:'application/xml' };
if (/^[{\[]/.test(t)){
try { JSON.parse(t); return { ext:'json', mime:'application/json' }; } catch(e){}
}
if (head.indexOf('-----begin ') === 0)
return { ext:'pem', mime:'application/x-pem-file' };
if (head.indexOf('#!') === 0)
return { ext:'sh', mime:'text/x-shellscript' };
if (/^%pdf/.test(head))
return { ext:'pdf', mime:'application/pdf' };
return { ext:'txt', mime:'text/plain' };
}
/* Last resort for a data URI whose payload has no magic bytes — the
declared MIME type usually names the extension we want. */
var MIME_EXT = {
'plain':'txt', 'jpeg':'jpg', 'javascript':'js', 'markdown':'md',
'octet-stream':'bin', 'msword':'doc', 'mpeg':'mp3', 'quicktime':'mov'
};
function extFromMime(mime){
var sub = (mime.split('/')[1] || '').split(';')[0].split('+')[0].trim().toLowerCase();
sub = sub.replace(/^(x-|vnd\.)/, '');
if (MIME_EXT[sub]) return MIME_EXT[sub];
return /^[a-z0-9]{1,8}$/.test(sub) ? sub : null;
}
function asUtf8(bytes){
try { return new TextDecoder('utf-8', {fatal:true}).decode(bytes); }
catch(e){ return null; }
}
function humanSize(n){
if (n < 1024) return n + ' B';
var u = ['KB','MB','GB'], i = -1, v = n;
do { v /= 1024; i++; } while (v >= 1024 && i < u.length - 1);
return v.toFixed(v < 10 ? 1 : 0) + ' ' + u[i];
}
function esc(s){
return String(s).replace(/[&<>"]/g, function(c){
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];
});
}
/* ── Decode ──────────────────────────────────────────────────────── */
// Strips a data URI prefix, whitespace and quotes; converts URL-safe
// base64 to standard and restores missing '=' padding.
function normalise(raw){
var mime = '';
var s = raw.trim().replace(/^["'`]|["'`]$/g, '');
var m = /^data:([^;,]*)(;[^,]*)?,/i.exec(s);
if (m){
if (!/;base64/i.test(m[2] || '')) throw new Error('That data URI is not base64-encoded.');
mime = m[1] || '';
s = s.slice(m[0].length);
}
s = s.replace(/\s+/g, '');
if (/%[0-9a-f]{2}/i.test(s)){
try { s = decodeURIComponent(s); } catch(e){}
}
s = s.replace(/-/g, '+').replace(/_/g, '/');
s = s.replace(/=+$/, '');
var bad = s.match(/[^A-Za-z0-9+/]/);
if (bad) throw new Error('Not valid base64 — unexpected character "' + bad[0] + '".');
if (s.length % 4 === 1) throw new Error('Not valid base64 — the input length is truncated.');
while (s.length % 4) s += '=';
return { data:s, mime:mime };
}
function toBytes(b64){
var bin = atob(b64);
var bytes = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes;
}
function hexDump(bytes, rows){
var lines = [], limit = Math.min(bytes.length, rows * 16);
for (var o = 0; o < limit; o += 16){
var hex = '', txt = '';
for (var i = 0; i < 16; i++){
if (o + i < limit){
var b = bytes[o + i];
hex += (b < 16 ? '0' : '') + b.toString(16) + ' ';
txt += (b >= 32 && b < 127) ? esc(String.fromCharCode(b)) : '.';
} else {
hex += ' '; txt += ' ';
}
if (i === 7) hex += ' ';
}
lines.push('<i>' + ('0000000' + o.toString(16)).slice(-8) + '</i> ' + hex + ' <b>' + txt + '</b>');
}
if (bytes.length > limit) lines.push('<i>' + humanSize(bytes.length - limit) + ' more…</i>');
return lines.join('\n');
}
function reset(){
if (state.url) URL.revokeObjectURL(state.url);
state = { bytes:null, name:'', mime:'', text:null, url:null };
btnSave.disabled = true;
btnCopy.disabled = true;
statOut.textContent = '0 B';
}
function showEmpty(){
reset();
out.innerHTML = '<div class="empty"><div class="big">&#8681;</div>' +
'<div>Paste or drop some base64 to get started</div></div>';
}
function showError(msg, hint){
reset();
out.innerHTML = '<div class="card err"><div class="card-label">Could not decode</div>' +
'<div class="err-msg">' + esc(msg) + '</div>' +
(hint ? '<div class="err-hint">' + esc(hint) + '</div>' : '') + '</div>';
}
function decode(){
var raw = input.value;
statIn.textContent = raw.replace(/\s+/g, '').length.toLocaleString();
if (!raw.trim()){ showEmpty(); return; }
var norm, bytes;
try {
norm = normalise(raw);
bytes = toBytes(norm.data);
} catch(e){
showError(e.message, 'Check that the whole string was copied and that nothing ' +
'was truncated or line-wrapped with stray characters.');
return;
}
if (!bytes.length){ showError('The input decoded to zero bytes.'); return; }
var text = asUtf8(bytes);
var hit = sniff(bytes);
var kind;
if (hit) kind = hit;
else if (text !== null) kind = sniffText(text);
else kind = { ext:'bin', mime:'application/octet-stream' };
var mime = norm.mime || kind.mime;
if (!hit && norm.mime) kind.ext = extFromMime(norm.mime) || kind.ext;
reset();
state.bytes = bytes;
state.mime = mime;
state.text = (kind.ext === 'txt' || /^text\/|json|xml|svg|javascript|pem|shellscript/.test(kind.mime)) ? text : null;
state.name = 'decoded.' + kind.ext;
state.url = URL.createObjectURL(new Blob([bytes], {type:mime}));
statOut.textContent = humanSize(bytes.length);
btnSave.disabled = false;
btnCopy.disabled = state.text === null;
render(bytes, mime, kind, hit, norm.mime);
}
function render(bytes, mime, kind, hit, uriMime){
var detected = hit ? 'magic bytes'
: uriMime ? 'data URI header'
: kind.ext === 'bin' ? 'unrecognised — treated as binary'
: 'content sniffing';
var html =
'<div class="card"><div class="card-label">Save as</div>' +
'<div class="save-row">' +
'<input type="text" id="nameField" value="' + esc(state.name) + '" spellcheck="false">' +
'<button class="primary" id="btnSave2">Download</button>' +
'</div>' +
'</div>' +
'<div class="card"><div class="card-label">File</div>' +
'<dl class="meta">' +
'<dt>Type</dt><dd><code>' + esc(mime) + '</code></dd>' +
'<dt>Size</dt><dd>' + humanSize(bytes.length) +
' <span style="color:#4a6878">(' + bytes.length.toLocaleString() + ' bytes)</span></dd>' +
'<dt>Detected via</dt><dd style="color:#4a6878">' + detected + '</dd>' +
'</dl>' +
'</div>';
var p = '';
if (/^image\//.test(mime)){
p = '<img src="' + state.url + '" alt="decoded image">';
} else if (/^video\//.test(mime)){
p = '<video src="' + state.url + '" controls></video>';
} else if (/^audio\//.test(mime)){
p = '<audio src="' + state.url + '" controls></audio>';
} else if (mime === 'application/pdf'){
p = '<iframe src="' + state.url + '"></iframe>';
} else if (state.text !== null){
p = '<pre>' + esc(state.text.slice(0, 20000)) +
(state.text.length > 20000 ? '\n…' : '') + '</pre>';
} else {
p = '<div class="hex">' + hexDump(bytes, 12) + '</div>';
}
html += '<div class="card preview"><div class="card-label">Preview</div>' + p + '</div>';
out.innerHTML = html;
var nameField = document.getElementById('nameField');
nameField.addEventListener('input', function(){ state.name = nameField.value; });
nameField.addEventListener('keydown', function(e){ if (e.key === 'Enter') save(); });
document.getElementById('btnSave2').addEventListener('click', save);
}
function save(){
if (!state.bytes) return;
var a = document.createElement('a');
a.href = state.url;
a.download = (state.name || 'decoded.bin').trim() || 'decoded.bin';
document.body.appendChild(a);
a.click();
a.remove();
}
/* ── Events ──────────────────────────────────────────────────────── */
var timer;
input.addEventListener('input', function(){
clearTimeout(timer);
timer = setTimeout(decode, 120);
});
btnSave.addEventListener('click', save);
btnCopy.addEventListener('click', function(){
if (state.text === null) return;
navigator.clipboard.writeText(state.text).then(function(){
btnCopy.textContent = 'Copied';
setTimeout(function(){ btnCopy.textContent = 'Copy text'; }, 1200);
});
});
document.getElementById('btnClear').addEventListener('click', function(){
input.value = '';
input.focus();
decode();
});
document.getElementById('btnPaste').addEventListener('click', function(){
navigator.clipboard.readText().then(function(t){
input.value = t;
decode();
}).catch(function(){
input.focus();
});
});
document.getElementById('btnLoad').addEventListener('click', function(){
filePicker.click();
});
filePicker.addEventListener('change', function(){
if (filePicker.files[0]) readAsText(filePicker.files[0]);
filePicker.value = '';
});
function readAsText(file){
var r = new FileReader();
r.onload = function(){ input.value = r.result; decode(); };
r.readAsText(file);
}
['dragenter','dragover'].forEach(function(ev){
drop.addEventListener(ev, function(e){
e.preventDefault();
drop.classList.add('over');
});
});
['dragleave','drop'].forEach(function(ev){
drop.addEventListener(ev, function(e){
e.preventDefault();
if (ev === 'dragleave' && drop.contains(e.relatedTarget)) return;
drop.classList.remove('over');
});
});
drop.addEventListener('drop', function(e){
var f = e.dataTransfer.files[0];
if (f) readAsText(f);
});
window.addEventListener('keydown', function(e){
if ((e.ctrlKey || e.metaKey) && e.key === 's' && state.bytes){
e.preventDefault();
save();
}
});
input.focus();
})();
</script>
</body>
</html>