diff --git a/changedetectionio/static/js/comparison-slider.js b/changedetectionio/static/js/comparison-slider.js new file mode 100644 index 000000000..5477079c6 --- /dev/null +++ b/changedetectionio/static/js/comparison-slider.js @@ -0,0 +1,173 @@ +/** + * Interactive Image Comparison Slider + * + * Allows users to drag a vertical slider to reveal differences between + * two images (before/after comparison). + */ +(function() { + 'use strict'; + + function initComparisonSlider() { + const container = document.getElementById('comparison-container'); + const slider = document.getElementById('comparison-slider'); + const afterWrapper = container ? container.querySelector('.comparison-after') : null; + const handle = slider ? slider.querySelector('.comparison-handle') : null; + + if (!container || !slider || !afterWrapper || !handle) { + return; + } + + let isDragging = false; + let isMouseOverContainer = false; + let mouseY = 0; + + /** + * Update handle position - follows mouse if hovering, or viewport center + */ + function updateHandlePosition() { + const containerRect = container.getBoundingClientRect(); + let handleTop; + + if (isMouseOverContainer) { + // Mouse is over container - follow the mouse Y position + handleTop = mouseY - containerRect.top; + } else { + // Mouse not over container - use viewport center + const viewportCenter = window.innerHeight / 2; + + if (containerRect.top > viewportCenter) { + // Container is below viewport center - position handle at container top + handleTop = 0; + } else if (containerRect.bottom < viewportCenter) { + // Container is above viewport center - position handle at container bottom + handleTop = containerRect.height; + } else { + // Container spans viewport center - position handle at viewport center + handleTop = viewportCenter - containerRect.top; + } + } + + // Ensure handle stays within container bounds + handleTop = Math.max(24, Math.min(handleTop, containerRect.height - 24)); + + handle.style.top = handleTop + 'px'; + } + + /** + * Track mouse position over container + */ + function onMouseMoveContainer(e) { + mouseY = e.clientY; + updateHandlePosition(); + } + + /** + * Mouse enters container + */ + function onMouseEnter() { + isMouseOverContainer = true; + } + + /** + * Mouse leaves container + */ + function onMouseLeave() { + isMouseOverContainer = false; + updateHandlePosition(); + } + + /** + * Update slider position and clip-path + */ + function updateSlider(clientX) { + const rect = container.getBoundingClientRect(); + const pos = Math.max(0, Math.min(clientX - rect.left, rect.width)); + const percentage = (pos / rect.width) * 100; + + slider.style.left = percentage + '%'; + afterWrapper.style.clipPath = `inset(0 0 0 ${percentage}%)`; + } + + /** + * Handle move events (mouse or touch) + */ + function onMove(e) { + if (!isDragging) return; + e.preventDefault(); + + const clientX = e.type.includes('touch') ? e.touches[0].clientX : e.clientX; + updateSlider(clientX); + } + + /** + * Start dragging + */ + function onStart(e) { + isDragging = true; + const clientX = e.type.includes('touch') ? e.touches[0].clientX : e.clientX; + updateSlider(clientX); + } + + /** + * Stop dragging + */ + function onEnd() { + isDragging = false; + } + + /** + * Click anywhere on container to jump slider to that position + */ + function onClick(e) { + if (e.target !== slider && !slider.contains(e.target)) { + const clientX = e.clientX; + updateSlider(clientX); + } + } + + // Mouse events + slider.addEventListener('mousedown', onStart); + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onEnd); + + // Touch events + slider.addEventListener('touchstart', onStart, { passive: false }); + document.addEventListener('touchmove', onMove, { passive: false }); + document.addEventListener('touchend', onEnd); + + // Click anywhere on container to move slider + container.addEventListener('click', onClick); + + // Track mouse position over container for handle following + container.addEventListener('mouseenter', onMouseEnter); + container.addEventListener('mouseleave', onMouseLeave); + container.addEventListener('mousemove', onMouseMoveContainer, { passive: true }); + + // Update handle position on scroll and resize + window.addEventListener('scroll', updateHandlePosition, { passive: true }); + window.addEventListener('resize', updateHandlePosition, { passive: true }); + + // Initial position + updateHandlePosition(); + + // Cleanup on page unload + window.addEventListener('beforeunload', function() { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onEnd); + document.removeEventListener('touchmove', onMove); + document.removeEventListener('touchend', onEnd); + container.removeEventListener('mouseenter', onMouseEnter); + container.removeEventListener('mouseleave', onMouseLeave); + container.removeEventListener('mousemove', onMouseMoveContainer); + window.removeEventListener('scroll', updateHandlePosition); + window.removeEventListener('resize', updateHandlePosition); + }); + } + + // Initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initComparisonSlider); + } else { + initComparisonSlider(); + } +})(); diff --git a/changedetectionio/static/styles/diff-image.css b/changedetectionio/static/styles/diff-image.css new file mode 100644 index 000000000..b63365fdc --- /dev/null +++ b/changedetectionio/static/styles/diff-image.css @@ -0,0 +1 @@ +.comparison-score{padding:1em;background:var(--color-table-stripe);border-radius:4px;margin:1em 0;border:1px solid var(--color-border-table-cell);color:var(--color-text)}.change-detected{color:#d32f2f;font-weight:bold}.no-change{color:#388e3c;font-weight:bold}.comparison-grid{display:grid;grid-template-columns:1fr 1fr;gap:1em;margin:1em 1em}@media(max-width: 1200px){.comparison-grid{grid-template-columns:1fr}}.image-comparison{position:relative;width:100%;overflow:hidden;border:1px solid var(--color-border-table-cell);box-shadow:0 2px 4px rgba(0,0,0,.1);user-select:none}.image-comparison img{display:block;width:100%;height:auto;max-width:100%;border:none;box-shadow:none}.comparison-image-wrapper{position:relative;width:100%;display:flex;align-items:flex-start;justify-content:center;background-color:var(--color-background);background-image:linear-gradient(45deg, var(--color-table-stripe) 25%, transparent 25%),linear-gradient(-45deg, var(--color-table-stripe) 25%, transparent 25%),linear-gradient(45deg, transparent 75%, var(--color-table-stripe) 75%),linear-gradient(-45deg, transparent 75%, var(--color-table-stripe) 75%);background-size:20px 20px;background-position:0 0,0 10px,10px -10px,-10px 0px}.comparison-after{position:absolute;top:0;left:0;width:100%;height:100%;clip-path:inset(0 0 0 50%)}.comparison-slider{position:absolute;top:0;left:50%;width:4px;height:100%;background:#0078e7;cursor:ew-resize;transform:translateX(-2px);z-index:10}.comparison-handle{position:absolute;top:50%;left:50%;width:48px;height:48px;background:#0078e7;border:3px solid #fff;border-radius:50%;transform:translate(-50%, -50%);box-shadow:0 2px 8px rgba(0,0,0,.3);display:flex;align-items:center;justify-content:center;cursor:ew-resize;transition:top .1s ease-out}.comparison-handle::after{content:"⇄";color:#fff;font-size:24px;font-weight:bold;pointer-events:none}.comparison-labels{position:absolute;top:10px;width:100%;display:flex;justify-content:space-between;padding:0 0px;z-index:5;pointer-events:none}.comparison-label{background:rgba(0,0,0,.7);color:#fff;padding:.5em 1em;border-radius:4px;font-size:.9em;font-weight:bold}.screenshot-panel{text-align:center;background:var(--color-background);border:1px solid var(--color-border-table-cell);border-radius:4px;padding:1em;box-shadow:0 2px 4px rgba(0,0,0,.05)}.screenshot-panel h3{margin:0 0 1em 0;font-size:1.1em;color:var(--color-text);border-bottom:2px solid var(--color-background-button-primary);padding-bottom:.5em}.screenshot-panel.diff h3{border-bottom-color:#d32f2f}.screenshot-panel img{max-width:100%;height:auto;border:1px solid var(--color-border-table-cell);box-shadow:0 2px 4px rgba(0,0,0,.1)}.version-selector{display:inline-block;margin:0 .5em}.version-selector label{font-weight:bold;margin-right:.5em;color:var(--color-text)}#settings{background:var(--color-background);padding:1.5em;border-radius:4px;box-shadow:0 2px 4px rgba(0,0,0,.05);margin-bottom:2em;border:1px solid var(--color-border-table-cell)}#settings h2{margin-top:0;color:var(--color-text)}.diff-fieldset{border:none;padding:0;margin:0}.edit-link{float:right;margin-top:-0.5em}.comparison-description{color:var(--color-text-input-description);font-size:.9em;margin-bottom:1em}.download-link{color:var(--color-link);text-decoration:none;display:inline-flex;align-items:center;gap:.3em;font-size:.85em}.download-link:hover{text-decoration:underline}.diff-section-header{color:#d32f2f;font-size:.9em;margin-bottom:1em;font-weight:bold;display:flex;align-items:center;justify-content:center;gap:1em}.comparison-history-section{margin-top:3em;padding:1em;background:var(--color-background);border:1px solid var(--color-border-table-cell);border-radius:4px;box-shadow:0 2px 4px rgba(0,0,0,.05)}.comparison-history-section h3{color:var(--color-text)}.comparison-history-section p{color:var(--color-text-input-description);font-size:.9em}.history-changed-yes{color:#d32f2f;font-weight:bold}.history-changed-no{color:#388e3c}