updraft / components /compare-slider.js
Nicholas Celestin
Build update — 2026-05-22T18:34:00.912Z
3f22414
Raw
History Blame Contribute Delete
18.5 kB
/**
* <compare-slider> — before/after image comparison slider.
*
* The slider is always on. Left-click / drag positions the divider. Right-click
* snaps the divider to whichever extreme it isn't currently nearer to (left
* or right edge), so a single right-click flips between the before-only and
* after-only views.
*/
import { morph } from 'lib/morph';
import { canvasToBlobUrl } from 'lib/canvas';
class CompareSlider extends HTMLElement {
#dragging = false;
#beforeSrc = '';
#afterSrc = '';
#positionFrac = 0.5;
#downloadSrc = '';
#downloadName = '';
#beforeCanvas = null;
#afterCanvas = null;
#lazyBlobURL = '';
#onWindowMouseMove = (e) => { if (this.#dragging) this.#setPosition(this.#getFrac(e)); };
#onWindowTouchMove = (e) => { if (this.#dragging) this.#setPosition(this.#getFrac(e)); };
#onWindowMouseUp = () => {
this.#dragging = false;
this.classList.remove('dragging');
};
#onWindowTouchEnd = () => {
this.#dragging = false;
this.classList.remove('dragging');
};
#knobScheduled = false;
#knobFullWidth = 0;
#resizeObserver = null;
#scheduleKnobUpdate = () => {
if (this.#knobScheduled) return;
this.#knobScheduled = true;
requestAnimationFrame(() => {
this.#knobScheduled = false;
this.#updateKnobPosition();
});
};
#onResize = () => {
// Knob's measured full width may change with font-metric / DPR shifts.
this.#knobFullWidth = 0;
this.#scheduleKnobUpdate();
};
connectedCallback() {
this.classList.add('compare');
// Belt-and-suspenders: also set display:none inline so the slider is
// definitely hidden before show() has been called. The class CSS rule
// does this too, but it lives inside our own <style> block — there's a
// brief window between when the host is added to the DOM (with the
// class but no inner content) and when #render() lands the style tag,
// during which the absolutely-positioned handle would render.
this.style.display = 'none';
this.#render();
this.addEventListener('mousedown', e => {
if (e.button !== 0) return; // left-button only — right-click handled below
e.preventDefault(); this.#dragging = true; this.classList.add('dragging');
this.#setPosition(this.#getFrac(e));
});
this.addEventListener('touchstart', e => {
this.#dragging = true; this.classList.add('dragging');
this.#setPosition(this.#getFrac(e));
}, { passive: true });
this.addEventListener('contextmenu', (e) => {
// Right-click flips the divider to whichever extreme it's currently
// farther from — single click toggles between full-before and full-after.
e.preventDefault();
this.#setPosition(this.#positionFrac < 0.5 ? 1 : 0);
});
window.addEventListener('mousemove', this.#onWindowMouseMove);
window.addEventListener('touchmove', this.#onWindowTouchMove, { passive: true });
window.addEventListener('mouseup', this.#onWindowMouseUp);
window.addEventListener('touchend', this.#onWindowTouchEnd);
// Keep the (fixed-position) knob aligned as the user scrolls / resizes.
window.addEventListener('scroll', this.#scheduleKnobUpdate, { passive: true });
window.addEventListener('resize', this.#onResize);
// native-size mode scrolls inside the slider itself, not the page.
this.addEventListener('scroll', this.#scheduleKnobUpdate, { passive: true });
// Catch view-mode swaps (e.g. .expanded ↔ .native-size) where the host
// class flips but no scroll/resize fires — without this the knob keeps
// its previous logical position until the next drag.
if (typeof ResizeObserver !== 'undefined') {
this.#resizeObserver = new ResizeObserver(this.#scheduleKnobUpdate);
this.#resizeObserver.observe(this);
}
}
disconnectedCallback() {
window.removeEventListener('mousemove', this.#onWindowMouseMove);
window.removeEventListener('touchmove', this.#onWindowTouchMove);
window.removeEventListener('mouseup', this.#onWindowMouseUp);
window.removeEventListener('touchend', this.#onWindowTouchEnd);
window.removeEventListener('scroll', this.#scheduleKnobUpdate);
window.removeEventListener('resize', this.#onResize);
this.#resizeObserver?.disconnect();
this.#resizeObserver = null;
}
/**
* @param {string|HTMLCanvasElement} beforeSrc
* @param {string|HTMLCanvasElement} afterSrc
* @param {{ downloadSrc?: string, downloadName?: string }} [opts]
*/
async show(beforeSrc, afterSrc, opts = {}) {
const canvasMode = beforeSrc instanceof HTMLCanvasElement;
if (canvasMode) {
this.#beforeCanvas = beforeSrc;
this.#afterCanvas = afterSrc;
this.#beforeSrc = '';
this.#afterSrc = '';
this.#downloadSrc = '';
} else {
this.#beforeCanvas = null;
this.#afterCanvas = null;
this.#beforeSrc = beforeSrc;
this.#afterSrc = afterSrc;
this.#downloadSrc = opts.downloadSrc || afterSrc;
}
this.#downloadName = opts.downloadName || 'download.png';
this.#render();
if (!canvasMode) {
const imgs = this.querySelectorAll('.compare-after, .compare-before-wrap img');
await Promise.all([...imgs].map(img =>
img.complete ? Promise.resolve() : new Promise(r => { img.onload = r; })
));
}
const afterEl = this.querySelector('.compare-after');
const natW = canvasMode
? (this.#afterCanvas?.width || 0)
: (afterEl?.naturalWidth || 0);
const natH = canvasMode
? (this.#afterCanvas?.height || 0)
: (afterEl?.naturalHeight || 0);
if (natW && natH) {
this.style.setProperty('--ar', `${natW} / ${natH}`);
// --ar-num is a plain number (w/h) used by calc() to derive the host
// width in fit-height mode. Block elements with width:auto +
// aspect-ratio don't reliably derive width from the aspect — the
// "stretch to containing block" default often wins. Computing the
// width explicitly via calc avoids that ambiguity.
this.style.setProperty('--ar-num', `${natW / natH}`);
this.style.setProperty('--natural-w', `${natW}px`);
}
this.style.display = 'block';
this.#setPosition(this.#positionFrac);
this.#updateKnobPosition();
if (canvasMode) this.#prepareLazyDownload();
}
hide() {
this.style.display = 'none';
const knob = this.querySelector('.handle-knob');
if (knob) knob.style.visibility = 'hidden';
this.style.removeProperty('--ar');
this.style.removeProperty('--ar-num');
this.style.removeProperty('--natural-w');
this.#beforeCanvas = null;
this.#afterCanvas = null;
if (this.#lazyBlobURL) {
URL.revokeObjectURL(this.#lazyBlobURL);
this.#lazyBlobURL = '';
this.#downloadSrc = '';
}
}
async openInTab() {
const url = this.#downloadSrc || await this.#ensureDownloadURL();
if (url) window.open(url, '_blank');
}
async download() {
const url = this.#downloadSrc || await this.#ensureDownloadURL();
if (!url) return;
const a = document.createElement('a');
a.download = this.#downloadName || 'download.png';
a.href = url;
a.click();
}
#getFrac(e) {
// Use the stage rect (the natural-size box that wraps the after canvas
// and the before-wrap clip) instead of the host. In normal modes the
// stage fills the host so this is equivalent, but in native-size mode
// the host is a scrollable container while the stage is at the canvas's
// natural pixel size — only the stage rect tracks the actual image
// coordinates the slider divides.
const stage = this.querySelector('.compare-stage');
const rect = (stage || this).getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
}
#setPosition(frac) {
const clamped = Math.max(0, Math.min(1, frac));
this.#positionFrac = clamped;
const pct = (clamped * 100).toFixed(2) + '%';
const wrap = this.querySelector('.compare-before-wrap');
const handle = this.querySelector('.compare-handle');
if (wrap) wrap.style.width = pct;
if (handle) handle.style.left = pct;
this.#updateKnobPosition();
}
/**
* Position the (fixed) knob inside the visible portion of the slider's
* stage — i.e. the image area, which is the "workspace" the divider
* actually lives in. The knob follows the user vertically (target =
* viewport center) but is clamped to the stage's visible rect on both
* axes. When clamped to a horizontal edge, only the icon for the side
* currently in view is shown, matching the "icon-clipped-by-canvas"
* behavior the slider had at extreme drag positions when the image fit
* the workspace.
*/
#updateKnobPosition() {
const knob = this.querySelector('.handle-knob');
if (!knob) return;
if (getComputedStyle(this).display === 'none') {
knob.style.visibility = 'hidden';
return;
}
const stage = this.querySelector('.compare-stage');
if (!stage) return;
const stageRect = stage.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
// Visible stage = intersection of stage bounds and viewport.
const visLeft = Math.max(stageRect.left, 0);
const visRight = Math.min(stageRect.right, vw);
const visTop = Math.max(stageRect.top, 0);
const visBottom = Math.min(stageRect.bottom, vh);
if (visRight <= visLeft || visBottom <= visTop ||
stageRect.width === 0 || stageRect.height === 0) {
knob.style.visibility = 'hidden';
return;
}
knob.style.visibility = '';
// Cache the knob's full (unclipped) width. The clip classes hide one
// half of the knob, which would otherwise shrink offsetWidth and cause
// the would-clip threshold to oscillate as the user drags near an edge.
if (this.#knobFullWidth === 0) {
const hadClipLeft = knob.classList.contains('clip-at-left');
const hadClipRight = knob.classList.contains('clip-at-right');
if (hadClipLeft) knob.classList.remove('clip-at-left');
if (hadClipRight) knob.classList.remove('clip-at-right');
this.#knobFullWidth = knob.offsetWidth;
if (hadClipLeft) knob.classList.add('clip-at-left');
if (hadClipRight) knob.classList.add('clip-at-right');
}
const halfKnob = this.#knobFullWidth / 2;
const logicalX = stageRect.left + this.#positionFrac * stageRect.width;
// The knob clips when its bounding box (not just its centerline) would
// extend past the visible stage. Before the knob was position:fixed it
// got this for free from the slider's overflow:hidden; now we have to
// detect it ourselves.
const wouldClipLeft = logicalX - halfKnob < visLeft;
const wouldClipRight = logicalX + halfKnob > visRight && !wouldClipLeft;
knob.classList.toggle('clip-at-left', wouldClipLeft);
knob.classList.toggle('clip-at-right', wouldClipRight);
let leftPx;
if (wouldClipLeft) leftPx = visLeft;
else if (wouldClipRight) leftPx = visRight;
else leftPx = logicalX;
const targetY = vh / 2;
const clampedY = Math.max(visTop, Math.min(visBottom, targetY));
knob.style.left = leftPx + 'px';
knob.style.top = clampedY + 'px';
}
#drawCanvasSources() {
const afterEl = this.querySelector('canvas.compare-after');
if (afterEl && this.#afterCanvas) {
afterEl.getContext('2d').drawImage(this.#afterCanvas, 0, 0);
}
const beforeEl = this.querySelector('.compare-before-wrap canvas');
if (beforeEl && this.#beforeCanvas) {
beforeEl.getContext('2d').drawImage(this.#beforeCanvas, 0, 0);
}
}
async #ensureDownloadURL() {
if (this.#downloadSrc) return this.#downloadSrc;
if (!this.#afterCanvas) return '';
this.#lazyBlobURL = await canvasToBlobUrl(this.#afterCanvas);
this.#downloadSrc = this.#lazyBlobURL;
return this.#downloadSrc;
}
async #prepareLazyDownload() {
const canvas = this.#afterCanvas;
if (!canvas) return;
const url = await canvasToBlobUrl(canvas);
if (this.#afterCanvas !== canvas) {
URL.revokeObjectURL(url);
return;
}
this.#lazyBlobURL = url;
this.#downloadSrc = this.#lazyBlobURL;
}
#render() {
const cm = !!this.#afterCanvas;
const afterTag = cm
? `<canvas class="compare-after" width="${this.#afterCanvas.width}" height="${this.#afterCanvas.height}"></canvas>`
: `<img class="compare-after" src="${this.#afterSrc}">`;
const beforeTag = cm
? `<canvas width="${this.#beforeCanvas.width}" height="${this.#beforeCanvas.height}"></canvas>`
: `<img src="${this.#beforeSrc}">`;
morph(this, `
<style>
.compare {
display: none; position: relative; overflow: hidden;
border: 1px solid var(--pico-muted-border-color, #333);
border-radius: var(--pico-border-radius, 4px);
cursor: col-resize; user-select: none; max-width: 100%;
}
.compare:not(.expanded):not(.native-size) {
width: 100%;
max-width: 100%;
aspect-ratio: var(--ar, auto);
margin-inline: auto;
}
.compare.expanded {
height: calc(100vh - 1rem);
width: calc((100vh - 1rem) * var(--ar-num, 1));
max-width: none;
margin-inline: auto;
}
.compare img, .compare canvas { display: block; width: 100%; height: auto; pointer-events: none; }
/* compare-stage is the natural-size containing block for the after
canvas, the before-wrap clip and the slider handle. In normal modes
it just fills the host (width: 100%); in native-size it shrinks to
the canvas's natural dimensions so before-wrap's percentage width
/ handle's percentage left line up with the same native pixels the
after canvas is showing. */
.compare .compare-stage {
position: relative;
display: block;
width: 100%;
}
.compare .compare-before-wrap {
position: absolute; top: 0; left: 0; height: 100%; overflow: hidden;
width: 50%; border-right: 2px solid rgba(255,255,255,0.35);
transition: border-color 0.2s ease;
}
.compare.dragging .compare-before-wrap {
border-right-color: #fff;
}
.compare .compare-before-wrap img,
.compare .compare-before-wrap canvas { width: auto; height: 100%; max-width: none; }
.compare .compare-handle {
position: absolute; top: 0; bottom: 0; width: 2px; background: #fff;
left: 50%; margin-left: -1px; z-index: 2; pointer-events: none;
opacity: 0.35;
transition: opacity 0.2s ease;
}
.compare.dragging .compare-handle {
opacity: 1;
}
/* The knob is positioned in viewport coordinates (position: fixed)
and re-placed on scroll/resize/drag by #updateKnobPosition. JS
sets its top/left; transform anchors the box around that point
(centered by default, left-anchored when clamped to the viewport's
left edge, right-anchored when clamped to the right edge). */
.compare .compare-handle .handle-knob {
position: fixed; top: 50%; left: 50%;
transform: translate(-50%, -50%);
display: inline-flex; align-items: center; gap: 8px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(255,255,255,0.92);
border: 2px solid #333;
box-shadow: 0 0 6px rgba(0,0,0,0.5);
color: #333;
white-space: nowrap;
z-index: 3;
pointer-events: none;
}
.compare .compare-handle .handle-knob.clip-at-left {
transform: translate(0, -50%);
}
.compare .compare-handle .handle-knob.clip-at-right {
transform: translate(-100%, -50%);
}
/* When clamped to a viewport edge, drop the icon for the side the
user *isn't* looking at so the knob unambiguously labels what's
currently in view. */
.compare .compare-handle .handle-knob.clip-at-left .handle-side-before {
display: none;
}
.compare .compare-handle .handle-knob.clip-at-right .handle-side-after {
display: none;
}
.compare .compare-handle .handle-side {
display: inline-flex; align-items: center; gap: 3px;
}
.compare .compare-handle .handle-side .fas {
font-size: 11px;
}
.compare .compare-handle .handle-arrow {
font-size: 9px; line-height: 1;
}
/* native-size renders content at native pixel dimensions inline.
The host stays in normal flow but scrolls internally when content
exceeds its bounds. Flex + margin:auto centers the stage when the
image is smaller than the host and keeps it scrollable when
larger. */
.compare.native-size {
width: 100%;
max-width: 100%;
height: calc(100vh - 1rem);
max-height: calc(100vh - 1rem);
aspect-ratio: auto;
overflow: auto;
cursor: default;
display: flex;
}
.compare.native-size .compare-stage {
margin: auto;
width: max-content;
height: max-content;
flex: 0 0 auto;
}
.compare.native-size .compare-after {
width: auto;
max-width: none;
height: auto;
display: block;
}
</style>
<div class="compare-stage">
${afterTag}
<div class="compare-before-wrap">
${beforeTag}
</div>
<div class="compare-handle" aria-hidden="true">
<div class="handle-knob">
<span class="handle-side handle-side-before" aria-label="Original">
<i class="fas fa-eye-low-vision"></i>
<span class="handle-arrow">◀</span>
</span>
<span class="handle-side handle-side-after" aria-label="Enhanced">
<span class="handle-arrow">▶</span>
<i class="fas fa-eye"></i>
</span>
</div>
</div>
</div>
`);
if (cm) this.#drawCanvasSources();
}
}
customElements.define('compare-slider', CompareSlider);