Skip to content

Instantly share code, notes, and snippets.

@princemaple
Created December 4, 2025 04:01
Show Gist options
  • Select an option

  • Save princemaple/1e5ec9f526f04632d8b50507b3675115 to your computer and use it in GitHub Desktop.

Select an option

Save princemaple/1e5ec9f526f04632d8b50507b3675115 to your computer and use it in GitHub Desktop.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Picture Comparison App</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-100 min-h-screen p-8">
<div class="max-w-7xl mx-auto">
<h1 class="text-4xl font-bold text-gray-800 mb-8 text-center">
Picture Comparison App
</h1>
<!-- Canvas Section -->
<div class="bg-white rounded-lg shadow-lg p-6 mb-6">
<div
class="relative bg-gray-200 rounded-lg overflow-hidden flex items-center justify-center"
style="height: 600px"
>
<canvas id="canvas" class="cursor-move"></canvas>
<!-- Swap Layers Button -->
<button
id="swap-layers"
class="absolute top-4 right-4 bg-purple-500 hover:bg-purple-600 text-white font-semibold py-2 px-4 rounded-lg shadow-lg transition duration-200 flex items-center gap-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"
></path>
</svg>
Swap
</button>
</div>
<p class="text-sm text-gray-600 mt-4 text-center">
Click on a picture control below to select it, then drag on canvas to move or
use mouse wheel to scale
</p>
</div>
<!-- Controls Section -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Picture 1 Controls -->
<div
id="controls1"
class="bg-white rounded-lg shadow-lg p-6 cursor-pointer transition-all duration-200 border-2 border-transparent"
>
<div class="flex items-center justify-between mb-4">
<h2 class="text-2xl font-semibold text-blue-600">Picture 1</h2>
<button
id="reset1"
class="bg-blue-500 hover:bg-blue-600 text-white font-semibold py-2 px-4 rounded-lg transition duration-200"
>
Reset
</button>
</div>
<input
type="file"
id="file1"
accept="image/*"
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 mb-4"
/>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"
>X: <span id="x1-value">0</span>px</label
>
<input
type="range"
id="x1"
min="-500"
max="500"
value="0"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"
>Y: <span id="y1-value">0</span>px</label
>
<input
type="range"
id="y1"
min="-500"
max="500"
value="0"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"
>Opacity: <span id="opacity1-value">100</span>%</label
>
<input
type="range"
id="opacity1"
min="0"
max="100"
value="100"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"
>Scale: <span id="scale1-value">1.0</span>x</label
>
<input
type="range"
id="scale1"
min="10"
max="200"
value="100"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"
>Skew X: <span id="skewX1-value">0</span>°</label
>
<input
type="range"
id="skewX1"
min="-45"
max="45"
value="0"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"
>Skew Y: <span id="skewY1-value">0</span>°</label
>
<input
type="range"
id="skewY1"
min="-45"
max="45"
value="0"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"
>Stretch X: <span id="stretchX1-value">1.0</span>x</label
>
<input
type="range"
id="stretchX1"
min="10"
max="300"
value="100"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"
>Stretch Y: <span id="stretchY1-value">1.0</span>x</label
>
<input
type="range"
id="stretchY1"
min="10"
max="300"
value="100"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"
>Rotation: <span id="rotation1-value">0</span>°</label
>
<input
type="range"
id="rotation1"
min="-180"
max="180"
value="0"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
</div>
</div>
</div>
<!-- Picture 2 Controls -->
<div
id="controls2"
class="bg-white rounded-lg shadow-lg p-6 cursor-pointer transition-all duration-200 border-2 border-transparent"
>
<div class="flex items-center justify-between mb-4">
<h2 class="text-2xl font-semibold text-green-600">Picture 2</h2>
<button
id="reset2"
class="bg-green-500 hover:bg-green-600 text-white font-semibold py-2 px-4 rounded-lg transition duration-200"
>
Reset
</button>
</div>
<input
type="file"
id="file2"
accept="image/*"
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-green-50 file:text-green-700 hover:file:bg-green-100 mb-4"
/>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"
>X: <span id="x2-value">0</span>px</label
>
<input
type="range"
id="x2"
min="-500"
max="500"
value="0"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-green-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"
>Y: <span id="y2-value">0</span>px</label
>
<input
type="range"
id="y2"
min="-500"
max="500"
value="0"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-green-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"
>Opacity: <span id="opacity2-value">100</span>%</label
>
<input
type="range"
id="opacity2"
min="0"
max="100"
value="100"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-green-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"
>Scale: <span id="scale2-value">1.0</span>x</label
>
<input
type="range"
id="scale2"
min="10"
max="200"
value="100"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-green-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"
>Skew X: <span id="skewX2-value">0</span>°</label
>
<input
type="range"
id="skewX2"
min="-45"
max="45"
value="0"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-green-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"
>Skew Y: <span id="skewY2-value">0</span>°</label
>
<input
type="range"
id="skewY2"
min="-45"
max="45"
value="0"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-green-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"
>Stretch X: <span id="stretchX2-value">1.0</span>x</label
>
<input
type="range"
id="stretchX2"
min="10"
max="300"
value="100"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-green-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"
>Stretch Y: <span id="stretchY2-value">1.0</span>x</label
>
<input
type="range"
id="stretchY2"
min="10"
max="300"
value="100"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-green-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"
>Rotation: <span id="rotation2-value">0</span>°</label
>
<input
type="range"
id="rotation2"
min="-180"
max="180"
value="0"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-green-600"
/>
</div>
</div>
</div>
</div>
</div>
<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
// Set canvas size
canvas.width = 800;
canvas.height = 600;
// Image objects
let img1 = null;
let img2 = null;
let img1Loaded = false;
let img2Loaded = false;
// Image properties
let props1 = {
x: 0,
y: 0,
opacity: 1,
scale: 1,
rotation: 0,
skewX: 0,
skewY: 0,
stretchX: 1,
stretchY: 1,
};
let props2 = {
x: 0,
y: 0,
opacity: 1,
scale: 1,
rotation: 0,
skewX: 0,
skewY: 0,
stretchX: 1,
stretchY: 1,
};
// Layer order (true = img1 on top, false = img2 on top)
let img1OnTop = false;
// Selection state
let selected = {img1: false, img2: false};
// Drag state
let isDragging = false;
let dragStartX = 0;
let dragStartY = 0;
// Update UI based on selection
function updateSelectionUI() {
const controls1 = document.getElementById('controls1');
const controls2 = document.getElementById('controls2');
if (selected.img1) {
controls1.classList.add('border-blue-500', 'shadow-xl', 'shadow-blue-200');
} else {
controls1.classList.remove('border-blue-500', 'shadow-xl', 'shadow-blue-200');
}
if (selected.img2) {
controls2.classList.add('border-green-500', 'shadow-xl', 'shadow-green-200');
} else {
controls2.classList.remove('border-green-500', 'shadow-xl', 'shadow-green-200');
}
}
// Selection handlers
document.getElementById('controls1').addEventListener('click', e => {
if (e.ctrlKey || e.metaKey) {
selected.img1 = !selected.img1;
} else {
selected.img1 = true;
selected.img2 = false;
}
updateSelectionUI();
});
document.getElementById('controls2').addEventListener('click', e => {
if (e.ctrlKey || e.metaKey) {
selected.img2 = !selected.img2;
} else {
selected.img2 = true;
selected.img1 = false;
}
updateSelectionUI();
});
// Canvas click to deselect
canvas.addEventListener('click', e => {
if (!isDragging) {
selected.img1 = false;
selected.img2 = false;
updateSelectionUI();
}
});
// Mouse drag handlers
canvas.addEventListener('mousedown', e => {
if (selected.img1 || selected.img2) {
isDragging = true;
dragStartX = e.offsetX;
dragStartY = e.offsetY;
e.preventDefault();
}
});
canvas.addEventListener('mousemove', e => {
if (isDragging) {
const deltaX = e.offsetX - dragStartX;
const deltaY = e.offsetY - dragStartY;
if (selected.img1) {
props1.x += deltaX;
props1.y += deltaY;
updateSliders1();
}
if (selected.img2) {
props2.x += deltaX;
props2.y += deltaY;
updateSliders2();
}
dragStartX = e.offsetX;
dragStartY = e.offsetY;
draw();
e.preventDefault();
}
});
canvas.addEventListener('mouseup', () => {
isDragging = false;
});
canvas.addEventListener('mouseleave', () => {
isDragging = false;
});
// Mouse wheel handler
canvas.addEventListener('wheel', e => {
e.preventDefault();
const delta = -e.deltaY * 0.0002;
if (selected.img1) {
props1.scale = Math.max(0.1, Math.min(2, props1.scale + delta));
updateSliders1();
}
if (selected.img2) {
props2.scale = Math.max(0.1, Math.min(2, props2.scale + delta));
updateSliders2();
}
draw();
});
// File input handlers
document.getElementById('file1').addEventListener('change', e => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = event => {
img1 = new Image();
img1.onload = () => {
img1Loaded = true;
draw();
};
img1.src = event.target.result;
};
reader.readAsDataURL(file);
}
e.stopPropagation();
});
document.getElementById('file2').addEventListener('change', e => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = event => {
img2 = new Image();
img2.onload = () => {
img2Loaded = true;
draw();
};
img2.src = event.target.result;
};
reader.readAsDataURL(file);
}
e.stopPropagation();
});
// Update slider values for Picture 1
function updateSliders1() {
document.getElementById('x1').value = props1.x;
document.getElementById('y1').value = props1.y;
document.getElementById('scale1').value = props1.scale * 100;
document.getElementById('rotation1').value = props1.rotation;
document.getElementById('skewX1').value = props1.skewX;
document.getElementById('skewY1').value = props1.skewY;
document.getElementById('stretchX1').value = props1.stretchX * 100;
document.getElementById('stretchY1').value = props1.stretchY * 100;
document.getElementById('x1-value').textContent = Math.round(props1.x);
document.getElementById('y1-value').textContent = Math.round(props1.y);
document.getElementById('scale1-value').textContent = props1.scale.toFixed(2);
document.getElementById('rotation1-value').textContent = Math.round(
props1.rotation,
);
document.getElementById('skewX1-value').textContent = Math.round(props1.skewX);
document.getElementById('skewY1-value').textContent = Math.round(props1.skewY);
document.getElementById('stretchX1-value').textContent = props1.stretchX.toFixed(2);
document.getElementById('stretchY1-value').textContent = props1.stretchY.toFixed(2);
}
// Update slider values for Picture 2
function updateSliders2() {
document.getElementById('x2').value = props2.x;
document.getElementById('y2').value = props2.y;
document.getElementById('scale2').value = props2.scale * 100;
document.getElementById('rotation2').value = props2.rotation;
document.getElementById('skewX2').value = props2.skewX;
document.getElementById('skewY2').value = props2.skewY;
document.getElementById('stretchX2').value = props2.stretchX * 100;
document.getElementById('stretchY2').value = props2.stretchY * 100;
document.getElementById('x2-value').textContent = Math.round(props2.x);
document.getElementById('y2-value').textContent = Math.round(props2.y);
document.getElementById('scale2-value').textContent = props2.scale.toFixed(2);
document.getElementById('rotation2-value').textContent = Math.round(
props2.rotation,
);
document.getElementById('skewX2-value').textContent = Math.round(props2.skewX);
document.getElementById('skewY2-value').textContent = Math.round(props2.skewY);
document.getElementById('stretchX2-value').textContent = props2.stretchX.toFixed(2);
document.getElementById('stretchY2-value').textContent = props2.stretchY.toFixed(2);
}
// Control handlers for Picture 1
document.getElementById('x1').addEventListener('input', e => {
props1.x = parseInt(e.target.value);
document.getElementById('x1-value').textContent = props1.x;
draw();
e.stopPropagation();
});
document.getElementById('y1').addEventListener('input', e => {
props1.y = parseInt(e.target.value);
document.getElementById('y1-value').textContent = props1.y;
draw();
e.stopPropagation();
});
document.getElementById('opacity1').addEventListener('input', e => {
props1.opacity = parseInt(e.target.value) / 100;
document.getElementById('opacity1-value').textContent = e.target.value;
draw();
e.stopPropagation();
});
document.getElementById('scale1').addEventListener('input', e => {
props1.scale = parseInt(e.target.value) / 100;
document.getElementById('scale1-value').textContent = props1.scale.toFixed(2);
draw();
e.stopPropagation();
});
document.getElementById('rotation1').addEventListener('input', e => {
props1.rotation = parseInt(e.target.value);
document.getElementById('rotation1-value').textContent = props1.rotation;
draw();
e.stopPropagation();
});
document.getElementById('skewX1').addEventListener('input', e => {
props1.skewX = parseInt(e.target.value);
document.getElementById('skewX1-value').textContent = props1.skewX;
draw();
e.stopPropagation();
});
document.getElementById('skewY1').addEventListener('input', e => {
props1.skewY = parseInt(e.target.value);
document.getElementById('skewY1-value').textContent = props1.skewY;
draw();
e.stopPropagation();
});
document.getElementById('stretchX1').addEventListener('input', e => {
props1.stretchX = parseInt(e.target.value) / 100;
document.getElementById('stretchX1-value').textContent = props1.stretchX.toFixed(2);
draw();
e.stopPropagation();
});
document.getElementById('stretchY1').addEventListener('input', e => {
props1.stretchY = parseInt(e.target.value) / 100;
document.getElementById('stretchY1-value').textContent = props1.stretchY.toFixed(2);
draw();
e.stopPropagation();
});
// Control handlers for Picture 2
document.getElementById('x2').addEventListener('input', e => {
props2.x = parseInt(e.target.value);
document.getElementById('x2-value').textContent = props2.x;
draw();
e.stopPropagation();
});
document.getElementById('y2').addEventListener('input', e => {
props2.y = parseInt(e.target.value);
document.getElementById('y2-value').textContent = props2.y;
draw();
e.stopPropagation();
});
document.getElementById('opacity2').addEventListener('input', e => {
props2.opacity = parseInt(e.target.value) / 100;
document.getElementById('opacity2-value').textContent = e.target.value;
draw();
e.stopPropagation();
});
document.getElementById('scale2').addEventListener('input', e => {
props2.scale = parseInt(e.target.value) / 100;
document.getElementById('scale2-value').textContent = props2.scale.toFixed(2);
draw();
e.stopPropagation();
});
document.getElementById('rotation2').addEventListener('input', e => {
props2.rotation = parseInt(e.target.value);
document.getElementById('rotation2-value').textContent = props2.rotation;
draw();
e.stopPropagation();
});
document.getElementById('skewX2').addEventListener('input', e => {
props2.skewX = parseInt(e.target.value);
document.getElementById('skewX2-value').textContent = props2.skewX;
draw();
e.stopPropagation();
});
document.getElementById('skewY2').addEventListener('input', e => {
props2.skewY = parseInt(e.target.value);
document.getElementById('skewY2-value').textContent = props2.skewY;
draw();
e.stopPropagation();
});
document.getElementById('stretchX2').addEventListener('input', e => {
props2.stretchX = parseInt(e.target.value) / 100;
document.getElementById('stretchX2-value').textContent = props2.stretchX.toFixed(2);
draw();
e.stopPropagation();
});
document.getElementById('stretchY2').addEventListener('input', e => {
props2.stretchY = parseInt(e.target.value) / 100;
document.getElementById('stretchY2-value').textContent = props2.stretchY.toFixed(2);
draw();
e.stopPropagation();
});
// Reset buttons
document.getElementById('reset1').addEventListener('click', e => {
props1 = {x: 0, y: 0, opacity: 1, scale: 1, rotation: 0, skewX: 0, skewY: 0, stretchX: 1, stretchY: 1};
updateSliders1();
document.getElementById('opacity1').value = 100;
document.getElementById('opacity1-value').textContent = 100;
draw();
e.stopPropagation();
});
document.getElementById('reset2').addEventListener('click', e => {
props2 = {x: 0, y: 0, opacity: 1, scale: 1, rotation: 0, skewX: 0, skewY: 0, stretchX: 1, stretchY: 1};
updateSliders2();
document.getElementById('opacity2').value = 100;
document.getElementById('opacity2-value').textContent = 100;
draw();
e.stopPropagation();
});
// Swap layers button
document.getElementById('swap-layers').addEventListener('click', () => {
img1OnTop = !img1OnTop;
draw();
});
// Draw function
function draw() {
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Fill with white background
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw images in order
if (img1OnTop) {
drawImage(img2, props2);
drawImage(img1, props1);
} else {
drawImage(img1, props1);
drawImage(img2, props2);
}
}
function drawImage(img, props) {
if (!img) return;
ctx.save();
ctx.globalAlpha = props.opacity;
// Calculate scaled dimensions with stretch
const scaledWidth = img.width * props.scale * props.stretchX;
const scaledHeight = img.height * props.scale * props.stretchY;
// Center the image on canvas and apply offset
const x = canvas.width / 2 + props.x;
const y = canvas.height / 2 + props.y;
// Move to the center point, apply transformations
ctx.translate(x, y);
ctx.rotate((props.rotation * Math.PI) / 180);
ctx.transform(
1,
(props.skewY * Math.PI) / 180,
(props.skewX * Math.PI) / 180,
1,
0,
0,
);
// Draw image centered at the transformation point
ctx.drawImage(img, -scaledWidth / 2, -scaledHeight / 2, scaledWidth, scaledHeight);
ctx.restore();
}
// Initialize
updateSelectionUI();
draw();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment