643 lines
22 KiB
JavaScript
643 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 = '▼';
|
|
|
|
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 (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 = `<!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 = '▼';
|
|
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') ? '▲' : '▼';
|
|
}
|
|
|
|
// ── 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();
|
|
}
|
|
});
|