/**
 * Copyright © Volker Schukai and all contributing authors, {{copyRightYear}}. All rights reserved.
 * Node module: @schukai/monster
 *
 * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3).
 * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html
 *
 * For those who do not wish to adhere to the AGPLv3, a commercial license is available.
 * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms.
 * For more information about purchasing a commercial license, please contact Volker Schukai.
 *
 * SPDX-License-Identifier: AGPL-3.0
 */

import { instanceSymbol } from "../../constants.mjs";
import { ATTRIBUTE_ROLE } from "../../dom/constants.mjs";
import { CustomElement } from "../../dom/customelement.mjs";
import {
	assembleMethodSymbol,
	registerCustomElement,
} from "../../dom/customelement.mjs";
import { ControlStyleSheet } from "../stylesheet/control.mjs";
import { FormStyleSheet } from "../stylesheet/form.mjs";
import { SpaceStyleSheet } from "../stylesheet/space.mjs";
import "../form/button-bar.mjs";
import "../form/field-set.mjs";
import "../form/message-state-button.mjs";
import "../layout/split-panel.mjs";
import "../state/state.mjs";
import { getLocaleOfDocument } from "../../dom/locale.mjs";
import { addErrorAttribute } from "../../dom/error.mjs";
import { fireCustomEvent } from "../../dom/events.mjs";
import { isString } from "../../types/is.mjs";

export { ImageEditor };

const READONLY_ATTRIBUTE = "data-monster-readonly";

/**
 * @private
 * @type {symbol}
 */
const controlElementSymbol = Symbol("controlElement");

/**
 * @private
 * @type {symbol}
 */
const stageElementSymbol = Symbol("stageElement");

/**
 * @private
 * @type {symbol}
 */
const surfaceElementSymbol = Symbol("surfaceElement");

/**
 * @private
 * @type {symbol}
 */
const canvasElementSymbol = Symbol("canvasElement");

/**
 * @private
 * @type {symbol}
 */
const overlayElementSymbol = Symbol("overlayElement");

/**
 * @private
 * @type {symbol}
 */
const filterSelectElementSymbol = Symbol("filterSelectElement");

/**
 * @private
 * @type {symbol}
 */
const filterIntensityElementSymbol = Symbol("filterIntensityElement");

/**
 * @private
 * @type {symbol}
 */
const zoomInputElementSymbol = Symbol("zoomInputElement");

/**
 * @private
 * @type {symbol}
 */
const rotationInputElementSymbol = Symbol("rotationInputElement");

/**
 * @private
 * @type {symbol}
 */
const rotationRangeElementSymbol = Symbol("rotationRangeElement");

/**
 * @private
 * @type {symbol}
 */
const cropInputsElementSymbol = Symbol("cropInputsElement");

/**
 * @private
 * @type {symbol}
 */
const cropInputXElementSymbol = Symbol("cropInputXElement");

/**
 * @private
 * @type {symbol}
 */
const cropInputYElementSymbol = Symbol("cropInputYElement");

/**
 * @private
 * @type {symbol}
 */
const cropInputWidthElementSymbol = Symbol("cropInputWidthElement");

/**
 * @private
 * @type {symbol}
 */
const cropInputHeightElementSymbol = Symbol("cropInputHeightElement");

/**
 * @private
 * @type {symbol}
 */
const viewSectionElementSymbol = Symbol("viewSectionElement");

/**
 * @private
 * @type {symbol}
 */
const selectionSectionElementSymbol = Symbol("selectionSectionElement");

/**
 * @private
 * @type {symbol}
 */
const filterSectionElementSymbol = Symbol("filterSectionElement");

/**
 * @private
 * @type {symbol}
 */
const startContentElementSymbol = Symbol("startContentElement");

/**
 * @private
 * @type {symbol}
 */
const endContentElementSymbol = Symbol("endContentElement");

/**
 * @private
 * @type {symbol}
 */
const selectButtonElementSymbol = Symbol("selectButtonElement");

/**
 * @private
 * @type {symbol}
 */
const applyCropButtonElementSymbol = Symbol("applyCropButtonElement");

/**
 * @private
 * @type {symbol}
 */
const resetButtonElementSymbol = Symbol("resetButtonElement");

/**
 * @private
 * @type {symbol}
 */
const saveButtonElementSymbol = Symbol("saveButtonElement");

/**
 * @private
 * @type {symbol}
 */
const emptyStateElementSymbol = Symbol("emptyStateElement");

/**
 * @private
 * @type {symbol}
 */
const sourceImageSymbol = Symbol("sourceImage");

/**
 * @private
 * @type {symbol}
 */
const originalSourceSymbol = Symbol("originalSource");

/**
 * @private
 * @type {symbol}
 */
const cropStateSymbol = Symbol("cropState");

/**
 * @private
 * @type {symbol}
 */
const autoLoadSymbol = Symbol("autoLoad");

/**
 * @private
 * @type {symbol}
 */
const stageAspectSymbol = Symbol("stageAspect");

/**
 * @private
 * @type {symbol}
 */
const viewStateSymbol = Symbol("viewState");

/**
 * @private
 * @type {symbol}
 */
const stageResizeObserverSymbol = Symbol("stageResizeObserver");

/**
 * @private
 * @type {symbol}
 */
const cropInputsResizeObserverSymbol = Symbol("cropInputsResizeObserver");

/**
 * @private
 * @type {symbol}
 */
const filterApplyButtonElementSymbol = Symbol("filterApplyButtonElement");

/**
 * @private
 * @type {symbol}
 */
const filterRegionsSymbol = Symbol("filterRegions");

/**
 * @private
 * @type {symbol}
 */
const rotationSymbol = Symbol("rotation");

/**
 * @private
 * @type {symbol}
 */
const rotateLeftButtonElementSymbol = Symbol("rotateLeftButtonElement");

/**
 * @private
 * @type {symbol}
 */
const rotateRightButtonElementSymbol = Symbol("rotateRightButtonElement");

/**
 * @private
 * @type {symbol}
 */
const rotateResetButtonElementSymbol = Symbol("rotateResetButtonElement");

/**
 * An Image Editor Component
 *
 * @fragments /fragments/components/content/image-editor/
 * @example /examples/components/content/image-editor-basic Basic editor workflow
 *
 * @since 4.68.0
 * @copyright Volker Schukai
 * @summary An image editor for cropping, rotation and lightweight visual adjustments before upload or storage.
 * @fires monster-image-editor-saved
 */
class ImageEditor extends CustomElement {
	/**
	 * Constructor for the ImageEditor class.
	 * Calls the parent class constructor.
	 */
	constructor() {
		super();

		this[cropStateSymbol] = {
			enabled: false,
			active: false,
			mode: "draw",
			startX: 0,
			startY: 0,
			endX: 0,
			endY: 0,
			offsetX: 0,
			offsetY: 0,
			handle: null,
			anchorX: 0,
			anchorY: 0,
		};

		this[autoLoadSymbol] = false;
		this[stageAspectSymbol] = null;
		this[viewStateSymbol] = {
			scale: 1,
			offsetX: 0,
			offsetY: 0,
			isPanning: false,
			lastX: 0,
			lastY: 0,
		};
		this[stageResizeObserverSymbol] = null;
		this[cropInputsResizeObserverSymbol] = null;
		this[filterRegionsSymbol] = [];
		this[rotationSymbol] = 0;
	}

	/**
	 * This method is called by the `instanceof` operator.
	 * @return {symbol}
	 */
	static get [instanceSymbol]() {
		return Symbol.for(
			"@schukai/monster/components/content/image-editor@instance",
		);
	}

	/**
	 *
	 * @return {Components.Content.ImageEditor
	 */
	[assembleMethodSymbol]() {
		super[assembleMethodSymbol]();
		initControlReferences.call(this);
		initEventHandler.call(this);
		applyFeatureFlags.call(this);
		syncFilterControls.call(this);
		setupStageResizeObserver.call(this);
		setupCropInputsResizeObserver.call(this);
		updateUiState.call(this, false);
		return this;
	}

	/**
	 * @return {void}
	 */
	connectedCallback() {
		super.connectedCallback();
		setupInitialSource.call(this);
		applyReadOnlyState.call(this);
	}

	/**
	 * @param {string} name
	 * @param {string|null} oldValue
	 * @param {string|null} newValue
	 */
	attributeChangedCallback(name, oldValue, newValue) {
		super.attributeChangedCallback(name, oldValue, newValue);
		if (name === READONLY_ATTRIBUTE && oldValue !== newValue) {
			applyReadOnlyState.call(this);
			updateUiState.call(this, Boolean(this[sourceImageSymbol]));
		}
	}

	/**
	 * @return {void}
	 */
	disconnectedCallback() {
		super.disconnectedCallback();
		if (this[stageResizeObserverSymbol]) {
			this[stageResizeObserverSymbol].disconnect();
			this[stageResizeObserverSymbol] = null;
		}
		if (this[cropInputsResizeObserverSymbol]) {
			this[cropInputsResizeObserverSymbol].disconnect();
			this[cropInputsResizeObserverSymbol] = null;
		}
	}

	/**
	 * To set the options via the HTML Tag, the attribute `data-monster-options` must be used.
	 * @see {@link https://monsterjs.org/en/doc/#configurate-a-monster-control}
	 *
	 * @property {Object} templates Template definitions
	 * @property {string} templates.main Main template
	 * @property {Object} source Source configuration
	 * @property {string|null} source.url URL to load an image from
	 * @property {*} source.data Binary data to load an image from
	 * @property {string} source.contentType Content type for binary data
	 * @property {Object} features Feature configuration
	 * @property {boolean} features.allowCrop=true Enable crop tools
	 * @property {boolean} features.allowFilters=true Enable filter tools
	 * @property {boolean} features.fetchUrl=true Fetch URLs as blobs before loading
	 * @property {boolean} features.crossOrigin=true Set crossOrigin for URLs
	 * @property {Object} output Output configuration
	 * @property {string} output.type="image/png" Output MIME type
	 * @property {number} output.quality=0.92 Output quality for lossy formats
	 * @property {Object} labels Labels
	 */
	get defaults() {
		return Object.assign({}, super.defaults, {
			templates: {
				main: getTemplate(),
			},
			source: {
				url: null,
				data: null,
				contentType: "image/png",
			},
			features: {
				allowCrop: true,
				allowFilters: true,
				fetchUrl: true,
				crossOrigin: true,
			},
			output: {
				type: "image/png",
				quality: 0.92,
			},
			labels: getTranslations(),
		});
	}

	/**
	 * @return {string}
	 */
	static getTag() {
		return "monster-image-editor";
	}

	/**
	 * @return {string[]}
	 */
	static get observedAttributes() {
		const attributes = super.observedAttributes;
		attributes.push(READONLY_ATTRIBUTE);
		return attributes;
	}

	/**
	 * @return {CSSStyleSheet[]}
	 */
	static getCSSStyleSheet() {
		return [ControlStyleSheet, FormStyleSheet, SpaceStyleSheet];
	}

	/**
	 * Sets the image source from binary data or a URL.
	 *
	 * @param {(Blob|ArrayBuffer|Uint8Array|string)} data
	 * @param {Object} [options]
	 * @param {string} [options.contentType]
	 * @param {boolean} [options.storeOriginal]
	 * @return {Promise<void>}
	 */
	setImage(data, options = {}) {
		return setImageFromData.call(this, data, options);
	}

	/**
	 * Load an image from a URL.
	 * @param {string} url
	 * @return {Promise<void>}
	 */
	load(url) {
		return loadFromUrl.call(this, url);
	}

	/**
	 * Reset the editor to the original image and clear edits.
	 * @return {Promise<void>|void}
	 */
	reset() {
		return resetEditor.call(this);
	}

	/**
	 * Save the current image and emit a save event.
	 * @return {Promise<Blob|null>}
	 */
	save() {
		return saveImage.call(this);
	}

	/**
	 * Returns a blob for the current image state.
	 * @param {string} [type]
	 * @param {number} [quality]
	 * @return {Promise<Blob|null>}
	 */
	getImageBlob(type, quality) {
		return getImageBlob.call(this, type, quality);
	}

	/**
	 * Returns a data URL for the current image state.
	 * @param {string} [type]
	 * @param {number} [quality]
	 * @return {string|null}
	 */
	getImageDataUrl(type, quality) {
		return getImageDataUrl.call(this, type, quality);
	}

	/**
	 * Adds a custom action button into the toolbar.
	 * @param {{label?:string,onClick?:Function,classes?:string}} options
	 * @return {HTMLElement}
	 */
	addActionButton(options = {}) {
		const button = document.createElement("monster-message-state-button");
		button.setAttribute("slot", "actions");

		this.appendChild(button);

		queueMicrotask(() => {
			if (options.label && button.setOption) {
				button.setOption("labels.button", options.label);
			}
			if (options.classes && button.setOption) {
				button.setOption("classes.button", options.classes);
			}
			if (options.onClick && button.setOption) {
				button.setOption("actions.click", options.onClick);
			}
		});

		return button;
	}
}

/**
 * @private
 */
function setupInitialSource() {
	if (this[autoLoadSymbol]) {
		return;
	}

	this[autoLoadSymbol] = true;

	const data = this.getOption("source.data");
	const url = this.getOption("source.url");

	if (data) {
		setImageFromData.call(this, data, {
			contentType: this.getOption("source.contentType"),
			storeOriginal: true,
		});
		return;
	}

	if (url) {
		loadFromUrl.call(this, url, { storeOriginal: true });
	}
}

/**
 * @private
 * @return {void}
 */
function initControlReferences() {
	this[controlElementSymbol] = this.shadowRoot.querySelector(
		`[${ATTRIBUTE_ROLE}="control"]`,
	);
	this[controlElementSymbol].style.height = "100%";
	this[controlElementSymbol].style.minHeight = "0";
	this[controlElementSymbol].style.display = "flex";
	this[controlElementSymbol].style.flexDirection = "column";
	this.style.height = "100%";
	this.style.minHeight = "0";

	const splitPanel = this.shadowRoot.querySelector(
		`[data-monster-role="splitPanel"]`,
	);
	if (splitPanel) {
		splitPanel.style.flex = "1";
		splitPanel.style.height = "100%";
		splitPanel.style.minHeight = "0";
		const splitShadow = splitPanel.shadowRoot;
		const startPanel = splitShadow?.querySelector(
			`[data-monster-role="startPanel"]`,
		);
		if (startPanel) {
			startPanel.style.minHeight = "0";
			startPanel.style.height = "100%";
			startPanel.style.overflowY = "auto";
			startPanel.style.overflowX = "hidden";
		}
		const endPanel = splitShadow?.querySelector(
			`[data-monster-role="endPanel"]`,
		);
		if (endPanel) {
			endPanel.style.minHeight = "0";
			endPanel.style.height = "100%";
			endPanel.style.overflowY = "auto";
			endPanel.style.overflowX = "hidden";
		}
	}

	this[stageElementSymbol] = this.shadowRoot.querySelector(
		`[data-monster-role="stage"]`,
	);

	this[canvasElementSymbol] = this.shadowRoot.querySelector(
		`[data-monster-role="canvas"]`,
	);

	this[surfaceElementSymbol] = this.shadowRoot.querySelector(
		`[data-monster-role="surface"]`,
	);

	this[overlayElementSymbol] = this.shadowRoot.querySelector(
		`[data-monster-role="overlay"]`,
	);

	this[stageElementSymbol].style.position = "relative";
	this[stageElementSymbol].style.width = "100%";
	this[stageElementSymbol].style.overflow = "hidden";
	this[stageElementSymbol].style.touchAction = "none";

	this[surfaceElementSymbol].style.position = "relative";
	this[surfaceElementSymbol].style.width = "100%";
	this[surfaceElementSymbol].style.height = "100%";

	this[startContentElementSymbol] = this.shadowRoot.querySelector(
		`[data-monster-role="startContent"]`,
	);
	if (this[startContentElementSymbol]) {
		this[startContentElementSymbol].style.overflow = "auto";
		this[startContentElementSymbol].style.maxHeight = "100%";
		this[startContentElementSymbol].style.minHeight = "0";
		this[startContentElementSymbol].style.height = "100%";
		this[startContentElementSymbol].style.boxSizing = "border-box";
	}

	this[endContentElementSymbol] = this.shadowRoot.querySelector(
		`[data-monster-role="endContent"]`,
	);
	if (this[endContentElementSymbol]) {
		this[endContentElementSymbol].style.overflow = "auto";
		this[endContentElementSymbol].style.maxHeight = "100%";
		this[endContentElementSymbol].style.minHeight = "0";
		this[endContentElementSymbol].style.height = "100%";
		this[endContentElementSymbol].style.boxSizing = "border-box";
	}

	this[canvasElementSymbol].style.display = "block";
	this[canvasElementSymbol].style.width = "100%";
	this[canvasElementSymbol].style.height = "100%";

	this[overlayElementSymbol].style.position = "absolute";
	this[overlayElementSymbol].style.left = "0";
	this[overlayElementSymbol].style.top = "0";
	this[overlayElementSymbol].style.width = "100%";
	this[overlayElementSymbol].style.height = "100%";
	this[overlayElementSymbol].style.pointerEvents = "none";

	this[filterSelectElementSymbol] = this.shadowRoot.querySelector(
		`[data-monster-role="filterSelect"]`,
	);

	this[filterIntensityElementSymbol] = this.shadowRoot.querySelector(
		`[data-monster-role="filterIntensity"]`,
	);

	this[zoomInputElementSymbol] = this.shadowRoot.querySelector(
		`[data-monster-role="zoomInput"]`,
	);

	this[rotationInputElementSymbol] = this.shadowRoot.querySelector(
		`[data-monster-role="rotationInput"]`,
	);

	this[rotationRangeElementSymbol] = this.shadowRoot.querySelector(
		`[data-monster-role="rotationRange"]`,
	);

	this[viewSectionElementSymbol] = this.shadowRoot.querySelector(
		`[data-monster-role="viewSection"]`,
	);

	this[selectionSectionElementSymbol] = this.shadowRoot.querySelector(
		`[data-monster-role="selectionSection"]`,
	);

	this[filterSectionElementSymbol] = this.shadowRoot.querySelector(
		`[data-monster-role="filterSection"]`,
	);

	this[cropInputsElementSymbol] = this.shadowRoot.querySelector(
		`[data-monster-role="cropInputs"]`,
	);
	this[cropInputsElementSymbol].hidden = true;
	this[cropInputsElementSymbol].style.display = "grid";
	this[cropInputsElementSymbol].style.gridTemplateColumns =
		"repeat(2, minmax(0, 1fr))";
	this[cropInputsElementSymbol].style.columnGap = "var(--monster-space-2)";
	this[cropInputsElementSymbol].style.rowGap = "var(--monster-space-2)";

	this[cropInputXElementSymbol] = this.shadowRoot.querySelector(
		`[data-monster-role="cropInputX"]`,
	);

	this[cropInputYElementSymbol] = this.shadowRoot.querySelector(
		`[data-monster-role="cropInputY"]`,
	);

	this[cropInputWidthElementSymbol] = this.shadowRoot.querySelector(
		`[data-monster-role="cropInputWidth"]`,
	);

	this[cropInputHeightElementSymbol] = this.shadowRoot.querySelector(
		`[data-monster-role="cropInputHeight"]`,
	);

	this[selectButtonElementSymbol] = this.shadowRoot.querySelector(
		`[data-monster-role="select"]`,
	);
	this[selectButtonElementSymbol].style.width = "100%";

	this[applyCropButtonElementSymbol] = this.shadowRoot.querySelector(
		`[data-monster-role="applyCrop"]`,
	);
	this[applyCropButtonElementSymbol].hidden = true;
	this[applyCropButtonElementSymbol].style.display = "none";

	this[filterApplyButtonElementSymbol] = this.shadowRoot.querySelector(
		`[data-monster-role="applyFilter"]`,
	);
	this[filterApplyButtonElementSymbol].style.width = "100%";

	const topActions = this.shadowRoot.querySelector(
		`[data-monster-role="topActions"]`,
	);
	if (topActions) {
		topActions.style.paddingLeft = "var(--monster-space-5)";
		topActions.style.paddingBottom = "var(--monster-space-4)";
	}

	this[rotateLeftButtonElementSymbol] = this.shadowRoot.querySelector(
		`[data-monster-role="rotateLeft"]`,
	);

	this[rotateRightButtonElementSymbol] = this.shadowRoot.querySelector(
		`[data-monster-role="rotateRight"]`,
	);

	this[rotateResetButtonElementSymbol] = this.shadowRoot.querySelector(
		`[data-monster-role="rotateReset"]`,
	);

	this[resetButtonElementSymbol] = this.shadowRoot.querySelector(
		`[data-monster-role="reset"]`,
	);

	this[saveButtonElementSymbol] = this.shadowRoot.querySelector(
		`[data-monster-role="save"]`,
	);

	this[emptyStateElementSymbol] = this.shadowRoot.querySelector(
		`[data-monster-role="emptyState"]`,
	);
}

/**
 * @private
 * @return {void}
 */
function initEventHandler() {
	const self = this;

	this[filterSelectElementSymbol].addEventListener("change", () => {
		syncFilterControls.call(self);
		renderImage.call(self);
	});

	this[filterIntensityElementSymbol].addEventListener("input", () => {
		renderImage.call(self);
	});

	this[zoomInputElementSymbol].addEventListener("input", () => {
		const value = Number.parseFloat(
			this[zoomInputElementSymbol].value || "100",
		);
		setViewScale.call(self, value / 100);
		renderImage.call(self);
	});

	this[rotationInputElementSymbol].addEventListener("input", () => {
		const value = Number.parseFloat(
			this[rotationInputElementSymbol].value || "0",
		);
		setRotation.call(self, value);
	});

	this[rotationRangeElementSymbol].addEventListener("input", () => {
		const value = Number.parseFloat(
			this[rotationRangeElementSymbol].value || "0",
		);
		setRotation.call(self, value);
	});

	this[selectButtonElementSymbol].setOption("actions.click", function () {
		handleSelectionButtonClick.call(self);
	});

	this[applyCropButtonElementSymbol].setOption("actions.click", function () {
		handleCropButtonClick.call(self);
	});

	this[filterApplyButtonElementSymbol].setOption("actions.click", function () {
		handleFilterApplyClick.call(self);
	});

	this[resetButtonElementSymbol].setOption("actions.click", function () {
		resetEditor.call(self);
	});

	this[saveButtonElementSymbol].setOption("actions.click", function () {
		saveImage.call(self);
	});

	this[rotateLeftButtonElementSymbol].setOption("actions.click", function () {
		rotateImage.call(self, -90);
	});

	this[rotateRightButtonElementSymbol].setOption("actions.click", function () {
		rotateImage.call(self, 90);
	});

	this[rotateResetButtonElementSymbol].setOption("actions.click", function () {
		setRotation.call(self, 0);
	});

	this[overlayElementSymbol].addEventListener("pointerdown", (event) => {
		startCropSelection.call(self, event);
	});

	this[overlayElementSymbol].addEventListener("pointermove", (event) => {
		updateCropSelection.call(self, event);
	});

	this[overlayElementSymbol].addEventListener("pointerup", (event) => {
		finishCropSelection.call(self, event);
	});

	this[overlayElementSymbol].addEventListener("pointerleave", (event) => {
		finishCropSelection.call(self, event);
	});

	this[stageElementSymbol].addEventListener("pointerdown", (event) => {
		startPan.call(self, event);
	});

	this[stageElementSymbol].addEventListener("pointermove", (event) => {
		updatePan.call(self, event);
	});

	this[stageElementSymbol].addEventListener("pointerup", (event) => {
		stopPan.call(self, event);
	});

	this[stageElementSymbol].addEventListener("pointerleave", (event) => {
		stopPan.call(self, event);
	});

	for (const input of [
		this[cropInputXElementSymbol],
		this[cropInputYElementSymbol],
		this[cropInputWidthElementSymbol],
		this[cropInputHeightElementSymbol],
	]) {
		input.addEventListener("input", () => {
			applyCropInputs.call(self);
		});
	}
}

/**
 * @private
 */
function applyFeatureFlags() {
	const allowFilters = this.getOption("features.allowFilters") !== false;
	const allowCrop = this.getOption("features.allowCrop") !== false;
	const allowSelection = allowCrop || allowFilters;

	if (!allowFilters) {
		if (this[filterSectionElementSymbol]) {
			this[filterSectionElementSymbol].hidden = true;
		}
		this[filterSelectElementSymbol].disabled = true;
		this[filterIntensityElementSymbol].disabled = true;
		this[filterApplyButtonElementSymbol].disabled = true;
	}

	if (!allowSelection) {
		this[selectButtonElementSymbol].hidden = true;
		this[applyCropButtonElementSymbol].hidden = true;
		this[overlayElementSymbol].style.pointerEvents = "none";
		this[cropInputsElementSymbol].hidden = true;
		if (this[selectionSectionElementSymbol]) {
			this[selectionSectionElementSymbol].hidden = true;
		}
	}

	if (!allowCrop) {
		this[applyCropButtonElementSymbol].hidden = true;
	}
}

/**
 * @private
 */
function isReadOnly() {
	return this.hasAttribute(READONLY_ATTRIBUTE);
}

/**
 * @private
 */
function applyReadOnlyState() {
	const readOnly = isReadOnly.call(this);
	if (!readOnly) {
		return;
	}

	if (this[cropStateSymbol].enabled) {
		setCropMode.call(this, false);
	}
	this[overlayElementSymbol].style.pointerEvents = "none";
}

/**
 * @private
 */
function setupStageResizeObserver() {
	if (this[stageResizeObserverSymbol]) {
		return;
	}

	this[stageResizeObserverSymbol] = new ResizeObserver(() => {
		updateStageSize.call(this);
	});

	const container = this[endContentElementSymbol] || this[stageElementSymbol];
	if (container) {
		this[stageResizeObserverSymbol].observe(container);
	}
}

/**
 * @private
 */
function setupCropInputsResizeObserver() {
	if (this[cropInputsResizeObserverSymbol]) {
		return;
	}

	this[cropInputsResizeObserverSymbol] = new ResizeObserver(() => {
		updateCropInputsLayout.call(this);
	});

	this[cropInputsResizeObserverSymbol].observe(this[cropInputsElementSymbol]);
	updateCropInputsLayout.call(this);
}

/**
 * @private
 */
function updateCropInputsLayout() {
	if (!this[cropInputsElementSymbol]) {
		return;
	}

	const width = this[cropInputsElementSymbol].clientWidth;
	if (!width) {
		return;
	}

	this[cropInputsElementSymbol].style.gridTemplateColumns =
		width < 260 ? "minmax(0, 1fr)" : "repeat(2, minmax(0, 1fr))";
	updateCropLabelWidths.call(this);
}

/**
 * @private
 */
function updateCropLabelWidths() {
	const spans = Array.from(
		this[cropInputsElementSymbol].querySelectorAll("label > span"),
	);
	if (spans.length === 0) {
		return;
	}

	let maxWidth = 0;
	for (const span of spans) {
		span.style.display = "inline-block";
		span.style.width = "auto";
		maxWidth = Math.max(maxWidth, span.getBoundingClientRect().width);
	}

	const targetWidth = Math.ceil(maxWidth);
	for (const span of spans) {
		span.style.width = `${targetWidth}px`;
	}
}

/**
 * @private
 */
function updateStageSize() {
	if (!this[stageAspectSymbol]) {
		this[stageElementSymbol].style.height = "auto";
		this[stageElementSymbol].style.width = "100%";
		this[stageElementSymbol].style.margin = "0";
		return;
	}

	const container =
		this[endContentElementSymbol] || this[stageElementSymbol].parentElement;
	const availableWidth = container?.clientWidth ?? 0;
	const availableHeight = container?.clientHeight ?? 0;
	if (!availableWidth) {
		return;
	}

	let targetWidth = availableWidth;
	let targetHeight = Math.round(targetWidth / this[stageAspectSymbol]);
	if (availableHeight && targetHeight > availableHeight) {
		targetHeight = availableHeight;
		targetWidth = Math.round(targetHeight * this[stageAspectSymbol]);
	}

	this[stageElementSymbol].style.width = `${targetWidth}px`;
	this[stageElementSymbol].style.height = `${targetHeight}px`;
	this[stageElementSymbol].style.margin = "0 auto";
}

/**
 * @private
 */
function updateStageAspectFromRenderSize() {
	const { width, height } = getRenderSize.call(this);
	this[stageAspectSymbol] = width > 0 && height > 0 ? width / height : null;
	updateStageSize.call(this);
}

/**
 * @private
 * @param {string} url
 * @return {Promise<void>}
 */
function loadFromUrl(url, options = {}) {
	if (!isString(url) || url.length === 0) {
		addErrorAttribute(this, "Invalid URL");
		return Promise.reject(new Error("Invalid URL"));
	}

	if (this.getOption("features.fetchUrl") === false) {
		return loadImageFromSource.call(this, url, {
			storeOriginal: options.storeOriginal !== false,
			resetAspect: options.storeOriginal !== false,
			resetView: options.storeOriginal !== false,
		});
	}

	return fetch(url)
		.then((response) => {
			if (!response.ok) {
				throw new Error(`Failed to fetch image: ${response.status}`);
			}
			return response.blob();
		})
		.then((blob) => blobToDataUrl(blob))
		.then((dataUrl) => {
			return loadImageFromSource.call(this, dataUrl, {
				storeOriginal: options.storeOriginal !== false,
				resetAspect: options.storeOriginal !== false,
				resetView: options.storeOriginal !== false,
			});
		})
		.catch((error) => {
			addErrorAttribute(this, error.message || error);
			throw error;
		});
}

/**
 * @private
 * @param {(Blob|ArrayBuffer|Uint8Array|string)} data
 * @param {Object} options
 * @return {Promise<void>}
 */
function setImageFromData(data, options) {
	const contentType =
		options.contentType || this.getOption("source.contentType");
	const storeOriginal = options.storeOriginal !== false;

	if (data instanceof Blob) {
		return blobToDataUrl(data).then((dataUrl) => {
			return loadImageFromSource.call(this, dataUrl, {
				storeOriginal,
				resetAspect: storeOriginal,
				resetView: storeOriginal,
			});
		});
	}

	if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
		const blob = new Blob([data], { type: contentType });
		return blobToDataUrl(blob).then((dataUrl) => {
			return loadImageFromSource.call(this, dataUrl, {
				storeOriginal,
				resetAspect: storeOriginal,
				resetView: storeOriginal,
			});
		});
	}

	if (isString(data)) {
		const trimmed = data.trim();
		if (trimmed.startsWith("data:") || trimmed.startsWith("blob:")) {
			return loadImageFromSource.call(this, trimmed, {
				storeOriginal,
				resetAspect: storeOriginal,
				resetView: storeOriginal,
			});
		}

		if (isURL(trimmed)) {
			return loadFromUrl.call(this, trimmed, { storeOriginal });
		}

		const dataUrl = `data:${contentType};base64,${trimmed}`;
		return loadImageFromSource.call(this, dataUrl, {
			storeOriginal,
			resetAspect: storeOriginal,
			resetView: storeOriginal,
		});
	}

	addErrorAttribute(this, "Unsupported image data format");
	return Promise.reject(new Error("Unsupported image data format"));
}

/**
 * @private
 * @param {string} source
 * @param {Object} options
 * @return {Promise<void>}
 */
function loadImageFromSource(source, options = {}) {
	return new Promise((resolve, reject) => {
		const image = new Image();

		if (this.getOption("features.crossOrigin") !== false) {
			image.crossOrigin = "anonymous";
		}

		image.onload = () => {
			this[sourceImageSymbol] = image;
			if (options.storeOriginal) {
				this[originalSourceSymbol] = source;
			}

			if (options.resetAspect === true || this[stageAspectSymbol] === null) {
				updateStageAspectFromRenderSize.call(this);
			}

			resetEditorState.call(this, {
				keepOriginal: true,
				preserveFilters: options.preserveFilters === true,
			});
			if (options.resetView !== false) {
				resetViewState.call(this);
			}
			renderImage.call(this);
			setCropMode.call(this, false);
			updateUiState.call(this, true);
			resolve();
		};

		image.onerror = (event) => {
			addErrorAttribute(this, "Image loading failed");
			reject(event);
		};

		image.src = source;
	});
}

/**
 * @private
 * @return {void}
 */
function renderImage() {
	const image = this[sourceImageSymbol];

	if (!image) {
		updateUiState.call(this, false);
		return;
	}

	const canvas = this[canvasElementSymbol];
	const overlay = this[overlayElementSymbol];
	const { width, height } = getRenderSize.call(this);
	const selectionRect = this[cropStateSymbol].enabled
		? getCropRect.call(this, { minSize: 1 })
		: null;

	canvas.width = width;
	canvas.height = height;
	overlay.width = width;
	overlay.height = height;

	constrainView.call(this);

	const ctx = canvas.getContext("2d");
	resetCanvasTransform(ctx);
	ctx.clearRect(0, 0, width, height);
	drawBaseImage.call(this, ctx, image, { useViewTransform: true });

	if (this[filterRegionsSymbol].length > 0) {
		for (const region of this[filterRegionsSymbol]) {
			drawFilteredRegion.call(this, ctx, image, region, {
				useViewTransform: true,
			});
		}
	}

	drawCropOverlay.call(this);
}

/**
 * @private
 * @return {string}
 */
function buildFilterString() {
	const filter = this[filterSelectElementSymbol].value;
	const intensityValue = Number.parseFloat(
		this[filterIntensityElementSymbol].value,
	);
	const ratio = Number.isNaN(intensityValue) ? 1 : intensityValue / 100;

	switch (filter) {
		case "grayscale":
			return `grayscale(${ratio})`;
		case "sepia":
			return `sepia(${ratio})`;
		case "contrast":
			return `contrast(${1 + ratio})`;
		case "saturate":
			return `saturate(${1 + ratio})`;
		case "invert":
			return `invert(${ratio})`;
		case "blur":
			return `blur(${Math.max(0, Math.round(ratio * 6))}px)`;
		case "none":
		default:
			return "none";
	}
}

/**
 * @private
 */
function syncFilterControls() {
	const hasImage = Boolean(this[sourceImageSymbol]);
	const allowFilters = this.getOption("features.allowFilters") !== false;
	const readOnly = isReadOnly.call(this);
	const filter = this[filterSelectElementSymbol].value;
	const disabled = filter === "none";
	const controlsDisabled = !hasImage || !allowFilters || readOnly;
	this[filterIntensityElementSymbol].disabled = controlsDisabled || disabled;
	this[filterApplyButtonElementSymbol].setOption(
		"disabled",
		controlsDisabled || disabled,
	);
}

/**
 * @private
 */
function handleFilterApplyClick() {
	if (isReadOnly.call(this)) {
		return;
	}

	if (this.getOption("features.allowFilters") === false) {
		return;
	}

	if (!this[sourceImageSymbol]) {
		return;
	}

	const filter = buildFilterString.call(this);
	if (filter === "none") {
		return;
	}

	const rect = this[cropStateSymbol].enabled
		? getCropRect.call(this, { minSize: 1 })
		: null;
	const renderSize = getRenderSize.call(this);
	const targetRect = rect || {
		x: 0,
		y: 0,
		width: renderSize.width,
		height: renderSize.height,
	};

	this[filterRegionsSymbol].push({
		filter,
		rect: targetRect,
	});

	notifyEditorChange.call(this);
	renderImage.call(this);
}

/**
 * @private
 * @param {number} delta
 */
function rotateImage(delta) {
	if (isReadOnly.call(this)) {
		return;
	}

	setRotation.call(this, this[rotationSymbol] + delta);
}

/**
 * @private
 * @param {number} rotation
 */
function setRotation(rotation) {
	if (!this[sourceImageSymbol]) {
		return;
	}
	if (isReadOnly.call(this)) {
		return;
	}

	const next = normalizeRotation(rotation);
	if (next === this[rotationSymbol]) {
		return;
	}
	this[rotationSymbol] = next;
	this[filterRegionsSymbol] = [];
	setCropMode.call(this, false);
	resetViewState.call(this);
	updateStageAspectFromRenderSize.call(this);
	updateRotationControl.call(this);
	notifyEditorChange.call(this);
	renderImage.call(this);
}

/**
 * @private
 * @param {number} rotation
 * @return {number}
 */
function normalizeRotation(rotation) {
	let value = Number.isFinite(rotation) ? rotation : 0;
	value %= 360;
	if (value < 0) {
		value += 360;
	}
	return value;
}
/**
 * @private
 * @param {CanvasRenderingContext2D} ctx
 * @param {HTMLImageElement} image
 */
function drawBaseImage(ctx, image, options = {}) {
	const { imageWidth, imageHeight } = getImageDimensions.call(this);
	const useViewTransform = options.useViewTransform !== false;
	ctx.save();
	if (useViewTransform) {
		applyViewTransform.call(this, ctx);
	}
	applyRotationTransform.call(this, ctx);
	ctx.drawImage(image, 0, 0, imageWidth, imageHeight);
	ctx.restore();
	resetCanvasTransform(ctx);
}

/**
 * @private
 * @param {CanvasRenderingContext2D} ctx
 * @param {HTMLImageElement} image
 * @param {{filter:string,rect:{x:number,y:number,width:number,height:number}}} region
 */
function drawFilteredRegion(ctx, image, region, options = {}) {
	if (!region || !region.filter || region.filter === "none") {
		return;
	}

	const { imageWidth, imageHeight } = getImageDimensions.call(this);
	const useViewTransform = options.useViewTransform !== false;
	ctx.save();
	if (useViewTransform) {
		applyViewTransform.call(this, ctx);
	}
	applyRotationTransform.call(this, ctx);
	ctx.filter = region.filter;
	ctx.beginPath();
	ctx.rect(region.rect.x, region.rect.y, region.rect.width, region.rect.height);
	ctx.clip();
	ctx.drawImage(image, 0, 0, imageWidth, imageHeight);
	ctx.restore();
	resetCanvasTransform(ctx);
	ctx.filter = "none";
}

/**
 * @private
 * @param {PointerEvent} event
 */
function startCropSelection(event) {
	if (this.getOption("features.allowCrop") === false) {
		return;
	}

	if (!this[sourceImageSymbol]) {
		return;
	}

	if (!this[cropStateSymbol].enabled) {
		return;
	}

	const point = getCanvasPoint.call(this, event);
	const rect = getNormalizedRect.call(this);
	const handle = rect ? getHandleAtPoint(rect, point) : null;

	this[cropStateSymbol].active = true;
	this[cropStateSymbol].handle = handle;

	if (handle) {
		this[cropStateSymbol].mode = "resize";
		const anchor = getResizeAnchor(rect, handle);
		this[cropStateSymbol].anchorX = anchor.x;
		this[cropStateSymbol].anchorY = anchor.y;
		this[cropStateSymbol].startX = anchor.x;
		this[cropStateSymbol].startY = anchor.y;
		this[cropStateSymbol].endX = point.x;
		this[cropStateSymbol].endY = point.y;
	} else if (rect && pointInRect(rect, point)) {
		this[cropStateSymbol].mode = "move";
		this[cropStateSymbol].offsetX = point.x - rect.x;
		this[cropStateSymbol].offsetY = point.y - rect.y;
	} else {
		this[cropStateSymbol].mode = "draw";
		this[cropStateSymbol].startX = point.x;
		this[cropStateSymbol].startY = point.y;
		this[cropStateSymbol].endX = point.x;
		this[cropStateSymbol].endY = point.y;
	}

	this[overlayElementSymbol].setPointerCapture(event.pointerId);
	drawCropOverlay.call(this);
	updateCropInputs.call(this);
	updateCropActionState.call(this);
}

/**
 * @private
 * @param {PointerEvent} event
 */
function updateCropSelection(event) {
	if (!this[cropStateSymbol].active) {
		return;
	}

	const point = getCanvasPoint.call(this, event);
	const mode = this[cropStateSymbol].mode;
	const imageSize = getImageSize.call(this);

	if (mode === "move") {
		const rect = getNormalizedRect.call(this);
		if (!rect || !imageSize) {
			return;
		}
		const newX = clampValue(
			point.x - this[cropStateSymbol].offsetX,
			0,
			imageSize.width - rect.width,
		);
		const newY = clampValue(
			point.y - this[cropStateSymbol].offsetY,
			0,
			imageSize.height - rect.height,
		);
		this[cropStateSymbol].startX = newX;
		this[cropStateSymbol].startY = newY;
		this[cropStateSymbol].endX = newX + rect.width;
		this[cropStateSymbol].endY = newY + rect.height;
	} else if (mode === "resize") {
		this[cropStateSymbol].startX = this[cropStateSymbol].anchorX;
		this[cropStateSymbol].startY = this[cropStateSymbol].anchorY;
		this[cropStateSymbol].endX = point.x;
		this[cropStateSymbol].endY = point.y;
	} else {
		this[cropStateSymbol].endX = point.x;
		this[cropStateSymbol].endY = point.y;
	}

	clampSelectionToImage.call(this);
	drawCropOverlay.call(this);
	updateCropInputs.call(this);
}

/**
 * @private
 * @param {PointerEvent} event
 */
function finishCropSelection(event) {
	if (!this[cropStateSymbol].active) {
		return;
	}

	if (this[cropStateSymbol].mode !== "move") {
		const point = getCanvasPoint.call(this, event);
		this[cropStateSymbol].endX = point.x;
		this[cropStateSymbol].endY = point.y;
	}
	this[cropStateSymbol].active = false;
	this[cropStateSymbol].mode = "draw";
	this[cropStateSymbol].handle = null;
	this[cropStateSymbol].anchorX = 0;
	this[cropStateSymbol].anchorY = 0;
	if (this[overlayElementSymbol].hasPointerCapture(event.pointerId)) {
		this[overlayElementSymbol].releasePointerCapture(event.pointerId);
	}
	clampSelectionToImage.call(this);
	drawCropOverlay.call(this);
	updateCropInputs.call(this);
}

/**
 * @private
 * @return {{x:number,y:number,width:number,height:number}|null}
 */
function getCropRect(options = {}) {
	const minSize = Number.isFinite(options.minSize) ? options.minSize : 4;
	const rect = getNormalizedRect.call(this);

	if (!rect || rect.width < minSize || rect.height < minSize) {
		return null;
	}

	return rect;
}

/**
 * @private
 * @return {{x:number,y:number,width:number,height:number,x2:number,y2:number}|null}
 */
function getNormalizedRect() {
	const state = this[cropStateSymbol];
	const width = Math.abs(state.endX - state.startX);
	const height = Math.abs(state.endY - state.startY);

	if (width === 0 || height === 0) {
		return null;
	}

	const x = Math.min(state.startX, state.endX);
	const y = Math.min(state.startY, state.endY);
	const x2 = x + width;
	const y2 = y + height;

	return {
		x,
		y,
		width,
		height,
		x2,
		y2,
	};
}

/**
 * @private
 */
function drawCropOverlay() {
	const overlay = this[overlayElementSymbol];
	const ctx = overlay.getContext("2d");
	const rect = getCropRect.call(this, { minSize: 1 });

	resetCanvasTransform(ctx);
	ctx.clearRect(0, 0, overlay.width, overlay.height);

	if (!this[cropStateSymbol].enabled || !rect) {
		return;
	}

	ctx.save();
	ctx.fillStyle = "rgba(0, 0, 0, 0.45)";
	ctx.fillRect(0, 0, overlay.width, overlay.height);
	ctx.globalCompositeOperation = "destination-out";
	ctx.fillStyle = "rgba(0, 0, 0, 1)";
	applyViewTransform.call(this, ctx);
	ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
	ctx.globalCompositeOperation = "source-over";
	ctx.strokeStyle = "rgba(255, 255, 255, 0.9)";
	ctx.lineWidth = 2 / this[viewStateSymbol].scale;
	ctx.setLineDash([]);
	ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);

	const handleSize = 8 / this[viewStateSymbol].scale;
	ctx.fillStyle = "rgba(255, 255, 255, 0.9)";
	for (const [hx, hy] of [
		[rect.x, rect.y],
		[rect.x2, rect.y],
		[rect.x, rect.y2],
		[rect.x2, rect.y2],
	]) {
		ctx.fillRect(
			hx - handleSize / 2,
			hy - handleSize / 2,
			handleSize,
			handleSize,
		);
	}
	ctx.restore();
	resetCanvasTransform(ctx);
}

/**
 * @private
 */
function handleSelectionButtonClick() {
	if (isReadOnly.call(this)) {
		return;
	}

	const allowFilters = this.getOption("features.allowFilters") !== false;
	const allowCrop = this.getOption("features.allowCrop") !== false;
	const allowSelection = allowCrop || allowFilters;
	if (!allowSelection) {
		return;
	}

	if (!this[sourceImageSymbol]) {
		return;
	}

	const enabled = !this[cropStateSymbol].enabled;
	setCropMode.call(this, enabled);

	renderImage.call(this);
}

/**
 * @private
 */
/**
 * @private
 */
function handleCropButtonClick() {
	if (isReadOnly.call(this)) {
		return;
	}

	if (this.getOption("features.allowCrop") === false) {
		return;
	}

	if (!this[sourceImageSymbol]) {
		return;
	}

	const rect = getCropRect.call(this);
	if (!rect || !this[cropStateSymbol].enabled) {
		return;
	}

	applyCrop.call(this);
	setCropMode.call(this, false);
	this[filterRegionsSymbol] = [];
	renderImage.call(this);
}

/**
 * @private
 * @param {boolean} enabled
 */
function setCropMode(enabled) {
	if (enabled && isReadOnly.call(this)) {
		return;
	}

	const allowFilters = this.getOption("features.allowFilters") !== false;
	const allowCrop = this.getOption("features.allowCrop") !== false;
	const allowSelection = allowCrop || allowFilters;
	if (!allowSelection && enabled) {
		return;
	}

	const effectiveEnabled = allowSelection && enabled;
	this[cropStateSymbol].enabled = effectiveEnabled;
	this[cropInputsElementSymbol].hidden = !effectiveEnabled;
	if (effectiveEnabled) {
		queueMicrotask(() => {
			updateCropInputsLayout.call(this);
		});
	}
	this[overlayElementSymbol].style.pointerEvents = effectiveEnabled
		? "auto"
		: "none";
	this[overlayElementSymbol].style.cursor = effectiveEnabled
		? "crosshair"
		: "default";
	this[stageElementSymbol].style.cursor = effectiveEnabled
		? "crosshair"
		: this[sourceImageSymbol]
			? "grab"
			: "default";
	for (const input of [
		this[cropInputXElementSymbol],
		this[cropInputYElementSymbol],
		this[cropInputWidthElementSymbol],
		this[cropInputHeightElementSymbol],
	]) {
		input.disabled = !effectiveEnabled;
	}

	if (!effectiveEnabled) {
		this[cropStateSymbol].active = false;
		this[cropStateSymbol].handle = null;
		this[cropStateSymbol].anchorX = 0;
		this[cropStateSymbol].anchorY = 0;
	}

	drawCropOverlay.call(this);
	updateCropInputs.call(this);
}

/**
 * @private
 */
/**
 * @private
 */
function applyCropInputs() {
	if (!this[cropStateSymbol].enabled) {
		return;
	}
	if (isReadOnly.call(this)) {
		return;
	}

	const imageSize = getImageSize.call(this);
	if (!imageSize) {
		return;
	}

	const x = Number.parseFloat(this[cropInputXElementSymbol].value || "0");
	const y = Number.parseFloat(this[cropInputYElementSymbol].value || "0");
	const width = Number.parseFloat(
		this[cropInputWidthElementSymbol].value || "0",
	);
	const height = Number.parseFloat(
		this[cropInputHeightElementSymbol].value || "0",
	);

	const nextX = clampValue(x, 0, imageSize.width);
	const nextY = clampValue(y, 0, imageSize.height);
	const nextWidth = clampValue(width, 1, imageSize.width - nextX);
	const nextHeight = clampValue(height, 1, imageSize.height - nextY);

	this[cropStateSymbol].startX = nextX;
	this[cropStateSymbol].startY = nextY;
	this[cropStateSymbol].endX = nextX + nextWidth;
	this[cropStateSymbol].endY = nextY + nextHeight;

	drawCropOverlay.call(this);
	updateCropInputs.call(this);
}

/**
 * @private
 */
function updateCropInputs() {
	const imageSize = getImageSize.call(this);
	if (imageSize) {
		this[cropInputXElementSymbol].max = `${Math.round(imageSize.width)}`;
		this[cropInputYElementSymbol].max = `${Math.round(imageSize.height)}`;
		this[cropInputWidthElementSymbol].max = `${Math.round(imageSize.width)}`;
		this[cropInputHeightElementSymbol].max = `${Math.round(imageSize.height)}`;
	}

	const rect = getCropRect.call(this, { minSize: 1 });

	if (!rect) {
		this[cropInputXElementSymbol].value = "0";
		this[cropInputYElementSymbol].value = "0";
		this[cropInputWidthElementSymbol].value = "0";
		this[cropInputHeightElementSymbol].value = "0";
		updateCropActionState.call(this);
		return;
	}

	this[cropInputXElementSymbol].value = `${Math.round(rect.x)}`;
	this[cropInputYElementSymbol].value = `${Math.round(rect.y)}`;
	this[cropInputWidthElementSymbol].value = `${Math.round(rect.width)}`;
	this[cropInputHeightElementSymbol].value = `${Math.round(rect.height)}`;
	updateCropActionState.call(this);
}

/**
 * @private
 */
function updateCropActionState() {
	const hasImage = Boolean(this[sourceImageSymbol]);
	const allowCrop = this.getOption("features.allowCrop") !== false;
	const readOnly = isReadOnly.call(this);
	const rect = getCropRect.call(this);
	const show =
		allowCrop &&
		this[cropStateSymbol].enabled &&
		Boolean(rect) &&
		hasImage &&
		!readOnly;

	this[applyCropButtonElementSymbol].hidden = !show;
	this[applyCropButtonElementSymbol].style.display = show ? "" : "none";
	this[applyCropButtonElementSymbol].setOption("disabled", !show);
}

/**
 * @private
 */
function notifyEditorChange() {
	fireCustomEvent(this, "monster-image-editor-changed", {
		element: this,
	});
}

/**
 * @private
 * @return {{width:number,height:number}|null}
 */
function getImageSize() {
	const image = this[sourceImageSymbol];
	if (!image) {
		return null;
	}

	const { width, height } = getRenderSize.call(this);
	return { width, height };
}

/**
 * @private
 * @param {CanvasRenderingContext2D} ctx
 */
function applyViewTransform(ctx) {
	const view = this[viewStateSymbol];
	ctx.setTransform(view.scale, 0, 0, view.scale, view.offsetX, view.offsetY);
}

/**
 * @private
 * @param {CanvasRenderingContext2D} ctx
 */
function resetCanvasTransform(ctx) {
	ctx.setTransform(1, 0, 0, 1, 0, 0);
}

/**
 * @private
 */
function resetViewState() {
	const view = this[viewStateSymbol];
	view.scale = 1;
	view.offsetX = 0;
	view.offsetY = 0;
	this[filterRegionsSymbol] = [];
	updateStageAspectFromRenderSize.call(this);
	updateZoomControl.call(this);
	updateRotationControl.call(this);
}

/**
 * @private
 * @param {number} scale
 */
function setViewScale(scale) {
	const view = this[viewStateSymbol];
	const canvas = this[canvasElementSymbol];
	const nextScale = clampValue(scale, 0.25, 4);

	if (!canvas) {
		view.scale = nextScale;
		updateZoomControl.call(this);
		return;
	}

	const centerX = canvas.width / 2;
	const centerY = canvas.height / 2;
	const imageCenterX = (centerX - view.offsetX) / view.scale;
	const imageCenterY = (centerY - view.offsetY) / view.scale;

	view.scale = nextScale;
	view.offsetX = centerX - imageCenterX * view.scale;
	view.offsetY = centerY - imageCenterY * view.scale;

	constrainView.call(this);
	updateZoomControl.call(this);
}

/**
 * @private
 * @param {CanvasRenderingContext2D} ctx
 */
function applyRotationTransform(ctx) {
	const { imageWidth, imageHeight } = getImageDimensions.call(this);
	const { width, height } = getRenderSize.call(this);
	const rad = (this[rotationSymbol] * Math.PI) / 180;

	ctx.translate(width / 2, height / 2);
	ctx.rotate(rad);
	ctx.translate(-imageWidth / 2, -imageHeight / 2);
}

/**
 * @private
 * @return {{width:number,height:number}}
 */
function getRenderSize() {
	const image = this[sourceImageSymbol];
	if (!image) {
		return { width: 0, height: 0 };
	}

	const imageWidth = image.naturalWidth || image.width;
	const imageHeight = image.naturalHeight || image.height;
	const rotation = normalizeRotation(this[rotationSymbol]);

	const rad = (rotation * Math.PI) / 180;
	const cos = Math.abs(Math.cos(rad));
	const sin = Math.abs(Math.sin(rad));

	return {
		width: imageWidth * cos + imageHeight * sin,
		height: imageWidth * sin + imageHeight * cos,
	};
}

/**
 * @private
 * @return {{imageWidth:number,imageHeight:number}}
 */
function getImageDimensions() {
	const image = this[sourceImageSymbol];
	if (!image) {
		return { imageWidth: 0, imageHeight: 0 };
	}

	return {
		imageWidth: image.naturalWidth || image.width,
		imageHeight: image.naturalHeight || image.height,
	};
}

/**
 * @private
 */
function constrainView() {
	const view = this[viewStateSymbol];
	const canvas = this[canvasElementSymbol];
	const imageSize = getRenderSize.call(this);

	if (!canvas || !imageSize) {
		return;
	}

	const scaledWidth = imageSize.width * view.scale;
	const scaledHeight = imageSize.height * view.scale;
	const canvasWidth = canvas.width;
	const canvasHeight = canvas.height;

	if (scaledWidth <= canvasWidth) {
		view.offsetX = (canvasWidth - scaledWidth) / 2;
	} else {
		const minX = canvasWidth - scaledWidth;
		view.offsetX = clampValue(view.offsetX, minX, 0);
	}

	if (scaledHeight <= canvasHeight) {
		view.offsetY = (canvasHeight - scaledHeight) / 2;
	} else {
		const minY = canvasHeight - scaledHeight;
		view.offsetY = clampValue(view.offsetY, minY, 0);
	}
}

/**
 * @private
 */
function updateZoomControl() {
	if (!this[zoomInputElementSymbol]) {
		return;
	}
	const view = this[viewStateSymbol];
	this[zoomInputElementSymbol].value = `${Math.round(view.scale * 100)}`;
}

/**
 * @private
 */
function updateRotationControl() {
	if (!this[rotationInputElementSymbol] || !this[rotationRangeElementSymbol]) {
		return;
	}
	const value = `${Math.round(this[rotationSymbol])}`;
	this[rotationInputElementSymbol].value = value;
	this[rotationRangeElementSymbol].value = value;
}

/**
 * @private
 * @param {PointerEvent} event
 */
function startPan(event) {
	if (!this[sourceImageSymbol]) {
		return;
	}

	if (this[cropStateSymbol].enabled) {
		return;
	}

	const view = this[viewStateSymbol];
	const point = getCanvasPointRaw.call(this, event);
	view.isPanning = true;
	view.lastX = point.x;
	view.lastY = point.y;
	this[stageElementSymbol].style.cursor = "grabbing";
	this[stageElementSymbol].setPointerCapture(event.pointerId);
}

/**
 * @private
 * @param {PointerEvent} event
 */
function updatePan(event) {
	const view = this[viewStateSymbol];
	if (!view.isPanning) {
		return;
	}

	const point = getCanvasPointRaw.call(this, event);
	const deltaX = point.x - view.lastX;
	const deltaY = point.y - view.lastY;
	view.lastX = point.x;
	view.lastY = point.y;
	view.offsetX += deltaX;
	view.offsetY += deltaY;

	constrainView.call(this);
	renderImage.call(this);
}

/**
 * @private
 * @param {PointerEvent} event
 */
function stopPan(event) {
	const view = this[viewStateSymbol];
	if (!view.isPanning) {
		return;
	}
	view.isPanning = false;
	if (this[stageElementSymbol].hasPointerCapture(event.pointerId)) {
		this[stageElementSymbol].releasePointerCapture(event.pointerId);
	}
	this[stageElementSymbol].style.cursor = view.moveEnabled
		? "grab"
		: this[cropStateSymbol].enabled
			? "crosshair"
			: this[sourceImageSymbol]
				? "grab"
				: "default";
}

/**
 * @private
 */
function clampSelectionToImage() {
	const imageSize = getImageSize.call(this);
	if (!imageSize) {
		return;
	}

	this[cropStateSymbol].startX = clampValue(
		this[cropStateSymbol].startX,
		0,
		imageSize.width,
	);
	this[cropStateSymbol].endX = clampValue(
		this[cropStateSymbol].endX,
		0,
		imageSize.width,
	);
	this[cropStateSymbol].startY = clampValue(
		this[cropStateSymbol].startY,
		0,
		imageSize.height,
	);
	this[cropStateSymbol].endY = clampValue(
		this[cropStateSymbol].endY,
		0,
		imageSize.height,
	);
}

/**
 * @private
 * @param {number} value
 * @param {number} min
 * @param {number} max
 * @return {number}
 */
function clampValue(value, min, max) {
	if (!Number.isFinite(value)) {
		return min;
	}
	return Math.min(max, Math.max(min, value));
}

/**
 * @private
 * @param {{x:number,y:number,width:number,height:number}} rect
 * @param {{x:number,y:number}} point
 * @return {boolean}
 */
function pointInRect(rect, point) {
	return (
		point.x >= rect.x &&
		point.x <= rect.x + rect.width &&
		point.y >= rect.y &&
		point.y <= rect.y + rect.height
	);
}

/**
 * @private
 * @param {{x:number,y:number,width:number,height:number,x2:number,y2:number}} rect
 * @param {{x:number,y:number}} point
 * @return {string|null}
 */
function getHandleAtPoint(rect, point) {
	const threshold = 10;
	const handles = {
		nw: { x: rect.x, y: rect.y },
		ne: { x: rect.x2, y: rect.y },
		sw: { x: rect.x, y: rect.y2 },
		se: { x: rect.x2, y: rect.y2 },
	};

	for (const [key, handle] of Object.entries(handles)) {
		if (
			Math.abs(point.x - handle.x) <= threshold &&
			Math.abs(point.y - handle.y) <= threshold
		) {
			return key;
		}
	}

	return null;
}

/**
 * @private
 * @param {{x:number,y:number,x2:number,y2:number}} rect
 * @param {string} handle
 * @return {{x:number,y:number}}
 */
function getResizeAnchor(rect, handle) {
	switch (handle) {
		case "nw":
			return { x: rect.x2, y: rect.y2 };
		case "ne":
			return { x: rect.x, y: rect.y2 };
		case "sw":
			return { x: rect.x2, y: rect.y };
		case "se":
		default:
			return { x: rect.x, y: rect.y };
	}
}

/**
 * @private
 */
function applyCrop() {
	const image = this[sourceImageSymbol];
	if (!image) {
		return;
	}
	if (isReadOnly.call(this)) {
		return;
	}

	const rect = getCropRect.call(this);
	if (!rect) {
		return;
	}

	const renderSize = getRenderSize.call(this);
	const baseCanvas = document.createElement("canvas");
	baseCanvas.width = Math.round(renderSize.width);
	baseCanvas.height = Math.round(renderSize.height);

	const baseCtx = baseCanvas.getContext("2d");
	resetCanvasTransform(baseCtx);
	baseCtx.clearRect(0, 0, baseCanvas.width, baseCanvas.height);
	drawBaseImage.call(this, baseCtx, image, { useViewTransform: false });

	const cropCanvas = document.createElement("canvas");
	cropCanvas.width = Math.round(rect.width);
	cropCanvas.height = Math.round(rect.height);

	const ctx = cropCanvas.getContext("2d");
	ctx.drawImage(
		baseCanvas,
		rect.x,
		rect.y,
		rect.width,
		rect.height,
		0,
		0,
		cropCanvas.width,
		cropCanvas.height,
	);

	const dataUrl = cropCanvas.toDataURL("image/png");
	return loadImageFromSource
		.call(this, dataUrl, {
			storeOriginal: false,
			preserveFilters: true,
			resetView: true,
		})
		.then(() => {
			notifyEditorChange.call(this);
		});
}

/**
 * @private
 * @return {Promise<Blob|null>}
 */
function saveImage() {
	return getImageBlob.call(this).then((blob) => {
		if (!blob) {
			return null;
		}

		fireCustomEvent(this, "monster-image-editor-saved", {
			element: this,
			blob,
		});

		return blob;
	});
}

/**
 * @private
 * @param {string} type
 * @param {number} quality
 * @return {Promise<Blob|null>}
 */
function getImageBlob(type, quality) {
	const image = this[sourceImageSymbol];
	if (!image) {
		return Promise.resolve(null);
	}

	const outputType = type || this.getOption("output.type");
	const outputQuality =
		quality !== undefined ? quality : this.getOption("output.quality");
	const canvas = renderOutputCanvas.call(this, image);

	return new Promise((resolve) => {
		canvas.toBlob(
			(blob) => {
				resolve(blob || null);
			},
			outputType,
			outputQuality,
		);
	});
}

/**
 * @private
 * @param {string} type
 * @param {number} quality
 * @return {string|null}
 */
function getImageDataUrl(type, quality) {
	const image = this[sourceImageSymbol];
	if (!image) {
		return null;
	}

	const outputType = type || this.getOption("output.type");
	const outputQuality =
		quality !== undefined ? quality : this.getOption("output.quality");
	const canvas = renderOutputCanvas.call(this, image);

	try {
		return canvas.toDataURL(outputType, outputQuality);
	} catch (error) {
		return null;
	}
}

/**
 * @private
 * @param {HTMLImageElement} image
 * @return {HTMLCanvasElement}
 */
function renderOutputCanvas(image) {
	const canvas = document.createElement("canvas");
	const { width, height } = getRenderSize.call(this);
	canvas.width = Math.round(width);
	canvas.height = Math.round(height);

	const ctx = canvas.getContext("2d");
	resetCanvasTransform(ctx);
	ctx.clearRect(0, 0, canvas.width, canvas.height);
	drawBaseImage.call(this, ctx, image, { useViewTransform: false });

	const selectionRect = this[cropStateSymbol].enabled
		? getCropRect.call(this, { minSize: 1 })
		: null;
	const filter = buildFilterString.call(this);
	const hasFilter = filter !== "none";

	if (this[filterRegionsSymbol].length > 0) {
		for (const region of this[filterRegionsSymbol]) {
			drawFilteredRegion.call(this, ctx, image, region, {
				useViewTransform: false,
			});
		}
	} else if (hasFilter && selectionRect) {
		drawFilteredRegion.call(
			this,
			ctx,
			image,
			{ filter, rect: selectionRect },
			{ useViewTransform: false },
		);
	} else if (hasFilter) {
		drawFilteredRegion.call(
			this,
			ctx,
			image,
			{ filter, rect: { x: 0, y: 0, width, height } },
			{ useViewTransform: false },
		);
	}

	return canvas;
}

/**
 * @private
 * @return {Promise<void>|void}
 */
function resetEditor() {
	resetEditorState.call(this, { keepOriginal: true });

	if (!this[originalSourceSymbol]) {
		updateUiState.call(this, false);
		return;
	}

	return loadImageFromSource.call(this, this[originalSourceSymbol], {
		storeOriginal: false,
	});
}

/**
 * @private
 * @param {Object} options
 */
function resetEditorState(options = {}) {
	this[cropStateSymbol] = {
		enabled: false,
		active: false,
		mode: "draw",
		startX: 0,
		startY: 0,
		endX: 0,
		endY: 0,
		offsetX: 0,
		offsetY: 0,
		handle: null,
		anchorX: 0,
		anchorY: 0,
	};

	this[rotationSymbol] = 0;
	updateRotationControl.call(this);

	if (!options.preserveFilters) {
		this[filterSelectElementSymbol].value = "none";
		this[filterIntensityElementSymbol].value = "100";
		syncFilterControls.call(this);
	}

	if (!options.keepOriginal) {
		this[originalSourceSymbol] = null;
	}
}

/**
 * @private
 * @param {boolean} hasImage
 */
function updateUiState(hasImage) {
	this[stageElementSymbol].style.display = hasImage ? "block" : "none";
	this[emptyStateElementSymbol].style.display = hasImage ? "none" : "block";

	const allowCrop = this.getOption("features.allowCrop") !== false;
	const allowFilters = this.getOption("features.allowFilters") !== false;
	const allowSelection = allowCrop || allowFilters;
	const readOnly = isReadOnly.call(this);

	this[applyCropButtonElementSymbol].setOption(
		"disabled",
		!hasImage || !allowCrop || readOnly,
	);
	this[selectButtonElementSymbol].setOption(
		"disabled",
		!hasImage || !allowSelection || readOnly,
	);
	this[resetButtonElementSymbol].setOption("disabled", !hasImage || readOnly);
	this[saveButtonElementSymbol].setOption("disabled", !hasImage || readOnly);

	this[filterSelectElementSymbol].disabled =
		!hasImage || !allowFilters || readOnly;
	this[filterIntensityElementSymbol].disabled =
		!hasImage ||
		!allowFilters ||
		readOnly ||
		this[filterSelectElementSymbol].value === "none";
	this[filterApplyButtonElementSymbol].setOption(
		"disabled",
		!hasImage ||
			!allowFilters ||
			readOnly ||
			this[filterSelectElementSymbol].value === "none",
	);
	this[zoomInputElementSymbol].disabled = !hasImage;
	this[rotationInputElementSymbol].disabled = !hasImage || readOnly;
	this[rotationRangeElementSymbol].disabled = !hasImage || readOnly;
	updateZoomControl.call(this);

	const cropInputsDisabled =
		!hasImage || !allowCrop || !this[cropStateSymbol].enabled || readOnly;
	for (const input of [
		this[cropInputXElementSymbol],
		this[cropInputYElementSymbol],
		this[cropInputWidthElementSymbol],
		this[cropInputHeightElementSymbol],
	]) {
		input.disabled = cropInputsDisabled;
	}

	if (!hasImage && this[cropStateSymbol].enabled) {
		setCropMode.call(this, false);
	}

	if (!hasImage) {
		resetViewState.call(this);
	}

	if (!this[cropStateSymbol].enabled) {
		this[stageElementSymbol].style.cursor = hasImage ? "grab" : "default";
	}

	updateRotationControl.call(this);
	updateCropActionState.call(this);
}

/**
 * @private
 * @param {PointerEvent} event
 * @return {{x:number,y:number}}
 */
function getCanvasPoint(event) {
	const raw = getCanvasPointRaw.call(this, event);
	const view = this[viewStateSymbol];
	const x = (raw.x - view.offsetX) / view.scale;
	const y = (raw.y - view.offsetY) / view.scale;

	return {
		x: Math.max(0, Math.min(this[canvasElementSymbol].width, x)),
		y: Math.max(0, Math.min(this[canvasElementSymbol].height, y)),
	};
}

/**
 * @private
 * @param {PointerEvent} event
 * @return {{x:number,y:number}}
 */
function getCanvasPointRaw(event) {
	const canvas = this[canvasElementSymbol];
	const rect = canvas.getBoundingClientRect();
	const scaleX = canvas.width / rect.width;
	const scaleY = canvas.height / rect.height;

	return {
		x: (event.clientX - rect.left) * scaleX,
		y: (event.clientY - rect.top) * scaleY,
	};
}

/**
 * @private
 * @param {string} variable
 * @return {boolean}
 */
function isURL(variable) {
	try {
		new URL(variable);
		return true;
	} catch (error) {
		return false;
	}
}

/**
 * @private
 * @param {Blob} blob
 * @return {Promise<string>}
 */
function blobToDataUrl(blob) {
	return new Promise((resolve, reject) => {
		const reader = new FileReader();
		reader.onloadend = () => resolve(reader.result);
		reader.onerror = reject;
		reader.readAsDataURL(blob);
	});
}

/**
 * @private
 * @returns {{save:string,reset:string,crop:string,select:string,filter:string,intensity:string,zoom:string,rotation:string,empty:string,filterNone:string,filterGrayscale:string,filterSepia:string,filterContrast:string,filterSaturate:string,filterInvert:string,filterBlur:string,cropX:string,cropY:string,cropWidth:string,cropHeight:string,applyFilter:string,rotateLeft:string,rotateRight:string,rotateReset:string}}
 */
function getTranslations() {
	const language = getLocaleOfDocument().language || "en";
	const base = language.split("-")[0];
	const translations = {
		en: {
			save: "Save",
			reset: "Reset",
			crop: "Crop",
			select: "Select",
			filter: "Filter",
			intensity: "Intensity",
			zoom: "Zoom",
			rotation: "Rotation",
			empty: "No image loaded",
			filterNone: "No filter",
			filterGrayscale: "Grayscale",
			filterSepia: "Sepia",
			filterContrast: "Contrast",
			filterSaturate: "Saturate",
			filterInvert: "Invert",
			filterBlur: "Blur",
			cropX: "X",
			cropY: "Y",
			cropWidth: "Width",
			cropHeight: "Height",
			applyFilter: "Apply Filter",
			rotateLeft: "Rotate Left",
			rotateRight: "Rotate Right",
			rotateReset: "Reset Rotation",
		},
		de: {
			save: "Speichern",
			reset: "Zurücksetzen",
			crop: "Zuschneiden",
			select: "Auswahl",
			filter: "Filter",
			intensity: "Intensität",
			zoom: "Zoom",
			rotation: "Drehung",
			empty: "Kein Bild geladen",
			filterNone: "Kein Filter",
			filterGrayscale: "Graustufen",
			filterSepia: "Sepia",
			filterContrast: "Kontrast",
			filterSaturate: "Sättigung",
			filterInvert: "Invertieren",
			filterBlur: "Weichzeichnen",
			cropX: "X",
			cropY: "Y",
			cropWidth: "Breite",
			cropHeight: "Höhe",
			applyFilter: "Filter anwenden",
			rotateLeft: "Links drehen",
			rotateRight: "Rechts drehen",
			rotateReset: "Drehung zurücksetzen",
		},
		es: {
			save: "Guardar",
			reset: "Restablecer",
			crop: "Recortar",
			select: "Selección",
			filter: "Filtro",
			intensity: "Intensidad",
			zoom: "Zoom",
			rotation: "Rotación",
			empty: "No hay imagen cargada",
			filterNone: "Sin filtro",
			filterGrayscale: "Escala de grises",
			filterSepia: "Sepia",
			filterContrast: "Contraste",
			filterSaturate: "Saturación",
			filterInvert: "Invertir",
			filterBlur: "Desenfocar",
			cropX: "X",
			cropY: "Y",
			cropWidth: "Ancho",
			cropHeight: "Alto",
			applyFilter: "Aplicar filtro",
			rotateLeft: "Girar a la izquierda",
			rotateRight: "Girar a la derecha",
			rotateReset: "Restablecer rotación",
		},
		zh: {
			save: "保存",
			reset: "重置",
			crop: "裁剪",
			select: "选择",
			filter: "滤镜",
			intensity: "强度",
			zoom: "缩放",
			rotation: "旋转",
			empty: "未加载图片",
			filterNone: "无滤镜",
			filterGrayscale: "灰度",
			filterSepia: "棕褐色",
			filterContrast: "对比度",
			filterSaturate: "饱和度",
			filterInvert: "反相",
			filterBlur: "模糊",
			cropX: "X",
			cropY: "Y",
			cropWidth: "宽度",
			cropHeight: "高度",
			applyFilter: "应用滤镜",
			rotateLeft: "向左旋转",
			rotateRight: "向右旋转",
			rotateReset: "重置旋转",
		},
		hi: {
			save: "सहेजें",
			reset: "रीसेट",
			crop: "क्रॉप",
			select: "चयन",
			filter: "फ़िल्टर",
			intensity: "तीव्रता",
			zoom: "ज़ूम",
			rotation: "घुमाव",
			empty: "कोई छवि लोड नहीं",
			filterNone: "कोई फ़िल्टर नहीं",
			filterGrayscale: "ग्रे-स्केल",
			filterSepia: "सेपिया",
			filterContrast: "कॉन्ट्रास्ट",
			filterSaturate: "संतृप्ति",
			filterInvert: "इनवर्ट",
			filterBlur: "धुंधला",
			cropX: "X",
			cropY: "Y",
			cropWidth: "चौड़ाई",
			cropHeight: "ऊँचाई",
			applyFilter: "फ़िल्टर लागू करें",
			rotateLeft: "बाएँ घुमाएँ",
			rotateRight: "दाएँ घुमाएँ",
			rotateReset: "घुमाव रीसेट",
		},
		bn: {
			save: "সংরক্ষণ",
			reset: "রিসেট",
			crop: "ক্রপ",
			select: "নির্বাচন",
			filter: "ফিল্টার",
			intensity: "তীব্রতা",
			zoom: "জুম",
			rotation: "ঘূর্ণন",
			empty: "কোনো ছবি লোড নেই",
			filterNone: "কোনো ফিল্টার নেই",
			filterGrayscale: "ধূসর",
			filterSepia: "সেপিয়া",
			filterContrast: "কনট্রাস্ট",
			filterSaturate: "স্যাচুরেশন",
			filterInvert: "ইনভার্ট",
			filterBlur: "ব্লার",
			cropX: "X",
			cropY: "Y",
			cropWidth: "প্রস্থ",
			cropHeight: "উচ্চতা",
			applyFilter: "ফিল্টার প্রয়োগ",
			rotateLeft: "বামে ঘোরান",
			rotateRight: "ডানে ঘোরান",
			rotateReset: "ঘূর্ণন রিসেট",
		},
		pt: {
			save: "Salvar",
			reset: "Repor",
			crop: "Cortar",
			select: "Selecionar",
			filter: "Filtro",
			intensity: "Intensidade",
			zoom: "Zoom",
			rotation: "Rotação",
			empty: "Nenhuma imagem carregada",
			filterNone: "Sem filtro",
			filterGrayscale: "Escala de cinzentos",
			filterSepia: "Sépia",
			filterContrast: "Contraste",
			filterSaturate: "Saturação",
			filterInvert: "Inverter",
			filterBlur: "Desfocar",
			cropX: "X",
			cropY: "Y",
			cropWidth: "Largura",
			cropHeight: "Altura",
			applyFilter: "Aplicar filtro",
			rotateLeft: "Rodar à esquerda",
			rotateRight: "Rodar à direita",
			rotateReset: "Repor rotação",
		},
		ru: {
			save: "Сохранить",
			reset: "Сбросить",
			crop: "Обрезать",
			select: "Выбор",
			filter: "Фильтр",
			intensity: "Интенсивность",
			zoom: "Масштаб",
			rotation: "Поворот",
			empty: "Изображение не загружено",
			filterNone: "Без фильтра",
			filterGrayscale: "Оттенки серого",
			filterSepia: "Сепия",
			filterContrast: "Контраст",
			filterSaturate: "Насыщенность",
			filterInvert: "Инвертировать",
			filterBlur: "Размытие",
			cropX: "X",
			cropY: "Y",
			cropWidth: "Ширина",
			cropHeight: "Высота",
			applyFilter: "Применить фильтр",
			rotateLeft: "Повернуть влево",
			rotateRight: "Повернуть вправо",
			rotateReset: "Сбросить поворот",
		},
		ja: {
			save: "保存",
			reset: "リセット",
			crop: "切り抜き",
			select: "選択",
			filter: "フィルター",
			intensity: "強度",
			zoom: "ズーム",
			rotation: "回転",
			empty: "画像が読み込まれていません",
			filterNone: "フィルターなし",
			filterGrayscale: "グレースケール",
			filterSepia: "セピア",
			filterContrast: "コントラスト",
			filterSaturate: "彩度",
			filterInvert: "反転",
			filterBlur: "ぼかし",
			cropX: "X",
			cropY: "Y",
			cropWidth: "幅",
			cropHeight: "高さ",
			applyFilter: "フィルターを適用",
			rotateLeft: "左に回転",
			rotateRight: "右に回転",
			rotateReset: "回転をリセット",
		},
		pa: {
			save: "ਸੇਵ ਕਰੋ",
			reset: "ਰੀਸੈਟ",
			crop: "ਕ੍ਰੌਪ",
			select: "ਚੋਣ",
			filter: "ਫਿਲਟਰ",
			intensity: "ਤੀਬਰਤਾ",
			zoom: "ਜ਼ੂਮ",
			rotation: "ਘੁੰਮਾਉ",
			empty: "ਕੋਈ ਚਿੱਤਰ ਲੋਡ ਨਹੀਂ",
			filterNone: "ਕੋਈ ਫਿਲਟਰ ਨਹੀਂ",
			filterGrayscale: "ਸਲੇਟੀ ਪੱਧਰ",
			filterSepia: "ਸੈਪੀਆ",
			filterContrast: "ਕਾਂਟਰਾਸਟ",
			filterSaturate: "ਸੈਚੂਰੇਸ਼ਨ",
			filterInvert: "ਉਲਟੋ",
			filterBlur: "ਧੁੰਦਲਾ",
			cropX: "X",
			cropY: "Y",
			cropWidth: "ਚੌੜਾਈ",
			cropHeight: "ਉਚਾਈ",
			applyFilter: "ਫਿਲਟਰ ਲਾਗੂ ਕਰੋ",
			rotateLeft: "ਖੱਬੇ ਘੁੰਮਾਓ",
			rotateRight: "ਸੱਜੇ ਘੁੰਮਾਓ",
			rotateReset: "ਘੁੰਮਾਉ ਰੀਸੈਟ",
		},
		mr: {
			save: "जतन करा",
			reset: "रीसेट",
			crop: "क्रॉप",
			select: "निवडा",
			filter: "फिल्टर",
			intensity: "तीव्रता",
			zoom: "झूम",
			rotation: "फिरवणे",
			empty: "प्रतिमा लोड नाही",
			filterNone: "कोणताही फिल्टर नाही",
			filterGrayscale: "करड्या छटा",
			filterSepia: "सेपिया",
			filterContrast: "काँट्रास्ट",
			filterSaturate: "सॅच्युरेशन",
			filterInvert: "उलट",
			filterBlur: "धूसर",
			cropX: "X",
			cropY: "Y",
			cropWidth: "रुंदी",
			cropHeight: "उंची",
			applyFilter: "फिल्टर लागू करा",
			rotateLeft: "डावीकडे फिरवा",
			rotateRight: "उजवीकडे फिरवा",
			rotateReset: "फिरवणे रीसेट",
		},
		fr: {
			save: "Enregistrer",
			reset: "Réinitialiser",
			crop: "Rogner",
			select: "Sélection",
			filter: "Filtre",
			intensity: "Intensité",
			zoom: "Zoom",
			rotation: "Rotation",
			empty: "Aucune image chargée",
			filterNone: "Sans filtre",
			filterGrayscale: "Niveaux de gris",
			filterSepia: "Sépia",
			filterContrast: "Contraste",
			filterSaturate: "Saturation",
			filterInvert: "Inverser",
			filterBlur: "Flou",
			cropX: "X",
			cropY: "Y",
			cropWidth: "Largeur",
			cropHeight: "Hauteur",
			applyFilter: "Appliquer le filtre",
			rotateLeft: "Tourner à gauche",
			rotateRight: "Tourner à droite",
			rotateReset: "Réinitialiser la rotation",
		},
		it: {
			save: "Salva",
			reset: "Reimposta",
			crop: "Ritaglia",
			select: "Selezione",
			filter: "Filtro",
			intensity: "Intensità",
			zoom: "Zoom",
			rotation: "Rotazione",
			empty: "Nessuna immagine caricata",
			filterNone: "Nessun filtro",
			filterGrayscale: "Scala di grigi",
			filterSepia: "Seppia",
			filterContrast: "Contrasto",
			filterSaturate: "Saturazione",
			filterInvert: "Inverti",
			filterBlur: "Sfocatura",
			cropX: "X",
			cropY: "Y",
			cropWidth: "Larghezza",
			cropHeight: "Altezza",
			applyFilter: "Applica filtro",
			rotateLeft: "Ruota a sinistra",
			rotateRight: "Ruota a destra",
			rotateReset: "Reimposta rotazione",
		},
		nl: {
			save: "Opslaan",
			reset: "Resetten",
			crop: "Bijsnijden",
			select: "Selectie",
			filter: "Filter",
			intensity: "Intensiteit",
			zoom: "Zoom",
			rotation: "Rotatie",
			empty: "Geen afbeelding geladen",
			filterNone: "Geen filter",
			filterGrayscale: "Grijswaarden",
			filterSepia: "Sepia",
			filterContrast: "Contrast",
			filterSaturate: "Verzadiging",
			filterInvert: "Inverteren",
			filterBlur: "Vervagen",
			cropX: "X",
			cropY: "Y",
			cropWidth: "Breedte",
			cropHeight: "Hoogte",
			applyFilter: "Filter toepassen",
			rotateLeft: "Draai links",
			rotateRight: "Draai rechts",
			rotateReset: "Rotatie resetten",
		},
		sv: {
			save: "Spara",
			reset: "Återställ",
			crop: "Beskär",
			select: "Val",
			filter: "Filter",
			intensity: "Intensitet",
			zoom: "Zoom",
			rotation: "Rotation",
			empty: "Ingen bild laddad",
			filterNone: "Inget filter",
			filterGrayscale: "Gråskala",
			filterSepia: "Sepia",
			filterContrast: "Kontrast",
			filterSaturate: "Mättnad",
			filterInvert: "Invertera",
			filterBlur: "Oskärpa",
			cropX: "X",
			cropY: "Y",
			cropWidth: "Bredd",
			cropHeight: "Höjd",
			applyFilter: "Applicera filter",
			rotateLeft: "Rotera vänster",
			rotateRight: "Rotera höger",
			rotateReset: "Återställ rotation",
		},
		pl: {
			save: "Zapisz",
			reset: "Resetuj",
			crop: "Przytnij",
			select: "Zaznacz",
			filter: "Filtr",
			intensity: "Intensywność",
			zoom: "Zoom",
			rotation: "Obrót",
			empty: "Brak załadowanego obrazu",
			filterNone: "Brak filtra",
			filterGrayscale: "Skala szarości",
			filterSepia: "Sepia",
			filterContrast: "Kontrast",
			filterSaturate: "Nasycenie",
			filterInvert: "Odwróć",
			filterBlur: "Rozmycie",
			cropX: "X",
			cropY: "Y",
			cropWidth: "Szerokość",
			cropHeight: "Wysokość",
			applyFilter: "Zastosuj filtr",
			rotateLeft: "Obróć w lewo",
			rotateRight: "Obróć w prawo",
			rotateReset: "Resetuj obrót",
		},
		da: {
			save: "Gem",
			reset: "Nulstil",
			crop: "Beskær",
			select: "Vælg",
			filter: "Filter",
			intensity: "Intensitet",
			zoom: "Zoom",
			rotation: "Rotation",
			empty: "Intet billede indlæst",
			filterNone: "Ingen filter",
			filterGrayscale: "Gråtoner",
			filterSepia: "Sepia",
			filterContrast: "Kontrast",
			filterSaturate: "Mætning",
			filterInvert: "Inverter",
			filterBlur: "Sløring",
			cropX: "X",
			cropY: "Y",
			cropWidth: "Bredde",
			cropHeight: "Højde",
			applyFilter: "Anvend filter",
			rotateLeft: "Drej venstre",
			rotateRight: "Drej højre",
			rotateReset: "Nulstil rotation",
		},
		fi: {
			save: "Tallenna",
			reset: "Palauta",
			crop: "Rajaa",
			select: "Valitse",
			filter: "Suodin",
			intensity: "Voimakkuus",
			zoom: "Zoom",
			rotation: "Kierto",
			empty: "Kuvaa ei ladattu",
			filterNone: "Ei suodatinta",
			filterGrayscale: "Harmaasävy",
			filterSepia: "Sepia",
			filterContrast: "Kontrasti",
			filterSaturate: "Kylläisyys",
			filterInvert: "Käännä",
			filterBlur: "Sumennus",
			cropX: "X",
			cropY: "Y",
			cropWidth: "Leveys",
			cropHeight: "Korkeus",
			applyFilter: "Käytä suodinta",
			rotateLeft: "Kierrä vasemmalle",
			rotateRight: "Kierrä oikealle",
			rotateReset: "Palauta kierto",
		},
		no: {
			save: "Lagre",
			reset: "Tilbakestill",
			crop: "Beskjær",
			select: "Velg",
			filter: "Filter",
			intensity: "Intensitet",
			zoom: "Zoom",
			rotation: "Rotasjon",
			empty: "Ingen bilde lastet",
			filterNone: "Ingen filter",
			filterGrayscale: "Gråskala",
			filterSepia: "Sepia",
			filterContrast: "Kontrast",
			filterSaturate: "Metning",
			filterInvert: "Inverter",
			filterBlur: "Uskarp",
			cropX: "X",
			cropY: "Y",
			cropWidth: "Bredde",
			cropHeight: "Høyde",
			applyFilter: "Bruk filter",
			rotateLeft: "Roter til venstre",
			rotateRight: "Roter til høyre",
			rotateReset: "Tilbakestill rotasjon",
		},
		cs: {
			save: "Uložit",
			reset: "Resetovat",
			crop: "Oříznout",
			select: "Výběr",
			filter: "Filtr",
			intensity: "Intenzita",
			zoom: "Zoom",
			rotation: "Rotace",
			empty: "Žádný obrázek není načten",
			filterNone: "Bez filtru",
			filterGrayscale: "Stupně šedi",
			filterSepia: "Sepia",
			filterContrast: "Kontrast",
			filterSaturate: "Sytost",
			filterInvert: "Invertovat",
			filterBlur: "Rozmazání",
			cropX: "X",
			cropY: "Y",
			cropWidth: "Šířka",
			cropHeight: "Výška",
			applyFilter: "Použít filtr",
			rotateLeft: "Otočit doleva",
			rotateRight: "Otočit doprava",
			rotateReset: "Resetovat rotaci",
		},
	};

	return translations[base] || translations.en;
}

/**
 * @private
 * @return {string}
 */
function getTemplate() {
	// language=HTML
	return `
        <style>
            :host {
                box-sizing: border-box;
                display: block;
                height: 100%;
                min-height: 320px;
            }
            [data-monster-role="control"] {
                box-sizing: border-box;
                display: flex;
                flex-direction: column;
                height: 100%;
                min-height: 0;
            }
            [data-monster-role="splitPanel"] {
                flex: 1;
                min-height: 0;
            }
            [data-monster-role="startContent"],
            [data-monster-role="endContent"] {
                min-height: 0;
            }
            [data-monster-role="endContent"] {
                display: flex;
                flex-direction: column;
            }
            [data-monster-role="stage"] {
                flex: 1;
                min-height: 0;
            }
            [data-monster-role="startContent"]::-webkit-scrollbar,
            [data-monster-role="endContent"]::-webkit-scrollbar {
                width: 6px;
                height: 6px;
            }
            [data-monster-role="startContent"]::-webkit-scrollbar-thumb,
            [data-monster-role="endContent"]::-webkit-scrollbar-thumb {
                background: var(--monster-color-gray-4);
            }
        </style>
        <div data-monster-role="control" part="control">
            <monster-state data-monster-role="emptyState">
                <p class="empty-text"
                   data-monster-replace="path:labels.empty | default:No image loaded:string"></p>
            </monster-state>

            <monster-split-panel data-monster-role="splitPanel"
                                 data-monster-options='{"splitType":"vertical","dimension":{"initial":"40%","min":"20%","max":"60%"}}'>
                <div slot="start" data-monster-role="startContent" class="monster-padding-3">
                    <monster-button-bar class="monster-margin-bottom-3" data-monster-role="topActions">
                        <monster-message-state-button part="resetButton" data-monster-role="reset"
                                                      data-monster-replace="path:labels.reset"></monster-message-state-button>
                        <monster-message-state-button part="saveButton" data-monster-role="save"
                                                      data-monster-replace="path:labels.save"></monster-message-state-button>
                        <slot name="actions"></slot>
                    </monster-button-bar>

                    <monster-field-set class="monster-margin-bottom-3" data-monster-role="viewSection"
                                       data-monster-options='{"labels":{"title":"View"},"features":{"multipleColumns":false}}'>
                        <label>
                            <span data-monster-replace="path:labels.zoom"></span>
                            <input type="range" min="25" max="400" value="100" data-monster-role="zoomInput" />
                        </label>
                        <label>
                            <span data-monster-replace="path:labels.rotation"></span>
                            <input type="range" min="0" max="359" step="1" value="0" data-monster-role="rotationRange" />
                        </label>
                        <label class="monster-margin-top-2">
                            <span data-monster-replace="path:labels.rotation"></span>
                            <input type="number" min="0" max="359" step="1" value="0" data-monster-role="rotationInput" />
                        </label>
                        <monster-button-bar class="monster-margin-top-3">
                            <monster-message-state-button part="rotateLeftButton" data-monster-role="rotateLeft"
                                                          data-monster-replace="path:labels.rotateLeft"></monster-message-state-button>
                            <monster-message-state-button part="rotateRightButton" data-monster-role="rotateRight"
                                                          data-monster-replace="path:labels.rotateRight"></monster-message-state-button>
                            <monster-message-state-button part="rotateResetButton" data-monster-role="rotateReset"
                                                          data-monster-replace="path:labels.rotateReset"></monster-message-state-button>
                        </monster-button-bar>
                    </monster-field-set>

                    <monster-field-set class="monster-margin-bottom-3" data-monster-role="selectionSection"
                                       data-monster-options='{"labels":{"title":"Selection"},"features":{"multipleColumns":false}}'>
                        <monster-button-bar class="monster-margin-bottom-2">
                            <monster-message-state-button part="selectButton" data-monster-role="select"
                                                          data-monster-replace="path:labels.select"></monster-message-state-button>
                            <monster-message-state-button part="cropButton" data-monster-role="applyCrop"
                                                          data-monster-replace="path:labels.crop"></monster-message-state-button>
                        </monster-button-bar>
                        <div class="monster-margin-top-2" data-monster-role="cropInputs">
                            <label>
                                <span data-monster-replace="path:labels.cropX"></span>
                                <input type="number" min="0" step="1" data-monster-role="cropInputX" />
                            </label>
                            <label>
                                <span data-monster-replace="path:labels.cropY"></span>
                                <input type="number" min="0" step="1" data-monster-role="cropInputY" />
                            </label>
                            <label>
                                <span data-monster-replace="path:labels.cropWidth"></span>
                                <input type="number" min="1" step="1" data-monster-role="cropInputWidth" />
                            </label>
                            <label>
                                <span data-monster-replace="path:labels.cropHeight"></span>
                                <input type="number" min="1" step="1" data-monster-role="cropInputHeight" />
                            </label>
                        </div>
                    </monster-field-set>

                    <monster-field-set class="monster-margin-bottom-3" data-monster-role="filterSection"
                                       data-monster-options='{"labels":{"title":"Filter"},"features":{"multipleColumns":false}}'>
                        <label>
                            <span data-monster-replace="path:labels.filter"></span>
                            <select data-monster-role="filterSelect">
                                <option value="none" data-monster-replace="path:labels.filterNone"></option>
                                <option value="grayscale" data-monster-replace="path:labels.filterGrayscale"></option>
                                <option value="sepia" data-monster-replace="path:labels.filterSepia"></option>
                                <option value="contrast" data-monster-replace="path:labels.filterContrast"></option>
                                <option value="saturate" data-monster-replace="path:labels.filterSaturate"></option>
                                <option value="invert" data-monster-replace="path:labels.filterInvert"></option>
                                <option value="blur" data-monster-replace="path:labels.filterBlur"></option>
                            </select>
                        </label>
                        <label>
                            <span data-monster-replace="path:labels.intensity"></span>
                            <input type="range" min="0" max="100" value="100" data-monster-role="filterIntensity" />
                        </label>
                        <monster-button-bar class="monster-margin-top-2">
                            <monster-message-state-button part="applyFilterButton" data-monster-role="applyFilter"
                                                          data-monster-replace="path:labels.applyFilter"></monster-message-state-button>
                        </monster-button-bar>
                    </monster-field-set>
                </div>
                <div slot="end" data-monster-role="endContent" class="monster-padding-3">
                    <div data-monster-role="stage">
                        <div data-monster-role="surface">
                            <canvas data-monster-role="canvas"></canvas>
                            <canvas data-monster-role="overlay"></canvas>
                        </div>
                    </div>
                </div>
            </monster-split-panel>
        </div>`;
}

registerCustomElement(ImageEditor);
