updraft / components /image-cropper.js
Nicholas Celestin
Build update — 2026-05-22T18:34:00.912Z
3f22414
Raw
History Blame Contribute Delete
8.05 kB
/**
* <image-cropper> — optional bounding-box selection on the source image.
*
* Events:
* crop-changed — detail: { crop: { x, y, w, h } | null }
*/
import { morph } from 'lib/morph';
import { cropToCanvas } from 'lib/canvas';
class ImageCropper extends HTMLElement {
#image = null;
#crop = null;
#dragging = false;
#dragStart = null;
#dragCurrent = null;
#onWindowMouseMove = (e) => { if (this.#dragging) { this.#dragCurrent = this.#eventToElement(e); this.#drawOverlay(); } };
#onWindowTouchMove = (e) => { if (this.#dragging) { this.#dragCurrent = this.#eventToElement(e); this.#drawOverlay(); } };
#onWindowMouseUp = () => { if (this.#dragging) this.#finishDrag(); };
#onWindowTouchEnd = () => { if (this.#dragging) this.#finishDrag(); };
connectedCallback() {
this.classList.add('image-cropper');
this.#render();
this.addEventListener('mousedown', e => {
const canvas = e.target.closest('canvas');
if (!canvas) return;
this.#dragging = true;
this.#dragStart = this.#eventToElement(e);
this.#dragCurrent = this.#dragStart;
this.#crop = null;
this.#drawOverlay();
});
this.addEventListener('touchstart', e => {
const canvas = e.target.closest('canvas');
if (!canvas) return;
this.#dragging = true;
this.#dragStart = this.#eventToElement(e);
this.#dragCurrent = this.#dragStart;
this.#crop = null;
this.#drawOverlay();
}, { passive: true });
window.addEventListener('mousemove', this.#onWindowMouseMove);
window.addEventListener('touchmove', this.#onWindowTouchMove, { passive: true });
window.addEventListener('mouseup', this.#onWindowMouseUp);
window.addEventListener('touchend', this.#onWindowTouchEnd);
}
disconnectedCallback() {
window.removeEventListener('mousemove', this.#onWindowMouseMove);
window.removeEventListener('touchmove', this.#onWindowTouchMove);
window.removeEventListener('mouseup', this.#onWindowMouseUp);
window.removeEventListener('touchend', this.#onWindowTouchEnd);
}
show(image) {
// Preserve the existing crop selection when re-shown with the same image
// reference (e.g. when the upscaler navigates back to crop mode from the
// compare view). A genuinely new image still resets the crop.
const sameImage = image === this.#image;
this.#image = image;
if (!sameImage) this.#crop = null;
this.style.setProperty('--ar', `${image.width} / ${image.height}`);
this.style.setProperty('--ar-num', `${image.width / image.height}`);
this.style.setProperty('--natural-w', `${image.width}px`);
this.#render();
this.style.display = 'block';
this.#resizeCanvas();
this.#drawOverlay();
}
hide() {
this.style.display = 'none';
this.style.removeProperty('--ar');
this.style.removeProperty('--ar-num');
this.style.removeProperty('--natural-w');
this.#image = null;
this.#crop = null;
const canvas = this.querySelector('canvas');
if (canvas) { canvas.width = 0; canvas.height = 0; }
}
clearCrop() {
this.#crop = null;
this.#render();
this.#resizeCanvas();
this.#drawOverlay();
this.dispatchEvent(new CustomEvent('crop-changed', { bubbles: true, detail: { crop: null } }));
}
get crop() { return this.#crop; }
extractImage() {
const img = this.#image;
if (!img) throw new Error('No image loaded');
if (!this.#crop) return img;
return cropToCanvas(img, this.#crop);
}
#resizeCanvas() {
if (!this.#image) return;
const canvas = this.querySelector('canvas');
if (!canvas) return;
canvas.width = this.#image.width;
canvas.height = this.#image.height;
}
#eventToElement(e) {
const canvas = this.querySelector('canvas');
if (!canvas) return { ex: 0, ey: 0 };
const rect = canvas.getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
return {
ex: Math.max(0, Math.min(rect.width, clientX - rect.left)),
ey: Math.max(0, Math.min(rect.height, clientY - rect.top)),
};
}
#finishDrag() {
this.#dragging = false;
if (!this.#dragStart || !this.#dragCurrent || !this.#image) return;
const canvas = this.querySelector('canvas');
const scaleX = this.#image.width / canvas.clientWidth;
const scaleY = this.#image.height / canvas.clientHeight;
const x1 = Math.min(this.#dragStart.ex, this.#dragCurrent.ex);
const y1 = Math.min(this.#dragStart.ey, this.#dragCurrent.ey);
const x2 = Math.max(this.#dragStart.ex, this.#dragCurrent.ex);
const y2 = Math.max(this.#dragStart.ey, this.#dragCurrent.ey);
const ix = Math.round(x1 * scaleX);
const iy = Math.round(y1 * scaleY);
const iw = Math.round((x2 - x1) * scaleX);
const ih = Math.round((y2 - y1) * scaleY);
if (iw < 16 || ih < 16) {
this.#crop = null;
this.#render();
this.#resizeCanvas();
this.#drawOverlay();
this.dispatchEvent(new CustomEvent('crop-changed', { bubbles: true, detail: { crop: null } }));
return;
}
this.#crop = { x: ix, y: iy, w: iw, h: ih };
this.#render();
this.#resizeCanvas();
this.#drawOverlay();
this.dispatchEvent(new CustomEvent('crop-changed', { bubbles: true, detail: { crop: this.#crop } }));
}
#drawOverlay() {
const canvas = this.querySelector('canvas');
if (!canvas || !this.#image) return;
const ctx = canvas.getContext('2d');
const cw = canvas.width;
const ch = canvas.height;
ctx.drawImage(this.#image, 0, 0, cw, ch);
let sx, sy, sw, sh;
if (this.#dragging && this.#dragStart && this.#dragCurrent) {
const scaleX = cw / canvas.clientWidth;
const scaleY = ch / canvas.clientHeight;
sx = Math.min(this.#dragStart.ex, this.#dragCurrent.ex) * scaleX;
sy = Math.min(this.#dragStart.ey, this.#dragCurrent.ey) * scaleY;
sw = Math.abs(this.#dragCurrent.ex - this.#dragStart.ex) * scaleX;
sh = Math.abs(this.#dragCurrent.ey - this.#dragStart.ey) * scaleY;
} else if (this.#crop) {
sx = this.#crop.x;
sy = this.#crop.y;
sw = this.#crop.w;
sh = this.#crop.h;
} else {
return;
}
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
ctx.fillRect(0, 0, cw, sy);
ctx.fillRect(0, sy, sx, sh);
ctx.fillRect(sx + sw, sy, cw - sx - sw, sh);
ctx.fillRect(0, sy + sh, cw, ch - sy - sh);
ctx.strokeStyle = 'var(--pico-primary, #4c8)';
ctx.lineWidth = 2;
ctx.strokeRect(sx, sy, sw, sh);
}
#render() {
morph(this, `
<style>
.image-cropper { display: none; }
.image-cropper:not(.expanded) {
width: 100%;
max-width: 100%;
aspect-ratio: var(--ar, auto);
margin-inline: auto;
}
.image-cropper.expanded {
height: calc(100vh - 1rem);
width: calc((100vh - 1rem) * var(--ar-num, 1));
max-width: none;
margin-inline: auto;
}
/* native-size: canvas at its natural pixel dimensions, centered in
a workspace-sized scroll container. Mirrors the compare-slider. */
.image-cropper.native-size {
width: 100%;
max-width: 100%;
height: calc(100vh - 1rem);
max-height: calc(100vh - 1rem);
overflow: auto;
display: flex;
margin-inline: auto;
}
.image-cropper.native-size canvas {
margin: auto;
width: auto;
height: auto;
max-width: none;
flex: 0 0 auto;
}
.image-cropper canvas {
display: block;
width: 100%;
height: auto;
max-width: 100%;
border: 1px solid var(--pico-muted-border-color, #333);
border-radius: var(--pico-border-radius, 4px);
cursor: crosshair;
}
</style>
<canvas></canvas>
`);
}
}
customElements.define('image-cropper', ImageCropper);