UNPKG

@schukai/monster

Version:

Monster is a simple library for creating fast, robust and lightweight websites.

2,060 lines (1,785 loc) 85.6 kB
/** * 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