Fix UX: double file picker, mode tabs, touch/pinch zoom
- Fix double file dialog by preventing label click bubble - Rewrite mode switching for clean Image/Patterns tab toggle - Add pinch-to-zoom for touch devices (two-finger gesture) - Ctrl/Cmd+scroll wheel zoom (also captures trackpad pinch) - Multiplicative zoom steps (1.2x per click) for smoother feel - Auto-fit zoom after every render - Fix copy button for pattern mode (no re-fetch needed) - Clean up all event handlers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b1874eace7
commit
01fb200add
413
static/app.js
413
static/app.js
|
|
@ -1,48 +1,79 @@
|
||||||
/* ASCII Art Generator - Frontend */
|
/* ASCII Art Generator - Frontend */
|
||||||
|
|
||||||
const dropZone = document.getElementById('dropZone');
|
const $ = id => document.getElementById(id);
|
||||||
const fileInput = document.getElementById('fileInput');
|
const dropZone = $('dropZone');
|
||||||
const fileInfo = document.getElementById('fileInfo');
|
const fileInput = $('fileInput');
|
||||||
const thumbPreview = document.getElementById('thumbPreview');
|
const fileInfo = $('fileInfo');
|
||||||
const fileName = document.getElementById('fileName');
|
const thumbPreview = $('thumbPreview');
|
||||||
const clearFile = document.getElementById('clearFile');
|
const fileName = $('fileName');
|
||||||
const generateBtn = document.getElementById('generateBtn');
|
const clearFile = $('clearFile');
|
||||||
const previewArea = document.getElementById('previewArea');
|
const generateBtn = $('generateBtn');
|
||||||
const spinner = document.getElementById('spinner');
|
const randomBtn = $('randomBtn');
|
||||||
const copyBtn = document.getElementById('copyBtn');
|
const previewArea = $('previewArea');
|
||||||
const downloadBtn = document.getElementById('downloadBtn');
|
const spinner = $('spinner');
|
||||||
const gifIndicator = document.getElementById('gifIndicator');
|
const copyBtn = $('copyBtn');
|
||||||
const widthSlider = document.getElementById('width');
|
const downloadBtn = $('downloadBtn');
|
||||||
const widthVal = document.getElementById('widthVal');
|
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');
|
||||||
|
|
||||||
let currentFile = null;
|
let currentFile = null;
|
||||||
let animationInterval = null;
|
let animationInterval = null;
|
||||||
let lastRenderedHtml = '';
|
let lastRenderedHtml = '';
|
||||||
let zoomScale = 1.0;
|
let zoomScale = 1.0;
|
||||||
let currentMode = 'image'; // 'image' or 'pattern'
|
let currentMode = 'image';
|
||||||
|
|
||||||
const zoomIn = document.getElementById('zoomIn');
|
// ── Mode Switching ──────────────────────────────
|
||||||
const zoomOut = document.getElementById('zoomOut');
|
|
||||||
const zoomFit = document.getElementById('zoomFit');
|
document.querySelectorAll('.mode-tab').forEach(tab => {
|
||||||
const zoomLevel = document.getElementById('zoomLevel');
|
tab.addEventListener('click', () => {
|
||||||
const fullscreenBtn = document.getElementById('fullscreenBtn');
|
document.querySelectorAll('.mode-tab').forEach(t => t.classList.remove('active'));
|
||||||
const previewContainer = document.querySelector('.preview-container');
|
tab.classList.add('active');
|
||||||
const compareBox = document.getElementById('compareBox');
|
currentMode = tab.dataset.mode;
|
||||||
const compareImg = document.getElementById('compareImg');
|
|
||||||
const compareToggle = document.getElementById('compareToggle');
|
if (currentMode === 'pattern') {
|
||||||
|
imageControls.style.display = 'none';
|
||||||
|
patternControls.style.display = 'flex';
|
||||||
|
generateBtn.style.display = 'none';
|
||||||
|
randomBtn.style.display = '';
|
||||||
|
} else {
|
||||||
|
imageControls.style.display = 'flex';
|
||||||
|
patternControls.style.display = 'none';
|
||||||
|
generateBtn.style.display = '';
|
||||||
|
generateBtn.disabled = !currentFile;
|
||||||
|
randomBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── File Handling ──────────────────────────────
|
// ── File Handling ──────────────────────────────
|
||||||
|
|
||||||
dropZone.addEventListener('click', () => fileInput.click());
|
// Only open file picker from the dropZone click — prevent double-trigger from label
|
||||||
|
dropZone.addEventListener('click', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
fileInput.click();
|
||||||
|
});
|
||||||
|
|
||||||
dropZone.addEventListener('dragover', e => {
|
dropZone.addEventListener('dragover', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dropZone.classList.add('dragover');
|
dropZone.classList.add('dragover');
|
||||||
});
|
});
|
||||||
|
|
||||||
dropZone.addEventListener('dragleave', () => {
|
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
|
||||||
dropZone.classList.remove('dragover');
|
|
||||||
});
|
|
||||||
|
|
||||||
dropZone.addEventListener('drop', e => {
|
dropZone.addEventListener('drop', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -54,7 +85,8 @@ fileInput.addEventListener('change', () => {
|
||||||
if (fileInput.files.length) setFile(fileInput.files[0]);
|
if (fileInput.files.length) setFile(fileInput.files[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
clearFile.addEventListener('click', () => {
|
clearFile.addEventListener('click', e => {
|
||||||
|
e.stopPropagation();
|
||||||
currentFile = null;
|
currentFile = null;
|
||||||
fileInput.value = '';
|
fileInput.value = '';
|
||||||
fileInfo.style.display = 'none';
|
fileInfo.style.display = 'none';
|
||||||
|
|
@ -72,7 +104,6 @@ function setFile(file) {
|
||||||
alert('File too large (max 10MB).');
|
alert('File too large (max 10MB).');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentFile = file;
|
currentFile = file;
|
||||||
fileName.textContent = file.name;
|
fileName.textContent = file.name;
|
||||||
thumbPreview.src = URL.createObjectURL(file);
|
thumbPreview.src = URL.createObjectURL(file);
|
||||||
|
|
@ -81,38 +112,69 @@ function setFile(file) {
|
||||||
generateBtn.disabled = false;
|
generateBtn.disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mode Switching ──────────────────────────────
|
// ── Sliders ──────────────────────────────
|
||||||
|
|
||||||
const modeTabs = document.querySelectorAll('.mode-tab');
|
widthSlider.addEventListener('input', () => { widthVal.textContent = widthSlider.value; });
|
||||||
const imageControls = document.getElementById('imageControls');
|
patternHeight.addEventListener('input', () => { heightVal.textContent = patternHeight.value; });
|
||||||
const patternControls = document.getElementById('patternControls');
|
|
||||||
const randomBtn = document.getElementById('randomBtn');
|
|
||||||
const patternHeight = document.getElementById('patternHeight');
|
|
||||||
const heightVal = document.getElementById('heightVal');
|
|
||||||
|
|
||||||
modeTabs.forEach(tab => {
|
// ── Generate (Image Mode) ──────────────────────────────
|
||||||
tab.addEventListener('click', () => {
|
|
||||||
modeTabs.forEach(t => t.classList.remove('active'));
|
|
||||||
tab.classList.add('active');
|
|
||||||
currentMode = tab.dataset.mode;
|
|
||||||
|
|
||||||
if (currentMode === 'pattern') {
|
generateBtn.addEventListener('click', generateImage);
|
||||||
imageControls.style.display = 'none';
|
|
||||||
patternControls.style.display = 'flex';
|
async function generateImage() {
|
||||||
generateBtn.style.display = 'none';
|
if (!currentFile) return;
|
||||||
randomBtn.style.display = '';
|
stopAnimation();
|
||||||
} else {
|
previewArea.innerHTML = '';
|
||||||
imageControls.style.display = 'flex';
|
spinner.style.display = 'flex';
|
||||||
patternControls.style.display = 'none';
|
generateBtn.disabled = true;
|
||||||
generateBtn.style.display = '';
|
copyBtn.style.display = 'none';
|
||||||
randomBtn.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;
|
||||||
|
|
||||||
patternHeight.addEventListener('input', () => {
|
const frames = previewArea.querySelectorAll('.art-frame');
|
||||||
heightVal.textContent = patternHeight.value;
|
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);
|
randomBtn.addEventListener('click', generatePattern);
|
||||||
|
|
||||||
|
|
@ -125,18 +187,17 @@ async function generatePattern() {
|
||||||
downloadBtn.style.display = 'none';
|
downloadBtn.style.display = 'none';
|
||||||
compareBox.style.display = 'none';
|
compareBox.style.display = 'none';
|
||||||
|
|
||||||
let patternType = document.getElementById('patternType').value;
|
let patternType = $('patternType').value;
|
||||||
if (patternType === 'random') {
|
if (patternType === 'random') {
|
||||||
const types = [...document.getElementById('patternType').options]
|
const opts = [...$('patternType').options].map(o => o.value).filter(v => v !== 'random');
|
||||||
.map(o => o.value).filter(v => v !== 'random');
|
patternType = opts[Math.floor(Math.random() * opts.length)];
|
||||||
patternType = types[Math.floor(Math.random() * types.length)];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('pattern', patternType);
|
formData.append('pattern', patternType);
|
||||||
formData.append('width', widthSlider.value);
|
formData.append('width', widthSlider.value);
|
||||||
formData.append('height', patternHeight.value);
|
formData.append('height', patternHeight.value);
|
||||||
formData.append('palette', document.getElementById('palette').value);
|
formData.append('palette', $('palette').value);
|
||||||
formData.append('output_format', 'html');
|
formData.append('output_format', 'html');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -146,18 +207,9 @@ async function generatePattern() {
|
||||||
lastRenderedHtml = html;
|
lastRenderedHtml = html;
|
||||||
spinner.style.display = 'none';
|
spinner.style.display = 'none';
|
||||||
previewArea.innerHTML = html;
|
previewArea.innerHTML = html;
|
||||||
|
|
||||||
copyBtn.style.display = '';
|
copyBtn.style.display = '';
|
||||||
downloadBtn.style.display = '';
|
downloadBtn.style.display = '';
|
||||||
|
autoFitZoom();
|
||||||
// Auto-fit
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const containerWidth = previewArea.clientWidth - 24;
|
|
||||||
const artWidth = previewArea.scrollWidth;
|
|
||||||
if (artWidth > containerWidth) {
|
|
||||||
setZoom(containerWidth / artWidth);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
spinner.style.display = 'none';
|
spinner.style.display = 'none';
|
||||||
previewArea.innerHTML = `<p class="placeholder" style="color:#ff453a">${err.message}</p>`;
|
previewArea.innerHTML = `<p class="placeholder" style="color:#ff453a">${err.message}</p>`;
|
||||||
|
|
@ -165,118 +217,46 @@ async function generatePattern() {
|
||||||
randomBtn.disabled = false;
|
randomBtn.disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Width Slider ──────────────────────────────
|
|
||||||
|
|
||||||
widthSlider.addEventListener('input', () => {
|
|
||||||
widthVal.textContent = widthSlider.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Generate ──────────────────────────────
|
|
||||||
|
|
||||||
generateBtn.addEventListener('click', generate);
|
|
||||||
|
|
||||||
async function generate() {
|
|
||||||
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', document.getElementById('palette').value);
|
|
||||||
formData.append('bg', document.getElementById('bg').value);
|
|
||||||
formData.append('dither', document.getElementById('dither').checked);
|
|
||||||
formData.append('double_width', document.getElementById('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;
|
|
||||||
|
|
||||||
// Check if animated (multiple frames)
|
|
||||||
const frames = previewArea.querySelectorAll('.art-frame');
|
|
||||||
if (frames.length > 1) {
|
|
||||||
gifIndicator.style.display = '';
|
|
||||||
startAnimation(frames);
|
|
||||||
}
|
|
||||||
|
|
||||||
copyBtn.style.display = '';
|
|
||||||
downloadBtn.style.display = '';
|
|
||||||
|
|
||||||
// Show original image for comparison
|
|
||||||
compareImg.src = URL.createObjectURL(currentFile);
|
|
||||||
compareBox.style.display = '';
|
|
||||||
compareBox.classList.remove('minimized');
|
|
||||||
compareToggle.innerHTML = '▼';
|
|
||||||
|
|
||||||
// Auto-fit to preview area
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const containerWidth = previewArea.clientWidth - 24;
|
|
||||||
const artWidth = previewArea.scrollWidth;
|
|
||||||
if (artWidth > containerWidth) {
|
|
||||||
setZoom(containerWidth / artWidth);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
spinner.style.display = 'none';
|
|
||||||
previewArea.innerHTML = `<p class="placeholder" style="color:#ff453a">${err.message}</p>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
generateBtn.disabled = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── GIF Animation ──────────────────────────────
|
// ── GIF Animation ──────────────────────────────
|
||||||
|
|
||||||
function startAnimation(frames) {
|
function startAnimation(frames) {
|
||||||
let current = 0;
|
let current = 0;
|
||||||
frames[0].style.display = 'block';
|
frames[0].style.display = 'block';
|
||||||
|
|
||||||
function nextFrame() {
|
function nextFrame() {
|
||||||
frames[current].style.display = 'none';
|
frames[current].style.display = 'none';
|
||||||
current = (current + 1) % frames.length;
|
current = (current + 1) % frames.length;
|
||||||
frames[current].style.display = 'block';
|
frames[current].style.display = 'block';
|
||||||
const duration = parseInt(frames[current].dataset.duration) || 100;
|
animationInterval = setTimeout(nextFrame, parseInt(frames[current].dataset.duration) || 100);
|
||||||
animationInterval = setTimeout(nextFrame, duration);
|
|
||||||
}
|
}
|
||||||
|
animationInterval = setTimeout(nextFrame, parseInt(frames[0].dataset.duration) || 100);
|
||||||
const duration = parseInt(frames[0].dataset.duration) || 100;
|
|
||||||
animationInterval = setTimeout(nextFrame, duration);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopAnimation() {
|
function stopAnimation() {
|
||||||
if (animationInterval) {
|
if (animationInterval) { clearTimeout(animationInterval); animationInterval = null; }
|
||||||
clearTimeout(animationInterval);
|
|
||||||
animationInterval = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Copy & Download ──────────────────────────────
|
// ── Copy & Download ──────────────────────────────
|
||||||
|
|
||||||
copyBtn.addEventListener('click', async () => {
|
copyBtn.addEventListener('click', async () => {
|
||||||
// Re-render as plain text for clipboard
|
// For patterns, just extract text from the current render
|
||||||
if (!currentFile) return;
|
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();
|
const formData = new FormData();
|
||||||
formData.append('file', currentFile);
|
formData.append('file', currentFile);
|
||||||
formData.append('width', widthSlider.value);
|
formData.append('width', widthSlider.value);
|
||||||
formData.append('palette', document.getElementById('palette').value);
|
formData.append('palette', $('palette').value);
|
||||||
formData.append('bg', document.getElementById('bg').value);
|
formData.append('bg', $('bg').value);
|
||||||
formData.append('dither', document.getElementById('dither').checked);
|
formData.append('dither', $('dither').checked);
|
||||||
formData.append('double_width', document.getElementById('doubleWidth').checked);
|
formData.append('double_width', $('doubleWidth').checked);
|
||||||
formData.append('output_format', 'plain');
|
formData.append('output_format', 'plain');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -284,11 +264,8 @@ copyBtn.addEventListener('click', async () => {
|
||||||
const text = await resp.text();
|
const text = await resp.text();
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
copyBtn.textContent = 'Copied!';
|
copyBtn.textContent = 'Copied!';
|
||||||
setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500);
|
} catch { copyBtn.textContent = 'Failed'; }
|
||||||
} catch {
|
setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500);
|
||||||
copyBtn.textContent = 'Failed';
|
|
||||||
setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
downloadBtn.addEventListener('click', () => {
|
downloadBtn.addEventListener('click', () => {
|
||||||
|
|
@ -297,90 +274,112 @@ downloadBtn.addEventListener('click', () => {
|
||||||
body{background:#0d1117;margin:20px;display:flex;justify-content:center}
|
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}
|
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>`;
|
</style></head><body><pre>${lastRenderedHtml}</pre></body></html>`;
|
||||||
|
|
||||||
const blob = new Blob([wrapper], { type: 'text/html' });
|
const blob = new Blob([wrapper], { type: 'text/html' });
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = URL.createObjectURL(blob);
|
||||||
a.download = 'ascii-art.html';
|
a.download = 'ascii-art.html';
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(a.href);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Compare Box ──────────────────────────────
|
// ── Compare Box ──────────────────────────────
|
||||||
|
|
||||||
compareToggle.addEventListener('click', e => {
|
compareToggle.addEventListener('click', e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
compareBox.classList.toggle('minimized');
|
toggleCompare();
|
||||||
compareToggle.innerHTML = compareBox.classList.contains('minimized') ? '▲' : '▼';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelector('.compare-header').addEventListener('click', () => {
|
document.querySelector('.compare-header').addEventListener('click', toggleCompare);
|
||||||
|
|
||||||
|
function toggleCompare() {
|
||||||
compareBox.classList.toggle('minimized');
|
compareBox.classList.toggle('minimized');
|
||||||
compareToggle.innerHTML = compareBox.classList.contains('minimized') ? '▲' : '▼';
|
compareToggle.innerHTML = compareBox.classList.contains('minimized') ? '▲' : '▼';
|
||||||
});
|
}
|
||||||
|
|
||||||
// ── Zoom ──────────────────────────────
|
// ── Zoom ──────────────────────────────
|
||||||
|
|
||||||
function setZoom(scale) {
|
function setZoom(scale) {
|
||||||
zoomScale = Math.max(0.25, Math.min(5.0, scale));
|
zoomScale = Math.max(0.1, Math.min(8.0, scale));
|
||||||
previewArea.style.fontSize = (8 * zoomScale) + 'px';
|
previewArea.style.fontSize = (8 * zoomScale) + 'px';
|
||||||
zoomLevel.textContent = Math.round(zoomScale * 100) + '%';
|
zoomLevel.textContent = Math.round(zoomScale * 100) + '%';
|
||||||
}
|
}
|
||||||
|
|
||||||
zoomIn.addEventListener('click', () => setZoom(zoomScale + 0.15));
|
function autoFitZoom() {
|
||||||
zoomOut.addEventListener('click', () => setZoom(zoomScale - 0.15));
|
requestAnimationFrame(() => {
|
||||||
|
const containerWidth = previewArea.clientWidth - 24;
|
||||||
|
previewArea.style.fontSize = '8px';
|
||||||
|
zoomScale = 1.0;
|
||||||
|
const artWidth = previewArea.scrollWidth;
|
||||||
|
if (artWidth > containerWidth && artWidth > 0) {
|
||||||
|
setZoom(containerWidth / artWidth);
|
||||||
|
} else {
|
||||||
|
zoomLevel.textContent = '100%';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
zoomFit.addEventListener('click', () => {
|
zoomInBtn.addEventListener('click', () => setZoom(zoomScale * 1.2));
|
||||||
// Measure art content width vs container width
|
zoomOutBtn.addEventListener('click', () => setZoom(zoomScale / 1.2));
|
||||||
const containerWidth = previewArea.clientWidth - 32; // padding
|
zoomFitBtn.addEventListener('click', autoFitZoom);
|
||||||
// Temporarily reset to measure natural width
|
|
||||||
previewArea.style.fontSize = '8px';
|
|
||||||
const artWidth = previewArea.scrollWidth;
|
|
||||||
if (artWidth > 0) {
|
|
||||||
const fitScale = containerWidth / artWidth;
|
|
||||||
setZoom(Math.min(fitScale, 2.0)); // cap at 200%
|
|
||||||
} else {
|
|
||||||
setZoom(1.0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ctrl+scroll to zoom
|
// Mouse wheel zoom (Ctrl/Cmd + scroll, or pinch gesture which browsers send as ctrlKey wheel)
|
||||||
previewArea.addEventListener('wheel', e => {
|
previewArea.addEventListener('wheel', e => {
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
const factor = e.deltaY > 0 ? 0.9 : 1.1;
|
||||||
setZoom(zoomScale + delta);
|
setZoom(zoomScale * factor);
|
||||||
}
|
}
|
||||||
}, { passive: false });
|
}, { 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 ──────────────────────────────
|
// ── Fullscreen ──────────────────────────────
|
||||||
|
|
||||||
fullscreenBtn.addEventListener('click', toggleFullscreen);
|
fullscreenBtn.addEventListener('click', toggleFullscreen);
|
||||||
|
|
||||||
function toggleFullscreen() {
|
function toggleFullscreen() {
|
||||||
previewContainer.classList.toggle('fullscreen');
|
previewContainer.classList.toggle('fullscreen');
|
||||||
if (previewContainer.classList.contains('fullscreen')) {
|
fullscreenBtn.textContent = previewContainer.classList.contains('fullscreen') ? '\u2716' : '\u26F6';
|
||||||
fullscreenBtn.textContent = '\u2716'; // ✖
|
fullscreenBtn.title = previewContainer.classList.contains('fullscreen') ? 'Exit (Esc)' : 'Fullscreen (F)';
|
||||||
fullscreenBtn.title = 'Exit fullscreen (Esc)';
|
|
||||||
} else {
|
|
||||||
fullscreenBtn.textContent = '\u26F6'; // ⛶
|
|
||||||
fullscreenBtn.title = 'Fullscreen (F)';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Keyboard Shortcuts ──────────────────────────────
|
// ── Keyboard Shortcuts ──────────────────────────────
|
||||||
|
|
||||||
document.addEventListener('keydown', e => {
|
document.addEventListener('keydown', e => {
|
||||||
// Don't capture when typing in inputs
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || e.target.tagName === 'TEXTAREA') return;
|
||||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
|
|
||||||
|
|
||||||
if (e.key === '+' || e.key === '=') { setZoom(zoomScale + 0.15); e.preventDefault(); }
|
if (e.key === '+' || e.key === '=') { setZoom(zoomScale * 1.2); e.preventDefault(); }
|
||||||
if (e.key === '-') { setZoom(zoomScale - 0.15); e.preventDefault(); }
|
if (e.key === '-') { setZoom(zoomScale / 1.2); e.preventDefault(); }
|
||||||
if (e.key === '0') { setZoom(1.0); e.preventDefault(); }
|
if (e.key === '0') { setZoom(1.0); e.preventDefault(); }
|
||||||
if (e.key === 'f' || e.key === 'F') { toggleFullscreen(); e.preventDefault(); }
|
if (e.key === 'f' || e.key === 'F') { toggleFullscreen(); e.preventDefault(); }
|
||||||
if (e.key === 'Escape' && previewContainer.classList.contains('fullscreen')) {
|
if (e.key === 'Escape' && previewContainer.classList.contains('fullscreen')) {
|
||||||
toggleFullscreen();
|
toggleFullscreen(); e.preventDefault();
|
||||||
e.preventDefault();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue