I am trying to get take a lasso-selection tool that uses points to define a polygon area on an image. I want to then take that image and display the clipped area (I believe that using GlobalCompositeOperation it's possible to get only the area inside the polygon or to exclude that area).
So basically, the selection tool defines a path of points (x,y) on an image. I want to take that image and display it on the div next to it or later export it. But I am first trying to display the clipped area at least.
I was able to get something drawing after reading through A reusable function to clip images into polygons using html5 canvas but I seem to be missing something
I also was having issues with the generated canvas being sized weirdly if anybody can help me with understanding how to get it to be sized like the image is displayed. Setting it to the image size makes it far larger and throws off the coordinates it seems.
lasso-tool.js
/**
* @typedef {Object} Point
* @property {number} x
* @property {number} y
*/
/**
* @typedef {Object} LassoOptions
* @property {HTMLImageElement} element
* @property {number} radius
* @property {(polygon: string) => void} onChange
* @property {(polygon: string) => void} onUpdate
* @property {boolean} enabled
*/
/**
* Create Canvas Config
* @param {LassoOptions} options
*/
function createLasso(options) {
if (!(options.element instanceof HTMLImageElement)) {
throw new Error('options.element is not a HTMLImageElement instance');
}
if (!options.element.parentElement) {
throw new Error('options.element have no parentElement');
}
options = Object.assign({
radius: 1,
onChange: Function.prototype,
onUpdate: Function.prototype,
enabled: true
}, options);
// Replace elements
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
/**
* @type {Point[]}
*/
const path = [];
let pathClosed = false;
const lastEvents = {
start: 0,
move: 0,
end: 0
};
/**
* @param {() => void} fn
*/
const addCtxPath = (fn) => {
ctx.save();
ctx.beginPath();
fn();
ctx.closePath();
ctx.restore();
}
/**
* @param {number} x
* @param {number} y
*/
const drawPoint = (x, y) => {
//Draws circle
addCtxPath(() => {
ctx.arc(x, y, options.radius, 0, 2 * Math.PI);
ctx.stroke();
});
//Draws line
addCtxPath(() => {
ctx.moveTo(x - options.radius / 2, y - options.radius / 2);
ctx.lineTo(x + options.radius / 2, y + options.radius / 2);
ctx.stroke();
});
//Completes cross inside circle
addCtxPath(() => {
ctx.moveTo(x + options.radius / 2, y - options.radius / 2);
ctx.lineTo(x - options.radius / 2, y + options.radius / 2);
ctx.stroke();
});
};
/**
* @param {Point} p1
* @param {Point} p2
*/
const drawLine = (p1, p2) => {
addCtxPath(() => {
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.stroke();
});
}
const nextFrame = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(options.element, 0, 0, canvas.width, canvas.height);
for (let i = 0; i < path.length; i++) {
const { x, y } = path[i];
drawPoint(x, y);
if (i > 0) {
drawLine(path[i - 1], path[i]);
}
}
/**This is where you handle what happens when the drawing is done */
if (pathClosed) {
if (path.length > 1) {
drawLine(path[0], path[path.length - 1]);
}
addCtxPath(() => {
ctx.moveTo(path[0].x, path[0].y);
for (let i = 1; i < path.length; i++) {
const { x, y } = path[i];
ctx.lineTo(x, y);
}
ctx.fillStyle = 'rgba(134, 228, 35, 0.25)';
ctx.fill();
});
} else if (path.length && !controllers.selectedPoint) {
const { x, y } = getDistance(path[0], controllers.pos) <= options.radius ? path[0] : controllers.pos;
drawPoint(x, y);
drawLine(path[path.length - 1], { x, y });
}
};
if (options.element.complete && options.element.naturalHeight !== 0) {
onLoad();
} else {
options.element.addEventListener('load', () => onLoad());
}
/**
* @param {MouseEvent | TouchEvent} e
* @param {boolean} [shiftSensitive]
*/
const getMousePosition = (e, shiftSensitive = true) => {
let clientX, clientY;
if (e instanceof MouseEvent) {
clientX = e.clientX;
clientY = e.clientY;
} else {
const tEvent = e.touches[0];
clientX = tEvent.clientX;
clientY = tEvent.clientY;
}
const rect = canvas.getBoundingClientRect();
const ret = {
x: clientX - rect.left,
y: clientY - rect.top
};
if (e.shiftKey) {
if (!controllers.relativePoint && path.length) {
controllers.relativePoint = path
.filter(p => p !== controllers.selectedPoint)
.reduce((a, b) => getDistance(ret, a) < getDistance(ret, b) ? a : b);
}
} else {
controllers.relativePoint = null;
}
if (shiftSensitive && controllers.relativePoint) {
straightenLine(ret, controllers.relativePoint);
}
return ret;
}
const controllers = {
mousedown: false,
startPos: { x: 0, y: 0 },
pos: { x: 0, y: 0 },
selectedPoint: null,
relativePoint: null
};
canvas.addEventListener('contextmenu', (e) => {
e.preventDefault();
});
['mousedown', 'touchstart'].forEach(event => canvas.addEventListener(event, /** @param {MouseEvent | TouchEvent} e */(e) => {
if (!options.enabled || Date.now() - lastEvents.start < 10) {
return;
}
lastEvents.start = Date.now();
nextFrame();
controllers.mousedown = true;
controllers.startPos = getMousePosition(e, false);
controllers.pos = getMousePosition(e);
controllers.selectedPoint = path.find((p1) => getDistance(p1, controllers.pos) <= options.radius) || null;
}));
['mousemove', 'touchmove'].forEach(event => canvas.addEventListener(event, /** @param {MouseEvent | TouchEvent} e */(e) => {
if (!options.enabled || Date.now() - lastEvents.move < 10) {
return;
}
lastEvents.move = Date.now();
controllers.pos = getMousePosition(e);
if (controllers.mousedown) {
if (controllers.selectedPoint) {
controllers.selectedPoint.x = controllers.pos.x;
controllers.selectedPoint.y = controllers.pos.y;
onPathUpdate();
}
}
nextFrame();
}));
['mouseup', 'touchend', 'touchcancel'].forEach(event => canvas.addEventListener(event, /** @param {MouseEvent | TouchEvent} e */(e) => {
if (!options.enabled || Date.now() - lastEvents.end < 10) {
return;
}
lastEvents.end = Date.now();
if (e instanceof MouseEvent && e.button === 2) {
if (controllers.selectedPoint) {
path.splice(path.indexOf(controllers.selectedPoint), 1);
} else {
const pointToRemove = path.find((p1) => getDistance(p1, controllers.pos) <= options.radius);
if (pointToRemove) {
path.splice(path.indexOf(pointToRemove), 1);
}
}
} else {
if (!controllers.selectedPoint) {
path.push({ x: controllers.pos.x, y: controllers.pos.y });
} else if (controllers.selectedPoint === path[0]) {
pathClosed = true;
}
}
if (path.length < 3) {
pathClosed = false;
}
controllers.mousedown = false;
controllers.selectedPoint = null;
controllers.relativePoint = null;
onPathChange();
onPathUpdate();
nextFrame();
}));
function onLoad() {
canvas.width = options.element.width;
canvas.height = options.element.height;
options.element.parentElement.replaceChild(canvas, options.element);
nextFrame();
}
/**
* @param {Point} point
* @param {Point} [relative]
*/
function straightenLine(point, relative) {
const dx = Math.abs(relative.x - point.x);
const dy = Math.abs(relative.y - point.y);
if (dx > dy) {
point.y = relative.y;
} else {
point.x = relative.x;
}
}
/**
* @param {Point} p1
* @param {Point} p2
*/
function getDistance(p1, p2) {
return Math.hypot(p1.x - p2.x, p1.y - p2.y);
}
function pathToString() {
return path.map(({ x, y }) => x + ',' + y).join(' ');
}
function onPathChange() {
const polygon = pathToString();
options.onChange(polygon);
}
function onPathUpdate() {
const polygon = pathToString();
options.onUpdate(polygon);
}
return {
reset() {
path.length = 0;
pathClosed = false;
nextFrame();
onPathChange();
onPathUpdate();
},
/**
* @param {string} polygon
*/
setPath(polygon) {
const newPath = polygon.split(' ').map(s => {
const [x, y] = s.split(',');
return { x: parseInt(x, 10), y: parseInt(y, 10) };
});
path.length = 0;
path.push(...newPath);
pathClosed = true;
nextFrame();
onPathChange();
onPathUpdate();
},
enable() {
options.enabled = true;
nextFrame();
},
disable() {
if (!pathClosed) {
path.length = 0;
pathClosed = true;
onPathChange();
onPathUpdate();
nextFrame();
}
options.enabled = false;
}
}
}
/**
* Getting the clipped image from the drawn area
* @param {string} src
* @param {Point[]} path
* @param {boolean} crop
* @param {(err:Error | null, canvas: HTMLCanvasElement) => void} callback
*/
function clipImage(src, path, crop, callback) {
const image = new Image();
image.crossOrigin = 'Anonymous';
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
return callback(new Error('Context is null'), canvas);
}
image.onerror = () => {
callback(new Error('Failed to load image'), canvas);
};
image.onload = () => {
try {
canvas.width = image.naturalWidth + 2;
canvas.height = image.naturalHeight + 2;
ctx.drawImage(image, 0, 0);
if (path.length < 3) {
callback(null, canvas);
return;
}
//Begin drawing the path of the polygon
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(canvas.width, 0);
ctx.lineTo(canvas.width, canvas.height);
ctx.lineTo(0, canvas.height);
ctx.lineTo(0, 0);
ctx.lineTo(path[0].x + 1, path[0].y + 1);
path.slice(1).forEach(({ x, y }) => ctx.lineTo(x + 1, y + 1));
ctx.lineTo(path[0].x + 1, path[0].y + 1);
ctx.lineTo(0, 0);
ctx.closePath();
ctx.clip('evenodd');
ctx.globalCompositeOperation = 'destination-out';
ctx.fill();
if (crop) {
const xAxis = path.map(({ x }) => x + 1);
const yAxis = path.map(({ y }) => y + 1);
const [minX, minY] = [Math.min.apply(null, xAxis), Math.min.apply(null, yAxis)];
const [maxX, maxY] = [Math.max.apply(null, xAxis), Math.max.apply(null, yAxis)];
const [width, height] = [maxX - minX, maxY - minY];
const imageData = ctx.getImageData(minX, minY, width, height);
canvas.width = width;
canvas.height = height;
ctx.putImageData(imageData, 0, 0);
}
callback(null, canvas);
} catch (err) {
callback(err instanceof Error ? err : new Error(String(err)), canvas);
}
image.src = src;
}
}
function clippingPath(path, img, canvas) {
if (path.length < 3) {
return;
}
var ctx = canvas.getContext("2d");
// save the unclipped context
ctx.save();
// define the path that will be clipped to
//Begin drawing the path of the polygon
ctx.beginPath();
ctx.moveTo(path[0].x + 1, path[0].y + 1);
path.slice(1).forEach(({ x, y }) => ctx.lineTo(x + 1, y + 1));
ctx.lineTo(path[1].x + 1, path[1].y + 1);
// stroke the path
// half of the stroke is outside the path
// the outside stroke will survive the clipping that follows
ctx.lineWidth = 1;
ctx.stroke();
// make the current path a clipping path
ctx.clip();
// draw the image which will be clipped except in the clipping path
ctx.drawImage(img, 0, 0);
// restore the unclipped context (==undo the clipping path)
ctx.restore();
}
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lasso Tool Example</title>
</head>
<body style="text-align: center;">
<div style="width: 49%;display: inline-block; position: relative;">
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/b/b6/Image_created_with_a_mobile_phone.png/1280px-Image_created_with_a_mobile_phone.png" style="width: 100%; height: 100%;" />
</div>
<div style="width: 49%;display: inline-block; position: relative;">
<canvas id="canvas"></canvas>
</div>
<div style="text-align: center;">
<button>RESET</button>
<br /><br />
<div id="output1"></div>
<div id="output2"></div>
</div>
<script src="./src/lasso-tool.js"></script>
<script type="text/javascript">
const $img = document.querySelector("img");
const $out1 = document.querySelector("#output1");
const $out2 = document.querySelector("#output2");
const $canvas = document.getElementById("canvas");
$img.parentElement.setAttribute("width", $img.width);
$img.parentElement.setAttribute("height", $img.height);
$canvas.setAttribute("width", $img.width);
$canvas.setAttribute("height",390);
const lasso = createLasso({
element: $img,
radius: 8,
onChange(polygon) {
$out1.innerHTML = "onChange(): " + polygon;
},
onUpdate(polygon) {
$out2.innerHTML = "onUpdate(): " + polygon;
const newPath = polygon.split(' ').map(s => {
const [x, y] = s.split(',');
return { x: parseInt(x, 10), y: parseInt(y, 10) };
});
if (newPath.length >= 3) {
//Clip image
clippingPath(newPath, $img, canvas);
}
}
});
document.querySelector("button").addEventListener("click", () => lasso.reset());
</script>
</body>
</html>