ascii-art/static/app.js

623 lines
22 KiB
JavaScript

/* 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 = '&#x25BC;';
autoFitZoom();
} catch (err) {
spinner.style.display = 'none';
previewArea.innerHTML = `<p class="placeholder" style="color:#ff453a">${err.message}</p>`;
}
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 = `<p class="placeholder" style="color:#ff453a">${err.message}</p>`;
}
randomBtn.disabled = false;
}
// ── GIF Animation ──────────────────────────────
let animFrames = null;
function startAnimation(frames) {
animFrames = frames;
totalFrames = frames.length;
currentFrameIdx = 0;
animationPlaying = true;
frames[0].style.display = 'block';
playbackControls.style.display = 'flex';
playPauseBtn.innerHTML = '&#x23F8;';
updateFrameCounter();
function nextFrame() {
if (!animationPlaying) return;
frames[currentFrameIdx].style.display = 'none';
currentFrameIdx = (currentFrameIdx + 1) % frames.length;
frames[currentFrameIdx].style.display = 'block';
updateFrameCounter();
animationInterval = setTimeout(nextFrame, parseInt(frames[currentFrameIdx].dataset.duration) || 100);
}
animationInterval = setTimeout(nextFrame, parseInt(frames[0].dataset.duration) || 100);
}
function stopAnimation() {
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 (animationInterval) { clearTimeout(animationInterval); animationInterval = null; }
playPauseBtn.innerHTML = '&#x25B6;';
} else {
// Resume
animationPlaying = true;
playPauseBtn.innerHTML = '&#x23F8;';
function nextFrame() {
if (!animationPlaying || !animFrames) return;
animFrames[currentFrameIdx].style.display = 'none';
currentFrameIdx = (currentFrameIdx + 1) % animFrames.length;
animFrames[currentFrameIdx].style.display = 'block';
updateFrameCounter();
animationInterval = setTimeout(nextFrame, parseInt(animFrames[currentFrameIdx].dataset.duration) || 100);
}
animationInterval = setTimeout(nextFrame, 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 = `<!DOCTYPE html>
<html><head><meta charset="UTF-8"><style>
body{background:#0d1117;margin:20px;display:flex;justify-content:center}
pre{font-family:'JetBrains Mono',monospace;font-size:11px;line-height:1.15;letter-spacing:0.5px}
</style></head><body><pre>${lastRenderedHtml}</pre></body></html>`;
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 = '&#x25BC;';
autoFitZoom();
} catch (err) {
spinner.style.display = 'none';
previewArea.innerHTML = `<p class="placeholder" style="color:#ff453a">${err.message}</p>`;
}
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 = `<p class="placeholder" style="color:#ff453a">${err.message}</p>`;
}
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') ? '&#x25B2;' : '&#x25BC;';
}
// ── 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();
}
});