UNPKG

@ckeditor/ckeditor5-image

Version:

Image feature for CKEditor 5.

5,638 lines 227 kB
/**
 * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
 * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
 */
import { Plugin, Command, icons } from '@ckeditor/ckeditor5-core/dist/index.js';
import { Clipboard, ClipboardPipeline } from '@ckeditor/ckeditor5-clipboard/dist/index.js';
import { LivePosition, LiveRange, Observer, UpcastWriter, enablePlaceholder, Element } from '@ckeditor/ckeditor5-engine/dist/index.js';
import { Undo } from '@ckeditor/ckeditor5-undo/dist/index.js';
import { Delete } from '@ckeditor/ckeditor5-typing/dist/index.js';
import { first, global, DomEmitterMixin, FocusTracker, KeystrokeHandler, toArray, logWarning, env, CKEditorError, Collection, Rect } from '@ckeditor/ckeditor5-utils/dist/index.js';
import { toWidget, isWidget, findOptimalInsertionRange, Widget, toWidgetEditable, WidgetResize, calculateResizeHostAncestorWidth, WidgetToolbarRepository } from '@ckeditor/ckeditor5-widget/dist/index.js';
import { View, submitHandler, ButtonView, LabeledFieldView, createLabeledInputText, ViewCollection, FocusCycler, BalloonPanelView, ContextualBalloon, CssTransitionDisablerMixin, clickOutsideHandler, CollapsibleView, SplitButtonView, createDropdown, MenuBarMenuListItemFileDialogButtonView, FileDialogButtonView, Notification, DropdownButtonView, ViewModel, addListToDropdown, createLabeledInputNumber, addToolbarToDropdown } from '@ckeditor/ckeditor5-ui/dist/index.js';
import { FileRepository } from '@ckeditor/ckeditor5-upload/dist/index.js';
import { map, isObject, identity } from 'lodash-es';

/**
 * Creates a view element representing the inline image.
 *
 * ```html
 * <span class="image-inline"><img></img></span>
 * ```
 *
 * Note that `alt` and `src` attributes are converted separately, so they are not included.
 *
 * @internal
 */ function createInlineImageViewElement(writer) {
    return writer.createContainerElement('span', {
        class: 'image-inline'
    }, writer.createEmptyElement('img'));
}
/**
 * Creates a view element representing the block image.
 *
 * ```html
 * <figure class="image"><img></img></figure>
 * ```
 *
 * Note that `alt` and `src` attributes are converted separately, so they are not included.
 *
 * @internal
 */ function createBlockImageViewElement(writer) {
    return writer.createContainerElement('figure', {
        class: 'image'
    }, [
        writer.createEmptyElement('img'),
        writer.createSlot('children')
    ]);
}
/**
 * A function returning a `MatcherPattern` for a particular type of View images.
 *
 * @internal
 * @param matchImageType The type of created image.
 */ function getImgViewElementMatcher(editor, matchImageType) {
    const imageUtils = editor.plugins.get('ImageUtils');
    const areBothImagePluginsLoaded = editor.plugins.has('ImageInlineEditing') && editor.plugins.has('ImageBlockEditing');
    return (element)=>{
        // Check if the matched view element is an <img>.
        if (!imageUtils.isInlineImageView(element)) {
            return null;
        }
        // If just one of the plugins is loaded (block or inline), it will match all kinds of images.
        if (!areBothImagePluginsLoaded) {
            return getPositiveMatchPattern(element);
        }
        // The <img> can be standalone, wrapped in <figure>...</figure> (ImageBlock plugin) or
        // wrapped in <figure><a>...</a></figure> (LinkImage plugin).
        const imageType = element.getStyle('display') == 'block' || element.findAncestor(imageUtils.isBlockImageView) ? 'imageBlock' : 'imageInline';
        if (imageType !== matchImageType) {
            return null;
        }
        return getPositiveMatchPattern(element);
    };
    function getPositiveMatchPattern(element) {
        const pattern = {
            name: true
        };
        // This will trigger src consumption (See https://github.com/ckeditor/ckeditor5/issues/11530).
        if (element.hasAttribute('src')) {
            pattern.attributes = [
                'src'
            ];
        }
        return pattern;
    }
}
/**
 * Considering the current model selection, it returns the name of the model image element
 * (`'imageBlock'` or `'imageInline'`) that will make most sense from the UX perspective if a new
 * image was inserted (also: uploaded, dropped, pasted) at that selection.
 *
 * The assumption is that inserting images into empty blocks or on other block widgets should
 * produce block images. Inline images should be inserted in other cases, e.g. in paragraphs
 * that already contain some text.
 *
 * @internal
 */ function determineImageTypeForInsertionAtSelection(schema, selection) {
    const firstBlock = first(selection.getSelectedBlocks());
    // Insert a block image if the selection is not in/on block elements or it's on a block widget.
    if (!firstBlock || schema.isObject(firstBlock)) {
        return 'imageBlock';
    }
    // A block image should also be inserted into an empty block element
    // (that is not an empty list item so the list won't get split).
    if (firstBlock.isEmpty && firstBlock.name != 'listItem') {
        return 'imageBlock';
    }
    // Otherwise insert an inline image.
    return 'imageInline';
}
/**
 * Returns parsed value of the size, but only if it contains unit: px.
 */ function getSizeValueIfInPx(size) {
    if (size && size.endsWith('px')) {
        return parseInt(size);
    }
    return null;
}
/**
 * Returns true if both styles (width and height) are set.
 *
 * If both image styles: width & height are set, they will override the image width & height attributes in the
 * browser. In this case, the image looks the same as if these styles were applied to attributes instead of styles.
 * That's why we can upcast these styles to width & height attributes instead of resizedWidth and resizedHeight.
 */ function widthAndHeightStylesAreBothSet(viewElement) {
    const widthStyle = getSizeValueIfInPx(viewElement.getStyle('width'));
    const heightStyle = getSizeValueIfInPx(viewElement.getStyle('height'));
    return !!(widthStyle && heightStyle);
}

const IMAGE_WIDGETS_CLASSES_MATCH_REGEXP = /^(image|image-inline)$/;
class ImageUtils extends Plugin {
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'ImageUtils';
    }
    /**
     * Checks if the provided model element is an `image` or `imageInline`.
     */ isImage(modelElement) {
        return this.isInlineImage(modelElement) || this.isBlockImage(modelElement);
    }
    /**
     * Checks if the provided view element represents an inline image.
     *
     * Also, see {@link module:image/imageutils~ImageUtils#isImageWidget}.
     */ isInlineImageView(element) {
        return !!element && element.is('element', 'img');
    }
    /**
     * Checks if the provided view element represents a block image.
     *
     * Also, see {@link module:image/imageutils~ImageUtils#isImageWidget}.
     */ isBlockImageView(element) {
        return !!element && element.is('element', 'figure') && element.hasClass('image');
    }
    /**
     * Handles inserting single file. This method unifies image insertion using {@link module:widget/utils~findOptimalInsertionRange}
     * method.
     *
     * ```ts
     * const imageUtils = editor.plugins.get( 'ImageUtils' );
     *
     * imageUtils.insertImage( { src: 'path/to/image.jpg' } );
     * ```
     *
     * @param attributes Attributes of the inserted image.
     * This method filters out the attributes which are disallowed by the {@link module:engine/model/schema~Schema}.
     * @param selectable Place to insert the image. If not specified,
     * the {@link module:widget/utils~findOptimalInsertionRange} logic will be applied for the block images
     * and `model.document.selection` for the inline images.
     *
     * **Note**: If `selectable` is passed, this helper will not be able to set selection attributes (such as `linkHref`)
     * and apply them to the new image. In this case, make sure all selection attributes are passed in `attributes`.
     *
     * @param imageType Image type of inserted image. If not specified,
     * it will be determined automatically depending of editor config or place of the insertion.
     * @param options.setImageSizes Specifies whether the image `width` and `height` attributes should be set automatically.
     * The default is `true`.
     * @return The inserted model image element.
     */ insertImage(attributes = {}, selectable = null, imageType = null, options = {}) {
        const editor = this.editor;
        const model = editor.model;
        const selection = model.document.selection;
        const determinedImageType = determineImageTypeForInsertion(editor, selectable || selection, imageType);
        // Mix declarative attributes with selection attributes because the new image should "inherit"
        // the latter for best UX. For instance, inline images inserted into existing links
        // should not split them. To do that, they need to have "linkHref" inherited from the selection.
        attributes = {
            ...Object.fromEntries(selection.getAttributes()),
            ...attributes
        };
        for(const attributeName in attributes){
            if (!model.schema.checkAttribute(determinedImageType, attributeName)) {
                delete attributes[attributeName];
            }
        }
        return model.change((writer)=>{
            const { setImageSizes = true } = options;
            const imageElement = writer.createElement(determinedImageType, attributes);
            model.insertObject(imageElement, selectable, null, {
                setSelection: 'on',
                // If we want to insert a block image (for whatever reason) then we don't want to split text blocks.
                // This applies only when we don't have the selectable specified (i.e., we insert multiple block images at once).
                findOptimalPosition: !selectable && determinedImageType != 'imageInline' ? 'auto' : undefined
            });
            // Inserting an image might've failed due to schema regulations.
            if (imageElement.parent) {
                if (setImageSizes) {
                    this.setImageNaturalSizeAttributes(imageElement);
                }
                return imageElement;
            }
            return null;
        });
    }
    /**
     * Reads original image sizes and sets them as `width` and `height`.
     *
     * The `src` attribute may not be available if the user is using an upload adapter. In such a case,
     * this method is called again after the upload process is complete and the `src` attribute is available.
     */ setImageNaturalSizeAttributes(imageElement) {
        const src = imageElement.getAttribute('src');
        if (!src) {
            return;
        }
        if (imageElement.getAttribute('width') || imageElement.getAttribute('height')) {
            return;
        }
        this.editor.model.change((writer)=>{
            const img = new global.window.Image();
            this._domEmitter.listenTo(img, 'load', ()=>{
                if (!imageElement.getAttribute('width') && !imageElement.getAttribute('height')) {
                    // We use writer.batch to be able to undo (in a single step) width and height setting
                    // along with any change that triggered this action (e.g. image resize or image style change).
                    this.editor.model.enqueueChange(writer.batch, (writer)=>{
                        writer.setAttribute('width', img.naturalWidth, imageElement);
                        writer.setAttribute('height', img.naturalHeight, imageElement);
                    });
                }
                this._domEmitter.stopListening(img, 'load');
            });
            img.src = src;
        });
    }
    /**
     * Returns an image widget editing view element if one is selected or is among the selection's ancestors.
     */ getClosestSelectedImageWidget(selection) {
        const selectionPosition = selection.getFirstPosition();
        if (!selectionPosition) {
            return null;
        }
        const viewElement = selection.getSelectedElement();
        if (viewElement && this.isImageWidget(viewElement)) {
            return viewElement;
        }
        let parent = selectionPosition.parent;
        while(parent){
            if (parent.is('element') && this.isImageWidget(parent)) {
                return parent;
            }
            parent = parent.parent;
        }
        return null;
    }
    /**
     * Returns a image model element if one is selected or is among the selection's ancestors.
     */ getClosestSelectedImageElement(selection) {
        const selectedElement = selection.getSelectedElement();
        return this.isImage(selectedElement) ? selectedElement : selection.getFirstPosition().findAncestor('imageBlock');
    }
    /**
     * Returns an image widget editing view based on the passed image view.
     */ getImageWidgetFromImageView(imageView) {
        return imageView.findAncestor({
            classes: IMAGE_WIDGETS_CLASSES_MATCH_REGEXP
        });
    }
    /**
     * Checks if image can be inserted at current model selection.
     *
     * @internal
     */ isImageAllowed() {
        const model = this.editor.model;
        const selection = model.document.selection;
        return isImageAllowedInParent(this.editor, selection) && isNotInsideImage(selection);
    }
    /**
     * Converts a given {@link module:engine/view/element~Element} to an image widget:
     * * Adds a {@link module:engine/view/element~Element#_setCustomProperty custom property} allowing to recognize the image widget
     * element.
     * * Calls the {@link module:widget/utils~toWidget} function with the proper element's label creator.
     *
     * @param writer An instance of the view writer.
     * @param label The element's label. It will be concatenated with the image `alt` attribute if one is present.
     */ toImageWidget(viewElement, writer, label) {
        writer.setCustomProperty('image', true, viewElement);
        const labelCreator = ()=>{
            const imgElement = this.findViewImgElement(viewElement);
            const altText = imgElement.getAttribute('alt');
            return altText ? `${altText} ${label}` : label;
        };
        return toWidget(viewElement, writer, {
            label: labelCreator
        });
    }
    /**
     * Checks if a given view element is an image widget.
     */ isImageWidget(viewElement) {
        return !!viewElement.getCustomProperty('image') && isWidget(viewElement);
    }
    /**
     * Checks if the provided model element is an `image`.
     */ isBlockImage(modelElement) {
        return !!modelElement && modelElement.is('element', 'imageBlock');
    }
    /**
     * Checks if the provided model element is an `imageInline`.
     */ isInlineImage(modelElement) {
        return !!modelElement && modelElement.is('element', 'imageInline');
    }
    /**
     * Get the view `<img>` from another view element, e.g. a widget (`<figure class="image">`), a link (`<a>`).
     *
     * The `<img>` can be located deep in other elements, so this helper performs a deep tree search.
     */ findViewImgElement(figureView) {
        if (this.isInlineImageView(figureView)) {
            return figureView;
        }
        const editingView = this.editor.editing.view;
        for (const { item } of editingView.createRangeIn(figureView)){
            if (this.isInlineImageView(item)) {
                return item;
            }
        }
    }
    /**
     * @inheritDoc
     */ destroy() {
        this._domEmitter.stopListening();
        return super.destroy();
    }
    constructor(){
        super(...arguments);
        /**
         * DOM Emitter.
         */ this._domEmitter = new (DomEmitterMixin())();
    }
}
/**
 * Checks if image is allowed by schema in optimal insertion parent.
 */ function isImageAllowedInParent(editor, selection) {
    const imageType = determineImageTypeForInsertion(editor, selection, null);
    if (imageType == 'imageBlock') {
        const parent = getInsertImageParent(selection, editor.model);
        if (editor.model.schema.checkChild(parent, 'imageBlock')) {
            return true;
        }
    } else if (editor.model.schema.checkChild(selection.focus, 'imageInline')) {
        return true;
    }
    return false;
}
/**
 * Checks if selection is not placed inside an image (e.g. its caption).
 */ function isNotInsideImage(selection) {
    return [
        ...selection.focus.getAncestors()
    ].every((ancestor)=>!ancestor.is('element', 'imageBlock'));
}
/**
 * Returns a node that will be used to insert image with `model.insertContent`.
 */ function getInsertImageParent(selection, model) {
    const insertionRange = findOptimalInsertionRange(selection, model);
    const parent = insertionRange.start.parent;
    if (parent.isEmpty && !parent.is('element', '$root')) {
        return parent.parent;
    }
    return parent;
}
/**
 * Determine image element type name depending on editor config or place of insertion.
 *
 * @param imageType Image element type name. Used to force return of provided element name,
 * but only if there is proper plugin enabled.
 */ function determineImageTypeForInsertion(editor, selectable, imageType) {
    const schema = editor.model.schema;
    const configImageInsertType = editor.config.get('image.insert.type');
    if (!editor.plugins.has('ImageBlockEditing')) {
        return 'imageInline';
    }
    if (!editor.plugins.has('ImageInlineEditing')) {
        return 'imageBlock';
    }
    if (imageType) {
        return imageType;
    }
    if (configImageInsertType === 'inline') {
        return 'imageInline';
    }
    if (configImageInsertType !== 'auto') {
        return 'imageBlock';
    }
    // Try to replace the selected widget (e.g. another image).
    if (selectable.is('selection')) {
        return determineImageTypeForInsertionAtSelection(schema, selectable);
    }
    return schema.checkChild(selectable, 'imageInline') ? 'imageInline' : 'imageBlock';
}

// Implements the pattern: http(s)://(www.)example.com/path/to/resource.ext?query=params&maybe=too.
const IMAGE_URL_REGEXP = new RegExp(String(/^(http(s)?:\/\/)?[\w-]+\.[\w.~:/[\]@!$&'()*+,;=%-]+/.source + /\.(jpg|jpeg|png|gif|ico|webp|JPG|JPEG|PNG|GIF|ICO|WEBP)/.source + /(\?[\w.~:/[\]@!$&'()*+,;=%-]*)?/.source + /(#[\w.~:/[\]@!$&'()*+,;=%-]*)?$/.source));
class AutoImage extends Plugin {
    /**
     * @inheritDoc
     */ static get requires() {
        return [
            Clipboard,
            ImageUtils,
            Undo,
            Delete
        ];
    }
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'AutoImage';
    }
    /**
     * @inheritDoc
     */ init() {
        const editor = this.editor;
        const modelDocument = editor.model.document;
        const clipboardPipeline = editor.plugins.get('ClipboardPipeline');
        // We need to listen on `Clipboard#inputTransformation` because we need to save positions of selection.
        // After pasting, the content between those positions will be checked for a URL that could be transformed
        // into an image.
        this.listenTo(clipboardPipeline, 'inputTransformation', ()=>{
            const firstRange = modelDocument.selection.getFirstRange();
            const leftLivePosition = LivePosition.fromPosition(firstRange.start);
            leftLivePosition.stickiness = 'toPrevious';
            const rightLivePosition = LivePosition.fromPosition(firstRange.end);
            rightLivePosition.stickiness = 'toNext';
            modelDocument.once('change:data', ()=>{
                this._embedImageBetweenPositions(leftLivePosition, rightLivePosition);
                leftLivePosition.detach();
                rightLivePosition.detach();
            }, {
                priority: 'high'
            });
        });
        editor.commands.get('undo').on('execute', ()=>{
            if (this._timeoutId) {
                global.window.clearTimeout(this._timeoutId);
                this._positionToInsert.detach();
                this._timeoutId = null;
                this._positionToInsert = null;
            }
        }, {
            priority: 'high'
        });
    }
    /**
     * Analyzes the part of the document between provided positions in search for a URL representing an image.
     * When the URL is found, it is automatically converted into an image.
     *
     * @param leftPosition Left position of the selection.
     * @param rightPosition Right position of the selection.
     */ _embedImageBetweenPositions(leftPosition, rightPosition) {
        const editor = this.editor;
        // TODO: Use a marker instead of LiveRange & LivePositions.
        const urlRange = new LiveRange(leftPosition, rightPosition);
        const walker = urlRange.getWalker({
            ignoreElementEnd: true
        });
        const selectionAttributes = Object.fromEntries(editor.model.document.selection.getAttributes());
        const imageUtils = this.editor.plugins.get('ImageUtils');
        let src = '';
        for (const node of walker){
            if (node.item.is('$textProxy')) {
                src += node.item.data;
            }
        }
        src = src.trim();
        // If the URL does not match the image URL regexp, let's skip that.
        if (!src.match(IMAGE_URL_REGEXP)) {
            urlRange.detach();
            return;
        }
        // Position will not be available in the `setTimeout` function so let's clone it.
        this._positionToInsert = LivePosition.fromPosition(leftPosition);
        // This action mustn't be executed if undo was called between pasting and auto-embedding.
        this._timeoutId = setTimeout(()=>{
            // Do nothing if image element cannot be inserted at the current position.
            // See https://github.com/ckeditor/ckeditor5/issues/2763.
            // Condition must be checked after timeout - pasting may take place on an element, replacing it. The final position matters.
            const imageCommand = editor.commands.get('insertImage');
            if (!imageCommand.isEnabled) {
                urlRange.detach();
                return;
            }
            editor.model.change((writer)=>{
                this._timeoutId = null;
                writer.remove(urlRange);
                urlRange.detach();
                let insertionPosition;
                // Check if the position where the element should be inserted is still valid.
                // Otherwise leave it as undefined to use the logic of insertImage().
                if (this._positionToInsert.root.rootName !== '$graveyard') {
                    insertionPosition = this._positionToInsert.toPosition();
                }
                imageUtils.insertImage({
                    ...selectionAttributes,
                    src
                }, insertionPosition);
                this._positionToInsert.detach();
                this._positionToInsert = null;
            });
            const deletePlugin = editor.plugins.get('Delete');
            deletePlugin.requestUndoOnBackspace();
        }, 100);
    }
    /**
     * @inheritDoc
     */ constructor(editor){
        super(editor);
        this._timeoutId = null;
        this._positionToInsert = null;
    }
}

class ImageTextAlternativeCommand extends Command {
    /**
     * @inheritDoc
     */ refresh() {
        const editor = this.editor;
        const imageUtils = editor.plugins.get('ImageUtils');
        const element = imageUtils.getClosestSelectedImageElement(this.editor.model.document.selection);
        this.isEnabled = !!element;
        if (this.isEnabled && element.hasAttribute('alt')) {
            this.value = element.getAttribute('alt');
        } else {
            this.value = false;
        }
    }
    /**
     * Executes the command.
     *
     * @fires execute
     * @param options
     * @param options.newValue The new value of the `alt` attribute to set.
     */ execute(options) {
        const editor = this.editor;
        const imageUtils = editor.plugins.get('ImageUtils');
        const model = editor.model;
        const imageElement = imageUtils.getClosestSelectedImageElement(model.document.selection);
        model.change((writer)=>{
            writer.setAttribute('alt', options.newValue, imageElement);
        });
    }
}

class ImageTextAlternativeEditing extends Plugin {
    /**
     * @inheritDoc
     */ static get requires() {
        return [
            ImageUtils
        ];
    }
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'ImageTextAlternativeEditing';
    }
    /**
     * @inheritDoc
     */ init() {
        this.editor.commands.add('imageTextAlternative', new ImageTextAlternativeCommand(this.editor));
    }
}

class TextAlternativeFormView extends View {
    /**
     * @inheritDoc
     */ render() {
        super.render();
        this.keystrokes.listenTo(this.element);
        submitHandler({
            view: this
        });
        [
            this.labeledInput,
            this.saveButtonView,
            this.cancelButtonView
        ].forEach((v)=>{
            // Register the view as focusable.
            this._focusables.add(v);
            // Register the view in the focus tracker.
            this.focusTracker.add(v.element);
        });
    }
    /**
     * @inheritDoc
     */ destroy() {
        super.destroy();
        this.focusTracker.destroy();
        this.keystrokes.destroy();
    }
    /**
     * Creates the button view.
     *
     * @param label The button label
     * @param icon The button's icon.
     * @param className The additional button CSS class name.
     * @param eventName The event name that the ButtonView#execute event will be delegated to.
     * @returns The button view instance.
     */ _createButton(label, icon, className, eventName) {
        const button = new ButtonView(this.locale);
        button.set({
            label,
            icon,
            tooltip: true
        });
        button.extendTemplate({
            attributes: {
                class: className
            }
        });
        if (eventName) {
            button.delegate('execute').to(this, eventName);
        }
        return button;
    }
    /**
     * Creates an input with a label.
     *
     * @returns Labeled field view instance.
     */ _createLabeledInputView() {
        const t = this.locale.t;
        const labeledInput = new LabeledFieldView(this.locale, createLabeledInputText);
        labeledInput.label = t('Text alternative');
        return labeledInput;
    }
    /**
     * @inheritDoc
     */ constructor(locale){
        super(locale);
        const t = this.locale.t;
        this.focusTracker = new FocusTracker();
        this.keystrokes = new KeystrokeHandler();
        this.labeledInput = this._createLabeledInputView();
        this.saveButtonView = this._createButton(t('Save'), icons.check, 'ck-button-save');
        this.saveButtonView.type = 'submit';
        this.cancelButtonView = this._createButton(t('Cancel'), icons.cancel, 'ck-button-cancel', 'cancel');
        this._focusables = new ViewCollection();
        this._focusCycler = new FocusCycler({
            focusables: this._focusables,
            focusTracker: this.focusTracker,
            keystrokeHandler: this.keystrokes,
            actions: {
                // Navigate form fields backwards using the Shift + Tab keystroke.
                focusPrevious: 'shift + tab',
                // Navigate form fields forwards using the Tab key.
                focusNext: 'tab'
            }
        });
        this.setTemplate({
            tag: 'form',
            attributes: {
                class: [
                    'ck',
                    'ck-text-alternative-form',
                    'ck-responsive-form'
                ],
                // https://github.com/ckeditor/ckeditor5-image/issues/40
                tabindex: '-1'
            },
            children: [
                this.labeledInput,
                this.saveButtonView,
                this.cancelButtonView
            ]
        });
    }
}

/**
 * A helper utility that positions the
 * {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon} instance
 * with respect to the image in the editor content, if one is selected.
 *
 * @param editor The editor instance.
 */ function repositionContextualBalloon(editor) {
    const balloon = editor.plugins.get('ContextualBalloon');
    const imageUtils = editor.plugins.get('ImageUtils');
    if (imageUtils.getClosestSelectedImageWidget(editor.editing.view.document.selection)) {
        const position = getBalloonPositionData(editor);
        balloon.updatePosition(position);
    }
}
/**
 * Returns the positioning options that control the geometry of the
 * {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon} with respect
 * to the selected element in the editor content.
 *
 * @param editor The editor instance.
 */ function getBalloonPositionData(editor) {
    const editingView = editor.editing.view;
    const defaultPositions = BalloonPanelView.defaultPositions;
    const imageUtils = editor.plugins.get('ImageUtils');
    return {
        target: editingView.domConverter.mapViewToDom(imageUtils.getClosestSelectedImageWidget(editingView.document.selection)),
        positions: [
            defaultPositions.northArrowSouth,
            defaultPositions.northArrowSouthWest,
            defaultPositions.northArrowSouthEast,
            defaultPositions.southArrowNorth,
            defaultPositions.southArrowNorthWest,
            defaultPositions.southArrowNorthEast,
            defaultPositions.viewportStickyNorth
        ]
    };
}

class ImageTextAlternativeUI extends Plugin {
    /**
     * @inheritDoc
     */ static get requires() {
        return [
            ContextualBalloon
        ];
    }
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'ImageTextAlternativeUI';
    }
    /**
     * @inheritDoc
     */ init() {
        this._createButton();
    }
    /**
     * @inheritDoc
     */ destroy() {
        super.destroy();
        // Destroy created UI components as they are not automatically destroyed (see ckeditor5#1341).
        if (this._form) {
            this._form.destroy();
        }
    }
    /**
     * Creates a button showing the balloon panel for changing the image text alternative and
     * registers it in the editor {@link module:ui/componentfactory~ComponentFactory ComponentFactory}.
     */ _createButton() {
        const editor = this.editor;
        const t = editor.t;
        editor.ui.componentFactory.add('imageTextAlternative', (locale)=>{
            const command = editor.commands.get('imageTextAlternative');
            const view = new ButtonView(locale);
            view.set({
                label: t('Change image text alternative'),
                icon: icons.textAlternative,
                tooltip: true
            });
            view.bind('isEnabled').to(command, 'isEnabled');
            view.bind('isOn').to(command, 'value', (value)=>!!value);
            this.listenTo(view, 'execute', ()=>{
                this._showForm();
            });
            return view;
        });
    }
    /**
     * Creates the {@link module:image/imagetextalternative/ui/textalternativeformview~TextAlternativeFormView}
     * form.
     */ _createForm() {
        const editor = this.editor;
        const view = editor.editing.view;
        const viewDocument = view.document;
        const imageUtils = editor.plugins.get('ImageUtils');
        this._balloon = this.editor.plugins.get('ContextualBalloon');
        this._form = new (CssTransitionDisablerMixin(TextAlternativeFormView))(editor.locale);
        // Render the form so its #element is available for clickOutsideHandler.
        this._form.render();
        this.listenTo(this._form, 'submit', ()=>{
            editor.execute('imageTextAlternative', {
                newValue: this._form.labeledInput.fieldView.element.value
            });
            this._hideForm(true);
        });
        this.listenTo(this._form, 'cancel', ()=>{
            this._hideForm(true);
        });
        // Close the form on Esc key press.
        this._form.keystrokes.set('Esc', (data, cancel)=>{
            this._hideForm(true);
            cancel();
        });
        // Reposition the balloon or hide the form if an image widget is no longer selected.
        this.listenTo(editor.ui, 'update', ()=>{
            if (!imageUtils.getClosestSelectedImageWidget(viewDocument.selection)) {
                this._hideForm(true);
            } else if (this._isVisible) {
                repositionContextualBalloon(editor);
            }
        });
        // Close on click outside of balloon panel element.
        clickOutsideHandler({
            emitter: this._form,
            activator: ()=>this._isVisible,
            contextElements: ()=>[
                    this._balloon.view.element
                ],
            callback: ()=>this._hideForm()
        });
    }
    /**
     * Shows the {@link #_form} in the {@link #_balloon}.
     */ _showForm() {
        if (this._isVisible) {
            return;
        }
        if (!this._form) {
            this._createForm();
        }
        const editor = this.editor;
        const command = editor.commands.get('imageTextAlternative');
        const labeledInput = this._form.labeledInput;
        this._form.disableCssTransitions();
        if (!this._isInBalloon) {
            this._balloon.add({
                view: this._form,
                position: getBalloonPositionData(editor)
            });
        }
        // Make sure that each time the panel shows up, the field remains in sync with the value of
        // the command. If the user typed in the input, then canceled the balloon (`labeledInput#value`
        // stays unaltered) and re-opened it without changing the value of the command, they would see the
        // old value instead of the actual value of the command.
        // https://github.com/ckeditor/ckeditor5-image/issues/114
        labeledInput.fieldView.value = labeledInput.fieldView.element.value = command.value || '';
        this._form.labeledInput.fieldView.select();
        this._form.enableCssTransitions();
    }
    /**
     * Removes the {@link #_form} from the {@link #_balloon}.
     *
     * @param focusEditable Controls whether the editing view is focused afterwards.
     */ _hideForm(focusEditable = false) {
        if (!this._isInBalloon) {
            return;
        }
        // Blur the input element before removing it from DOM to prevent issues in some browsers.
        // See https://github.com/ckeditor/ckeditor5/issues/1501.
        if (this._form.focusTracker.isFocused) {
            this._form.saveButtonView.focus();
        }
        this._balloon.remove(this._form);
        if (focusEditable) {
            this.editor.editing.view.focus();
        }
    }
    /**
     * Returns `true` when the {@link #_form} is the visible view in the {@link #_balloon}.
     */ get _isVisible() {
        return !!this._balloon && this._balloon.visibleView === this._form;
    }
    /**
     * Returns `true` when the {@link #_form} is in the {@link #_balloon}.
     */ get _isInBalloon() {
        return !!this._balloon && this._balloon.hasView(this._form);
    }
}

class ImageTextAlternative extends Plugin {
    /**
     * @inheritDoc
     */ static get requires() {
        return [
            ImageTextAlternativeEditing,
            ImageTextAlternativeUI
        ];
    }
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'ImageTextAlternative';
    }
}

/**
 * Returns a function that converts the image view representation:
 *
 * ```html
 * <figure class="image"><img src="..." alt="..."></img></figure>
 * ```
 *
 * to the model representation:
 *
 * ```html
 * <imageBlock src="..." alt="..."></imageBlock>
 * ```
 *
 * The entire content of the `<figure>` element except the first `<img>` is being converted as children
 * of the `<imageBlock>` model element.
 *
 * @internal
 */ function upcastImageFigure(imageUtils) {
    const converter = (evt, data, conversionApi)=>{
        // Do not convert if this is not an "image figure".
        if (!conversionApi.consumable.test(data.viewItem, {
            name: true,
            classes: 'image'
        })) {
            return;
        }
        // Find an image element inside the figure element.
        const viewImage = imageUtils.findViewImgElement(data.viewItem);
        // Do not convert if image element is absent or was already converted.
        if (!viewImage || !conversionApi.consumable.test(viewImage, {
            name: true
        })) {
            return;
        }
        // Consume the figure to prevent other converters from processing it again.
        conversionApi.consumable.consume(data.viewItem, {
            name: true,
            classes: 'image'
        });
        // Convert view image to model image.
        const conversionResult = conversionApi.convertItem(viewImage, data.modelCursor);
        // Get image element from conversion result.
        const modelImage = first(conversionResult.modelRange.getItems());
        // When image wasn't successfully converted then finish conversion.
        if (!modelImage) {
            // Revert consumed figure so other features can convert it.
            conversionApi.consumable.revert(data.viewItem, {
                name: true,
                classes: 'image'
            });
            return;
        }
        // Convert rest of the figure element's children as an image children.
        conversionApi.convertChildren(data.viewItem, modelImage);
        conversionApi.updateConversionResult(modelImage, data);
    };
    return (dispatcher)=>{
        dispatcher.on('element:figure', converter);
    };
}
/**
 * Returns a function that converts the image view representation:
 *
 * ```html
 * <picture><source ... /><source ... />...<img ... /></picture>
 * ```
 *
 * to the model representation as the `sources` attribute:
 *
 * ```html
 * <image[Block|Inline] ... sources="..."></image[Block|Inline]>
 * ```
 *
 * @internal
 */ function upcastPicture(imageUtils) {
    const sourceAttributeNames = [
        'srcset',
        'media',
        'type',
        'sizes'
    ];
    const converter = (evt, data, conversionApi)=>{
        const pictureViewElement = data.viewItem;
        // Do not convert <picture> if already consumed.
        if (!conversionApi.consumable.test(pictureViewElement, {
            name: true
        })) {
            return;
        }
        const sources = new Map();
        // Collect all <source /> elements attribute values.
        for (const childSourceElement of pictureViewElement.getChildren()){
            if (childSourceElement.is('element', 'source')) {
                const attributes = {};
                for (const name of sourceAttributeNames){
                    if (childSourceElement.hasAttribute(name)) {
                        // Don't collect <source /> attribute if already consumed somewhere else.
                        if (conversionApi.consumable.test(childSourceElement, {
                            attributes: name
                        })) {
                            attributes[name] = childSourceElement.getAttribute(name);
                        }
                    }
                }
                if (Object.keys(attributes).length) {
                    sources.set(childSourceElement, attributes);
                }
            }
        }
        const imgViewElement = imageUtils.findViewImgElement(pictureViewElement);
        // Don't convert when a picture has no <img/> inside (it is broken).
        if (!imgViewElement) {
            return;
        }
        let modelImage = data.modelCursor.parent;
        // - In case of an inline image (cursor parent in a <paragraph>), the <img/> must be converted right away
        // because no converter handled it yet and otherwise there would be no model element to set the sources attribute on.
        // - In case of a block image, the <figure class="image"> converter (in ImageBlockEditing) converts the
        // <img/> right away on its own and the modelCursor is already inside an imageBlock and there's nothing special
        // to do here.
        if (!modelImage.is('element', 'imageBlock')) {
            const conversionResult = conversionApi.convertItem(imgViewElement, data.modelCursor);
            // Set image range as conversion result.
            data.modelRange = conversionResult.modelRange;
            // Continue conversion where image conversion ends.
            data.modelCursor = conversionResult.modelCursor;
            modelImage = first(conversionResult.modelRange.getItems());
        }
        conversionApi.consumable.consume(pictureViewElement, {
            name: true
        });
        // Consume only these <source/> attributes that were actually collected and will be passed on
        // to the image model element.
        for (const [sourceElement, attributes] of sources){
            conversionApi.consumable.consume(sourceElement, {
                attributes: Object.keys(attributes)
            });
        }
        if (sources.size) {
            conversionApi.writer.setAttribute('sources', Array.from(sources.values()), modelImage);
        }
        // Convert rest of the <picture> children as an image children. Other converters may want to consume them.
        conversionApi.convertChildren(pictureViewElement, modelImage);
    };
    return (dispatcher)=>{
        dispatcher.on('element:picture', converter);
    };
}
/**
 * Converter used to convert the `srcset` model image attribute to the `srcset` and `sizes` attributes in the view.
 *
 * @internal
 * @param imageType The type of the image.
 */ function downcastSrcsetAttribute(imageUtils, imageType) {
    const converter = (evt, data, conversionApi)=>{
        if (!conversionApi.consumable.consume(data.item, evt.name)) {
            return;
        }
        const writer = conversionApi.writer;
        const element = conversionApi.mapper.toViewElement(data.item);
        const img = imageUtils.findViewImgElement(element);
        if (data.attributeNewValue === null) {
            writer.removeAttribute('srcset', img);
            writer.removeAttribute('sizes', img);
        } else {
            if (data.attributeNewValue) {
                writer.setAttribute('srcset', data.attributeNewValue, img);
                // Always outputting `100vw`. See https://github.com/ckeditor/ckeditor5-image/issues/2.
                writer.setAttribute('sizes', '100vw', img);
            }
        }
    };
    return (dispatcher)=>{
        dispatcher.on(`attribute:srcset:${imageType}`, converter);
    };
}
/**
 * Converts the `source` model attribute to the `<picture><source /><source />...<img /></picture>`
 * view structure.
 *
 * @internal
 */ function downcastSourcesAttribute(imageUtils) {
    const converter = (evt, data, conversionApi)=>{
        if (!conversionApi.consumable.consume(data.item, evt.name)) {
            return;
        }
        const viewWriter = conversionApi.writer;
        const element = conversionApi.mapper.toViewElement(data.item);
        const imgElement = imageUtils.findViewImgElement(element);
        const attributeNewValue = data.attributeNewValue;
        if (attributeNewValue && attributeNewValue.length) {
            // Make sure <picture> does not break attribute elements, for instance <a> in linked images.
            const pictureElement = viewWriter.createContainerElement('picture', null, attributeNewValue.map((sourceAttributes)=>{
                return viewWriter.createEmptyElement('source', sourceAttributes);
            }));
            // Collect all wrapping attribute elements.
            const attributeElements = [];
            let viewElement = imgElement.parent;
            while(viewElement && viewElement.is('attributeElement')){
                const parentElement = viewElement.parent;
                viewWriter.unwrap(viewWriter.createRangeOn(imgElement), viewElement);
                attributeElements.unshift(viewElement);
                viewElement = parentElement;
            }
            // Insert the picture and move img into it.
            viewWriter.insert(viewWriter.createPositionBefore(imgElement), pictureElement);
            viewWriter.move(viewWriter.createRangeOn(imgElement), viewWriter.createPositionAt(pictureElement, 'end'));
            // Apply collected attribute elements over the new picture element.
            for (const attributeElement of attributeElements){
                viewWriter.wrap(viewWriter.createRangeOn(pictureElement), attributeElement);
            }
        } else if (imgElement.parent.is('element', 'picture')) {
            const pictureElement = imgElement.parent;
            viewWriter.move(viewWriter.createRangeOn(imgElement), viewWriter.createPositionBefore(pictureElement));
            viewWriter.remove(pictureElement);
        }
    };
    return (dispatcher)=>{
        dispatcher.on('attribute:sources:imageBlock', converter);
        dispatcher.on('attribute:sources:imageInline', converter);
    };
}
/**
 * Converter used to convert a given image attribute from the model to the view.
 *
 * @internal
 * @param imageType The type of the image.
 * @param attributeKey The name of the attribute to convert.
 */ function downcastImageAttribute(imageUtils, imageType, attributeKey) {
    const converter = (evt, data, conversionApi)=>{
        if (!conversionApi.consumable.consume(data.item, evt.name)) {
            return;
        }
        const viewWriter = conversionApi.writer;
        const element = conversionApi.mapper.toViewElement(data.item);
        const img = imageUtils.findViewImgElement(element);
        viewWriter.setAttribute(data.attributeKey, data.attributeNewValue || '', img);
    };
    return (dispatcher)=>{
        dispatcher.on(`attribute:${attributeKey}:${imageType}`, converter);
    };
}

class ImageLoadObserver extends Observer {
    /**
     * @inheritDoc
     */ observe(domRoot) {
        this.listenTo(domRoot, 'load', (event, domEvent)=>{
            const domElement = domEvent.target;
            if (this.checkShouldIgnoreEventFromTarget(domElement)) {
                return;
            }
            if (domElement.tagName == 'IMG') {
                this._fireEvents(domEvent);
            }
        // Use capture phase for better performance (#4504).
        }, {
            useCapture: true
        });
    }
    /**
     * @inheritDoc
     */ stopObserving(domRoot) {
        this.stopListening(domRoot);
    }
    /**
     * Fires {@link module:engine/view/document~Document#event:layoutChanged} and
     * {@link module:engine/view/document~Document#event:imageLoaded}
     * if observer {@link #isEnabled is enabled}.
     *
     * @param domEvent The DOM event.
     */ _fireEvents(domEvent) {
        if (this.isEnabled) {
            this.document.fire('layoutChanged');
            this.document.fire('imageLoaded', domEvent);
        }
    }
}

class InsertImageCommand extends Command {
    /**
     * @inheritDoc
     */ refresh() {
        const imageUtils = this.editor.plugins.get('ImageUtils');
        this.isEnabled = imageUtils.isImageAllowed();
    }
    /**
     * Executes the command.
     *
     * @fires execute
     * @param options Options for the executed command.
     * @param options.source The image source or an array of image sources to insert.
     * See the documentation of the command to learn more about accepted formats.
     */ execute(options) {
        const sourceDefinitions = toArray(options.source);
        const selection = this.editor.model.document.selection;
        const imageUtils = this.editor.plugins.get('ImageUtils');
        // In case of multiple images, each image (starting from the 2nd) will be inserted at a position that
        // follows the previous one. That will move the selection and, to stay on the safe side and make sure
        // all images inherit the same selection attributes, they are collected beforehand.
        //
        // Applying these attributes ensures, for instance, that inserting an (inline) image into a link does
        // not split that link but preserves its continuity.
        //
        // Note: Selection attributes that do not make sense for images will be filtered out by insertImage() anyway.
        const selectionAttributes = Object.fromEntries(selection.getAttributes());
        sourceDefinitions.forEach((sourceDefinition, index)=>{
            const selectedElement = selection.getSelectedElement();
            if (typeof sourceDefinition === 'string') {
                sourceDefinition = {
                    src: sourceDefinition
                };
            }
            // Inserting of an inline image replace the selected element and make a selection on the inserted image.
            // Therefore inserting multiple inline images requires creating position after each element.
            if (index && selectedElement && imageUtils.isImage(selectedElement)) {
                const position = this.editor.model.createPositionAfter(selectedElement);
                imageUtils.insertImage({
                    ...sourceDefinition,
                    ...selectionAttributes
                }, position);
            } else {
                imageUtils.insertImage({
                    ...sourceDefinition,
                    ...selectionAttributes
                });
            }
        });
    }
    /**
     * @inheritDoc
     */ constructor(editor){
        super(editor);
        const configImageInsertType = editor.config.get('image.insert.type');
        if (!editor.plugins.has('ImageBlockEditing')) {
            if (configImageInsertType === 'block') {
                /**
                 * The {@link module:image/imageblock~ImageBlock} plugin must be enabled to allow inserting block images. See
                 * {@link module:image/imageconfig~ImageInsertConfig#type} to learn more.
                 *
                 * @error image-block-plugin-required
                 */ logWarning('image-block-plugin-required');
            }
        }
        if (!editor.plugins.has('ImageInlineEditing')) {
            if (configImageInsertType === 'inline') {
                /**
                 * The {@link module:image/imageinline~ImageInline} plugin must be enabled to allow inserting inline images. See
                 * {@link module:image/imageconfig~ImageInsertConfig#type} to learn more.
                 *
                 * @error image-inline-plugin-required
                 */ logWarning('image-inline-plugin-required');
            }
        }
    }
}

class ReplaceImageSourceCommand extends Command {
    /**
     * @inheritDoc
     */ refresh() {
        const editor = this.editor;
        const imageUtils = editor.plugins.get('ImageUtils');
        const element = this.editor.model.document.selection.getSelectedElement();
        this.isEnabled = imageUtils.isImage(element);
        this.value = this.isEnabled ? element.getAttribute('src') : null;
    }
    /**
     * Executes the command.
     *
     * @fires execute
     * @param options Options for the executed command.
     * @param options.source The image source to replace.
     */ execute(options) {
        const image = this.editor.model.document.selection.getSelectedElement();
        const imageUtils = this.editor.plugins.get('ImageUtils');
        this.editor.model.change((writer)=>{
            writer.setAttribute('src', options.source, image);
            this.cleanupImage(writer, image);
            imageUtils.setImageNaturalSizeAttributes(image);
        });
    }
    /**
     * Cleanup image attributes that are not relevant to the new source.
     *
     * Removed attributes are: 'srcset', 'sizes', 'sources', 'width', 'height', 'alt'.
     *
     * This method is decorated, to allow custom cleanup logic.
     * For example, to remove 'myImageId' attribute after 'src' has changed:
     *
     * ```ts
     * replaceImageSourceCommand.on( 'cleanupImage', ( eventInfo, [ writer, image ] ) => {
     * 	writer.removeAttribute( 'myImageId', image );
     * } );
     * ```
     */ cleanupImage(writer, image) {
        writer.removeAttribute('srcset', image);
        writer.removeAttribute('sizes', image);
        /**
         * In case responsive images some attributes should be cleaned up.
         * Check: https://github.com/ckeditor/ckeditor5/issues/15093
         */ writer.removeAttribute('sources', image);
        writer.removeAttribute('width', image);
        writer.removeAttribute('height', image);
        writer.removeAttribute('alt', image);
    }
    constructor(editor){
        super(editor);
        this.decorate('cleanupImage');
    }
}

class ImageEditing extends Plugin {
    /**
     * @inheritDoc
     */ static get requires() {
        return [
            ImageUtils
        ];
    }
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'ImageEditing';
    }
    /**
     * @inheritDoc
     */ init() {
        const editor = this.editor;
        const conversion = editor.conversion;
        // See https://github.com/ckeditor/ckeditor5-image/issues/142.
        editor.editing.view.addObserver(ImageLoadObserver);
        conversion.for('upcast').attributeToAttribute({
            view: {
                name: 'img',
                key: 'alt'
            },
            model: 'alt'
        }).attributeToAttribute({
            view: {
                name: 'img',
                key: 'srcset'
            },
            model: 'srcset'
        });
        const insertImageCommand = new InsertImageCommand(editor);
        const replaceImageSourceCommand = new ReplaceImageSourceCommand(editor);
        editor.commands.add('insertImage', insertImageCommand);
        editor.commands.add('replaceImageSource', replaceImageSourceCommand);
        // `imageInsert` is an alias for backward compatibility.
        editor.commands.add('imageInsert', insertImageCommand);
    }
}

class ImageSizeAttributes extends Plugin {
    /**
     * @inheritDoc
     */ static get requires() {
        return [
            ImageUtils
        ];
    }
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'ImageSizeAttributes';
    }
    /**
     * @inheritDoc
     */ afterInit() {
        this._registerSchema();
        this._registerConverters('imageBlock');
        this._registerConverters('imageInline');
    }
    /**
     * Registers the `width` and `height` attributes for inline and block images.
     */ _registerSchema() {
        if (this.editor.plugins.has('ImageBlockEditing')) {
            this.editor.model.schema.extend('imageBlock', {
                allowAttributes: [
                    'width',
                    'height'
                ]
            });
        }
        if (this.editor.plugins.has('ImageInlineEditing')) {
            this.editor.model.schema.extend('imageInline', {
                allowAttributes: [
                    'width',
                    'height'
                ]
            });
        }
    }
    /**
     * Registers converters for `width` and `height` attributes.
     */ _registerConverters(imageType) {
        const editor = this.editor;
        const imageUtils = editor.plugins.get('ImageUtils');
        const viewElementName = imageType === 'imageBlock' ? 'figure' : 'img';
        editor.conversion.for('upcast').attributeToAttribute({
            view: {
                name: viewElementName,
                styles: {
                    width: /.+/
                }
            },
            model: {
                key: 'width',
                value: (viewElement)=>{
                    if (widthAndHeightStylesAreBothSet(viewElement)) {
                        return getSizeValueIfInPx(viewElement.getStyle('width'));
                    }
                    return null;
                }
            }
        }).attributeToAttribute({
            view: {
                name: viewElementName,
                key: 'width'
            },
            model: 'width'
        }).attributeToAttribute({
            view: {
                name: viewElementName,
                styles: {
                    height: /.+/
                }
            },
            model: {
                key: 'height',
                value: (viewElement)=>{
                    if (widthAndHeightStylesAreBothSet(viewElement)) {
                        return getSizeValueIfInPx(viewElement.getStyle('height'));
                    }
                    return null;
                }
            }
        }).attributeToAttribute({
            view: {
                name: viewElementName,
                key: 'height'
            },
            model: 'height'
        });
        // Dedicated converters to propagate attributes to the <img> element.
        editor.conversion.for('editingDowncast').add((dispatcher)=>{
            attachDowncastConverter(dispatcher, 'width', 'width', true);
            attachDowncastConverter(dispatcher, 'height', 'height', true);
        });
        editor.conversion.for('dataDowncast').add((dispatcher)=>{
            attachDowncastConverter(dispatcher, 'width', 'width', false);
            attachDowncastConverter(dispatcher, 'height', 'height', false);
        });
        function attachDowncastConverter(dispatcher, modelAttributeName, viewAttributeName, setRatioForInlineImage) {
            dispatcher.on(`attribute:${modelAttributeName}:${imageType}`, (evt, data, conversionApi)=>{
                if (!conversionApi.consumable.consume(data.item, evt.name)) {
                    return;
                }
                const viewWriter = conversionApi.writer;
                const viewElement = conversionApi.mapper.toViewElement(data.item);
                const img = imageUtils.findViewImgElement(viewElement);
                if (data.attributeNewValue !== null) {
                    viewWriter.setAttribute(viewAttributeName, data.attributeNewValue, img);
                } else {
                    viewWriter.removeAttribute(viewAttributeName, img);
                }
                // Do not set aspect-ratio for pictures. See https://github.com/ckeditor/ckeditor5/issues/14579.
                if (data.item.hasAttribute('sources')) {
                    return;
                }
                const isResized = data.item.hasAttribute('resizedWidth');
                // Do not set aspect ratio for inline images which are not resized (data pipeline).
                if (imageType === 'imageInline' && !isResized && !setRatioForInlineImage) {
                    return;
                }
                const width = data.item.getAttribute('width');
                const height = data.item.getAttribute('height');
                if (width && height) {
                    viewWriter.setStyle('aspect-ratio', `${width}/${height}`, img);
                }
            });
        }
    }
}

class ImageTypeCommand extends Command {
    /**
     * @inheritDoc
     */ refresh() {
        const editor = this.editor;
        const imageUtils = editor.plugins.get('ImageUtils');
        const element = imageUtils.getClosestSelectedImageElement(this.editor.model.document.selection);
        if (this._modelElementName === 'imageBlock') {
            this.isEnabled = imageUtils.isInlineImage(element);
        } else {
            this.isEnabled = imageUtils.isBlockImage(element);
        }
    }
    /**
     * Executes the command and changes the type of a selected image.
     *
     * @fires execute
     * @param options.setImageSizes Specifies whether the image `width` and `height` attributes should be set automatically.
     * The default is `true`.
     * @returns An object containing references to old and new model image elements
     * (for before and after the change) so external integrations can hook into the decorated
     * `execute` event and handle this change. `null` if the type change failed.
     */ execute(options = {}) {
        const editor = this.editor;
        const model = this.editor.model;
        const imageUtils = editor.plugins.get('ImageUtils');
        const oldElement = imageUtils.getClosestSelectedImageElement(model.document.selection);
        const attributes = Object.fromEntries(oldElement.getAttributes());
        // Don't change image type if "src" is missing (a broken image), unless there's "uploadId" set.
        // This state may happen during image upload (before it finishes) and it should be possible to change type
        // of the image in the meantime.
        if (!attributes.src && !attributes.uploadId) {
            return null;
        }
        return model.change((writer)=>{
            const { setImageSizes = true } = options;
            // Get all markers that contain the old image element.
            const markers = Array.from(model.markers).filter((marker)=>marker.getRange().containsItem(oldElement));
            const newElement = imageUtils.insertImage(attributes, model.createSelection(oldElement, 'on'), this._modelElementName, {
                setImageSizes
            });
            if (!newElement) {
                return null;
            }
            const newElementRange = writer.createRangeOn(newElement);
            // Expand the previously intersecting markers' ranges to include the new image element.
            for (const marker of markers){
                const markerRange = marker.getRange();
                // Join the survived part of the old marker range with the new element range
                // (loosely because there could be some new paragraph or the existing one might got split).
                const range = markerRange.root.rootName != '$graveyard' ? markerRange.getJoined(newElementRange, true) : newElementRange;
                writer.updateMarker(marker, {
                    range
                });
            }
            return {
                oldElement,
                newElement
            };
        });
    }
    /**
     * @inheritDoc
     *
     * @param modelElementName Model element name the command converts to.
     */ constructor(editor, modelElementName){
        super(editor);
        this._modelElementName = modelElementName;
    }
}

class ImagePlaceholder extends Plugin {
    /**
     * @inheritDoc
     */ static get requires() {
        return [
            ImageUtils
        ];
    }
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'ImagePlaceholder';
    }
    /**
     * @inheritDoc
     */ afterInit() {
        this._setupSchema();
        this._setupConversion();
        this._setupLoadListener();
    }
    /**
     * Extends model schema.
     */ _setupSchema() {
        const schema = this.editor.model.schema;
        // Wait for ImageBlockEditing or ImageInlineEditing to register their elements first,
        // that's why doing this in afterInit() instead of init().
        if (schema.isRegistered('imageBlock')) {
            schema.extend('imageBlock', {
                allowAttributes: [
                    'placeholder'
                ]
            });
        }
        if (schema.isRegistered('imageInline')) {
            schema.extend('imageInline', {
                allowAttributes: [
                    'placeholder'
                ]
            });
        }
    }
    /**
     * Registers converters.
     */ _setupConversion() {
        const editor = this.editor;
        const conversion = editor.conversion;
        const imageUtils = editor.plugins.get('ImageUtils');
        conversion.for('editingDowncast').add((dispatcher)=>{
            dispatcher.on('attribute:placeholder', (evt, data, conversionApi)=>{
                if (!conversionApi.consumable.test(data.item, evt.name)) {
                    return;
                }
                if (!data.item.is('element', 'imageBlock') && !data.item.is('element', 'imageInline')) {
                    return;
                }
                conversionApi.consumable.consume(data.item, evt.name);
                const viewWriter = conversionApi.writer;
                const element = conversionApi.mapper.toViewElement(data.item);
                const img = imageUtils.findViewImgElement(element);
                if (data.attributeNewValue) {
                    viewWriter.addClass('image_placeholder', img);
                    viewWriter.setStyle('background-image', `url(${data.attributeNewValue})`, img);
                    viewWriter.setCustomProperty('editingPipeline:doNotReuseOnce', true, img);
                } else {
                    viewWriter.removeClass('image_placeholder', img);
                    viewWriter.removeStyle('background-image', img);
                }
            });
        });
    }
    /**
     * Prepares listener for image load.
     */ _setupLoadListener() {
        const editor = this.editor;
        const model = editor.model;
        const editing = editor.editing;
        const editingView = editing.view;
        const imageUtils = editor.plugins.get('ImageUtils');
        editingView.addObserver(ImageLoadObserver);
        this.listenTo(editingView.document, 'imageLoaded', (evt, domEvent)=>{
            const imgViewElement = editingView.domConverter.mapDomToView(domEvent.target);
            if (!imgViewElement) {
                return;
            }
            const viewElement = imageUtils.getImageWidgetFromImageView(imgViewElement);
            if (!viewElement) {
                return;
            }
            const modelElement = editing.mapper.toModelElement(viewElement);
            if (!modelElement || !modelElement.hasAttribute('placeholder')) {
                return;
            }
            model.enqueueChange({
                isUndoable: false
            }, (writer)=>{
                writer.removeAttribute('placeholder', modelElement);
            });
        });
    }
}

class ImageBlockEditing extends Plugin {
    /**
     * @inheritDoc
     */ static get requires() {
        return [
            ImageEditing,
            ImageSizeAttributes,
            ImageUtils,
            ImagePlaceholder,
            ClipboardPipeline
        ];
    }
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'ImageBlockEditing';
    }
    /**
     * @inheritDoc
     */ init() {
        const editor = this.editor;
        const schema = editor.model.schema;
        // Converters 'alt' and 'srcset' are added in 'ImageEditing' plugin.
        schema.register('imageBlock', {
            inheritAllFrom: '$blockObject',
            allowAttributes: [
                'alt',
                'src',
                'srcset'
            ]
        });
        this._setupConversion();
        if (editor.plugins.has('ImageInlineEditing')) {
            editor.commands.add('imageTypeBlock', new ImageTypeCommand(this.editor, 'imageBlock'));
            this._setupClipboardIntegration();
        }
    }
    /**
     * Configures conversion pipelines to support upcasting and downcasting
     * block images (block image widgets) and their attributes.
     */ _setupConversion() {
        const editor = this.editor;
        const t = editor.t;
        const conversion = editor.conversion;
        const imageUtils = editor.plugins.get('ImageUtils');
        conversion.for('dataDowncast').elementToStructure({
            model: 'imageBlock',
            view: (modelElement, { writer })=>createBlockImageViewElement(writer)
        });
        conversion.for('editingDowncast').elementToStructure({
            model: 'imageBlock',
            view: (modelElement, { writer })=>imageUtils.toImageWidget(createBlockImageViewElement(writer), writer, t('image widget'))
        });
        conversion.for('downcast').add(downcastImageAttribute(imageUtils, 'imageBlock', 'src')).add(downcastImageAttribute(imageUtils, 'imageBlock', 'alt')).add(downcastSrcsetAttribute(imageUtils, 'imageBlock'));
        // More image related upcasts are in 'ImageEditing' plugin.
        conversion.for('upcast').elementToElement({
            view: getImgViewElementMatcher(editor, 'imageBlock'),
            model: (viewImage, { writer })=>writer.createElement('imageBlock', viewImage.hasAttribute('src') ? {
                    src: viewImage.getAttribute('src')
                } : undefined)
        }).add(upcastImageFigure(imageUtils));
    }
    /**
     * Integrates the plugin with the clipboard pipeline.
     *
     * Idea is that the feature should recognize the user's intent when an **inline** image is
     * pasted or dropped. If such an image is pasted/dropped:
     *
     * * into an empty block (e.g. an empty paragraph),
     * * on another object (e.g. some block widget).
     *
     * it gets converted into a block image on the fly. We assume this is the user's intent
     * if they decided to put their image there.
     *
     * See the `ImageInlineEditing` for the similar integration that works in the opposite direction.
     *
     * The feature also sets image `width` and `height` attributes on paste.
     */ _setupClipboardIntegration() {
        const editor = this.editor;
        const model = editor.model;
        const editingView = editor.editing.view;
        const imageUtils = editor.plugins.get('ImageUtils');
        const clipboardPipeline = editor.plugins.get('ClipboardPipeline');
        this.listenTo(clipboardPipeline, 'inputTransformation', (evt, data)=>{
            const docFragmentChildren = Array.from(data.content.getChildren());
            let modelRange;
            // Make sure only <img> elements are dropped or pasted. Otherwise, if there some other HTML
            // mixed up, this should be handled as a regular paste.
            if (!docFragmentChildren.every(imageUtils.isInlineImageView)) {
                return;
            }
            // When drag and dropping, data.targetRanges specifies where to drop because
            // this is usually a different place than the current model selection (the user
            // uses a drop marker to specify the drop location).
            if (data.targetRanges) {
                modelRange = editor.editing.mapper.toModelRange(data.targetRanges[0]);
            } else {
                modelRange = model.document.selection.getFirstRange();
            }
            const selection = model.createSelection(modelRange);
            // Convert inline images into block images only when the currently selected block is empty
            // (e.g. an empty paragraph) or some object is selected (to replace it).
            if (determineImageTypeForInsertionAtSelection(model.schema, selection) === 'imageBlock') {
                const writer = new UpcastWriter(editingView.document);
                // Wrap <img ... /> -> <figure class="image"><img .../></figure>
                const blockViewImages = docFragmentChildren.map((inlineViewImage)=>writer.createElement('figure', {
                        class: 'image'
                    }, inlineViewImage));
                data.content = writer.createDocumentFragment(blockViewImages);
            }
        });
        this.listenTo(clipboardPipeline, 'contentInsertion', (evt, data)=>{
            if (data.method !== 'paste') {
                return;
            }
            model.change((writer)=>{
                const range = writer.createRangeIn(data.content);
                for (const item of range.getItems()){
                    if (item.is('element', 'imageBlock')) {
                        imageUtils.setImageNaturalSizeAttributes(item);
                    }
                }
            });
        });
    }
}

class ImageInsertFormView extends View {
    /**
     * @inheritDoc
     */ render() {
        super.render();
        submitHandler({
            view: this
        });
        for (const view of this._focusables){
            this.focusTracker.add(view.element);
        }
        // Start listening for the keystrokes coming from #element.
        this.keystrokes.listenTo(this.element);
        const stopPropagation = (data)=>data.stopPropagation();
        // Since the form is in the dropdown panel which is a child of the toolbar, the toolbar's
        // keystroke handler would take over the key management in the URL input. We need to prevent
        // this ASAP. Otherwise, the basic caret movement using the arrow keys will be impossible.
        this.keystrokes.set('arrowright', stopPropagation);
        this.keystrokes.set('arrowleft', stopPropagation);
        this.keystrokes.set('arrowup', stopPropagation);
        this.keystrokes.set('arrowdown', stopPropagation);
    }
    /**
     * @inheritDoc
     */ destroy() {
        super.destroy();
        this.focusTracker.destroy();
        this.keystrokes.destroy();
    }
    /**
     * Focuses the first {@link #_focusables focusable} in the form.
     */ focus() {
        this._focusCycler.focusFirst();
    }
    /**
     * Creates a view for the dropdown panel of {@link module:image/imageinsert/imageinsertui~ImageInsertUI}.
     *
     * @param locale The localization services instance.
     * @param integrations An integrations object that contains components (or tokens for components) to be shown in the panel view.
     */ constructor(locale, integrations = []){
        super(locale);
        this.focusTracker = new FocusTracker();
        this.keystrokes = new KeystrokeHandler();
        this._focusables = new ViewCollection();
        this.children = this.createCollection();
        this._focusCycler = new FocusCycler({
            focusables: this._focusables,
            focusTracker: this.focusTracker,
            keystrokeHandler: this.keystrokes,
            actions: {
                // Navigate form fields backwards using the Shift + Tab keystroke.
                focusPrevious: 'shift + tab',
                // Navigate form fields forwards using the Tab key.
                focusNext: 'tab'
            }
        });
        for (const view of integrations){
            this.children.add(view);
            this._focusables.add(view);
            if (view instanceof CollapsibleView) {
                this._focusables.addMany(view.children);
            }
        }
        if (this._focusables.length > 1) {
            for (const view of this._focusables){
                if (isViewWithFocusCycler(view)) {
                    view.focusCycler.on('forwardCycle', (evt)=>{
                        this._focusCycler.focusNext();
                        evt.stop();
                    });
                    view.focusCycler.on('backwardCycle', (evt)=>{
                        this._focusCycler.focusPrevious();
                        evt.stop();
                    });
                }
            }
        }
        this.setTemplate({
            tag: 'form',
            attributes: {
                class: [
                    'ck',
                    'ck-image-insert-form'
                ],
                tabindex: -1
            },
            children: this.children
        });
    }
}
function isViewWithFocusCycler(view) {
    return 'focusCycler' in view;
}

class ImageInsertUI extends Plugin {
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'ImageInsertUI';
    }
    /**
     * @inheritDoc
     */ static get requires() {
        return [
            ImageUtils
        ];
    }
    /**
     * @inheritDoc
     */ init() {
        const editor = this.editor;
        const selection = editor.model.document.selection;
        const imageUtils = editor.plugins.get('ImageUtils');
        this.set('isImageSelected', false);
        this.listenTo(editor.model.document, 'change', ()=>{
            this.isImageSelected = imageUtils.isImage(selection.getSelectedElement());
        });
        const componentCreator = (locale)=>this._createToolbarComponent(locale);
        // Register `insertImage` dropdown and add `imageInsert` dropdown as an alias for backward compatibility.
        editor.ui.componentFactory.add('insertImage', componentCreator);
        editor.ui.componentFactory.add('imageInsert', componentCreator);
    }
    /**
     * Registers the insert image dropdown integration.
     */ registerIntegration({ name, observable, buttonViewCreator, formViewCreator, requiresForm }) {
        if (this._integrations.has(name)) {
            /**
             * There are two insert-image integrations registered with the same name.
             *
             * Make sure that you do not load multiple asset manager plugins.
             *
             * @error image-insert-integration-exists
             */ logWarning('image-insert-integration-exists', {
                name
            });
        }
        this._integrations.set(name, {
            observable,
            buttonViewCreator,
            formViewCreator,
            requiresForm: !!requiresForm
        });
    }
    /**
     * Creates the toolbar component.
     */ _createToolbarComponent(locale) {
        const editor = this.editor;
        const t = locale.t;
        const integrations = this._prepareIntegrations();
        if (!integrations.length) {
            return null;
        }
        let dropdownButton;
        const firstIntegration = integrations[0];
        if (integrations.length == 1) {
            // Do not use dropdown for a single integration button (integration that does not require form view).
            if (!firstIntegration.requiresForm) {
                return firstIntegration.buttonViewCreator(true);
            }
            dropdownButton = firstIntegration.buttonViewCreator(true);
        } else {
            const actionButton = firstIntegration.buttonViewCreator(false);
            dropdownButton = new SplitButtonView(locale, actionButton);
            dropdownButton.tooltip = true;
            dropdownButton.bind('label').to(this, 'isImageSelected', (isImageSelected)=>isImageSelected ? t('Replace image') : t('Insert image'));
        }
        const dropdownView = this.dropdownView = createDropdown(locale, dropdownButton);
        const observables = integrations.map(({ observable })=>typeof observable == 'function' ? observable() : observable);
        dropdownView.bind('isEnabled').toMany(observables, 'isEnabled', (...isEnabled)=>isEnabled.some((isEnabled)=>isEnabled));
        dropdownView.once('change:isOpen', ()=>{
            const integrationViews = integrations.map(({ formViewCreator })=>formViewCreator(integrations.length == 1));
            const imageInsertFormView = new ImageInsertFormView(editor.locale, integrationViews);
            dropdownView.panelView.children.add(imageInsertFormView);
        });
        return dropdownView;
    }
    /**
     * Validates the integrations list.
     */ _prepareIntegrations() {
        const editor = this.editor;
        const items = editor.config.get('image.insert.integrations');
        const result = [];
        if (!items.length) {
            /**
             * The insert image feature requires a list of integrations to be provided in the editor configuration.
             *
             * The default list of integrations is `upload`, `assetManager`, `url`. Those integrations are included
             * in the insert image dropdown if the given feature plugin is loaded. You should omit the `integrations`
             * configuration key to use the default set or provide a selected list of integrations that should be used.
             *
             * @error image-insert-integrations-not-specified
             */ logWarning('image-insert-integrations-not-specified');
            return result;
        }
        for (const item of items){
            if (!this._integrations.has(item)) {
                if (![
                    'upload',
                    'assetManager',
                    'url'
                ].includes(item)) {
                    /**
                     * The specified insert image integration name is unknown or the providing plugin is not loaded in the editor.
                     *
                     * @error image-insert-unknown-integration
                     */ logWarning('image-insert-unknown-integration', {
                        item
                    });
                }
                continue;
            }
            result.push(this._integrations.get(item));
        }
        if (!result.length) {
            /**
             * The image insert feature requires integrations to be registered by separate features.
             *
             * The `insertImage` toolbar button requires integrations to be registered by other features.
             * For example {@link module:image/imageupload~ImageUpload ImageUpload},
             * {@link module:image/imageinsert~ImageInsert ImageInsert},
             * {@link module:image/imageinsertviaurl~ImageInsertViaUrl ImageInsertViaUrl},
             * {@link module:ckbox/ckbox~CKBox CKBox}
             *
             * @error image-insert-integrations-not-registered
             */ logWarning('image-insert-integrations-not-registered');
        }
        return result;
    }
    /**
     * @inheritDoc
     */ constructor(editor){
        super(editor);
        /**
         * Registered integrations map.
         */ this._integrations = new Map();
        editor.config.define('image.insert.integrations', [
            'upload',
            'assetManager',
            'url'
        ]);
    }
}

class ImageBlock extends Plugin {
    /**
     * @inheritDoc
     */ static get requires() {
        return [
            ImageBlockEditing,
            Widget,
            ImageTextAlternative,
            ImageInsertUI
        ];
    }
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'ImageBlock';
    }
}

class ImageInlineEditing extends Plugin {
    /**
     * @inheritDoc
     */ static get requires() {
        return [
            ImageEditing,
            ImageSizeAttributes,
            ImageUtils,
            ImagePlaceholder,
            ClipboardPipeline
        ];
    }
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'ImageInlineEditing';
    }
    /**
     * @inheritDoc
     */ init() {
        const editor = this.editor;
        const schema = editor.model.schema;
        // Converters 'alt' and 'srcset' are added in 'ImageEditing' plugin.
        schema.register('imageInline', {
            inheritAllFrom: '$inlineObject',
            allowAttributes: [
                'alt',
                'src',
                'srcset'
            ]
        });
        // Disallow inline images in captions (for now). This is the best spot to do that because
        // independent packages can introduce captions (ImageCaption, TableCaption, etc.) so better this
        // be future-proof.
        schema.addChildCheck((context, childDefinition)=>{
            if (context.endsWith('caption') && childDefinition.name === 'imageInline') {
                return false;
            }
        });
        this._setupConversion();
        if (editor.plugins.has('ImageBlockEditing')) {
            editor.commands.add('imageTypeInline', new ImageTypeCommand(this.editor, 'imageInline'));
            this._setupClipboardIntegration();
        }
    }
    /**
     * Configures conversion pipelines to support upcasting and downcasting
     * inline images (inline image widgets) and their attributes.
     */ _setupConversion() {
        const editor = this.editor;
        const t = editor.t;
        const conversion = editor.conversion;
        const imageUtils = editor.plugins.get('ImageUtils');
        conversion.for('dataDowncast').elementToElement({
            model: 'imageInline',
            view: (modelElement, { writer })=>writer.createEmptyElement('img')
        });
        conversion.for('editingDowncast').elementToStructure({
            model: 'imageInline',
            view: (modelElement, { writer })=>imageUtils.toImageWidget(createInlineImageViewElement(writer), writer, t('image widget'))
        });
        conversion.for('downcast').add(downcastImageAttribute(imageUtils, 'imageInline', 'src')).add(downcastImageAttribute(imageUtils, 'imageInline', 'alt')).add(downcastSrcsetAttribute(imageUtils, 'imageInline'));
        // More image related upcasts are in 'ImageEditing' plugin.
        conversion.for('upcast').elementToElement({
            view: getImgViewElementMatcher(editor, 'imageInline'),
            model: (viewImage, { writer })=>writer.createElement('imageInline', viewImage.hasAttribute('src') ? {
                    src: viewImage.getAttribute('src')
                } : undefined)
        });
    }
    /**
     * Integrates the plugin with the clipboard pipeline.
     *
     * Idea is that the feature should recognize the user's intent when an **block** image is
     * pasted or dropped. If such an image is pasted/dropped into a non-empty block
     * (e.g. a paragraph with some text) it gets converted into an inline image on the fly.
     *
     * We assume this is the user's intent if they decided to put their image there.
     *
     * **Note**: If a block image has a caption, it will not be converted to an inline image
     * to avoid the confusion. Captions are added on purpose and they should never be lost
     * in the clipboard pipeline.
     *
     * See the `ImageBlockEditing` for the similar integration that works in the opposite direction.
     *
     * The feature also sets image `width` and `height` attributes when pasting.
     */ _setupClipboardIntegration() {
        const editor = this.editor;
        const model = editor.model;
        const editingView = editor.editing.view;
        const imageUtils = editor.plugins.get('ImageUtils');
        const clipboardPipeline = editor.plugins.get('ClipboardPipeline');
        this.listenTo(clipboardPipeline, 'inputTransformation', (evt, data)=>{
            const docFragmentChildren = Array.from(data.content.getChildren());
            let modelRange;
            // Make sure only <figure class="image"></figure> elements are dropped or pasted. Otherwise, if there some other HTML
            // mixed up, this should be handled as a regular paste.
            if (!docFragmentChildren.every(imageUtils.isBlockImageView)) {
                return;
            }
            // When drag and dropping, data.targetRanges specifies where to drop because
            // this is usually a different place than the current model selection (the user
            // uses a drop marker to specify the drop location).
            if (data.targetRanges) {
                modelRange = editor.editing.mapper.toModelRange(data.targetRanges[0]);
            } else {
                modelRange = model.document.selection.getFirstRange();
            }
            const selection = model.createSelection(modelRange);
            // Convert block images into inline images only when pasting or dropping into non-empty blocks
            // and when the block is not an object (e.g. pasting to replace another widget).
            if (determineImageTypeForInsertionAtSelection(model.schema, selection) === 'imageInline') {
                const writer = new UpcastWriter(editingView.document);
                // Unwrap <figure class="image"><img .../></figure> -> <img ... />
                // but <figure class="image"><img .../><figcaption>...</figcaption></figure> -> stays the same
                const inlineViewImages = docFragmentChildren.map((blockViewImage)=>{
                    // If there's just one child, it can be either <img /> or <a><img></a>.
                    // If there are other children than <img>, this means that the block image
                    // has a caption or some other features and this kind of image should be
                    // pasted/dropped without modifications.
                    if (blockViewImage.childCount === 1) {
                        // Pass the attributes which are present only in the <figure> to the <img>
                        // (e.g. the style="width:10%" attribute applied by the ImageResize plugin).
                        Array.from(blockViewImage.getAttributes()).forEach((attribute)=>writer.setAttribute(...attribute, imageUtils.findViewImgElement(blockViewImage)));
                        return blockViewImage.getChild(0);
                    } else {
                        return blockViewImage;
                    }
                });
                data.content = writer.createDocumentFragment(inlineViewImages);
            }
        });
        this.listenTo(clipboardPipeline, 'contentInsertion', (evt, data)=>{
            if (data.method !== 'paste') {
                return;
            }
            model.change((writer)=>{
                const range = writer.createRangeIn(data.content);
                for (const item of range.getItems()){
                    if (item.is('element', 'imageInline')) {
                        imageUtils.setImageNaturalSizeAttributes(item);
                    }
                }
            });
        });
    }
}

class ImageInline extends Plugin {
    /**
     * @inheritDoc
     */ static get requires() {
        return [
            ImageInlineEditing,
            Widget,
            ImageTextAlternative,
            ImageInsertUI
        ];
    }
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'ImageInline';
    }
}

class Image extends Plugin {
    /**
     * @inheritDoc
     */ static get requires() {
        return [
            ImageBlock,
            ImageInline
        ];
    }
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'Image';
    }
}

class ImageCaptionUtils extends Plugin {
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'ImageCaptionUtils';
    }
    /**
     * @inheritDoc
     */ static get requires() {
        return [
            ImageUtils
        ];
    }
    /**
     * Returns the caption model element from a given image element. Returns `null` if no caption is found.
     */ getCaptionFromImageModelElement(imageModelElement) {
        for (const node of imageModelElement.getChildren()){
            if (!!node && node.is('element', 'caption')) {
                return node;
            }
        }
        return null;
    }
    /**
     * Returns the caption model element for a model selection. Returns `null` if the selection has no caption element ancestor.
     */ getCaptionFromModelSelection(selection) {
        const imageUtils = this.editor.plugins.get('ImageUtils');
        const captionElement = selection.getFirstPosition().findAncestor('caption');
        if (!captionElement) {
            return null;
        }
        if (imageUtils.isBlockImage(captionElement.parent)) {
            return captionElement;
        }
        return null;
    }
    /**
     * {@link module:engine/view/matcher~Matcher} pattern. Checks if a given element is a `<figcaption>` element that is placed
     * inside the image `<figure>` element.
     * @returns Returns the object accepted by {@link module:engine/view/matcher~Matcher} or `null` if the element
     * cannot be matched.
     */ matchImageCaptionViewElement(element) {
        const imageUtils = this.editor.plugins.get('ImageUtils');
        // Convert only captions for images.
        if (element.name == 'figcaption' && imageUtils.isBlockImageView(element.parent)) {
            return {
                name: true
            };
        }
        return null;
    }
}

class ToggleImageCaptionCommand extends Command {
    /**
     * @inheritDoc
     */ refresh() {
        const editor = this.editor;
        const imageCaptionUtils = editor.plugins.get('ImageCaptionUtils');
        const imageUtils = editor.plugins.get('ImageUtils');
        // Only block images can get captions.
        if (!editor.plugins.has(ImageBlockEditing)) {
            this.isEnabled = false;
            this.value = false;
            return;
        }
        const selection = editor.model.document.selection;
        const selectedElement = selection.getSelectedElement();
        if (!selectedElement) {
            const ancestorCaptionElement = imageCaptionUtils.getCaptionFromModelSelection(selection);
            this.isEnabled = !!ancestorCaptionElement;
            this.value = !!ancestorCaptionElement;
            return;
        }
        // Block images support captions by default but the command should also be enabled for inline
        // images because toggling the caption when one is selected should convert it into a block image.
        this.isEnabled = imageUtils.isImage(selectedElement);
        if (!this.isEnabled) {
            this.value = false;
        } else {
            this.value = !!imageCaptionUtils.getCaptionFromImageModelElement(selectedElement);
        }
    }
    /**
     * Executes the command.
     *
     * ```ts
     * editor.execute( 'toggleImageCaption' );
     * ```
     *
     * @param options Options for the executed command.
     * @param options.focusCaptionOnShow When true and the caption shows up, the selection will be moved into it straight away.
     * @fires execute
     */ execute(options = {}) {
        const { focusCaptionOnShow } = options;
        this.editor.model.change((writer)=>{
            if (this.value) {
                this._hideImageCaption(writer);
            } else {
                this._showImageCaption(writer, focusCaptionOnShow);
            }
        });
    }
    /**
     * Shows the caption of the `<imageBlock>` or `<imageInline>`. Also:
     *
     * * it converts `<imageInline>` to `<imageBlock>` to show the caption,
     * * it attempts to restore the caption content from the `ImageCaptionEditing` caption registry,
     * * it moves the selection to the caption right away, it the `focusCaptionOnShow` option was set.
     */ _showImageCaption(writer, focusCaptionOnShow) {
        const model = this.editor.model;
        const selection = model.document.selection;
        const imageCaptionEditing = this.editor.plugins.get('ImageCaptionEditing');
        const imageUtils = this.editor.plugins.get('ImageUtils');
        let selectedImage = selection.getSelectedElement();
        const savedCaption = imageCaptionEditing._getSavedCaption(selectedImage);
        // Convert imageInline -> image first.
        if (imageUtils.isInlineImage(selectedImage)) {
            this.editor.execute('imageTypeBlock');
            // Executing the command created a new model element. Let's pick it again.
            selectedImage = selection.getSelectedElement();
        }
        // Try restoring the caption from the ImageCaptionEditing plugin storage.
        const newCaptionElement = savedCaption || writer.createElement('caption');
        writer.append(newCaptionElement, selectedImage);
        if (focusCaptionOnShow) {
            writer.setSelection(newCaptionElement, 'in');
        }
    }
    /**
     * Hides the caption of a selected image (or an image caption the selection is anchored to).
     *
     * The content of the caption is stored in the `ImageCaptionEditing` caption registry to make this
     * a reversible action.
     */ _hideImageCaption(writer) {
        const editor = this.editor;
        const selection = editor.model.document.selection;
        const imageCaptionEditing = editor.plugins.get('ImageCaptionEditing');
        const imageCaptionUtils = editor.plugins.get('ImageCaptionUtils');
        let selectedImage = selection.getSelectedElement();
        let captionElement;
        if (selectedImage) {
            captionElement = imageCaptionUtils.getCaptionFromImageModelElement(selectedImage);
        } else {
            captionElement = imageCaptionUtils.getCaptionFromModelSelection(selection);
            selectedImage = captionElement.parent;
        }
        // Store the caption content so it can be restored quickly if the user changes their mind even if they toggle image<->imageInline.
        imageCaptionEditing._saveCaption(selectedImage, captionElement);
        writer.setSelection(selectedImage, 'on');
        writer.remove(captionElement);
    }
}

class ImageCaptionEditing extends Plugin {
    /**
     * @inheritDoc
     */ static get requires() {
        return [
            ImageUtils,
            ImageCaptionUtils
        ];
    }
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'ImageCaptionEditing';
    }
    /**
     * @inheritDoc
     */ init() {
        const editor = this.editor;
        const schema = editor.model.schema;
        // Schema configuration.
        if (!schema.isRegistered('caption')) {
            schema.register('caption', {
                allowIn: 'imageBlock',
                allowContentOf: '$block',
                isLimit: true
            });
        } else {
            schema.extend('caption', {
                allowIn: 'imageBlock'
            });
        }
        editor.commands.add('toggleImageCaption', new ToggleImageCaptionCommand(this.editor));
        this._setupConversion();
        this._setupImageTypeCommandsIntegration();
        this._registerCaptionReconversion();
    }
    /**
     * Configures conversion pipelines to support upcasting and downcasting
     * image captions.
     */ _setupConversion() {
        const editor = this.editor;
        const view = editor.editing.view;
        const imageUtils = editor.plugins.get('ImageUtils');
        const imageCaptionUtils = editor.plugins.get('ImageCaptionUtils');
        const t = editor.t;
        // View -> model converter for the data pipeline.
        editor.conversion.for('upcast').elementToElement({
            view: (element)=>imageCaptionUtils.matchImageCaptionViewElement(element),
            model: 'caption'
        });
        // Model -> view converter for the data pipeline.
        editor.conversion.for('dataDowncast').elementToElement({
            model: 'caption',
            view: (modelElement, { writer })=>{
                if (!imageUtils.isBlockImage(modelElement.parent)) {
                    return null;
                }
                return writer.createContainerElement('figcaption');
            }
        });
        // Model -> view converter for the editing pipeline.
        editor.conversion.for('editingDowncast').elementToElement({
            model: 'caption',
            view: (modelElement, { writer })=>{
                if (!imageUtils.isBlockImage(modelElement.parent)) {
                    return null;
                }
                const figcaptionElement = writer.createEditableElement('figcaption');
                writer.setCustomProperty('imageCaption', true, figcaptionElement);
                figcaptionElement.placeholder = t('Enter image caption');
                enablePlaceholder({
                    view,
                    element: figcaptionElement,
                    keepOnFocus: true
                });
                const imageAlt = modelElement.parent.getAttribute('alt');
                const label = imageAlt ? t('Caption for image: %0', [
                    imageAlt
                ]) : t('Caption for the image');
                return toWidgetEditable(figcaptionElement, writer, {
                    label
                });
            }
        });
    }
    /**
     * Integrates with {@link module:image/image/imagetypecommand~ImageTypeCommand image type commands}
     * to make sure the caption is preserved when the type of an image changes so it can be restored
     * in the future if the user decides they want their caption back.
     */ _setupImageTypeCommandsIntegration() {
        const editor = this.editor;
        const imageUtils = editor.plugins.get('ImageUtils');
        const imageCaptionUtils = editor.plugins.get('ImageCaptionUtils');
        const imageTypeInlineCommand = editor.commands.get('imageTypeInline');
        const imageTypeBlockCommand = editor.commands.get('imageTypeBlock');
        const handleImageTypeChange = (evt)=>{
            // The image type command execution can be unsuccessful.
            if (!evt.return) {
                return;
            }
            const { oldElement, newElement } = evt.return;
            /* istanbul ignore if: paranoid check -- @preserve */ if (!oldElement) {
                return;
            }
            if (imageUtils.isBlockImage(oldElement)) {
                const oldCaptionElement = imageCaptionUtils.getCaptionFromImageModelElement(oldElement);
                // If the old element was a captioned block image (the caption was visible),
                // simply save it so it can be restored.
                if (oldCaptionElement) {
                    this._saveCaption(newElement, oldCaptionElement);
                    return;
                }
            }
            const savedOldElementCaption = this._getSavedCaption(oldElement);
            // If either:
            //
            // * the block image didn't have a visible caption,
            // * the block image caption was hidden (and already saved),
            // * the inline image was passed
            //
            // just try to "pass" the saved caption from the old image to the new image
            // so it can be retrieved in the future if the user wants it back.
            if (savedOldElementCaption) {
                // Note: Since we're writing to a WeakMap, we don't bother with removing the
                // [ oldElement, savedOldElementCaption ] pair from it.
                this._saveCaption(newElement, savedOldElementCaption);
            }
        };
        // Presence of the commands depends on the Image(Inline|Block)Editing plugins loaded in the editor.
        if (imageTypeInlineCommand) {
            this.listenTo(imageTypeInlineCommand, 'execute', handleImageTypeChange, {
                priority: 'low'
            });
        }
        if (imageTypeBlockCommand) {
            this.listenTo(imageTypeBlockCommand, 'execute', handleImageTypeChange, {
                priority: 'low'
            });
        }
    }
    /**
     * Returns the saved {@link module:engine/model/element~Element#toJSON JSONified} caption
     * of an image model element.
     *
     * See {@link #_saveCaption}.
     *
     * @internal
     * @param imageModelElement The model element the caption should be returned for.
     * @returns The model caption element or `null` if there is none.
     */ _getSavedCaption(imageModelElement) {
        const jsonObject = this._savedCaptionsMap.get(imageModelElement);
        return jsonObject ? Element.fromJSON(jsonObject) : null;
    }
    /**
     * Saves a {@link module:engine/model/element~Element#toJSON JSONified} caption for
     * an image element to allow restoring it in the future.
     *
     * A caption is saved every time it gets hidden and/or the type of an image changes. The
     * user should be able to restore it on demand.
     *
     * **Note**: The caption cannot be stored in the image model element attribute because,
     * for instance, when the model state propagates to collaborators, the attribute would get
     * lost (mainly because it does not convert to anything when the caption is hidden) and
     * the states of collaborators' models would de-synchronize causing numerous issues.
     *
     * See {@link #_getSavedCaption}.
     *
     * @internal
     * @param imageModelElement The model element the caption is saved for.
     * @param caption The caption model element to be saved.
     */ _saveCaption(imageModelElement, caption) {
        this._savedCaptionsMap.set(imageModelElement, caption.toJSON());
    }
    /**
     * Reconverts image caption when image alt attribute changes.
     * The change of alt attribute is reflected in caption's aria-label attribute.
     */ _registerCaptionReconversion() {
        const editor = this.editor;
        const model = editor.model;
        const imageUtils = editor.plugins.get('ImageUtils');
        const imageCaptionUtils = editor.plugins.get('ImageCaptionUtils');
        model.document.on('change:data', ()=>{
            const changes = model.document.differ.getChanges();
            for (const change of changes){
                if (change.attributeKey !== 'alt') {
                    continue;
                }
                const image = change.range.start.nodeAfter;
                if (imageUtils.isBlockImage(image)) {
                    const caption = imageCaptionUtils.getCaptionFromImageModelElement(image);
                    if (!caption) {
                        return;
                    }
                    editor.editing.reconvertItem(caption);
                }
            }
        });
    }
    /**
     * @inheritDoc
     */ constructor(editor){
        super(editor);
        this._savedCaptionsMap = new WeakMap();
    }
}

class ImageCaptionUI extends Plugin {
    /**
     * @inheritDoc
     */ static get requires() {
        return [
            ImageCaptionUtils
        ];
    }
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'ImageCaptionUI';
    }
    /**
     * @inheritDoc
     */ init() {
        const editor = this.editor;
        const editingView = editor.editing.view;
        const imageCaptionUtils = editor.plugins.get('ImageCaptionUtils');
        const t = editor.t;
        editor.ui.componentFactory.add('toggleImageCaption', (locale)=>{
            const command = editor.commands.get('toggleImageCaption');
            const view = new ButtonView(locale);
            view.set({
                icon: icons.caption,
                tooltip: true,
                isToggleable: true
            });
            view.bind('isOn', 'isEnabled').to(command, 'value', 'isEnabled');
            view.bind('label').to(command, 'value', (value)=>value ? t('Toggle caption off') : t('Toggle caption on'));
            this.listenTo(view, 'execute', ()=>{
                editor.execute('toggleImageCaption', {
                    focusCaptionOnShow: true
                });
                // Scroll to the selection and highlight the caption if the caption showed up.
                const modelCaptionElement = imageCaptionUtils.getCaptionFromModelSelection(editor.model.document.selection);
                if (modelCaptionElement) {
                    const figcaptionElement = editor.editing.mapper.toViewElement(modelCaptionElement);
                    editingView.scrollToTheSelection();
                    editingView.change((writer)=>{
                        writer.addClass('image__caption_highlighted', figcaptionElement);
                    });
                }
                editor.editing.view.focus();
            });
            return view;
        });
    }
}

class ImageCaption extends Plugin {
    /**
     * @inheritDoc
     */ static get requires() {
        return [
            ImageCaptionEditing,
            ImageCaptionUI
        ];
    }
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'ImageCaption';
    }
}

/**
 * Creates a regular expression used to test for image files.
 *
 * ```ts
 * const imageType = createImageTypeRegExp( [ 'png', 'jpeg', 'svg+xml', 'vnd.microsoft.icon' ] );
 *
 * console.log( 'is supported image', imageType.test( file.type ) );
 * ```
 */ function createImageTypeRegExp(types) {
    // Sanitize the MIME type name which may include: "+", "-" or ".".
    const regExpSafeNames = types.map((type)=>type.replace('+', '\\+'));
    return new RegExp(`^image\\/(${regExpSafeNames.join('|')})$`);
}
/**
 * Creates a promise that fetches the image local source (Base64 or blob) and resolves with a `File` object.
 *
 * @param image Image whose source to fetch.
 * @returns A promise which resolves when an image source is fetched and converted to a `File` instance.
 * It resolves with a `File` object. If there were any errors during file processing, the promise will be rejected.
 */ function fetchLocalImage(image) {
    return new Promise((resolve, reject)=>{
        const imageSrc = image.getAttribute('src');
        // Fetch works asynchronously and so does not block browser UI when processing data.
        fetch(imageSrc).then((resource)=>resource.blob()).then((blob)=>{
            const mimeType = getImageMimeType(blob, imageSrc);
            const ext = mimeType.replace('image/', '');
            const filename = `image.${ext}`;
            const file = new File([
                blob
            ], filename, {
                type: mimeType
            });
            resolve(file);
        }).catch((err)=>{
            // Fetch fails only, if it can't make a request due to a network failure or if anything prevented the request
            // from completing, i.e. the Content Security Policy rules. It is not possible to detect the exact cause of failure,
            // so we are just trying the fallback solution, if general TypeError is thrown.
            return err && err.name === 'TypeError' ? convertLocalImageOnCanvas(imageSrc).then(resolve).catch(reject) : reject(err);
        });
    });
}
/**
 * Checks whether a given node is an image element with a local source (Base64 or blob).
 *
 * @param node The node to check.
 */ function isLocalImage(imageUtils, node) {
    if (!imageUtils.isInlineImageView(node) || !node.getAttribute('src')) {
        return false;
    }
    return !!node.getAttribute('src').match(/^data:image\/\w+;base64,/g) || !!node.getAttribute('src').match(/^blob:/g);
}
/**
 * Extracts an image type based on its blob representation or its source.
 * @param blob Image blob representation.
 * @param src Image `src` attribute value.
 */ function getImageMimeType(blob, src) {
    if (blob.type) {
        return blob.type;
    } else if (src.match(/data:(image\/\w+);base64/)) {
        return src.match(/data:(image\/\w+);base64/)[1].toLowerCase();
    } else {
        // Fallback to 'jpeg' as common extension.
        return 'image/jpeg';
    }
}
/**
 * Creates a promise that converts the image local source (Base64 or blob) to a blob using canvas and resolves
 * with a `File` object.
 * @param imageSrc Image `src` attribute value.
 * @returns A promise which resolves when an image source is converted to a `File` instance.
 * It resolves with a `File` object. If there were any errors during file processing, the promise will be rejected.
 */ function convertLocalImageOnCanvas(imageSrc) {
    return getBlobFromCanvas(imageSrc).then((blob)=>{
        const mimeType = getImageMimeType(blob, imageSrc);
        const ext = mimeType.replace('image/', '');
        const filename = `image.${ext}`;
        return new File([
            blob
        ], filename, {
            type: mimeType
        });
    });
}
/**
 * Creates a promise that resolves with a `Blob` object converted from the image source (Base64 or blob).
 * @param imageSrc Image `src` attribute value.
 */ function getBlobFromCanvas(imageSrc) {
    return new Promise((resolve, reject)=>{
        const image = global.document.createElement('img');
        image.addEventListener('load', ()=>{
            const canvas = global.document.createElement('canvas');
            canvas.width = image.width;
            canvas.height = image.height;
            const ctx = canvas.getContext('2d');
            ctx.drawImage(image, 0, 0);
            canvas.toBlob((blob)=>blob ? resolve(blob) : reject());
        });
        image.addEventListener('error', ()=>reject());
        image.src = imageSrc;
    });
}

class ImageUploadUI extends Plugin {
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'ImageUploadUI';
    }
    /**
     * @inheritDoc
     */ init() {
        const editor = this.editor;
        const t = editor.t;
        const toolbarComponentCreator = ()=>{
            const button = this._createButton(FileDialogButtonView);
            button.set({
                label: t('Upload image from computer'),
                tooltip: true
            });
            return button;
        };
        // Setup `uploadImage` button and add `imageUpload` button as an alias for backward compatibility.
        editor.ui.componentFactory.add('uploadImage', toolbarComponentCreator);
        editor.ui.componentFactory.add('imageUpload', toolbarComponentCreator);
        editor.ui.componentFactory.add('menuBar:uploadImage', ()=>{
            const button = this._createButton(MenuBarMenuListItemFileDialogButtonView);
            button.label = t('Image from computer');
            return button;
        });
        if (editor.plugins.has('ImageInsertUI')) {
            const imageInsertUI = editor.plugins.get('ImageInsertUI');
            imageInsertUI.registerIntegration({
                name: 'upload',
                observable: ()=>editor.commands.get('uploadImage'),
                buttonViewCreator: ()=>{
                    const uploadImageButton = editor.ui.componentFactory.create('uploadImage');
                    uploadImageButton.bind('label').to(imageInsertUI, 'isImageSelected', (isImageSelected)=>isImageSelected ? t('Replace image from computer') : t('Upload image from computer'));
                    return uploadImageButton;
                },
                formViewCreator: ()=>{
                    const uploadImageButton = editor.ui.componentFactory.create('uploadImage');
                    uploadImageButton.withText = true;
                    uploadImageButton.bind('label').to(imageInsertUI, 'isImageSelected', (isImageSelected)=>isImageSelected ? t('Replace from computer') : t('Upload from computer'));
                    uploadImageButton.on('execute', ()=>{
                        imageInsertUI.dropdownView.isOpen = false;
                    });
                    return uploadImageButton;
                }
            });
        }
    }
    /**
     * Creates a button for image upload command to use either in toolbar or in menu bar.
     */ _createButton(ButtonClass) {
        const editor = this.editor;
        const locale = editor.locale;
        const command = editor.commands.get('uploadImage');
        const imageTypes = editor.config.get('image.upload.types');
        const imageTypesRegExp = createImageTypeRegExp(imageTypes);
        const view = new ButtonClass(editor.locale);
        const t = locale.t;
        view.set({
            acceptedType: imageTypes.map((type)=>`image/${type}`).join(','),
            allowMultipleFiles: true,
            label: t('Upload image from computer'),
            icon: icons.imageUpload
        });
        view.bind('isEnabled').to(command);
        view.on('done', (evt, files)=>{
            const imagesToUpload = Array.from(files).filter((file)=>imageTypesRegExp.test(file.type));
            if (imagesToUpload.length) {
                editor.execute('uploadImage', {
                    file: imagesToUpload
                });
                editor.editing.view.focus();
            }
        });
        return view;
    }
}

class ImageUploadProgress extends Plugin {
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'ImageUploadProgress';
    }
    /**
     * @inheritDoc
     */ init() {
        const editor = this.editor;
        // Upload status change - update image's view according to that status.
        if (editor.plugins.has('ImageBlockEditing')) {
            editor.editing.downcastDispatcher.on('attribute:uploadStatus:imageBlock', this.uploadStatusChange);
        }
        if (editor.plugins.has('ImageInlineEditing')) {
            editor.editing.downcastDispatcher.on('attribute:uploadStatus:imageInline', this.uploadStatusChange);
        }
    }
    /**
     * @inheritDoc
     */ constructor(editor){
        super(editor);
        /**
         * This method is called each time the image `uploadStatus` attribute is changed.
         *
         * @param evt An object containing information about the fired event.
         * @param data Additional information about the change.
         */ this.uploadStatusChange = (evt, data, conversionApi)=>{
            const editor = this.editor;
            const modelImage = data.item;
            const uploadId = modelImage.getAttribute('uploadId');
            if (!conversionApi.consumable.consume(data.item, evt.name)) {
                return;
            }
            const imageUtils = editor.plugins.get('ImageUtils');
            const fileRepository = editor.plugins.get(FileRepository);
            const status = uploadId ? data.attributeNewValue : null;
            const placeholder = this.placeholder;
            const viewFigure = editor.editing.mapper.toViewElement(modelImage);
            const viewWriter = conversionApi.writer;
            if (status == 'reading') {
                // Start "appearing" effect and show placeholder with infinite progress bar on the top
                // while image is read from disk.
                _startAppearEffect(viewFigure, viewWriter);
                _showPlaceholder(imageUtils, placeholder, viewFigure, viewWriter);
                return;
            }
            // Show progress bar on the top of the image when image is uploading.
            if (status == 'uploading') {
                const loader = fileRepository.loaders.get(uploadId);
                // Start appear effect if needed - see https://github.com/ckeditor/ckeditor5-image/issues/191.
                _startAppearEffect(viewFigure, viewWriter);
                if (!loader) {
                    // There is no loader associated with uploadId - this means that image came from external changes.
                    // In such cases we still want to show the placeholder until image is fully uploaded.
                    // Show placeholder if needed - see https://github.com/ckeditor/ckeditor5-image/issues/191.
                    _showPlaceholder(imageUtils, placeholder, viewFigure, viewWriter);
                } else {
                    // Hide placeholder and initialize progress bar showing upload progress.
                    _hidePlaceholder(viewFigure, viewWriter);
                    _showProgressBar(viewFigure, viewWriter, loader, editor.editing.view);
                    _displayLocalImage(imageUtils, viewFigure, viewWriter, loader);
                }
                return;
            }
            if (status == 'complete' && fileRepository.loaders.get(uploadId)) {
                _showCompleteIcon(viewFigure, viewWriter, editor.editing.view);
            }
            // Clean up.
            _hideProgressBar(viewFigure, viewWriter);
            _hidePlaceholder(viewFigure, viewWriter);
            _stopAppearEffect(viewFigure, viewWriter);
        };
        this.placeholder = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
    }
}
/**
 * Adds ck-appear class to the image figure if one is not already applied.
 */ function _startAppearEffect(viewFigure, writer) {
    if (!viewFigure.hasClass('ck-appear')) {
        writer.addClass('ck-appear', viewFigure);
    }
}
/**
 * Removes ck-appear class to the image figure if one is not already removed.
 */ function _stopAppearEffect(viewFigure, writer) {
    writer.removeClass('ck-appear', viewFigure);
}
/**
 * Shows placeholder together with infinite progress bar on given image figure.
 */ function _showPlaceholder(imageUtils, placeholder, viewFigure, writer) {
    if (!viewFigure.hasClass('ck-image-upload-placeholder')) {
        writer.addClass('ck-image-upload-placeholder', viewFigure);
    }
    const viewImg = imageUtils.findViewImgElement(viewFigure);
    if (viewImg.getAttribute('src') !== placeholder) {
        writer.setAttribute('src', placeholder, viewImg);
    }
    if (!_getUIElement(viewFigure, 'placeholder')) {
        writer.insert(writer.createPositionAfter(viewImg), _createPlaceholder(writer));
    }
}
/**
 * Removes placeholder together with infinite progress bar on given image figure.
 */ function _hidePlaceholder(viewFigure, writer) {
    if (viewFigure.hasClass('ck-image-upload-placeholder')) {
        writer.removeClass('ck-image-upload-placeholder', viewFigure);
    }
    _removeUIElement(viewFigure, writer, 'placeholder');
}
/**
 * Shows progress bar displaying upload progress.
 * Attaches it to the file loader to update when upload percentace is changed.
 */ function _showProgressBar(viewFigure, writer, loader, view) {
    const progressBar = _createProgressBar(writer);
    writer.insert(writer.createPositionAt(viewFigure, 'end'), progressBar);
    // Update progress bar width when uploadedPercent is changed.
    loader.on('change:uploadedPercent', (evt, name, value)=>{
        view.change((writer)=>{
            writer.setStyle('width', value + '%', progressBar);
        });
    });
}
/**
 * Hides upload progress bar.
 */ function _hideProgressBar(viewFigure, writer) {
    _removeUIElement(viewFigure, writer, 'progressBar');
}
/**
 * Shows complete icon and hides after a certain amount of time.
 */ function _showCompleteIcon(viewFigure, writer, view) {
    const completeIcon = writer.createUIElement('div', {
        class: 'ck-image-upload-complete-icon'
    });
    writer.insert(writer.createPositionAt(viewFigure, 'end'), completeIcon);
    setTimeout(()=>{
        view.change((writer)=>writer.remove(writer.createRangeOn(completeIcon)));
    }, 3000);
}
/**
 * Create progress bar element using {@link module:engine/view/uielement~UIElement}.
 */ function _createProgressBar(writer) {
    const progressBar = writer.createUIElement('div', {
        class: 'ck-progress-bar'
    });
    writer.setCustomProperty('progressBar', true, progressBar);
    return progressBar;
}
/**
 * Create placeholder element using {@link module:engine/view/uielement~UIElement}.
 */ function _createPlaceholder(writer) {
    const placeholder = writer.createUIElement('div', {
        class: 'ck-upload-placeholder-loader'
    });
    writer.setCustomProperty('placeholder', true, placeholder);
    return placeholder;
}
/**
 * Returns {@link module:engine/view/uielement~UIElement} of given unique property from image figure element.
 * Returns `undefined` if element is not found.
 */ function _getUIElement(imageFigure, uniqueProperty) {
    for (const child of imageFigure.getChildren()){
        if (child.getCustomProperty(uniqueProperty)) {
            return child;
        }
    }
}
/**
 * Removes {@link module:engine/view/uielement~UIElement} of given unique property from image figure element.
 */ function _removeUIElement(viewFigure, writer, uniqueProperty) {
    const element = _getUIElement(viewFigure, uniqueProperty);
    if (element) {
        writer.remove(writer.createRangeOn(element));
    }
}
/**
 * Displays local data from file loader.
 */ function _displayLocalImage(imageUtils, viewFigure, writer, loader) {
    if (loader.data) {
        const viewImg = imageUtils.findViewImgElement(viewFigure);
        writer.setAttribute('src', loader.data, viewImg);
    }
}

class UploadImageCommand extends Command {
    /**
     * @inheritDoc
     */ refresh() {
        const editor = this.editor;
        const imageUtils = editor.plugins.get('ImageUtils');
        const selectedElement = editor.model.document.selection.getSelectedElement();
        // TODO: This needs refactoring.
        this.isEnabled = imageUtils.isImageAllowed() || imageUtils.isImage(selectedElement);
    }
    /**
     * Executes the command.
     *
     * @fires execute
     * @param options Options for the executed command.
     * @param options.file The image file or an array of image files to upload.
     */ execute(options) {
        const files = toArray(options.file);
        const selection = this.editor.model.document.selection;
        const imageUtils = this.editor.plugins.get('ImageUtils');
        // In case of multiple files, each file (starting from the 2nd) will be inserted at a position that
        // follows the previous one. That will move the selection and, to stay on the safe side and make sure
        // all images inherit the same selection attributes, they are collected beforehand.
        //
        // Applying these attributes ensures, for instance, that inserting an (inline) image into a link does
        // not split that link but preserves its continuity.
        //
        // Note: Selection attributes that do not make sense for images will be filtered out by insertImage() anyway.
        const selectionAttributes = Object.fromEntries(selection.getAttributes());
        files.forEach((file, index)=>{
            const selectedElement = selection.getSelectedElement();
            // Inserting of an inline image replace the selected element and make a selection on the inserted image.
            // Therefore inserting multiple inline images requires creating position after each element.
            if (index && selectedElement && imageUtils.isImage(selectedElement)) {
                const position = this.editor.model.createPositionAfter(selectedElement);
                this._uploadImage(file, selectionAttributes, position);
            } else {
                this._uploadImage(file, selectionAttributes);
            }
        });
    }
    /**
     * Handles uploading single file.
     */ _uploadImage(file, attributes, position) {
        const editor = this.editor;
        const fileRepository = editor.plugins.get(FileRepository);
        const loader = fileRepository.createLoader(file);
        const imageUtils = editor.plugins.get('ImageUtils');
        // Do not throw when upload adapter is not set. FileRepository will log an error anyway.
        if (!loader) {
            return;
        }
        imageUtils.insertImage({
            ...attributes,
            uploadId: loader.id
        }, position);
    }
}

class ImageUploadEditing extends Plugin {
    /**
     * @inheritDoc
     */ static get requires() {
        return [
            FileRepository,
            Notification,
            ClipboardPipeline,
            ImageUtils
        ];
    }
    static get pluginName() {
        return 'ImageUploadEditing';
    }
    /**
     * @inheritDoc
     */ init() {
        const editor = this.editor;
        const doc = editor.model.document;
        const conversion = editor.conversion;
        const fileRepository = editor.plugins.get(FileRepository);
        const imageUtils = editor.plugins.get('ImageUtils');
        const clipboardPipeline = editor.plugins.get('ClipboardPipeline');
        const imageTypes = createImageTypeRegExp(editor.config.get('image.upload.types'));
        const uploadImageCommand = new UploadImageCommand(editor);
        // Register `uploadImage` command and add `imageUpload` command as an alias for backward compatibility.
        editor.commands.add('uploadImage', uploadImageCommand);
        editor.commands.add('imageUpload', uploadImageCommand);
        // Register upcast converter for uploadId.
        conversion.for('upcast').attributeToAttribute({
            view: {
                name: 'img',
                key: 'uploadId'
            },
            model: 'uploadId'
        });
        // Handle pasted images.
        // For every image file, a new file loader is created and a placeholder image is
        // inserted into the content. Then, those images are uploaded once they appear in the model
        // (see Document#change listener below).
        this.listenTo(editor.editing.view.document, 'clipboardInput', (evt, data)=>{
            // Skip if non empty HTML data is included.
            // https://github.com/ckeditor/ckeditor5-upload/issues/68
            if (isHtmlIncluded(data.dataTransfer)) {
                return;
            }
            const images = Array.from(data.dataTransfer.files).filter((file)=>{
                // See https://github.com/ckeditor/ckeditor5-image/pull/254.
                if (!file) {
                    return false;
                }
                return imageTypes.test(file.type);
            });
            if (!images.length) {
                return;
            }
            evt.stop();
            editor.model.change((writer)=>{
                // Set selection to paste target.
                if (data.targetRanges) {
                    writer.setSelection(data.targetRanges.map((viewRange)=>editor.editing.mapper.toModelRange(viewRange)));
                }
                editor.execute('uploadImage', {
                    file: images
                });
            });
        });
        // Handle HTML pasted with images with base64 or blob sources.
        // For every image file, a new file loader is created and a placeholder image is
        // inserted into the content. Then, those images are uploaded once they appear in the model
        // (see Document#change listener below).
        this.listenTo(clipboardPipeline, 'inputTransformation', (evt, data)=>{
            const fetchableImages = Array.from(editor.editing.view.createRangeIn(data.content)).map((value)=>value.item).filter((viewElement)=>isLocalImage(imageUtils, viewElement) && !viewElement.getAttribute('uploadProcessed')).map((viewElement)=>{
                return {
                    promise: fetchLocalImage(viewElement),
                    imageElement: viewElement
                };
            });
            if (!fetchableImages.length) {
                return;
            }
            const writer = new UpcastWriter(editor.editing.view.document);
            for (const fetchableImage of fetchableImages){
                // Set attribute marking that the image was processed already.
                writer.setAttribute('uploadProcessed', true, fetchableImage.imageElement);
                const loader = fileRepository.createLoader(fetchableImage.promise);
                if (loader) {
                    writer.setAttribute('src', '', fetchableImage.imageElement);
                    writer.setAttribute('uploadId', loader.id, fetchableImage.imageElement);
                }
            }
        });
        // Prevents from the browser redirecting to the dropped image.
        editor.editing.view.document.on('dragover', (evt, data)=>{
            data.preventDefault();
        });
        // Upload placeholder images that appeared in the model.
        doc.on('change', ()=>{
            // Note: Reversing changes to start with insertions and only then handle removals. If it was the other way around,
            // loaders for **all** images that land in the $graveyard would abort while in fact only those that were **not** replaced
            // by other images should be aborted.
            const changes = doc.differ.getChanges({
                includeChangesInGraveyard: true
            }).reverse();
            const insertedImagesIds = new Set();
            for (const entry of changes){
                if (entry.type == 'insert' && entry.name != '$text') {
                    const item = entry.position.nodeAfter;
                    const isInsertedInGraveyard = entry.position.root.rootName == '$graveyard';
                    for (const imageElement of getImagesFromChangeItem(editor, item)){
                        // Check if the image element still has upload id.
                        const uploadId = imageElement.getAttribute('uploadId');
                        if (!uploadId) {
                            continue;
                        }
                        // Check if the image is loaded on this client.
                        const loader = fileRepository.loaders.get(uploadId);
                        if (!loader) {
                            continue;
                        }
                        if (isInsertedInGraveyard) {
                            // If the image was inserted to the graveyard for good (**not** replaced by another image),
                            // only then abort the loading process.
                            if (!insertedImagesIds.has(uploadId)) {
                                loader.abort();
                            }
                        } else {
                            // Remember the upload id of the inserted image. If it acted as a replacement for another
                            // image (which landed in the $graveyard), the related loader will not be aborted because
                            // this is still the same image upload.
                            insertedImagesIds.add(uploadId);
                            // Keep the mapping between the upload ID and the image model element so the upload
                            // can later resolve in the context of the correct model element. The model element could
                            // change for the same upload if one image was replaced by another (e.g. image type was changed),
                            // so this may also replace an existing mapping.
                            this._uploadImageElements.set(uploadId, imageElement);
                            if (loader.status == 'idle') {
                                // If the image was inserted into content and has not been loaded yet, start loading it.
                                this._readAndUpload(loader);
                            }
                        }
                    }
                }
            }
        });
        // Set the default handler for feeding the image element with `src` and `srcset` attributes.
        // Also set the natural `width` and `height` attributes (if not already set).
        this.on('uploadComplete', (evt, { imageElement, data })=>{
            const urls = data.urls ? data.urls : data;
            this.editor.model.change((writer)=>{
                writer.setAttribute('src', urls.default, imageElement);
                this._parseAndSetSrcsetAttributeOnImage(urls, imageElement, writer);
                imageUtils.setImageNaturalSizeAttributes(imageElement);
            });
        }, {
            priority: 'low'
        });
    }
    /**
     * @inheritDoc
     */ afterInit() {
        const schema = this.editor.model.schema;
        // Setup schema to allow uploadId and uploadStatus for images.
        // Wait for ImageBlockEditing or ImageInlineEditing to register their elements first,
        // that's why doing this in afterInit() instead of init().
        if (this.editor.plugins.has('ImageBlockEditing')) {
            schema.extend('imageBlock', {
                allowAttributes: [
                    'uploadId',
                    'uploadStatus'
                ]
            });
        }
        if (this.editor.plugins.has('ImageInlineEditing')) {
            schema.extend('imageInline', {
                allowAttributes: [
                    'uploadId',
                    'uploadStatus'
                ]
            });
        }
    }
    /**
     * Reads and uploads an image.
     *
     * The image is read from the disk and as a Base64-encoded string it is set temporarily to
     * `image[src]`. When the image is successfully uploaded, the temporary data is replaced with the target
     * image's URL (the URL to the uploaded image on the server).
     */ _readAndUpload(loader) {
        const editor = this.editor;
        const model = editor.model;
        const t = editor.locale.t;
        const fileRepository = editor.plugins.get(FileRepository);
        const notification = editor.plugins.get(Notification);
        const imageUtils = editor.plugins.get('ImageUtils');
        const imageUploadElements = this._uploadImageElements;
        model.enqueueChange({
            isUndoable: false
        }, (writer)=>{
            writer.setAttribute('uploadStatus', 'reading', imageUploadElements.get(loader.id));
        });
        return loader.read().then(()=>{
            const promise = loader.upload();
            const imageElement = imageUploadElements.get(loader.id);
            // Force re–paint in Safari. Without it, the image will display with a wrong size.
            // https://github.com/ckeditor/ckeditor5/issues/1975
            /* istanbul ignore next -- @preserve */ if (env.isSafari) {
                const viewFigure = editor.editing.mapper.toViewElement(imageElement);
                const viewImg = imageUtils.findViewImgElement(viewFigure);
                editor.editing.view.once('render', ()=>{
                    // Early returns just to be safe. There might be some code ran
                    // in between the outer scope and this callback.
                    if (!viewImg.parent) {
                        return;
                    }
                    const domFigure = editor.editing.view.domConverter.mapViewToDom(viewImg.parent);
                    if (!domFigure) {
                        return;
                    }
                    const originalDisplay = domFigure.style.display;
                    domFigure.style.display = 'none';
                    // Make sure this line will never be removed during minification for having "no effect".
                    domFigure._ckHack = domFigure.offsetHeight;
                    domFigure.style.display = originalDisplay;
                });
            }
            if (editor.ui) {
                editor.ui.ariaLiveAnnouncer.announce(t('Uploading image'));
            }
            model.enqueueChange({
                isUndoable: false
            }, (writer)=>{
                writer.setAttribute('uploadStatus', 'uploading', imageElement);
            });
            return promise;
        }).then((data)=>{
            model.enqueueChange({
                isUndoable: false
            }, (writer)=>{
                const imageElement = imageUploadElements.get(loader.id);
                writer.setAttribute('uploadStatus', 'complete', imageElement);
                if (editor.ui) {
                    editor.ui.ariaLiveAnnouncer.announce(t('Image upload complete'));
                }
                this.fire('uploadComplete', {
                    data,
                    imageElement
                });
            });
            clean();
        }).catch((error)=>{
            if (editor.ui) {
                editor.ui.ariaLiveAnnouncer.announce(t('Error during image upload'));
            }
            // If status is not 'error' nor 'aborted' - throw error because it means that something else went wrong,
            // it might be generic error and it would be real pain to find what is going on.
            if (loader.status !== 'error' && loader.status !== 'aborted') {
                throw error;
            }
            // Might be 'aborted'.
            if (loader.status == 'error' && error) {
                notification.showWarning(error, {
                    title: t('Upload failed'),
                    namespace: 'upload'
                });
            }
            // Permanently remove image from insertion batch.
            model.enqueueChange({
                isUndoable: false
            }, (writer)=>{
                writer.remove(imageUploadElements.get(loader.id));
            });
            clean();
        });
        function clean() {
            model.enqueueChange({
                isUndoable: false
            }, (writer)=>{
                const imageElement = imageUploadElements.get(loader.id);
                writer.removeAttribute('uploadId', imageElement);
                writer.removeAttribute('uploadStatus', imageElement);
                imageUploadElements.delete(loader.id);
            });
            fileRepository.destroyLoader(loader);
        }
    }
    /**
     * Creates the `srcset` attribute based on a given file upload response and sets it as an attribute to a specific image element.
     *
     * @param data Data object from which `srcset` will be created.
     * @param image The image element on which the `srcset` attribute will be set.
     */ _parseAndSetSrcsetAttributeOnImage(data, image, writer) {
        // Srcset attribute for responsive images support.
        let maxWidth = 0;
        const srcsetAttribute = Object.keys(data)// Filter out keys that are not integers.
        .filter((key)=>{
            const width = parseInt(key, 10);
            if (!isNaN(width)) {
                maxWidth = Math.max(maxWidth, width);
                return true;
            }
        })// Convert each key to srcset entry.
        .map((key)=>`${data[key]} ${key}w`)// Join all entries.
        .join(', ');
        if (srcsetAttribute != '') {
            const attributes = {
                srcset: srcsetAttribute
            };
            if (!image.hasAttribute('width') && !image.hasAttribute('height')) {
                attributes.width = maxWidth;
            }
            writer.setAttributes(attributes, image);
        }
    }
    /**
     * @inheritDoc
     */ constructor(editor){
        super(editor);
        editor.config.define('image', {
            upload: {
                types: [
                    'jpeg',
                    'png',
                    'gif',
                    'bmp',
                    'webp',
                    'tiff'
                ]
            }
        });
        this._uploadImageElements = new Map();
    }
}
/**
 * Returns `true` if non-empty `text/html` is included in the data transfer.
 */ function isHtmlIncluded(dataTransfer) {
    return Array.from(dataTransfer.types).includes('text/html') && dataTransfer.getData('text/html') !== '';
}
function getImagesFromChangeItem(editor, item) {
    const imageUtils = editor.plugins.get('ImageUtils');
    return Array.from(editor.model.createRangeOn(item)).filter((value)=>imageUtils.isImage(value.item)).map((value)=>value.item);
}

class ImageUpload extends Plugin {
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'ImageUpload';
    }
    /**
     * @inheritDoc
     */ static get requires() {
        return [
            ImageUploadEditing,
            ImageUploadUI,
            ImageUploadProgress
        ];
    }
}

class ImageInsertUrlView extends View {
    /**
     * @inheritDoc
     */ render() {
        super.render();
        for (const view of this._focusables){
            this.focusTracker.add(view.element);
        }
        // Start listening for the keystrokes coming from #element.
        this.keystrokes.listenTo(this.element);
    }
    /**
     * @inheritDoc
     */ destroy() {
        super.destroy();
        this.focusTracker.destroy();
        this.keystrokes.destroy();
    }
    /**
     * Creates the {@link #urlInputView}.
     */ _createUrlInputView() {
        const locale = this.locale;
        const t = locale.t;
        const urlInputView = new LabeledFieldView(locale, createLabeledInputText);
        urlInputView.bind('label').to(this, 'isImageSelected', (value)=>value ? t('Update image URL') : t('Insert image via URL'));
        urlInputView.bind('isEnabled').to(this);
        urlInputView.fieldView.placeholder = 'https://example.com/image.png';
        urlInputView.fieldView.bind('value').to(this, 'imageURLInputValue', (value)=>value || '');
        urlInputView.fieldView.on('input', ()=>{
            this.imageURLInputValue = urlInputView.fieldView.element.value.trim();
        });
        return urlInputView;
    }
    /**
     * Creates the {@link #insertButtonView}.
     */ _createInsertButton() {
        const locale = this.locale;
        const t = locale.t;
        const insertButtonView = new ButtonView(locale);
        insertButtonView.set({
            icon: icons.check,
            class: 'ck-button-save',
            type: 'submit',
            withText: true
        });
        insertButtonView.bind('label').to(this, 'isImageSelected', (value)=>value ? t('Update') : t('Insert'));
        insertButtonView.bind('isEnabled').to(this, 'imageURLInputValue', this, 'isEnabled', (...values)=>values.every((value)=>value));
        insertButtonView.delegate('execute').to(this, 'submit');
        return insertButtonView;
    }
    /**
     * Creates the {@link #cancelButtonView}.
     */ _createCancelButton() {
        const locale = this.locale;
        const t = locale.t;
        const cancelButtonView = new ButtonView(locale);
        cancelButtonView.set({
            label: t('Cancel'),
            icon: icons.cancel,
            class: 'ck-button-cancel',
            withText: true
        });
        cancelButtonView.bind('isEnabled').to(this);
        cancelButtonView.delegate('execute').to(this, 'cancel');
        return cancelButtonView;
    }
    /**
     * Focuses the view.
     */ focus(direction) {
        if (direction === -1) {
            this.focusCycler.focusLast();
        } else {
            this.focusCycler.focusFirst();
        }
    }
    /**
     * Creates a view for the dropdown panel of {@link module:image/imageinsert/imageinsertui~ImageInsertUI}.
     *
     * @param locale The localization services instance.
     */ constructor(locale){
        super(locale);
        this.set('imageURLInputValue', '');
        this.set('isImageSelected', false);
        this.set('isEnabled', true);
        this.focusTracker = new FocusTracker();
        this.keystrokes = new KeystrokeHandler();
        this._focusables = new ViewCollection();
        this.focusCycler = new FocusCycler({
            focusables: this._focusables,
            focusTracker: this.focusTracker,
            keystrokeHandler: this.keystrokes,
            actions: {
                // Navigate form fields backwards using the Shift + Tab keystroke.
                focusPrevious: 'shift + tab',
                // Navigate form fields forwards using the Tab key.
                focusNext: 'tab'
            }
        });
        this.urlInputView = this._createUrlInputView();
        this.insertButtonView = this._createInsertButton();
        this.cancelButtonView = this._createCancelButton();
        this._focusables.addMany([
            this.urlInputView,
            this.insertButtonView,
            this.cancelButtonView
        ]);
        this.setTemplate({
            tag: 'div',
            attributes: {
                class: [
                    'ck',
                    'ck-image-insert-url'
                ]
            },
            children: [
                this.urlInputView,
                {
                    tag: 'div',
                    attributes: {
                        class: [
                            'ck',
                            'ck-image-insert-url__action-row'
                        ]
                    },
                    children: [
                        this.insertButtonView,
                        this.cancelButtonView
                    ]
                }
            ]
        });
    }
}

class ImageInsertViaUrlUI extends Plugin {
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'ImageInsertViaUrlUI';
    }
    /**
     * @inheritDoc
     */ static get requires() {
        return [
            ImageInsertUI
        ];
    }
    /**
     * @inheritDoc
     */ afterInit() {
        this._imageInsertUI = this.editor.plugins.get('ImageInsertUI');
        this._imageInsertUI.registerIntegration({
            name: 'url',
            observable: ()=>this.editor.commands.get('insertImage'),
            requiresForm: true,
            buttonViewCreator: (isOnlyOne)=>this._createInsertUrlButton(isOnlyOne),
            formViewCreator: (isOnlyOne)=>this._createInsertUrlView(isOnlyOne)
        });
    }
    /**
     * Creates the view displayed in the dropdown.
     */ _createInsertUrlView(isOnlyOne) {
        const editor = this.editor;
        const locale = editor.locale;
        const t = locale.t;
        const replaceImageSourceCommand = editor.commands.get('replaceImageSource');
        const insertImageCommand = editor.commands.get('insertImage');
        const imageInsertUrlView = new ImageInsertUrlView(locale);
        const collapsibleView = isOnlyOne ? null : new CollapsibleView(locale, [
            imageInsertUrlView
        ]);
        imageInsertUrlView.bind('isImageSelected').to(this._imageInsertUI);
        imageInsertUrlView.bind('isEnabled').toMany([
            insertImageCommand,
            replaceImageSourceCommand
        ], 'isEnabled', (...isEnabled)=>isEnabled.some((isCommandEnabled)=>isCommandEnabled));
        // Set initial value because integrations are created on first dropdown open.
        imageInsertUrlView.imageURLInputValue = replaceImageSourceCommand.value || '';
        this._imageInsertUI.dropdownView.on('change:isOpen', ()=>{
            if (this._imageInsertUI.dropdownView.isOpen) {
                // Make sure that each time the panel shows up, the URL field remains in sync with the value of
                // the command. If the user typed in the input, then canceled and re-opened it without changing
                // the value of the media command (e.g. because they didn't change the selection), they would see
                // the old value instead of the actual value of the command.
                imageInsertUrlView.imageURLInputValue = replaceImageSourceCommand.value || '';
                if (collapsibleView) {
                    collapsibleView.isCollapsed = true;
                }
            }
        // Note: Use the low priority to make sure the following listener starts working after the
        // default action of the drop-down is executed (i.e. the panel showed up). Otherwise, the
        // invisible form/input cannot be focused/selected.
        }, {
            priority: 'low'
        });
        imageInsertUrlView.on('submit', ()=>{
            if (replaceImageSourceCommand.isEnabled) {
                editor.execute('replaceImageSource', {
                    source: imageInsertUrlView.imageURLInputValue
                });
            } else {
                editor.execute('insertImage', {
                    source: imageInsertUrlView.imageURLInputValue
                });
            }
            this._closePanel();
        });
        imageInsertUrlView.on('cancel', ()=>this._closePanel());
        if (collapsibleView) {
            collapsibleView.set({
                isCollapsed: true
            });
            collapsibleView.bind('label').to(this._imageInsertUI, 'isImageSelected', (isImageSelected)=>isImageSelected ? t('Update image URL') : t('Insert image via URL'));
            return collapsibleView;
        }
        return imageInsertUrlView;
    }
    /**
     * Creates the toolbar button.
     */ _createInsertUrlButton(isOnlyOne) {
        const ButtonClass = isOnlyOne ? DropdownButtonView : ButtonView;
        const editor = this.editor;
        const button = new ButtonClass(editor.locale);
        const t = editor.locale.t;
        button.set({
            icon: icons.imageUrl,
            tooltip: true
        });
        button.bind('label').to(this._imageInsertUI, 'isImageSelected', (isImageSelected)=>isImageSelected ? t('Update image URL') : t('Insert image via URL'));
        return button;
    }
    /**
     * Closes the dropdown.
     */ _closePanel() {
        this.editor.editing.view.focus();
        this._imageInsertUI.dropdownView.isOpen = false;
    }
}

class ImageInsertViaUrl extends Plugin {
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'ImageInsertViaUrl';
    }
    /**
     * @inheritDoc
     */ static get requires() {
        return [
            ImageInsertViaUrlUI,
            ImageInsertUI
        ];
    }
}

class ImageInsert extends Plugin {
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'ImageInsert';
    }
    /**
     * @inheritDoc
     */ static get requires() {
        return [
            ImageUpload,
            ImageInsertViaUrl,
            ImageInsertUI
        ];
    }
}

class ResizeImageCommand extends Command {
    /**
     * @inheritDoc
     */ refresh() {
        const editor = this.editor;
        const imageUtils = editor.plugins.get('ImageUtils');
        const element = imageUtils.getClosestSelectedImageElement(editor.model.document.selection);
        this.isEnabled = !!element;
        if (!element || !element.hasAttribute('resizedWidth')) {
            this.value = null;
        } else {
            this.value = {
                width: element.getAttribute('resizedWidth'),
                height: null
            };
        }
    }
    /**
     * Executes the command.
     *
     * ```ts
     * // Sets the width to 50%:
     * editor.execute( 'resizeImage', { width: '50%' } );
     *
     * // Removes the width attribute:
     * editor.execute( 'resizeImage', { width: null } );
     * ```
     *
     * @param options
     * @param options.width The new width of the image.
     * @fires execute
     */ execute(options) {
        const editor = this.editor;
        const model = editor.model;
        const imageUtils = editor.plugins.get('ImageUtils');
        const imageElement = imageUtils.getClosestSelectedImageElement(model.document.selection);
        this.value = {
            width: options.width,
            height: null
        };
        if (imageElement) {
            model.change((writer)=>{
                writer.setAttribute('resizedWidth', options.width, imageElement);
                writer.removeAttribute('resizedHeight', imageElement);
                imageUtils.setImageNaturalSizeAttributes(imageElement);
            });
        }
    }
}

class ImageResizeEditing extends Plugin {
    /**
     * @inheritDoc
     */ static get requires() {
        return [
            ImageUtils
        ];
    }
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'ImageResizeEditing';
    }
    /**
     * @inheritDoc
     */ init() {
        const editor = this.editor;
        const resizeImageCommand = new ResizeImageCommand(editor);
        this._registerConverters('imageBlock');
        this._registerConverters('imageInline');
        // Register `resizeImage` command and add `imageResize` command as an alias for backward compatibility.
        editor.commands.add('resizeImage', resizeImageCommand);
        editor.commands.add('imageResize', resizeImageCommand);
    }
    /**
     * @inheritDoc
     */ afterInit() {
        this._registerSchema();
    }
    _registerSchema() {
        if (this.editor.plugins.has('ImageBlockEditing')) {
            this.editor.model.schema.extend('imageBlock', {
                allowAttributes: [
                    'resizedWidth',
                    'resizedHeight'
                ]
            });
        }
        if (this.editor.plugins.has('ImageInlineEditing')) {
            this.editor.model.schema.extend('imageInline', {
                allowAttributes: [
                    'resizedWidth',
                    'resizedHeight'
                ]
            });
        }
    }
    /**
     * Registers image resize converters.
     *
     * @param imageType The type of the image.
     */ _registerConverters(imageType) {
        const editor = this.editor;
        const imageUtils = editor.plugins.get('ImageUtils');
        // Dedicated converter to propagate image's attribute to the img tag.
        editor.conversion.for('downcast').add((dispatcher)=>dispatcher.on(`attribute:resizedWidth:${imageType}`, (evt, data, conversionApi)=>{
                if (!conversionApi.consumable.consume(data.item, evt.name)) {
                    return;
                }
                const viewWriter = conversionApi.writer;
                const viewImg = conversionApi.mapper.toViewElement(data.item);
                if (data.attributeNewValue !== null) {
                    viewWriter.setStyle('width', data.attributeNewValue, viewImg);
                    viewWriter.addClass('image_resized', viewImg);
                } else {
                    viewWriter.removeStyle('width', viewImg);
                    viewWriter.removeClass('image_resized', viewImg);
                }
            }));
        editor.conversion.for('dataDowncast').attributeToAttribute({
            model: {
                name: imageType,
                key: 'resizedHeight'
            },
            view: (modelAttributeValue)=>({
                    key: 'style',
                    value: {
                        'height': modelAttributeValue
                    }
                })
        });
        editor.conversion.for('editingDowncast').add((dispatcher)=>dispatcher.on(`attribute:resizedHeight:${imageType}`, (evt, data, conversionApi)=>{
                if (!conversionApi.consumable.consume(data.item, evt.name)) {
                    return;
                }
                const viewWriter = conversionApi.writer;
                const viewImg = conversionApi.mapper.toViewElement(data.item);
                const target = imageType === 'imageInline' ? imageUtils.findViewImgElement(viewImg) : viewImg;
                if (data.attributeNewValue !== null) {
                    viewWriter.setStyle('height', data.attributeNewValue, target);
                } else {
                    viewWriter.removeStyle('height', target);
                }
            }));
        editor.conversion.for('upcast').attributeToAttribute({
            view: {
                name: imageType === 'imageBlock' ? 'figure' : 'img',
                styles: {
                    width: /.+/
                }
            },
            model: {
                key: 'resizedWidth',
                value: (viewElement)=>{
                    if (widthAndHeightStylesAreBothSet(viewElement)) {
                        return null;
                    }
                    return viewElement.getStyle('width');
                }
            }
        });
        editor.conversion.for('upcast').attributeToAttribute({
            view: {
                name: imageType === 'imageBlock' ? 'figure' : 'img',
                styles: {
                    height: /.+/
                }
            },
            model: {
                key: 'resizedHeight',
                value: (viewElement)=>{
                    if (widthAndHeightStylesAreBothSet(viewElement)) {
                        return null;
                    }
                    return viewElement.getStyle('height');
                }
            }
        });
    }
    /**
     * @inheritDoc
     */ constructor(editor){
        super(editor);
        editor.config.define('image', {
            resizeUnit: '%',
            resizeOptions: [
                {
                    name: 'resizeImage:original',
                    value: null,
                    icon: 'original'
                },
                {
                    name: 'resizeImage:custom',
                    value: 'custom',
                    icon: 'custom'
                },
                {
                    name: 'resizeImage:25',
                    value: '25',
                    icon: 'small'
                },
                {
                    name: 'resizeImage:50',
                    value: '50',
                    icon: 'medium'
                },
                {
                    name: 'resizeImage:75',
                    value: '75',
                    icon: 'large'
                }
            ]
        });
    }
}

const RESIZE_ICONS = {
    small: icons.objectSizeSmall,
    medium: icons.objectSizeMedium,
    large: icons.objectSizeLarge,
    custom: icons.objectSizeCustom,
    original: icons.objectSizeFull
};
class ImageResizeButtons extends Plugin {
    /**
     * @inheritDoc
     */ static get requires() {
        return [
            ImageResizeEditing
        ];
    }
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'ImageResizeButtons';
    }
    /**
     * @inheritDoc
     */ init() {
        const editor = this.editor;
        const options = editor.config.get('image.resizeOptions');
        const command = editor.commands.get('resizeImage');
        this.bind('isEnabled').to(command);
        for (const option of options){
            this._registerImageResizeButton(option);
        }
        this._registerImageResizeDropdown(options);
    }
    /**
     * A helper function that creates a standalone button component for the plugin.
     *
     * @param resizeOption A model of the resize option.
     */ _registerImageResizeButton(option) {
        const editor = this.editor;
        const { name, value, icon } = option;
        editor.ui.componentFactory.add(name, (locale)=>{
            const button = new ButtonView(locale);
            const command = editor.commands.get('resizeImage');
            const labelText = this._getOptionLabelValue(option, true);
            if (!RESIZE_ICONS[icon]) {
                /**
                 * When configuring {@link module:image/imageconfig~ImageConfig#resizeOptions `config.image.resizeOptions`} for standalone
                 * buttons, a valid `icon` token must be set for each option.
                 *
                 * See all valid options described in the
                 * {@link module:image/imageconfig~ImageResizeOption plugin configuration}.
                 *
                 * @error imageresizebuttons-missing-icon
                 * @param option Invalid image resize option.
                */ throw new CKEditorError('imageresizebuttons-missing-icon', editor, option);
            }
            button.set({
                // Use the `label` property for a verbose description (because of ARIA).
                label: labelText,
                icon: RESIZE_ICONS[icon],
                tooltip: labelText,
                isToggleable: true
            });
            // Bind button to the command.
            button.bind('isEnabled').to(this);
            if (editor.plugins.has('ImageCustomResizeUI') && isCustomImageResizeOption(option)) {
                const customResizeUI = editor.plugins.get('ImageCustomResizeUI');
                this.listenTo(button, 'execute', ()=>{
                    customResizeUI._showForm(this._resizeUnit);
                });
            } else {
                const optionValueWithUnit = value ? value + this._resizeUnit : null;
                button.bind('isOn').to(command, 'value', getIsOnButtonCallback(optionValueWithUnit));
                this.listenTo(button, 'execute', ()=>{
                    editor.execute('resizeImage', {
                        width: optionValueWithUnit
                    });
                });
            }
            return button;
        });
    }
    /**
     * A helper function that creates a dropdown component for the plugin containing all the resize options defined in
     * the editor configuration.
     *
     * @param options An array of configured options.
     */ _registerImageResizeDropdown(options) {
        const editor = this.editor;
        const t = editor.t;
        const originalSizeOption = options.find((option)=>!option.value);
        const componentCreator = (locale)=>{
            const command = editor.commands.get('resizeImage');
            const dropdownView = createDropdown(locale, DropdownButtonView);
            const dropdownButton = dropdownView.buttonView;
            const accessibleLabel = t('Resize image');
            dropdownButton.set({
                tooltip: accessibleLabel,
                commandValue: originalSizeOption.value,
                icon: RESIZE_ICONS.medium,
                isToggleable: true,
                label: this._getOptionLabelValue(originalSizeOption),
                withText: true,
                class: 'ck-resize-image-button',
                ariaLabel: accessibleLabel,
                ariaLabelledBy: undefined
            });
            dropdownButton.bind('label').to(command, 'value', (commandValue)=>{
                if (commandValue && commandValue.width) {
                    return commandValue.width;
                } else {
                    return this._getOptionLabelValue(originalSizeOption);
                }
            });
            dropdownView.bind('isEnabled').to(this);
            addListToDropdown(dropdownView, ()=>this._getResizeDropdownListItemDefinitions(options, command), {
                ariaLabel: t('Image resize list'),
                role: 'menu'
            });
            // Execute command when an item from the dropdown is selected.
            this.listenTo(dropdownView, 'execute', (evt)=>{
                if ('onClick' in evt.source) {
                    evt.source.onClick();
                } else {
                    editor.execute(evt.source.commandName, {
                        width: evt.source.commandValue
                    });
                    editor.editing.view.focus();
                }
            });
            return dropdownView;
        };
        // Register `resizeImage` dropdown and add `imageResize` dropdown as an alias for backward compatibility.
        editor.ui.componentFactory.add('resizeImage', componentCreator);
        editor.ui.componentFactory.add('imageResize', componentCreator);
    }
    /**
     * A helper function for creating an option label value string.
     *
     * @param option A resize option object.
     * @param forTooltip An optional flag for creating a tooltip label.
     * @returns A user-defined label combined from the numeric value and the resize unit or the default label
     * for reset options (`Original`).
     */ _getOptionLabelValue(option, forTooltip = false) {
        const t = this.editor.t;
        if (option.label) {
            return option.label;
        } else if (forTooltip) {
            if (isCustomImageResizeOption(option)) {
                return t('Custom image size');
            } else if (option.value) {
                return t('Resize image to %0', option.value + this._resizeUnit);
            } else {
                return t('Resize image to the original size');
            }
        } else {
            if (isCustomImageResizeOption(option)) {
                return t('Custom');
            } else if (option.value) {
                return option.value + this._resizeUnit;
            } else {
                return t('Original');
            }
        }
    }
    /**
     * A helper function that parses the resize options and returns list item definitions ready for use in the dropdown.
     *
     * @param options The resize options.
     * @param command The resize image command.
     * @returns Dropdown item definitions.
     */ _getResizeDropdownListItemDefinitions(options, command) {
        const { editor } = this;
        const itemDefinitions = new Collection();
        const optionsWithSerializedValues = options.map((option)=>{
            if (isCustomImageResizeOption(option)) {
                return {
                    ...option,
                    valueWithUnits: 'custom'
                };
            }
            if (!option.value) {
                return {
                    ...option,
                    valueWithUnits: null
                };
            }
            return {
                ...option,
                valueWithUnits: `${option.value}${this._resizeUnit}`
            };
        });
        for (const option of optionsWithSerializedValues){
            let definition = null;
            if (editor.plugins.has('ImageCustomResizeUI') && isCustomImageResizeOption(option)) {
                const customResizeUI = editor.plugins.get('ImageCustomResizeUI');
                definition = {
                    type: 'button',
                    model: new ViewModel({
                        label: this._getOptionLabelValue(option),
                        role: 'menuitemradio',
                        withText: true,
                        icon: null,
                        onClick: ()=>{
                            customResizeUI._showForm(this._resizeUnit);
                        }
                    })
                };
                const allDropdownValues = map(optionsWithSerializedValues, 'valueWithUnits');
                definition.model.bind('isOn').to(command, 'value', getIsOnCustomButtonCallback(allDropdownValues));
            } else {
                definition = {
                    type: 'button',
                    model: new ViewModel({
                        commandName: 'resizeImage',
                        commandValue: option.valueWithUnits,
                        label: this._getOptionLabelValue(option),
                        role: 'menuitemradio',
                        withText: true,
                        icon: null
                    })
                };
                definition.model.bind('isOn').to(command, 'value', getIsOnButtonCallback(option.valueWithUnits));
            }
            definition.model.bind('isEnabled').to(command, 'isEnabled');
            itemDefinitions.add(definition);
        }
        return itemDefinitions;
    }
    /**
     * @inheritDoc
     */ constructor(editor){
        super(editor);
        this._resizeUnit = editor.config.get('image.resizeUnit');
    }
}
/**
 * A helper that checks if provided option triggers custom resize balloon.
 */ function isCustomImageResizeOption(option) {
    return option.value === 'custom';
}
/**
 * A helper function for setting the `isOn` state of buttons in value bindings.
 */ function getIsOnButtonCallback(value) {
    return (commandValue)=>{
        const objectCommandValue = commandValue;
        if (value === null && objectCommandValue === value) {
            return true;
        }
        return objectCommandValue !== null && objectCommandValue.width === value;
    };
}
/**
 * A helper function for setting the `isOn` state of custom size button in value bindings.
 */ function getIsOnCustomButtonCallback(allDropdownValues) {
    return (commandValue)=>!allDropdownValues.some((dropdownValue)=>getIsOnButtonCallback(dropdownValue)(commandValue));
}

const RESIZABLE_IMAGES_CSS_SELECTOR = 'figure.image.ck-widget > img,' + 'figure.image.ck-widget > picture > img,' + 'figure.image.ck-widget > a > img,' + 'figure.image.ck-widget > a > picture > img,' + 'span.image-inline.ck-widget > img,' + 'span.image-inline.ck-widget > picture > img';
const RESIZED_IMAGE_CLASS = 'image_resized';
class ImageResizeHandles extends Plugin {
    /**
     * @inheritDoc
     */ static get requires() {
        return [
            WidgetResize,
            ImageUtils
        ];
    }
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'ImageResizeHandles';
    }
    /**
     * @inheritDoc
     */ init() {
        const command = this.editor.commands.get('resizeImage');
        this.bind('isEnabled').to(command);
        this._setupResizerCreator();
    }
    /**
     * Attaches the listeners responsible for creating a resizer for each image, except for images inside the HTML embed preview.
     */ _setupResizerCreator() {
        const editor = this.editor;
        const editingView = editor.editing.view;
        const imageUtils = editor.plugins.get('ImageUtils');
        editingView.addObserver(ImageLoadObserver);
        this.listenTo(editingView.document, 'imageLoaded', (evt, domEvent)=>{
            // The resizer must be attached only to images loaded by the `ImageInsert`, `ImageUpload` or `LinkImage` plugins.
            if (!domEvent.target.matches(RESIZABLE_IMAGES_CSS_SELECTOR)) {
                return;
            }
            const domConverter = editor.editing.view.domConverter;
            const imageView = domConverter.domToView(domEvent.target);
            const widgetView = imageUtils.getImageWidgetFromImageView(imageView);
            let resizer = this.editor.plugins.get(WidgetResize).getResizerByViewElement(widgetView);
            if (resizer) {
                // There are rare cases when the image will be triggered multiple times for the same widget, e.g. when
                // the image's source was changed after upload (https://github.com/ckeditor/ckeditor5/pull/8108#issuecomment-708302992).
                resizer.redraw();
                return;
            }
            const mapper = editor.editing.mapper;
            const imageModel = mapper.toModelElement(widgetView);
            resizer = editor.plugins.get(WidgetResize).attachTo({
                unit: editor.config.get('image.resizeUnit'),
                modelElement: imageModel,
                viewElement: widgetView,
                editor,
                getHandleHost (domWidgetElement) {
                    return domWidgetElement.querySelector('img');
                },
                getResizeHost () {
                    return domConverter.mapViewToDom(mapper.toViewElement(imageModel));
                },
                isCentered () {
                    const imageStyle = imageModel.getAttribute('imageStyle');
                    return imageStyle == 'alignCenter';
                },
                onCommit (newValue) {
                    // Get rid of the CSS class in case the command execution that follows is unsuccessful
                    // (e.g. Track Changes can override it and the new dimensions will not apply). Otherwise,
                    // the presence of the class and the absence of the width style will cause it to take 100%
                    // of the horizontal space.
                    editingView.change((writer)=>{
                        writer.removeClass(RESIZED_IMAGE_CLASS, widgetView);
                    });
                    editor.execute('resizeImage', {
                        width: newValue
                    });
                }
            });
            resizer.on('updateSize', ()=>{
                if (!widgetView.hasClass(RESIZED_IMAGE_CLASS)) {
                    editingView.change((writer)=>{
                        writer.addClass(RESIZED_IMAGE_CLASS, widgetView);
                    });
                }
                const target = imageModel.name === 'imageInline' ? imageView : widgetView;
                if (target.getStyle('height')) {
                    editingView.change((writer)=>{
                        writer.removeStyle('height', target);
                    });
                }
            });
            resizer.bind('isEnabled').to(this);
        });
    }
}

/**
 * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
 * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
 */ /**
 * @module image/imageresize/utils/tryparsedimensionwithunit
 */ /**
 * Parses provided string with dimension value and returns extracted numeric value and unit.
 *
 * 	* If non-string dimension is passed then `null` value is returned.
 * 	* If unit is missing then `null` is returned.
 * 	* If numeric value part of string is not a number then `null` is returned.
 *
 * Example:
 * 	`"222px"` => `{ value: 222, unit: "px" }`
 *	`"99%"` => `{ value: 99, unit: "%" }`

 * @param dimension Unsafe string with dimension.
 * @returns Parsed dimension with extracted numeric value and units.
 */ function tryParseDimensionWithUnit(dimension) {
    if (!dimension) {
        return null;
    }
    const [, rawValue, unit] = dimension.trim().match(/([.,\d]+)(%|px)$/) || [];
    const parsedValue = Number.parseFloat(rawValue);
    if (Number.isNaN(parsedValue)) {
        return null;
    }
    return {
        value: parsedValue,
        unit
    };
}
/**
 * Converts dimension between `px` -> `%` and `%` -> `px`.
 *
 * @param parentDimensionPx	Dimension of parent element that contains measured element.
 * @param dimension Measured element dimension.
 * @returns Casted dimension.
 */ function tryCastDimensionsToUnit(parentDimensionPx, dimension, targetUnit) {
    // "%" -> "px" conversion
    if (targetUnit === 'px') {
        return {
            value: dimension.value,
            unit: 'px'
        };
    }
    // "px" -> "%" conversion
    return {
        value: dimension.value / parentDimensionPx * 100,
        unit: '%'
    };
}

/**
 * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
 * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
 */ /**
 * Finds model, view and DOM element for selected image element. Returns `null` if there is no image selected.
 *
 * @param editor Editor instance.
 */ function getSelectedImageEditorNodes(editor) {
    const { editing } = editor;
    const imageUtils = editor.plugins.get('ImageUtils');
    const imageModelElement = imageUtils.getClosestSelectedImageElement(editor.model.document.selection);
    if (!imageModelElement) {
        return null;
    }
    const imageViewElement = editing.mapper.toViewElement(imageModelElement);
    const imageDOMElement = editing.view.domConverter.mapViewToDom(imageViewElement);
    return {
        model: imageModelElement,
        view: imageViewElement,
        dom: imageDOMElement
    };
}

/**
 * Returns image width in specified units. It is width of image after resize.
 *
 * 	* If image is not selected or command is disabled then `null` will be returned.
 * 	* If image is not fully loaded (and it is impossible to determine its natural size) then `null` will be returned.
 *	* If `targetUnit` percentage is passed then it will return width percentage of image related to its accessors.
 *
 * @param editor Editor instance.
 * @param targetUnit Unit in which dimension will be returned.
 * @returns Parsed image width after resize (with unit).
 */ function getSelectedImageWidthInUnits(editor, targetUnit) {
    const imageNodes = getSelectedImageEditorNodes(editor);
    if (!imageNodes) {
        return null;
    }
    const parsedResizedWidth = tryParseDimensionWithUnit(imageNodes.model.getAttribute('resizedWidth') || null);
    if (!parsedResizedWidth) {
        return null;
    }
    if (parsedResizedWidth.unit === targetUnit) {
        return parsedResizedWidth;
    }
    const imageParentWidthPx = calculateResizeHostAncestorWidth(imageNodes.dom);
    const imageHolderDimension = {
        unit: 'px',
        value: new Rect(imageNodes.dom).width
    };
    return tryCastDimensionsToUnit(imageParentWidthPx, imageHolderDimension, targetUnit);
}

class ImageCustomResizeFormView extends View {
    /**
     * @inheritDoc
     */ render() {
        super.render();
        this.keystrokes.listenTo(this.element);
        submitHandler({
            view: this
        });
        [
            this.labeledInput,
            this.saveButtonView,
            this.cancelButtonView
        ].forEach((v)=>{
            // Register the view as focusable.
            this._focusables.add(v);
            // Register the view in the focus tracker.
            this.focusTracker.add(v.element);
        });
    }
    /**
     * @inheritDoc
     */ destroy() {
        super.destroy();
        this.focusTracker.destroy();
        this.keystrokes.destroy();
    }
    /**
     * Creates the button view.
     *
     * @param label The button label
     * @param icon The button's icon.
     * @param className The additional button CSS class name.
     * @param eventName The event name that the ButtonView#execute event will be delegated to.
     * @returns The button view instance.
     */ _createButton(label, icon, className, eventName) {
        const button = new ButtonView(this.locale);
        button.set({
            label,
            icon,
            tooltip: true
        });
        button.extendTemplate({
            attributes: {
                class: className
            }
        });
        if (eventName) {
            button.delegate('execute').to(this, eventName);
        }
        return button;
    }
    /**
     * Creates an input with a label.
     *
     * @returns Labeled field view instance.
     */ _createLabeledInputView() {
        const t = this.locale.t;
        const labeledInput = new LabeledFieldView(this.locale, createLabeledInputNumber);
        labeledInput.label = t('Resize image (in %0)', this.unit);
        labeledInput.fieldView.set({
            step: 0.1
        });
        return labeledInput;
    }
    /**
     * Validates the form and returns `false` when some fields are invalid.
     */ isValid() {
        this.resetFormStatus();
        for (const validator of this._validators){
            const errorText = validator(this);
            // One error per field is enough.
            if (errorText) {
                // Apply updated error.
                this.labeledInput.errorText = errorText;
                return false;
            }
        }
        return true;
    }
    /**
     * Cleans up the supplementary error and information text of the {@link #labeledInput}
     * bringing them back to the state when the form has been displayed for the first time.
     *
     * See {@link #isValid}.
     */ resetFormStatus() {
        this.labeledInput.errorText = null;
    }
    /**
     * The native DOM `value` of the input element of {@link #labeledInput}.
     */ get rawSize() {
        const { element } = this.labeledInput.fieldView;
        if (!element) {
            return null;
        }
        return element.value;
    }
    /**
     * Get numeric value of size. Returns `null` if value of size input element in {@link #labeledInput}.is not a number.
     */ get parsedSize() {
        const { rawSize } = this;
        if (rawSize === null) {
            return null;
        }
        const parsed = Number.parseFloat(rawSize);
        if (Number.isNaN(parsed)) {
            return null;
        }
        return parsed;
    }
    /**
     * Returns serialized image input size with unit.
     * Returns `null` if value of size input element in {@link #labeledInput}.is not a number.
     */ get sizeWithUnits() {
        const { parsedSize, unit } = this;
        if (parsedSize === null) {
            return null;
        }
        return `${parsedSize}${unit}`;
    }
    /**
     * @inheritDoc
     */ constructor(locale, unit, validators){
        super(locale);
        const t = this.locale.t;
        this.focusTracker = new FocusTracker();
        this.keystrokes = new KeystrokeHandler();
        this.unit = unit;
        this.labeledInput = this._createLabeledInputView();
        this.saveButtonView = this._createButton(t('Save'), icons.check, 'ck-button-save');
        this.saveButtonView.type = 'submit';
        this.cancelButtonView = this._createButton(t('Cancel'), icons.cancel, 'ck-button-cancel', 'cancel');
        this._focusables = new ViewCollection();
        this._validators = validators;
        this._focusCycler = new FocusCycler({
            focusables: this._focusables,
            focusTracker: this.focusTracker,
            keystrokeHandler: this.keystrokes,
            actions: {
                // Navigate form fields backwards using the Shift + Tab keystroke.
                focusPrevious: 'shift + tab',
                // Navigate form fields forwards using the Tab key.
                focusNext: 'tab'
            }
        });
        this.setTemplate({
            tag: 'form',
            attributes: {
                class: [
                    'ck',
                    'ck-image-custom-resize-form',
                    'ck-responsive-form'
                ],
                // https://github.com/ckeditor/ckeditor5-image/issues/40
                tabindex: '-1'
            },
            children: [
                this.labeledInput,
                this.saveButtonView,
                this.cancelButtonView
            ]
        });
    }
}

/**
 * Returns min and max value of resize image in specified unit.
 *
 * @param editor Editor instance.
 * @param targetUnit Unit in which dimension will be returned.
 * @returns Possible resize range in numeric form.
 */ function getSelectedImagePossibleResizeRange(editor, targetUnit) {
    const imageNodes = getSelectedImageEditorNodes(editor);
    if (!imageNodes) {
        return null;
    }
    const imageParentWidthPx = calculateResizeHostAncestorWidth(imageNodes.dom);
    const minimumImageWidth = tryParseDimensionWithUnit(window.getComputedStyle(imageNodes.dom).minWidth) || {
        value: 1,
        unit: 'px'
    };
    const lower = Math.max(0.1, tryCastDimensionsToUnit(imageParentWidthPx, minimumImageWidth, targetUnit).value);
    const upper = targetUnit === 'px' ? imageParentWidthPx : 100;
    return {
        unit: targetUnit,
        lower,
        upper
    };
}

class ImageCustomResizeUI extends Plugin {
    /**
     * @inheritDoc
     */ static get requires() {
        return [
            ContextualBalloon
        ];
    }
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'ImageCustomResizeUI';
    }
    /**
     * @inheritDoc
     */ destroy() {
        super.destroy();
        // Destroy created UI components as they are not automatically destroyed (see ckeditor5#1341).
        if (this._form) {
            this._form.destroy();
        }
    }
    /**
     * Creates the {@link module:image/imageresize/ui/imagecustomresizeformview~ImageCustomResizeFormView}
     * form.
     */ _createForm(unit) {
        const editor = this.editor;
        this._balloon = this.editor.plugins.get('ContextualBalloon');
        this._form = new (CssTransitionDisablerMixin(ImageCustomResizeFormView))(editor.locale, unit, getFormValidators(editor));
        // Render the form so its #element is available for clickOutsideHandler.
        this._form.render();
        this.listenTo(this._form, 'submit', ()=>{
            if (this._form.isValid()) {
                editor.execute('resizeImage', {
                    width: this._form.sizeWithUnits
                });
                this._hideForm(true);
            }
        });
        // Update balloon position when form error label is added .
        this.listenTo(this._form.labeledInput, 'change:errorText', ()=>{
            editor.ui.update();
        });
        this.listenTo(this._form, 'cancel', ()=>{
            this._hideForm(true);
        });
        // Close the form on Esc key press.
        this._form.keystrokes.set('Esc', (data, cancel)=>{
            this._hideForm(true);
            cancel();
        });
        // Close on click outside of balloon panel element.
        clickOutsideHandler({
            emitter: this._form,
            activator: ()=>this._isVisible,
            contextElements: ()=>[
                    this._balloon.view.element
                ],
            callback: ()=>this._hideForm()
        });
    }
    /**
     * Shows the {@link #_form} in the {@link #_balloon}.
     *
     * @internal
     */ _showForm(unit) {
        if (this._isVisible) {
            return;
        }
        if (!this._form) {
            this._createForm(unit);
        }
        const editor = this.editor;
        const labeledInput = this._form.labeledInput;
        this._form.disableCssTransitions();
        this._form.resetFormStatus();
        if (!this._isInBalloon) {
            this._balloon.add({
                view: this._form,
                position: getBalloonPositionData(editor)
            });
        }
        // Make sure that each time the panel shows up, the field remains in sync with the value of
        // the command. If the user typed in the input, then canceled the balloon (`labeledInput#value`
        // stays unaltered) and re-opened it without changing the value of the command, they would see the
        // old value instead of the actual value of the command.
        const currentParsedWidth = getSelectedImageWidthInUnits(editor, unit);
        const initialInputValue = currentParsedWidth ? currentParsedWidth.value.toFixed(1) : '';
        const possibleRange = getSelectedImagePossibleResizeRange(editor, unit);
        labeledInput.fieldView.value = labeledInput.fieldView.element.value = initialInputValue;
        if (possibleRange) {
            Object.assign(labeledInput.fieldView, {
                min: possibleRange.lower.toFixed(1),
                max: Math.ceil(possibleRange.upper).toFixed(1)
            });
        }
        this._form.labeledInput.fieldView.select();
        this._form.enableCssTransitions();
    }
    /**
     * Removes the {@link #_form} from the {@link #_balloon}.
     *
     * @param focusEditable Controls whether the editing view is focused afterwards.
     */ _hideForm(focusEditable = false) {
        if (!this._isInBalloon) {
            return;
        }
        // Blur the input element before removing it from DOM to prevent issues in some browsers.
        // See https://github.com/ckeditor/ckeditor5/issues/1501.
        if (this._form.focusTracker.isFocused) {
            this._form.saveButtonView.focus();
        }
        this._balloon.remove(this._form);
        if (focusEditable) {
            this.editor.editing.view.focus();
        }
    }
    /**
     * Returns `true` when the {@link #_form} is the visible view in the {@link #_balloon}.
     */ get _isVisible() {
        return !!this._balloon && this._balloon.visibleView === this._form;
    }
    /**
     * Returns `true` when the {@link #_form} is in the {@link #_balloon}.
     */ get _isInBalloon() {
        return !!this._balloon && this._balloon.hasView(this._form);
    }
}
/**
 * Returns image resize form validation callbacks.
 *
 * @param editor Editor instance.
 */ function getFormValidators(editor) {
    const t = editor.t;
    return [
        (form)=>{
            if (form.rawSize.trim() === '') {
                return t('The value must not be empty.');
            }
            if (form.parsedSize === null) {
                return t('The value should be a plain number.');
            }
        }
    ];
}

class ImageResize extends Plugin {
    /**
     * @inheritDoc
     */ static get requires() {
        return [
            ImageResizeEditing,
            ImageResizeHandles,
            ImageCustomResizeUI,
            ImageResizeButtons
        ];
    }
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'ImageResize';
    }
}

class ImageStyleCommand extends Command {
    /**
     * @inheritDoc
     */ refresh() {
        const editor = this.editor;
        const imageUtils = editor.plugins.get('ImageUtils');
        const element = imageUtils.getClosestSelectedImageElement(this.editor.model.document.selection);
        this.isEnabled = !!element;
        if (!this.isEnabled) {
            this.value = false;
        } else if (element.hasAttribute('imageStyle')) {
            this.value = element.getAttribute('imageStyle');
        } else {
            this.value = this._defaultStyles[element.name];
        }
    }
    /**
     * Executes the command and applies the style to the currently selected image:
     *
     * ```ts
     * editor.execute( 'imageStyle', { value: 'side' } );
     * ```
     *
     * **Note**: Executing this command may change the image model element if the desired style requires an image
     * of a different type. Learn more about {@link module:image/imageconfig~ImageStyleOptionDefinition#modelElements model element}
     * configuration for the style option.
     *
     * @param options.value The name of the style (as configured in {@link module:image/imageconfig~ImageStyleConfig#options}).
     * @param options.setImageSizes Specifies whether the image `width` and `height` attributes should be set automatically.
     * The default is `true`.
     * @fires execute
     */ execute(options = {}) {
        const editor = this.editor;
        const model = editor.model;
        const imageUtils = editor.plugins.get('ImageUtils');
        model.change((writer)=>{
            const requestedStyle = options.value;
            const { setImageSizes = true } = options;
            let imageElement = imageUtils.getClosestSelectedImageElement(model.document.selection);
            // Change the image type if a style requires it.
            if (requestedStyle && this.shouldConvertImageType(requestedStyle, imageElement)) {
                this.editor.execute(imageUtils.isBlockImage(imageElement) ? 'imageTypeInline' : 'imageTypeBlock', {
                    setImageSizes
                });
                // Update the imageElement to the newly created image.
                imageElement = imageUtils.getClosestSelectedImageElement(model.document.selection);
            }
            // Default style means that there is no `imageStyle` attribute in the model.
            // https://github.com/ckeditor/ckeditor5-image/issues/147
            if (!requestedStyle || this._styles.get(requestedStyle).isDefault) {
                writer.removeAttribute('imageStyle', imageElement);
            } else {
                writer.setAttribute('imageStyle', requestedStyle, imageElement);
            }
            if (setImageSizes) {
                imageUtils.setImageNaturalSizeAttributes(imageElement);
            }
        });
    }
    /**
     * Returns `true` if requested style change would trigger the image type change.
     *
     * @param requestedStyle The name of the style (as configured in {@link module:image/imageconfig~ImageStyleConfig#options}).
     * @param imageElement The image model element.
     */ shouldConvertImageType(requestedStyle, imageElement) {
        const supportedTypes = this._styles.get(requestedStyle).modelElements;
        return !supportedTypes.includes(imageElement.name);
    }
    /**
     * Creates an instance of the image style command. When executed, the command applies one of
     * {@link module:image/imageconfig~ImageStyleConfig#options style options} to the currently selected image.
     *
     * @param editor The editor instance.
     * @param styles The style options that this command supports.
     */ constructor(editor, styles){
        super(editor);
        this._defaultStyles = {
            imageBlock: false,
            imageInline: false
        };
        this._styles = new Map(styles.map((style)=>{
            if (style.isDefault) {
                for (const modelElementName of style.modelElements){
                    this._defaultStyles[modelElementName] = style.name;
                }
            }
            return [
                style.name,
                style
            ];
        }));
    }
}

const { objectFullWidth, objectInline, objectLeft, objectRight, objectCenter, objectBlockLeft, objectBlockRight } = icons;
/**
 * Default image style options provided by the plugin that can be referred in the {@link module:image/imageconfig~ImageConfig#styles}
 * configuration.
 *
 * There are available 5 styles focused on formatting:
 *
 * * **`'alignLeft'`** aligns the inline or block image to the left and wraps it with the text using the `image-style-align-left` class,
 * * **`'alignRight'`** aligns the inline or block image to the right and wraps it with the text using the `image-style-align-right` class,
 * * **`'alignCenter'`** centers the block image using the `image-style-align-center` class,
 * * **`'alignBlockLeft'`** aligns the block image to the left using the `image-style-block-align-left` class,
 * * **`'alignBlockRight'`** aligns the block image to the right using the `image-style-block-align-right` class,
 *
 * and 3 semantic styles:
 *
 * * **`'inline'`** is an inline image without any CSS class,
 * * **`'block'`** is a block image without any CSS class,
 * * **`'side'`** is a block image styled with the `image-style-side` CSS class.
 */ const DEFAULT_OPTIONS = {
    // This style represents an image placed in the line of text.
    get inline () {
        return {
            name: 'inline',
            title: 'In line',
            icon: objectInline,
            modelElements: [
                'imageInline'
            ],
            isDefault: true
        };
    },
    // This style represents an image aligned to the left and wrapped with text.
    get alignLeft () {
        return {
            name: 'alignLeft',
            title: 'Left aligned image',
            icon: objectLeft,
            modelElements: [
                'imageBlock',
                'imageInline'
            ],
            className: 'image-style-align-left'
        };
    },
    // This style represents an image aligned to the left.
    get alignBlockLeft () {
        return {
            name: 'alignBlockLeft',
            title: 'Left aligned image',
            icon: objectBlockLeft,
            modelElements: [
                'imageBlock'
            ],
            className: 'image-style-block-align-left'
        };
    },
    // This style represents a centered image.
    get alignCenter () {
        return {
            name: 'alignCenter',
            title: 'Centered image',
            icon: objectCenter,
            modelElements: [
                'imageBlock'
            ],
            className: 'image-style-align-center'
        };
    },
    // This style represents an image aligned to the right and wrapped with text.
    get alignRight () {
        return {
            name: 'alignRight',
            title: 'Right aligned image',
            icon: objectRight,
            modelElements: [
                'imageBlock',
                'imageInline'
            ],
            className: 'image-style-align-right'
        };
    },
    // This style represents an image aligned to the right.
    get alignBlockRight () {
        return {
            name: 'alignBlockRight',
            title: 'Right aligned image',
            icon: objectBlockRight,
            modelElements: [
                'imageBlock'
            ],
            className: 'image-style-block-align-right'
        };
    },
    // This option is equal to the situation when no style is applied.
    get block () {
        return {
            name: 'block',
            title: 'Centered image',
            icon: objectCenter,
            modelElements: [
                'imageBlock'
            ],
            isDefault: true
        };
    },
    // This represents a side image.
    get side () {
        return {
            name: 'side',
            title: 'Side image',
            icon: objectRight,
            modelElements: [
                'imageBlock'
            ],
            className: 'image-style-side'
        };
    }
};
/**
 * Default image style icons provided by the plugin that can be referred in the {@link module:image/imageconfig~ImageConfig#styles}
 * configuration.
 *
 * See {@link module:image/imageconfig~ImageStyleOptionDefinition#icon} to learn more.
 *
 * There are 7 default icons available: `'full'`, `'left'`, `'inlineLeft'`, `'center'`, `'right'`, `'inlineRight'`, and `'inline'`.
 */ const DEFAULT_ICONS = {
    full: objectFullWidth,
    left: objectBlockLeft,
    right: objectBlockRight,
    center: objectCenter,
    inlineLeft: objectLeft,
    inlineRight: objectRight,
    inline: objectInline
};
/**
 * Default drop-downs provided by the plugin that can be referred in the {@link module:image/imageconfig~ImageConfig#toolbar}
 * configuration. The drop-downs are containers for the {@link module:image/imageconfig~ImageStyleConfig#options image style options}.
 *
 * If both of the `ImageEditing` plugins are loaded, there are 2 predefined drop-downs available:
 *
 * * **`'imageStyle:wrapText'`**, which contains the `alignLeft` and `alignRight` options, that is,
 * those that wraps the text around the image,
 * * **`'imageStyle:breakText'`**, which contains the `alignBlockLeft`, `alignCenter` and `alignBlockRight` options, that is,
 * those that breaks the text around the image.
 */ const DEFAULT_DROPDOWN_DEFINITIONS = [
    {
        name: 'imageStyle:wrapText',
        title: 'Wrap text',
        defaultItem: 'imageStyle:alignLeft',
        items: [
            'imageStyle:alignLeft',
            'imageStyle:alignRight'
        ]
    },
    {
        name: 'imageStyle:breakText',
        title: 'Break text',
        defaultItem: 'imageStyle:block',
        items: [
            'imageStyle:alignBlockLeft',
            'imageStyle:block',
            'imageStyle:alignBlockRight'
        ]
    }
];
/**
 * Returns a list of the normalized and validated image style options.
 *
 * @param config
 * @param config.isInlinePluginLoaded
 * Determines whether the {@link module:image/image/imageblockediting~ImageBlockEditing `ImageBlockEditing`} plugin has been loaded.
 * @param config.isBlockPluginLoaded
 * Determines whether the {@link module:image/image/imageinlineediting~ImageInlineEditing `ImageInlineEditing`} plugin has been loaded.
 * @param config.configuredStyles
 * The image styles configuration provided in the image styles {@link module:image/imageconfig~ImageConfig#styles configuration}
 * as a default or custom value.
 * @returns
 * * Each of options contains a complete icon markup.
 * * The image style options not supported by any of the loaded plugins are filtered out.
 */ function normalizeStyles(config) {
    const configuredStyles = config.configuredStyles.options || [];
    const styles = configuredStyles.map((arrangement)=>normalizeDefinition(arrangement)).filter((arrangement)=>isValidOption(arrangement, config));
    return styles;
}
/**
 * Returns the default image styles configuration depending on the loaded image editing plugins.
 *
 * @param isInlinePluginLoaded
 * Determines whether the {@link module:image/image/imageblockediting~ImageBlockEditing `ImageBlockEditing`} plugin has been loaded.
 *
 * @param isBlockPluginLoaded
 * Determines whether the {@link module:image/image/imageinlineediting~ImageInlineEditing `ImageInlineEditing`} plugin has been loaded.
 *
 * @returns
 * It returns an object with the lists of the image style options and groups defined as strings related to the
 * {@link module:image/imagestyle/utils#DEFAULT_OPTIONS default options}
 */ function getDefaultStylesConfiguration(isBlockPluginLoaded, isInlinePluginLoaded) {
    if (isBlockPluginLoaded && isInlinePluginLoaded) {
        return {
            options: [
                'inline',
                'alignLeft',
                'alignRight',
                'alignCenter',
                'alignBlockLeft',
                'alignBlockRight',
                'block',
                'side'
            ]
        };
    } else if (isBlockPluginLoaded) {
        return {
            options: [
                'block',
                'side'
            ]
        };
    } else if (isInlinePluginLoaded) {
        return {
            options: [
                'inline',
                'alignLeft',
                'alignRight'
            ]
        };
    }
    return {};
}
/**
 * Returns a list of the available predefined drop-downs' definitions depending on the loaded image editing plugins.
 */ function getDefaultDropdownDefinitions(pluginCollection) {
    if (pluginCollection.has('ImageBlockEditing') && pluginCollection.has('ImageInlineEditing')) {
        return [
            ...DEFAULT_DROPDOWN_DEFINITIONS
        ];
    } else {
        return [];
    }
}
/**
 * Normalizes an image style option or group provided in the {@link module:image/imageconfig~ImageConfig#styles}
 * and returns it in a {@link module:image/imageconfig~ImageStyleOptionDefinition}/
 */ function normalizeDefinition(definition) {
    if (typeof definition === 'string') {
        // Just the name of the style has been passed, but none of the defaults.
        if (!DEFAULT_OPTIONS[definition]) {
            // Normalize the style anyway to prevent errors.
            definition = {
                name: definition
            };
        } else {
            definition = {
                ...DEFAULT_OPTIONS[definition]
            };
        }
    } else {
        // If an object style has been passed and if the name matches one of the defaults,
        // extend it with defaults – the user wants to customize a default style.
        // Note: Don't override the user–defined style object, clone it instead.
        definition = extendStyle(DEFAULT_OPTIONS[definition.name], definition);
    }
    // If an icon is defined as a string and correspond with a name
    // in default icons, use the default icon provided by the plugin.
    if (typeof definition.icon === 'string') {
        definition.icon = DEFAULT_ICONS[definition.icon] || definition.icon;
    }
    return definition;
}
/**
 * Checks if the image style option is valid:
 * * if it has the modelElements fields defined and filled,
 * * if the defined modelElements are supported by any of the loaded image editing plugins.
 * It also displays a console warning these conditions are not met.
 *
 * @param option image style option
 */ function isValidOption(option, { isBlockPluginLoaded, isInlinePluginLoaded }) {
    const { modelElements, name } = option;
    if (!modelElements || !modelElements.length || !name) {
        warnInvalidStyle({
            style: option
        });
        return false;
    } else {
        const supportedElements = [
            isBlockPluginLoaded ? 'imageBlock' : null,
            isInlinePluginLoaded ? 'imageInline' : null
        ];
        // Check if the option is supported by any of the loaded plugins.
        if (!modelElements.some((elementName)=>supportedElements.includes(elementName))) {
            /**
             * In order to work correctly, each image style {@link module:image/imageconfig~ImageStyleOptionDefinition option}
             * requires specific model elements (also: types of images) to be supported by the editor.
             *
             * Model element names to which the image style option can be applied are defined in the
             * {@link module:image/imageconfig~ImageStyleOptionDefinition#modelElements} property of the style option
             * definition.
             *
             * Explore the warning in the console to find out precisely which option is not supported and which editor plugins
             * are missing. Make sure these plugins are loaded in your editor to get this image style option working.
             *
             * @error image-style-missing-dependency
             * @param {String} [option] The name of the unsupported option.
             * @param {String} [missingPlugins] The names of the plugins one of which has to be loaded for the particular option.
             */ logWarning('image-style-missing-dependency', {
                style: option,
                missingPlugins: modelElements.map((name)=>name === 'imageBlock' ? 'ImageBlockEditing' : 'ImageInlineEditing')
            });
            return false;
        }
    }
    return true;
}
/**
 * Extends the default style with a style provided by the developer.
 * Note: Don't override the custom–defined style object, clone it instead.
 */ function extendStyle(source, style) {
    const extendedStyle = {
        ...style
    };
    for(const prop in source){
        if (!Object.prototype.hasOwnProperty.call(style, prop)) {
            extendedStyle[prop] = source[prop];
        }
    }
    return extendedStyle;
}
/**
 * Displays a console warning with the 'image-style-configuration-definition-invalid' error.
 */ function warnInvalidStyle(info) {
    /**
     * The image style definition provided in the configuration is invalid.
     *
     * Please make sure the definition implements properly one of the following:
     *
     * * {@link module:image/imageconfig~ImageStyleOptionDefinition image style option definition},
     * * {@link module:image/imageconfig~ImageStyleDropdownDefinition image style dropdown definition}
     *
     * @error image-style-configuration-definition-invalid
     * @param {String} [dropdown] The name of the invalid drop-down
     * @param {String} [style] The name of the invalid image style option
     */ logWarning('image-style-configuration-definition-invalid', info);
}
var utils = {
    normalizeStyles,
    getDefaultStylesConfiguration,
    getDefaultDropdownDefinitions,
    warnInvalidStyle,
    DEFAULT_OPTIONS,
    DEFAULT_ICONS,
    DEFAULT_DROPDOWN_DEFINITIONS
};

/**
 * @module image/imagestyle/converters
 */ /**
 * Returns a converter for the `imageStyle` attribute. It can be used for adding, changing and removing the attribute.
 *
 * @param styles An array containing available image style options.
 * @returns A model-to-view attribute converter.
 */ function modelToViewStyleAttribute(styles) {
    return (evt, data, conversionApi)=>{
        if (!conversionApi.consumable.consume(data.item, evt.name)) {
            return;
        }
        // Check if there is class name associated with given value.
        const newStyle = getStyleDefinitionByName(data.attributeNewValue, styles);
        const oldStyle = getStyleDefinitionByName(data.attributeOldValue, styles);
        const viewElement = conversionApi.mapper.toViewElement(data.item);
        const viewWriter = conversionApi.writer;
        if (oldStyle) {
            viewWriter.removeClass(oldStyle.className, viewElement);
        }
        if (newStyle) {
            viewWriter.addClass(newStyle.className, viewElement);
        }
    };
}
/**
 * Returns a view-to-model converter converting image CSS classes to a proper value in the model.
 *
 * @param styles Image style options for which the converter is created.
 * @returns A view-to-model converter.
 */ function viewToModelStyleAttribute(styles) {
    // Convert only non–default styles.
    const nonDefaultStyles = {
        imageInline: styles.filter((style)=>!style.isDefault && style.modelElements.includes('imageInline')),
        imageBlock: styles.filter((style)=>!style.isDefault && style.modelElements.includes('imageBlock'))
    };
    return (evt, data, conversionApi)=>{
        if (!data.modelRange) {
            return;
        }
        const viewElement = data.viewItem;
        const modelImageElement = first(data.modelRange.getItems());
        // Run this converter only if an image has been found in the model.
        // In some cases it may not be found (for example if we run this on a figure with different type than image).
        if (!modelImageElement) {
            return;
        }
        // ...and the `imageStyle` attribute is allowed for that element, otherwise stop conversion early.
        if (!conversionApi.schema.checkAttribute(modelImageElement, 'imageStyle')) {
            return;
        }
        // Convert styles one by one.
        for (const style of nonDefaultStyles[modelImageElement.name]){
            // Try to consume class corresponding with the style.
            if (conversionApi.consumable.consume(viewElement, {
                classes: style.className
            })) {
                // And convert this style to model attribute.
                conversionApi.writer.setAttribute('imageStyle', style.name, modelImageElement);
            }
        }
    };
}
/**
 * Returns the style with a given `name` from an array of styles.
 */ function getStyleDefinitionByName(name, styles) {
    for (const style of styles){
        if (style.name === name) {
            return style;
        }
    }
}

class ImageStyleEditing extends Plugin {
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'ImageStyleEditing';
    }
    /**
     * @inheritDoc
     */ static get requires() {
        return [
            ImageUtils
        ];
    }
    /**
     * @inheritDoc
     */ init() {
        const { normalizeStyles, getDefaultStylesConfiguration } = utils;
        const editor = this.editor;
        const isBlockPluginLoaded = editor.plugins.has('ImageBlockEditing');
        const isInlinePluginLoaded = editor.plugins.has('ImageInlineEditing');
        editor.config.define('image.styles', getDefaultStylesConfiguration(isBlockPluginLoaded, isInlinePluginLoaded));
        this.normalizedStyles = normalizeStyles({
            configuredStyles: editor.config.get('image.styles'),
            isBlockPluginLoaded,
            isInlinePluginLoaded
        });
        this._setupConversion(isBlockPluginLoaded, isInlinePluginLoaded);
        this._setupPostFixer();
        // Register imageStyle command.
        editor.commands.add('imageStyle', new ImageStyleCommand(editor, this.normalizedStyles));
    }
    /**
     * Sets the editor conversion taking the presence of
     * {@link module:image/image/imageinlineediting~ImageInlineEditing `ImageInlineEditing`}
     * and {@link module:image/image/imageblockediting~ImageBlockEditing `ImageBlockEditing`} plugins into consideration.
     */ _setupConversion(isBlockPluginLoaded, isInlinePluginLoaded) {
        const editor = this.editor;
        const schema = editor.model.schema;
        const modelToViewConverter = modelToViewStyleAttribute(this.normalizedStyles);
        const viewToModelConverter = viewToModelStyleAttribute(this.normalizedStyles);
        editor.editing.downcastDispatcher.on('attribute:imageStyle', modelToViewConverter);
        editor.data.downcastDispatcher.on('attribute:imageStyle', modelToViewConverter);
        // Allow imageStyle attribute in image and imageInline.
        // We could call it 'style' but https://github.com/ckeditor/ckeditor5-engine/issues/559.
        if (isBlockPluginLoaded) {
            schema.extend('imageBlock', {
                allowAttributes: 'imageStyle'
            });
            // Converter for figure element from view to model.
            editor.data.upcastDispatcher.on('element:figure', viewToModelConverter, {
                priority: 'low'
            });
        }
        if (isInlinePluginLoaded) {
            schema.extend('imageInline', {
                allowAttributes: 'imageStyle'
            });
            // Converter for the img element from view to model.
            editor.data.upcastDispatcher.on('element:img', viewToModelConverter, {
                priority: 'low'
            });
        }
    }
    /**
     * Registers a post-fixer that will make sure that the style attribute value is correct for a specific image type (block vs inline).
     */ _setupPostFixer() {
        const editor = this.editor;
        const document = editor.model.document;
        const imageUtils = editor.plugins.get(ImageUtils);
        const stylesMap = new Map(this.normalizedStyles.map((style)=>[
                style.name,
                style
            ]));
        // Make sure that style attribute is valid for the image type.
        document.registerPostFixer((writer)=>{
            let changed = false;
            for (const change of document.differ.getChanges()){
                if (change.type == 'insert' || change.type == 'attribute' && change.attributeKey == 'imageStyle') {
                    let element = change.type == 'insert' ? change.position.nodeAfter : change.range.start.nodeAfter;
                    if (element && element.is('element', 'paragraph') && element.childCount > 0) {
                        element = element.getChild(0);
                    }
                    if (!imageUtils.isImage(element)) {
                        continue;
                    }
                    const imageStyle = element.getAttribute('imageStyle');
                    if (!imageStyle) {
                        continue;
                    }
                    const imageStyleDefinition = stylesMap.get(imageStyle);
                    if (!imageStyleDefinition || !imageStyleDefinition.modelElements.includes(element.name)) {
                        writer.removeAttribute('imageStyle', element);
                        changed = true;
                    }
                }
            }
            return changed;
        });
    }
}

class ImageStyleUI extends Plugin {
    /**
     * @inheritDoc
     */ static get requires() {
        return [
            ImageStyleEditing
        ];
    }
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'ImageStyleUI';
    }
    /**
     * Returns the default localized style titles provided by the plugin.
     *
     * The following localized titles corresponding with
     * {@link module:image/imagestyle/utils#DEFAULT_OPTIONS} are available:
     *
     * * `'Wrap text'`,
     * * `'Break text'`,
     * * `'In line'`,
     * * `'Full size image'`,
     * * `'Side image'`,
     * * `'Left aligned image'`,
     * * `'Centered image'`,
     * * `'Right aligned image'`
     */ get localizedDefaultStylesTitles() {
        const t = this.editor.t;
        return {
            'Wrap text': t('Wrap text'),
            'Break text': t('Break text'),
            'In line': t('In line'),
            'Full size image': t('Full size image'),
            'Side image': t('Side image'),
            'Left aligned image': t('Left aligned image'),
            'Centered image': t('Centered image'),
            'Right aligned image': t('Right aligned image')
        };
    }
    /**
     * @inheritDoc
     */ init() {
        const plugins = this.editor.plugins;
        const toolbarConfig = this.editor.config.get('image.toolbar') || [];
        const imageStyleEditing = plugins.get('ImageStyleEditing');
        const definedStyles = translateStyles(imageStyleEditing.normalizedStyles, this.localizedDefaultStylesTitles);
        for (const styleConfig of definedStyles){
            this._createButton(styleConfig);
        }
        const definedDropdowns = translateStyles([
            ...toolbarConfig.filter(isObject),
            ...utils.getDefaultDropdownDefinitions(plugins)
        ], this.localizedDefaultStylesTitles);
        for (const dropdownConfig of definedDropdowns){
            this._createDropdown(dropdownConfig, definedStyles);
        }
    }
    /**
     * Creates a dropdown and stores it in the editor {@link module:ui/componentfactory~ComponentFactory}.
     */ _createDropdown(dropdownConfig, definedStyles) {
        const factory = this.editor.ui.componentFactory;
        factory.add(dropdownConfig.name, (locale)=>{
            let defaultButton;
            const { defaultItem, items, title } = dropdownConfig;
            const buttonViews = items.filter((itemName)=>definedStyles.find(({ name })=>getUIComponentName(name) === itemName)).map((buttonName)=>{
                const button = factory.create(buttonName);
                if (buttonName === defaultItem) {
                    defaultButton = button;
                }
                return button;
            });
            if (items.length !== buttonViews.length) {
                utils.warnInvalidStyle({
                    dropdown: dropdownConfig
                });
            }
            const dropdownView = createDropdown(locale, SplitButtonView);
            const splitButtonView = dropdownView.buttonView;
            const splitButtonViewArrow = splitButtonView.arrowView;
            addToolbarToDropdown(dropdownView, buttonViews, {
                enableActiveItemFocusOnDropdownOpen: true
            });
            splitButtonView.set({
                label: getDropdownButtonTitle(title, defaultButton.label),
                class: null,
                tooltip: true
            });
            splitButtonViewArrow.unbind('label');
            splitButtonViewArrow.set({
                label: title
            });
            splitButtonView.bind('icon').toMany(buttonViews, 'isOn', (...areOn)=>{
                const index = areOn.findIndex(identity);
                return index < 0 ? defaultButton.icon : buttonViews[index].icon;
            });
            splitButtonView.bind('label').toMany(buttonViews, 'isOn', (...areOn)=>{
                const index = areOn.findIndex(identity);
                return getDropdownButtonTitle(title, index < 0 ? defaultButton.label : buttonViews[index].label);
            });
            splitButtonView.bind('isOn').toMany(buttonViews, 'isOn', (...areOn)=>areOn.some(identity));
            splitButtonView.bind('class').toMany(buttonViews, 'isOn', (...areOn)=>areOn.some(identity) ? 'ck-splitbutton_flatten' : undefined);
            splitButtonView.on('execute', ()=>{
                if (!buttonViews.some(({ isOn })=>isOn)) {
                    defaultButton.fire('execute');
                } else {
                    dropdownView.isOpen = !dropdownView.isOpen;
                }
            });
            dropdownView.bind('isEnabled').toMany(buttonViews, 'isEnabled', (...areEnabled)=>areEnabled.some(identity));
            // Focus the editable after executing the command.
            // Overrides a default behaviour where the focus is moved to the dropdown button (#12125).
            this.listenTo(dropdownView, 'execute', ()=>{
                this.editor.editing.view.focus();
            });
            return dropdownView;
        });
    }
    /**
     * Creates a button and stores it in the editor {@link module:ui/componentfactory~ComponentFactory}.
     */ _createButton(buttonConfig) {
        const buttonName = buttonConfig.name;
        this.editor.ui.componentFactory.add(getUIComponentName(buttonName), (locale)=>{
            const command = this.editor.commands.get('imageStyle');
            const view = new ButtonView(locale);
            view.set({
                label: buttonConfig.title,
                icon: buttonConfig.icon,
                tooltip: true,
                isToggleable: true
            });
            view.bind('isEnabled').to(command, 'isEnabled');
            view.bind('isOn').to(command, 'value', (value)=>value === buttonName);
            view.on('execute', this._executeCommand.bind(this, buttonName));
            return view;
        });
    }
    _executeCommand(name) {
        this.editor.execute('imageStyle', {
            value: name
        });
        this.editor.editing.view.focus();
    }
}
/**
 * Returns the translated `title` from the passed styles array.
 */ function translateStyles(styles, titles) {
    for (const style of styles){
        // Localize the titles of the styles, if a title corresponds with
        // a localized default provided by the plugin.
        if (titles[style.title]) {
            style.title = titles[style.title];
        }
    }
    return styles;
}
/**
 * Returns the image style component name with the "imageStyle:" prefix.
 */ function getUIComponentName(name) {
    return `imageStyle:${name}`;
}
/**
 * Returns title for the splitbutton containing the dropdown title and default action item title.
 */ function getDropdownButtonTitle(dropdownTitle, buttonTitle) {
    return (dropdownTitle ? dropdownTitle + ': ' : '') + buttonTitle;
}

class ImageStyle extends Plugin {
    /**
     * @inheritDoc
     */ static get requires() {
        return [
            ImageStyleEditing,
            ImageStyleUI
        ];
    }
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'ImageStyle';
    }
}

class ImageToolbar extends Plugin {
    /**
     * @inheritDoc
     */ static get requires() {
        return [
            WidgetToolbarRepository,
            ImageUtils
        ];
    }
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'ImageToolbar';
    }
    /**
     * @inheritDoc
     */ afterInit() {
        const editor = this.editor;
        const t = editor.t;
        const widgetToolbarRepository = editor.plugins.get(WidgetToolbarRepository);
        const imageUtils = editor.plugins.get('ImageUtils');
        widgetToolbarRepository.register('image', {
            ariaLabel: t('Image toolbar'),
            items: normalizeDeclarativeConfig(editor.config.get('image.toolbar') || []),
            getRelatedElement: (selection)=>imageUtils.getClosestSelectedImageWidget(selection)
        });
    }
}
/**
 * Convert the dropdown definitions to their keys registered in the ComponentFactory.
 * The registration precess should be handled by the plugin which handles the UI of a particular feature.
 */ function normalizeDeclarativeConfig(config) {
    return config.map((item)=>isObject(item) ? item.name : item);
}

class PictureEditing extends Plugin {
    /**
     * @inheritDoc
     */ static get requires() {
        return [
            ImageEditing,
            ImageUtils
        ];
    }
    /**
     * @inheritDoc
     */ static get pluginName() {
        return 'PictureEditing';
    }
    /**
     * @inheritDoc
     */ afterInit() {
        const editor = this.editor;
        if (editor.plugins.has('ImageBlockEditing')) {
            editor.model.schema.extend('imageBlock', {
                allowAttributes: [
                    'sources'
                ]
            });
        }
        if (editor.plugins.has('ImageInlineEditing')) {
            editor.model.schema.extend('imageInline', {
                allowAttributes: [
                    'sources'
                ]
            });
        }
        this._setupConversion();
        this._setupImageUploadEditingIntegration();
    }
    /**
     * Configures conversion pipelines to support upcasting and downcasting images using the `<picture>` view element
     * and the model `sources` attribute.
     */ _setupConversion() {
        const editor = this.editor;
        const conversion = editor.conversion;
        const imageUtils = editor.plugins.get('ImageUtils');
        conversion.for('upcast').add(upcastPicture(imageUtils));
        conversion.for('downcast').add(downcastSourcesAttribute(imageUtils));
    }
    /**
     * Makes it possible for uploaded images to get the `sources` model attribute and the `<picture>...</picture>`
     * view structure out-of-the-box if relevant data is provided along the
     * {@link module:image/imageupload/imageuploadediting~ImageUploadEditing#event:uploadComplete} event.
     */ _setupImageUploadEditingIntegration() {
        const editor = this.editor;
        if (!editor.plugins.has('ImageUploadEditing')) {
            return;
        }
        const imageUploadEditing = editor.plugins.get('ImageUploadEditing');
        this.listenTo(imageUploadEditing, 'uploadComplete', (evt, { imageElement, data })=>{
            const sources = data.sources;
            if (!sources) {
                return;
            }
            editor.model.change((writer)=>{
                writer.setAttributes({
                    sources
                }, imageElement);
            });
        });
    }
}

export { AutoImage, Image, ImageBlock, ImageBlockEditing, ImageCaption, ImageCaptionEditing, ImageCaptionUI, ImageCaptionUtils, ImageCustomResizeUI, ImageEditing, ImageInline, ImageInsert, ImageInsertUI, ImageInsertViaUrl, ImageResize, ImageResizeButtons, ImageResizeEditing, ImageResizeHandles, ImageSizeAttributes, ImageStyle, ImageStyleEditing, ImageStyleUI, ImageTextAlternative, ImageTextAlternativeEditing, ImageTextAlternativeUI, ImageToolbar, ImageUpload, ImageUploadEditing, ImageUploadProgress, ImageUploadUI, ImageUtils, PictureEditing };
//# sourceMappingURL=index.js.map