/* ASCII Art Generator - Frontend */ const $ = id => document.getElementById(id); const dropZone = $('dropZone'); const fileInput = $('fileInput'); const fileInfo = $('fileInfo'); const thumbPreview = $('thumbPreview'); const fileName = $('fileName'); const clearFile = $('clearFile'); const generateBtn = $('generateBtn'); const randomBtn = $('randomBtn'); const previewArea = $('previewArea'); const spinner = $('spinner'); const copyBtn = $('copyBtn'); const downloadBtn = $('downloadBtn'); const gifIndicator = $('gifIndicator'); const widthSlider = $('width'); const widthVal = $('widthVal'); const zoomInBtn = $('zoomIn'); const zoomOutBtn = $('zoomOut'); const zoomFitBtn = $('zoomFit'); const zoomLevel = $('zoomLevel'); const fullscreenBtn = $('fullscreenBtn'); const previewContainer = document.querySelector('.preview-container'); const compareBox = $('compareBox'); const compareImg = $('compareImg'); const compareToggle = $('compareToggle'); const imageControls = $('imageControls'); const patternControls = $('patternControls'); const patternHeight = $('patternHeight'); const heightVal = $('heightVal'); const animateBtn = $('animateBtn'); const animatePatternBtn = $('animatePatternBtn'); const numFramesSlider = $('numFrames'); const framesVal = $('framesVal'); const frameSpeedSlider = $('frameSpeed'); const speedVal = $('speedVal'); const playPauseBtn = $('playPauseBtn'); const frameCounter = $('frameCounter'); const playbackControls = $('playbackControls'); const gifDownloadBtn = $('gifDownloadBtn'); let currentFile = null; let animationInterval = null; let lastRenderedHtml = ''; let zoomScale = 1.0; let currentMode = 'image'; let animationPlaying = true; let currentFrameIdx = 0; let totalFrames = 0; let lastAnimParams = null; // ── Mode Switching ────────────────────────────── const modeImage = $('modeImage'); const modePattern = $('modePattern'); function switchMode(mode) { currentMode = mode; modeImage.classList.toggle('active', mode === 'image'); modePattern.classList.toggle('active', mode === 'pattern'); imageControls.style.display = mode === 'image' ? 'flex' : 'none'; patternControls.style.display = mode === 'pattern' ? 'flex' : 'none'; generateBtn.style.display = mode === 'image' ? '' : 'none'; animateBtn.style.display = mode === 'image' ? '' : 'none'; randomBtn.style.display = mode === 'pattern' ? '' : 'none'; animatePatternBtn.style.display = mode === 'pattern' ? '' : 'none'; if (mode === 'image') { generateBtn.disabled = !currentFile; animateBtn.disabled = !currentFile; } } modeImage.addEventListener('click', () => switchMode('image')); modePattern.addEventListener('click', () => switchMode('pattern')); // ── File Handling ────────────────────────────── dropZone.addEventListener('click', () => fileInput.click()); 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.length) setFile(e.dataTransfer.files[0]); }); fileInput.addEventListener('change', () => { if (fileInput.files.length) setFile(fileInput.files[0]); }); // Reset file input after each use so re-selecting the same file triggers change fileInput.addEventListener('click', () => { fileInput.value = ''; }); clearFile.addEventListener('click', e => { e.stopPropagation(); currentFile = null; fileInput.value = ''; fileInfo.style.display = 'none'; dropZone.style.display = ''; generateBtn.disabled = true; animateBtn.disabled = true; }); function setFile(file) { const allowed = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']; if (!allowed.includes(file.type)) { alert('Please upload a PNG, JPG, GIF, or WebP image.'); return; } if (file.size > 10 * 1024 * 1024) { alert('File too large (max 10MB).'); return; } currentFile = file; fileName.textContent = file.name; thumbPreview.src = URL.createObjectURL(file); dropZone.style.display = 'none'; fileInfo.style.display = 'flex'; generateBtn.disabled = false; animateBtn.disabled = false; // Auto-generate immediately on file select if (currentMode === 'image') { generateImage(); } } // ── Sliders ────────────────────────────── widthSlider.addEventListener('input', () => { widthVal.textContent = widthSlider.value; }); patternHeight.addEventListener('input', () => { heightVal.textContent = patternHeight.value; }); numFramesSlider.addEventListener('input', () => { framesVal.textContent = numFramesSlider.value; }); frameSpeedSlider.addEventListener('input', () => { speedVal.textContent = frameSpeedSlider.value; }); // Resolution presets sync the width slider const resPreset = $('resPreset'); resPreset.addEventListener('change', () => { const val = parseInt(resPreset.value); widthSlider.value = val; widthVal.textContent = val; }); widthSlider.addEventListener('input', () => { // Update preset dropdown to match closest const val = parseInt(widthSlider.value); const opts = [100, 300, 600, 1000, 1500]; let closest = opts[0]; for (const o of opts) { if (Math.abs(o - val) < Math.abs(closest - val)) closest = o; } resPreset.value = closest; }); // ── Generate (Image Mode) ────────────────────────────── generateBtn.addEventListener('click', generateImage); async function generateImage() { if (!currentFile) return; stopAnimation(); previewArea.innerHTML = ''; spinner.style.display = 'flex'; generateBtn.disabled = true; copyBtn.style.display = 'none'; downloadBtn.style.display = 'none'; gifIndicator.style.display = 'none'; const formData = new FormData(); formData.append('file', currentFile); formData.append('width', widthSlider.value); formData.append('palette', $('palette').value); formData.append('bg', $('bg').value); formData.append('dither', $('dither').checked); formData.append('double_width', $('doubleWidth').checked); formData.append('output_format', 'html'); try { const resp = await fetch('/api/render', { method: 'POST', body: formData }); if (!resp.ok) { const err = await resp.json().catch(() => ({ error: 'Request failed' })); throw new Error(err.error || `HTTP ${resp.status}`); } const html = await resp.text(); lastRenderedHtml = html; spinner.style.display = 'none'; previewArea.innerHTML = html; const frames = previewArea.querySelectorAll('.art-frame'); if (frames.length > 1) { gifIndicator.style.display = ''; startAnimation(frames); } copyBtn.style.display = ''; downloadBtn.style.display = ''; // Show original for comparison compareImg.src = URL.createObjectURL(currentFile); compareBox.style.display = ''; compareBox.classList.remove('minimized'); compareToggle.innerHTML = '▼'; autoFitZoom(); } catch (err) { spinner.style.display = 'none'; previewArea.innerHTML = `

${err.message}

`; } generateBtn.disabled = false; } // ── Generate (Pattern Mode) ────────────────────────────── randomBtn.addEventListener('click', generatePattern); async function generatePattern() { stopAnimation(); previewArea.innerHTML = ''; spinner.style.display = 'flex'; randomBtn.disabled = true; copyBtn.style.display = 'none'; downloadBtn.style.display = 'none'; compareBox.style.display = 'none'; let patternType = $('patternType').value; if (patternType === 'random') { const opts = [...$('patternType').options].map(o => o.value).filter(v => v !== 'random'); patternType = opts[Math.floor(Math.random() * opts.length)]; } const formData = new FormData(); formData.append('pattern', patternType); formData.append('width', widthSlider.value); formData.append('height', patternHeight.value); formData.append('palette', $('palette').value); formData.append('output_format', 'html'); try { const resp = await fetch('/api/pattern', { method: 'POST', body: formData }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const html = await resp.text(); lastRenderedHtml = html; spinner.style.display = 'none'; previewArea.innerHTML = html; copyBtn.style.display = ''; downloadBtn.style.display = ''; autoFitZoom(); } catch (err) { spinner.style.display = 'none'; previewArea.innerHTML = `

${err.message}

`; } randomBtn.disabled = false; } // ── GIF Animation (runs even when tab is inactive) ────────────────────────────── let animFrames = null; let animationWorker = null; function createAnimationWorker() { const blob = new Blob([` let timerId = null; self.onmessage = function(e) { if (e.data.cmd === 'start') { if (timerId) clearTimeout(timerId); timerId = setTimeout(() => self.postMessage('tick'), e.data.delay); } else if (e.data.cmd === 'stop') { if (timerId) { clearTimeout(timerId); timerId = null; } } }; `], { type: 'application/javascript' }); return new Worker(URL.createObjectURL(blob)); } function scheduleNextFrame(delay) { if (animationWorker) { animationWorker.postMessage({ cmd: 'start', delay }); } } function advanceFrame() { if (!animationPlaying || !animFrames) return; animFrames[currentFrameIdx].style.display = 'none'; currentFrameIdx = (currentFrameIdx + 1) % animFrames.length; animFrames[currentFrameIdx].style.display = 'block'; updateFrameCounter(); scheduleNextFrame(parseInt(animFrames[currentFrameIdx].dataset.duration) || 100); } function startAnimation(frames) { animFrames = frames; totalFrames = frames.length; currentFrameIdx = 0; animationPlaying = true; frames[0].style.display = 'block'; playbackControls.style.display = 'flex'; playPauseBtn.innerHTML = '⏸'; updateFrameCounter(); if (animationWorker) animationWorker.terminate(); animationWorker = createAnimationWorker(); animationWorker.onmessage = advanceFrame; scheduleNextFrame(parseInt(frames[0].dataset.duration) || 100); } function stopAnimation() { if (animationWorker) { animationWorker.postMessage({ cmd: 'stop' }); animationWorker.terminate(); animationWorker = null; } if (animationInterval) { clearTimeout(animationInterval); animationInterval = null; } animationPlaying = false; animFrames = null; playbackControls.style.display = 'none'; } function updateFrameCounter() { frameCounter.textContent = `${currentFrameIdx + 1}/${totalFrames}`; } playPauseBtn.addEventListener('click', () => { if (!animFrames) return; if (animationPlaying) { // Pause animationPlaying = false; if (animationWorker) animationWorker.postMessage({ cmd: 'stop' }); playPauseBtn.innerHTML = '▶'; } else { // Resume animationPlaying = true; playPauseBtn.innerHTML = '⏸'; scheduleNextFrame(parseInt(animFrames[currentFrameIdx].dataset.duration) || 100); } }); // ── Copy & Download ────────────────────────────── copyBtn.addEventListener('click', async () => { // For patterns, just extract text from the current render if (currentMode === 'pattern') { const text = previewArea.textContent; try { await navigator.clipboard.writeText(text); copyBtn.textContent = 'Copied!'; } catch { copyBtn.textContent = 'Failed'; } setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500); return; } if (!currentFile) return; const formData = new FormData(); formData.append('file', currentFile); formData.append('width', widthSlider.value); formData.append('palette', $('palette').value); formData.append('bg', $('bg').value); formData.append('dither', $('dither').checked); formData.append('double_width', $('doubleWidth').checked); formData.append('output_format', 'plain'); try { const resp = await fetch('/api/render', { method: 'POST', body: formData }); const text = await resp.text(); await navigator.clipboard.writeText(text); copyBtn.textContent = 'Copied!'; } catch { copyBtn.textContent = 'Failed'; } setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500); }); downloadBtn.addEventListener('click', () => { const wrapper = `
${lastRenderedHtml}
`; const blob = new Blob([wrapper], { type: 'text/html' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'ascii-art.html'; a.click(); URL.revokeObjectURL(a.href); }); // ── Animate Image ────────────────────────────── animateBtn.addEventListener('click', animateImage); async function animateImage() { if (!currentFile) return; stopAnimation(); previewArea.innerHTML = ''; spinner.style.display = 'flex'; animateBtn.disabled = true; copyBtn.style.display = 'none'; downloadBtn.style.display = 'none'; gifDownloadBtn.style.display = 'none'; gifIndicator.style.display = 'none'; const formData = new FormData(); formData.append('file', currentFile); formData.append('width', widthSlider.value); formData.append('palette', $('palette').value); formData.append('bg', $('bg').value); formData.append('dither', $('dither').checked); formData.append('double_width', $('doubleWidth').checked); formData.append('effect', $('effectType').value); formData.append('num_frames', numFramesSlider.value); formData.append('frame_duration', frameSpeedSlider.value); lastAnimParams = { mode: 'image', formData: formData }; try { const resp = await fetch('/api/animate', { method: 'POST', body: formData }); if (!resp.ok) { const err = await resp.json().catch(() => ({ error: 'Request failed' })); throw new Error(err.error || `HTTP ${resp.status}`); } const html = await resp.text(); lastRenderedHtml = html; spinner.style.display = 'none'; previewArea.innerHTML = html; const frames = previewArea.querySelectorAll('.art-frame'); if (frames.length > 1) { gifIndicator.style.display = ''; gifIndicator.textContent = `ANIM ${frames.length}f`; startAnimation(frames); } copyBtn.style.display = ''; downloadBtn.style.display = ''; gifDownloadBtn.style.display = ''; compareImg.src = URL.createObjectURL(currentFile); compareBox.style.display = ''; compareBox.classList.remove('minimized'); compareToggle.innerHTML = '▼'; autoFitZoom(); } catch (err) { spinner.style.display = 'none'; previewArea.innerHTML = `

${err.message}

`; } animateBtn.disabled = false; } // ── Animate Pattern ────────────────────────────── animatePatternBtn.addEventListener('click', animatePatternFn); async function animatePatternFn() { stopAnimation(); previewArea.innerHTML = ''; spinner.style.display = 'flex'; animatePatternBtn.disabled = true; copyBtn.style.display = 'none'; downloadBtn.style.display = 'none'; gifDownloadBtn.style.display = 'none'; compareBox.style.display = 'none'; let patternType = $('patternType').value; if (patternType === 'random') { const opts = [...$('patternType').options].map(o => o.value).filter(v => v !== 'random'); patternType = opts[Math.floor(Math.random() * opts.length)]; } const formData = new FormData(); formData.append('pattern', patternType); formData.append('width', widthSlider.value); formData.append('height', patternHeight.value); formData.append('palette', $('palette').value); formData.append('num_frames', numFramesSlider.value); formData.append('frame_duration', frameSpeedSlider.value); lastAnimParams = { mode: 'pattern', formData: formData }; try { const resp = await fetch('/api/animate-pattern', { method: 'POST', body: formData }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const html = await resp.text(); lastRenderedHtml = html; spinner.style.display = 'none'; previewArea.innerHTML = html; const frames = previewArea.querySelectorAll('.art-frame'); if (frames.length > 1) { gifIndicator.style.display = ''; gifIndicator.textContent = `ANIM ${frames.length}f`; startAnimation(frames); } copyBtn.style.display = ''; downloadBtn.style.display = ''; gifDownloadBtn.style.display = ''; autoFitZoom(); } catch (err) { spinner.style.display = 'none'; previewArea.innerHTML = `

${err.message}

`; } animatePatternBtn.disabled = false; } // ── GIF Download ────────────────────────────── gifDownloadBtn.addEventListener('click', async () => { if (!lastAnimParams) return; gifDownloadBtn.textContent = '...'; gifDownloadBtn.disabled = true; try { const formData = new FormData(); // Copy all entries from saved params for (const [key, val] of lastAnimParams.formData.entries()) { formData.append(key, val); } formData.set('export_gif', 'true'); const endpoint = lastAnimParams.mode === 'image' ? '/api/animate' : '/api/animate-pattern'; const resp = await fetch(endpoint, { method: 'POST', body: formData }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const blob = await resp.blob(); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = lastAnimParams.mode === 'image' ? 'ascii-animation.gif' : 'pattern-animation.gif'; a.click(); URL.revokeObjectURL(a.href); } catch (err) { alert('GIF download failed: ' + err.message); } gifDownloadBtn.textContent = 'GIF'; gifDownloadBtn.disabled = false; }); // ── Compare Box ────────────────────────────── compareToggle.addEventListener('click', e => { e.stopPropagation(); toggleCompare(); }); document.querySelector('.compare-header').addEventListener('click', toggleCompare); function toggleCompare() { compareBox.classList.toggle('minimized'); compareToggle.innerHTML = compareBox.classList.contains('minimized') ? '▲' : '▼'; } // ── Zoom ────────────────────────────── function setZoom(scale) { zoomScale = Math.max(0.1, Math.min(8.0, scale)); previewArea.style.fontSize = (5 * zoomScale) + 'px'; zoomLevel.textContent = Math.round(zoomScale * 100) + '%'; } function autoFitZoom() { requestAnimationFrame(() => { // Reset to base size to measure natural dimensions previewArea.style.fontSize = '5px'; zoomScale = 1.0; const containerW = previewArea.clientWidth - 16; const containerH = previewArea.clientHeight - 16; const artW = previewArea.scrollWidth; const artH = previewArea.scrollHeight; if (artW > 0 && artH > 0) { // Scale to fill the preview area (fit the limiting dimension) const scaleW = containerW / artW; const scaleH = containerH / artH; const fitScale = Math.min(scaleW, scaleH); setZoom(Math.max(0.1, fitScale)); } }); } zoomInBtn.addEventListener('click', () => setZoom(zoomScale * 1.2)); zoomOutBtn.addEventListener('click', () => setZoom(zoomScale / 1.2)); zoomFitBtn.addEventListener('click', autoFitZoom); // Mouse wheel zoom (Ctrl/Cmd + scroll, or pinch gesture which browsers send as ctrlKey wheel) previewArea.addEventListener('wheel', e => { if (e.ctrlKey || e.metaKey) { e.preventDefault(); const factor = e.deltaY > 0 ? 0.9 : 1.1; setZoom(zoomScale * factor); } }, { passive: false }); // ── Touch Pinch-to-Zoom ────────────────────────────── let touchStartDist = 0; let touchStartZoom = 1.0; previewArea.addEventListener('touchstart', e => { if (e.touches.length === 2) { e.preventDefault(); const dx = e.touches[0].clientX - e.touches[1].clientX; const dy = e.touches[0].clientY - e.touches[1].clientY; touchStartDist = Math.sqrt(dx * dx + dy * dy); touchStartZoom = zoomScale; } }, { passive: false }); previewArea.addEventListener('touchmove', e => { if (e.touches.length === 2) { e.preventDefault(); const dx = e.touches[0].clientX - e.touches[1].clientX; const dy = e.touches[0].clientY - e.touches[1].clientY; const dist = Math.sqrt(dx * dx + dy * dy); if (touchStartDist > 0) { setZoom(touchStartZoom * (dist / touchStartDist)); } } }, { passive: false }); previewArea.addEventListener('touchend', () => { touchStartDist = 0; }); // ── Fullscreen ────────────────────────────── fullscreenBtn.addEventListener('click', toggleFullscreen); function toggleFullscreen() { previewContainer.classList.toggle('fullscreen'); fullscreenBtn.textContent = previewContainer.classList.contains('fullscreen') ? '\u2716' : '\u26F6'; fullscreenBtn.title = previewContainer.classList.contains('fullscreen') ? 'Exit (Esc)' : 'Fullscreen (F)'; } // ── Keyboard Shortcuts ────────────────────────────── document.addEventListener('keydown', e => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || e.target.tagName === 'TEXTAREA') return; if (e.key === '+' || e.key === '=') { setZoom(zoomScale * 1.2); e.preventDefault(); } if (e.key === '-') { setZoom(zoomScale / 1.2); e.preventDefault(); } if (e.key === '0') { setZoom(1.0); e.preventDefault(); } if (e.key === 'f' || e.key === 'F') { toggleFullscreen(); e.preventDefault(); } if (e.key === 'Escape' && previewContainer.classList.contains('fullscreen')) { toggleFullscreen(); e.preventDefault(); } });