@schukai/monster
Version:
Monster is a simple library for creating fast, robust and lightweight websites.
2,060 lines (1,785 loc) • 85.6 kB
JavaScript
/**
* 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/
*
* @since 4.68.0
* @copyright Volker Schukai
* @summary An image editor for cropping and basic filters.
* @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 u