UNPKG

jcanvas

Version:

jCanvas brings jQuery's powerful syntax and capability to the HTML5 canvas. Quickly create canvas-based apps that can utilize layers, animations, events, and much more. jCanvas works on all modern browsers and platforms, including iOS and Android.

4,605 lines 122 kB
/**
 * @license jCanvas
 * Copyright 2017-2024 Caleb Evans
 * Released under the MIT license
 */
import $ from "jquery";
import "./jcanvas.d";

// Define local aliases t o frequently used properties
const extendObject = Object.assign,
	inArray = $.inArray,
	typeOf = function (operand: any) {
		return Object.prototype.toString.call(operand).slice(8, -1).toLowerCase();
	},
	isPlainObject = $.isPlainObject,
	// Math constants and functions
	PI = Math.PI,
	round = Math.round,
	abs = Math.abs,
	sin = Math.sin,
	cos = Math.cos,
	atan2 = Math.atan2,
	// jQuery's internal event normalization function
	jQueryEventFix = ($.event as JQueryEventWithFix).fix,
	// Object for storing a number of internal property maps
	maps: JCanvasMaps = {
		// Map drawing names with their respective method names
		drawings: {
			arc: "drawArc",
			bezier: "drawBezier",
			ellipse: "drawEllipse",
			function: "draw",
			image: "drawImage",
			line: "drawLine",
			path: "drawPath",
			polygon: "drawPolygon",
			slice: "drawSlice",
			quadratic: "drawQuadratic",
			rectangle: "drawRect",
			text: "drawText",
			vector: "drawVector",
			save: "saveCanvas",
			restore: "restoreCanvas",
			rotate: "rotateCanvas",
			scale: "scaleCanvas",
			translate: "translateCanvas",
		},
		touchEvents: {
			mousedown: "touchstart",
			mouseup: "touchend",
			mousemove: "touchmove",
		},
		// Map standard touch events to mouse events
		mouseEvents: {
			touchstart: "mousedown",
			touchend: "mouseup",
			touchmove: "mousemove",
		},
	},
	// jQuery internal caches
	caches: JCanvasCache = {
		dataCache: {},
		propCache: {},
		imageCache: {},
		pathCache: {},
	},
	// Base transformations
	baseTransforms: JCanvasBaseTransforms = {
		rotate: 0,
		scaleX: 1,
		scaleY: 1,
		translateX: 0,
		translateY: 0,
		// Store all previous masks
		masks: [],
	},
	css: JCanvasCss = {
		// Define properties used in both CSS and jCanvas
		props: ["width", "height", "opacity", "lineHeight"],
		propsObj: {},
	},
	tangibleEvents: (
		| JCanvasMouseEventName
		| JCanvasTouchEventName
		| JCanvasPointerEventName
	)[] = [
		"mousedown",
		"mousemove",
		"mouseup",
		"mouseover",
		"mouseout",
		"touchstart",
		"touchmove",
		"touchend",
		"pointerdown",
		"pointermove",
		"pointerup",
	];

// jCanvas object in which global settings are other data are stored
const jCanvas = {
	// Events object for storing jCanvas event initiation functions
	events: {},
	// Object containing all jCanvas event hooks
	eventHooks: {},
	// Settings for enabling future jCanvas features
	future: {},
	// Need to type assert since some public API methods like transformShape are
	// lazily added to this object
} as JCanvas;

// jCanvas default property values
class jCanvasDefaults implements JCanvasDefaults {
	align: CanvasRenderingContext2D["textAlign"] = "center";
	arrowAngle: number = 90;
	arrowRadius: number = 0;
	autosave: boolean = true;
	baseline: CanvasRenderingContext2D["textBaseline"] = "middle";
	bringToFront: boolean = false;
	canvas: HTMLCanvasElement | null = null;
	ccw: boolean = false;
	closed: boolean = false;
	compositing: CanvasRenderingContext2D["globalCompositeOperation"] =
		"source-over";
	concavity: number = 0;
	cornerRadius: number = 0;
	count: number = 1;
	cropFromCenter: boolean = true;
	crossOrigin: HTMLImageElement["crossOrigin"] = null;
	cursors: Record<string, string> | null = null;
	disableEvents: boolean = false;
	draggable: boolean = false;
	dragging: boolean = false;
	dragGroups: string[] | null = null;
	groups: string[] | null = null;
	d: string | null = null;
	data: object | null = null;
	dx: number = 0;
	dy: number = 0;
	end: number = 360;
	endArrow: boolean = false;
	eventX: number | null = null;
	eventY: number | null = null;
	fillRule: CanvasFillRule = "nonzero";
	fillStyle: string | CanvasGradient | CanvasPattern | JCanvasStyleFunction =
		"transparent";
	flipArcText: boolean = false;
	fontStyle: string = "normal";
	fontSize: string = "12pt";
	fontFamily: string = "sans-serif";
	fromCenter: boolean = true;
	height: number | null = null;
	imageSmoothing: boolean = true;
	inDegrees: boolean = true;
	intangible: boolean = false;
	index: number | null = null;
	intersects: boolean = false;
	letterSpacing: number | null = null;
	lineHeight: number = 1;
	layer: boolean = false;
	mask: boolean = false;
	maxWidth: number | null = null;
	method: keyof JQuery | null = null;
	miterLimit: number = 10;
	name: string | null = null;
	opacity: number = 1;
	r1: number | null = null;
	r2: number | null = null;
	radius: number = 0;
	repeat: Parameters<CanvasRenderingContext2D["createPattern"]>[1] = "repeat";
	respectAlign: boolean = false;
	restrictDragToAxis: "x" | "y" | null = null;
	rotate: number = 0;
	rounded: boolean = false;
	scale: number = 1;
	scaleX: number = 1;
	scaleY: number = 1;
	shadowBlur: number = 0;
	shadowColor: string = "transparent";
	shadowStroke: boolean = false;
	shadowX: number = 0;
	shadowY: number = 0;
	sHeight: number | null = null;
	sides: number = 0;
	source: string | HTMLImageElement | HTMLCanvasElement = "";
	spread: number = 0;
	start: number = 0;
	startArrow: boolean = false;
	strokeCap: CanvasRenderingContext2D["lineCap"] = "butt";
	strokeDash: number[] | null = null;
	strokeDashOffset: CanvasRenderingContext2D["lineDashOffset"] = 0;
	strokeJoin: CanvasRenderingContext2D["lineJoin"] = "miter";
	strokeStyle: string | CanvasGradient | CanvasPattern | JCanvasStyleFunction =
		"transparent";
	strokeWidth: number = 1;
	style: Record<string, boolean> = {};
	sWidth: number | null = null;
	sx: number | null = null;
	sy: number | null = null;
	text: string = "";
	translate: number = 0;
	translateX: number = 0;
	translateY: number = 0;
	type: string | null = null;
	visible: boolean = true;
	width: number | null = null;
	willReadFrequently: boolean = false;
	x: number = 0;
	y: number = 0;
	[key: `x${number}`]: number;
	[key: `y${number}`]: number;
	[key: `cx${number}`]: number;
	[key: `cy${number}`]: number;
	[key: `a${number}`]: number;
	[key: `l${number}`]: number;
	[key: `p${number}`]: number;
	[key: `_${string}`]: any;
}
const defaults = new jCanvasDefaults();

// Constructor for creating objects that inherit from jCanvas preferences and defaults
const jCanvasObject: JCanvasObjectFunction = function jCanvasObject(args) {
	return extendObject(this, args);
} as JCanvasObjectFunction;
jCanvasObject.prototype = defaults;

// Constructor for creating a fully-qualified jCanvas layer
const jCanvasLayer: JCanvasLayerFunction = function jCanvasLayer(
	canvas,
	params
) {
	Object.assign(this, params, {
		canvas,
		_layer: true,
	});
} as JCanvasLayerFunction;
jCanvasLayer.prototype = jCanvasObject.prototype;

function _getParamsObject(args?: Partial<JCanvasObject>) {
	return args?._layer ? (args as JCanvasLayer) : new jCanvasObject(args);
}

/* Internal helper methods */

// Determines if the given operand is a string
function isString(operand: any): operand is string {
	return typeOf(operand) === "string";
}

// Determines if the given operand is a function
function isFunction(operand: any): operand is Function {
	return typeOf(operand) === "function";
}

// Determines if the given operand is numeric
function isNumeric(operand: any): operand is number | string {
	return !isNaN(Number(operand)) && !isNaN(parseFloat(operand));
}

// Tells TypeScript that the given element is a canvas
function _isCanvas(element: HTMLElement): element is HTMLCanvasElement {
	return element instanceof HTMLCanvasElement;
}

// Get 2D context for the given canvas
function _getContext(
	canvas: HTMLCanvasElement
): CanvasRenderingContext2D | null {
	if ($.jCanvas.defaults.willReadFrequently) {
		return canvas.getContext("2d", {
			willReadFrequently: $.jCanvas.defaults.willReadFrequently,
		});
	} else {
		return canvas.getContext("2d");
	}
}

// Coerce designated number properties from strings to numbers
function _coerceNumericProps(props: Partial<JCanvasObject>) {
	// Loop through all properties in given property map
	for (const propName in props) {
		if (Object.prototype.hasOwnProperty.call(props, propName)) {
			const propValue = props[propName as keyof typeof props];
			const propType = typeOf(propValue);
			// If property is non-empty string and value is numeric
			if (
				propType === "string" &&
				isNumeric(propValue) &&
				propName !== "text"
			) {
				// Convert value to number
				props[propName as keyof typeof props] = parseFloat(String(propValue));
			}
		}
	}
	// Ensure value of text property is always a string
	if (props.text !== undefined) {
		props.text = String(props.text);
	}
}

// Clone the given transformations object
function _cloneTransforms(transforms: JCanvasBaseTransforms) {
	// Clone the object itself
	transforms = extendObject({}, transforms);
	// Clone the object's masks array
	transforms.masks = transforms.masks.slice(0);
	return transforms;
}

// Save canvas context and update transformation stack
function _saveCanvas(ctx: CanvasRenderingContext2D, data: JCanvasInternalData) {
	ctx.save();
	const transforms = _cloneTransforms(data.transforms);
	data.savedTransforms.push(transforms);
}

// Restore canvas context update transformation stack
function _restoreCanvas(
	ctx: CanvasRenderingContext2D,
	data: JCanvasInternalData
) {
	if (data.savedTransforms.length === 0) {
		// Reset transformation state if it can't be restored any more
		data.transforms = _cloneTransforms(baseTransforms);
	} else {
		// Restore canvas context
		ctx.restore();
		// Restore current transform state to the last saved state
		const lastTransform = data.savedTransforms.pop();
		if (lastTransform) {
			data.transforms = lastTransform;
		}
	}
}

// Set the style with the given name
function _setStyle(
	canvas: HTMLCanvasElement,
	ctx: CanvasRenderingContext2D,
	params: JCanvasObject,
	styleName: "fillStyle" | "strokeStyle"
) {
	const styleValue = params[styleName];
	if (styleValue) {
		if (isFunction(styleValue)) {
			// Handle functions
			ctx[styleName] = styleValue.call(canvas, params);
		} else {
			// Handle string, gradients, and patterns
			ctx[styleName] = styleValue;
		}
	}
}

// Set canvas context properties
function _setGlobalProps(
	canvas: HTMLCanvasElement,
	ctx: CanvasRenderingContext2D,
	params: JCanvasObject
) {
	_setStyle(canvas, ctx, params, "fillStyle");
	_setStyle(canvas, ctx, params, "strokeStyle");
	ctx.lineWidth = params.strokeWidth;
	// Optionally round corners for paths
	if (params.rounded) {
		ctx.lineCap = ctx.lineJoin = "round";
	} else {
		ctx.lineCap = params.strokeCap;
		ctx.lineJoin = params.strokeJoin;
		ctx.miterLimit = params.miterLimit;
	}
	// Reset strokeDash if null
	if (!params.strokeDash) {
		params.strokeDash = [];
	}
	// Dashed lines
	if (ctx.setLineDash) {
		ctx.setLineDash(params.strokeDash);
	}
	// @ts-expect-error Permit vendor-prefixed property
	ctx.webkitLineDash = params.strokeDash;
	ctx.lineDashOffset =
		// @ts-expect-error Permit vendor-prefixed property
		ctx.webkitLineDashOffset =
		// @ts-expect-error Permit vendor-prefixed property
		ctx.mozDashOffset =
			params.strokeDashOffset;
	// Drop shadow
	ctx.shadowOffsetX = params.shadowX;
	ctx.shadowOffsetY = params.shadowY;
	ctx.shadowBlur = params.shadowBlur;
	ctx.shadowColor = params.shadowColor;
	// Opacity and composite operation
	ctx.globalAlpha = params.opacity;
	ctx.globalCompositeOperation = params.compositing;
	// Support cross-browser toggling of image smoothing
	if (params.imageSmoothing) {
		ctx.imageSmoothingEnabled = params.imageSmoothing;
	}
}

// Optionally enable masking support for this path
function _enableMasking(
	ctx: CanvasRenderingContext2D,
	data: JCanvasInternalData,
	params: JCanvasObject
) {
	if (params.mask) {
		// If jCanvas autosave is enabled
		if (params.autosave) {
			// Automatically save transformation state by default
			_saveCanvas(ctx, data);
		}
		// Clip the current path
		if (params._path) {
			ctx.clip(params._path, params.fillRule);
		} else {
			ctx.clip(params.fillRule);
		}
		// Keep track of current masks
		data.transforms.masks.push(params._args);
	}
}

// Restore individual shape transformation
function _restoreTransform(
	ctx: CanvasRenderingContext2D,
	params: JCanvasObject
) {
	// If shape has been transformed by jCanvas
	if (params._transformed) {
		// Restore canvas context
		ctx.restore();
	}
}

// Close current canvas path
function _closePath(
	canvas: HTMLCanvasElement,
	ctx: CanvasRenderingContext2D,
	params: JCanvasObject
) {
	// Optionally close path
	if (params.closed) {
		ctx.closePath();
	}

	if (params.shadowStroke && params.strokeWidth !== 0) {
		// Extend the shadow to include the stroke of a drawing

		// Add a stroke shadow by stroking before filling
		if (params._path) {
			ctx.stroke(params._path);
			ctx.fill(params._path, params.fillRule);
		} else {
			ctx.stroke();
			ctx.fill(params.fillRule);
		}
		// Ensure the below stroking does not inherit a shadow
		ctx.shadowColor = "transparent";
		ctx.shadowBlur = 0;
		// Stroke over fill as usual
		if (params._path) {
			ctx.stroke(params._path);
		} else {
			ctx.stroke();
		}
	} else {
		// If shadowStroke is not enabled, stroke & fill as usual

		if (params._path) {
			ctx.fill(params._path, params.fillRule);
		} else {
			ctx.fill(params.fillRule);
		}
		// Prevent extra shadow created by stroke (but only when fill is present)
		if (params.fillStyle !== "transparent") {
			ctx.shadowColor = "transparent";
		}
		if (params.strokeWidth !== 0) {
			// Only stroke if the stroke is not 0
			if (params._path) {
				ctx.stroke(params._path);
			} else {
				ctx.stroke();
			}
		}
	}

	// Optionally close path
	if (!params.closed) {
		ctx.closePath();
	}

	// Restore individual shape transformation
	_restoreTransform(ctx, params);

	// Mask shape if chosen
	if (params.mask) {
		// Retrieve canvas data
		const data = _getCanvasData(canvas);
		_enableMasking(ctx, data, params);
	}
}

// Transform (translate, scale, or rotate) shape
function _transformShape(
	_canvas: HTMLCanvasElement,
	ctx: CanvasRenderingContext2D,
	params: JCanvasObject,
	width: number | null = null,
	height: number | null = null
) {
	// Get conversion factor for radians
	params._toRad = params.inDegrees ? PI / 180 : 1;

	params._transformed = true;
	ctx.save();

	// Optionally measure (x, y) position from top-left corner
	if (
		!params.fromCenter &&
		!params._centered &&
		width !== null &&
		height !== null
	) {
		// Always draw from center unless otherwise specified
		if (height === undefined) {
			height = width;
		}
		params.x += width / 2;
		params.y += height / 2;
		params._centered = true;
	}
	// Optionally rotate shape
	if (params.rotate) {
		_rotateCanvas(ctx, params, null);
	}
	// Optionally scale shape
	if (params.scale !== 1 || params.scaleX !== 1 || params.scaleY !== 1) {
		_scaleCanvas(ctx, params, null);
	}
	// Optionally translate shape
	if (params.translate || params.translateX || params.translateY) {
		_translateCanvas(ctx, params, null);
	}
}

/* Plugin API */

// Extend jCanvas with a user-defined method
jCanvas.extend = function extend(plugin) {
	// Create plugin
	if (plugin.name) {
		// Merge properties with defaults
		if (plugin.props) {
			extendObject(defaults, plugin.props);
		}
		// Define plugin method
		// @ts-expect-error TODO: fix this
		$.fn[plugin.name] = function self(args) {
			const $canvases = this;

			for (let e = 0; e < $canvases.length; e += 1) {
				const canvas = $canvases[e];
				if (!_isCanvas(canvas)) {
					continue;
				}
				const ctx = _getContext(canvas);
				if (!ctx) {
					continue;
				}
				const params = _getParamsObject(args);
				_addLayer(canvas, params, args, self);

				_setGlobalProps(canvas, ctx, params);
				plugin.fn.call(canvas, ctx, params);
			}
			return $canvases;
		};
		// Add drawing type to drawing map
		if (plugin.type) {
			maps.drawings[plugin.type] = plugin.name as string;
		}
	}
	// @ts-expect-error TODO: fix this
	return $.fn[plugin.name];
};

/* Layer API */

class JCanvasInternalData {
	// The associated canvas element
	canvas: HTMLCanvasElement;
	// Layers array
	layers: JCanvasLayer[] = [];
	// Layer maps
	layer: {
		names: Record<string, JCanvasLayer>;
		groups: Record<string, JCanvasLayer[]>;
	} = {
		names: {},
		groups: {},
	};
	eventHooks = {};
	// All layers that intersect with the event coordinates (regardless of visibility)
	intersecting: JCanvasLayer[] = [];
	// The topmost layer whose area contains the event coordinates
	lastIntersected: JCanvasLayer | null = null;
	cursor: string;
	// Properties for the current drag event
	drag = {
		layer: null as JCanvasLayer | null,
		dragging: false,
	};
	// Data for the current event
	event: {
		type: JCanvasInteractionEventName | null;
		x: number | null;
		y: number | null;
		event?: Event | null;
	} = {
		type: null,
		x: null,
		y: null,
	};
	// Events which already have been bound to the canvas
	events: Record<string, boolean> = {};
	// The canvas's current transformation state
	transforms: JCanvasBaseTransforms;
	savedTransforms: JCanvasBaseTransforms[] = [];
	// Whether a layer is being animated or not
	animating = false;
	// The layer currently being animated
	animated: JCanvasLayer | null = null;
	// The device pixel ratio
	pixelRatio = 1;
	// Whether pixel ratio transformations have been applied
	scaled = false;
	// Whether the canvas should be redrawn when a layer mousemove
	// event triggers (either directly, or indirectly via dragging)
	redrawOnMousemove = false;
	originalRedrawOnMousemove = false;
	drawLayersComplete?: () => void;
	constructor(canvas: HTMLCanvasElement) {
		this.canvas = canvas;
		this.cursor = $(canvas).css("cursor");
		this.transforms = _cloneTransforms(baseTransforms);
	}
}

// Retrieved the stored jCanvas data for a canvas element
function _getCanvasData(canvas: HTMLCanvasElement) {
	const dataCache = caches.dataCache;
	let data: JCanvasInternalData;
	if (dataCache._canvas === canvas && dataCache._data) {
		// Retrieve canvas data from cache if possible
		data = dataCache._data;
	} else {
		// Retrieve canvas data from jQuery's internal data storage
		data = $.data(canvas, "jCanvas");
		if (!data) {
			// Create canvas data object if it does not already exist
			data = new JCanvasInternalData(canvas);
			// Use jQuery to store canvas data
			$.data(canvas, "jCanvas", data);
		}
		// Cache canvas data for faster retrieval
		dataCache._canvas = canvas;
		dataCache._data = data;
	}
	return data;
}

// Initialize all of a layer's associated jCanvas events
function _addLayerEvents(
	$canvas: JQuery<HTMLCanvasElement>,
	data: JCanvasInternalData,
	layer: JCanvasLayer
) {
	// Determine which jCanvas events need to be bound to this layer
	for (const eventName in jCanvas.events) {
		if (Object.prototype.hasOwnProperty.call(jCanvas.events, eventName)) {
			// If layer has callback function to complement it
			if (
				layer[eventName as keyof JCanvasLayer] ||
				layer.cursors?.[eventName]
			) {
				// Bind event to layer
				_addExplicitLayerEvent($canvas, data, layer, eventName);
			}
		}
	}
	if (!data.events.mouseout) {
		$canvas.on("mouseout.jCanvas", function () {
			let shouldRedraw = false;
			// Retrieve the layer whose drag event was canceled
			const dragLayer = data.drag.layer;
			// If cursor mouses out of canvas while dragging
			if (dragLayer) {
				// Cancel drag
				data.drag = { layer: null, dragging: false };
				_triggerLayerEvent($canvas, data, dragLayer, "dragcancel");
				shouldRedraw = true;
			}
			// Loop through all layers
			for (let l = 0; l < data.layers.length; l += 1) {
				layer = data.layers[l];
				// If layer thinks it's still being moused over
				if (layer?._hovered) {
					// Trigger mouseout on layer
					$canvas.triggerLayerEvent(data.layers[l], "mouseout");
					shouldRedraw = true;
				}
			}
			// Redraw layers only if an event has been triggered
			if (shouldRedraw) {
				$canvas.drawLayers();
			}
		});
		// Indicate that an event handler has been bound
		data.events.mouseout = true;
	}
}

// Initialize the given event on the given layer
function _addLayerEvent(
	$canvas: JQuery<HTMLCanvasElement>,
	data: JCanvasInternalData,
	layer: JCanvasLayer,
	eventName: string
) {
	// Use touch events if appropriate
	// eventName = _getMouseEventName(eventName);
	// Bind event to layer
	jCanvas.events[eventName]($canvas, data);
	layer._event = true;
}

// Add a layer event that was explicitly declared in the layer's parameter map,
// excluding events added implicitly (e.g. mousemove event required by draggable
// layers)
function _addExplicitLayerEvent(
	$canvas: JQuery<HTMLCanvasElement>,
	data: JCanvasInternalData,
	layer: JCanvasLayer,
	eventName: string
) {
	_addLayerEvent($canvas, data, layer, eventName);
	if (
		eventName === "mouseover" ||
		eventName === "mouseout" ||
		eventName === "mousemove"
	) {
		data.redrawOnMousemove = true;
	}
}

// Enable drag support for this layer
function _enableDrag(
	$canvas: JQuery<HTMLCanvasElement>,
	data: JCanvasInternalData,
	layer: JCanvasLayer
) {
	// Only make layer draggable if necessary
	if (layer.draggable || layer.cursors) {
		// Organize helper events which enable drag support
		const dragHelperEvents = ["mousedown", "mousemove", "mouseup"];

		// Bind each helper event to the canvas
		for (let i = 0; i < dragHelperEvents.length; i += 1) {
			// Use touch events if appropriate
			const eventName = dragHelperEvents[i];
			// Bind event
			_addLayerEvent($canvas, data, layer, eventName);
		}
		// Indicate that this layer has events bound to it
		layer._event = true;
	}
}

// Update a layer property map if property is changed
function _updateLayerName(
	data: JCanvasInternalData,
	layer: JCanvasLayer,
	props?: Partial<JCanvasObject>
) {
	const nameMap = data.layer.names;

	// If layer name is being added, not changed
	if (!props) {
		props = layer;
	} else {
		// Remove old layer name entry because layer name has changed
		if (
			props.name !== undefined &&
			isString(layer.name) &&
			layer.name !== props.name
		) {
			delete nameMap[layer.name];
		}
	}

	// Add new entry to layer name map with new name
	if (isString(props.name)) {
		nameMap[props.name] = layer;
	}
}

// Create or update the data map for the given layer and group type
function _updateLayerGroups(
	data: JCanvasInternalData,
	layer: JCanvasLayer,
	props?: Partial<JCanvasObject>
) {
	const groupMap = data.layer.groups;
	let index: number | undefined = undefined;

	// If group name is not changing
	if (!props) {
		props = layer;
	} else {
		// Remove layer from all of its associated groups
		if (props.groups !== undefined && layer.groups !== null) {
			for (let g = 0; g < layer.groups.length; g += 1) {
				const groupName = layer.groups[g];
				const group = groupMap[groupName];
				if (group) {
					// Remove layer from its old layer group entry
					for (let l = 0; l < group.length; l += 1) {
						if (group[l] === layer) {
							// Keep track of the layer's initial index
							index = l;
							// Remove layer once found
							group.splice(l, 1);
							break;
						}
					}
					// Remove layer group entry if group is empty
					if (group.length === 0) {
						delete groupMap[groupName];
					}
				}
			}
		}
	}

	// Add layer to new group if a new group name is given
	if (props.groups !== undefined && props.groups !== null) {
		for (let g = 0; g < props.groups.length; g += 1) {
			const groupName = props.groups[g];

			let group = groupMap[groupName];
			if (!group) {
				// Create new group entry if it doesn't exist
				group = groupMap[groupName] = [];
			}
			if (index === undefined) {
				// Add layer to end of group unless otherwise stated
				index = group.length;
			}
			// Add layer to its new layer group
			group.splice(index, 0, layer);
		}
	}
}

// Get event hooks object for the first selected canvas
$.fn.getEventHooks = function getEventHooks() {
	const $canvases = this;

	if ($canvases.length !== 0) {
		const canvas = $canvases[0];
		if (!_isCanvas(canvas)) {
			return {};
		}
		const data = _getCanvasData(canvas);
		return data.eventHooks;
	}
	return {};
};

// Set event hooks for the selected canvases
$.fn.setEventHooks = function setEventHooks(eventHooks) {
	const $canvases = this;
	for (let e = 0; e < $canvases.length; e += 1) {
		const canvas = $canvases[e];
		if (!_isCanvas(canvas)) {
			continue;
		}
		const data = _getCanvasData(canvas);
		extendObject(data.eventHooks, eventHooks);
	}
	return $canvases;
};

// Get jCanvas layers array
$.fn.getLayers = function getLayers(callback) {
	const $canvases = this;
	let matching: JCanvasLayer[] = [];

	if ($canvases.length !== 0) {
		const canvas = $canvases[0];
		if (!_isCanvas(canvas)) {
			return matching;
		}
		const data = _getCanvasData(canvas);
		// Retrieve layers array for this canvas
		const layers = data.layers;

		// If a callback function is given
		if (isFunction(callback)) {
			// Filter the layers array using the callback
			for (let l = 0; l < layers.length; l += 1) {
				const layer = layers[l];
				if (callback.call(canvas, layer)) {
					// Add layer to array of matching layers if test passes
					matching.push(layer);
				}
			}
		} else {
			// Otherwise, get all layers

			matching = layers;
		}
	}
	return matching;
};

// Get a single jCanvas layer object
$.fn.getLayer = function getLayer(layerId) {
	const $canvases = this;

	if ($canvases.length !== 0) {
		const canvas = $canvases[0];
		if (!_isCanvas(canvas)) {
			return undefined;
		}
		const data = _getCanvasData(canvas);
		const layers = data.layers;
		const idType = typeOf(layerId);

		if (
			layerId &&
			typeof layerId === "object" &&
			"layer" in layerId &&
			layerId.layer
		) {
			// Return the actual layer object if given
			return layerId;
		} else if (idType === "number") {
			// Retrieve the layer using the given index

			let layerIndex = layerId as number;

			// Allow for negative indices
			if (layerIndex < 0) {
				layerIndex = layers.length + layerIndex;
			}
			// Get layer with the given index
			return layers[layerIndex];
		} else if (idType === "regexp") {
			const layerPattern = layerId as RegExp;
			// Get layer with the name that matches the given regex
			for (let l = 0; l < layers.length; l += 1) {
				const layer = layers[l];
				// Check if layer matches name
				if (isString(layer.name) && layer.name.match(layerPattern)) {
					return layer;
				}
			}
		} else {
			const layerName = layerId as string;
			// Get layer with the given name
			return data.layer.names[layerName];
		}
	}
};

// Get all layers in the given group
$.fn.getLayerGroup = function getLayerGroup(groupId) {
	const $canvases = this;
	const idType = typeOf(groupId);

	if ($canvases.length !== 0) {
		const canvas = $canvases[0];
		if (!_isCanvas(canvas)) {
			return undefined;
		}

		if (idType === "array") {
			// Return layer group if given
			return groupId as Exclude<typeof groupId, string | RegExp>;
		} else if (idType === "regexp") {
			const groupPattern = groupId as RegExp;
			// Get canvas data
			const data = _getCanvasData(canvas);
			const groups = data.layer.groups;
			// Loop through all layers groups for this canvas
			for (const groupName in groups) {
				// Find a group whose name matches the given regex
				if (groupName.match(groupPattern)) {
					// Stop after finding the first matching group
					return groups[groupName];
				}
			}
		} else if (typeof groupId === "string") {
			// Find layer group with the given group name
			const groupName = groupId as string;
			const data = _getCanvasData(canvas);
			return data.layer.groups[groupName];
		}
	}
};

// Get index of layer in layers array
$.fn.getLayerIndex = function getLayerIndex(layerId) {
	const $canvases = this;
	const layers = $canvases.getLayers();
	const layer = $canvases.getLayer(layerId);

	return inArray(layer, layers);
};

// Set properties of a layer
$.fn.setLayer = function setLayer(layerId, props) {
	const $canvases = this;

	for (let e = 0; e < $canvases.length; e += 1) {
		const canvas = $canvases[e];
		if (!_isCanvas(canvas)) {
			continue;
		}
		const $canvas = $(canvas);
		const data = _getCanvasData(canvas);

		const layer = $($canvases[e]).getLayer(layerId);
		if (layer) {
			// Update layer property maps
			_updateLayerName(data, layer, props);
			_updateLayerGroups(data, layer, props);

			_coerceNumericProps(props);

			// Merge properties with layer
			for (const propName in props) {
				if (Object.prototype.hasOwnProperty.call(props, propName)) {
					const propValue = props[propName as keyof typeof props];
					const propType = typeOf(propValue);
					if (propType === "object" && isPlainObject(propValue)) {
						// Clone objects
						layer[propName as any] = extendObject({}, propValue);
						_coerceNumericProps(layer[propName as keyof JCanvasLayer]);
					} else if (propType === "array") {
						// Clone arrays
						layer[propName as any] = propValue.slice(0);
					} else if (propType === "string") {
						if (propValue.indexOf("+=") === 0) {
							// Increment numbers prefixed with +=
							layer[propName as any] += parseFloat(propValue.substr(2));
						} else if (propValue.indexOf("-=") === 0) {
							// Decrement numbers prefixed with -=
							layer[propName as any] -= parseFloat(propValue.substr(2));
						} else if (
							!isNaN(propValue) &&
							isNumeric(propValue) &&
							propName !== "text"
						) {
							// Convert numeric values as strings to numbers
							layer[propName as any] = parseFloat(String(propValue));
						} else {
							// Otherwise, set given string value
							layer[propName as any] = propValue;
						}
					} else {
						// Otherwise, set given value
						layer[propName as any] = propValue;
					}
				}
			}

			// Update layer events
			_addLayerEvents($canvas, data, layer);
			_enableDrag($canvas, data, layer);

			// If layer's properties were changed
			if ($.isEmptyObject(props) === false) {
				_triggerLayerEvent($canvas, data, layer, "change", props);
			}
		}
	}
	return $canvases;
};

// Set properties of all layers (optionally filtered by a callback)
$.fn.setLayers = function setLayers(props, callback) {
	const $canvases = this;
	for (let e = 0; e < $canvases.length; e += 1) {
		const $canvas = $($canvases[e]);

		const layers = $canvas.getLayers(callback);
		// Loop through all layers
		for (let l = 0; l < layers.length; l += 1) {
			// Set properties of each layer
			$canvas.setLayer(layers[l], props);
		}
	}
	return $canvases;
};

// Set properties of all layers in the given group
$.fn.setLayerGroup = function setLayerGroup(groupId, props) {
	const $canvases = this;

	for (let e = 0; e < $canvases.length; e += 1) {
		// Get layer group
		const $canvas = $($canvases[e]);

		const group = $canvas.getLayerGroup(groupId);
		// If group exists
		if (group) {
			// Loop through layers in group
			for (let l = 0; l < group.length; l += 1) {
				// Merge given properties with layer
				$canvas.setLayer(group[l], props);
			}
		}
	}
	return $canvases;
};

// Move a layer to the given index in the layers array
$.fn.moveLayer = function moveLayer(layerId, index) {
	const $canvases = this;

	for (let e = 0; e < $canvases.length; e += 1) {
		const canvas = $canvases[e];
		if (!_isCanvas(canvas)) {
			continue;
		}
		const $canvas = $(canvas);
		const data = _getCanvasData(canvas);

		// Retrieve layers array and desired layer
		const layers = data.layers;
		const layer = $canvas.getLayer(layerId);
		if (layer) {
			// Ensure layer index is accurate
			layer.index = inArray(layer, layers);

			// Remove layer from its current placement
			layers.splice(layer.index, 1);
			// Add layer in its new placement
			layers.splice(index, 0, layer);

			// Handle negative indices
			if (index < 0) {
				index = layers.length + index;
			}
			// Update layer's stored index
			layer.index = index;

			_triggerLayerEvent($canvas, data, layer, "move");
		}
	}
	return $canvases;
};

// Remove a jCanvas layer
$.fn.removeLayer = function removeLayer(layerId) {
	const $canvases = this;

	for (let e = 0; e < $canvases.length; e += 1) {
		const canvas = $canvases[e];
		if (!_isCanvas(canvas)) {
			continue;
		}
		const $canvas = $(canvas);
		const data = _getCanvasData(canvas);

		// Retrieve layers array and desired layer
		const layers = $canvas.getLayers();
		const layer = $canvas.getLayer(layerId);
		// Remove layer if found
		if (layer) {
			// Ensure layer index is accurate
			layer.index = inArray(layer, layers);
			// Remove layer and allow it to be re-added later
			layers.splice(layer.index, 1);
			delete layer._layer;

			// Update layer name map
			_updateLayerName(data, layer, {
				name: null,
			});
			// Update layer group map
			_updateLayerGroups(data, layer, {
				groups: null,
			});

			// Trigger 'remove' event
			_triggerLayerEvent($canvas, data, layer, "remove");
		}
	}
	return $canvases;
};

// Remove all layers
$.fn.removeLayers = function removeLayers(callback) {
	const $canvases = this;

	for (let e = 0; e < $canvases.length; e += 1) {
		const canvas = $canvases[e];
		if (!_isCanvas(canvas)) {
			continue;
		}
		const $canvas = $(canvas);
		const data = _getCanvasData(canvas);
		const layers = $canvas.getLayers(callback).slice(0);
		// Remove all layers individually
		for (let l = 0; l < layers.length; l += 1) {
			$canvas.removeLayer(layers[l]);
		}
		// Update layer maps
		data.layer.names = {};
		data.layer.groups = {};
	}
	return $canvases;
};

// Remove all layers in the group with the given ID
$.fn.removeLayerGroup = function removeLayerGroup(groupId) {
	const $canvases = this;

	if (groupId !== undefined) {
		for (let e = 0; e < $canvases.length; e += 1) {
			const $canvas = $($canvases[e]);

			let group = $canvas.getLayerGroup(groupId);
			// Remove layer group using given group name
			if (group) {
				// Clone groups array
				group = group.slice(0);

				// Loop through layers in group
				for (let l = 0; l < group.length; l += 1) {
					$canvas.removeLayer(group[l]);
				}
			}
		}
	}
	return $canvases;
};

// Add an existing layer to a layer group
$.fn.addLayerToGroup = function addLayerToGroup(layerId, groupName) {
	const $canvases = this;
	let groups = [groupName];

	for (let e = 0; e < $canvases.length; e += 1) {
		const $canvas = $($canvases[e]);
		const layer = $canvas.getLayer(layerId);

		// If layer is not already in group
		if (layer?.groups) {
			// Clone groups list
			groups = layer.groups.slice(0);
			// If layer is not already in group
			if (inArray(groupName, layer.groups) === -1) {
				// Add layer to group
				groups.push(groupName);
			}
		}
		// Update layer group maps
		$canvas.setLayer(layer, {
			groups: groups,
		});
	}
	return $canvases;
};

// Remove an existing layer from a layer group
$.fn.removeLayerFromGroup = function removeLayerFromGroup(layerId, groupName) {
	const $canvases = this;

	for (let e = 0; e < $canvases.length; e += 1) {
		const $canvas = $($canvases[e]);
		const layer = $canvas.getLayer(layerId);

		if (layer?.groups) {
			// Find index of layer in group
			const index = inArray(groupName, layer.groups);

			// If layer is in group
			if (index !== -1) {
				// Clone groups list
				const groups = layer.groups.slice(0);

				// Remove layer from group
				groups.splice(index, 1);

				// Update layer group maps
				$canvas.setLayer(layer, {
					groups: groups,
				});
			}
		}
	}
	return $canvases;
};

// Get topmost layer that intersects with event coordinates
function _getIntersectingLayer(data: JCanvasInternalData) {
	// Store the topmost layer
	let layer: JCanvasLayer | null = null;
	let mask: JCanvasObject;

	// Get the topmost layer whose visible area intersects event coordinates
	for (let i = data.intersecting.length - 1; i >= 0; i -= 1) {
		// Get current layer
		layer = data.intersecting[i];

		// If layer has previous masks
		if (layer._masks) {
			// Search previous masks to ensure
			// layer is visible at event coordinates
			for (let m = layer._masks.length - 1; m >= 0; m -= 1) {
				mask = layer._masks[m];
				// If mask does not intersect event coordinates
				if (!mask.intersects) {
					// Indicate that the mask does not
					// intersect event coordinates
					layer.intersects = false;
					// Stop searching previous masks
					break;
				}
			}

			// If event coordinates intersect all previous masks
			// and layer is not intangible
			if (layer.intersects && !layer.intangible) {
				// Stop searching for topmost layer
				break;
			}
		}
	}
	// If resulting layer is intangible
	if (layer?.intangible) {
		// Cursor does not intersect this layer
		layer = null;
	}
	return layer;
}

// Draw individual layer (internal)
function _drawLayer(
	$canvas: JQuery,
	layer: JCanvasLayer,
	nextLayerIndex?: number
) {
	if (layer.visible && layer._method) {
		layer._next = nextLayerIndex || null;
		// If layer is an object, call its respective method
		layer._method?.call($canvas, layer);
	}
}

// Handle dragging of the currently-dragged layer
function _handleLayerDrag(
	$canvas: JQuery<HTMLCanvasElement>,
	data: JCanvasInternalData,
	eventType: JCanvasInteractionEventName
) {
	const drag = data.drag;
	const layer = drag.layer as JCanvasLayer;
	const dragGroups = layer?.dragGroups || [];
	const layers = data.layers;

	if (eventType === "mousemove" || eventType === "touchmove") {
		// Detect when user is currently dragging layer

		if (!drag.dragging) {
			// Detect when user starts dragging layer

			// Signify that a layer on the canvas is being dragged
			drag.dragging = true;
			layer.dragging = true;

			// Optionally bring layer to front when drag starts
			if (layer.bringToFront && layer.index !== null) {
				// Remove layer from its original position
				layers.splice(layer.index, 1);
				// Bring layer to front
				// push() returns the new array length
				layer.index = layers.push(layer);
			}

			// Set drag properties for this layer
			layer._startX = layer.x;
			layer._startY = layer.y;
			layer._endX = layer._eventX;
			layer._endY = layer._eventY;

			// Trigger dragstart event
			_triggerLayerEvent($canvas, data, layer, "dragstart");
		}

		if (drag.dragging) {
			// Calculate position after drag
			let newX = layer._eventX - (layer._endX - layer._startX);
			let newY = layer._eventY - (layer._endY - layer._startY);
			if (layer.updateDragX) {
				newX = layer.updateDragX.call($canvas[0], layer, newX);
			}
			if (layer.updateDragY) {
				newY = layer.updateDragY.call($canvas[0], layer, newY);
			}
			layer.dx = newX - layer.x;
			layer.dy = newY - layer.y;
			if (layer.restrictDragToAxis !== "y") {
				layer.x = newX;
			}
			if (layer.restrictDragToAxis !== "x") {
				layer.y = newY;
			}

			// Trigger drag event
			_triggerLayerEvent($canvas, data, layer, "drag");

			// Move groups with layer on drag
			for (let g = 0; g < dragGroups.length; g += 1) {
				const groupName = dragGroups[g];
				const group = data.layer.groups[groupName];
				if (layer.groups && group) {
					for (let l = 0; l < group.length; l += 1) {
						if (group[l] !== layer) {
							if (
								layer.restrictDragToAxis !== "y" &&
								group[l].restrictDragToAxis !== "y"
							) {
								group[l].x += layer.dx;
							}
							if (
								layer.restrictDragToAxis !== "x" &&
								group[l].restrictDragToAxis !== "x"
							) {
								group[l].y += layer.dy;
							}
						}
					}
				}
			}
		}
	} else if (eventType === "mouseup" || eventType === "touchend") {
		// Detect when user stops dragging layer

		if (drag.dragging) {
			layer.dragging = false;
			drag.dragging = false;
			data.redrawOnMousemove = data.originalRedrawOnMousemove;
			// Trigger dragstop event
			_triggerLayerEvent($canvas, data, layer, "dragstop");
		}

		// Cancel dragging
		data.drag = {
			layer: null,
			dragging: false,
		};
	}
}

// Set cursor on canvas
function _setCursor(
	$canvas: JQuery<HTMLCanvasElement>,
	layer: JCanvasLayer,
	eventType: JCanvasInteractionEventName
) {
	let cursor;
	if (layer.cursors) {
		// Retrieve cursor from cursors object if it exists
		cursor = layer.cursors[eventType];
	}
	// If cursor is defined
	if (cursor) {
		// Set canvas cursor
		$canvas.css({
			cursor: cursor,
		});
	}
}

// Reset cursor on canvas
function _resetCursor(
	$canvas: JQuery<HTMLCanvasElement>,
	data: JCanvasInternalData
) {
	$canvas.css({
		cursor: data.cursor,
	});
}

// Run the given event callback with the given arguments
function _runEventCallback(
	$canvas: JQuery<HTMLCanvasElement>,
	layer: JCanvasLayer,
	eventType: string,
	callbacks: any,
	arg: any
) {
	// Prevent callback from firing recursively
	if (callbacks[eventType] && layer._running && !layer._running[eventType]) {
		// Signify the start of callback execution for this event
		layer._running[eventType] = true;
		// Run event callback with the given arguments
		callbacks[eventType].call($canvas[0], layer, arg);
		// Signify the end of callback execution for this event
		layer._running[eventType] = false;
	}
}

// Determine if the given layer can "legally" fire the given event
function _layerCanFireEvent(
	layer: JCanvasLayer,
	eventType: string
): eventType is JCanvasMouseEventName | JCanvasTouchEventName {
	// If events are disable and if
	// layer is tangible or event is not tangible
	return (
		!layer.disableEvents &&
		(!layer.intangible || inArray(eventType, tangibleEvents) === -1)
	);
}

// Trigger the given event on the given layer
function _triggerLayerEvent(
	$canvas: JQuery<HTMLCanvasElement>,
	data: JCanvasInternalData,
	layer: JCanvasLayer,
	eventType: JCanvasEventName,
	arg?: any
) {
	// If layer can legally fire this event type
	if (_layerCanFireEvent(layer, eventType)) {
		// Do not set a custom cursor on layer mouseout
		if (eventType !== "mouseout") {
			// Update cursor if one is defined for this event
			_setCursor($canvas, layer, eventType);
		}

		// Trigger the user-defined event callback
		_runEventCallback($canvas, layer, eventType, layer, arg);
		// Trigger the canvas-bound event hook
		_runEventCallback($canvas, layer, eventType, data.eventHooks, arg);
		// Trigger the global event hook
		_runEventCallback($canvas, layer, eventType, jCanvas.eventHooks, arg);
	}
}

// Manually trigger a layer event
$.fn.triggerLayerEvent = function (layerId, eventType) {
	const $canvases = this;

	for (let e = 0; e < $canvases.length; e += 1) {
		const canvas = $canvases[e];
		if (!_isCanvas(canvas)) {
			continue;
		}
		const $canvas = $(canvas);
		const data = _getCanvasData(canvas);
		const layer = $canvas.getLayer(layerId);
		if (layer) {
			// We need to type assert here because the developer may trigger a
			// custom event that is not inherently part of JCanvasEventName;
			// this is a use case we want to allow
			_triggerLayerEvent($canvas, data, layer, eventType as JCanvasEventName);
		}
	}
	return $canvases;
};

// Draw layer with the given ID
$.fn.drawLayer = function drawLayer(layerId) {
	const $canvases = this;

	for (let e = 0; e < $canvases.length; e += 1) {
		const $canvas = $($canvases[e]);
		const canvas = $canvases[e];
		if (!_isCanvas(canvas)) {
			continue;
		}
		const ctx = _getContext(canvas);
		if (!ctx) {
			continue;
		}
		const layer = $canvas.getLayer(layerId);
		if (layer) {
			_drawLayer($canvas, layer);
		}
	}
	return $canvases;
};

// Draw all layers (or, if given, only layers starting at an index)
$.fn.drawLayers = function drawLayers(args) {
	const $canvases = this;
	// Internal parameters for redrawing the canvas
	const params = args || {};
	// Other variables
	let isImageLayer: boolean = false;

	// The layer index from which to start redrawing the canvas
	const index = params.index || 0;

	for (let e = 0; e < $canvases.length; e += 1) {
		const canvas = $canvases[e];
		if (!_isCanvas(canvas)) {
			continue;
		}
		const $canvas = $(canvas);
		const ctx = _getContext(canvas);
		if (!ctx) {
			continue;
		}
		const data = _getCanvasData(canvas);

		// Clear canvas first unless otherwise directed
		if (params.clear !== false) {
			$canvas.clearCanvas();
		}

		// If a completion callback was provided, save it to the canvas data
		// store so that the function can be passed to drawLayers() again
		// after any image layers have loaded
		if (params.complete) {
			data.drawLayersComplete = params.complete;
		}

		// Cache the layers array
		const layers = data.layers;

		// Draw layers from first to last (bottom to top)
		let l;
		for (l = index; l < layers.length; l += 1) {
			const layer = layers[l];

			// Ensure layer index is up-to-date
			layer.index = l;

			// Prevent any one event from firing excessively
			if (params.resetFire) {
				layer._fired = false;
			}
			// Draw layer
			_drawLayer($canvas, layer, l + 1);
			// Store list of previous masks for each layer
			layer._masks = data.transforms.masks.slice(0);

			// Allow image layers to load before drawing successive layers
			if (layer._method === $.fn.drawImage && layer.visible) {
				isImageLayer = true;
				break;
			}
		}

		// If layer is an image layer
		if (isImageLayer) {
			// Stop and wait for drawImage() to resume drawLayers()
			continue;
		}

		// Store the latest
		const lastIndex = l;

		// Run completion callback (if provided) once all layers have drawn
		if (params.complete) {
			params.complete.call($canvases[e]);
			delete data.drawLayersComplete;
		}

		// Get first layer that intersects with event coordinates
		const layer = _getIntersectingLayer(data);

		const eventCache = data.event;
		let eventType = eventCache.type;

		// If jCanvas has detected a dragstart
		if (data.drag.layer && eventType) {
			// Handle dragging of layer
			_handleLayerDrag($canvas, data, eventType);
		}

		// Manage mouseout event
		const lastLayer = data.lastIntersected;
		if (
			lastLayer !== null &&
			layer !== lastLayer &&
			lastLayer._hovered &&
			!lastLayer._fired &&
			!data.drag.dragging
		) {
			data.lastIntersected = null;
			lastLayer._fired = true;
			lastLayer._hovered = false;
			_triggerLayerEvent($canvas, data, lastLayer, "mouseout");
			_resetCursor($canvas, data);
		}

		if (layer && eventType) {
			// Use mouse event callbacks if no touch event callbacks are given
			if (!layer[eventType as keyof JCanvasLayer]) {
				eventType = _getMouseEventName(eventType);
			}

			// Check events for intersecting layer
			if (layer._event && layer.intersects) {
				data.lastIntersected = layer;

				// Detect mouseover events
				if (
					(layer.mouseover || layer.mouseout || layer.cursors) &&
					!data.drag.dragging
				) {
					if (!layer._hovered && !layer._fired) {
						// Prevent events from firing excessively
						layer._fired = true;
						layer._hovered = true;
						_triggerLayerEvent($canvas, data, layer, "mouseover");
					}
				}

				// Detect any other mouse event
				if (!layer._fired) {
					// Prevent event from firing twice unintentionally
					layer._fired = true;
					eventCache.type = null;

					_triggerLayerEvent($canvas, data, layer, eventType);
				}

				// Use the mousedown event to start drag
				if (
					layer.draggable &&
					!layer.disableEvents &&
					(eventType === "mousedown" || eventType === "touchstart")
				) {
					// Keep track of drag state
					data.drag.layer = layer;
					data.originalRedrawOnMousemove = data.redrawOnMousemove;
					data.redrawOnMousemove = true;
				}
			}

			// If cursor is not intersecting with any layer
			if (layer === null && !data.drag.dragging) {
				// Reset cursor to previous state
				_resetCursor($canvas, data);
			}

			// If the last layer has been drawn
			if (lastIndex === layers.length) {
				// Reset list of intersecting layers
				data.intersecting.length = 0;
				// Reset transformation stack
				data.transforms = _cloneTransforms(baseTransforms);
				data.savedTransforms.length = 0;
			}
		}
	}
	return $canvases;
};

// Add a jCanvas layer (internal)
function _addLayer(
	canvas: HTMLCanvasElement,
	params: JCanvasObject,
	args?: Partial<JCanvasObject>,
	method?: (_args: JCanvasObject) => JQuery<HTMLElement>
): JCanvasObject {
	const layer: JCanvasObject = params._layer ? (args as JCanvasLayer) : params;

	// Store arguments object for later use
	params._args = args;

	// Convert all draggable drawings into jCanvas layers
	if (params.draggable || params.dragGroups) {
		params.layer = true;
		params.draggable = true;
	}

	// Determine the layer's type using the available information
	if (!params._method) {
		if (method) {
			params._method = method;
		} else if (params.method) {
			params._method = $.fn[params.method];
		} else if (params.type) {
			params._method = $.fn[maps.drawings[params.type] as keyof JQuery];
		}
	}

	// If layer hasn't been added yet
	if (params.layer && !params._layer && layer) {
		// Add layer to canvas

		const $canvas = $(canvas);

		const data = _getCanvasData(canvas);
		const layers = data.layers;

		// Do not add duplicate layers of same name
		if (
			layer.name === null ||
			(isString(layer.name) && data.layer.names[layer.name] === undefined)
		) {
			// Convert number properties to numbers
			_coerceNumericProps(params);

			// Ensure layers are unique across canvases by cloning them
			const newLayer = new jCanvasLayer(canvas, params);
			newLayer.canvas = canvas;
			// Indicate that this is a layer for future checks
			newLayer.layer = true;
			newLayer._layer = true;
			newLayer._running = {};
			// If layer stores user-defined data
			if (newLayer.data !== null) {
				// Clone object
				newLayer.data = extendObject({}, newLayer.data);
			} else {
				// Otherwise, create data object
				newLayer.data = {};
			}
			// If layer stores a list of associated groups
			if (newLayer.groups) {
				// Clone list
				newLayer.groups = newLayer.groups.slice(0);
			} else {
				// Otherwise, create empty list
				newLayer.groups = [];
			}

			// Update layer group maps
			_updateLayerName(data, newLayer);
			_updateLayerGroups(data, newLayer);

			// Check for any associated jCanvas events and enable them
			_addLayerEvents($canvas, data, newLayer);

			// Optionally enable drag-and-drop support and cursor support
			_enableDrag($canvas, data, newLayer);

			// Copy _event property to parameters object
			params._event = newLayer._event;

			// Calculate width/height for text layers
			if (newLayer._method === $.fn.drawText) {
				$canvas.measureText(newLayer);
			}

			// Add layer to end of array if no index is specified
			if (newLayer.index === null) {
				newLayer.index = layers.length;
			}

			// Add layer to layers array at specified index
			layers.splice(newLayer.index, 0, newLayer);

			// Store layer on parameters object
			params._args = newLayer;

			// Trigger an 'add' event
			_triggerLayerEvent($canvas, data, newLayer, "add");
			return newLayer;
		}
	} else if (!params.layer) {
		_coerceNumericProps(params);
	}

	return layer;
}

// Add a jCanvas layer
$.fn.addLayer = function addLayer(args) {
	const $canvases = this;

	for (let e = 0; e < $canvases.length; e += 1) {
		const canvas = $canvases[e];
		if (!_isCanvas(canvas)) {
			continue;
		}
		const ctx = _getContext(canvas);
		if (!ctx) {
			continue;
		}
		const params = _getParamsObject(args);
		params.layer = true;
		_addLayer(canvas, params, args);
	}
	return $canvases;
};

/* Animation API */

// Hide/show jCanvas/CSS properties so they can be animated using jQuery
function _showProps(obj: Partial<JCanvasObject>) {
	for (let p = 0; p < css.props.length; p += 1) {
		const cssProp = css.props[p];
		obj[cssProp as keyof typeof obj] = obj[("_" + cssProp) as `_${string}`];
	}
}
function _hideProps(obj: Partial<JCanvasObject>, reset?: boolean) {
	for (let p = 0; p < css.props.length; p += 1) {
		const cssProp = css.props[p];
		// Hide property using same name with leading underscore
		if (obj[cssProp as keyof typeof obj] !== undefined) {
			obj[("_" + cssProp) as `_${string}`] = obj[cssProp as keyof typeof obj];
			css.propsObj[cssProp] = true;
			if (reset) {
				delete obj[cssProp as keyof typeof obj];
			}
		}
	}
}

// Evaluate property values that are functions
function _parseEndValues(
	canvas: HTMLCanvasElement,
	layer: JCanvasLayer,
	endValues: Record<string, any>
) {
	// Loop through all properties in map of end values
	for (const propName in endValues) {
		if (Object.prototype.hasOwnProperty.call(endValues, propName)) {
			const propValue = endValues[propName];
			// If end value is function
			if (isFunction(propValue)) {
				// Call function and use its value as the end value
				endValues[propName] = propValue.call(canvas, layer, propName);
			}
			// If end value is an object
			if (typeOf(propValue) === "object" && isPlainObject(propValue)) {
				// Prepare to animate properties in object
				for (const subPropName in propValue) {
					if (Object.prototype.hasOwnProperty.call(propValue, subPropName)) {
						const subPropValue = propValue[subPropName];
						// Store property's start value at top-level of layer
						if (layer[propName as keyof JCanvasLayer] !== undefined) {
							layer[(propName + "." + subPropName) as any] =
								layer[propName as keyof JCanvasLayer][subPropName];
							// Store property's end value at top-level of end values map
							endValues[propName + "." + subPropName] = subPropValue;
						}
					}
				}
				// Delete sub-property of object as it's no longer needed
				delete endValues[propName];
			}
		}
	}
	return endValues;
}

// Remove sub-property aliases from layer object
function _removeSubPropAliases(layer: JCanvasLayer) {
	for (const propName in layer) {
		if (Object.prototype.hasOwnProperty.call(layer, propName)) {
			if (propName.indexOf(".") !== -1) {
				delete layer[propName as keyof JCanvasLayer];
			}
		}
	}
}

// Convert the given color to a normalized RGB/RGBA color string
function _normalizeColor(color: string) {
	if (color === "transparent") {
		// Deal with complete transparency
		return "rgba(0, 0, 0, 0)";
	} else if (color.match(/^([a-z]+|#[0-9a-f]+)$/gi)) {
		// Deal with hexadecimal colors and color names (note: element must be
		// in the DOM for this to work consistently)
		const headElem = document.head;
		const originalColor = headElem.style.color;
		headElem.style.color = color;
		const normalizedColor = $.css(headElem, "color");
		headElem.style.color = originalColor;
		return normalizedColor;
	} else {
		return color;
	}
}

// Convert a color value to an array of RGB values
function _convertColorToRgbArray(color: string) {
	const normalizedColor: string = _normalizeColor(color);
	// Parse RGB string
	if (/^rgb/gi.test(normalizedColor)) {
		const rgbMatches: string[] = normalizedColor.match(/(\d+(\.\d+)?)/gi) || [];
		// Deal with RGB percentages
		const multiple = /%/gi.test(normalizedColor) ? 2.55 : 1;
		return [
			Number(rgbMatches[0]) * multiple,
			Number(rgbMatches[1]) * multiple,
			Number(rgbMatches[2]) * multiple,
			rgbMatches[3] ? Number(rgbMatches[3]) * multiple : 1,
		];
	} else {
		console.error(`Color format unsupported: ${normalizedColor}`);
		return [];
	}
}

// Blend two colors by the given percentage
function _blendColors(color1: string, color2: string, percentage: number) {
	const [r1, g1, b1, a1] = _convertColorToRgbArray(color1);
	const [r2, g2, b2, a2] = _convertColorToRgbArray(color2);

	const r = round(r1 + (r2 - r1) * percentage);
	const g = round(g1 + (g2 - g1) * percentage);
	const b = round(b1 + (b2 - b1) * percentage);
	const a = a1 + (a2 - a1) * percentage;

	return `rgba(${r}, ${g}, ${b}, ${a})`;
}

// Animate jCanvas layer
$.fn.animateLayer = function animateLayer(...args) {
	const $canvases = this;

	// Deal with all cases of argument placement
	/*
		0. layer name/index
		1. properties
		2. duration/options
		3. easing
		4. complete function
		5. step function
	*/

	if (!args.length) {
		return $canvases;
	}

	if (typeOf(args[2]) === "object") {
		// Accept an options object for animation
		args.splice(2, 0, args[2].duration || null);
		args.splice(3, 0, args[3].easing || null);
		args.splice(4, 0, args[4].complete || null);
		args.splice(5, 0, args[5].step || null);
	} else {
		if (args[2] === undefined) {
			// If object is the last argument
			args.splice(2, 0, null);
			args.splice(3, 0, null);
			args.splice(4, 0, null);
		} else if (isFunction(args[2])) {
			// If callback comes after object
			args.splice(2, 0, null);
			args.splice(3, 0, null);
		}
		if (args[3] === undefined) {
			// If duration is the last argument
			args[3] = null;
			args.splice(4, 0, null);
		} else if (isFunction(args[3])) {
			// If callback comes after duration
			args.splice(3, 0, null);
		}
	}

	// Run callback function when animation completes
	function complete(
		$canvas: JQuery<HTMLCanvasElement>,
		data: JCanvasInternalData,
		layer: JCanvasLayer
	) {
		return function () {
			_showProps(layer);
			_removeSubPropAliases(layer);

			// Prevent multiple redraw loops
			if (!data.animating || data.animated === layer) {
				// Redraw layers on last frame
				$canvas.drawLayers();
			}

			// Signify the end of an animation loop
			layer._animating = false;
			data.animating = false;
			data.animated = null;

			// If callback is defined
			if (args[4]) {
				// Run callback at the end of the animation
				args[4].call($canvas[0], layer);
			}

			_triggerLayerEvent($canvas, data, layer, "animateend");
		};
	}

	// Redraw layers on every frame of the animation
	function step(
		$canvas: JQuery<HTMLCanvasElement>,
		data: JCanvasInternalData,
		layer: JCanvasLayer
	) {
		return function (now: any, fx: any) {
			let parts,
				propName,
				subPropName,
				hidden = false;

			// If animated property has been hidden
			if (fx.prop[0] === "_") {
				hidden = true;
				// Unhide property temporarily
				fx.prop = fx.prop.replace("_", "");
				layer[fx.prop] = layer[("_" + fx.prop) as keyof JCanvasLayer];
			}

			// If animating property of sub-object
			if (fx.prop.indexOf(".") !== -1) {
				parts = fx.prop.split(".");
				propName = parts[0];
				subPropName = parts[1];
				if (layer[propName as keyof JCanvasLayer]) {
					layer[propName as keyof JCanvasLayer][subPropName] = fx.now;
				}
			}

			// Throttle animation to improve efficiency
			if (layer._pos !== fx.pos) {
				layer._pos = fx.pos;

				// Signify the start of an animation loop
				if (!layer._animating && !data.animating) {
					layer._animating = true;
					data.animating = true;
					data.animated = layer;
				}

				// Prevent multiple redraw loops
				if (!data.animating || data.animated === layer) {
					// Redraw layers for every frame
					$canvas.drawLayers();
				}
			}

			// If callback is defined
			if (args[5]) {
				// Run callback for each step of animation
				args[5].call($canvas[0], now, fx, layer);
			}

			_triggerLayerEvent($canvas, data, layer, "animate", fx);

			// If property should be hidden during animation
			if (hidden) {
				// Hide property again
				fx.prop = "_" + fx.prop;
			}
		};
	}

	for (let e = 0; e < $canvases.length; e += 1) {
		const canvas = $canvases[e];
		if (!_isCanvas(canvas)) {
			continue;
		}
		const $canvas = $(canvas);
		const ctx = _getContext(canvas);
		if (!ctx) {
			continue;
		}
		const data = _getCanvasData(canvas);

		// If a layer object was passed, use it the layer to be animated
		const layer = $canvas.getLayer(args[0]);

		// Ignore layers that are functions
		if (layer && layer._method !== $.fn.draw) {
			// Do not modify original object
			const props = _parseEndValues(canvas, layer, extendObject({}, args[1]));

			// Bypass jQuery CSS Hooks for CSS properties (width, opacity, etc.)
			_hideProps(props, true);
			_hideProps(layer);

			// Fix for jQuery's vendor prefixing support, which affects how width/height/opacity are animated
			layer.style = css.propsObj;

			// Animate layer
			$(layer).animate(props, {
				duration: args[2],
				easing: $.easing[args[3]] ? args[3] : null,
				// When animation completes
				complete: complete($canvas, data, layer),
				// Redraw canvas for every animation frame
				step: step($canvas, data, layer),
			});
			_triggerLayerEvent($canvas, data, layer, "animatestart");
		}
	}
	return $canvases;
};

// Animate all layers in a layer group
$.fn.animateLayerGroup = function animateLayerGroup(groupId, props, ...args) {
	const $canvases = this;

	for (let e = 0; e < $canvases.length; e += 1) {
		const $canvas = $($canvases[e]);
		const group = $canvas.getLayerGroup(groupId);
		if (group) {
			// Animate all layers in the group
			for (let l = 0; l < group.length; l += 1) {
				// Replace first argument with layer
				args[0] = group[l];
				$canvas.animateLayer.apply($canvas, [props, ...args]);
			}
		}
	}
	return $canvases;
};

// Delay layer animation by a given number of milliseconds
$.fn.delayLayer = function delayLayer(layerId, duration) {
	const $canvases = this;

	for (let e = 0; e < $canvases.length; e += 1) {
		const canvas = $canvases[e];
		if (!_isCanvas(canvas)) {
			continue;
		}
		const $canvas = $(canvas);
		const data = _getCanvasData(canvas);
		const layer = $canvas.getLayer(layerId);
		// If layer exists
		if (layer) {
			// Delay animation
			$(layer).delay(duration || 0);
			_triggerLayerEvent($canvas, data, layer, "delay");
		}
	}
	return $canvases;
};

// Delay animation all layers in a layer group
$.fn.delayLayerGroup = function delayLayerGroup(groupId, duration) {
	const $canvases = this;

	for (let e = 0; e < $canvases.length; e += 1) {
		const $canvas = $($canvases[e]);

		const group = $canvas.getLayerGroup(groupId);
		// Delay all layers in the group
		if (group) {
			for (let l = 0; l < group.length; l += 1) {
				// Delay each layer in the group
				$canvas.delayLayer(group[l], duration || 0);
			}
		}
	}
	return $canvases;
};

// Stop layer animation
$.fn.stopLayer = function stopLayer(layerId, clearQueue) {
	const $canvases = this;

	for (let e = 0; e < $canvases.length; e += 1) {
		const canvas = $canvases[e];
		if (!_isCanvas(canvas)) {
			continue;
		}
		const $canvas = $(canvas);
		const data = _getCanvasData(canvas);
		const layer = $canvas.getLayer(layerId);
		// If layer exists
		if (layer) {
			// Stop animation
			$(layer).stop(clearQueue);
			_triggerLayerEvent($canvas, data, layer, "stop");
		}
	}
	return $canvases;
};

// Stop animation of all layers in a layer group
$.fn.stopLayerGroup = function stopLayerGroup(groupId, clearQueue) {
	const $canvases = this;

	for (let e = 0; e < $canvases.length; e += 1) {
		const $canvas = $($canvases[e]);

		const group = $canvas.getLayerGroup(groupId);
		// Stop all layers in the group
		if (group) {
			for (let l = 0; l < group.length; l += 1) {
				// Stop each layer in the group
				const layer = group[l];
				$canvas.stopLayer(layer, clearQueue);
			}
		}
	}
	return $canvases;
};

// Enable animation for color properties
function _supportColorProps(props: string[]) {
	for (let p = 0; p < props.length; p += 1) {
		($.Tween.propHooks as JCanvasPropHooks)[props[p]] = {
			get: function (tween) {
				return tween.elem[tween.prop as keyof typeof tween.elem];
			},
			set: function (tween) {
				tween.elem[tween.prop as any] = _blendColors(
					tween.start as any,
					tween.end as any,
					tween.pos || 0
				);
			},
		};
	}
}

// Enable animation for color properties
_supportColorProps([
	"color",
	"backgroundColor",
	"borderColor",
	"borderTopColor",
	"borderRightColor",
	"borderBottomColor",
	"borderLeftColor",
	"fillStyle",
	"outlineColor",
	"strokeStyle",
	"shadowColor",
]);

/* Event API */

// Convert mouse event name to a corresponding touch event name (if possible)
function _getTouchEventName(eventName: JCanvasInteractionEventName) {
	const touchEventName = maps.touchEvents[eventName];
	if (touchEventName) {
		return touchEventName;
	}
	return eventName;
}
// Convert touch event name to a corresponding mouse event name
function _getMouseEventName(eventName: JCanvasInteractionEventName) {
	const mouseEventName = maps.mouseEvents[eventName];
	if (mouseEventName) {
		return mouseEventName;
	}
	return eventName;
}

// Bind event to jCanvas layer using standard jQuery events
function _createEvent(eventName: JCanvasInteractionEventName) {
	jCanvas.events[eventName] = function ($canvas, data) {
		// Retrieve canvas's event cache
		const eventCache = data.event;

		// Both mouseover/mouseout events will be managed by a single mousemove event
		const helperEventName =
			eventName === "mouseover" || eventName === "mouseout"
				? "mousemove"
				: eventName;
		const touchEventName = _getTouchEventName(helperEventName);

		function eventCallback(event: MouseEvent) {
			// Cache current mouse position and redraw layers
			eventCache.x = event.offsetX;
			eventCache.y = event.offsetY;
			eventCache.type = helperEventName;
			eventCache.event = event;
			// Redraw layers on every trigger of the event; don't redraw if at
			// least one layer is draggable and there are no layers with
			// explicit mouseover/mouseout/mousemove events
			if (
				event.type !== "mousemove" ||
				data.redrawOnMousemove ||
				data.drag.dragging
			) {
				$canvas.drawLayers({
					resetFire: true,
				});
			}
			// Prevent default event behavior
			event.preventDefault();
		}

		// Ensure the event is not bound more than once
		if (!data.events[helperEventName]) {
			// Bind one canvas event which handles all layer events of that type
			if (touchEventName !== helperEventName) {
				$canvas.on(
					helperEventName + ".jCanvas " + touchEventName + ".jCanvas",
					eventCallback as JQuery.EventHandlerBase<HTMLElement, object>
				);
			} else {
				$canvas.on(
					helperEventName + ".jCanvas",
					eventCallback as JQuery.EventHandlerBase<HTMLElement, object>
				);
			}
			// Prevent this event from being bound twice
			data.events[helperEventName] = true;
		}
	};
}
function _createEvents(eventNames: JCanvasInteractionEventName[]) {
	for (let n = 0; n < eventNames.length; n += 1) {
		_createEvent(eventNames[n]);
	}
}
// Populate jCanvas events object with some standard events
_createEvents([
	"click",
	"dblclick",
	"mousedown",
	"mouseup",
	"mousemove",
	"mouseover",
	"mouseout",
	"touchstart",
	"touchmove",
	"touchend",
	"pointerdown",
	"pointermove",
	"pointerup",
	"contextmenu",
]);

// Check if event fires when a drawing is drawn
function _detectEvents(
	canvas: HTMLCanvasElement,
	ctx: CanvasRenderingContext2D,
	params: JCanvasObject
) {
	// Use the layer object stored by the given parameters object
	const layer = params._args;
	// Canvas must have event bindings
	if (layer) {
		const data = _getCanvasData(canvas);
		const eventCache = data.event;
		let intersects: boolean = false;
		let x: number;
		let y: number;
		if (eventCache.x !== null && eventCache.y !== null) {
			// Respect user-defined pixel ratio
			x = eventCache.x * data.pixelRatio;
			y = eventCache.y * data.pixelRatio;
			// Determine if the given coordinates are in the current path
			if (layer._path) {
				intersects =
					ctx.isPointInPath(layer._path, x, y) ||
					ctx.isPointInStroke(layer._path, x, y);
			} else {
				intersects = ctx.isPointInPath(x, y) || ctx.isPointInStroke(x, y);
			}
		}
		const transforms = data.transforms;

		// Allow callback functions to retrieve the mouse coordinates
		layer.eventX = eventCache.x;
		layer.eventY = eventCache.y;
		layer.event = eventCache.event;

		// Adjust coordinates to match current canvas transformation

		// Keep track of some transformation values
		const angle = data.transforms.rotate;
		x = layer.eventX;
		y = layer.eventY;

		if (angle !== 0) {
			// Rotate coordinates if coordinate space has been rotated
			layer._eventX = x * cos(-angle) - y * sin(-angle);
			layer._eventY = y * cos(-angle) + x * sin(-angle);
		} else {
			// Otherwise, no calculations need to be made
			layer._eventX = x;
			layer._eventY = y;
		}

		// Scale coordinates
		layer._eventX /= transforms.scaleX;
		layer._eventY /= transforms.scaleY;

		// If layer intersects with cursor
		if (intersects) {
			// Add it to a list of layers that intersect with cursor
			data.intersecting.push(layer);
		}
		layer.intersects = Boolean(intersects);
	}
}

// Normalize offsetX and offsetY for all browsers
// @ts-expect-error TODO: properly type extension for $.event
$.event.fix = function (event) {
	event = jQueryEventFix.call($.event, event);
	const originalEvent = event.originalEvent;

	// originalEvent does not exist for manually-triggered events
	if (originalEvent) {
		const touches = originalEvent.changedTouches;

		// If offsetX and offsetY are not supported, define them
		let offset: JQuery.Coordinates | undefined;
		if (event.pageX !== undefined && event.offsetX === undefined) {
			try {
				offset = $(event.currentTarget).offset();
				if (offset) {
					event.offsetX = event.pageX - offset.left;
					event.offsetY = event.pageY - offset.top;
				}
			} catch (error) {
				// Fail silently
			}
		} else if (touches) {
			try {
				// Enable offsetX and offsetY for mobile devices
				offset = $(event.currentTarget).offset();
				if (offset) {
					event.offsetX = touches[0].pageX - offset.left;
					event.offsetY = touches[0].pageY - offset.top;
				}
			} catch (error) {
				// Fail silently
			}
		}
	}
	return event;
};

/* Drawing API */

// Draws on canvas using a function
$.fn.draw = function draw(args) {
	const $canvases = this;
	const params = _getParamsObject(args);

	// Draw using any other method
	const fn = $.fn[maps.drawings[params.type!] as keyof JQuery];
	if (params.type && maps.drawings[params.type] && isFunction(fn)) {
		// @ts-expect-error TODO (not sure how to fix this: "This expression is
		// not callable. Each member of the union type '...' has signatures, but
		// none of those signatures are compatible with each other.")
		fn.call($canvases, args);
	} else {
		for (let e = 0; e < $canvases.length; e += 1) {
			const canvas = $canvases[e];
			if (!_isCanvas(canvas)) {
				continue;
			}
			const ctx = _getContext(canvas);
			if (!ctx) {
				continue;
			}
			const params = _getParamsObject(args);
			_addLayer(canvas, params, args, draw);
			if (!params.visible) {
				continue;
			}
			if (params.fn) {
				// Call the given user-defined function
				params.fn.call($canvases[e], ctx, params);
			}
		}
	}
	return $canvases;
};

// Clears canvas
$.fn.clearCanvas = function clearCanvas(args) {
	const $canvases = this;
	const params = _getParamsObject(args);

	for (let e = 0; e < $canvases.length; e += 1) {
		const canvas = $canvases[e];
		if (!_isCanvas(canvas)) {
			continue;
		}
		const ctx = _getContext(canvas);
		if (!ctx) {
			continue;
		}
		if (params.width === null || params.height === null) {
			// Clear entire canvas if width/height is not given

			// Reset current transformation temporarily to ensure that the entire canvas is cleared
			ctx.save();
			ctx.setTransform(1, 0, 0, 1, 0, 0);
			ctx.clearRect(0, 0, canvas.width, canvas.height);
			ctx.restore();
		} else {
			// Otherwise, clear the defined section of the canvas

			// Transform clear rectangle
			_addLayer(canvas, params, args, clearCanvas);
			_transformShape(canvas, ctx, params, params.width, params.height);
			ctx.clearRect(
				params.x - params.width / 2,
				params.y - params.height / 2,
				params.width,
				params.height
			);
			// Restore previous transformation
			_restoreTransform(ctx, params);
		}
	}
	return $canvases;
};

/* Transformation API */

// Restores canvas
$.fn.saveCanvas = function saveCanvas(args) {
	const $canvases = this;

	for (let e = 0; e < $canvases.length; e += 1) {
		const canvas = $canvases[e];
		if (!_isCanvas(canvas)) {
			continue;
		}
		const ctx = _getContext(canvas);
		if (!ctx) {
			continue;
		}
		const data = _getCanvasData(canvas);

		const params = _getParamsObject(args);
		_addLayer(canvas, params, args, saveCanvas);

		// Restore a number of times using the given count
		for (let i = 0; i < params.count; i += 1) {
			_saveCanvas(ctx, data);
		}
	}
	return $canvases;
};

// Restores canvas
$.fn.restoreCanvas = function restoreCanvas(args) {
	const $canvases = this;

	for (let e = 0; e < $canvases.length; e += 1) {
		const canvas = $canvases[e];
		if (!_isCanvas(canvas)) {
			continue;
		}
		const ctx = _getContext(canvas);
		if (!ctx) {
			continue;
		}
		const data = _getCanvasData(canvas);

		const params = _getParamsObject(args);
		_addLayer(canvas, params, args, restoreCanvas);

		// Restore a number of times using the given count
		for (let i = 0; i < params.count; i += 1) {
			_restoreCanvas(ctx, data);
		}
	}
	return $canvases;
};

// Rotates canvas (internal)
function _rotateCanvas(
	ctx: CanvasRenderingContext2D,
	params: JCanvasObject,
	transforms: JCanvasBaseTransforms | null
) {
	// Get conversion factor for radians
	params._toRad = params.inDegrees ? PI / 180 : 1;

	// Rotate canvas using shape as center of rotation
	ctx.translate(params.x, params.y);
	ctx.rotate(params.rotate * params._toRad);
	ctx.translate(-params.x, -params.y);

	// If transformation data was given
	if (transforms) {
		// Update transformation data
		transforms.rotate += params.rotate * params._toRad;
	}
}

// Scales canvas (internal)
function _scaleCanvas(
	ctx: CanvasRenderingContext2D,
	params: JCanvasObject,
	transforms: JCanvasBaseTransforms | null
) {
	// Scale both the x- and y- axis using the 'scale' property
	if (params.scale !== 1) {
		params.scaleX = params.scaleY = params.scale;
	}

	// Scale canvas using shape as center of rotation
	ctx.translate(params.x, params.y);
	ctx.scale(params.scaleX, params.scaleY);
	ctx.translate(-params.x, -params.y);

	// If transformation data was given
	if (transforms) {
		// Update transformation data
		transforms.scaleX *= params.scaleX;
		transforms.scaleY *= params.scaleY;
	}
}

// Translates canvas (internal)
function _translateCanvas(
	ctx: CanvasRenderingContext2D,
	params: JCanvasObject,
	transforms: JCanvasBaseTransforms | null
) {
	// Translate both the x- and y-axis using the 'translate' property
	if (params.translate) {
		params.translateX = params.translateY = params.translate;
	}

	// Translate canvas
	ctx.translate(params.translateX, params.translateY);

	// If transformation data was given
	if (transforms) {
		// Update transformation data
		transforms.translateX += params.translateX;
		transforms.translateY += params.translateY;
	}
}

// Rotates canvas
$.fn.rotateCanvas = function rotateCanvas(args) {
	const $canvases = this;

	for (let e = 0; e < $canvases.length; e += 1) {
		const canvas = $canvases[e];
		if (!_isCanvas(canvas)) {
			continue;
		}
		const ctx = _getContext(canvas);
		if (!ctx) {
			continue;
		}
		const data = _getCanvasData(canvas);

		const params = _getParamsObject(args);
		_addLayer(canvas, params, args, rotateCanvas);

		// Autosave transformation state by default
		if (params.autosave) {
			// Automatically save transformation state by default
			_saveCanvas(ctx, data);
		}
		_rotateCanvas(ctx, params, data.transforms);
	}
	return $canvases;
};

// Scales canvas
$.fn.scaleCanvas = function scaleCanvas(args) {
	const $canvases = this;

	for (let e = 0; e < $canvases.length; e += 1) {
		const canvas = $canvases[e];
		if (!_isCanvas(canvas)) {
			continue;
		}
		const ctx = _getContext(canvas);
		if (!ctx) {
			continue;
		}
		const data = _getCanvasData(canvas);

		const params = _getParamsObject(args);
		_addLayer(canvas, params, args, scaleCanvas);

		// Autosave transformation state by default
		if (params.autosave) {
			// Automatically save transformation state by default
			_saveCanvas(ctx, data);
		}
		_scaleCanvas(ctx, params, data.transforms);
	}
	return $canvases;
};

// Translates canvas
$.fn.translateCanvas = function translateCanvas(args) {
	const $canvases = this;

	for (let e = 0; e < $canvases.length; e += 1) {
		const canvas = $canvases[e];
		if (!_isCanvas(canvas)) {
			continue;
		}
		const ctx = _getContext(canvas);
		if (!ctx) {
			continue;
		}
		const data = _getCanvasData(canvas);

		const params = _getParamsObject(args);
		_addLayer(canvas, params, args, translateCanvas);

		// Autosave transformation state by default
		if (params.autosave) {
			// Automatically save transformation state by default
			_saveCanvas(ctx, data);
		}
		_translateCanvas(ctx, params, data.transforms);
	}
	return $canvases;
};

/* Shape API */

// Draws rectangle
$.fn.drawRect = function drawRect(args) {
	const $canvases = this;

	for (let e = 0; e < $canvases.length; e += 1) {
		const canvas = $canvases[e];
		if (!_isCanvas(canvas)) {
			continue;
		}
		const ctx = _getContext(canvas);
		if (!ctx) {
			continue;
		}
		const params = _getParamsObject(args);
		_addLayer(canvas, params, args, drawRect);
		if (!params.visible) {
			continue;
		}
		_transformShape(canvas, ctx, params, params.width, params.height);
		_setGlobalProps(canvas, ctx, params);

		const nonNullWidth = params.width || 0;
		const nonNullHeight = params.height || 0;
		ctx.beginPath();
		const x1 = params.x - nonNullWidth / 2;
		const y1 = params.y - nonNullHeight / 2;
		const r = abs(params.cornerRadius);
		// If corner radius is defined and is not zero
		if (r) {
			// Draw rectangle with rounded corners if cornerRadius is defined
			ctx.roundRect(x1, y1, nonNullWidth, nonNullHeight, r);
		} else {
			// Otherwise, draw rectangle with square corners
			ctx.rect(x1, y1, nonNullWidth, nonNullHeight);
		}
		// Check for jCanvas events
		_detectEvents(canvas, ctx, params);
		// Close rectangle path
		_closePath(canvas, ctx, params);
	}
	return $canvases;
};

// Retrieves a coterminal angle between 0 and 2pi for the given angle
function _getCoterminal(angle: number) {
	while (angle < 0) {
		angle += 2 * PI;
	}
	return angle;
}

// Retrieves the x-coordinate for the given angle in an arc/ellipse
function _getConicX(x: number, radiusX: number, angle: number) {
	return x + radiusX * cos(angle);
}
// Retrieves the y-coordinate for the given angle in an arc/ellipse
function _getConicY(y: number, radiusY: number, angle: number) {
	return y + radiusY * sin(angle);
}

// Calculate angles and positioning for arcs/ellipses
function _getConicOffsets(params: JCanvasObject, path: JCanvasObject) {
	// Determine offset from dragging
	const offsetX = params === path ? 0 : params.x;
	const offsetY = params === path ? 0 : params.y;
	// Ensure arrows are pointed correctly for CCW arcs
	const angleDiff = path.ccw ? -PI / 180 : PI / 180;

	const pathX = path.x + offsetX;
	const pathY = path.y + offsetY;

	// Convert angles to radians, then offset to make 0deg due north of arc
	const pathStartAngle = path.start * params._toRad - PI / 2;
	const pathEndAngle =
		!path.inDegrees && path.end === 360
			? PI * 2
			: path.end * params._toRad - PI / 2;

	return { pathX, pathY, angleDiff, pathStartAngle, pathEndAngle };
}

// Draws arc (internal)
function _drawArc(
	canvas: HTMLCanvasElement,
	ctx: CanvasRenderingContext2D,
	params: JCanvasObject,
	path: JCanvasObject
) {
	const { pathX, pathY, angleDiff, pathStartAngle, pathEndAngle } =
		_getConicOffsets(params, path);

	// Calculate coordinates for start arrow
	const x1 = _getConicX(pathX, path.radius, pathStartAngle + angleDiff);
	const y1 = _getConicY(pathY, path.radius, pathStartAngle + angleDiff);
	const x2 = _getConicX(pathX, path.radius, pathStartAngle);
	const y2 = _getConicY(pathY, path.radius, pathStartAngle);

	_addStartArrow(canvas, ctx, params, path, x1, y1, x2, y2);

	// Draw arc
	ctx.arc(pathX, pathY, path.radius, pathStartAngle, pathEndAngle, path.ccw);

	// Calculate coordinates for end arrow
	const x3 = _getConicX(pathX, path.radius, pathEndAngle + angleDiff);
	const y3 = _getConicY(pathY, path.radius, pathEndAngle + angleDiff);
	const x4 = _getConicX(pathX, path.radius, pathEndAngle);
	const y4 = _getConicY(pathY, path.radius, pathEndAngle);

	_addEndArrow(canvas, ctx, params, path, x4, y4, x3, y3);
}

// Draws ellipse (internal)
function _drawEllipse(
	canvas: HTMLCanvasElement,
	ctx: CanvasRenderingContext2D,
	params: JCanvasObject,
	path: JCanvasObject
) {
	const { pathX, pathY, angleDiff, pathStartAngle, pathEndAngle } =
		_getConicOffsets(params, path);

	const nonNullWidth = path.width || 0;
	const nonNullHeight = path.height || 0;

	// Calculate coordinates for start arrow
	const x1 = _getConicX(pathX, nonNullWidth / 2, pathStartAngle + angleDiff);
	const y1 = _getConicY(pathY, nonNullHeight / 2, pathStartAngle + angleDiff);
	const x2 = _getConicX(pathX, nonNullWidth / 2, pathStartAngle);
	const y2 = _getConicY(pathY, nonNullHeight / 2, pathStartAngle);

	_addStartArrow(canvas, ctx, params, path, x1, y1, x2, y2);

	ctx.ellipse(
		pathX,
		pathY,
		nonNullWidth / 2,
		nonNullHeight / 2,
		0,
		pathStartAngle,
		pathEndAngle,
		path.ccw
	);

	// Calculate coordinates for end arrow
	const x3 = _getConicX(pathX, nonNullWidth / 2, pathEndAngle + angleDiff);
	const y3 = _getConicY(pathY, nonNullHeight / 2, pathEndAngle + angleDiff);
	const x4 = _getConicX(pathX, nonNullWidth / 2, pathEndAngle);
	const y4 = _getConicY(pathY, nonNullHeight / 2, pathEndAngle);

	_addEndArrow(canvas, ctx, params, path, x4, y4, x3, y3);
}

// Draws arc or circle
$.fn.drawArc = function drawArc(args) {
	const $canvases = this;

	for (let e = 0; e < $canvases.length; e += 1) {
		const canvas = $canvases[e];
		if (!_isCanvas(canvas)) {
			continue;
		}
		const ctx = _getContext(canvas);
		if (!ctx) {
			continue;
		}
		const params = _getParamsObject(args);
		_addLayer(canvas, params, args, drawArc);
		if (!params.visible) {
			continue;
		}
		_transformShape(canvas, ctx, params, params.radius * 2);
		_setGlobalProps(canvas, ctx, params);

		ctx.beginPath();
		_drawArc(canvas, ctx, params, params);
		// Check for jCanvas events
		_detectEvents(canvas, ctx, params);
		// Optionally close path
		_closePath(canvas, ctx, params);
	}
	return $canvases;
};

// Draws ellipse
$.fn.drawEllipse = function drawEllipse(args) {
	const $canvases = this;

	for (let e = 0; e < $canvases.length; e += 1) {
		const canvas = $canvases[e];
		if (!_isCanvas(canvas)) {
			continue;
		}
		const ctx = _getContext(canvas);
		if (!ctx) {
			continue;
		}
		const params = _getParamsObject(args);
		_addLayer(canvas, params, args, drawEllipse);
		if (!params.visible) {
			continue;
		}
		_transformShape(canvas, ctx, params, params.width, params.height);
		_setGlobalProps(canvas, ctx, params);
		ctx.beginPath();
		_drawEllipse(canvas, ctx, params, params);
		// Check for jCanvas events
		_detectEvents(canvas, ctx, params);
		// Always close path
		_closePath(canvas, ctx, params);
	}
	return $canvases;
};

// Draws a regular (equal-angled) polygon
$.fn.drawPolygon = function drawPolygon(args) {
	const $canvases = this;

	for (let e = 0; e < $canvases.length; e += 1) {
		const canvas = $canvases[e];
		if (!_isCanvas(canvas)) {
			continue;
		}
		const ctx = _getContext(canvas);
		if (!ctx) {
			continue;
		}
		const params = _getParamsObject(args);
		_addLayer(canvas, params, args, drawPolygon);
		if (!params.visible) {
			continue;
		}
		_transformShape(canvas, ctx, params, params.radius * 2);
		_setGlobalProps(canvas, ctx, params);

		// Polygon's central angle
		const dtheta = (2 * PI) / params.sides;
		// Half of dtheta
		const hdtheta = dtheta / 2;
		// Polygon's starting angle
		let theta = hdtheta + PI / 2;
		// Distance from polygon's center to the middle of its side
		const apothem = params.radius * cos(hdtheta);

		// Calculate path and draw
		ctx.beginPath();
		for (let i = 0; i < params.sides; i += 1) {
			// Draw side of polygon
			let x = params.x + params.radius * cos(theta);
			let y = params.y + params.radius * sin(theta);

			// Plot point on polygon
			ctx.lineTo(x, y);

			// Project side if chosen
			if (params.concavity) {
				// Sides are projected from the polygon's apothem
				x =
					params.x +
					(apothem + -apothem * params.concavity) * cos(theta + hdtheta);
				y =
					params.y +
					(apothem + -apothem * params.concavity) * sin(theta + hdtheta);
				ctx.lineTo(x, y);
			}

			// Increment theta by delta theta
			theta += dtheta;
		}
		// Check for jCanvas events
		_detectEvents(canvas, ctx, params);
		// Always close path
		params.closed = true;
		_closePath(canvas, ctx, params);
	}
	return $canvases;
};

// Draws pie-shaped slice
$.fn.drawSlice = function drawSlice(args) {
	const $canvases = this;

	for (let e = 0; e < $canvases.length; e += 1) {
		const canvas = $canvases[e];
		if (!_isCanvas(canvas)) {
			continue;
		}
		const ctx = _getContext(canvas);
		if (!ctx) {
			continue;
		}
		const params = _getParamsObject(args);
		_addLayer(canvas, params, args, drawSlice);
		if (!params.visible) {
			continue;
		}
		_transformShape(canvas, ctx, params, params.radius * 2);
		_setGlobalProps(canvas, ctx, params);

		// Perform extra calculations

		// Convert angles to radians
		params.start *= params._toRad;
		params.end *= params._toRad;
		// Consider 0deg at north of arc
		params.start -= PI / 2;
		params.end -= PI / 2;

		// Find positive equivalents of angles
		params.start = _getCoterminal(params.start);
		params.end = _getCoterminal(params.end);
		// Ensure start angle is less than end angle
		if (params.end < params.start) {
			params.end += 2 * PI;
		}

		// Calculate angular position of slice
		const angle = (params.start + params.end) / 2;

		// Calculate ratios for slice's angle
		const dx = params.radius * params.spread * cos(angle);
		const dy = params.radius * params.spread * sin(angle);

		// Adjust position of slice
		params.x += dx;
		params.y += dy;

		// Draw slice
		ctx.beginPath();
		ctx.arc(
			params.x,
			params.y,
			params.radius,
			params.start,
			params.end,
			params.ccw
		);
		ctx.lineTo(params.x, params.y);
		// Check for jCanvas events
		_detectEvents(canvas, ctx, params);
		// Always close path
		params.closed = true;
		_closePath(canvas, ctx, params);
	}
	return $canvases;
};

/* Path API */

// Adds arrow to path using the given properties
function _addArrow(
	_canvas: HTMLCanvasElement,
	ctx: CanvasRenderingContext2D,
	params: JCanvasObject,
	path: JCanvasObject,
	x1: number,
	y1: number,
	x2: number,
	y2: number
) {
	// If arrow radius is given and path is not closed
	if (path.arrowRadius && !params.closed) {
		// Calculate angle
		let angle = atan2(y2 - y1, x2 - x1);
		// Adjust angle correctly
		angle -= PI;
		// Calculate offset to place arrow at edge of path
		const offsetX = params.strokeWidth * cos(angle);
		const offsetY = params.strokeWidth * sin(angle);

		// Calculate coordinates for left half of arrow
		const leftX = x2 + path.arrowRadius * cos(angle + path.arrowAngle / 2);
		const leftY = y2 + path.arrowRadius * sin(angle + path.arrowAngle / 2);
		// Calculate coordinates for right half of arrow
		const rightX = x2 + path.arrowRadius * cos(angle - path.arrowAngle / 2);
		const rightY = y2 + path.arrowRadius * sin(angle - path.arrowAngle / 2);

		// Draw left half of arrow
		ctx.moveTo(leftX - offsetX, leftY - offsetY);
		ctx.lineTo(x2 - offsetX, y2 - offsetY);
		// Draw right half of arrow
		ctx.lineTo(rightX - offsetX, rightY - offsetY);

		// Visually connect arrow to path
		ctx.moveTo(x2 - offsetX, y2 - offsetY);
		ctx.lineTo(x2 + offsetX, y2 + offsetY);
		// Move back to end of path
		ctx.moveTo(x2, y2);
	}
}

// Optionally adds arrow to start of path
function _addStartArrow(
	canvas: HTMLCanvasElement,
	ctx: CanvasRenderingContext2D,
	params: JCanvasObject,
	path: JCanvasObject,
	x1: number,
	y1: number,
	x2: number,
	y2: number
) {
	if (!path._arrowAngleConverted) {
		path.arrowAngle *= params._toRad;
		path._arrowAngleConverted = true;
	}
	if (path.startArrow) {
		_addArrow(canvas, ctx, params, path, x1, y1, x2, y2);
	}
}

// Optionally adds arrow to end of path
function _addEndArrow(
	canvas: HTMLCanvasElement,
	ctx: CanvasRenderingContext2D,
	params: JCanvasObject,
	path: JCanvasObject,
	x1: number,
	y1: number,
	x2: number,
	y2: number
) {
	if (!path._arrowAngleConverted) {
		path.arrowAngle *= params._toRad;
		path._arrowAngleConverted = true;
	}
	if (path.endArrow) {
		_addArrow(canvas, ctx, params, path, x1, y1, x2, y2);
	}
}

// Draws line (internal)
function _drawLine(
	canvas: HTMLCanvasElement,
	ctx: CanvasRenderingContext2D,
	params: JCanvasObject,
	path: JCanvasObject
) {
	let l = 2;
	_addStartArrow(
		canvas,
		ctx,
		params,
		path,
		path.x2 + params.x,
		path.y2 + params.y,
		path.x1 + params.x,
		path.y1 + params.y
	);
	if (path.x1 !== undefined && path.y1 !== undefined) {
		ctx.moveTo(path.x1 + params.x, path.y1 + params.y);
	}
	while (true) {
		// Calculate next coordinates
		const lx = path[("x" + l) as `x${number}`];
		const ly = path[("y" + l) as `y${number}`];
		// If coordinates are given
		if (lx !== undefined && ly !== undefined) {
			// Draw next line
			ctx.lineTo(lx + params.x, ly + params.y);
			l += 1;
		} else {
			// Otherwise, stop drawing
			break;
		}
	}
	l -= 1;
	// Optionally add arrows to path
	_addEndArrow(
		canvas,
		ctx,
		params,
		path,
		path[("x" + (l - 1)) as `x${number}`] + params.x,
		path[("y" + (l - 1)) as `y${number}`] + params.y,
		path[("x" + l) as `x${number}`] + params.x,
		path[("y" + l) as `y${number}`] + params.y
	);
}

// Draws line
$.fn.drawLine = function drawLine(args) {
	const $canvases = this;

	for (let e = 0; e < $canvases.length; e += 1) {
		const canvas = $canvases[e];
		if (!_isCanvas(canvas)) {
			continue;
		}
		const ctx = _getContext(canvas);
		if (!ctx) {
			continue;
		}
		const params = _getParamsObject(args);
		_addLayer(canvas, params, args, drawLine);
		if (!params.visible) {
			continue;
		}
		_transformShape(canvas, ctx, params);
		_setGlobalProps(canvas, ctx, params);

		// Draw each point
		ctx.beginPath();
		_drawLine(canvas, ctx, params, params);
		// Check for jCanvas events
		_detectEvents(canvas, ctx, params);
		// Optionally close path
		_closePath(canvas, ctx, params);
	}
	return $canvases;
};

// Draws quadratic curve (internal)
function _drawQuadratic(
	canvas: HTMLCanvasElement,
	ctx: CanvasRenderingContext2D,
	params: JCanvasObject,
	path: JCanvasObject
) {
	let l = 2;

	_addStartArrow(
		canvas,
		ctx,
		params,
		path,
		path.cx1 + params.x,
		path.cy1 + params.y,
		path.x1 + params.x,
		path.y1 + params.y
	);

	if (path.x1 !== undefined && path.y1 !== undefined) {
		ctx.moveTo(path.x1 + params.x, path.y1 + params.y);
	}
	while (true) {
		// Calculate next coordinates
		const lx = path[("x" + l) as `x${number}`];
		const ly = path[("y" + l) as `y${number}`];
		const lcx = path[("cx" + (l - 1)) as `cx${number}`];
		const lcy = path[("cy" + (l - 1)) as `cy${number}`];
		// If coordinates are given
		if (
			lx !== undefined &&
			ly !== undefined &&
			lcx !== undefined &&
			lcy !== undefined
		) {
			// Draw next curve
			ctx.quadraticCurveTo(
				lcx + params.x,
				lcy + params.y,
				lx + params.x,
				ly + params.y
			);
			l += 1;
		} else {
			// Otherwise, stop drawing
			break;
		}
	}
	l -= 1;
	_addEndArrow(
		canvas,
		ctx,
		params,
		path,
		path[("cx" + (l - 1)) as `cx${number}`] + params.x,
		path[("cy" + (l - 1)) as `cy${number}`] + params.y,
		path[("x" + l) as `x${number}`] + params.x,
		path[("y" + l) as `y${number}`] + params.y
	);
}

// Draws quadratic curve
$.fn.drawQuadratic = function drawQuadratic(args) {
	const $canvases = this;

	for (let e = 0; e < $canvases.length; e += 1) {
		const canvas = $canvases[e];
		if (!_isCanvas(canvas)) {
			continue;
		}
		const ctx = _getContext(canvas);
		if (!ctx) {
			continue;
		}
		const params = _getParamsObject(args);
		_addLayer(canvas, params, args, drawQuadratic);
		if (!params.visible) {
			continue;
		}
		_transformShape(canvas, ctx, params);
		_setGlobalProps(canvas, ctx, params);

		// Draw each point
		ctx.beginPath();
		_drawQuadratic(canvas, ctx, params, params);
		// Check for jCanvas events
		_detectEvents(canvas, ctx, params);
		// Optionally close path
		_closePath(canvas, ctx, params);
	}
	return $canvases;
};

// Draws Bezier curve (internal)
function _drawBezier(
	canvas: HTMLCanvasElement,
	ctx: CanvasRenderingContext2D,
	params: JCanvasObject,
	path: JCanvasObject
) {
	let l = 2;
	let lc = 1;

	_addStartArrow(
		canvas,
		ctx,
		params,
		path,
		path.cx1 + params.x,
		path.cy1 + params.y,
		path.x1 + params.x,
		path.y1 + params.y
	);

	if (path.x1 !== undefined && path.y1 !== undefined) {
		ctx.moveTo(path.x1 + params.x, path.y1 + params.y);
	}
	while (true) {
		// Calculate next coordinates
		const lx = path[("x" + l) as `x${number}`];
		const ly = path[("y" + l) as `y${number}`];
		const lcx1 = path[("cx" + lc) as `cx${number}`];
		const lcy1 = path[("cy" + lc) as `cy${number}`];
		const lcx2 = path[("cx" + (lc + 1)) as `cx${number}`];
		const lcy2 = path[("cy" + (lc + 1)) as `cy${number}`];
		// If next coordinates are given
		if (
			lx !== undefined &&
			ly !== undefined &&
			lcx1 !== undefined &&
			lcy1 !== undefined &&
			lcx2 !== undefined &&
			lcy2 !== undefined
		) {
			// Draw next curve
			ctx.bezierCurveTo(
				lcx1 + params.x,
				lcy1 + params.y,
				lcx2 + params.x,
				lcy2 + params.y,
				lx + params.x,
				ly + params.y
			);
			l += 1;
			lc += 2;
		} else {
			// Otherwise, stop drawing
			break;
		}
	}
	l -= 1;
	lc -= 2;
	_addEndArrow(
		canvas,
		ctx,
		params,
		path,
		path[("cx" + (lc + 1)) as `cx${number}`] + params.x,
		path[("cy" + (lc + 1)) as `cy${number}`] + params.y,
		path[("x" + l) as `x${number}`] + params.x,
		path[("y" + l) as `y${number}`] + params.y
	);
}

// Draws Bezier curve
$.fn.drawBezier = function drawBezier(args) {
	const $canvases = this;

	for (let e = 0; e < $canvases.length; e += 1) {
		const canvas = $canvases[e];
		if (!_isCanvas(canvas)) {
			continue;
		}
		const ctx = _getContext(canvas);
		if (!ctx) {
			continue;
		}
		const params = _getParamsObject(args);
		_addLayer(canvas, params, args, drawBezier);
		if (!params.visible) {
			continue;
		}
		_transformShape(canvas, ctx, params);
		_setGlobalProps(canvas, ctx, params);

		// Draw each point
		ctx.beginPath();
		_drawBezier(canvas, ctx, params, params);
		// Check for jCanvas events
		_detectEvents(canvas, ctx, params);
		// Optionally close path
		_closePath(canvas, ctx, params);
	}
	return $canvases;
};

// Retrieves the x-coordinate for the given vector angle and length
function _getVectorX(params: JCanvasObject, angle: number, length: number) {
	angle *= params._toRad;
	angle -= PI / 2;
	return length * cos(angle);
}
// Retrieves the y-coordinate for the given vector angle and length
function _getVectorY(params: JCanvasObject, angle: number, length: number) {
	angle *= params._toRad;
	angle -= PI / 2;
	return length * sin(angle);
}

// Draws vector (internal) #2
function _drawVector(
	canvas: HTMLCanvasElement,
	ctx: CanvasRenderingContext2D,
	params: JCanvasObject,
	path: JCanvasObject
) {
	// Determine offset from dragging
	let offsetX: number;
	let offsetY: number;
	if (params === path) {
		offsetX = 0;
		offsetY = 0;
	} else {
		offsetX = params.x;
		offsetY = params.y;
	}

	let l = 1;
	const x = path.x + offsetX;
	let x3 = x;
	let x4 = x;
	const y = path.y + offsetY;
	let y3 = y;
	let y4 = y;

	_addStartArrow(
		canvas,
		ctx,
		params,
		path,
		x + _getVectorX(params, path.a1, path.l1),
		y + _getVectorY(params, path.a1, path.l1),
		x,
		y
	);

	// The vector starts at the given (x, y) coordinates
	if (path.x !== undefined && path.y !== undefined) {
		ctx.moveTo(x, y);
	}
	while (true) {
		const angle = path[("a" + l) as `a${number}`];
		const length = path[("l" + l) as `l${number}`];

		if (angle !== undefined && length !== undefined) {
			// Convert the angle to radians with 0 degrees starting at north
			// Keep track of last two coordinates
			x3 = x4;
			y3 = y4;
			// Compute (x, y) coordinates from angle and length
			x4 += _getVectorX(params, angle, length);
			y4 += _getVectorY(params, angle, length);
			ctx.lineTo(x4, y4);
			l += 1;
		} else {
			// Otherwise, stop drawing
			break;
		}
	}
	_addEndArrow(canvas, ctx, params, path, x3, y3, x4, y4);
}

// Draws vector
$.fn.drawVector = function drawVector(args) {
	const $canvases = this;

	for (let e = 0; e < $canvases.length; e += 1) {
		const canvas = $canvases[e];
		if (!_isCanvas(canvas)) {
			continue;
		}
		const ctx = _getContext(canvas);
		if (!ctx) {
			continue;
		}
		const params = _getParamsObject(args);
		_addLayer(canvas, params, args, drawVector);
		if (!params.visible) {
			continue;
		}
		_transformShape(canvas, ctx, params);
		_setGlobalProps(canvas, ctx, params);

		// Draw each point
		ctx.beginPath();
		_drawVector(canvas, ctx, params, params);
		// Check for jCanvas events
		_detectEvents(canvas, ctx, params);
		// Optionally close path
		_closePath(canvas, ctx, params);
	}
	return $canvases;
};

// Draws a path consisting of one or more subpaths
$.fn.drawPath = function drawPath(args) {
	const $canvases = this;

	for (let e = 0; e < $canvases.length; e += 1) {
		const canvas = $canvases[e];
		if (!_isCanvas(canvas)) {
			continue;
		}
		const ctx = _getContext(canvas);
		if (!ctx) {
			continue;
		}
		const params = _getParamsObject(args);
		if (params.d) {
			// The only way to offset an SVG path drawn with Path2D() is to
			// translate it (making sure we undo the translation it at the end
			// of the method); note that we cannot use ctx.save() and
			// ctx.restore() because it would cause any masking to be undone at
			// the end of the drawPath() code
			ctx.translate(params.x, params.y);
			params._path = caches.pathCache[params.d] || new Path2D(params.d);
			caches.pathCache[params.d] = params._path;
		}
		_addLayer(canvas, params, args, drawPath);
		if (!params.visible) {
			continue;
		}
		_transformShape(canvas, ctx, params);
		_setGlobalProps(canvas, ctx, params);

		if (!params.d) {
			ctx.beginPath();
			let l = 1;
			while (true) {
				let lp = params[("p" + l) as keyof typeof params];
				if (lp !== undefined) {
					lp = new jCanvasObject(lp);
					if (lp.type === "line") {
						_drawLine(canvas, ctx, params, lp);
					} else if (lp.type === "quadratic") {
						_drawQuadratic(canvas, ctx, params, lp);
					} else if (lp.type === "bezier") {
						_drawBezier(canvas, ctx, params, lp);
					} else if (lp.type === "vector") {
						_drawVector(canvas, ctx, params, lp);
					} else if (lp.type === "arc") {
						_drawArc(canvas, ctx, params, lp);
					} else if (lp.type === "ellipse") {
						_drawEllipse(canvas, ctx, params, lp);
					}
					l += 1;
				} else {
					break;
				}
			}
		}

		// Check for jCanvas events
		_detectEvents(canvas, ctx, params);
		// Optionally close path
		_closePath(canvas, ctx, params);

		// Remember to restore the earlier translation
		if (params.d) {
			ctx.translate(-params.x, -params.y);
		}
	}
	return $canvases;
};

/* Text API */

// Calculates font string and set it as the canvas font
function _setCanvasFont(
	_canvas: HTMLCanvasElement,
	ctx: CanvasRenderingContext2D,
	params: JCanvasObject
) {
	// Otherwise, use the given font attributes
	if (!isNaN(Number(params.fontSize))) {
		// Give font size units if it doesn't have any
		params.fontSize += "px";
	}
	// Set font using given font properties
	ctx.font = params.fontStyle + " " + params.fontSize + " " + params.fontFamily;
}

// Measures canvas text
function _measureText(
	canvas: HTMLCanvasElement,
	ctx: CanvasRenderingContext2D,
	params: JCanvasObject,
	lines: string[]
) {
	const propCache = caches.propCache;

	// Used cached width/height if possible
	if (
		propCache.width &&
		propCache.height &&
		propCache.text === params.text &&
		propCache.fontStyle === params.fontStyle &&
		propCache.fontSize === params.fontSize &&
		propCache.fontFamily === params.fontFamily &&
		propCache.maxWidth === params.maxWidth &&
		propCache.lineHeight === params.lineHeight
	) {
		params.width = propCache.width;
		params.height = propCache.height;
	} else {
		// Calculate text dimensions only once

		// Calculate width of first line (for comparison)
		params.width = ctx.measureText(lines[0]).width;

		// Get width of longest line
		for (let l = 1; l < lines.length; l += 1) {
			const curWidth = ctx.measureText(lines[l]).width;
			// Ensure text's width is the width of its longest line
			if (curWidth > params.width) {
				params.width = curWidth;
			}
		}

		// Save original font size
		const originalSize = canvas.style.fontSize;
		// Temporarily set canvas font size to retrieve size in pixels
		canvas.style.fontSize = params.fontSize;
		// Save text width and height in parameters object
		params.height =
			parseFloat($.css(canvas, "fontSize")) * lines.length * params.lineHeight;
		// Reset font size to original size
		canvas.style.fontSize = originalSize;
	}
}

// Wraps a string of text within a defined width
function _wrapText(ctx: CanvasRenderingContext2D, params: JCanvasObject) {
	const allText = String(params.text);
	// Maximum line width (optional)
	const maxWidth = params.maxWidth;
	// Lines created by manual line breaks (\n)
	const manualLines = allText.split("\n");
	// All lines created manually and by wrapping
	let allLines: string[] = [];

	// Loop through manually-broken lines
	for (let l = 0; l < manualLines.length; l += 1) {
		const text = manualLines[l];
		// Split line into list of words
		const words = text.split(" ");
		let lines = [];
		let line = "";

		// If text is short enough initially
		// Or, if the text consists of only one word
		if (
			words.length === 1 ||
			(maxWidth && ctx.measureText(text).width < maxWidth)
		) {
			// No need to wrap text
			lines = [text];
		} else {
			// Wrap lines
			for (let w = 0; w < words.length; w += 1) {
				// Once line gets too wide, push word to next line
				if (maxWidth && ctx.measureText(line + words[w]).width > maxWidth) {
					// This check prevents empty lines from being created
					if (line !== "") {
						lines.push(line);
					}
					// Start new line and repeat process
					line = "";
				}
				// Add words to line until the line is too wide
				line += words[w];
				// Do not add a space after the last word
				if (w !== words.length - 1) {
					line += " ";
				}
			}
			// The last word should always be pushed
			lines.push(line);
		}
		// Remove extra space at the end of each line
		allLines = allLines.concat(
			lines
				.join("\n")
				.replace(/((\n))|($)/gi, "$2")
				.split("\n")
		);
	}

	return allLines;
}

// Draws text on canvas
$.fn.drawText = function drawText(args) {
	const $canvases = this;
	const constantCloseness = 500;

	for (let e = 0; e < $canvases.length; e += 1) {
		const canvas = $canvases[e];
		if (!_isCanvas(canvas)) {
			continue;
		}
		const ctx = _getContext(canvas);
		if (!ctx) {
			continue;
		}
		const params = _getParamsObject(args);
		_addLayer(canvas, params, args, drawText);
		if (!params.visible) {
			continue;
		}
		// Set text-specific properties
		ctx.textBaseline = params.baseline;
		ctx.textAlign = params.align;

		// Set canvas font using given properties
		_setCanvasFont(canvas, ctx, params);

		let lines: string[];
		if (params.maxWidth !== null) {
			// Wrap text using an internal function
			lines = _wrapText(ctx, params);
		} else {
			// Convert string of text to list of lines
			lines = params.text.toString().split("\n");
		}

		// Calculate text's width and height
		_measureText(canvas, ctx, params, lines);

		_transformShape(canvas, ctx, params, params.width, params.height);
		_setGlobalProps(canvas, ctx, params);

		// Adjust text position to accomodate different horizontal alignments
		let x = params.x;
		if (params.align === "left" && params.width) {
			if (params.respectAlign) {
				// Realign text to the left if chosen
				params.x += params.width / 2;
			} else {
				// Center text block by default
				x -= params.width / 2;
			}
		} else if (params.align === "right" && params.width) {
			if (params.respectAlign) {
				// Realign text to the right if chosen
				params.x -= params.width / 2;
			} else {
				// Center text block by default
				x += params.width / 2;
			}
		}

		if (params.radius) {
			const fontSize = parseFloat(params.fontSize);

			// Greater values move clockwise
			if (params.letterSpacing === null) {
				params.letterSpacing = fontSize / constantCloseness;
			}

			// Loop through each line of text
			for (let l = 0; l < lines.length; l += 1) {
				ctx.save();
				ctx.translate(params.x, params.y);
				let line = lines[l];
				let chars: string[];
				if (params.flipArcText) {
					chars = line.split("");
					chars.reverse();
					line = chars.join("");
				}
				const nchars = line.length;
				ctx.rotate(-(PI * params.letterSpacing * (nchars - 1)) / 2);
				// Loop through characters on each line
				for (let c = 0; c < nchars; c += 1) {
					const ch = line[c];
					// If character is not the first character
					if (c !== 0) {
						// Rotate character onto arc
						ctx.rotate(PI * params.letterSpacing);
					}
					ctx.save();
					ctx.translate(0, -params.radius);
					if (params.flipArcText) {
						ctx.scale(-1, -1);
					}
					ctx.fillText(ch, 0, 0);
					// Prevent extra shadow created by stroke (but only when fill is present)
					if (params.fillStyle !== "transparent") {
						ctx.shadowColor = "transparent";
					}
					if (params.strokeWidth !== 0) {
						// Only stroke if the stroke is not 0
						ctx.strokeText(ch, 0, 0);
					}
					ctx.restore();
				}
				params.radius -= fontSize;
				params.letterSpacing += fontSize / (constantCloseness * 2 * PI);
				ctx.restore();
			}
		} else {
			// Draw each line of text separately
			for (let l = 0; l < lines.length; l += 1) {
				const line = lines[l];
				// Add line offset to center point, but subtract some to center everything
				const y =
					params.y +
					(l * (params.height || 0)) / lines.length -
					((lines.length - 1) * (params.height || 0)) / lines.length / 2;

				ctx.shadowColor = params.shadowColor;

				// Fill & stroke text
				ctx.fillText(line, x, y);
				// Prevent extra shadow created by stroke (but only when fill is present)
				if (params.fillStyle !== "transparent") {
					ctx.shadowColor = "transparent";
				}
				if (params.strokeWidth !== 0) {
					// Only stroke if the stroke is not 0
					ctx.strokeText(line, x, y);
				}
			}
		}

		// Adjust bounding box according to text baseline
		let y = 0;
		if (params.baseline === "top") {
			y += (params.height || 0) / 2;
		} else if (params.baseline === "bottom") {
			y -= (params.height || 0) / 2;
		}

		// Detect jCanvas events
		if (params._event) {
			const nonNullWidth = params.width || 0;
			const nonNullHeight = params.height || 0;
			ctx.beginPath();
			ctx.rect(
				params.x - nonNullWidth / 2,
				params.y - nonNullHeight / 2 + y,
				nonNullWidth,
				nonNullHeight
			);
			_detectEvents(canvas, ctx, params);
			// Close path and configure masking
			ctx.closePath();
		}
		_restoreTransform(ctx, params);
		// Cache jCanvas parameters object for efficiency
		if (params) {
			caches.propCache = params;
		}
	}
	return $canvases;
};

// Measures text width/height using the given parameters
$.fn.measureText = function measureText(args) {
	const $canvases = this;

	// Attempt to retrieve layer
	const params =
		$canvases.getLayer(args) || new jCanvasObject(args as JCanvasObject);

	const canvas = $canvases[0];
	if (!_isCanvas(canvas)) {
		return params;
	}
	const ctx = _getContext(canvas);
	if (!ctx) {
		return params;
	}
	// Set canvas font using given properties
	_setCanvasFont(canvas, ctx, params);
	// Calculate width and height of text
	let lines: string[];
	if (params.maxWidth !== null) {
		lines = _wrapText(ctx, params);
	} else {
		lines = params.text.split("\n");
	}
	_measureText(canvas, ctx, params, lines);

	return params;
};

/* Image API */

// Draws image on canvas
$.fn.drawImage = function drawImage(args) {
	const $canvases = this;
	const imageCache = caches.imageCache;
	let img: HTMLImageElement | HTMLCanvasElement | null = null,
		source: JCanvasObject["source"];

	// Draw image function
	function draw(
		canvas: HTMLCanvasElement,
		ctx: CanvasRenderingContext2D,
		data: JCanvasInternalData,
		params: JCanvasObject,
		layer: JCanvasObject | null
	) {
		if (!img) {
			return;
		}
		// If width and sWidth are not defined, use image width
		if (params.width === null && params.sWidth === null) {
			params.width = params.sWidth = img.width;
		}
		// If width and sHeight are not defined, use image height
		if (params.height === null && params.sHeight === null) {
			params.height = params.sHeight = img.height;
		}

		// Ensure image layer's width and height are accurate
		if (layer) {
			layer.width = params.width;
			layer.height = params.height;
		}

		const nonNullWidth = params.width || 0;
		const nonNullHeight = params.height || 0;

		// Only crop image if all cropping properties are given
		if (
			params.sWidth !== null &&
			params.sHeight !== null &&
			params.sx !== null &&
			params.sy !== null
		) {
			// If width is not defined, use the given sWidth
			if (params.width === null) {
				params.width = params.sWidth;
			}
			// If height is not defined, use the given sHeight
			if (params.height === null) {
				params.height = params.sHeight;
			}

			// Optionally crop from top-left corner of region
			if (params.cropFromCenter) {
				params.sx += params.sWidth / 2;
				params.sy += params.sHeight / 2;
			}

			// Ensure cropped region does not escape image boundaries

			// Top
			if (params.sy - params.sHeight / 2 < 0) {
				params.sy = params.sHeight / 2;
			}
			// Bottom
			if (params.sy + params.sHeight / 2 > img.height) {
				params.sy = img.height - params.sHeight / 2;
			}
			// Left
			if (params.sx - params.sWidth / 2 < 0) {
				params.sx = params.sWidth / 2;
			}
			// Right
			if (params.sx + params.sWidth / 2 > img.width) {
				params.sx = img.width - params.sWidth / 2;
			}

			_transformShape(canvas, ctx, params, params.width, params.height);
			_setGlobalProps(canvas, ctx, params);

			// Draw image
			ctx.drawImage(
				img,
				params.sx - params.sWidth / 2,
				params.sy - params.sHeight / 2,
				params.sWidth,
				params.sHeight,
				params.x - params.width / 2,
				params.y - params.height / 2,
				params.width,
				params.height
			);
		} else {
			// Show entire image if no crop region is defined

			_transformShape(canvas, ctx, params, params.width, params.height);
			_setGlobalProps(canvas, ctx, params);

			// Draw image on canvas
			ctx.drawImage(
				img,
				params.x - nonNullWidth / 2,
				params.y - nonNullHeight / 2,
				nonNullWidth,
				nonNullHeight
			);
		}

		// Draw invisible rectangle to allow for events and masking
		ctx.beginPath();
		ctx.rect(
			params.x - nonNullWidth / 2,
			params.y - nonNullHeight / 2,
			nonNullWidth,
			nonNullHeight
		);
		// Check for jCanvas events
		_detectEvents(canvas, ctx, params);
		// Close path and configure masking
		ctx.closePath();
		_restoreTransform(ctx, params);
		_enableMasking(ctx, data, params);
	}
	// On load function
	function onload(
		canvas: HTMLCanvasElement,
		ctx: CanvasRenderingContext2D,
		data: JCanvasInternalData,
		params: JCanvasObject,
		layer: JCanvasObject | null
	) {
		return function () {
			const $canvas = $(canvas);
			draw(canvas, ctx, data, params, layer);
			if (params.layer && layer instanceof jCanvasLayer) {
				// Trigger 'load' event for layers
				_triggerLayerEvent($canvas, data, layer, "load");
			} else if (params.load) {
				// Run 'load' callback for non-layers
				params.load.call($canvas[0], layer);
			}
			// Continue drawing successive layers after this image layer has loaded
			if (params.layer && layer) {
				// Store list of previous masks for each layer
				layer._masks = data.transforms.masks.slice(0);
				if (params._next) {
					// Draw successive layers
					const complete = data.drawLayersComplete;
					delete data.drawLayersComplete;
					$canvas.drawLayers({
						clear: false,
						resetFire: true,
						index: params._next,
						complete: complete,
					});
				}
			}
		};
	}
	for (let e = 0; e < $canvases.length; e += 1) {
		const canvas = $canvases[e];
		if (!_isCanvas(canvas)) {
			continue;
		}
		const ctx = _getContext(canvas);
		if (!ctx) {
			continue;
		}
		const data = _getCanvasData(canvas);
		const params = _getParamsObject(args);
		const layer = _addLayer(canvas, params, args, drawImage);
		if (!params.visible) {
			continue;
		}
		// Cache the given source
		source = params.source;

		if (
			source instanceof HTMLImageElement ||
			source instanceof HTMLCanvasElement
		) {
			// Use image or canvas element if given
			img = source;
		} else if (source) {
			const cachedImg = imageCache[source];
			if (cachedImg?.complete) {
				// Get the image element from the cache if possible
				img = cachedImg;
			} else {
				// Otherwise, get the image from the given source URL
				img = new Image();
				// If source URL is not a data URL
				if (!source.match(/^data:/i)) {
					// Set crossOrigin for this image
					img.crossOrigin = params.crossOrigin;
				}
				img.src = source;
				// Save image in cache for improved performance
				imageCache[source] = img;
			}
		}

		if (img) {
			if (
				(img instanceof HTMLImageElement && img.complete) ||
				img instanceof HTMLCanvasElement
			) {
				// Draw image if already loaded
				onload(canvas, ctx, data, params, layer)();
			} else {
				// Otherwise, draw image when it loads
				img.onload = onload(canvas, ctx, data, params, layer);
			}
		}
	}
	return $canvases;
};

// Creates a canvas pattern object
$.fn.createPattern = function createPattern(args) {
	const $canvases = this;
	let img: HTMLCanvasElement | HTMLImageElement;
	let pattern: CanvasPattern | null = null;

	// Function to be called when pattern loads
	function onload(ctx: CanvasRenderingContext2D, params: JCanvasObject) {
		// Create pattern
		pattern = ctx.createPattern(img, params.repeat);
		// Run callback function if defined
		if (params.load && pattern) {
			params.load.call($canvases[0], pattern);
		}
	}

	const canvas = $canvases[0];
	if (!_isCanvas(canvas)) {
		return null;
	}
	const ctx = _getContext(canvas);
	if (!ctx) {
		return null;
	}
	const params = _getParamsObject(args);

	// Cache the given source
	const source = params.source;

	// Draw when image is loaded (if load() callback function is defined)

	if (isFunction(source)) {
		// Draw pattern using function if given

		img = document.createElement("canvas");
		img.width = params.width || 0;
		img.height = params.height || 0;
		const imgCtx = _getContext(img);
		source.call(img, imgCtx);
		onload(ctx, params);
	} else {
		// Otherwise, draw pattern using source image

		if (
			source instanceof HTMLImageElement ||
			source instanceof HTMLCanvasElement
		) {
			// Use image element if given
			img = source;
		} else {
			// Use URL if given to get the image
			img = new Image();
			// If source URL is not a data URL
			if (!source.match(/^data:/i)) {
				// Set crossOrigin for this image
				img.crossOrigin = params.crossOrigin;
			}
			img.src = source;
		}

		// Create pattern if already loaded
		if (
			(img instanceof HTMLImageElement && img.complete) ||
			img instanceof HTMLCanvasElement
		) {
			onload(ctx, params);
		} else {
			img.onload = () => onload(ctx, params);
		}
	}
	return pattern;
};

// Creates a canvas gradient object
$.fn.createGradient = function createGradient(args) {
	const $canvases = this;
	let gradient: CanvasGradient | null = null;
	const stops: (number | null)[] = [];

	const params = _getParamsObject(args);
	const canvas = $canvases[0];
	if (!_isCanvas(canvas)) {
		return null;
	}
	const ctx = _getContext(canvas);
	if (!ctx) {
		return null;
	}
	// Gradient coordinates must be defined
	params.x1 = params.x1 || 0;
	params.y1 = params.y1 || 0;
	params.x2 = params.x2 || 0;
	params.y2 = params.y2 || 0;

	if (params.r1 !== null && params.r2 !== null) {
		// Create radial gradient if chosen
		gradient = ctx.createRadialGradient(
			params.x1,
			params.y1,
			params.r1,
			params.x2,
			params.y2,
			params.r2
		);
	} else {
		// Otherwise, create a linear gradient by default
		gradient = ctx.createLinearGradient(
			params.x1,
			params.y1,
			params.x2,
			params.y2
		);
	}

	// Count number of color stops
	for (
		let i = 1;
		params[("c" + i) as keyof typeof params] !== undefined;
		i += 1
	) {
		if (params[("s" + i) as keyof typeof params] !== undefined) {
			stops.push(params[("s" + i) as keyof typeof params]);
		} else {
			stops.push(null);
		}
	}
	const nstops = stops.length;

	// Define start stop if not already defined
	if (stops[0] === null) {
		stops[0] = 0;
	}
	// Define end stop if not already defined
	if (stops[nstops - 1] === null) {
		stops[nstops - 1] = 1;
	}

	// Loop through color stops to fill in the blanks
	for (let i = 0; i < nstops; i += 1) {
		// A progression, in this context, is defined as all of the color stops between and including two known color stops
		let p: number = 0;
		let start: number | null = null;
		let end: number | null = null;
		// Number of stops in current progression
		let n = 1;
		if (stops[i] !== null) {
			// Start a new progression if stop is a number

			// Current iteration in current progression
			p = 0;
			start = stops[i];

			// Look ahead to find end stop
			let a;
			for (a = i + 1; a < nstops; a += 1) {
				if (stops[a] !== null) {
					// If this future stop is a number, make it the end stop for this progression
					end = stops[a];
					break;
				} else {
					// Otherwise, keep looking ahead
					n += 1;
				}
			}

			// Ensure start stop is not greater than end stop
			if (start! > end!) {
				stops[a] = stops[i];
			}
		} else if (stops[i] === null) {
			// Calculate stop if not initially given
			p += 1;
			stops[i] = start! + p * ((end! - start!) / n);
		}
		// Add color stop to gradient object
		const stop = stops[i];
		if (stop !== null) {
			gradient.addColorStop(
				stop,
				params[("c" + (i + 1)) as keyof typeof params]
			);
		}
	}
	return gradient;
};

// Manipulates pixels on the canvas
$.fn.setPixels = function setPixels(args) {
	const $canvases = this;

	for (let e = 0; e < $canvases.length; e += 1) {
		const canvas = $canvases[e];
		if (!_isCanvas(canvas)) {
			continue;
		}
		const ctx = _getContext(canvas);
		const canvasData = _getCanvasData(canvas);
		if (!ctx) {
			continue;
		}
		const params = _getParamsObject(args);
		_addLayer(canvas, params, args, setPixels);
		_transformShape(canvas, ctx, params, params.width, params.height);

		// Use entire canvas of x, y, width, or height is not defined
		if (params.width === null || params.height === null) {
			params.width = canvas.width;
			params.height = canvas.height;
			params.x = params.width / 2;
			params.y = params.height / 2;
		}

		if (params.width !== 0 && params.height !== 0) {
			// Only set pixels if width and height are not zero

			const imgData = ctx.getImageData(
				(params.x - params.width / 2) * canvasData.pixelRatio,
				(params.y - params.height / 2) * canvasData.pixelRatio,
				params.width * canvasData.pixelRatio,
				params.height * canvasData.pixelRatio
			);
			const pixelData = imgData.data;
			const len = pixelData.length;

			// Loop through pixels with the "each" callback function
			if (params.each) {
				for (let i = 0; i < len; i += 4) {
					const px = {
						r: pixelData[i],
						g: pixelData[i + 1],
						b: pixelData[i + 2],
						a: pixelData[i + 3],
					};
					params.each.call(canvas, px, params);
					pixelData[i] = px.r;
					pixelData[i + 1] = px.g;
					pixelData[i + 2] = px.b;
					pixelData[i + 3] = px.a;
				}
			}
			// Put pixels on canvas
			ctx.putImageData(
				imgData,
				(params.x - params.width / 2) * canvasData.pixelRatio,
				(params.y - params.height / 2) * canvasData.pixelRatio
			);
			// Restore transformation
			ctx.restore();
		}
	}
	return $canvases;
};

// Retrieves canvas image as data URL
$.fn.getCanvasImage = function getCanvasImage(type, quality) {
	const $canvases = this;
	if ($canvases.length !== 0) {
		const canvas = $canvases[0];
		if (!_isCanvas(canvas)) {
			return null;
		}
		// JPEG quality defaults to 1
		if (quality === undefined) {
			quality = 1;
		}
		return canvas.toDataURL("image/" + type, quality);
	}
	return null;
};

// Scales canvas based on the device's pixel ratio
$.fn.detectPixelRatio = function detectPixelRatio(callback) {
	const $canvases = this;

	for (let e = 0; e < $canvases.length; e += 1) {
		// Get canvas and its associated data
		const canvas = $canvases[e];
		if (!_isCanvas(canvas)) {
			continue;
		}
		const ctx = _getContext(canvas);
		if (!ctx) {
			continue;
		}
		const data = _getCanvasData(canvas);

		// If canvas has not already been scaled with this method
		if (!data.scaled) {
			// Determine device pixel ratios
			const ratio = window.devicePixelRatio || 1;

			if (ratio !== 1) {
				// Scale canvas relative to ratio

				// Get the current canvas dimensions for future use
				const oldWidth = canvas.width;
				const oldHeight = canvas.height;

				// Resize canvas relative to the determined ratio
				canvas.width = oldWidth * ratio;
				canvas.height = oldHeight * ratio;

				// Scale canvas back to original dimensions via CSS
				canvas.style.width = oldWidth + "px";
				canvas.style.height = oldHeight + "px";

				// Scale context to counter the manual scaling of canvas
				ctx.scale(ratio, ratio);
			}

			// Set pixel ratio on canvas data object
			data.pixelRatio = ratio;
			// Ensure that this method can only be called once for any given canvas
			data.scaled = true;

			// Call the given callback function with the ratio as its only argument
			if (callback) {
				callback.call(canvas, ratio);
			}
		}
	}
	return $canvases;
};

// Clears the jCanvas cache
jCanvas.clearCache = function clearCache() {
	for (const cacheName in caches) {
		if (Object.prototype.hasOwnProperty.call(caches, cacheName)) {
			caches[cacheName as keyof typeof caches] = {};
		}
	}
};

// Export jCanvas functions
extendObject(jCanvas, {
	defaults: defaults,
	setGlobalProps: _setGlobalProps,
	transformShape: _transformShape,
	detectEvents: _detectEvents,
	closePath: _closePath,
	setCanvasFont: _setCanvasFont,
	measureText: _measureText,
});
$.jCanvas = jCanvas;
$.jCanvasObject = jCanvasObject;