JavaScript
Make an Accessible Focal Point Picker
JavaScript
// AZUL CODING ---------------------------------------
// JavaScript - Make an Accessible Focal Point Picker
// https://youtu.be/u_hJcnw-QF4
/**
* Retrieves the focal point at the center of the image.
*
* @returns The focal point at the center of the image.
*/
function getCenterFocalPoint() {
return { x: 0.5, y: 0.5 };
}
class FocalPointPicker {
/**
* Constructs a new FocalPointPicker instance.
*
* @param imageElement The image element to attach the focal point picker to.
* @param onChange Optional callback function to be called when the focal point is changed.
* @param defaultPoint Optional default focal point.
*/
constructor(imageElement, onChange, defaultPoint) {
this.image = imageElement;
this.container = document.createElement("div");
this.pointer = document.createElement("div");
this.crosshairVertical = document.createElement("div");
this.crosshairHorizontal = document.createElement("div");
this.focalPoint = defaultPoint || getCenterFocalPoint();
this.onChange = onChange || (() => {});
this.isDragging = false;
this.originalNextSibling = imageElement.nextSibling;
this.stepSize = 0.01;
// Bind event handlers
this.boundHandleClick = this.handleClick.bind(this);
this.boundHandleDragStart = this.handleDragStart.bind(this);
this.boundHandleDragMove = this.handleDragMove.bind(this);
this.boundHandleDragEnd = this.handleDragEnd.bind(this);
this.boundHandleKeyDown = this.handleKeyDown.bind(this);
this.setupStyles();
this.setupEventListeners();
this.updatePointerPosition();
}
setupStyles() {
this.container.style.cssText = `
position: relative;
display: inline-block;
`;
this.pointer.style.cssText = `
position: absolute;
width: 20px;
height: 20px;
border-radius: 50%;
background-color: rgba(255, 0, 0, 0.5);
border: 2px solid red;
transform: translate(-50%, -50%);
cursor: move;
z-index: 2;
`;
const crosshairStyle = `
position: absolute;
background-color: rgba(255, 255, 255, 0.5);
z-index: 1;
`;
this.crosshairVertical.style.cssText = crosshairStyle + `
width: 2px;
height: 100%;
left: calc(50% - 1px);
top: 0;
`;
this.crosshairHorizontal.style.cssText = crosshairStyle + `
width: 100%;
height: 2px;
top: calc(50% - 1px);
`;
this.image.parentNode?.insertBefore(this.container, this.image);
this.container.appendChild(this.image);
this.container.appendChild(this.crosshairVertical);
this.container.appendChild(this.crosshairHorizontal);
this.container.appendChild(this.pointer);
// Make the pointer focusable
this.pointer.tabIndex = 0;
this.pointer.setAttribute("role", "slider");
this.pointer.setAttribute("aria-label", "Focal point");
this.updateAriaValues();
}
setupEventListeners() {
this.container.addEventListener("click", this.boundHandleClick);
this.pointer.addEventListener("mousedown", this.boundHandleDragStart);
document.addEventListener("mousemove", this.boundHandleDragMove);
document.addEventListener("mouseup", this.boundHandleDragEnd);
this.pointer.addEventListener("keydown", this.boundHandleKeyDown);
}
updatePointerPosition() {
this.pointer.style.left = `${this.focalPoint.x * 100}%`;
this.pointer.style.top = `${this.focalPoint.y * 100}%`;
}
handleClick(event) {
const rect = this.image.getBoundingClientRect();
this.setFocalPoint({
x: (event.clientX - rect.left) / rect.width,
y: (event.clientY - rect.top) / rect.height
});
}
handleDragStart(event) {
event.preventDefault();
this.isDragging = true;
}
handleDragMove(event) {
if (!this.isDragging) return;
const rect = this.image.getBoundingClientRect();
this.setFocalPoint({
x: Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)),
y: Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height))
});
}
handleDragEnd() {
this.isDragging = false;
}
handleKeyDown(event) {
let newX = this.focalPoint.x;
let newY = this.focalPoint.y;
switch (event.key) {
case "ArrowLeft":
newX = Math.max(0, newX - this.stepSize);
break;
case "ArrowRight":
newX = Math.min(1, newX + this.stepSize);
break;
case "ArrowUp":
newY = Math.max(0, newY - this.stepSize);
break;
case "ArrowDown":
newY = Math.min(1, newY + this.stepSize);
break;
default:
return;
}
event.preventDefault(); // Prevent scrolling
this.setFocalPoint({ x: newX, y: newY });
}
updateAriaValues() {
const positionFormatted = `X: ${Math.round(this.focalPoint.x * 100)}%, Y: ${Math.round(this.focalPoint.y * 100)}%`;
this.pointer.setAttribute("aria-valuenow", `${Math.round(this.focalPoint.x * 100)}`);
this.pointer.setAttribute("aria-valuetext", positionFormatted);
this.pointer.setAttribute("title", positionFormatted);
}
/**
* Gets the current focal point.
*
* @returns The current focal point.
*/
getFocalPoint() {
return this.focalPoint;
}
/**
* Sets the focal point and updates related elements.
*
* @param newFocalPoint The new focal point to be set.
*/
setFocalPoint(newFocalPoint) {
this.focalPoint = newFocalPoint;
this.updatePointerPosition();
this.updateAriaValues();
this.onChange(this.focalPoint);
}
/**
* Disposes of the focal point picker by removing event listeners,
* re-inserting the image back into the DOM, and resetting styles.
*/
dispose() {
// Remove event listeners
this.container.removeEventListener("click", this.boundHandleClick);
this.pointer.removeEventListener("mousedown", this.boundHandleDragStart);
document.removeEventListener("mousemove", this.boundHandleDragMove);
document.removeEventListener("mouseup", this.boundHandleDragEnd);
this.pointer.removeEventListener("keydown", this.boundHandleKeyDown);
// Remove the image from the container
this.container.removeChild(this.image);
// Insert the image back to its original position in the DOM
if (this.originalNextSibling) {
this.originalNextSibling.parentNode?.insertBefore(this.image, this.originalNextSibling);
} else {
// If there was no next sibling, append it to the parent
this.container.parentNode?.appendChild(this.image);
}
// Remove the container and other elements
this.container.parentNode?.removeChild(this.container);
// Reset image styles that might have been changed
this.image.style.position = "";
this.image.style.left = "";
this.image.style.top = "";
// Nullify references
this.container = null;
this.pointer = null;
this.crosshairVertical = null;
this.crosshairHorizontal = null;
this.onChange = null;
this.originalNextSibling = null;
// Reset other properties
this.focalPoint = { x: 0, y: 0 };
this.isDragging = false;
}
}
TypeScript
// AZUL CODING ---------------------------------------
// JavaScript - Make an Accessible Focal Point Picker
// https://youtu.be/u_hJcnw-QF4
export interface FocalPoint {
/**
* The x-coordinate of the focal point.
*/
x: number;
/**
* The y-coordinate of the focal point.
*/
y: number;
}
/**
* Callback function for when the focal point changes.
*
* @param focalPoint The new focal point.
*/
export type OnChangeCallback = (focalPoint: FocalPoint) => void;
/**
* Retrieves the focal point at the center of the image.
*
* @returns The focal point at the center of the image.
*/
export function getCenterFocalPoint(): FocalPoint {
return { x: 0.5, y: 0.5 };
}
export default class FocalPointPicker {
private image: HTMLImageElement;
private container: HTMLDivElement;
private pointer: HTMLDivElement;
private crosshairVertical: HTMLDivElement;
private crosshairHorizontal: HTMLDivElement;
private focalPoint: FocalPoint;
private onChange: OnChangeCallback;
private isDragging: boolean = false;
private stepSize: number = 0.01;
private boundHandleClick: (event: MouseEvent) => void;
private boundHandleDragStart: (event: MouseEvent) => void;
private boundHandleDragMove: (event: MouseEvent) => void;
private boundHandleDragEnd: (event: MouseEvent) => void;
private boundHandleKeyDown: (event: KeyboardEvent) => void;
private originalNextSibling: Node | null;
/**
* Constructs a new FocalPointPicker instance.
*
* @param imageElement The image element to attach the focal point picker to.
* @param onChange Optional callback function to be called when the focal point is changed.
* @param defaultPoint Optional default focal point.
*/
constructor(imageElement: HTMLImageElement, onChange?: OnChangeCallback, defaultPoint?: FocalPoint) {
this.image = imageElement;
this.container = document.createElement("div");
this.pointer = document.createElement("div");
this.crosshairVertical = document.createElement("div");
this.crosshairHorizontal = document.createElement("div");
this.focalPoint = defaultPoint || getCenterFocalPoint();
this.onChange = onChange || (() => {});
this.originalNextSibling = imageElement.nextSibling;
// Bind event handlers
this.boundHandleClick = this.handleClick.bind(this);
this.boundHandleDragStart = this.handleDragStart.bind(this);
this.boundHandleDragMove = this.handleDragMove.bind(this);
this.boundHandleDragEnd = this.handleDragEnd.bind(this);
this.boundHandleKeyDown = this.handleKeyDown.bind(this);
this.setupStyles();
this.setupEventListeners();
this.updatePointerPosition();
}
private setupStyles(): void {
this.container.style.cssText = `
position: relative;
display: inline-block;
`;
this.pointer.style.cssText = `
position: absolute;
width: 20px;
height: 20px;
border-radius: 50%;
background-color: rgba(255, 0, 0, 0.5);
border: 2px solid red;
transform: translate(-50%, -50%);
cursor: move;
z-index: 2;
`;
const crosshairStyle = `
position: absolute;
background-color: rgba(255, 255, 255, 0.5);
z-index: 1;
`;
this.crosshairVertical.style.cssText = crosshairStyle + `
width: 2px;
height: 100%;
left: calc(50% - 1px);
top: 0;
`;
this.crosshairHorizontal.style.cssText = crosshairStyle + `
width: 100%;
height: 2px;
top: calc(50% - 1px);
`;
this.image.parentNode?.insertBefore(this.container, this.image);
this.container.appendChild(this.image);
this.container.appendChild(this.crosshairVertical);
this.container.appendChild(this.crosshairHorizontal);
this.container.appendChild(this.pointer);
// Make the pointer focusable
this.pointer.tabIndex = 0;
this.pointer.setAttribute("role", "slider");
this.pointer.setAttribute("aria-label", "Focal point");
this.updateAriaValues();
}
private setupEventListeners(): void {
this.container.addEventListener("click", this.boundHandleClick);
this.pointer.addEventListener("mousedown", this.boundHandleDragStart);
document.addEventListener("mousemove", this.boundHandleDragMove);
document.addEventListener("mouseup", this.boundHandleDragEnd);
this.pointer.addEventListener("keydown", this.boundHandleKeyDown);
}
private updatePointerPosition(): void {
this.pointer.style.left = `${this.focalPoint.x * 100}%`;
this.pointer.style.top = `${this.focalPoint.y * 100}%`;
}
private handleClick(event: MouseEvent): void {
const rect = this.image.getBoundingClientRect();
this.setFocalPoint({
x: (event.clientX - rect.left) / rect.width,
y: (event.clientY - rect.top) / rect.height
});
}
private handleDragStart(event: MouseEvent): void {
event.preventDefault();
this.isDragging = true;
}
private handleDragMove(event: MouseEvent): void {
if (!this.isDragging) return;
const rect = this.image.getBoundingClientRect();
this.setFocalPoint({
x: Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)),
y: Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height))
});
}
private handleDragEnd(): void {
this.isDragging = false;
}
private handleKeyDown(event: KeyboardEvent): void {
let newX = this.focalPoint.x;
let newY = this.focalPoint.y;
switch (event.key) {
case "ArrowLeft":
newX = Math.max(0, newX - this.stepSize);
break;
case "ArrowRight":
newX = Math.min(1, newX + this.stepSize);
break;
case "ArrowUp":
newY = Math.max(0, newY - this.stepSize);
break;
case "ArrowDown":
newY = Math.min(1, newY + this.stepSize);
break;
default:
return;
}
event.preventDefault(); // Prevent scrolling
this.setFocalPoint({ x: newX, y: newY });
}
private updateAriaValues(): void {
const positionFormatted = `X: ${Math.round(this.focalPoint.x * 100)}%, Y: ${Math.round(this.focalPoint.y * 100)}%`;
this.pointer.setAttribute("aria-valuenow", `${Math.round(this.focalPoint.x * 100)}`);
this.pointer.setAttribute("aria-valuetext", positionFormatted);
this.pointer.setAttribute("title", positionFormatted);
}
/**
* Gets the current focal point.
*
* @returns The current focal point.
*/
public getFocalPoint(): FocalPoint {
return this.focalPoint;
}
/**
* Sets the focal point and updates related elements.
*
* @param newFocalPoint The new focal point to be set.
*/
public setFocalPoint(newFocalPoint: FocalPoint): void {
this.focalPoint = newFocalPoint;
this.updatePointerPosition();
this.updateAriaValues();
this.onChange(this.focalPoint);
}
/**
* Disposes of the focal point picker by removing event listeners,
* re-inserting the image back into the DOM, and resetting styles.
*/
public dispose(): void {
// Remove event listeners
this.container.removeEventListener("click", this.boundHandleClick);
this.pointer.removeEventListener("mousedown", this.boundHandleDragStart);
document.removeEventListener("mousemove", this.boundHandleDragMove);
document.removeEventListener("mouseup", this.boundHandleDragEnd);
this.pointer.removeEventListener("keydown", this.boundHandleKeyDown);
// Remove the image from the container
this.container.removeChild(this.image);
// Insert the image back to its original position in the DOM
if (this.originalNextSibling) {
this.originalNextSibling.parentNode?.insertBefore(this.image, this.originalNextSibling);
} else {
// If there was no next sibling, append it to the parent
this.container.parentNode?.appendChild(this.image);
}
// Remove the container and other elements
this.container.parentNode?.removeChild(this.container);
// Reset image styles that might have been changed
this.image.style.position = "";
this.image.style.left = "";
this.image.style.top = "";
// Nullify references
this.container = null!;
this.pointer = null!;
this.crosshairVertical = null!;
this.crosshairHorizontal = null!;
this.onChange = null!;
this.originalNextSibling = null;
// Reset other properties
this.focalPoint = { x: 0, y: 0 };
this.isDragging = false;
}
}
HTML
<!-- AZUL CODING --------------------------------------- -->
<!-- JavaScript - Make an Accessible Focal Point Picker -->
<!-- https://youtu.be/u_hJcnw-QF4 -->
<!DOCTYPE html>
<html>
<head>
<title>Azul Coding</title>
<style>
body {
margin: 30px;
background-color: #03506E;
}
* {
font-family: "Inter", sans-serif;
font-weight: 500;
font-size: 18px;
}
button {
background-color: white;
border: none;
border-radius: 10px;
padding: 6px 12px;
cursor: pointer;
}
button:active {
transform: scale(0.9);
}
.comparison {
display: flex;
gap: 25px;
margin: 25px 0;
}
.comparison > div {
/*
Looking for some sample images?
https://quetzal.johnjds.co.uk/?ref=azul
*/
background-image: url("image.jpg");
background-size: cover;
background-position: center;
}
#portrait {
height: 350px;
width: 250px;
}
#square {
height: 350px;
width: 350px;
}
</style>
<script src="focal.js"></script>
</head>
<body>
<img id="focal-image" src="image.jpg" width="625" alt="">
<div class="comparison">
<div id="portrait"></div>
<div id="square"></div>
</div>
<button id="reset-btn">Reset</button>
<script>
const focalImage = document.getElementById("focal-image");
const portraitImage = document.getElementById("portrait");
const squareImage = document.getElementById("square");
const resetButton = document.getElementById("reset-btn");
function onFocalPointChange(point) {
const x = `${Math.round(point.x * 100)}%`;
const y = `${Math.round(point.y * 100)}%`;
for (const image of [portraitImage, squareImage])
image.style.backgroundPosition = `${x} ${y}`;
}
const focalPicker = new FocalPointPicker(
focalImage, onFocalPointChange);
resetButton.addEventListener("click", () => {
focalPicker.setFocalPoint(getCenterFocalPoint());
});
// Use focalPicker.dispose() to undo the creation of the focal point picker.
// You can also use focalPicker.getFocalPoint() to get the current position.
</script>
</body>
</html>