Files
Fichero/web/index.html

952 lines
25 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fichero D11s Label Printer</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #1a1a2e;
--surface: #16213e;
--surface2: #0f3460;
--accent: #e94560;
--text: #eee;
--text2: #aab;
--ok: #4ade80;
--warn: #fbbf24;
--err: #f87171;
--radius: 8px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
}
.container {
width: 100%;
max-width: 560px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
/* Top bar */
.topbar {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--surface);
border-radius: var(--radius);
}
.topbar h1 {
font-size: 16px;
font-weight: 600;
flex: 1;
}
.status-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--err);
}
.status-dot.connected { background: var(--ok); }
.battery {
font-size: 13px;
color: var(--text2);
display: none;
}
.battery.visible { display: inline; }
button {
background: var(--accent);
color: #fff;
border: none;
padding: 8px 16px;
border-radius: var(--radius);
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: opacity .15s;
}
button:hover { opacity: .85; }
button:disabled { opacity: .4; cursor: not-allowed; }
.btn-sm { padding: 6px 12px; font-size: 13px; }
.btn-outline {
background: transparent;
border: 1px solid var(--accent);
color: var(--accent);
}
/* Tabs */
.tabs {
display: flex;
gap: 0;
background: var(--surface);
border-radius: var(--radius);
overflow: hidden;
}
.tab-btn {
flex: 1;
background: transparent;
color: var(--text2);
border-radius: 0;
padding: 10px;
font-size: 14px;
}
.tab-btn.active {
background: var(--surface2);
color: var(--text);
}
.tab-panel { display: none; }
.tab-panel.active { display: flex; flex-direction: column; gap: 12px; }
/* Main panels */
.panel {
background: var(--surface);
border-radius: var(--radius);
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.panel h2 {
font-size: 14px;
font-weight: 600;
color: var(--text2);
text-transform: uppercase;
letter-spacing: .5px;
}
textarea {
width: 100%;
background: var(--bg);
color: var(--text);
border: 1px solid var(--surface2);
border-radius: var(--radius);
padding: 10px;
font-size: 16px;
resize: vertical;
min-height: 60px;
font-family: inherit;
}
textarea:focus { outline: 1px solid var(--accent); border-color: var(--accent); }
input[type="range"] {
width: 100%;
accent-color: var(--accent);
}
.range-row {
display: flex;
align-items: center;
gap: 10px;
}
.range-row label { font-size: 13px; color: var(--text2); min-width: 80px; }
.range-row span { font-size: 13px; color: var(--text); min-width: 32px; text-align: right; }
/* Preview */
.preview-wrap {
display: flex;
justify-content: center;
padding: 12px;
background: #fff;
border-radius: var(--radius);
min-height: 60px;
}
.preview-wrap canvas {
image-rendering: pixelated;
}
/* Image drop zone */
.dropzone {
border: 2px dashed var(--surface2);
border-radius: var(--radius);
padding: 32px 16px;
text-align: center;
color: var(--text2);
font-size: 14px;
cursor: pointer;
transition: border-color .2s, background .2s;
}
.dropzone.dragover {
border-color: var(--accent);
background: rgba(233, 69, 96, .08);
}
.dropzone input { display: none; }
/* Settings grid */
.settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.setting-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.setting-item label { font-size: 12px; color: var(--text2); }
.setting-item select, .setting-item input[type="number"] {
background: var(--bg);
color: var(--text);
border: 1px solid var(--surface2);
border-radius: 6px;
padding: 6px 8px;
font-size: 14px;
}
.setting-item select:focus, .setting-item input[type="number"]:focus {
outline: 1px solid var(--accent);
border-color: var(--accent);
}
/* Device info */
.info-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 12px;
font-size: 13px;
}
.info-grid .label { color: var(--text2); }
.info-grid .value { color: var(--text); font-family: "SF Mono", "Menlo", monospace; font-size: 12px; }
/* Log */
.log {
font-family: "SF Mono", "Menlo", monospace;
font-size: 11px;
color: var(--text2);
max-height: 120px;
overflow-y: auto;
background: var(--bg);
border-radius: 6px;
padding: 8px;
line-height: 1.6;
}
.print-actions {
display: flex;
gap: 8px;
align-items: center;
}
.print-actions button { flex: 1; }
.print-actions .copies-input {
display: flex;
align-items: center;
gap: 4px;
}
.print-actions .copies-input label { font-size: 12px; color: var(--text2); }
.print-actions .copies-input input {
width: 50px;
background: var(--bg);
color: var(--text);
border: 1px solid var(--surface2);
border-radius: 6px;
padding: 6px;
font-size: 13px;
text-align: center;
}
</style>
</head>
<body>
<div class="container">
<!-- Top bar -->
<div class="topbar">
<h1>Fichero D11s</h1>
<span class="battery" id="battery"></span>
<span class="status-dot" id="statusDot"></span>
<button id="connectBtn" onclick="toggleConnect()">Connect</button>
</div>
<!-- Tabs -->
<div class="tabs">
<button class="tab-btn active" data-tab="text" onclick="switchTab('text')">Text</button>
<button class="tab-btn" data-tab="image" onclick="switchTab('image')">Image</button>
</div>
<!-- Text tab -->
<div class="tab-panel active" id="tab-text">
<div class="panel">
<textarea id="textInput" placeholder="Type your label text..." rows="2">Hello</textarea>
<div class="range-row">
<label>Font size</label>
<input type="range" id="fontSize" min="10" max="72" value="30">
<span id="fontSizeVal">30</span>
</div>
<div class="preview-wrap">
<canvas id="textPreview"></canvas>
</div>
<div class="print-actions">
<button id="textPrintBtn" onclick="printText()" disabled>Print</button>
<div class="copies-input">
<label>x</label>
<input type="number" id="textCopies" value="1" min="1" max="99">
</div>
</div>
</div>
</div>
<!-- Image tab -->
<div class="tab-panel" id="tab-image">
<div class="panel">
<div class="dropzone" id="dropzone">
<div>Drop an image here, or click to browse</div>
<input type="file" id="fileInput" accept="image/*">
</div>
<label style="font-size:13px;color:var(--text2);display:flex;align-items:center;gap:6px">
<input type="checkbox" id="imgDither" checked> Floyd-Steinberg dithering
</label>
<div class="preview-wrap" id="imgPreviewWrap" style="display:none">
<canvas id="imgPreview"></canvas>
</div>
<div class="print-actions">
<button id="imgPrintBtn" onclick="printImage()" disabled>Print</button>
<div class="copies-input">
<label>x</label>
<input type="number" id="imgCopies" value="1" min="1" max="99">
</div>
</div>
</div>
</div>
<!-- Settings -->
<div class="panel">
<h2>Settings</h2>
<div class="settings-grid">
<div class="setting-item">
<label>Density</label>
<select id="density">
<option value="0">Light</option>
<option value="1">Medium</option>
<option value="2" selected>Thick</option>
</select>
</div>
<div class="setting-item">
<label>Paper type</label>
<select id="paperType">
<option value="0" selected>Gap / label</option>
<option value="1">Black mark</option>
<option value="2">Continuous</option>
</select>
</div>
<div class="setting-item">
<label>Label length (mm)</label>
<select id="labelLength">
<option value="30" selected>30 mm</option>
<option value="40">40 mm</option>
<option value="50">50 mm</option>
</select>
</div>
<div class="setting-item">
<label>Shutdown (min)</label>
<input type="number" id="shutdownTime" value="20" min="1" max="999">
</div>
<div class="setting-item">
<button class="btn-sm btn-outline" onclick="applySettings()" disabled id="applyBtn">Apply</button>
</div>
</div>
</div>
<!-- Device info -->
<div class="panel" id="devicePanel" style="display:none">
<h2>Device</h2>
<div class="info-grid" id="infoGrid"></div>
</div>
<!-- Log -->
<div class="panel">
<h2>Log</h2>
<div class="log" id="log"></div>
</div>
</div>
<script>
// ---- Constants ----
const SERVICE_UUID = '000018f0-0000-1000-8000-00805f9b34fb';
const WRITE_UUID = '00002af1-0000-1000-8000-00805f9b34fb';
const NOTIFY_UUID = '00002af0-0000-1000-8000-00805f9b34fb';
const PRINTHEAD_PX = 96;
const BYTES_PER_ROW = 12;
const CHUNK_SIZE = 200;
const CHUNK_DELAY_MS = 20;
// ---- State ----
let device = null;
let server = null;
let writeChar = null;
let notifyChar = null;
let notifyBuf = [];
let notifyResolve = null;
let connected = false;
let uploadedCanvas = null; // prepared 1-bit canvas from image upload
let lastUploadedImg = null; // original Image element for re-processing
// ---- DOM refs ----
const $ = id => document.getElementById(id);
const logEl = $('log');
function log(msg) {
const t = new Date().toLocaleTimeString('en-GB', { hour12: false });
logEl.textContent += `[${t}] ${msg}\n`;
logEl.scrollTop = logEl.scrollHeight;
}
// ---- BLE layer ----
function onNotify(event) {
const val = new Uint8Array(event.target.value.buffer);
notifyBuf.push(...val);
if (notifyResolve) {
notifyResolve();
notifyResolve = null;
}
}
function waitForNotify(timeout = 2000) {
return new Promise(resolve => {
const timer = setTimeout(() => {
notifyResolve = null;
resolve();
}, timeout);
notifyResolve = () => {
clearTimeout(timer);
resolve();
};
});
}
async function send(data, wait = false, timeout = 2000) {
if (wait) {
notifyBuf = [];
}
await writeChar.writeValueWithoutResponse(new Uint8Array(data));
if (wait) {
await waitForNotify(timeout);
await sleep(50);
}
return new Uint8Array(notifyBuf);
}
async function sendChunked(data) {
const arr = data instanceof Uint8Array ? data : new Uint8Array(data);
for (let i = 0; i < arr.length; i += CHUNK_SIZE) {
const chunk = arr.slice(i, i + CHUNK_SIZE);
await writeChar.writeValueWithoutResponse(chunk);
await sleep(CHUNK_DELAY_MS);
}
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
function decodeResponse(buf) {
return new TextDecoder().decode(new Uint8Array(buf)).trim();
}
// ---- Connect / disconnect ----
async function toggleConnect() {
if (connected) {
await doDisconnect();
} else {
await doConnect();
}
}
async function doConnect() {
log('Requesting Bluetooth device...');
try {
device = await navigator.bluetooth.requestDevice({
filters: [
{ namePrefix: 'FICHERO' },
{ namePrefix: 'D11s_' },
],
optionalServices: [SERVICE_UUID],
});
} catch (e) {
log('Cancelled or no device found.');
return;
}
device.addEventListener('gattserverdisconnected', onDisconnected);
log(`Connecting to ${device.name}...`);
try {
server = await device.gatt.connect();
const service = await server.getPrimaryService(SERVICE_UUID);
writeChar = await service.getCharacteristic(WRITE_UUID);
notifyChar = await service.getCharacteristic(NOTIFY_UUID);
await notifyChar.startNotifications();
notifyChar.addEventListener('characteristicvaluechanged', onNotify);
} catch (e) {
log(`Connection failed: ${e.message}`);
return;
}
connected = true;
updateUI();
log(`Connected to ${device.name}`);
await fetchDeviceInfo();
}
async function doDisconnect() {
if (device && device.gatt.connected) {
device.gatt.disconnect();
}
onDisconnected();
}
function onDisconnected() {
connected = false;
server = null;
writeChar = null;
notifyChar = null;
updateUI();
log('Disconnected.');
}
function updateUI() {
$('statusDot').classList.toggle('connected', connected);
$('connectBtn').textContent = connected ? 'Disconnect' : 'Connect';
$('textPrintBtn').disabled = !connected;
$('imgPrintBtn').disabled = !connected || !uploadedCanvas;
$('applyBtn').disabled = !connected;
$('devicePanel').style.display = connected ? '' : 'none';
}
// ---- Info commands ----
async function fetchDeviceInfo() {
log('Fetching device info...');
const info = {};
let r = await send([0x10, 0xFF, 0x20, 0xF0], true);
info.Model = decodeResponse(r);
r = await send([0x10, 0xFF, 0x20, 0xF1], true);
info.Firmware = decodeResponse(r);
r = await send([0x10, 0xFF, 0x20, 0xF2], true);
info.Serial = decodeResponse(r);
r = await send([0x10, 0xFF, 0x50, 0xF1], true);
if (r.length >= 2) {
const pct = r[r.length - 1];
const charging = r[r.length - 2] !== 0;
info.Battery = `${pct}%${charging ? ' (charging)' : ''}`;
$('battery').textContent = `${pct}%`;
$('battery').classList.add('visible');
}
r = await send([0x10, 0xFF, 0x40], true);
if (r.length > 0) {
info.Status = parseStatus(r[r.length - 1]);
}
r = await send([0x10, 0xFF, 0x13], true);
if (r.length >= 2) {
const mins = (r[0] << 8) | r[1];
info['Shutdown'] = `${mins} min`;
$('shutdownTime').value = mins;
}
const grid = $('infoGrid');
grid.innerHTML = '';
for (const [k, v] of Object.entries(info)) {
grid.innerHTML += `<span class="label">${k}</span><span class="value">${v}</span>`;
}
log('Device info loaded.');
}
function parseStatus(byte) {
const flags = [];
if (byte & 0x01) flags.push('printing');
if (byte & 0x02) flags.push('cover open');
if (byte & 0x04) flags.push('no paper');
if (byte & 0x08) flags.push('low battery');
if (byte & 0x10 || byte & 0x40) flags.push('overheated');
if (byte & 0x20) flags.push('charging');
return flags.length ? flags.join(', ') : 'ready';
}
// ---- Settings ----
async function applySettings() {
if (!connected) return;
const density = parseInt($('density').value);
const paper = parseInt($('paperType').value);
const shutdown = parseInt($('shutdownTime').value);
let r = await send([0x10, 0xFF, 0x10, 0x00, density], true);
log(`Set density=${density}: ${decodeResponse(r)}`);
r = await send([0x10, 0xFF, 0x84, paper], true);
log(`Set paper=${paper}: ${decodeResponse(r)}`);
const hi = (shutdown >> 8) & 0xFF;
const lo = shutdown & 0xFF;
r = await send([0x10, 0xFF, 0x12, hi, lo], true);
log(`Set shutdown=${shutdown}min: ${decodeResponse(r)}`);
}
// ---- Image processing ----
function getLabelHeight() {
const mm = parseInt($('labelLength').value) || 30;
return Math.round(mm * 8); // 203 DPI = 8 dots/mm
}
function textToCanvas(text, fontSize) {
// Render text in landscape orientation, then rotate 90 CW for label
const landscapeW = getLabelHeight();
const landscapeH = PRINTHEAD_PX;
const tmp = document.createElement('canvas');
tmp.width = landscapeW;
tmp.height = landscapeH;
const ctx = tmp.getContext('2d');
// White background
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, landscapeW, landscapeH);
// Render text centered
ctx.fillStyle = '#000';
ctx.font = `${fontSize}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const centerX = landscapeW / 2;
const lines = text.split('\n');
const lineHeight = fontSize * 1.2;
const totalH = lines.length * lineHeight;
const startY = (landscapeH - totalH) / 2 + lineHeight / 2;
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i], centerX, startY + i * lineHeight);
}
// Rotate 90 CW
const out = document.createElement('canvas');
out.width = PRINTHEAD_PX;
out.height = landscapeW;
const octx = out.getContext('2d');
octx.translate(PRINTHEAD_PX, 0);
octx.rotate(Math.PI / 2);
octx.drawImage(tmp, 0, 0);
return threshold(out);
}
function prepareImageCanvas(img) {
// Resize to 96px wide, proportional height, cap to label length
const scale = PRINTHEAD_PX / img.width;
const maxH = getLabelHeight();
let newH = Math.round(img.height * scale);
if (newH > maxH) newH = maxH;
const c = document.createElement('canvas');
c.width = PRINTHEAD_PX;
c.height = newH;
const ctx = c.getContext('2d');
ctx.drawImage(img, 0, 0, PRINTHEAD_PX, newH);
const useDither = $('imgDither').checked;
return useDither ? floydSteinbergDither(c) : threshold(c);
}
function threshold(canvas) {
// Convert to 1-bit (black/white) on a new canvas
const ctx = canvas.getContext('2d');
const data = ctx.getImageData(0, 0, canvas.width, canvas.height);
const px = data.data;
for (let i = 0; i < px.length; i += 4) {
// Grayscale via luminance
const gray = 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2];
const bw = gray < 160 ? 0 : 255;
px[i] = px[i + 1] = px[i + 2] = bw;
px[i + 3] = 255;
}
ctx.putImageData(data, 0, 0);
return canvas;
}
function floydSteinbergDither(canvas) {
// Floyd-Steinberg error-diffusion dithering.
// Same algorithm as PrinterImageProcessor.ditherFloydSteinberg() in the
// decompiled Fichero APK (7/16, 3/16, 5/16, 1/16 error distribution).
const ctx = canvas.getContext('2d');
const data = ctx.getImageData(0, 0, canvas.width, canvas.height);
const px = data.data;
const w = canvas.width;
const h = canvas.height;
// Build grayscale float buffer
const gray = new Float32Array(w * h);
for (let i = 0; i < w * h; i++) {
const j = i * 4;
gray[i] = 0.299 * px[j] + 0.587 * px[j + 1] + 0.114 * px[j + 2];
}
// Dither
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const idx = y * w + x;
const old = gray[idx];
const val = old < 128 ? 0 : 255;
gray[idx] = val;
const err = old - val;
if (x + 1 < w) gray[idx + 1] += err * 7 / 16;
if (y + 1 < h) {
if (x - 1 >= 0) gray[idx + w - 1] += err * 3 / 16;
gray[idx + w] += err * 5 / 16;
if (x + 1 < w) gray[idx + w + 1] += err * 1 / 16;
}
}
}
// Write back
for (let i = 0; i < w * h; i++) {
const v = gray[i] < 128 ? 0 : 255;
const j = i * 4;
px[j] = px[j + 1] = px[j + 2] = v;
px[j + 3] = 255;
}
ctx.putImageData(data, 0, 0);
return canvas;
}
function canvasToRaster(canvas) {
// Pack pixels into MSB-first bytes. 1 = black, 0 = white.
const ctx = canvas.getContext('2d');
const data = ctx.getImageData(0, 0, canvas.width, canvas.height);
const px = data.data;
const rows = canvas.height;
const bytesPerRow = BYTES_PER_ROW;
const out = new Uint8Array(rows * bytesPerRow);
for (let y = 0; y < rows; y++) {
for (let byteIdx = 0; byteIdx < bytesPerRow; byteIdx++) {
let byte = 0;
for (let bit = 0; bit < 8; bit++) {
const x = byteIdx * 8 + bit;
if (x < canvas.width) {
const i = (y * canvas.width + x) * 4;
// Black pixel (0) -> bit = 1 (heater on)
if (px[i] === 0) {
byte |= (0x80 >> bit);
}
}
}
out[y * bytesPerRow + byteIdx] = byte;
}
}
return out;
}
// ---- Preview rendering ----
function showPreview(canvas, targetEl) {
const target = typeof targetEl === 'string' ? $(targetEl) : targetEl;
// Display scaled up for visibility (2x or 3x)
const scale = Math.max(1, Math.floor(280 / canvas.width));
target.width = canvas.width * scale;
target.height = canvas.height * scale;
const ctx = target.getContext('2d');
ctx.imageSmoothingEnabled = false;
ctx.drawImage(canvas, 0, 0, target.width, target.height);
}
function updateTextPreview() {
const text = $('textInput').value || ' ';
const size = parseInt($('fontSize').value);
$('fontSizeVal').textContent = size;
const canvas = textToCanvas(text, size);
showPreview(canvas, 'textPreview');
}
// ---- Print ----
async function doPrint(canvas, copies) {
if (!connected) { log('Not connected.'); return; }
const rows = canvas.height;
const raster = canvasToRaster(canvas);
const density = parseInt($('density').value);
const paper = parseInt($('paperType').value);
log(`Printing ${canvas.width}x${rows}, ${raster.length} bytes, ${copies} copies, density=${density}`);
// Check status
const statusResp = await send([0x10, 0xFF, 0x40], true);
if (statusResp.length > 0) {
const sb = statusResp[statusResp.length - 1];
if (sb & 0x02) { log('ERROR: Cover open'); return; }
if (sb & 0x04) { log('ERROR: No paper'); return; }
if (sb & 0x50) { log('ERROR: Overheated'); return; }
}
// Set density
await send([0x10, 0xFF, 0x10, 0x00, density], true);
await sleep(100);
for (let c = 0; c < copies; c++) {
if (copies > 1) log(`Copy ${c + 1}/${copies}...`);
// Paper type
await send([0x10, 0xFF, 0x84, paper], true);
await sleep(50);
// Wake up (12 null bytes)
await send(new Array(12).fill(0));
await sleep(50);
// Enable printer (AiYin)
await send([0x10, 0xFF, 0xFE, 0x01]);
await sleep(50);
// Raster header: GS v 0 mode xL xH yL yH
const yL = rows & 0xFF;
const yH = (rows >> 8) & 0xFF;
const header = new Uint8Array([0x1D, 0x76, 0x30, 0x00, BYTES_PER_ROW, 0x00, yL, yH]);
// Combine header + raster data
const payload = new Uint8Array(header.length + raster.length);
payload.set(header, 0);
payload.set(raster, header.length);
await sendChunked(payload);
await sleep(500);
// Form feed
await send([0x1D, 0x0C]);
await sleep(300);
// Stop print, wait for 0xAA or OK
const stopResp = await send([0x10, 0xFF, 0xFE, 0x45], true, 60000);
if (stopResp.length > 0) {
if (stopResp[0] === 0xAA || decodeResponse(stopResp) === 'OK') {
log(copies > 1 ? `Copy ${c + 1} done.` : 'Print complete.');
} else {
log(`WARNING: unexpected stop response: [${Array.from(stopResp).map(b => b.toString(16)).join(' ')}]`);
}
} else {
log('WARNING: no response from stop command.');
}
}
// Refresh battery after print
const battResp = await send([0x10, 0xFF, 0x50, 0xF1], true);
if (battResp.length >= 2) {
$('battery').textContent = `${battResp[battResp.length - 1]}%`;
}
}
async function printText() {
const text = $('textInput').value || ' ';
const size = parseInt($('fontSize').value);
const canvas = textToCanvas(text, size);
const copies = parseInt($('textCopies').value) || 1;
await doPrint(canvas, copies);
}
async function printImage() {
if (!uploadedCanvas) { log('No image loaded.'); return; }
const copies = parseInt($('imgCopies').value) || 1;
await doPrint(uploadedCanvas, copies);
}
// ---- Image upload / drop ----
function handleFile(file) {
if (!file || !file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = () => {
const img = new Image();
img.onload = () => {
lastUploadedImg = img;
reprocessImage();
log(`Image loaded: ${img.width}x${img.height} -> ${uploadedCanvas.width}x${uploadedCanvas.height}`);
};
img.src = reader.result;
};
reader.readAsDataURL(file);
}
function reprocessImage() {
if (!lastUploadedImg) return;
uploadedCanvas = prepareImageCanvas(lastUploadedImg);
showPreview(uploadedCanvas, 'imgPreview');
$('imgPreviewWrap').style.display = '';
$('imgPrintBtn').disabled = !connected;
}
// ---- Event listeners ----
$('textInput').addEventListener('input', updateTextPreview);
$('fontSize').addEventListener('input', updateTextPreview);
$('labelLength').addEventListener('change', updateTextPreview);
// File input
$('fileInput').addEventListener('change', e => {
if (e.target.files[0]) handleFile(e.target.files[0]);
});
// Re-process image when dither toggle or label length changes
$('imgDither').addEventListener('change', () => { if (lastUploadedImg) reprocessImage(); });
$('labelLength').addEventListener('change', () => { if (lastUploadedImg) reprocessImage(); });
// Dropzone click -> trigger file input
$('dropzone').addEventListener('click', () => $('fileInput').click());
// Drag and drop
$('dropzone').addEventListener('dragover', e => {
e.preventDefault();
$('dropzone').classList.add('dragover');
});
$('dropzone').addEventListener('dragleave', () => {
$('dropzone').classList.remove('dragover');
});
$('dropzone').addEventListener('drop', e => {
e.preventDefault();
$('dropzone').classList.remove('dragover');
if (e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0]);
});
// Tab switching
function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === name));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.toggle('active', p.id === `tab-${name}`));
}
// Init preview on load
updateTextPreview();
log('Ready. Click Connect to pair with your printer.');
</script>
</body>
</html>