@phx-hook/crop-image
Version:
Crop images to a specified size for upload
384 lines (312 loc) • 12.2 kB
JavaScript
export default function (options = {}) {
let activeClass = options.activeClass || "cropping";
let minSize = options.minSize || 50;
let threshold = options.threshold || 10;
return {
mounted() {
this.moving = false;
// Bind event handlers
this.handleInputChange = this.handleInputChange.bind(this);
this.handleMouseDown = this.handleMouseDown.bind(this);
this.handleMouseMove = this.handleMouseMove.bind(this);
this.handleDocumentMouseMove = this.handleDocumentMouseMove.bind(this);
this.handleDocumentMouseUp = this.handleDocumentMouseUp.bind(this);
// Set up container element
let containerId = this.el.dataset["cropContainerId"];
this.containerEl = containerId
? document.getElementById(containerId)
: this.el;
if (!this.containerEl) {
throw new Error(
`-hook/crop-image: Cound not find element #${containerId}`,
);
}
if (this.containerEl.getAttribute("phx-update") !== "ignore") {
console.warn(
`-hook/crop-image: Add phx-update="ignore" to your crop container element to prevent DOM overwriting`,
);
}
// Set up canvas element
this.appendCanvas();
this.canvasEl.addEventListener("mousemove", this.handleMouseMove);
this.canvasEl.addEventListener("mousedown", this.handleMouseDown);
// Attempt to load image from data-crop-image-src attribute
this.initImageSrc();
// Listen for files to show in canvas if configured
let fileInputId = this.el.dataset["cropFileInputId"];
if (fileInputId) {
let fileInputEl = document.getElementById(fileInputId);
if (!fileInputEl || fileInputEl.type !== "file") {
throw new Error(
`-hook/crop-image: Element #${fileInputId} is missing or is not a "file"-type input`,
);
}
fileInputEl.addEventListener("change", this.handleInputChange);
}
},
updated() {
this.initImageSrc();
if (this.active) {
this.el.classList.add(activeClass);
this.updateInputs();
}
},
destroyed() {
document.removeEventListener("mousemove", this.handleDocumentMouseMove);
document.removeEventListener("mouseup", this.handleDocumentMouseUp);
},
appendCanvas() {
let canvasEl = document.createElement("canvas");
Object.assign(canvasEl.style, { width: "100%", height: "100%" });
this.containerEl.appendChild(canvasEl);
this.canvasEl = canvasEl;
},
initImageSrc() {
// Initialize canvas from image src if configured
let imageSrc = this.el.dataset["cropImageSrc"];
if (imageSrc && imageSrc !== this.imageSrc) {
this.imageSrc = imageSrc;
this.initCanvasForImage(imageSrc);
}
},
initCanvasForImage(src) {
let image = new Image();
image.addEventListener("load", () => {
let size = Math.max(image.width, image.height);
this.canvasEl.setAttribute("width", size);
this.canvasEl.setAttribute("height", size);
this.ctx = this.canvasEl.getContext("2d");
// Avoid reading canvas dimensions before they are calculated
requestAnimationFrame(() => {
// Calculate pixel ratio between canvas and canvasEl
let { width: pixelWidth } = this.canvasEl.getBoundingClientRect();
let pr = size / pixelWidth;
let yOffset = Math.max(0, image.width - image.height) / 2 / pr;
let xOffset = Math.max(0, image.height - image.width) / 2 / pr;
this.imagePos = {
top: yOffset,
bottom: yOffset,
left: xOffset,
right: xOffset,
width: image.width / pr,
height: image.height / pr,
};
this.pixelRatio = pr;
this.image = image;
this.setInitPos();
this.updateInputs();
this.draw();
this.active = true;
this.el.classList.add(activeClass);
});
});
image.src = src;
},
setInitPos() {
let initPos;
let squareAspect = this.el.dataset["cropAspect"] === "square";
if (squareAspect) {
let imagePos = this.imagePos;
let size = Math.min(imagePos.width, imagePos.height);
initPos = {
top: imagePos.top,
bottom: imagePos.bottom + (imagePos.height - size),
left: imagePos.left,
right: imagePos.right + (imagePos.width - size),
width: size,
height: size,
};
} else {
initPos = { ...this.imagePos };
}
this.pos = this.initPos = initPos;
},
updateInputs() {
let inputs = this.el.querySelectorAll("input[data-crop-field]");
let px = (i) => Math.round(i * this.pixelRatio);
let left = px(this.pos.left - this.imagePos.left);
let top = px(this.pos.top - this.imagePos.top);
let width = px(this.pos.width);
let height = px(this.pos.height);
for (let i = 0; i < inputs.length; i++) {
let inputEl = inputs[i];
let cropField = inputEl.dataset["cropField"];
if (cropField === "left") inputEl.value = left;
if (cropField === "top") inputEl.value = top;
if (cropField === "width") inputEl.value = width;
if (cropField === "height") inputEl.value = height;
}
},
draw() {
if (!this.pos) return;
let px = (i) => i * this.pixelRatio;
// Clear draw space
this.ctx.clearRect(0, 0, this.canvasEl.width, this.canvasEl.height);
// Draw backdrop image
this.ctx.drawImage(
this.image,
px(this.imagePos.left),
px(this.imagePos.top),
px(this.imagePos.width),
px(this.imagePos.height),
);
// Draw opacity over backdrop image
this.ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
this.ctx.fillRect(
px(this.imagePos.left),
px(this.imagePos.top),
px(this.imagePos.width),
px(this.imagePos.height),
);
// Draw light image inside crop area
this.ctx.drawImage(
this.image,
px(this.pos.left - this.imagePos.left),
px(this.pos.top - this.imagePos.top),
px(this.pos.width),
px(this.pos.height),
px(this.pos.left),
px(this.pos.top),
px(this.pos.width),
px(this.pos.height),
);
// Draw dashed border around crop area
this.ctx.strokeStyle = "white";
this.ctx.lineWidth = px(1);
this.ctx.setLineDash([px(2), px(2)]);
this.ctx.strokeRect(
px(this.pos.left + 1),
px(this.pos.top + 1),
px(this.pos.width - 2),
px(this.pos.height - 2),
);
},
handleInputChange(event) {
let file = event.target.files[0];
if (file) {
let src = URL.createObjectURL(file);
this.initCanvasForImage(src);
let onHasFile = this.el.dataset["onHasFile"];
if (onHasFile) {
liveSocket.execJS(this.el, onHasFile);
}
}
},
handleMouseDown(event) {
if (!this.cursor) return;
event.preventDefault();
this.clientInitX = event.clientX;
this.clientInitY = event.clientY;
this.moving = true;
document.addEventListener("mousemove", this.handleDocumentMouseMove);
document.addEventListener("mouseup", this.handleDocumentMouseUp);
},
handleMouseMove(event) {
if (this.moving || !this.pos) return;
event.preventDefault();
let rect = this.canvasEl.getBoundingClientRect();
let top = rect.top + this.pos.top;
let bottom = rect.bottom - this.pos.bottom;
let left = rect.left + this.pos.left;
let right = rect.right - this.pos.right;
let dTop = event.clientY - top;
let dBottom = bottom - event.clientY;
let dLeft = event.clientX - left;
let dRight = right - event.clientX;
let active = (d) => d > 0 && d < threshold;
let activeTop = active(dTop);
let activeBottom = active(dBottom);
let activeLeft = active(dLeft);
let activeRight = active(dRight);
let activeTopLeft = activeTop && activeLeft;
let activeTopRight = activeTop && activeRight;
let activeBottomLeft = activeBottom && activeLeft;
let activeBottomRight = activeBottom && activeRight;
let withinAll = dTop > 0 && dBottom > 0 && dLeft > 0 && dRight > 0;
let cursor = (() => {
if (activeTopLeft) return "nw-resize";
if (activeTopRight) return "ne-resize";
if (activeBottomLeft) return "sw-resize";
if (activeBottomRight) return "se-resize";
if (activeTop) return "n-resize";
if (activeBottom) return "s-resize";
if (activeLeft) return "w-resize";
if (activeRight) return "e-resize";
if (withinAll) return "move";
return null;
})();
this.cursor = cursor;
this.canvasEl.style.cursor = this.cursor || "auto";
},
handleDocumentMouseMove(event) {
event.preventDefault();
let cursor = this.cursor.replace("-resize", "");
let deltaX = this.clientInitX - event.clientX;
let deltaY = this.clientInitY - event.clientY;
let imagePos = this.imagePos;
let initTop = this.initPos.top - imagePos.top;
let initBottom = this.initPos.bottom - imagePos.bottom;
let initLeft = this.initPos.left - imagePos.left;
let initRight = this.initPos.right - imagePos.right;
let top = initTop;
let bottom = initBottom;
let left = initLeft;
let right = initRight;
// Apply boundary checks to maintain square aspect ratio
let squareAspect = this.el.dataset["cropAspect"] === "square";
if (squareAspect && cursor !== "move") {
if (["n", "ne"].includes(cursor)) {
top -= deltaY + Math.min(initRight - deltaY, 0);
right -= deltaY + Math.min(initTop - deltaY, 0);
}
if (["s", "sw"].includes(cursor)) {
bottom += deltaY - Math.min(initLeft + deltaY, 0);
left += deltaY - Math.min(initBottom + deltaY, 0);
}
if (["w", "nw"].includes(cursor)) {
left -= deltaX + Math.min(initTop - deltaX, 0);
top -= deltaX + Math.min(initLeft - deltaX, 0);
}
if (["e", "se"].includes(cursor)) {
right += deltaX - Math.min(initBottom + deltaX, 0);
bottom += deltaX - Math.min(initRight + deltaX, 0);
}
} else {
if (["move", "n", "nw", "ne"].includes(cursor)) top -= deltaY;
if (["move", "s", "sw", "se"].includes(cursor)) bottom += deltaY;
if (["move", "w", "nw", "sw"].includes(cursor)) left -= deltaX;
if (["move", "e", "ne", "se"].includes(cursor)) right += deltaX;
}
// Apply min & max constraints
let minMax = (val, min, max) => Math.max(Math.min(val, max), min);
if (cursor === "move") {
top = minMax(top, 0, imagePos.height - this.initPos.height);
bottom = minMax(bottom, 0, imagePos.height - this.initPos.height);
left = minMax(left, 0, imagePos.width - this.initPos.width);
right = minMax(right, 0, imagePos.width - this.initPos.width);
} else {
top = minMax(top, 0, imagePos.height - initBottom - minSize);
bottom = minMax(bottom, 0, imagePos.height - initTop - minSize);
left = minMax(left, 0, imagePos.width - initRight - minSize);
right = minMax(right, 0, imagePos.width - initLeft - minSize);
}
this.pos = {
top: top + imagePos.top,
bottom: bottom + imagePos.bottom,
left: left + imagePos.left,
right: right + imagePos.right,
width: imagePos.width - left - right,
height: imagePos.height - top - bottom,
};
this.draw();
},
handleDocumentMouseUp(event) {
event.preventDefault();
this.moving = false;
this.initPos = this.pos;
this.updateInputs();
document.removeEventListener("mousemove", this.handleDocumentMouseMove);
document.removeEventListener("mouseup", this.handleDocumentMouseUp);
},
};
}