This is a standalone, self-contained app that works within a Softr custom code block. To make it as simple as possible to use, the app uses your local file browser to select images. This means your images are not uploaded to any server, providing you with complete privacy.
I tried to connect the app with Softr’s database, but there were technical challenges in getting it to read the image information correctly, along with other fields in a table. To ensure a smooth and reliable experience for you, I opted for this simpler, local file approach. I will revisit this once Softr workflows is released, maybe it will provide the solution I am looking for.
Also, I can picture a lot of use cases for this expanding multiple different fields and areas of business. So consider this a request feature from me as well @softr / @SoftrDev / @Jjenglert
How to Use the Interactive Image Comparison App
Welcome! This is a simple but powerful tool for creating interactive “before and after” image comparisons. You can upload two images, compare them with a slider, and then save the final result as a single image.
Here’s a breakdown of its features and how to use them.
1. Uploading Your Images
You have two main slots: Before Image and After Image . You can add a picture to each slot in two ways:
- Drag & Drop: Simply drag an image file from your computer and drop it directly onto either the “Before” or “After” upload area.
- Click to Upload: Click the “Upload New” button (or the text inside the dotted-line box) to open your computer’s standard file browser and select an image.
Once an image is uploaded, you will see a preview of it in the box.
2. Using the Interactive Comparison Viewer
As soon as both the “Before” and “After” images are uploaded, they will appear in the main “Interactive Image Comparison” viewer at the top.
- Slider: A vertical line with a round handle will appear over the image. Click and drag this handle left and right to reveal more or less of the “Before” and “After” images.
- Pan Images: Sometimes the images may not line up perfectly. You can click and hold your mouse button anywhere on the image and drag to pan (move) either the “Before” or “After” image to get the alignment just right. The app is smart enough to know which image you’re trying to move based on where you click.
- Reset Positions: If you’ve moved the images around and want to return them to their original centered positions, just click the Reset Positions button.
3. Saving and Resetting
Located in the top-right corner of the main card are two primary action buttons:
- Save Image: Once you have the slider positioned exactly where you want it, click this button. It will instantly generate a new PNG file that combines the visible parts of the “Before” and “After” images and download it to your computer.
- Reset: If you want to start completely fresh, click this button. It will remove both the “Before” and “After” images from the app and clear them from your browser’s memory.
How It Remembers Your Images
The app uses your browser’s localStorage
to automatically save the “Before” and “After” images you’ve uploaded. This means if you close the browser tab and come back later, your images will still be there, ready for you to continue working. The “Reset” button is the only action that will clear this saved data.
I hope you find this tool useful!
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Image Comparison App</title>
<!-- `Created by Ken Ove Ferbu for use in Softr` -->
<!-- Tailwind CSS for styling -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Lucide Icons for UI icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<!-- Google Fonts: Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
/* Custom styles to complement Tailwind and replicate component library behavior */
html, body {
font-family: 'Inter', sans-serif;
background-color: #f8fafc; /* tailwind's gray-50 */
color: #0f172a; /* tailwind's slate-900 */
margin: 0;
padding: 0;
}
/* Styling for the toast notifications */
.toast-container {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: flex-end;
}
.toast {
min-width: 350px;
background-color: white;
border-radius: 0.5rem;
border: 1px solid #e2e8f0; /* slate-200 */
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
padding: 1rem;
display: flex;
align-items: flex-start;
gap: 0.75rem;
opacity: 0;
transform: translateX(100%);
transition: opacity 300ms ease-in-out, transform 300ms ease-in-out;
}
.toast.show {
opacity: 1;
transform: translateX(0);
}
.toast.destructive {
border-color: #ef4444; /* red-500 */
background-color: #fef2f2; /* red-50 */
}
.toast.destructive .toast-title {
color: #b91c1c; /* red-700 */
}
.toast.destructive .toast-description {
color: #dc2626; /* red-600 */
}
.toast-icon {
flex-shrink: 0;
width: 1.25rem;
height: 1.25rem;
margin-top: 2px;
}
.toast-content {
flex-grow: 1;
}
.toast-title {
font-weight: 600;
color: #1e293b; /* slate-800 */
}
.toast-description {
font-size: 0.875rem;
color: #475569; /* slate-600 */
margin-top: 0.25rem;
}
</style>
</head>
<body class="bg-slate-50">
<!-- Main Container -->
<div id="main-container" class="flex-grow w-full max-w-7xl mx-auto px-4 py-8">
<!-- Interactive Comparison Card -->
<div class="bg-white shadow-lg rounded-xl mb-6">
<div class="p-6 flex justify-between items-start gap-4">
<div>
<h2 id="interactive-comparison-title" class="text-xl font-semibold text-slate-900">Interactive Image Comparison</h2>
<p id="interactive-comparison-desc" class="text-sm text-slate-500 mt-1">Drag the slider to compare. Click and drag images in the viewer to pan.</p>
<button id="reset-positions-btn" class="text-blue-600 hover:text-blue-800 text-xs font-semibold mt-1 p-0 h-auto disabled:opacity-50 disabled:cursor-not-allowed">
<i data-lucide="move" class="inline-block mr-1 h-3 w-3"></i>
<span id="reset-positions-text">Reset Positions</span>
</button>
</div>
<div>
<button id="download-btn" class="bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded-lg shadow-sm transition-transform transform hover:scale-105 inline-flex items-center justify-center whitespace-nowrap">
<i data-lucide="download" class="inline-block mr-2 h-4 w-4"></i>
<span id="download-text">Save Image</span>
</button>
</div>
</div>
<div class="px-6 pb-6 pt-0">
<!-- Juxtapose Viewer Area -->
<div id="juxtapose-container" class="relative w-full aspect-[4/3] rounded-lg overflow-hidden border group bg-slate-100 select-none cursor-grab">
<!-- This div will be populated by JavaScript -->
</div>
</div>
</div>
<!-- Image Upload/Selection Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<!-- Before Image Card -->
<div class="bg-white rounded-lg overflow-hidden shadow-lg">
<div class="p-6 border-b"><h3 id="before-image-title" class="text-lg font-semibold text-slate-800">Before Image</h3></div>
<div id="before-drop-wrapper" class="p-4">
<div id="before-dropzone" class="aspect-[4/3] w-full rounded-md border-2 border-dashed flex flex-col items-center justify-center p-4 text-center transition-colors border-slate-300 bg-slate-50">
<i data-lucide="upload-cloud" class="h-12 w-12 text-slate-400 mb-3"></i>
<p id="dropzone-text-before" class="text-slate-500 text-sm mb-1">Drag & drop image here, or</p>
<button id="click-upload-before" class="text-blue-600 hover:underline text-sm font-semibold">click to upload</button>
</div>
<input type="file" accept="image/*" id="before-input" class="hidden"/>
<div class="mt-4">
<button id="upload-btn-before" class="w-full bg-white hover:bg-slate-100 text-slate-700 font-semibold py-2 px-4 border border-slate-300 rounded-md shadow-sm inline-flex items-center justify-center">
<i data-lucide="image-plus" class="mr-2 h-4 w-4"></i>
<span id="upload-new-text-before">Upload New</span>
</button>
</div>
</div>
</div>
<!-- After Image Card -->
<div class="bg-white rounded-lg overflow-hidden shadow-lg">
<div class="p-6 border-b"><h3 id="after-image-title" class="text-lg font-semibold text-slate-800">After Image</h3></div>
<div id="after-drop-wrapper" class="p-4">
<div id="after-dropzone" class="aspect-[4/3] w-full rounded-md border-2 border-dashed flex flex-col items-center justify-center p-4 text-center transition-colors border-slate-300 bg-slate-50">
<i data-lucide="upload-cloud" class="h-12 w-12 text-slate-400 mb-3"></i>
<p id="dropzone-text-after" class="text-slate-500 text-sm mb-1">Drag & drop image here, or</p>
<button id="click-upload-after" class="text-blue-600 hover:underline text-sm font-semibold">click to upload</button>
</div>
<input type="file" accept="image/*" id="after-input" class="hidden"/>
<div class="mt-4">
<button id="upload-btn-after" class="w-full bg-white hover:bg-slate-100 text-slate-700 font-semibold py-2 px-4 border border-slate-300 rounded-md shadow-sm inline-flex items-center justify-center">
<i data-lucide="image-plus" class="mr-2 h-4 w-4"></i>
<span id="upload-new-text-after">Upload New</span>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Toast Notification Container -->
<div id="toast-container" class="toast-container"></div>
<script>
document.addEventListener('DOMContentLoaded', () => {
// --- ENGLISH TEXT ---
const textContent = {
interactiveComparison: "Interactive Image Comparison",
dragSliderDesc: "Drag the slider to compare. Click and drag images in the viewer to pan.",
resetPositions: "Reset Positions",
beforeImageTitle: "Before Image",
afterImageTitle: "After Image",
changeUpload: "Change/Upload",
uploadNew: "Upload New",
downloadText: "Save Image",
comparisonArea: "Comparison Area",
selectOrUpload: "Select or upload 'Before' and 'After' images in the slots below to activate the interactive slider.",
dropZoneText: "Drag & drop image here, or",
clickToUpload: "click to upload",
toastInvalidFileTitle: "Invalid File",
toastInvalidFileDesc: "Please upload a valid image file.",
toastMissingImagesTitle: "Missing Images",
toastMissingImagesDesc: "Please select both a Before and After image to save.",
labelBefore: "BEFORE",
labelAfter: "AFTER",
};
// --- APPLICATION STATE ---
let state = {
t: textContent,
beforeImage: null,
afterImage: null,
sliderValue: 50,
beforeImageOffset: { x: 0, y: 0 },
afterImageOffset: { x: 0, y: 0 },
// Dragging states
isDraggingSliderHandle: false,
activePanTarget: null, // 'before' or 'after'
dragStartCoords: { x: 0, y: 0 },
initialPanOffset: { x: 0, y: 0 },
sliderDragInitialX: 0,
sliderDragInitialValue: 50,
};
// --- DOM ELEMENT REFERENCES ---
const DOMElements = {
mainContainer: document.getElementById('main-container'),
interactiveComparisonTitle: document.getElementById('interactive-comparison-title'),
interactiveComparisonDesc: document.getElementById('interactive-comparison-desc'),
resetPositionsBtn: document.getElementById('reset-positions-btn'),
resetPositionsText: document.getElementById('reset-positions-text'),
juxtaposeContainer: document.getElementById('juxtapose-container'),
beforeImageTitle: document.getElementById('before-image-title'),
afterImageTitle: document.getElementById('after-image-title'),
beforeDropWrapper: document.getElementById('before-drop-wrapper'),
afterDropWrapper: document.getElementById('after-drop-wrapper'),
beforeDropzone: document.getElementById('before-dropzone'),
afterDropzone: document.getElementById('after-dropzone'),
beforeInput: document.getElementById('before-input'),
afterInput: document.getElementById('after-input'),
uploadBtnBefore: document.getElementById('upload-btn-before'),
uploadBtnAfter: document.getElementById('upload-btn-after'),
downloadBtn: document.getElementById('download-btn'),
dropzoneTextBefore: document.getElementById('dropzone-text-before'),
clickUploadBefore: document.getElementById('click-upload-before'),
uploadNewTextBefore: document.getElementById('upload-new-text-before'),
dropzoneTextAfter: document.getElementById('dropzone-text-after'),
clickUploadAfter: document.getElementById('click-upload-after'),
uploadNewTextAfter: document.getElementById('upload-new-text-after'),
downloadText: document.getElementById('download-text'),
};
// --- LOCAL STORAGE FUNCTIONS ---
function saveStateToLocalStorage() {
const dataToSave = {
beforeImage: state.beforeImage,
afterImage: state.afterImage,
};
localStorage.setItem('juxtaposeAppData', JSON.stringify(dataToSave));
}
function loadStateFromLocalStorage() {
const savedData = localStorage.getItem('juxtaposeAppData');
if (savedData) {
const parsedData = JSON.parse(savedData);
state.beforeImage = parsedData.beforeImage || null;
state.afterImage = parsedData.afterImage || null;
}
}
// --- RENDER & UI UPDATE FUNCTIONS ---
function applyTextContent() { const t = state.t; DOMElements.interactiveComparisonTitle.textContent = t.interactiveComparison; DOMElements.interactiveComparisonDesc.textContent = t.dragSliderDesc; DOMElements.resetPositionsText.textContent = t.resetPositions; DOMElements.beforeImageTitle.textContent = t.beforeImageTitle; DOMElements.afterImageTitle.textContent = t.afterImageTitle; DOMElements.dropzoneTextBefore.textContent = t.dropZoneText; DOMElements.clickUploadBefore.textContent = t.clickToUpload; DOMElements.dropzoneTextAfter.textContent = t.dropZoneText; DOMElements.clickUploadAfter.textContent = t.clickToUpload; DOMElements.downloadText.textContent = t.downloadText; const beforeBtn = DOMElements.uploadBtnBefore; DOMElements.uploadNewTextBefore.textContent = state.beforeImage ? t.changeUpload : t.uploadNew; let existingBeforeIcon = beforeBtn.querySelector('i, svg'); if (existingBeforeIcon) existingBeforeIcon.remove(); const newBeforeIcon = document.createElement('i'); newBeforeIcon.setAttribute('data-lucide', state.beforeImage ? 'replace' : 'image-plus'); newBeforeIcon.className = 'mr-2 h-4 w-4'; beforeBtn.prepend(newBeforeIcon); const afterBtn = DOMElements.uploadBtnAfter; DOMElements.uploadNewTextAfter.textContent = state.afterImage ? t.changeUpload : t.uploadNew; let existingAfterIcon = afterBtn.querySelector('i, svg'); if (existingAfterIcon) existingAfterIcon.remove(); const newAfterIcon = document.createElement('i'); newAfterIcon.setAttribute('data-lucide', state.afterImage ? 'replace' : 'image-plus'); newAfterIcon.className = 'mr-2 h-4 w-4'; afterBtn.prepend(newAfterIcon); lucide.createIcons(); }
function showToast({ title, description, variant = 'default' }) { const container = document.getElementById('toast-container'); const toast = document.createElement('div'); toast.className = `toast ${variant}`; const iconName = variant === 'destructive' ? 'alert-circle' : 'check-circle'; toast.innerHTML = `<i data-lucide="${iconName}" class="toast-icon ${variant === 'destructive' ? 'text-red-500' : 'text-green-500'}"></i><div class="toast-content"><div class="toast-title">${title}</div><div class="toast-description">${description}</div></div>`; container.appendChild(toast); lucide.createIcons(); setTimeout(() => toast.classList.add('show'), 10); setTimeout(() => { toast.classList.remove('show'); toast.addEventListener('transitionend', () => toast.remove()); }, 5000); }
function renderJuxtaposeViewer() { const { beforeImage, afterImage, sliderValue, beforeImageOffset, afterImageOffset } = state; const t = state.t; if (beforeImage && afterImage) { DOMElements.juxtaposeContainer.innerHTML = `<div class="absolute inset-0 z-0" style="background-image: url(${afterImage}); background-position: calc(50% + ${afterImageOffset.x}px) calc(50% + ${afterImageOffset.y}px); background-size: cover; background-repeat: no-repeat;"><div class="absolute top-2 right-2 px-2 py-1 bg-black/50 text-white text-xs font-semibold rounded pointer-events-none">${t.labelAfter}</div></div><div class="absolute inset-0 z-[1]" style="clip-path: inset(0 ${100 - sliderValue}% 0 0); background-image: url(${beforeImage}); background-position: calc(50% + ${beforeImageOffset.x}px) calc(50% + ${beforeImageOffset.y}px); background-size: cover; background-repeat: no-repeat;"><div class="absolute top-2 left-2 px-2 py-1 bg-black/50 text-white text-xs font-semibold rounded pointer-events-none">${t.labelBefore}</div></div><div class="absolute inset-y-0 w-1 bg-blue-600/70 pointer-events-none z-[2] shadow-md opacity-0 group-hover:opacity-100 focus-within:opacity-100" style="left: ${sliderValue}%; transform: translateX(-50%);"></div><div id="slider-handle" class="absolute top-1/2 w-10 h-10 bg-blue-600 rounded-full border-2 border-white shadow-lg z-[4] flex items-center justify-center cursor-ew-resize opacity-0 group-hover:opacity-100 focus-within:opacity-100" style="left: ${sliderValue}%; transform: translate(-50%, -50%);"><i data-lucide="chevrons-left-right" class="w-5 h-5 text-white"></i></div>`; document.getElementById('slider-handle').addEventListener('mousedown', handleSliderHandleInteractionStart); document.getElementById('slider-handle').addEventListener('touchstart', handleSliderHandleInteractionStart, { passive: true }); DOMElements.juxtaposeContainer.style.cursor = 'grab'; } else { DOMElements.juxtaposeContainer.innerHTML = `<div class="relative w-full h-full flex flex-col items-center justify-center text-slate-500 p-8 text-center"><i data-lucide="layers" class="h-16 w-16 mb-4 text-slate-400"></i><h3 class="text-xl font-semibold mb-2 text-slate-700">${t.comparisonArea}</h3><p>${t.selectOrUpload}</p></div>`; DOMElements.juxtaposeContainer.style.cursor = 'default'; } DOMElements.resetPositionsBtn.disabled = !beforeImage && !afterImage; lucide.createIcons(); }
function renderDropZone(type) { const imageSrc = state[type === 'before' ? 'beforeImage' : 'afterImage']; const dropzone = DOMElements[type === 'before' ? 'beforeDropzone' : 'afterDropzone']; const t = state.t; if (imageSrc) { dropzone.innerHTML = `<div class="relative aspect-[4/3] w-full rounded-md overflow-hidden border bg-slate-100"><img src="${imageSrc}" alt="${type} image preview" class="object-contain w-full h-full"></div>`; dropzone.className = "aspect-[4/3] w-full rounded-md p-0 text-center"; } else { dropzone.innerHTML = `<i data-lucide="upload-cloud" class="h-12 w-12 text-slate-400 mb-3"></i><p id="dropzone-text-${type}" class="text-slate-500 text-sm mb-1">${t.dropZoneText}</p><button id="click-upload-${type}" class="text-blue-600 hover:underline text-sm font-semibold">${t.clickToUpload}</button>`; dropzone.className = "aspect-[4/3] w-full rounded-md border-2 border-dashed flex flex-col items-center justify-center p-4 text-center transition-colors border-slate-300 bg-slate-50"; document.getElementById(`click-upload-${type}`).addEventListener('click', () => DOMElements[`${type}Input`].click()); } applyTextContent(); }
// --- EVENT HANDLERS ---
function resetImageOffsets() { state.beforeImageOffset = { x: 0, y: 0 }; state.afterImageOffset = { x: 0, y: 0 }; renderJuxtaposeViewer(); }
function processImageFile(file, type) { if (file && file.type.startsWith('image/')) { const reader = new FileReader(); reader.onloadend = () => { const result = reader.result; if (type === 'before') state.beforeImage = result; else state.afterImage = result; saveStateToLocalStorage(); resetImageOffsets(); renderDropZone(type); renderJuxtaposeViewer(); }; reader.readAsDataURL(file); } else { showToast({ title: state.t.toastInvalidFileTitle, description: state.t.toastInvalidFileDesc, variant: "destructive" }); } }
function handleFileInputChange(event, type) { const file = event.target.files?.[0]; if (file) processImageFile(file, type); }
function handleDrop(event, type) { event.preventDefault(); event.stopPropagation(); DOMElements[`${type}Dropzone`].classList.remove('border-blue-500', 'bg-blue-50', 'ring-2', 'ring-blue-200'); const file = event.dataTransfer.files?.[0]; if (file) processImageFile(file, type); }
function handleDragOver(event, type) { event.preventDefault(); event.stopPropagation(); DOMElements[`${type}Dropzone`].classList.add('border-blue-500', 'bg-blue-50', 'ring-2', 'ring-blue-200'); }
function handleDragLeave(event, type) { event.preventDefault(); event.stopPropagation(); DOMElements[`${type}Dropzone`].classList.remove('border-blue-500', 'bg-blue-50', 'ring-2', 'ring-blue-200'); }
function handleDownloadComparison() {
const { beforeImage, afterImage, sliderValue, t } = state;
if (!beforeImage || !afterImage) {
showToast({ title: t.toastMissingImagesTitle, description: t.toastMissingImagesDesc, variant: "destructive" });
return;
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img1 = new Image();
const img2 = new Image();
let loadedCount = 0;
const onImagesLoaded = () => {
loadedCount++;
if (loadedCount === 2) {
canvas.width = img1.naturalWidth;
canvas.height = img1.naturalHeight;
// Draw after image first (the base)
ctx.drawImage(img2, 0, 0);
// Create a clipping region for the before image
const clipWidth = canvas.width * (sliderValue / 100);
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, clipWidth, canvas.height);
ctx.clip();
// Draw the before image within the clipped region
ctx.drawImage(img1, 0, 0);
ctx.restore();
// Trigger download
const link = document.createElement('a');
link.download = 'comparison.png';
link.href = canvas.toDataURL('image/png');
link.click();
}
};
img1.onload = onImagesLoaded;
img2.onload = onImagesLoaded;
img1.src = beforeImage;
img2.src = afterImage;
}
function handleSliderHandleInteractionStart(e) { e.stopPropagation(); state.isDraggingSliderHandle = true; state.sliderDragInitialValue = state.sliderValue; state.sliderDragInitialX = e.type === 'touchstart' ? e.touches[0].clientX : e.clientX; document.body.style.userSelect = 'none'; document.body.style.cursor = 'ew-resize'; }
function handleContainerInteractionStart(e) { if (state.isDraggingSliderHandle) return; const handle = document.getElementById('slider-handle'); if (handle && handle.contains(e.target)) return; const { beforeImage, afterImage, sliderValue, beforeImageOffset, afterImageOffset } = state; if (!beforeImage && !afterImage) return; const rect = DOMElements.juxtaposeContainer.getBoundingClientRect(); const clientX = e.type === 'touchstart' ? e.touches[0].clientX : e.clientX; const clientY = e.type === 'touchstart' ? e.touches[0].clientY : e.clientY; const clickXRelative = clientX - rect.left; let determinedTarget; if (clickXRelative < rect.width * (sliderValue / 100)) { if (!beforeImage) return; determinedTarget = 'before'; state.initialPanOffset = { ...beforeImageOffset }; } else { if (!afterImage) return; determinedTarget = 'after'; state.initialPanOffset = { ...afterImageOffset }; } state.activePanTarget = determinedTarget; state.dragStartCoords = { x: clientX, y: clientY }; document.body.style.userSelect = 'none'; DOMElements.juxtaposeContainer.style.cursor = 'grabbing'; if (e.type === 'touchstart' && e.cancelable) e.preventDefault(); }
function handleInteractionMove(e) { window.requestAnimationFrame(() => { const clientX = e.type === 'touchmove' ? e.touches[0].clientX : e.clientX; if (state.isDraggingSliderHandle) { const dx = clientX - state.sliderDragInitialX; const containerWidth = DOMElements.juxtaposeContainer.offsetWidth; if (containerWidth === 0) return; const dValue = (dx / containerWidth) * 100; let newSliderValue = state.sliderDragInitialValue + dValue; state.sliderValue = Math.max(0, Math.min(100, newSliderValue)); } else if (state.activePanTarget) { if (e.type === 'touchmove' && e.cancelable) e.preventDefault(); const clientY = e.type === 'touchmove' ? e.touches[0].clientY : e.clientY; const dx = clientX - state.dragStartCoords.x; const dy = clientY - state.dragStartCoords.y; const newOffset = { x: state.initialPanOffset.x + dx, y: state.initialPanOffset.y + dy }; if (state.activePanTarget === 'before') state.beforeImageOffset = newOffset; else state.afterImageOffset = newOffset; } renderJuxtaposeViewer(); }); }
function handleInteractionEnd() { state.isDraggingSliderHandle = false; state.activePanTarget = null; document.body.style.userSelect = ''; document.body.style.cursor = ''; if (state.beforeImage && state.afterImage) DOMElements.juxtaposeContainer.style.cursor = 'grab'; else DOMElements.juxtaposeContainer.style.cursor = 'default'; }
// --- INITIALIZATION ---
function init() {
loadStateFromLocalStorage();
// Render initial views
applyTextContent();
renderJuxtaposeViewer();
renderDropZone('before');
renderDropZone('after');
// Attach event listeners
DOMElements.resetPositionsBtn.addEventListener('click', resetImageOffsets);
// Correctly attach listeners once
DOMElements.uploadBtnBefore.addEventListener('click', () => DOMElements.beforeInput.click());
DOMElements.uploadBtnAfter.addEventListener('click', () => DOMElements.afterInput.click());
DOMElements.beforeDropWrapper.addEventListener('dragover', (e) => handleDragOver(e, 'before'));
DOMElements.beforeDropWrapper.addEventListener('dragleave', (e) => handleDragLeave(e, 'before'));
DOMElements.beforeDropWrapper.addEventListener('drop', (e) => handleDrop(e, 'before'));
DOMElements.beforeInput.addEventListener('change', (e) => handleFileInputChange(e, 'before'));
DOMElements.afterDropWrapper.addEventListener('dragover', (e) => handleDragOver(e, 'after'));
DOMElements.afterDropWrapper.addEventListener('dragleave', (e) => handleDragLeave(e, 'after'));
DOMElements.afterDropWrapper.addEventListener('drop', (e) => handleDrop(e, 'after'));
DOMElements.afterInput.addEventListener('change', (e) => handleFileInputChange(e, 'after'));
DOMElements.downloadBtn.addEventListener('click', handleDownloadComparison);
DOMElements.juxtaposeContainer.addEventListener('mousedown', handleContainerInteractionStart);
DOMElements.juxtaposeContainer.addEventListener('touchstart', handleContainerInteractionStart, { passive: false });
document.addEventListener('mousemove', handleInteractionMove);
document.addEventListener('touchmove', handleInteractionMove, { passive: false });
document.addEventListener('mouseup', handleInteractionEnd);
document.addEventListener('touchend', handleInteractionEnd);
}
// Start the application
init();
});
</script>
<p style="text-align: center; font-size: 14px; color: #888888; margin-top: 25px;">
Created by Ken Ove Ferbu
</p>
</body>
</html>