/**
* @param {HTMLElement} element DOM element for component instantiation and scope
* @param {Object} options
* @param {String} options.imgSelector Selector for the image element
* @param {String} options.debugSelector Selector for enabling the debug
* @param {String} options.imagePendingAttribute Selector for hiding the image before calculations are done
* @param {String} options.focalPointAttribute Attribute containing the focal point
*/
export class Picture {
/**
* @static
* Shorthand for instance creation and initialisation.
*
* @param {HTMLElement} root DOM element for component instantiation and scope
*
* @return {Picture} An instance of Picture.
*/
static autoInit(root, { PICTURE: defaultOptions = {} } = {}) {
const picture = new Picture(root, defaultOptions);
picture.init();
root.ECLPicture = picture;
return picture;
}
constructor(
element,
{
imgSelector = 'img',
focalPointAttribute = 'data-picture-focal-point',
debugSelector = 'data-picture-debug',
imagePendingAttribute = 'data-image-focal-pending',
} = {},
) {
if (!element || element.nodeType !== Node.ELEMENT_NODE) {
throw new TypeError(
'DOM element should be given to initialize this widget.',
);
}
this.element = element;
this.imgSelector = imgSelector;
this.debugSelector = debugSelector;
this.focalPointAttribute = focalPointAttribute;
this.imagePendingAttribute = imagePendingAttribute;
this.image = null;
this.resizeObserver = null;
this.hadPendingAttribute = false;
this.debug = false;
this.debugMarker = null;
this.resizeTimeout = null;
this.updateFocalPoint = this.updateFocalPoint.bind(this);
this.updateDebugMarker = this.updateDebugMarker.bind(this);
}
/**
* Initialise component.
*/
init() {
if (!ECL) {
throw new TypeError('Called init but ECL is not present');
}
ECL.components = ECL.components || new Map();
this.image = this.element.querySelector(this.imgSelector);
this.debug = this.element.hasAttribute(this.debugSelector);
if (!this.image) {
return;
}
this.image.addEventListener('load', this.updateFocalPoint);
if (this.image.complete) {
this.updateFocalPoint();
}
this.hadPendingAttribute = this.image.hasAttribute(
this.imagePendingAttribute,
);
this.resizeObserver = new ResizeObserver(this.handleResize);
this.resizeObserver.observe(this.image);
// Set ecl initialized attribute
this.element.setAttribute('data-ecl-auto-initialized', 'true');
ECL.components.set(this.element, this);
}
/**
* Update the image object-position.
*/
async updateFocalPoint() {
if (!this.image?.naturalWidth || !this.image?.naturalHeight) {
return;
}
const focalPoint = this.getFocalPoint();
if (!focalPoint) {
this.destroy();
// If we cannot get a focal point let's prevent future requests.
this.element.removeAttribute('data-ecl-auto-init');
// Remove the attribute that sets the visibility to hidden
this.image.removeAttribute(this.imagePendingAttribute);
// Set back this.image to null
this.image = null;
// Exit and don't comme back
return;
}
const container = this.element.getBoundingClientRect();
const objectPosition = this.calculateObjectPosition({
imageWidth: this.image.naturalWidth,
imageHeight: this.image.naturalHeight,
containerWidth: container.width,
containerHeight: container.height,
focalX: focalPoint?.x,
focalY: focalPoint?.y,
});
this.image.style.setProperty(
'--ecl-image-anchor',
`${objectPosition.x}% ${objectPosition.y}%`,
);
if (this.debug) {
const position = getComputedStyle(this.element).position;
if (position === 'static') {
this.element.style.position = 'relative';
}
this.updateDebugMarker();
}
this.image.removeAttribute(this.imagePendingAttribute);
}
/**
* Read the focal point.
*/
getFocalPoint() {
const value = this.element.getAttribute(this.focalPointAttribute);
if (!value) {
return null;
}
const [x, y] = value.split(/[ ,]+/).map((v) => Number(v.replace(/%$/, '')));
if (Number.isNaN(x) || Number.isNaN(y)) {
return null;
}
return { x, y };
}
/**
* Calculate the object-position.
*
* @param {Number} imageWidth Width of the image
* @param {Number} imageHeight Height of the image
* @param {Number} containerWidth Width of the container
* @param {Number} containerHeight Height of the container
* @param {Number} focalX Value in percentage of the horizontal position of the focal point
* @param {Number} focalY Value in percentage of the vertical position of the focal point
*/
calculateObjectPosition({
imageWidth,
imageHeight,
containerWidth,
containerHeight,
focalX,
focalY,
}) {
const scale = Math.max(
containerWidth / imageWidth,
containerHeight / imageHeight,
);
const renderedWidth = imageWidth * scale;
const renderedHeight = imageHeight * scale;
const focalRenderedX = renderedWidth * (focalX / 100);
const focalRenderedY = renderedHeight * (focalY / 100);
let translateX = containerWidth / 2 - focalRenderedX;
let translateY = containerHeight / 2 - focalRenderedY;
translateX = Math.min(
0,
Math.max(containerWidth - renderedWidth, translateX),
);
translateY = Math.min(
0,
Math.max(containerHeight - renderedHeight, translateY),
);
const overflowX = renderedWidth - containerWidth;
const overflowY = renderedHeight - containerHeight;
return {
x: overflowX > 0 ? (-translateX / overflowX) * 100 : 50,
y: overflowY > 0 ? (-translateY / overflowY) * 100 : 50,
};
}
/**
* Debounced call to the update function when resizing.
*/
handleResize = () => {
clearTimeout(this.resizeTimeout);
this.resizeTimeout = setTimeout(() => {
this.updateFocalPoint();
}, 100);
};
/**
* Get the rendered coordinates of the focal point inside the container.
*
* @returns {{x: number, y: number, visible: boolean}|null}
*/
getRenderedFocalPoint = () => {
if (!this.image?.naturalWidth || !this.image?.naturalHeight) {
return null;
}
const focalPoint = this.getFocalPoint();
if (!focalPoint) {
return null;
}
const container = this.element.getBoundingClientRect();
const imageWidth = this.image.naturalWidth;
const imageHeight = this.image.naturalHeight;
const containerWidth = container.width;
const containerHeight = container.height;
const scale = Math.max(
containerWidth / imageWidth,
containerHeight / imageHeight,
);
const renderedWidth = imageWidth * scale;
const renderedHeight = imageHeight * scale;
const objectPosition = this.calculateObjectPosition({
imageWidth,
imageHeight,
containerWidth,
containerHeight,
focalX: focalPoint.x,
focalY: focalPoint.y,
});
const overflowX = renderedWidth - containerWidth;
const overflowY = renderedHeight - containerHeight;
const translateX =
overflowX > 0 ? -(objectPosition.x / 100) * overflowX : 0;
const translateY =
overflowY > 0 ? -(objectPosition.y / 100) * overflowY : 0;
const x = renderedWidth * (focalPoint.x / 100) + translateX;
const y = renderedHeight * (focalPoint.y / 100) + translateY;
return {
x,
y,
visible: x >= 0 && x <= containerWidth && y >= 0 && y <= containerHeight,
};
};
/**
* Set the marker at the right position in the image.
*/
updateDebugMarker() {
const point = this.getRenderedFocalPoint();
if (!point) {
return;
}
if (!this.debugMarker) {
this.debugMarker = document.createElement('span');
Object.assign(this.debugMarker.style, {
position: 'absolute',
width: '16px',
height: '16px',
borderRadius: '50%',
background: '#e43',
border: '3px solid white',
transform: 'translate(-50%, -50%)',
boxShadow: '0 0 0 1px rgba(0,0,0,.3)',
pointerEvents: 'none',
zIndex: 9999,
});
this.element.appendChild(this.debugMarker);
}
this.debugMarker.style.left = `${point.x}px`;
this.debugMarker.style.top = `${point.y}px`;
this.debugMarker.style.display = point.visible ? '' : 'none';
}
/*
* Destroy the instance
*/
destroy() {
if (this.image) {
this.image.removeEventListener('load', this.updateFocalPoint);
this.element.removeAttribute('data-picture-focal-point');
this.element.removeAttribute('data-picture-debug');
this.element.removeAttribute('data-picture-focal-point');
if (this.hadPendingAttribute) {
this.image.setAttribute(this.imagePendingAttribute, '');
}
}
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
if (this.debugMarker) {
this.debugMarker.remove();
}
if (this.element) {
this.element.removeAttribute('data-ecl-auto-initialized');
ECL.components.delete(this.element);
}
}
}
export default Picture;