@ckeditor/ckeditor5-image
Version:
Image feature for CKEditor 5.
6,275 lines • 217 kB
JavaScript
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import { Command, Plugin } from "@ckeditor/ckeditor5-core";
import { Clipboard, ClipboardPipeline } from "@ckeditor/ckeditor5-clipboard";
import { ModelElement, ModelLivePosition, ModelLiveRange, Observer, ViewUpcastWriter, _isParagraphableModelNode, enableViewPlaceholder } from "@ckeditor/ckeditor5-engine";
import { Undo } from "@ckeditor/ckeditor5-undo";
import { Delete } from "@ckeditor/ckeditor5-typing";
import { CKEditorError, Collection, DomEmitterMixin, FocusTracker, KeystrokeHandler, Rect, _tryCastDimensionsToUnit, _tryParseDimensionWithUnit, env, first, global, logWarning, toArray } from "@ckeditor/ckeditor5-utils";
import { Widget, WidgetResize, WidgetToolbarRepository, calculateResizeHostAncestorWidth, findOptimalInsertionRange, isWidget, toWidget, toWidgetEditable } from "@ckeditor/ckeditor5-widget";
import { BalloonPanelView, ButtonView, CollapsibleView, ContextualBalloon, CssTransitionDisablerMixin, Dialog, DropdownButtonView, FileDialogButtonView, FocusCycler, FormHeaderView, FormRowView, LabeledFieldView, MenuBarMenuListItemButtonView, MenuBarMenuListItemFileDialogButtonView, MenuBarMenuListItemView, MenuBarMenuListView, MenuBarMenuView, Notification, SplitButtonView, UIModel, View, ViewCollection, addListToDropdown, addToolbarToDropdown, clickOutsideHandler, createDropdown, createLabeledInputNumber, createLabeledInputText, submitHandler } from "@ckeditor/ckeditor5-ui";
import { IconCaption, IconImage, IconImageUpload, IconImageUrl, IconObjectCenter, IconObjectFullWidth, IconObjectInline, IconObjectInlineLeft, IconObjectInlineRight, IconObjectLeft, IconObjectRight, IconObjectSizeCustom, IconObjectSizeFull, IconObjectSizeLarge, IconObjectSizeMedium, IconObjectSizeSmall, IconPreviousArrow, IconTextAlternative } from "@ckeditor/ckeditor5-icons";
import { FileRepository } from "@ckeditor/ckeditor5-upload";
import { identity, isEqual, isObject } from "es-toolkit/compat";
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/image/utils
*/
/**
* 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.
*
* @deprecated
* @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) => {
if (!imageUtils.isInlineImageView(element)) return null;
if (!areBothImagePluginsLoaded) return getPositiveMatchPattern(element);
if (getViewImageType(element, imageUtils) !== matchImageType) return null;
return getPositiveMatchPattern(element);
};
function getPositiveMatchPattern(element) {
const pattern = { name: true };
if (element.hasAttribute("src")) pattern.attributes = ["src"];
return pattern;
}
}
/**
* Resolves the model image type (`'imageBlock'` or `'imageInline'`) that a given `<img>` view element represents,
* based purely on its view structure.
*
* An `<img>` is treated as a block image when it has a `display: block` style or is wrapped in a block image figure
* (`<figure class="image">`, also `<figure class="image"><a>...</a></figure>` added by the `LinkImage` plugin).
* Otherwise it is treated as an inline image.
*
* Note that this only reflects the view representation - it does not check whether the resolved type is allowed by the
* schema at the insertion position.
*
* @internal
* @param element The `<img>` view element to resolve the type for.
* @param imageUtils The `ImageUtils` plugin instance.
*/
function getViewImageType(element, imageUtils) {
return element.getStyle("display") == "block" || element.findAncestor(imageUtils.isBlockImageView) ? "imageBlock" : "imageInline";
}
/**
* Checks whether the given image type can be placed at the specified position - either directly (or after hoisting
* to an allowed ancestor) or, for inline images, wrapped in an auto-created paragraph.
*
* This is the predicate that decides whether a block image may land at a position or must degrade to an inline image
* (for example, inside an `$inlineRoot` that disallows block content) and, symmetrically, whether an inline image may
* become a block image. `isParagraphable()` is always `false` for `imageBlock` (a block object never fits in a
* paragraph), so the auto-paragraph relaxation only applies to the inline target.
*
* @internal
* @param schema The model schema to check against.
* @param position The position at which the image type should be placed.
* @param imageType The image type to check.
*/
function isImageTypePlaceable(schema, position, imageType) {
return !!schema.findAllowedParent(position, imageType) || _isParagraphableModelNode(position, imageType, schema);
}
/**
* 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());
if (!firstBlock || schema.isObject(firstBlock)) return "imageBlock";
if (firstBlock.isEmpty && firstBlock.name != "listItem") return "imageBlock";
return "imageInline";
}
/**
* Returns parsed value of the size, but only if it contains unit: px.
*
* @internal
*/
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.
*
* @internal
*/
function widthAndHeightStylesAreBothSet(viewElement) {
const widthStyle = getSizeValueIfInPx(viewElement.getStyle("width"));
const heightStyle = getSizeValueIfInPx(viewElement.getStyle("height"));
return !!(widthStyle && heightStyle);
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
const IMAGE_WIDGETS_CLASSES_MATCH_REGEXP = /^(image|image-inline)$/;
/**
* A set of helpers related to images.
*/
var ImageUtils = class extends Plugin {
/**
* DOM Emitter.
*/
_domEmitter = new (DomEmitterMixin())();
/**
* @inheritDoc
*/
static get pluginName() {
return "ImageUtils";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* 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~ModelSchema}.
* @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);
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",
findOptimalPosition: !selectable && determinedImageType != "imageInline" ? "auto" : void 0
});
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")) 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 selection = this.editor.model.document.selection;
return isImageAllowedInParent(this.editor, selection) && isNotInsideImage(selection);
}
/**
* Converts a given {@link module:engine/view/element~ViewElement} to an image widget:
* * Adds a {@link module:engine/view/element~ViewElement#_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 altText = this.findViewImgElement(viewElement).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();
}
};
/**
* Checks if image is allowed by schema in optimal insertion parent.
*/
function isImageAllowedInParent(editor, selection) {
if (determineImageTypeForInsertion(editor, selection, null) == "imageBlock") return isBlockImageAllowedAtInsertion(editor.model, selection);
return editor.model.schema.checkChild(selection.focus, "imageInline");
}
/**
* 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 parent = findOptimalInsertionRange(selection, model).start.parent;
if (parent.isEmpty && !parent.is("rootElement")) return parent.parent;
return parent;
}
/**
* Checks whether a block image is allowed by the schema at the optimal insertion point for the given selectable
* (e.g. it is not allowed inside `$inlineRoot` or any other inline-only container).
*/
function isBlockImageAllowedAtInsertion(model, selectable) {
const insertionParent = getInsertImageParent(selectable.is("position") ? model.createSelection(selectable) : selectable, model);
return model.schema.checkChild(insertionParent, "imageBlock");
}
/**
* 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 (!isBlockImageAllowedAtInsertion(editor.model, selectable)) return "imageInline";
if (configImageInsertType === "inline") return "imageInline";
if (configImageInsertType !== "auto") return "imageBlock";
if (selectable.is("selection")) return determineImageTypeForInsertionAtSelection(schema, selectable);
return schema.checkChild(selectable, "imageInline") ? "imageInline" : "imageBlock";
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/autoimage
*/
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));
/**
* The auto-image plugin. It recognizes image links in the pasted content and embeds
* them shortly after they are injected into the document.
*/
var AutoImage = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [
Clipboard,
ImageUtils,
Undo,
Delete
];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "AutoImage";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* The paste–to–embed `setTimeout` ID. Stored as a property to allow
* cleaning of the timeout.
*/
_timeoutId;
/**
* The position where the `<imageBlock>` element will be inserted after the timeout,
* determined each time a new content is pasted into the document.
*/
_positionToInsert;
/**
* @inheritDoc
*/
constructor(editor) {
super(editor);
this._timeoutId = null;
this._positionToInsert = null;
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const modelDocument = editor.model.document;
const clipboardPipeline = editor.plugins.get("ClipboardPipeline");
this.listenTo(clipboardPipeline, "inputTransformation", () => {
const firstRange = modelDocument.selection.getFirstRange();
const leftLivePosition = ModelLivePosition.fromPosition(firstRange.start);
leftLivePosition.stickiness = "toPrevious";
const rightLivePosition = ModelLivePosition.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;
const urlRange = new ModelLiveRange(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 (!src.match(IMAGE_URL_REGEXP)) {
urlRange.detach();
return;
}
this._positionToInsert = ModelLivePosition.fromPosition(leftPosition);
this._timeoutId = setTimeout(() => {
if (!editor.commands.get("insertImage").isEnabled) {
urlRange.detach();
return;
}
editor.model.change((writer) => {
this._timeoutId = null;
writer.remove(urlRange);
urlRange.detach();
let insertionPosition;
if (this._positionToInsert.root.rootName !== "$graveyard") insertionPosition = this._positionToInsert.toPosition();
imageUtils.insertImage({
...selectionAttributes,
src
}, insertionPosition);
this._positionToInsert.detach();
this._positionToInsert = null;
});
editor.plugins.get("Delete").requestUndoOnBackspace();
}, 100);
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imagetextalternative/imagetextalternativecommand
*/
/**
* The image text alternative command. It is used to change the `alt` attribute of `<imageBlock>` and `<imageInline>` model elements.
*/
var ImageTextAlternativeCommand = class extends Command {
/**
* @inheritDoc
*/
refresh() {
const element = this.editor.plugins.get("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);
});
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imagetextalternative/imagetextalternativeediting
*/
/**
* The image text alternative editing plugin.
*
* Registers the `'imageTextAlternative'` command.
*/
var ImageTextAlternativeEditing = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [ImageUtils];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "ImageTextAlternativeEditing";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
init() {
this.editor.commands.add("imageTextAlternative", new ImageTextAlternativeCommand(this.editor));
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imagetextalternative/ui/textalternativeformview
*/
/**
* The TextAlternativeFormView class.
*
* @internal
*/
var TextAlternativeFormView = class extends View {
/**
* Tracks information about the DOM focus in the form.
*/
focusTracker;
/**
* An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}.
*/
keystrokes;
/**
* An input with a label.
*/
labeledInput;
/**
* The Back button view displayed in the header.
*/
backButtonView;
/**
* A button used to submit the form.
*/
saveButtonView;
/**
* A collection of child views.
*/
children;
/**
* A collection of views which can be focused in the form.
*/
_focusables;
/**
* Helps cycling over {@link #_focusables} in the form.
*/
_focusCycler;
/**
* @inheritDoc
*/
constructor(locale) {
super(locale);
this.focusTracker = new FocusTracker();
this.keystrokes = new KeystrokeHandler();
this.backButtonView = this._createBackButton();
this.saveButtonView = this._createSaveButton();
this.labeledInput = this._createLabeledInputView();
this.children = this.createCollection([this._createHeaderView()]);
this.children.add(new FormRowView(locale, {
children: [this.labeledInput, this.saveButtonView],
class: ["ck-form__row_with-submit", "ck-form__row_large-top-padding"]
}));
this._focusables = new ViewCollection();
this.keystrokes.set("Esc", (data, cancel) => {
this.fire("cancel");
cancel();
});
this._focusCycler = new FocusCycler({
focusables: this._focusables,
focusTracker: this.focusTracker,
keystrokeHandler: this.keystrokes,
actions: {
focusPrevious: "shift + tab",
focusNext: "tab"
}
});
this.setTemplate({
tag: "form",
attributes: {
class: [
"ck",
"ck-form",
"ck-text-alternative-form",
"ck-responsive-form"
],
tabindex: "-1"
},
children: this.children
});
}
/**
* @inheritDoc
*/
render() {
super.render();
submitHandler({ view: this });
[
this.backButtonView,
this.labeledInput,
this.saveButtonView
].forEach((v) => {
this._focusables.add(v);
this.focusTracker.add(v.element);
});
this.keystrokes.listenTo(this.element);
}
/**
* @inheritDoc
*/
destroy() {
super.destroy();
this.focusTracker.destroy();
this.keystrokes.destroy();
}
/**
* Creates a back button view that cancels the form.
*/
_createBackButton() {
const t = this.locale.t;
const backButton = new ButtonView(this.locale);
backButton.set({
class: "ck-button-back",
label: t("Back"),
icon: IconPreviousArrow,
tooltip: true
});
backButton.delegate("execute").to(this, "cancel");
return backButton;
}
/**
* Creates a save button view that text alternative the image.
*/
_createSaveButton() {
const t = this.locale.t;
const saveButton = new ButtonView(this.locale);
saveButton.set({
label: t("Save"),
withText: true,
type: "submit",
class: "ck-button-action ck-button-bold"
});
return saveButton;
}
/**
* Creates a header view for the form.
*/
_createHeaderView() {
const t = this.locale.t;
const header = new FormHeaderView(this.locale, { label: t("Text Alternative") });
header.children.add(this.backButtonView, 0);
return header;
}
/**
* 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");
labeledInput.class = "ck-labeled-field-view_full-width";
return labeledInput;
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* 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.
* @internal
*/
function repositionContextualBalloon(editor) {
const balloon = editor.plugins.get("ContextualBalloon");
if (editor.plugins.get("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.
* @internal
*/
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
]
};
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imagetextalternative/imagetextalternativeui
*/
/**
* The image text alternative UI plugin.
*
* The plugin uses the {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon}.
*/
var ImageTextAlternativeUI = class extends Plugin {
/**
* The contextual balloon plugin instance.
*/
_balloon;
/**
* A form containing a textarea and buttons, used to change the `alt` text value.
*/
_form;
/**
* @inheritDoc
*/
static get requires() {
return [ContextualBalloon];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "ImageTextAlternativeUI";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
init() {
this._createButton();
}
/**
* @inheritDoc
*/
destroy() {
super.destroy();
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: IconTextAlternative,
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 viewDocument = editor.editing.view.document;
const imageUtils = editor.plugins.get("ImageUtils");
this._balloon = this.editor.plugins.get("ContextualBalloon");
this._form = new (CssTransitionDisablerMixin(TextAlternativeFormView))(editor.locale);
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);
});
this.listenTo(editor.ui, "update", () => {
if (!imageUtils.getClosestSelectedImageWidget(viewDocument.selection)) this._hideForm(true);
else if (this._isVisible) repositionContextualBalloon(editor);
});
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)
});
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;
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);
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imagetextalternative
*/
/**
* The image text alternative plugin.
*
* For a detailed overview, check the {@glink features/images/images-styles image styles} documentation.
*
* This is a "glue" plugin which loads the
* {@link module:image/imagetextalternative/imagetextalternativeediting~ImageTextAlternativeEditing}
* and {@link module:image/imagetextalternative/imagetextalternativeui~ImageTextAlternativeUI} plugins.
*/
var ImageTextAlternative = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [ImageTextAlternativeEditing, ImageTextAlternativeUI];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "ImageTextAlternative";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* 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) => {
if (!conversionApi.consumable.test(data.viewItem, {
name: true,
classes: "image"
})) return;
const viewImage = imageUtils.findViewImgElement(data.viewItem);
if (!viewImage || !conversionApi.consumable.test(viewImage, { name: true })) return;
if (!isImageTypePlaceable(conversionApi.schema, data.modelCursor, "imageBlock")) return;
conversionApi.consumable.consume(data.viewItem, {
name: true,
classes: "image"
});
const conversionResult = conversionApi.convertItem(viewImage, data.modelCursor);
/* istanbul ignore if: defensive guard for the `ModelRange | null` return type -- @preserve */
if (!conversionResult.modelRange) {
conversionApi.consumable.revert(data.viewItem, {
name: true,
classes: "image"
});
return;
}
const modelImage = first(conversionResult.modelRange.getItems());
if (!modelImage) {
conversionApi.consumable.revert(data.viewItem, {
name: true,
classes: "image"
});
return;
}
conversionApi.convertChildren(data.viewItem, modelImage);
conversionApi.updateConversionResult(modelImage, data);
};
return (dispatcher) => {
dispatcher.on("element:figure", converter);
};
}
/**
* Returns a function that upcasts an `<img>` element to either an `imageBlock` or an `imageInline` model element.
*
* The image type is first determined from the view structure (see {@link module:image/image/utils~getViewImageType}):
* an `<img>` wrapped in a `<figure class="image">` or styled with `display: block` becomes an `imageBlock`, otherwise
* an `imageInline`.
*
* That structural type is then verified against the schema at the insertion position. If it cannot be placed there -
* neither directly (or after hoisting to an allowed ancestor) nor wrapped in an auto-created paragraph - the converter
* falls back to `matchImageType`. This is what allows a block image to degrade to an inline image inside an inline root
* (and, symmetrically, an inline image to become a block image in a context that only accepts block images) instead of
* being dropped.
*
* Both `ImageBlockEditing` and `ImageInlineEditing` register this converter, each passing the type it falls back to.
* When only one of them is loaded, that single type is always produced.
*
* @internal
* @param matchImageType The image type to fall back to when the type resolved from the view cannot be placed at the
* insertion position.
* @param imageUtils The `ImageUtils` plugin instance.
*/
function upcastImg(matchImageType, imageUtils) {
const converter = (evt, data, conversionApi) => {
let imageType = getViewImageType(data.viewItem, imageUtils);
const attributes = data.viewItem.hasAttribute("src") ? { src: data.viewItem.getAttribute("src") } : void 0;
const consumables = {
name: true,
attributes: Object.keys(attributes || {})
};
if (!conversionApi.consumable.test(data.viewItem, consumables)) return;
if (imageType != matchImageType && !isImageTypePlaceable(conversionApi.schema, data.modelCursor, imageType)) imageType = matchImageType;
const modelElement = conversionApi.writer.createElement(imageType, attributes);
if (!conversionApi.safeInsert(modelElement, data.modelCursor)) return;
conversionApi.consumable.consume(data.viewItem, consumables);
conversionApi.convertChildren(data.viewItem, modelElement);
conversionApi.updateConversionResult(modelElement, data);
};
return (dispatcher) => {
dispatcher.on("element:img", 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;
if (!conversionApi.consumable.test(pictureViewElement, { name: true })) return;
const sources = /* @__PURE__ */ new Map();
for (const childSourceElement of pictureViewElement.getChildren()) if (childSourceElement.is("element", "source")) {
const attributes = {};
for (const name of sourceAttributeNames) if (childSourceElement.hasAttribute(name)) {
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);
if (!imgViewElement) return;
let modelImage = data.modelCursor.parent;
if (!modelImage.is("element", "imageBlock")) {
const conversionResult = conversionApi.convertItem(imgViewElement, data.modelCursor);
data.modelRange = conversionResult.modelRange;
data.modelCursor = conversionResult.modelCursor;
/* istanbul ignore if: defensive guard for the `ModelRange | null` return type -- @preserve */
if (!conversionResult.modelRange) return;
modelImage = first(conversionResult.modelRange.getItems());
if (!modelImage) return;
}
conversionApi.consumable.consume(pictureViewElement, { name: true });
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);
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);
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) {
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;
}
const hasPictureElement = imgElement.parent.is("element", "picture");
const pictureElement = hasPictureElement ? imgElement.parent : viewWriter.createContainerElement("picture", null);
if (!hasPictureElement) viewWriter.insert(viewWriter.createPositionBefore(imgElement), pictureElement);
viewWriter.remove(viewWriter.createRangeIn(pictureElement));
viewWriter.insert(viewWriter.createPositionAt(pictureElement, "end"), attributeNewValue.map((sourceAttributes) => {
return viewWriter.createEmptyElement("source", sourceAttributes);
}));
viewWriter.move(viewWriter.createRangeOn(imgElement), viewWriter.createPositionAt(pictureElement, "end"));
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);
};
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/image/imageloadobserver
*/
/**
* Observes all new images added to the {@link module:engine/view/document~ViewDocument},
* fires {@link module:engine/view/document~ViewDocument#event:imageLoaded} and
* {@link module:engine/view/document~ViewDocument#event:layoutChanged} event every time when the new image
* has been loaded.
*
* **Note:** This event is not fired for images that has been added to the document and rendered as `complete` (already loaded).
*/
var ImageLoadObserver = class 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);
}, { useCapture: true });
}
/**
* @inheritDoc
*/
stopObserving(domRoot) {
this.stopListening(domRoot);
}
/**
* Fires {@link module:engine/view/document~ViewDocument#event:layoutChanged} and
* {@link module:engine/view/document~ViewDocument#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);
}
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/image/insertimagecommand
*/
/**
* Insert image command.
*
* The command is registered by the {@link module:image/image/imageediting~ImageEditing} plugin as `insertImage`
* and it is also available via aliased `imageInsert` name.
*
* In order to insert an image at the current selection position
* (according to the {@link module:widget/utils~findOptimalInsertionRange} algorithm),
* execute the command and specify the image source:
*
* ```ts
* editor.execute( 'insertImage', { source: 'http://url.to.the/image' } );
* ```
*
* It is also possible to insert multiple images at once:
*
* ```ts
* editor.execute( 'insertImage', {
* source: [
* 'path/to/image.jpg',
* 'path/to/other-image.jpg'
* ]
* } );
* ```
*
* If you want to take the full control over the process, you can specify individual model attributes:
*
* ```ts
* editor.execute( 'insertImage', {
* source: [
* { src: 'path/to/image.jpg', alt: 'First alt text' },
* { src: 'path/to/other-image.jpg', alt: 'Second alt text', customAttribute: 'My attribute value' }
* ]
* } );
* ```
*/
var InsertImageCommand = class extends Command {
/**
* @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");
}
}
/**
* @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.imageType The type of the image to insert. If not specified, the type will be determined automatically.
* @param options.source The image source or an array of image sources to insert.
* @param options.breakBlock If set to `true`, the block at the selection start will be broken before inserting the image.
* 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");
const selectionAttributes = Object.fromEntries(selection.getAttributes());
sourceDefinitions.forEach((sourceDefinition, index) => {
const selectedElement = selection.getSelectedElement();
if (typeof sourceDefinition === "string") sourceDefinition = { src: sourceDefinition };
if (index && selectedElement && imageUtils.isImage(selectedElement)) {
const position = this.editor.model.createPositionAfter(selectedElement);
imageUtils.insertImage({
...sourceDefinition,
...selectionAttributes
}, position, options.imageType);
} else if (options.breakBlock) imageUtils.insertImage({
...sourceDefinition,
...selectionAttributes
}, selection.getFirstPosition(), options.imageType);
else imageUtils.insertImage({
...sourceDefinition,
...selectionAttributes
}, null, options.imageType);
});
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/image/replaceimagesourcecommand
*/
/**
* Replace image source command.
*
* Changes image source to the one provided. Can be executed as follows:
*
* ```ts
* editor.execute( 'replaceImageSource', { source: 'http://url.to.the/image' } );
* ```
*/
var ReplaceImageSourceCommand = class extends Command {
constructor(editor) {
super(editor);
this.decorate("cleanupImage");
}
/**
* @inheritDoc
*/
refresh() {
const imageUtils = this.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);
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/image/imageediting
*/
/**
* The image engine plugin. This module loads common code shared between
* {@link module:image/image/imageinlineediting~ImageInlineEditing} and
* {@link module:image/image/imageblockediting~ImageBlockEditing} plugins.
*
* This plugin registers the {@link module:image/image/insertimagecommand~InsertImageCommand 'insertImage'} command.
*/
var ImageEditing = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [ImageUtils];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "ImageEditing";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const conversion = editor.conversion;
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);
editor.commands.add("imageInsert", insertImageCommand);
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imagesizeattributes
*/
/**
* This plugin enables `width` and `height` attributes in inline and block image elements.
*/
var ImageSizeAttributes = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [ImageUtils];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "ImageSizeAttributes";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
afterInit() {
this._registerSchema();
this._registerConverters("imageBlock");
this._registerConverters("imageInline");
}
/**
* Registers the `width` and `height` attributes for inline and block images.
*/
_registerSchema() {
const schema = this.editor.model.schema;
if (this.editor.plugins.has("ImageBlockEditing")) schema.extend("imageBlock", { allowAttributes: ["width", "height"] });
if (this.editor.plugins.has("ImageInlineEditing")) 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"
});
editor.conversion.for("editingDowncast").add((dispatcher) => {
attachDowncastConverter(dispatcher, "width", "width", true, true);
attachDowncastConverter(dispatcher, "height", "height", true, true);
});
editor.conversion.for("dataDowncast").add((dispatcher) => {
attachDowncastConverter(dispatcher, "width", "width", false);
attachDowncastConverter(dispatcher, "height", "height", false);
});
editor.conversion.for("upcast").add((dispatcher) => {
dispatcher.on("element:img", (evt, data, conversionApi) => {
const width = data.viewItem.getAttribute("width");
const height = data.viewItem.getAttribute("height");
if (width && height) conversionApi.consumable.consume(data.viewItem, { styles: ["aspect-ratio"] });
});
});
function attachDowncastConverter(dispatcher, modelAttributeName, viewAttributeName, setRatioForInlineImage, isEditingDowncast = false) {
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);
const width = data.item.getAttribute("width");
const height = data.item.getAttribute("height");
const hasSizes = width && height;
if (hasSizes && isEditingDowncast) viewWriter.setAttribute("loading", "lazy", img);
if (data.item.hasAttribute("sources")) return;
const isResized = data.item.hasAttribute("resizedWidth");
if (imageType === "imageInline" && !isResized && !setRatioForInlineImage) return;
if (hasSizes) viewWriter.setStyle("aspect-ratio", `${width}/${height}`, img);
});
}
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* The image type command. It changes the type of a selected image, depending on the configuration.
*/
var ImageTypeCommand = class extends Command {
/**
* Model element name the command converts to.
*/
_modelElementName;
/**
* @inheritDoc
*
* @param modelElementName Model element name the command converts to.
*/
constructor(editor, modelElementName) {
super(editor);
this._modelElementName = modelElementName;
}
/**
* @inheritDoc
*/
refresh() {
const editor = this.editor;
const model = editor.model;
const schema = model.schema;
const imageUtils = editor.plugins.get("ImageUtils");
const element = imageUtils.getClosestSelectedImageElement(model.document.selection);
if (!(this._modelElementName === "imageBlock" ? imageUtils.isInlineImage(element) : imageUtils.isBlockImage(element))) {
this.isEnabled = false;
return;
}
const position = model.createPositionBefore(element);
this.isEnabled = isImageTypePlaceable(schema, position, this._modelElementName);
}
/**
* 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());
if (!attributes.src && !attributes.uploadId) return null;
return model.change((writer) => {
const { setImageSizes = true } = options;
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);
for (const marker of markers) {
const markerRange = marker.getRange();
const range = markerRange.root.rootName != "$graveyard" ? markerRange.getJoined(newElementRange, true) : newElementRange;
writer.updateMarker(marker, { range });
}
return {
oldElement,
newElement
};
});
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/image/imageplaceholder
*/
/**
* Adds support for image placeholder that is automatically removed when the image is loaded.
*/
var ImagePlaceholder = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [ImageUtils];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "ImagePlaceholder";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
afterInit() {
this._setupSchema();
this._setupConversion();
this._setupLoadListener();
}
/**
* Extends model schema.
*/
_setupSchema() {
const schema = this.editor.model.schema;
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);
});
});
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/image/imageblockediting
*/
/**
* The image block plugin.
*
* It registers:
*
* * `<imageBlock>` as a block element in the document schema, and allows `alt`, `src` and `srcset` attributes.
* * converters for editing and data pipelines.,
* * {@link module:image/image/imagetypecommand~ImageTypeCommand `'imageTypeBlock'`} command that converts inline images into
* block images.
*/
var ImageBlockEditing = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [
ImageEditing,
ImageSizeAttributes,
ImageUtils,
ImagePlaceholder,
ClipboardPipeline
];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "ImageBlockEditing";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
editor.model.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"));
conversion.for("upcast").add(upcastImg("imageBlock", imageUtils)).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;
if (!docFragmentChildren.every(imageUtils.isInlineImageView)) return;
if (data.targetRanges) modelRange = editor.editing.mapper.toModelRange(data.targetRanges[0]);
else modelRange = model.document.selection.getFirstRange();
const selection = model.createSelection(modelRange);
const selectionPosition = selection.getFirstPosition();
if (!!selectionPosition && isImageTypePlaceable(model.schema, selectionPosition, "imageBlock") && determineImageTypeForInsertionAtSelection(model.schema, selection) === "imageBlock") {
const writer = new ViewUpcastWriter(editingView.document);
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);
});
});
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imageinsert/ui/imageinsertformview
*/
/**
* The view displayed in the insert image dropdown.
*
* See {@link module:image/imageinsert/imageinsertui~ImageInsertUI}.
*
* @internal
*/
var ImageInsertFormView = class extends View {
/**
* Tracks information about DOM focus in the form.
*/
focusTracker;
/**
* An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}.
*/
keystrokes;
/**
* A collection of views that can be focused in the form.
*/
_focusables;
/**
* Helps cycling over {@link #_focusables} in the form.
*/
_focusCycler;
/**
* A collection of the defined integrations for inserting the images.
*/
children;
/**
* 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: {
focusPrevious: "shift + tab",
focusNext: "tab"
}
});
for (const view of integrations) {
this.children.add(view);
this._focusables.add(view);
if (view instanceof CollapsibleView) this._focusables.addMany(view.children);
}
this.setTemplate({
tag: "form",
attributes: {
class: ["ck", "ck-image-insert-form"],
tabindex: -1
},
children: this.children
});
}
/**
* @inheritDoc
*/
render() {
super.render();
submitHandler({ view: this });
for (const view of this._focusables) this.focusTracker.add(view.element);
this.keystrokes.listenTo(this.element);
const stopPropagation = (data) => data.stopPropagation();
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();
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imageinsert/imageinsertui
*/
/**
* The image insert dropdown plugin.
*
* For a detailed overview, check the {@glink features/images/image-upload/image-upload Image upload feature}
* and {@glink features/images/images-inserting Insert images via source URL} documentation.
*
* Adds the `'insertImage'` dropdown to the {@link module:ui/componentfactory~ComponentFactory UI component factory}
* and also the `imageInsert` dropdown as an alias for backward compatibility.
*
* Adds the `'menuBar:insertImage'` sub-menu to the {@link module:ui/componentfactory~ComponentFactory UI component factory}, which is
* by default added to the `'Insert'` menu.
*/
var ImageInsertUI = class extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return "ImageInsertUI";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
static get requires() {
return [ImageUtils];
}
/**
* The dropdown view responsible for displaying the image insert UI.
*/
dropdownView;
/**
* Registered integrations map.
*/
_integrations = /* @__PURE__ */ new Map();
/**
* @inheritDoc
*/
constructor(editor) {
super(editor);
editor.config.define("image.insert.integrations", [
"upload",
"assetManager",
"url"
]);
}
/**
* @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);
const menuBarComponentCreator = (locale) => this._createMenuBarComponent(locale);
editor.ui.componentFactory.add("insertImage", componentCreator);
editor.ui.componentFactory.add("imageInsert", componentCreator);
editor.ui.componentFactory.add("menuBar:insertImage", menuBarComponentCreator);
}
/**
* Registers the insert image dropdown integration.
*/
registerIntegration({ name, observable, buttonViewCreator, formViewCreator, menuBarButtonViewCreator, requiresForm = false, override = false }) {
if (this._integrations.has(name) && !override)
/**
* 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,
menuBarButtonViewCreator,
formViewCreator,
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) {
if (!firstIntegration.requiresForm) return firstIntegration.buttonViewCreator(true);
dropdownButton = firstIntegration.buttonViewCreator(true);
} else {
dropdownButton = new SplitButtonView(locale, firstIntegration.buttonViewCreator(false));
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.flatMap(({ formViewCreator }) => formViewCreator(integrations.length == 1));
const imageInsertFormView = new ImageInsertFormView(editor.locale, integrationViews);
dropdownView.panelView.children.add(imageInsertFormView);
});
return dropdownView;
}
/**
* Creates the menu bar component.
*/
_createMenuBarComponent(locale) {
const t = locale.t;
const integrations = this._prepareIntegrations();
if (!integrations.length) return null;
const integrationViews = integrations.flatMap(({ menuBarButtonViewCreator }) => menuBarButtonViewCreator(integrations.length == 1));
const resultView = new MenuBarMenuView(locale);
const listView = new MenuBarMenuListView(locale);
resultView.panelView.children.add(listView);
resultView.buttonView.set({
icon: IconImage,
label: t("Image")
});
for (const integrationView of integrationViews) {
const listItemView = new MenuBarMenuListItemView(locale, resultView);
listItemView.children.add(integrationView);
listView.items.add(listItemView);
integrationView.delegate("execute").to(resultView);
}
return resultView;
}
/**
* Validates the integrations list.
*/
_prepareIntegrations() {
const items = this.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;
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imageblock
*/
/**
* The image block plugin.
*
* This is a "glue" plugin which loads the following plugins:
*
* * {@link module:image/image/imageblockediting~ImageBlockEditing},
* * {@link module:image/imagetextalternative~ImageTextAlternative}.
*
* Usually, it is used in conjunction with other plugins from this package. See the {@glink api/image package page}
* for more information.
*/
var ImageBlock = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [
ImageBlockEditing,
Widget,
ImageTextAlternative,
ImageInsertUI
];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "ImageBlock";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/image/imageinlineediting
*/
/**
* The image inline plugin.
*
* It registers:
*
* * `<imageInline>` as an inline element in the document schema, and allows `alt`, `src` and `srcset` attributes.
* * converters for editing and data pipelines.
* * {@link module:image/image/imagetypecommand~ImageTypeCommand `'imageTypeInline'`} command that converts block images into
* inline images.
*/
var ImageInlineEditing = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [
ImageEditing,
ImageSizeAttributes,
ImageUtils,
ImagePlaceholder,
ClipboardPipeline
];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "ImageInlineEditing";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
editor.model.schema.register("imageInline", {
inheritAllFrom: "$inlineObject",
allowAttributes: [
"alt",
"src",
"srcset"
],
disallowIn: ["caption"]
});
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"));
conversion.for("upcast").add(upcastImg("imageInline", imageUtils));
}
/**
* 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;
if (!docFragmentChildren.every(imageUtils.isBlockImageView)) return;
if (data.targetRanges) modelRange = editor.editing.mapper.toModelRange(data.targetRanges[0]);
else modelRange = model.document.selection.getFirstRange();
const selection = model.createSelection(modelRange);
const selectionPosition = selection.getFirstPosition();
if (!(!!selectionPosition && isImageTypePlaceable(model.schema, selectionPosition, "imageBlock")) || determineImageTypeForInsertionAtSelection(model.schema, selection) === "imageInline") {
const writer = new ViewUpcastWriter(editingView.document);
const inlineViewImages = docFragmentChildren.map((blockViewImage) => {
if (blockViewImage.childCount === 1) {
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);
});
});
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imageinline
*/
/**
* The image inline plugin.
*
* This is a "glue" plugin which loads the following plugins:
*
* * {@link module:image/image/imageinlineediting~ImageInlineEditing},
* * {@link module:image/imagetextalternative~ImageTextAlternative}.
*
* Usually, it is used in conjunction with other plugins from this package. See the {@glink api/image package page}
* for more information.
*/
var ImageInline = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [
ImageInlineEditing,
Widget,
ImageTextAlternative,
ImageInsertUI
];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "ImageInline";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/image
*/
/**
* The image plugin.
*
* For a detailed overview, check the {@glink features/images/images-overview image feature} documentation.
*
* This is a "glue" plugin which loads the following plugins:
*
* * {@link module:image/imageblock~ImageBlock},
* * {@link module:image/imageinline~ImageInline},
*
* Usually, it is used in conjunction with other plugins from this package. See the {@glink api/image package page}
* for more information.
*/
var Image = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [ImageBlock, ImageInline];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "Image";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* The image caption utilities plugin.
*/
var ImageCaptionUtils = class extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return "ImageCaptionUtils";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @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");
if (element.name == "figcaption" && imageUtils.isBlockImageView(element.parent)) return { name: true };
return null;
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* The toggle image caption command.
*
* This command is registered by {@link module:image/imagecaption/imagecaptionediting~ImageCaptionEditing} as the
* `'toggleImageCaption'` editor command.
*
* Executing this command:
*
* * either adds or removes the image caption of a selected image (depending on whether the caption is present or not),
* * removes the image caption if the selection is anchored in one.
*
* ```ts
* // Toggle the presence of the caption.
* editor.execute( 'toggleImageCaption' );
* ```
*
* **Note**: Upon executing this command, the selection will be set on the image if previously anchored in the caption element.
*
* **Note**: You can move the selection to the caption right away as it shows up upon executing this command by using
* the `focusCaptionOnShow` option:
*
* ```ts
* editor.execute( 'toggleImageCaption', { focusCaptionOnShow: true } );
* ```
*/
var ToggleImageCaptionCommand = class extends Command {
/**
* @inheritDoc
*/
refresh() {
const editor = this.editor;
const imageCaptionUtils = editor.plugins.get("ImageCaptionUtils");
const imageUtils = editor.plugins.get("ImageUtils");
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;
}
this.isEnabled = imageUtils.isImage(selectedElement);
if (this.isEnabled && imageUtils.isInlineImage(selectedElement)) {
const position = editor.model.createPositionBefore(selectedElement);
this.isEnabled = isImageTypePlaceable(editor.model.schema, position, "imageBlock");
}
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 selection = this.editor.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);
if (imageUtils.isInlineImage(selectedImage)) {
this.editor.execute("imageTypeBlock");
selectedImage = selection.getSelectedElement();
}
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;
}
imageCaptionEditing._saveCaption(selectedImage, captionElement);
writer.setSelection(selectedImage, "on");
writer.remove(captionElement);
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imagecaption/imagecaptionediting
*/
/**
* The image caption engine plugin. It is responsible for:
*
* * registering converters for the caption element,
* * registering converters for the caption model attribute,
* * registering the {@link module:image/imagecaption/toggleimagecaptioncommand~ToggleImageCaptionCommand `toggleImageCaption`} command.
*/
var ImageCaptionEditing = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [ImageUtils, ImageCaptionUtils];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "ImageCaptionEditing";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* A map that keeps saved JSONified image captions and image model elements they are
* associated with.
*
* To learn more about this system, see {@link #_saveCaption}.
*/
_savedCaptionsMap;
/**
* @inheritDoc
*/
constructor(editor) {
super(editor);
this._savedCaptionsMap = /* @__PURE__ */ new WeakMap();
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const schema = editor.model.schema;
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;
editor.conversion.for("upcast").elementToElement({
view: (element) => imageCaptionUtils.matchImageCaptionViewElement(element),
model: "caption"
});
editor.conversion.for("dataDowncast").elementToElement({
model: "caption",
view: (modelElement, { writer }) => {
if (!imageUtils.isBlockImage(modelElement.parent)) return null;
return writer.createContainerElement("figcaption");
}
});
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");
enableViewPlaceholder({
view,
element: figcaptionElement,
keepOnFocus: true
});
const imageAlt = modelElement.parent.getAttribute("alt");
return toWidgetEditable(figcaptionElement, writer, { label: imageAlt ? t("Caption for image: %0", [imageAlt]) : t("Caption for the image") });
}
});
}
/**
* 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) => {
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 (oldCaptionElement) {
this._saveCaption(newElement, oldCaptionElement);
return;
}
}
const savedOldElementCaption = this._getSavedCaption(oldElement);
if (savedOldElementCaption) this._saveCaption(newElement, savedOldElementCaption);
};
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~ModelElement#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 ? ModelElement.fromJSON(jsonObject) : null;
}
/**
* Saves a {@link module:engine/model/element~ModelElement#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);
}
}
});
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imagecaption/imagecaptionui
*/
/**
* The image caption UI plugin. It introduces the `'toggleImageCaption'` UI button.
*/
var ImageCaptionUI = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [ImageCaptionUtils];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "ImageCaptionUI";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @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: IconCaption,
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 });
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;
});
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imagecaption
*/
/**
* The image caption plugin.
*
* For a detailed overview, check the {@glink features/images/images-captions image caption} documentation.
*/
var ImageCaption = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [ImageCaptionEditing, ImageCaptionUI];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "ImageCaption";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* 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) {
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.
*
* @internal
* @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(imageSrc).then((resource) => resource.blob()).then((blob) => {
const mimeType = getImageMimeType(blob, imageSrc);
const filename = `image.${mimeType.replace("image/", "")}`;
resolve(new File([blob], filename, { type: mimeType }));
}).catch((err) => {
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.
* @internal
*/
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 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 filename = `image.${mimeType.replace("image/", "")}`;
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;
canvas.getContext("2d").drawImage(image, 0, 0);
canvas.toBlob((blob) => blob ? resolve(blob) : reject());
});
image.addEventListener("error", () => reject());
image.src = imageSrc;
});
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imageupload/imageuploadui
*/
/**
* The image upload button plugin.
*
* For a detailed overview, check the {@glink features/images/image-upload/image-upload Image upload feature} documentation.
*
* Adds the `'uploadImage'` button to the {@link module:ui/componentfactory~ComponentFactory UI component factory}
* and also the `imageUpload` button as an alias for backward compatibility.
*
* Adds the `'menuBar:uploadImage'` menu button to the {@link module:ui/componentfactory~ComponentFactory UI component factory}.
*
* It also integrates with the `insertImage` toolbar component and `menuBar:insertImage` menu component, which are the default components
* through which image upload is available.
*/
var ImageUploadUI = class extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return "ImageUploadUI";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
editor.ui.componentFactory.add("uploadImage", () => this._createToolbarButton());
editor.ui.componentFactory.add("imageUpload", () => this._createToolbarButton());
editor.ui.componentFactory.add("menuBar:uploadImage", () => this._createMenuBarButton("standalone"));
if (editor.plugins.has("ImageInsertUI")) editor.plugins.get("ImageInsertUI").registerIntegration({
name: "upload",
observable: () => editor.commands.get("uploadImage"),
buttonViewCreator: () => this._createToolbarButton(),
formViewCreator: () => this._createDropdownButton(),
menuBarButtonViewCreator: (isOnly) => this._createMenuBarButton(isOnly ? "insertOnly" : "insertNested")
});
}
/**
* Creates the base for various kinds of the button component provided by this feature.
*/
_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 from computer"),
icon: IconImageUpload
});
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;
}
/**
* Creates a simple toolbar button, with an icon and a tooltip.
*/
_createToolbarButton() {
const t = this.editor.locale.t;
const imageInsertUI = this.editor.plugins.get("ImageInsertUI");
const uploadImageCommand = this.editor.commands.get("uploadImage");
const button = this._createButton(FileDialogButtonView);
button.tooltip = true;
button.bind("label").to(imageInsertUI, "isImageSelected", uploadImageCommand, "isAccessAllowed", (isImageSelected, isAccessAllowed) => {
if (!isAccessAllowed) return t("You have no image upload permissions.");
return isImageSelected ? t("Replace image from computer") : t("Upload image from computer");
});
return button;
}
/**
* Creates a button for the dropdown view, with an icon, text and no tooltip.
*/
_createDropdownButton() {
const t = this.editor.locale.t;
const imageInsertUI = this.editor.plugins.get("ImageInsertUI");
const button = this._createButton(FileDialogButtonView);
button.withText = true;
button.bind("label").to(imageInsertUI, "isImageSelected", (isImageSelected) => isImageSelected ? t("Replace from computer") : t("Upload from computer"));
button.on("execute", () => {
imageInsertUI.dropdownView.isOpen = false;
});
return button;
}
/**
* Creates a button for the menu bar.
*/
_createMenuBarButton(type) {
const t = this.editor.locale.t;
const button = this._createButton(MenuBarMenuListItemFileDialogButtonView);
button.withText = true;
switch (type) {
case "standalone":
button.label = t("Image from computer");
break;
case "insertOnly":
button.label = t("Image");
break;
case "insertNested":
button.label = t("From computer");
break;
}
return button;
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imageupload/imageuploadprogress
*/
/**
* The image upload progress plugin.
* It shows a placeholder when the image is read from the disk and a progress bar while the image is uploading.
*/
var ImageUploadProgress = class extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return "ImageUploadProgress";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* The image placeholder that is displayed before real image data can be accessed.
*
* For the record, this image is a 1x1 px GIF with an aspect ratio set by CSS.
*/
placeholder;
/**
* @inheritDoc
*/
constructor(editor) {
super(editor);
this.placeholder = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==";
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
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);
}
/**
* 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.
*/
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") {
_startAppearEffect(viewFigure, viewWriter);
_showPlaceholder(imageUtils, placeholder, viewFigure, viewWriter);
return;
}
if (status == "uploading") {
const loader = fileRepository.loaders.get(uploadId);
_startAppearEffect(viewFigure, viewWriter);
if (!loader) _showPlaceholder(imageUtils, placeholder, viewFigure, viewWriter);
else {
_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);
_hideProgressBar(viewFigure, viewWriter);
_hidePlaceholder(viewFigure, viewWriter);
_stopAppearEffect(viewFigure, viewWriter);
};
};
/**
* 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);
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)));
}, 3e3);
}
/**
* Create progress bar element using {@link module:engine/view/uielement~ViewUIElement}.
*/
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~ViewUIElement}.
*/
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~ViewUIElement} 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~ViewUIElement} 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);
}
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imageupload/uploadimagecommand
*/
/**
* The upload image command.
*
* The command is registered by the {@link module:image/imageupload/imageuploadediting~ImageUploadEditing} plugin as `uploadImage`
* and it is also available via aliased `imageUpload` name.
*
* In order to upload an image at the current selection position
* (according to the {@link module:widget/utils~findOptimalInsertionRange} algorithm),
* execute the command and pass the native image file instance:
*
* ```ts
* this.listenTo( editor.editing.view.document, 'clipboardInput', ( evt, data ) => {
* // Assuming that only images were pasted:
* const images = Array.from( data.dataTransfer.files );
*
* // Upload the first image:
* editor.execute( 'uploadImage', { file: images[ 0 ] } );
* } );
* ```
*
* It is also possible to insert multiple images at once:
*
* ```ts
* editor.execute( 'uploadImage', {
* file: [
* file1,
* file2
* ]
* } );
* ```
*/
var UploadImageCommand = class extends Command {
/**
* Creates an instance of the `imageUlpoad` command. When executed, the command upload one of
* the currently selected image from computer.
*
* @param editor The editor instance.
*/
constructor(editor) {
super(editor);
this.set("isAccessAllowed", true);
}
/**
* @inheritDoc
*/
refresh() {
const editor = this.editor;
const imageUtils = editor.plugins.get("ImageUtils");
const selectedElement = editor.model.document.selection.getSelectedElement();
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");
const selectionAttributes = Object.fromEntries(selection.getAttributes());
files.forEach((file, index) => {
const selectedElement = selection.getSelectedElement();
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 loader = editor.plugins.get(FileRepository).createLoader(file);
const imageUtils = editor.plugins.get("ImageUtils");
if (!loader) return;
imageUtils.insertImage({
...attributes,
uploadId: loader.id
}, position);
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imageupload/imageuploadediting
*/
/**
* The editing part of the image upload feature. It registers the `'uploadImage'` command
* and the `imageUpload` command as an aliased name.
*
* When an image is uploaded, it fires the {@link ~ImageUploadEditing#event:uploadComplete `uploadComplete`} event
* that allows adding custom attributes to the {@link module:engine/model/element~ModelElement image element}.
*/
var ImageUploadEditing = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [
FileRepository,
Notification,
ClipboardPipeline,
ImageUtils
];
}
static get pluginName() {
return "ImageUploadEditing";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* An internal mapping of {@link module:upload/filerepository~FileLoader#id file loader UIDs} and
* model elements during the upload.
*
* Model element of the uploaded image can change, for instance, when {@link module:image/image/imagetypecommand~ImageTypeCommand}
* is executed as a result of adding caption or changing image style. As a result, the upload logic must keep track of the model
* element (reference) and resolve the upload for the correct model element (instead of the one that landed in the `$graveyard`
* after image type changed).
*/
_uploadImageElements;
/**
* An internal mapping of {@link module:upload/filerepository~FileLoader#id file loader UIDs} and
* upload responses for handling images dragged during their upload process. When such images are later
* dropped, their original upload IDs no longer exist in the registry (as the original upload completed).
* This map preserves the upload responses to properly handle such cases.
*/
_uploadedImages = /* @__PURE__ */ new Map();
/**
* @inheritDoc
*/
constructor(editor) {
super(editor);
editor.config.define("image", { upload: { types: [
"jpeg",
"png",
"gif",
"bmp",
"webp",
"tiff"
] } });
this._uploadImageElements = /* @__PURE__ */ new Map();
}
/**
* @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);
editor.commands.add("uploadImage", uploadImageCommand);
editor.commands.add("imageUpload", uploadImageCommand);
conversion.for("upcast").attributeToAttribute({
view: {
name: "img",
key: "uploadId"
},
model: "uploadId"
}).add((dispatcher) => dispatcher.on("element:img", (evt, data, conversionApi) => {
if (!conversionApi.consumable.test(data.viewItem, { attributes: ["data-ck-upload-id"] })) return;
const uploadId = data.viewItem.getAttribute("data-ck-upload-id");
if (!uploadId) return;
if (!data.modelRange) return;
const [modelElement] = Array.from(data.modelRange.getItems({ shallow: true }));
const loader = fileRepository.loaders.get(uploadId);
/* v8 ignore else -- @preserve */
if (modelElement) {
conversionApi.writer.setAttribute("uploadId", uploadId, modelElement);
conversionApi.consumable.consume(data.viewItem, { attributes: ["data-ck-upload-id"] });
if (loader && loader.data) conversionApi.writer.setAttribute("uploadStatus", loader.status, modelElement);
}
}, { priority: "low" }));
this.listenTo(editor.editing.view.document, "clipboardInput", (evt, data) => {
if (isHtmlInDataTransfer(data.dataTransfer)) return;
const images = Array.from(data.dataTransfer.files).filter((file) => {
if (!file) return false;
return imageTypes.test(file.type);
});
if (!images.length) return;
evt.stop();
editor.model.change((writer) => {
if (data.targetRanges) writer.setSelection(data.targetRanges.map((viewRange) => editor.editing.mapper.toModelRange(viewRange)));
editor.execute("uploadImage", { file: images });
});
if (!editor.commands.get("uploadImage").isAccessAllowed) {
const notification = editor.plugins.get("Notification");
const t = editor.locale.t;
notification.showWarning(t("You have no image upload permissions."), { namespace: "image" });
}
});
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 ViewUpcastWriter(editor.editing.view.document);
for (const fetchableImage of fetchableImages) {
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);
}
}
});
editor.editing.view.document.on("dragover", (evt, data) => {
data.preventDefault();
});
doc.on("change", () => {
const changes = doc.differ.getChanges({ includeChangesInGraveyard: true }).reverse();
const insertedImagesIds = /* @__PURE__ */ 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)) {
const uploadId = imageElement.getAttribute("uploadId");
const uploadStatus = imageElement.getAttribute("uploadStatus");
if (!uploadId || uploadStatus == "complete") continue;
const loader = fileRepository.loaders.get(uploadId);
if (!loader) {
if (!isInsertedInGraveyard && this._uploadedImages.has(uploadId)) editor.model.enqueueChange({ isUndoable: false }, (writer) => {
writer.setAttribute("uploadStatus", "complete", imageElement);
this.fire("uploadComplete", {
data: this._uploadedImages.get(uploadId),
imageElement
});
});
continue;
}
if (isInsertedInGraveyard) {
if (!insertedImagesIds.has(uploadId)) {
/* v8 ignore else -- @preserve */
if (Array.from(this._uploadImageElements.get(uploadId)).every((element) => element.root.rootName == "$graveyard")) loader.abort();
}
} else {
insertedImagesIds.add(uploadId);
if (!this._uploadImageElements.has(uploadId)) this._uploadImageElements.set(uploadId, /* @__PURE__ */ new Set([imageElement]));
else this._uploadImageElements.get(uploadId).add(imageElement);
if (loader.status == "idle") this._readAndUpload(loader);
}
}
}
});
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;
if (this.editor.plugins.has("ImageBlockEditing")) {
schema.extend("imageBlock", { allowAttributes: ["uploadId", "uploadStatus"] });
this._registerConverters("imageBlock");
}
if (this.editor.plugins.has("ImageInlineEditing")) {
schema.extend("imageInline", { allowAttributes: ["uploadId", "uploadStatus"] });
this._registerConverters("imageInline");
}
}
/**
* 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) => {
const elements = imageUploadElements.get(loader.id);
for (const element of elements) writer.setAttribute("uploadStatus", "reading", element);
});
return loader.read().then(() => {
const promise = loader.upload();
/* v8 ignore else -- @preserve */
if (editor.ui) editor.ui.ariaLiveAnnouncer.announce(t("Uploading image"));
for (const imageElement of imageUploadElements.get(loader.id)) {
/* istanbul ignore next -- @preserve */
if (env.isSafari) {
const viewFigure = editor.editing.mapper.toViewElement(imageElement);
const viewImg = imageUtils.findViewImgElement(viewFigure);
editor.editing.view.once("render", () => {
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";
domFigure._ckHack = domFigure.offsetHeight;
domFigure.style.display = originalDisplay;
});
}
model.enqueueChange({ isUndoable: false }, (writer) => {
writer.setAttribute("uploadStatus", "uploading", imageElement);
});
}
return promise;
}).then((data) => {
model.enqueueChange({ isUndoable: false }, (writer) => {
for (const imageElement of imageUploadElements.get(loader.id)) {
writer.setAttribute("uploadStatus", "complete", imageElement);
this.fire("uploadComplete", {
data,
imageElement
});
}
/* v8 ignore else -- @preserve */
if (editor.ui) editor.ui.ariaLiveAnnouncer.announce(t("Image upload complete"));
this._uploadedImages.set(loader.id, data);
});
clean();
}).catch((error) => {
/* v8 ignore else -- @preserve */
if (editor.ui) editor.ui.ariaLiveAnnouncer.announce(t("Error during image upload"));
if (loader.status !== "error" && loader.status !== "aborted") throw error;
if (loader.status == "error" && error) notification.showWarning(error, {
title: t("Upload failed"),
namespace: "upload"
});
model.enqueueChange({ isUndoable: false }, (writer) => {
for (const imageElement of imageUploadElements.get(loader.id)) if (imageElement.root.rootName !== "$graveyard") writer.remove(imageElement);
});
clean();
});
function clean() {
model.enqueueChange({ isUndoable: false }, (writer) => {
for (const imageElement of 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) {
let maxWidth = 0;
const srcsetAttribute = Object.keys(data).filter((key) => {
const width = parseInt(key, 10);
if (!isNaN(width)) {
maxWidth = Math.max(maxWidth, width);
return true;
}
}).map((key) => `${data[key]} ${key}w`).join(", ");
if (srcsetAttribute != "") {
const attributes = { srcset: srcsetAttribute };
if (!image.hasAttribute("width") && !image.hasAttribute("height")) attributes.width = maxWidth;
writer.setAttributes(attributes, image);
}
}
/**
* Registers image upload converters.
*
* @param imageType The type of the image.
*/
_registerConverters(imageType) {
const { conversion, plugins } = this.editor;
const fileRepository = plugins.get(FileRepository);
const imageUtils = plugins.get(ImageUtils);
conversion.for("dataDowncast").add((dispatcher) => {
dispatcher.on(`attribute:uploadId:${imageType}`, (evt, data, conversionApi) => {
if (!conversionApi.consumable.test(data.item, evt.name)) return;
const loader = fileRepository.loaders.get(data.attributeNewValue);
if (!loader || !loader.data) return null;
const viewElement = conversionApi.mapper.toViewElement(data.item);
const img = imageUtils.findViewImgElement(viewElement);
/* v8 ignore else -- @preserve */
if (img) {
conversionApi.consumable.consume(data.item, evt.name);
conversionApi.writer.setAttribute("data-ck-upload-id", loader.id, img);
}
});
});
}
};
/**
* TODO move this to the clipboard package.
*
* Returns `true` if non-empty `text/html` is included in the data transfer.
*/
function isHtmlInDataTransfer(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);
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imageupload
*/
/**
* The image upload plugin.
*
* For a detailed overview, check the {@glink features/images/image-upload/image-upload image upload feature} documentation.
*
* This plugin does not do anything directly, but it loads a set of specific plugins to enable image uploading:
*
* * {@link module:image/imageupload/imageuploadediting~ImageUploadEditing},
* * {@link module:image/imageupload/imageuploadui~ImageUploadUI},
* * {@link module:image/imageupload/imageuploadprogress~ImageUploadProgress}.
*/
var ImageUpload = class extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return "ImageUpload";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
static get requires() {
return [
ImageUploadEditing,
ImageUploadUI,
ImageUploadProgress
];
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imageinsert/ui/imageinserturlview
*/
/**
* The insert an image via URL view.
*
* See {@link module:image/imageinsert/imageinsertviaurlui~ImageInsertViaUrlUI}.
*
* @internal
*/
var ImageInsertUrlView = class extends View {
/**
* The URL input field view.
*/
urlInputView;
/**
* An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}.
*/
keystrokes;
/**
* 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.keystrokes = new KeystrokeHandler();
this.urlInputView = this._createUrlInputView();
this.setTemplate({
tag: "form",
attributes: {
class: ["ck", "ck-image-insert-url"],
tabindex: "-1"
},
children: [this.urlInputView, {
tag: "div",
attributes: { class: ["ck", "ck-image-insert-url__action-row"] }
}]
});
}
/**
* @inheritDoc
*/
render() {
super.render();
submitHandler({ view: this });
this.keystrokes.listenTo(this.element);
}
/**
* @inheritDoc
*/
destroy() {
super.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.inputMode = "url";
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;
}
/**
* Focuses the view.
*/
focus() {
this.urlInputView.focus();
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imageinsert/imageinsertviaurlui
*/
/**
* The image insert via URL plugin (UI part).
*
* The plugin introduces two UI components to the {@link module:ui/componentfactory~ComponentFactory UI component factory}:
*
* * the `'insertImageViaUrl'` toolbar button,
* * the `'menuBar:insertImageViaUrl'` menu bar component.
*
* It also integrates with the `insertImage` toolbar component and `menuBar:insertImage` menu component, which are default components
* through which inserting image via URL is available.
*/
var ImageInsertViaUrlUI = class extends Plugin {
_imageInsertUI;
_formView;
/**
* @inheritDoc
*/
static get pluginName() {
return "ImageInsertViaUrlUI";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
static get requires() {
return [ImageInsertUI, Dialog];
}
init() {
this.editor.ui.componentFactory.add("insertImageViaUrl", () => this._createToolbarButton());
this.editor.ui.componentFactory.add("menuBar:insertImageViaUrl", () => this._createMenuBarButton("standalone"));
}
/**
* @inheritDoc
*/
afterInit() {
this._imageInsertUI = this.editor.plugins.get("ImageInsertUI");
this._imageInsertUI.registerIntegration({
name: "url",
observable: () => this.editor.commands.get("insertImage"),
buttonViewCreator: () => this._createToolbarButton(),
formViewCreator: () => this._createDropdownButton(),
menuBarButtonViewCreator: (isOnly) => this._createMenuBarButton(isOnly ? "insertOnly" : "insertNested")
});
}
/**
* Creates the base for various kinds of the button component provided by this feature.
*/
_createInsertUrlButton(ButtonClass) {
const button = new ButtonClass(this.editor.locale);
button.icon = IconImageUrl;
button.on("execute", () => {
this._showModal();
});
return button;
}
/**
* Creates a simple toolbar button, with an icon and a tooltip.
*/
_createToolbarButton() {
const t = this.editor.locale.t;
const button = this._createInsertUrlButton(ButtonView);
button.tooltip = true;
button.bind("label").to(this._imageInsertUI, "isImageSelected", (isImageSelected) => isImageSelected ? t("Update image URL") : t("Insert image via URL"));
return button;
}
/**
* Creates a button for the dropdown view, with an icon, text and no tooltip.
*/
_createDropdownButton() {
const t = this.editor.locale.t;
const button = this._createInsertUrlButton(ButtonView);
button.withText = true;
button.bind("label").to(this._imageInsertUI, "isImageSelected", (isImageSelected) => isImageSelected ? t("Update image URL") : t("Insert via URL"));
return button;
}
/**
* Creates a button for the menu bar.
*/
_createMenuBarButton(type) {
const t = this.editor.locale.t;
const button = this._createInsertUrlButton(MenuBarMenuListItemButtonView);
button.withText = true;
switch (type) {
case "standalone":
button.label = t("Image via URL");
break;
case "insertOnly":
button.label = t("Image");
break;
case "insertNested":
button.label = t("Via URL");
break;
}
return button;
}
/**
* Creates the form view used to submit the image URL.
*/
_createInsertUrlView() {
const editor = this.editor;
const locale = editor.locale;
const replaceImageSourceCommand = editor.commands.get("replaceImageSource");
const insertImageCommand = editor.commands.get("insertImage");
const imageInsertUrlView = new ImageInsertUrlView(locale);
imageInsertUrlView.bind("isImageSelected").to(this._imageInsertUI);
imageInsertUrlView.bind("isEnabled").toMany([insertImageCommand, replaceImageSourceCommand], "isEnabled", (...isEnabled) => isEnabled.some((isCommandEnabled) => isCommandEnabled));
return imageInsertUrlView;
}
/**
* Shows the insert image via URL form view in a modal.
*/
_showModal() {
const editor = this.editor;
const t = editor.locale.t;
const dialog = editor.plugins.get("Dialog");
if (!this._formView) {
this._formView = this._createInsertUrlView();
this._formView.on("submit", () => this._handleSave());
}
const replaceImageSourceCommand = editor.commands.get("replaceImageSource");
this._formView.imageURLInputValue = replaceImageSourceCommand.value || "";
dialog.show({
id: "insertImageViaUrl",
title: t("Image via URL"),
isModal: true,
content: this._formView,
actionButtons: [{
label: t("Cancel"),
withText: true,
onExecute: () => dialog.hide()
}, {
label: this._imageInsertUI.isImageSelected ? t("Save") : t("Insert"),
class: "ck-button-action",
withText: true,
onExecute: () => this._handleSave()
}]
});
}
/**
* Executes appropriate command depending on selection and form value.
*/
_handleSave() {
if (this.editor.commands.get("replaceImageSource").isEnabled) this.editor.execute("replaceImageSource", { source: this._formView.imageURLInputValue });
else this.editor.execute("insertImage", { source: this._formView.imageURLInputValue });
this.editor.plugins.get("Dialog").hide();
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imageinsertviaurl
*/
/**
* The image insert via URL plugin.
*
* For a detailed overview, check the {@glink features/images/images-inserting
* Insert images via source URL} documentation.
*
* This plugin does not do anything directly, but it loads a set of specific plugins
* to enable image inserting via implemented integrations:
*
* * {@link module:image/imageinsert/imageinsertui~ImageInsertUI},
*/
var ImageInsertViaUrl = class extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return "ImageInsertViaUrl";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
static get requires() {
return [ImageInsertViaUrlUI, ImageInsertUI];
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imageinsert
*/
/**
* The image insert plugin.
*
* For a detailed overview, check the {@glink features/images/image-upload/image-upload Image upload feature}
* and {@glink features/images/images-inserting Insert images via source URL} documentation.
*
* This plugin does not do anything directly, but it loads a set of specific plugins
* to enable image uploading or inserting via implemented integrations:
*
* * {@link module:image/imageupload~ImageUpload}
* * {@link module:image/imageinsert/imageinsertui~ImageInsertUI}
*/
var ImageInsert = class extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return "ImageInsert";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
static get requires() {
return [
ImageUpload,
ImageInsertViaUrl,
ImageInsertUI
];
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imageresize/resizeimagecommand
*/
/**
* The resize image command. Currently, it only supports the width attribute.
*/
var ResizeImageCommand = class extends Command {
/**
* @inheritDoc
*/
refresh() {
const editor = this.editor;
const element = editor.plugins.get("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);
});
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* The image resize editing feature.
*
* It adds the ability to resize each image using handles or manually by
* {@link module:image/imageresize/imageresizebuttons~ImageResizeButtons} buttons.
*/
var ImageResizeEditing = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [ImageUtils];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "ImageResizeEditing";
}
/**
* @inheritDoc
* @internal
*/
static get licenseFeatureCode() {
return "IR";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
static get isPremiumPlugin() {
return true;
}
/**
* @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"
}
]
});
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const resizeImageCommand = new ResizeImageCommand(editor);
this._registerConverters("imageBlock");
this._registerConverters("imageInline");
editor.commands.add("resizeImage", resizeImageCommand);
editor.commands.add("imageResize", resizeImageCommand);
}
/**
* @inheritDoc
*/
afterInit() {
this._registerSchema();
}
_registerSchema() {
const schema = this.editor.model.schema;
if (this.editor.plugins.has("ImageBlockEditing")) {
schema.extend("imageBlock", { allowAttributes: ["resizedWidth", "resizedHeight"] });
schema.setAttributeProperties("resizedWidth", { isFormatting: true });
schema.setAttributeProperties("resizedHeight", { isFormatting: true });
}
if (this.editor.plugins.has("ImageInlineEditing")) {
schema.extend("imageInline", { allowAttributes: ["resizedWidth", "resizedHeight"] });
schema.setAttributeProperties("resizedWidth", { isFormatting: true });
schema.setAttributeProperties("resizedHeight", { isFormatting: true });
}
}
/**
* Registers image resize converters.
*
* @param imageType The type of the image.
*/
_registerConverters(imageType) {
const editor = this.editor;
const imageUtils = editor.plugins.get("ImageUtils");
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");
}
}
});
editor.conversion.for("upcast").add((dispatcher) => {
dispatcher.on(`element:${imageType === "imageBlock" ? "figure" : "img"}`, (evt, data, conversionApi) => {
conversionApi.consumable.consume(data.viewItem, { classes: ["image_resized"] });
});
});
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imageresize/imageresizebuttons
*/
const RESIZE_ICONS = /* #__PURE__ */ (() => ({
small: IconObjectSizeSmall,
medium: IconObjectSizeMedium,
large: IconObjectSizeLarge,
custom: IconObjectSizeCustom,
original: IconObjectSizeFull
}))();
/**
* The image resize buttons plugin.
*
* It adds a possibility to resize images using the toolbar dropdown or individual buttons, depending on the plugin configuration.
*/
var ImageResizeButtons = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [ImageResizeEditing];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "ImageResizeButtons";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* The resize unit.
* @default '%'
*/
_resizeUnit;
/**
* @inheritDoc
*/
constructor(editor) {
super(editor);
this._resizeUnit = editor.config.get("image.resizeUnit");
}
/**
* @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 option 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 {module:image/imageconfig~ImageResizeOption} option Invalid image resize option.
*/
throw new CKEditorError("imageresizebuttons-missing-icon", editor, option);
button.set({
label: labelText,
icon: RESIZE_ICONS[icon],
tooltip: labelText,
isToggleable: true
});
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", command, "isEnabled", 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: void 0
});
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"
});
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;
};
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;
if (editor.plugins.has("ImageCustomResizeUI") && isCustomImageResizeOption(option)) {
const customResizeUI = editor.plugins.get("ImageCustomResizeUI");
definition = {
type: "button",
model: new UIModel({
label: this._getOptionLabelValue(option),
role: "menuitemradio",
withText: true,
icon: null,
onClick: () => {
customResizeUI._showForm(this._resizeUnit);
}
})
};
const allDropdownValues = Object.values(optionsWithSerializedValues).map((option) => option.valueWithUnits);
definition.model.bind("isOn").to(command, "value", command, "isEnabled", getIsOnCustomButtonCallback(allDropdownValues));
} else {
definition = {
type: "button",
model: new UIModel({
commandName: "resizeImage",
commandValue: option.valueWithUnits,
label: this._getOptionLabelValue(option),
role: "menuitemradio",
withText: true,
icon: null
})
};
definition.model.bind("isOn").to(command, "value", command, "isEnabled", getIsOnButtonCallback(option.valueWithUnits));
}
definition.model.bind("isEnabled").to(command, "isEnabled");
itemDefinitions.add(definition);
}
return itemDefinitions;
}
};
/**
* 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, isEnabled) => {
const objectCommandValue = commandValue;
if (objectCommandValue === void 0 || !isEnabled) return false;
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, isEnabled) => !allDropdownValues.some((dropdownValue) => getIsOnButtonCallback(dropdownValue)(commandValue, isEnabled));
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
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";
/**
* The image resize by handles feature.
*
* It adds the ability to resize each image using handles or manually by
* {@link module:image/imageresize/imageresizebuttons~ImageResizeButtons} buttons.
*/
var ImageResizeHandles = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [WidgetResize, ImageUtils];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "ImageResizeHandles";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @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) => {
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) {
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() {
return imageModel.getAttribute("imageStyle") == "alignCenter";
},
onCommit(newValue) {
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-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* Finds model, view and DOM element for selected image element. Returns `null` if there is no image selected.
*
* @param editor Editor instance.
* @internal
*/
function getSelectedImageEditorNodes(editor) {
const { editing } = editor;
const imageModelElement = editor.plugins.get("ImageUtils").getClosestSelectedImageElement(editor.model.document.selection);
if (!imageModelElement) return null;
const imageViewElement = editing.mapper.toViewElement(imageModelElement);
return {
model: imageModelElement,
view: imageViewElement,
dom: editing.view.domConverter.mapViewToDom(imageViewElement)
};
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imageresize/utils/getselectedimagewidthinunits
*/
/**
* 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).
* @internal
*/
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;
return _tryCastDimensionsToUnit(calculateResizeHostAncestorWidth(imageNodes.dom), {
unit: "px",
value: new Rect(imageNodes.dom).width
}, targetUnit);
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imageresize/ui/imagecustomresizeformview
*/
/**
* The ImageCustomResizeFormView class.
*
* @internal
*/
var ImageCustomResizeFormView = class extends View {
/**
* Tracks information about the DOM focus in the form.
*/
focusTracker;
/**
* An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}.
*/
keystrokes;
/**
* Resize unit shortcut.
*/
unit;
/**
* The Back button view displayed in the header.
*/
backButtonView;
/**
* A button used to submit the form.
*/
saveButtonView;
/**
* An input with a label.
*/
labeledInput;
/**
* A collection of child views.
*/
children;
/**
* A collection of views which can be focused in the form.
*/
_focusables;
/**
* Helps cycling over {@link #_focusables} in the form.
*/
_focusCycler;
/**
* An array of form validators used by {@link #isValid}.
*/
_validators;
/**
* @inheritDoc
*/
constructor(locale, unit, validators) {
super(locale);
this.focusTracker = new FocusTracker();
this.keystrokes = new KeystrokeHandler();
this.unit = unit;
this.backButtonView = this._createBackButton();
this.saveButtonView = this._createSaveButton();
this.labeledInput = this._createLabeledInputView();
this.children = this.createCollection([this._createHeaderView()]);
this.children.add(new FormRowView(locale, {
children: [this.labeledInput, this.saveButtonView],
class: ["ck-form__row_with-submit", "ck-form__row_large-top-padding"]
}));
this._focusables = new ViewCollection();
this._validators = validators;
this.keystrokes.set("Esc", (data, cancel) => {
this.fire("cancel");
cancel();
});
this._focusCycler = new FocusCycler({
focusables: this._focusables,
focusTracker: this.focusTracker,
keystrokeHandler: this.keystrokes,
actions: {
focusPrevious: "shift + tab",
focusNext: "tab"
}
});
this.setTemplate({
tag: "form",
attributes: {
class: [
"ck",
"ck-form",
"ck-image-custom-resize-form",
"ck-responsive-form"
],
tabindex: "-1"
},
children: this.children
});
}
/**
* @inheritDoc
*/
render() {
super.render();
submitHandler({ view: this });
[
this.backButtonView,
this.labeledInput,
this.saveButtonView
].forEach((v) => {
this._focusables.add(v);
this.focusTracker.add(v.element);
});
this.keystrokes.listenTo(this.element);
}
/**
* @inheritDoc
*/
destroy() {
super.destroy();
this.focusTracker.destroy();
this.keystrokes.destroy();
}
/**
* Creates a back button view that cancels the form.
*/
_createBackButton() {
const t = this.locale.t;
const backButton = new ButtonView(this.locale);
backButton.set({
class: "ck-button-back",
label: t("Back"),
icon: IconPreviousArrow,
tooltip: true
});
backButton.delegate("execute").to(this, "cancel");
return backButton;
}
/**
* Creates a save button view that resize the image.
*/
_createSaveButton() {
const t = this.locale.t;
const saveButton = new ButtonView(this.locale);
saveButton.set({
label: t("Save"),
withText: true,
type: "submit",
class: "ck-button-action ck-button-bold"
});
return saveButton;
}
/**
* Creates a header view for the form.
*/
_createHeaderView() {
const t = this.locale.t;
const header = new FormHeaderView(this.locale, { label: t("Image Resize") });
header.children.add(this.backButtonView, 0);
return header;
}
/**
* 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.class = "ck-labeled-field-view_full-width";
labeledInput.fieldView.set({ step: .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);
if (errorText) {
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}`;
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* 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.
* @internal
*/
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"
};
return {
unit: targetUnit,
lower: Math.max(.1, _tryCastDimensionsToUnit(imageParentWidthPx, minimumImageWidth, targetUnit).value),
upper: targetUnit === "px" ? imageParentWidthPx : 100
};
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imageresize/imagecustomresizeui
*/
/**
* The custom resize image UI plugin.
*
* The plugin uses the {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon}.
*/
var ImageCustomResizeUI = class extends Plugin {
/**
* The contextual balloon plugin instance.
*/
_balloon;
/**
* A form containing a textarea and buttons, used to change the `alt` text value.
*/
_form;
/**
* @inheritDoc
*/
static get requires() {
return [ContextualBalloon];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "ImageCustomResizeUI";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
destroy() {
super.destroy();
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));
this._form.render();
this.listenTo(this._form, "submit", () => {
if (this._form.isValid()) {
editor.execute("resizeImage", { width: this._form.sizeWithUnits });
this._hideForm(true);
}
});
this.listenTo(this._form.labeledInput, "change:errorText", () => {
editor.ui.update();
});
this.listenTo(this._form, "cancel", () => {
this._hideForm(true);
});
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)
});
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;
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.");
}];
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imageresize
*/
/**
* The image resize plugin.
*
* It adds a possibility to resize each image using handles.
*/
var ImageResize = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [
ImageResizeEditing,
ImageResizeHandles,
ImageCustomResizeUI,
ImageResizeButtons
];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "ImageResize";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* The image style command. It is used to apply {@link module:image/imageconfig~ImageStyleConfig#options image style option}
* to a selected image.
*
* **Note**: Executing this command may change the image model element if the desired style requires an image of a different
* type. See {@link module:image/imagestyle/imagestylecommand~ImageStyleCommand#execute} to learn more.
*/
var ImageStyleCommand = class extends Command {
/**
* An object containing names of default style options for the inline and block images.
* If there is no default style option for the given image type in the configuration,
* the name will be `false`.
*/
_defaultStyles;
/**
* The styles handled by this command.
*/
_styles;
/**
* 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];
}));
}
/**
* @inheritDoc
*/
refresh() {
const element = this.editor.plugins.get("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];
}
/**
* Returns whether the style with the given name can be applied to the currently selected image.
*
* Applying a style may internally trigger a type conversion (block ↔ inline) when the style
* supports only the opposite type. If that conversion would be rejected by the schema (for
* example, converting an inline image to a block image inside `$inlineRoot`), this style
* cannot be applied even though the command itself is enabled. UI components representing
* individual styles should reflect this per-style state in their `isEnabled`.
*/
isStyleEnabled(styleName) {
if (!this.isEnabled) return false;
const imageUtils = this.editor.plugins.get("ImageUtils");
const element = imageUtils.getClosestSelectedImageElement(this.editor.model.document.selection);
if (!element || !this._styles.has(styleName)) return false;
if (!this.shouldConvertImageType(styleName, element)) return true;
const typeCommandName = imageUtils.isBlockImage(element) ? "imageTypeInline" : "imageTypeBlock";
const typeCommand = this.editor.commands.get(typeCommandName);
return !!typeCommand && typeCommand.isEnabled;
}
/**
* 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");
if (options.value && !this.isStyleEnabled(options.value)) return;
model.change((writer) => {
const requestedStyle = options.value;
const { setImageSizes = true } = options;
let imageElement = imageUtils.getClosestSelectedImageElement(model.document.selection);
if (requestedStyle && this.shouldConvertImageType(requestedStyle, imageElement)) {
this.editor.execute(imageUtils.isBlockImage(imageElement) ? "imageTypeInline" : "imageTypeBlock", { setImageSizes });
imageElement = imageUtils.getClosestSelectedImageElement(model.document.selection);
}
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) {
return !this._styles.get(requestedStyle).modelElements.includes(imageElement.name);
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imagestyle/utils
*/
/**
* 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.
*
* @internal
*/
const DEFAULT_OPTIONS = {
get inline() {
return {
name: "inline",
title: "In line",
icon: IconObjectInline,
modelElements: ["imageInline"],
isDefault: true
};
},
get alignLeft() {
return {
name: "alignLeft",
title: "Left aligned image",
icon: IconObjectInlineLeft,
modelElements: ["imageBlock", "imageInline"],
className: "image-style-align-left"
};
},
get alignBlockLeft() {
return {
name: "alignBlockLeft",
title: "Left aligned image",
icon: IconObjectLeft,
modelElements: ["imageBlock"],
className: "image-style-block-align-left"
};
},
get alignCenter() {
return {
name: "alignCenter",
title: "Centered image",
icon: IconObjectCenter,
modelElements: ["imageBlock"],
className: "image-style-align-center"
};
},
get alignRight() {
return {
name: "alignRight",
title: "Right aligned image",
icon: IconObjectInlineRight,
modelElements: ["imageBlock", "imageInline"],
className: "image-style-align-right"
};
},
get alignBlockRight() {
return {
name: "alignBlockRight",
title: "Right aligned image",
icon: IconObjectRight,
modelElements: ["imageBlock"],
className: "image-style-block-align-right"
};
},
get block() {
return {
name: "block",
title: "Centered image",
icon: IconObjectCenter,
modelElements: ["imageBlock"],
isDefault: true
};
},
get side() {
return {
name: "side",
title: "Side image",
icon: IconObjectInlineRight,
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'`.
*
* @internal
*/
const DEFAULT_ICONS = /* #__PURE__ */ (() => ({
full: IconObjectFullWidth,
left: IconObjectLeft,
right: IconObjectRight,
center: IconObjectCenter,
inlineLeft: IconObjectInlineLeft,
inlineRight: IconObjectInlineRight,
inline: IconObjectInline
}))();
/**
* 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.
*
* @internal
*/
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 the style with a given `name` from an array of styles.
*
* @internal
*/
function getStyleDefinitionByName(name, styles) {
for (const style of styles) if (style.name === name) return style;
}
/**
* 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) {
return (config.configuredStyles.options || []).map((arrangement) => normalizeDefinition(arrangement)).filter((arrangement) => isValidOption(arrangement, config));
}
/**
* 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") if (!DEFAULT_OPTIONS[definition]) definition = { name: definition };
else definition = { ...DEFAULT_OPTIONS[definition] };
else definition = extendStyle(DEFAULT_OPTIONS[definition.name], definition);
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];
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} style The name of the unsupported option.
* @param {Array.<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 {object} info The information about the invalid definition.
*/
logWarning("image-style-configuration-definition-invalid", info);
}
/**
* @internal
*/
const utils = {
normalizeStyles,
getDefaultStylesConfiguration,
getDefaultDropdownDefinitions,
warnInvalidStyle
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @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.
* @internal
*/
function modelToViewStyleAttribute(styles) {
return (evt, data, conversionApi) => {
if (!conversionApi.consumable.consume(data.item, evt.name)) return;
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.
* @internal
*/
function viewToModelStyleAttribute(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());
if (!modelImageElement) return;
if (!conversionApi.schema.checkAttribute(modelImageElement, "imageStyle")) return;
for (const style of nonDefaultStyles[modelImageElement.name]) if (conversionApi.consumable.consume(viewElement, { classes: style.className })) conversionApi.writer.setAttribute("imageStyle", style.name, modelImageElement);
normalizeFloatToDefinitionStyle(conversionApi, viewElement, modelImageElement, styles);
};
}
/**
* A helper function that attempts to convert the `float` CSS style into a corresponding `imageStyle` attribute.
*
* It maps `float: left` and `float: right` to standard alignment styles (e.g. `'alignLeft'`, `'alignRight'`),
* but only if the target style definition matches one of the {@link module:image/image/utils~DEFAULT_OPTIONS default options}.
*/
function normalizeFloatToDefinitionStyle(conversionApi, viewElement, modelElement, styles) {
if (!conversionApi.consumable.test(viewElement, { styles: ["float"] })) return;
let floatStyleName = null;
switch (viewElement.getStyle("float")) {
case "left":
floatStyleName = "alignLeft";
break;
case "right":
floatStyleName = "alignRight";
break;
}
if (!floatStyleName) return;
const definition = getStyleDefinitionByName(floatStyleName, styles);
if (!definition) return;
const builtinDefinition = DEFAULT_OPTIONS[definition.name];
if (!isEqual(definition, builtinDefinition)) return;
conversionApi.writer.setAttribute("imageStyle", floatStyleName, modelElement);
conversionApi.consumable.consume(viewElement, { styles: ["float"] });
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imagestyle/imagestyleediting
*/
/**
* The image style engine plugin. It sets the default configuration, creates converters and registers
* {@link module:image/imagestyle/imagestylecommand~ImageStyleCommand ImageStyleCommand}.
*/
var ImageStyleEditing = class extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return "ImageStyleEditing";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
static get requires() {
return [ImageUtils];
}
/**
* It contains a list of the normalized and validated style options.
*
* * Each option contains a complete icon markup.
* * The style options not supported by any of the loaded image editing plugins (
* {@link module:image/image/imageinlineediting~ImageInlineEditing `ImageInlineEditing`} or
* {@link module:image/image/imageblockediting~ImageBlockEditing `ImageBlockEditing`}) are filtered out.
*
* @internal
* @readonly
*/
normalizedStyles;
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const isBlockPluginLoaded = editor.plugins.has("ImageBlockEditing");
const isInlinePluginLoaded = editor.plugins.has("ImageInlineEditing");
editor.config.define("image.styles", utils.getDefaultStylesConfiguration(isBlockPluginLoaded, isInlinePluginLoaded));
this.normalizedStyles = utils.normalizeStyles({
configuredStyles: editor.config.get("image.styles"),
isBlockPluginLoaded,
isInlinePluginLoaded
});
this._setupConversion(isBlockPluginLoaded, isInlinePluginLoaded);
this._setupPostFixer();
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);
if (isBlockPluginLoaded) {
schema.extend("imageBlock", { allowAttributes: "imageStyle" });
schema.setAttributeProperties("imageStyle", {
isFormatting: true,
blockAlignment: getBlockAlignmentAttributeProperty(this.normalizedStyles)
});
editor.data.upcastDispatcher.on("element:figure", viewToModelConverter, { priority: "low" });
}
if (isInlinePluginLoaded) {
schema.extend("imageInline", { allowAttributes: "imageStyle" });
schema.setAttributeProperties("imageStyle", { isFormatting: true });
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]));
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;
});
}
};
/**
* Returns a mapping of generic alignment values ('left', 'right', 'center') to their corresponding
* block image alignment style names.
*
* This function is particularly useful for scenarios like `td[align]` conversion, which needs to know
* exactly how to map a generic `align` attribute to the correct alignment value. This mapping is necessary
* because the actual alignment value can differ depending on the target element type (e.g., block images
* vs. tables).
*
* @param styles An array of available image style option definitions.
* @returns A record mapping basic alignments to their valid, block-specific style names.
*/
function getBlockAlignmentAttributeProperty(styles) {
const pickFirstSupported = (...names) => {
const found = names.map((name) => getStyleDefinitionByName(name, styles)).find((definition) => definition?.modelElements.includes("imageBlock"));
if (!found) return;
return {
isDefault: !!found.isDefault,
value: found.name
};
};
return Object.fromEntries(Object.entries({
left: ["alignBlockLeft", "alignLeft"],
right: ["alignBlockRight", "alignRight"],
center: ["block", "alignCenter"]
}).map(([align, names]) => [align, pickFirstSupported(...names)]).filter(([, resolved]) => resolved !== void 0));
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imagestyle/imagestyleui
*/
/**
* The image style UI plugin.
*
* It registers buttons corresponding to the {@link module:image/imageconfig~ImageConfig#styles} configuration.
* It also registers the {@link module:image/imagestyle/utils#DEFAULT_DROPDOWN_DEFINITIONS default drop-downs} and the
* custom drop-downs defined by the developer in the {@link module:image/imageconfig~ImageConfig#toolbar} configuration.
*/
var ImageStyleUI = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [ImageStyleEditing];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "ImageStyleUI";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* 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 definedStyles = translateStyles(plugins.get("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" : void 0);
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));
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 editor = this.editor;
const command = editor.commands.get("imageStyle");
const view = new ButtonView(locale);
view.set({
label: buttonConfig.title,
icon: buttonConfig.icon,
tooltip: true,
isToggleable: true
});
const reactiveSources = [command];
const imageTypeBlock = editor.commands.get("imageTypeBlock");
const imageTypeInline = editor.commands.get("imageTypeInline");
if (imageTypeBlock) reactiveSources.push(imageTypeBlock);
if (imageTypeInline) reactiveSources.push(imageTypeInline);
view.bind("isEnabled").toMany(reactiveSources, "isEnabled", () => command.isStyleEnabled(buttonName));
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) 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;
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imagestyle
*/
/**
* The image style plugin.
*
* For a detailed overview of the image styles feature, check the {@glink features/images/images-styles documentation}.
*
* This is a "glue" plugin which loads the following plugins:
* * {@link module:image/imagestyle/imagestyleediting~ImageStyleEditing},
* * {@link module:image/imagestyle/imagestyleui~ImageStyleUI}
*
* It provides a default configuration, which can be extended or overwritten.
* Read more about the {@link module:image/imageconfig~ImageConfig#styles image styles configuration}.
*/
var ImageStyle = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [ImageStyleEditing, ImageStyleUI];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "ImageStyle";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/imagetoolbar
*/
/**
* The image toolbar plugin. It creates and manages the image toolbar (the toolbar displayed when an image is selected).
*
* For an overview, check the {@glink features/images/images-overview#image-contextual-toolbar image contextual toolbar} documentation.
*
* Instances of toolbar components (e.g. buttons) are created using the editor's
* {@link module:ui/componentfactory~ComponentFactory component factory}
* based on the {@link module:image/imageconfig~ImageConfig#toolbar `image.toolbar` configuration option}.
*
* The toolbar uses the {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon}.
*/
var ImageToolbar = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [WidgetToolbarRepository, ImageUtils];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "ImageToolbar";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @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);
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module image/pictureediting
*/
/**
* This plugin enables the [`<picture>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/picture) element support in the editor.
*
* * It enables the `sources` model attribute on `imageBlock` and `imageInline` model elements
* (brought by {@link module:image/imageblock~ImageBlock} and {@link module:image/imageinline~ImageInline}, respectively).
* * It translates the `sources` model element to the view (also: data) structure that may look as follows:
*
* ```html
* <p>Inline image using picture:
* <picture>
* <source media="(min-width: 800px)" srcset="image-large.webp" type="image/webp">
* <source media="(max-width: 800px)" srcset="image-small.webp" type="image/webp">
* <!-- Other sources as specified in the "sources" model attribute... -->
* <img src="image.png" alt="An image using picture" />
* </picture>
* </p>
*
* <p>Block image using picture:</p>
* <figure class="image">
* <picture>
* <source media="(min-width: 800px)" srcset="image-large.webp" type="image/webp">
* <source media="(max-width: 800px)" srcset="image-small.webp" type="image/webp">
* <!-- Other sources as specified in the "sources" model attribute... -->
* <img src="image.png" alt="An image using picture" />
* </picture>
* <figcaption>Caption of the image</figcaption>
* </figure>
* ```
*
* **Note:** The value of the `sources` {@glink framework/architecture/editing-engine#changing-the-model model attribute}
* in both examples equals:
*
* ```css
* [
* {
* media: '(min-width: 800px)',
* srcset: 'image-large.webp',
* type: 'image/webp'
* },
* {
* media: '(max-width: 800px)',
* srcset: 'image-small.webp',
* type: 'image/webp'
* }
* ]
* ```
*
* * It integrates with the {@link module:image/imageupload~ImageUpload} plugin so images uploaded in the editor
* automatically render using `<picture>` if the {@glink features/images/image-upload/image-upload upload adapter}
* supports image sources and provides neccessary data.
*
* @private
*/
var PictureEditing = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [ImageEditing, ImageUtils];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "PictureEditing";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @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);
});
});
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
export { AutoImage, Image, ImageBlock, ImageBlockEditing, ImageCaption, ImageCaptionEditing, ImageCaptionUI, ImageCaptionUtils, ImageCustomResizeUI, ImageEditing, ImageInline, ImageInlineEditing, ImageInsert, ImageInsertUI, ImageInsertViaUrl, ImageInsertViaUrlUI, ImageLoadObserver, ImagePlaceholder, ImageResize, ImageResizeButtons, ImageResizeEditing, ImageResizeHandles, ImageSizeAttributes, ImageStyle, ImageStyleCommand, ImageStyleEditing, ImageStyleUI, ImageTextAlternative, ImageTextAlternativeCommand, ImageTextAlternativeEditing, ImageTextAlternativeUI, ImageToolbar, ImageTypeCommand, ImageUpload, ImageUploadEditing, ImageUploadProgress, ImageUploadUI, ImageUtils, InsertImageCommand, PictureEditing, ReplaceImageSourceCommand, ResizeImageCommand, ToggleImageCaptionCommand, UploadImageCommand, DEFAULT_DROPDOWN_DEFINITIONS as _IMAGE_DEFAULT_DROPDOWN_DEFINITIONS, DEFAULT_ICONS as _IMAGE_DEFAULT_ICONS, DEFAULT_OPTIONS as _IMAGE_DEFAULT_OPTIONS, ImageCustomResizeFormView as _ImageCustomResizeFormView, ImageInsertFormView as _ImageInsertFormView, ImageInsertUrlView as _ImageInsertUrlView, utils as _ImageStyleUtils, TextAlternativeFormView as _ImageTextAlternativeFormView, widthAndHeightStylesAreBothSet as _checkIfImageWidthAndHeightStylesAreBothSet, createBlockImageViewElement as _createBlockImageViewElement, createInlineImageViewElement as _createInlineImageViewElement, determineImageTypeForInsertionAtSelection as _determineImageTypeForInsertionAtSelection, downcastImageAttribute as _downcastImageAttribute, downcastSourcesAttribute as _downcastImageSourcesAttribute, downcastSrcsetAttribute as _downcastImageSrcsetAttribute, fetchLocalImage as _fetchLocalImage, getBalloonPositionData as _getImageBalloonPositionData, getSizeValueIfInPx as _getImageSizeValueIfInPx, getImgViewElementMatcher as _getImageViewElementMatcher, getSelectedImageEditorNodes as _getSelectedImageEditorNodes, getSelectedImagePossibleResizeRange as _getSelectedImagePossibleResizeRange, getSelectedImageWidthInUnits as _getSelectedImageWidthInUnits, isLocalImage as _isLocalImage, modelToViewStyleAttribute as _modelToViewImageStyleAttribute, repositionContextualBalloon as _repositionImageContextualBalloon, upcastImageFigure as _upcastImageFigure, upcastPicture as _upcastImagePicture, viewToModelStyleAttribute as _viewToModelImageStyleAttribute, createImageTypeRegExp, isHtmlInDataTransfer };
//# sourceMappingURL=index.js.map