@ckeditor/ckeditor5-media-embed
Version:
Media embed feature for CKEditor 5.
2,693 lines • 88 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 { Widget, WidgetResize, WidgetToolbarRepository, calculateResizeHostAncestorWidth, findOptimalInsertionRange, isWidget, toWidget } from "@ckeditor/ckeditor5-widget";
import { CKEditorError, Collection, FocusTracker, KeystrokeHandler, Rect, _tryCastDimensionsToUnit, _tryParseDimensionWithUnit, first, global, logWarning, toArray } from "@ckeditor/ckeditor5-utils";
import { BalloonPanelView, ButtonView, ContextualBalloon, CssTransitionDisablerMixin, Dialog, DropdownButtonView, FocusCycler, FormHeaderView, FormRowView, IconView, LabeledFieldView, MenuBarMenuListItemButtonView, SplitButtonView, Template, UIModel, View, ViewCollection, addListToDropdown, addToolbarToDropdown, clickOutsideHandler, createDropdown, createLabeledInputNumber, createLabeledInputText, submitHandler } from "@ckeditor/ckeditor5-ui";
import { IconMedia, IconMediaPlaceholder, IconObjectCenter, IconObjectInlineLeft, IconObjectInlineRight, IconObjectLeft, IconObjectRight, IconObjectSizeCustom, IconObjectSizeFull, IconObjectSizeLarge, IconObjectSizeMedium, IconObjectSizeSmall, IconPreviousArrow } from "@ckeditor/ckeditor5-icons";
import { ModelLivePosition, ModelLiveRange } from "@ckeditor/ckeditor5-engine";
import { Clipboard } from "@ckeditor/ckeditor5-clipboard";
import { Delete } from "@ckeditor/ckeditor5-typing";
import { Undo } from "@ckeditor/ckeditor5-undo";
/**
* @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 model "url" attribute to the view representation.
*
* Depending on the configuration, the view representation can be "semantic" (for the data pipeline):
*
* ```html
* <figure class="media">
* <oembed url="foo"></oembed>
* </figure>
* ```
*
* or "non-semantic" (for the editing view pipeline):
*
* ```html
* <figure class="media">
* <div data-oembed-url="foo">[ non-semantic media preview for "foo" ]</div>
* </figure>
* ```
*
* **Note:** Changing the model "url" attribute replaces the entire content of the
* `<figure>` in the view.
*
* @param registry The registry providing
* the media and their content.
* @param options options object with following properties:
* - elementName When set, overrides the default element name for semantic media embeds.
* - renderMediaPreview When `true`, the converter will create the view in the non-semantic form.
* - renderForEditingView When `true`, the converter will create a view specific for the
* editing pipeline (e.g. including CSS classes, content placeholders).
*
* @internal
*/
function modelToViewUrlAttributeConverter(registry, options) {
const converter = (evt, data, conversionApi) => {
if (!conversionApi.consumable.consume(data.item, evt.name)) return;
const url = data.attributeNewValue;
const viewWriter = conversionApi.writer;
const figure = conversionApi.mapper.toViewElement(data.item);
const mediaContentElement = [...figure.getChildren()].find((child) => child.getCustomProperty("media-content"));
viewWriter.remove(mediaContentElement);
const mediaViewElement = registry.getMediaViewElement(viewWriter, url, options);
viewWriter.insert(viewWriter.createPositionAt(figure, 0), mediaViewElement);
};
return (dispatcher) => {
dispatcher.on("attribute:url:media", 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
*/
/**
* Converts a given {@link module:engine/view/element~ViewElement} to a media embed widget:
* * Adds a {@link module:engine/view/element~ViewElement#_setCustomProperty custom property}
* allowing to recognize the media 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.
* @internal
*/
function toMediaWidget(viewElement, writer, label) {
writer.setCustomProperty("media", true, viewElement);
return toWidget(viewElement, writer, { label });
}
/**
* Returns a media widget editing view element if one is selected.
*
* @internal
*/
function getSelectedMediaViewWidget(selection) {
const viewElement = selection.getSelectedElement();
if (viewElement && isMediaWidget(viewElement)) return viewElement;
return null;
}
/**
* Checks if a given view element is a media widget.
*
* @internal
*/
function isMediaWidget(viewElement) {
return !!viewElement.getCustomProperty("media") && isWidget(viewElement);
}
/**
* Creates a view element representing the media. Either a "semantic" one for the data pipeline:
*
* ```html
* <figure class="media">
* <oembed url="foo"></oembed>
* </figure>
* ```
*
* or a "non-semantic" (for the editing view pipeline):
*
* ```html
* <figure class="media">
* <div data-oembed-url="foo">[ non-semantic media preview for "foo" ]</div>
* </figure>
* ```
*
* @internal
*/
function createMediaFigureElement(writer, registry, url, options) {
return writer.createContainerElement("figure", { class: "media" }, [registry.getMediaViewElement(writer, url, options), writer.createSlot()]);
}
/**
* Returns a selected media element in the model, if any.
*
* @internal
*/
function getSelectedMediaModelWidget(selection) {
const selectedElement = selection.getSelectedElement();
if (selectedElement && selectedElement.is("element", "media")) return selectedElement;
return null;
}
/**
* Creates a media element and inserts it into the model.
*
* **Note**: This method will use {@link module:engine/model/model~Model#insertContent `model.insertContent()`} logic of inserting content
* if no `insertPosition` is passed.
*
* @param url An URL of an embeddable media.
* @param findOptimalPosition If true it will try to find optimal position to insert media without breaking content
* in which a selection is.
* @internal
*/
function insertMedia(model, url, selectable, findOptimalPosition) {
model.change((writer) => {
const mediaElement = writer.createElement("media", { url });
model.insertObject(mediaElement, selectable, null, {
setSelection: "on",
findOptimalPosition: findOptimalPosition ? "auto" : 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
*/
/**
* The insert media command.
*
* The command is registered by the {@link module:media-embed/mediaembedediting~MediaEmbedEditing} as `'mediaEmbed'`.
*
* To insert media at the current selection, execute the command and specify the URL:
*
* ```ts
* editor.execute( 'mediaEmbed', 'http://url.to.the/media' );
* ```
*/
var MediaEmbedCommand = class extends Command {
/**
* @inheritDoc
*/
refresh() {
const model = this.editor.model;
const selection = model.document.selection;
const selectedMedia = getSelectedMediaModelWidget(selection);
this.value = selectedMedia ? selectedMedia.getAttribute("url") : void 0;
this.isEnabled = isMediaSelected(selection) || isAllowedInParent(selection, model);
}
/**
* Executes the command, which either:
*
* * updates the URL of the selected media,
* * inserts the new media into the editor and puts the selection around it.
*
* @fires execute
* @param url The URL of the media.
*/
execute(url) {
const model = this.editor.model;
const selection = model.document.selection;
const selectedMedia = getSelectedMediaModelWidget(selection);
if (selectedMedia) model.change((writer) => {
writer.setAttribute("url", url, selectedMedia);
});
else insertMedia(model, url, selection, true);
}
};
/**
* Checks if the media embed is allowed in the parent.
*/
function isAllowedInParent(selection, model) {
let parent = findOptimalInsertionRange(selection, model).start.parent;
if (parent.isEmpty && !model.schema.isLimit(parent)) parent = parent.parent;
return model.schema.checkChild(parent, "media");
}
/**
* Checks if the media object is selected.
*/
function isMediaSelected(selection) {
const element = selection.getSelectedElement();
return !!element && element.name === "media";
}
/**
* @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 media-embed/mediaregistry
*/
const mediaPlaceholderIconViewBox = "0 0 64 42";
/**
* A bridge between the raw media content provider definitions and the editor view content.
*
* It helps translating media URLs to corresponding {@link module:engine/view/element~ViewElement view elements}.
*
* Mostly used by the {@link module:media-embed/mediaembedediting~MediaEmbedEditing} plugin.
*/
var MediaRegistry = class {
/**
* The {@link module:utils/locale~Locale} instance.
*/
locale;
/**
* The media provider definitions available for the registry. Usually corresponding with the
* {@link module:media-embed/mediaembedconfig~MediaEmbedConfig media configuration}.
*/
providerDefinitions;
/**
* Creates an instance of the {@link module:media-embed/mediaregistry~MediaRegistry} class.
*
* @param locale The localization services instance.
* @param config The configuration of the media embed feature.
*/
constructor(locale, config) {
const providers = config.providers;
const extraProviders = config.extraProviders || [];
const removedProviders = new Set(config.removeProviders);
const providerDefinitions = providers.concat(extraProviders).filter((provider) => {
const name = provider.name;
if (!name) {
/**
* One of the providers (or extra providers) specified in the media embed configuration
* has no name and will not be used by the editor. In order to get this media
* provider working, double check your editor configuration.
*
* @error media-embed-no-provider-name
*/
logWarning("media-embed-no-provider-name", { provider });
return false;
}
return !removedProviders.has(name);
});
this.locale = locale;
this.providerDefinitions = providerDefinitions;
}
/**
* Checks whether the passed URL is representing a certain media type allowed in the editor.
*
* @param url The URL to be checked
*/
hasMedia(url) {
return !!this._getMedia(url);
}
/**
* For the given media URL string and options, it returns the {@link module:engine/view/element~ViewElement view element}
* representing that media.
*
* **Note:** If no URL is specified, an empty view element is returned.
*
* @param writer The view writer used to produce a view element.
* @param url The URL to be translated into a view element.
*/
getMediaViewElement(writer, url, options) {
return this._getMedia(url).getViewElement(writer, options);
}
/**
* Returns a `Media` instance for the given URL.
*
* @param url The URL of the media.
* @returns The `Media` instance or `null` when there is none.
*/
_getMedia(url) {
if (!url) return new Media(this.locale);
url = url.trim();
for (const definition of this.providerDefinitions) {
const previewRenderer = definition.html;
const pattern = toArray(definition.url);
for (const subPattern of pattern) {
const match = this._getUrlMatches(url, subPattern);
if (match) return new Media(this.locale, url, match, previewRenderer);
}
}
return null;
}
/**
* Tries to match `url` to `pattern`.
*
* @param url The URL of the media.
* @param pattern The pattern that should accept the media URL.
*/
_getUrlMatches(url, pattern) {
let match = url.match(pattern);
if (match) return match;
let rawUrl = url.replace(/^https?:\/\//, "");
match = rawUrl.match(pattern);
if (match) return match;
rawUrl = rawUrl.replace(/^www\./, "");
match = rawUrl.match(pattern);
if (match) return match;
return null;
}
};
/**
* Represents media defined by the provider configuration.
*
* It can be rendered to the {@link module:engine/view/element~ViewElement view element} and used in the editing or data pipeline.
*/
var Media = class {
/**
* The URL this Media instance represents.
*/
url;
/**
* Shorthand for {@link module:utils/locale~Locale#t}.
*
* @see module:utils/locale~Locale#t
*/
_locale;
/**
* The output of the `RegExp.match` which validated the {@link #url} of this media.
*/
_match;
/**
* The function returning the HTML string preview of this media.
*/
_previewRenderer;
constructor(locale, url, match, previewRenderer) {
this.url = this._getValidUrl(url);
this._locale = locale;
this._match = match;
this._previewRenderer = previewRenderer;
}
/**
* Returns the view element representation of the media.
*
* @param writer The view writer used to produce a view element.
*/
getViewElement(writer, options) {
const attributes = {};
let viewElement;
if (options.renderForEditingView || options.renderMediaPreview && this.url && this._previewRenderer) {
if (this.url) attributes["data-oembed-url"] = this.url;
if (options.renderForEditingView) attributes.class = "ck-media__wrapper";
const mediaHtml = this._getPreviewHtml(options);
viewElement = writer.createRawElement("div", attributes, (domElement, domConverter) => {
domConverter.setContentOf(domElement, mediaHtml);
});
} else {
if (this.url) attributes.url = this.url;
viewElement = writer.createEmptyElement(options.elementName, attributes);
}
writer.setCustomProperty("media-content", true, viewElement);
return viewElement;
}
/**
* Returns the HTML string of the media content preview.
*/
_getPreviewHtml(options) {
if (this._previewRenderer) return this._previewRenderer(this._match);
else {
if (this.url && options.renderForEditingView) return this._getPlaceholderHtml();
return "";
}
}
/**
* Returns the placeholder HTML when the media has no content preview.
*/
_getPlaceholderHtml() {
const icon = new IconView();
const t = this._locale.t;
icon.content = IconMediaPlaceholder;
icon.viewBox = mediaPlaceholderIconViewBox;
return new Template({
tag: "div",
attributes: { class: "ck ck-reset_all ck-media__placeholder" },
children: [{
tag: "div",
attributes: { class: "ck-media__placeholder__icon" },
children: [icon]
}, {
tag: "a",
attributes: {
class: "ck-media__placeholder__url",
target: "_blank",
rel: "noopener noreferrer",
href: this.url,
"data-cke-tooltip-text": t("Open media in new tab")
},
children: [{
tag: "span",
attributes: { class: "ck-media__placeholder__url__text" },
children: [this.url]
}]
}]
}).render().outerHTML;
}
/**
* Returns the full URL to the specified media.
*
* @param url The URL of the media.
*/
_getValidUrl(url) {
if (!url) return null;
if (url.match(/^https?/)) return url;
return "https://" + url;
}
};
/**
* @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 media-embed/mediaembedediting
*/
/**
* The media embed editing feature.
*/
var MediaEmbedEditing = class extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return "MediaEmbedEditing";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* The media registry managing the media providers in the editor.
*/
registry;
/**
* @inheritDoc
*/
constructor(editor) {
super(editor);
editor.config.define("mediaEmbed", {
elementName: "oembed",
providers: [
{
name: "dailymotion",
url: [/^dailymotion\.com\/video\/(\w+)/, /^dai.ly\/(\w+)/],
html: (match) => {
return `<div><iframe src="https://www.dailymotion.com/embed/video/${match[1]}" width="1280" height="720" style="width: 100%; height: auto; aspect-ratio: 16 / 9; border: 0; display: block;" frameborder="0" allowfullscreen allow="autoplay"></iframe></div>`;
}
},
{
name: "spotify",
url: [
/^open\.spotify\.com\/(artist\/\w+)/,
/^open\.spotify\.com\/(album\/\w+)/,
/^open\.spotify\.com\/(track\/\w+)/
],
html: (match) => {
const id = match[1];
const isTrack = id.startsWith("track/");
return `<div><iframe src="https://open.spotify.com/embed/${id}" width="300" height="${isTrack ? "80" : "378"}" style="${isTrack ? "width: 100%; height: 80px; border: 0; display: block;" : "width: 100%; height: auto; aspect-ratio: 100 / 126; border: 0; display: block;"}" frameborder="0" allowtransparency="true" allow="encrypted-media"></iframe></div>`;
}
},
{
name: "youtube",
url: [
/^(?:m\.)?youtube\.com\/watch\?v=([\w-]+)(?:&t=(\d+))?/,
/^(?:m\.)?youtube\.com\/shorts\/([\w-]+)(?:\?t=(\d+))?/,
/^(?:m\.)?youtube\.com\/v\/([\w-]+)(?:\?t=(\d+))?/,
/^youtube\.com\/embed\/([\w-]+)(?:\?start=(\d+))?/,
/^youtu\.be\/([\w-]+)(?:\?t=(\d+))?/
],
html: (match) => {
const id = match[1];
const time = match[2];
return `<div><iframe src="https://www.youtube.com/embed/${id}${time ? `?start=${time}` : ""}" width="1280" height="720" style="width: 100%; height: auto; aspect-ratio: 16 / 9; border: 0; display: block;" frameborder="0" allow="autoplay; encrypted-media" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe></div>`;
}
},
{
name: "vimeo",
url: [
/^vimeo\.com\/(\d+)/,
/^vimeo\.com\/[^/]+\/[^/]+\/video\/(\d+)/,
/^vimeo\.com\/album\/[^/]+\/video\/(\d+)/,
/^vimeo\.com\/channels\/[^/]+\/(\d+)/,
/^vimeo\.com\/groups\/[^/]+\/videos\/(\d+)/,
/^vimeo\.com\/ondemand\/[^/]+\/(\d+)/,
/^player\.vimeo\.com\/video\/(\d+)/
],
html: (match) => {
return `<div><iframe src="https://player.vimeo.com/video/${match[1]}" width="1280" height="720" style="width: 100%; height: auto; aspect-ratio: 16 / 9; border: 0; display: block;" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen></iframe></div>`;
}
},
{
name: "instagram",
url: [/^instagram\.com\/p\/(\w+)/, /^instagram\.com\/reel\/(\w+)/]
},
{
name: "twitter",
url: [/^twitter\.com/, /^x\.com/]
},
{
name: "googleMaps",
url: [
/^google\.com\/maps/,
/^goo\.gl\/maps/,
/^maps\.google\.com/,
/^maps\.app\.goo\.gl/
]
},
{
name: "flickr",
url: /^flickr\.com/
},
{
name: "facebook",
url: /^facebook\.com/
}
]
});
this.registry = new MediaRegistry(editor.locale, editor.config.get("mediaEmbed"));
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const schema = editor.model.schema;
const t = editor.t;
const conversion = editor.conversion;
const renderMediaPreview = editor.config.get("mediaEmbed.previewsInData");
const elementName = editor.config.get("mediaEmbed.elementName");
const registry = this.registry;
editor.commands.add("mediaEmbed", new MediaEmbedCommand(editor));
schema.register("media", {
inheritAllFrom: "$blockObject",
allowAttributes: ["url"]
});
conversion.for("dataDowncast").elementToStructure({
model: "media",
view: (modelElement, { writer }) => {
const url = modelElement.getAttribute("url");
return createMediaFigureElement(writer, registry, url, {
elementName,
renderMediaPreview: !!url && renderMediaPreview
});
}
});
conversion.for("dataDowncast").add(modelToViewUrlAttributeConverter(registry, {
elementName,
renderMediaPreview
}));
conversion.for("editingDowncast").elementToStructure({
model: "media",
view: (modelElement, { writer }) => {
const url = modelElement.getAttribute("url");
return toMediaWidget(createMediaFigureElement(writer, registry, url, {
elementName,
renderForEditingView: true
}), writer, t("media widget"));
}
});
conversion.for("editingDowncast").add(modelToViewUrlAttributeConverter(registry, {
elementName,
renderForEditingView: true
}));
conversion.for("upcast").elementToElement({
view: (element) => ["oembed", elementName].includes(element.name) && element.getAttribute("url") ? { name: true } : null,
model: (viewMedia, { writer }) => {
const url = viewMedia.getAttribute("url");
if (registry.hasMedia(url)) return writer.createElement("media", { url });
return null;
}
}).elementToElement({
view: {
name: "div",
attributes: { "data-oembed-url": true }
},
model: (viewMedia, { writer }) => {
const url = viewMedia.getAttribute("data-oembed-url");
if (registry.hasMedia(url)) return writer.createElement("media", { url });
return null;
}
}).add((dispatcher) => {
const converter = (evt, data, conversionApi) => {
if (!conversionApi.consumable.consume(data.viewItem, {
name: true,
classes: "media"
})) return;
const { modelRange, modelCursor } = conversionApi.convertChildren(data.viewItem, data.modelCursor);
data.modelRange = modelRange;
data.modelCursor = modelCursor;
if (!first(modelRange.getItems())) conversionApi.consumable.revert(data.viewItem, {
name: true,
classes: "media"
});
};
dispatcher.on("element:figure", 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 media-embed/automediaembed
*/
const URL_REGEXP = /^(?:http(s)?:\/\/)?[\w-]+\.[\w-.~:/?#[\]@!$&'()*+,;=%]+$/;
/**
* The auto-media embed plugin. It recognizes media links in the pasted content and embeds
* them shortly after they are injected into the document.
*/
var AutoMediaEmbed = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [
Clipboard,
Delete,
Undo
];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "AutoMediaEmbed";
}
/**
* @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 `<media>` element will be inserted after the timeout,
* determined each time the 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._embedMediaBetweenPositions(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 media.
* When the URL is found, it is automatically converted into media.
*
* @param leftPosition Left position of the selection.
* @param rightPosition Right position of the selection.
*/
_embedMediaBetweenPositions(leftPosition, rightPosition) {
const editor = this.editor;
const mediaRegistry = editor.plugins.get(MediaEmbedEditing).registry;
const urlRange = new ModelLiveRange(leftPosition, rightPosition);
const walker = urlRange.getWalker({ ignoreElementEnd: true });
let url = "";
for (const node of walker) if (node.item.is("$textProxy")) url += node.item.data;
url = url.trim();
if (!url.match(URL_REGEXP)) {
urlRange.detach();
return;
}
if (!mediaRegistry.hasMedia(url)) {
urlRange.detach();
return;
}
if (!editor.commands.get("mediaEmbed").isEnabled) {
urlRange.detach();
return;
}
this._positionToInsert = ModelLivePosition.fromPosition(leftPosition);
this._timeoutId = global.window.setTimeout(() => {
editor.model.change((writer) => {
this._timeoutId = null;
writer.remove(urlRange);
urlRange.detach();
let insertionPosition = null;
if (this._positionToInsert.root.rootName !== "$graveyard") insertionPosition = this._positionToInsert;
insertMedia(editor.model, url, insertionPosition, false);
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 media-embed/ui/mediaformview
*/
/**
* The media form view controller class.
*
* See {@link module:media-embed/ui/mediaformview~MediaFormView}.
*/
var MediaFormView = class extends View {
/**
* Tracks information about the DOM focus in the form.
*/
focusTracker;
/**
* An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}.
*/
keystrokes;
/**
* The URL input view.
*/
urlInputView;
/**
* An array of form validators used by {@link #isValid}.
*/
_validators;
/**
* The default info text for the {@link #urlInputView}.
*/
_urlInputViewInfoDefault;
/**
* The info text with an additional tip for the {@link #urlInputView},
* displayed when the input has some value.
*/
_urlInputViewInfoTip;
/**
* @param validators Form validators used by {@link #isValid}.
* @param locale The localization services instance.
*/
constructor(validators, locale) {
super(locale);
this.focusTracker = new FocusTracker();
this.keystrokes = new KeystrokeHandler();
this.set("mediaURLInputValue", "");
this.urlInputView = this._createUrlInput();
this._validators = validators;
this.setTemplate({
tag: "form",
attributes: {
class: [
"ck",
"ck-media-form",
"ck-responsive-form"
],
tabindex: "-1"
},
children: [this.urlInputView]
});
}
/**
* @inheritDoc
*/
render() {
super.render();
submitHandler({ view: this });
this.focusTracker.add(this.urlInputView.element);
this.keystrokes.listenTo(this.element);
}
/**
* @inheritDoc
*/
destroy() {
super.destroy();
this.focusTracker.destroy();
this.keystrokes.destroy();
}
/**
* Focuses the {@link #urlInputView}.
*/
focus() {
this.urlInputView.focus();
}
/**
* The native DOM `value` of the {@link #urlInputView} element.
*
* **Note**: Do not confuse it with the {@link module:ui/inputtext/inputtextview~InputTextView#value}
* which works one way only and may not represent the actual state of the component in the DOM.
*/
get url() {
return this.urlInputView.fieldView.element.value.trim();
}
set url(url) {
this.urlInputView.fieldView.value = url.trim();
}
/**
* 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.urlInputView.errorText = errorText;
return false;
}
}
return true;
}
/**
* Cleans up the supplementary error and information text of the {@link #urlInputView}
* bringing them back to the state when the form has been displayed for the first time.
*
* See {@link #isValid}.
*/
resetFormStatus() {
this.urlInputView.errorText = null;
this.urlInputView.infoText = this._urlInputViewInfoDefault;
}
/**
* Creates a labeled input view.
*
* @returns Labeled input view instance.
*/
_createUrlInput() {
const t = this.locale.t;
const labeledInput = new LabeledFieldView(this.locale, createLabeledInputText);
const inputField = labeledInput.fieldView;
this._urlInputViewInfoDefault = t("Paste the media URL in the input.");
this._urlInputViewInfoTip = t("Tip: Paste the URL into the content to embed faster.");
labeledInput.label = t("Media URL");
labeledInput.infoText = this._urlInputViewInfoDefault;
inputField.inputMode = "url";
inputField.on("input", () => {
labeledInput.infoText = inputField.element.value ? this._urlInputViewInfoTip : this._urlInputViewInfoDefault;
this.mediaURLInputValue = inputField.element.value.trim();
});
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
*/
/**
* @module media-embed/mediaembedui
*/
/**
* The media embed UI plugin.
*/
var MediaEmbedUI = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [MediaEmbedEditing, Dialog];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "MediaEmbedUI";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
_formView;
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
editor.ui.componentFactory.add("mediaEmbed", () => {
const t = this.editor.locale.t;
const button = this._createDialogButton(ButtonView);
button.tooltip = true;
button.label = t("Insert media");
return button;
});
editor.ui.componentFactory.add("menuBar:mediaEmbed", () => {
const t = this.editor.locale.t;
const button = this._createDialogButton(MenuBarMenuListItemButtonView);
button.label = t("Media");
return button;
});
}
/**
* Creates a button for menu bar that will show media embed dialog.
*/
_createDialogButton(ButtonClass) {
const editor = this.editor;
const buttonView = new ButtonClass(editor.locale);
const command = editor.commands.get("mediaEmbed");
const dialogPlugin = this.editor.plugins.get("Dialog");
buttonView.icon = IconMedia;
buttonView.bind("isEnabled").to(command, "isEnabled");
buttonView.on("execute", () => {
if (dialogPlugin.id === "mediaEmbed") dialogPlugin.hide();
else this._showDialog();
});
return buttonView;
}
_showDialog() {
const editor = this.editor;
const dialog = editor.plugins.get("Dialog");
const command = editor.commands.get("mediaEmbed");
const t = editor.locale.t;
const isMediaSelected = command.value !== void 0;
if (!this._formView) {
const registry = editor.plugins.get(MediaEmbedEditing).registry;
this._formView = new (CssTransitionDisablerMixin(MediaFormView))(getFormValidators$1(editor.t, registry), editor.locale);
this._formView.on("submit", () => this._handleSubmitForm());
}
dialog.show({
id: "mediaEmbed",
title: t("Media embed"),
content: this._formView,
isModal: true,
onShow: () => {
this._formView.url = command.value || "";
this._formView.resetFormStatus();
this._formView.urlInputView.fieldView.select();
},
actionButtons: [{
label: t("Cancel"),
withText: true,
onExecute: () => dialog.hide()
}, {
label: isMediaSelected ? t("Save") : t("Insert"),
class: "ck-button-action",
withText: true,
onExecute: () => this._handleSubmitForm()
}]
});
}
_handleSubmitForm() {
const editor = this.editor;
const dialog = editor.plugins.get("Dialog");
if (this._formView.isValid()) {
editor.execute("mediaEmbed", this._formView.url);
dialog.hide();
editor.editing.view.focus();
}
}
};
function getFormValidators$1(t, registry) {
return [(form) => {
if (!form.url.length) return t("The URL must not be empty.");
}, (form) => {
if (!registry.hasMedia(form.url)) return t("This media URL is not supported.");
}];
}
/**
* @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 media-embed/mediaembed
*/
/**
* The media embed plugin.
*
* For a detailed overview, check the {@glink features/media-embed/media-embed Media Embed feature documentation}.
*
* This is a "glue" plugin which loads the following plugins:
*
* * The {@link module:media-embed/mediaembedediting~MediaEmbedEditing media embed editing feature},
* * The {@link module:media-embed/mediaembedui~MediaEmbedUI media embed UI feature} and
* * The {@link module:media-embed/automediaembed~AutoMediaEmbed auto-media embed feature}.
*/
var MediaEmbed = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [
MediaEmbedEditing,
MediaEmbedUI,
AutoMediaEmbed,
Widget
];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "MediaEmbed";
}
/**
* @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 media-embed/mediaembedstyle/constants
*/
/**
* Built-in style options provided by the plugin. Integrators can refer to these by
* name in {@link module:media-embed/mediaembedconfig~MediaEmbedConfig#styles `config.mediaEmbed.styles`}
* to opt out, override individual fields, or coexist with custom styles.
*
* @internal
*/
const DEFAULT_OPTIONS = {
alignLeft: {
name: "alignLeft",
title: "Left aligned media",
icon: IconObjectInlineLeft,
className: "media-style-align-left"
},
alignBlockLeft: {
name: "alignBlockLeft",
title: "Left aligned media",
icon: IconObjectLeft,
className: "media-style-block-align-left"
},
alignCenter: {
name: "alignCenter",
title: "Centered media",
icon: IconObjectCenter,
isDefault: true
},
alignBlockRight: {
name: "alignBlockRight",
title: "Right aligned media",
icon: IconObjectRight,
className: "media-style-block-align-right"
},
alignRight: {
name: "alignRight",
title: "Right aligned media",
icon: IconObjectInlineRight,
className: "media-style-align-right"
}
};
/**
* Short icon-name aliases that can be used as the `icon` value in a media style
* option definition. Matches the alias set exposed by the image styles feature so
* the two APIs feel symmetrical.
*
* @internal
*/
const DEFAULT_ICONS = {
inlineLeft: IconObjectInlineLeft,
left: IconObjectLeft,
center: IconObjectCenter,
right: IconObjectRight,
inlineRight: IconObjectInlineRight
};
/**
* Built-in dropdown groupings. Each entry references built-in style component names. If any
* items are filtered out by configuration, the dropdown is rebuilt from the remaining names
* (or skipped entirely if fewer than two remain).
*
* @internal
*/
const DEFAULT_DROPDOWN_DEFINITIONS = [{
name: "mediaEmbed:wrapText",
title: "Wrap text",
items: ["mediaEmbed:alignLeft", "mediaEmbed:alignRight"],
defaultItem: "mediaEmbed:alignLeft"
}, {
name: "mediaEmbed:breakText",
title: "Break text",
items: [
"mediaEmbed:alignBlockLeft",
"mediaEmbed:alignCenter",
"mediaEmbed:alignBlockRight"
],
defaultItem: "mediaEmbed:alignCenter"
}];
/**
* @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 media-embed/mediaembedstyle/utils
*/
/**
* Normalizes the {@link module:media-embed/mediaembedconfig~MediaStyleConfig#options style options}
* provided by the integrator. Each entry is resolved into a full
* {@link module:media-embed/mediaembedconfig~MediaStyleOptionDefinition} and invalid entries
* are filtered out with a console warning.
*
* @internal
*/
function normalizeStyles(configuredStyles) {
return (configuredStyles.options || []).map((entry) => normalizeDefinition(entry)).filter((entry) => isValidOption(entry));
}
/**
* Resolves a single config entry into a style option definition. A string entry is first
* promoted to its object form (`{ name }`) and then shallow-merged on top of the matching
* built-in default — entries without a matching built-in pass through unchanged and are
* rejected by {@link ~isValidOption} if they lack required fields.
*
* Also resolves icon-name aliases (`'left'`, `'inlineLeft'`, etc.) to the corresponding
* SVG sources from {@link module:media-embed/mediaembedstyle/constants~DEFAULT_ICONS}.
*/
function normalizeDefinition(entry) {
const override = typeof entry === "string" ? { name: entry } : entry;
const definition = {
...DEFAULT_OPTIONS[override.name],
...override
};
if (typeof definition.icon === "string" && DEFAULT_ICONS[definition.icon]) definition.icon = DEFAULT_ICONS[definition.icon];
return definition;
}
/**
* Validates a normalized style option. `name`, `title`, and `icon` are always required.
* `className` is required unless the entry is the default style (defaults encode as
* attribute-absence and intentionally have no class). Emits a console warning and returns
* `false` when any of these checks fails.
*/
function isValidOption(option) {
if (!option.name || !option.title || !option.icon || !option.isDefault && !option.className) {
warnInvalidStyle({ style: option });
return false;
}
return true;
}
function warnInvalidStyle(info) {
/**
* The media style configuration provided in the editor config is invalid. The warning is
* emitted in two situations:
*
* * An entry in {@link module:media-embed/mediaembedconfig~MediaEmbedConfig#styles `config.mediaEmbed.styles.options`}
* does not reference a built-in style by name (`'alignLeft'`, `'alignBlockLeft'`,
* `'alignCenter'`, `'alignBlockRight'`, `'alignRight'`) and does not follow the
* {@link module:media-embed/mediaembedconfig~MediaStyleOptionDefinition} shape —
* `name`, `title`, `icon`, and (unless `isDefault: true`) `className` are required.
* The offending entry is reported under the `style` parameter.
* * A dropdown entry placed inline in
* {@link module:media-embed/mediaembedconfig~MediaEmbedConfig#toolbar `config.mediaEmbed.toolbar`}
* does not follow the {@link module:media-embed/mediaembedconfig~MediaStyleDropdownDefinition} shape
* (`name` and every `items[]` entry must use the full `mediaEmbed:` prefix, `defaultItem` must
* be one of the `items`, `title` must be a non-empty string), or its `items[]` reference styles
* that are not in the resolved
* {@link module:media-embed/mediaembedconfig~MediaEmbedConfig#styles `config.mediaEmbed.styles`} list.
* The offending entry is reported under the `dropdown` parameter.
*
* @error media-style-configuration-definition-invalid
*/
logWarning("media-style-configuration-definition-invalid", info);
}
/**
* Type guard for toolbar config entries shaped like a media style dropdown definition. The
* discriminator is `defaultItem` — generic toolbar groupings use `items` + `label` and never
* carry a `defaultItem` field.
*
* @internal
*/
function isMediaStyleDropdown(item) {
return typeof item === "object" && item !== null && typeof item.defaultItem === "string";
}
/**
* @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 media-embed/mediaembedtoolbar
*/
/**
* The media embed toolbar plugin. It creates a toolbar for media embed that shows up when the media element is selected.
*
* Instances of toolbar components (e.g. buttons) are created based on the
* {@link module:media-embed/mediaembedconfig~MediaEmbedConfig#toolbar `media.toolbar` configuration option}.
*/
var MediaEmbedToolbar = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [WidgetToolbarRepository];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "MediaEmbedToolbar";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
afterInit() {
const editor = this.editor;
const t = editor.t;
editor.plugins.get(WidgetToolbarRepository).register("mediaEmbed", {
ariaLabel: t("Media toolbar"),
items: normalizeDeclarativeConfig(editor.ui.componentFactory, editor.config.get("mediaEmbed.toolbar") || []),
getRelatedElement: getSelectedMediaViewWidget
});
}
};
/**
* Flattens dropdown definitions to their factory names, dropping any `mediaEmbed:`-prefixed
* name the style UI did not register — otherwise the toolbar crashes with `componentfactory-item-missing`.
* Non-string entries (e.g. generic `{ label, items }` toolbar groupings) pass through unchanged.
*/
function normalizeDeclarativeConfig(factory, config) {
return config.map((item) => isMediaStyleDropdown(item) ? item.name : item).filter((item) => typeof item !== "string" || !item.startsWith("mediaEmbed:") || factory.has(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 media-embed/mediaembedresize/resizemediaembedcommand
*/
/**
* The resize media embed command.
*/
var ResizeMediaEmbedCommand = class extends Command {
/**
* @inheritDoc
*/
refresh() {
const element = getSelectedMediaModelWidget(this.editor.model.document.selection);
this.isEnabled = !!element;
if (!element || !element.hasAttribute("resizedWidth")) this.value = null;
else this.value = element.getAttribute("resizedWidth");
}
/**
* Executes the command.
*
* ```ts
* // Sets the width as a percentage of the parent width:
* editor.execute( 'resizeMediaEmbed', { width: '50%' } );
*
* // Removes the resize and restores the default width:
* editor.execute( 'resizeMediaEmbed', { width: null } );
* ```
*
* @param options
* @param options.width The new width of the media embed as a CSS `width` value
* (e.g. `'50%'`), or `null` to remove the resize.
* @fires execute
*/
execute(options) {
const model = this.editor.model;
const mediaElement = getSelectedMediaModelWidget(model.document.selection);
if (mediaElement) model.change((writer) => {
writer.setAttribute("resizedWidth", options.width, mediaElement);
});
}
};
/**
* @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 media-embed/mediaembedresize/constants
*/
/**
* The view class applied to a resized media embed figure.
*
* Shared between the editing plugin (which toggles it via downcast of `resizedWidth` and consumes
* it on upcast) and the handles plugin (which adds it during drag and strips it on commit), so
* both layers agree on the exact class name.
*
* @internal
*/
const RESIZED_MEDIA_CLASS = "media_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
*/
/**
* The media embed resize editing feature.
*
* It adds the ability to resize each media embed using handles.
*/
var MediaEmbedResizeEditing = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [MediaEmbedEditing];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "MediaEmbedResizeEditing";
}
/**
* @inheritDoc
* @internal
*/
static get licenseFeatureCode() {
return "MER";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
static get isPremiumPlugin() {
return true;
}
/**
* @inheritDoc
*/
constructor(editor) {
super(editor);
editor.config.define("mediaEmbed", {
resizeUnit: "%",
resizeOptions: [
{
name: "resizeMediaEmbed:original",
value: null,
icon: "original"
},
{
name: "resizeMediaEmbed:custom",
value: "custom",
icon: "custom"
},
{
name: "resizeMediaEmbed:25",
value: "25",
icon: "small"
},
{
name: "resizeMediaEmbed:50",
value: "50",
icon: "medium"
},
{
name: "resizeMediaEmbed:75",
value: "75",
icon: "large"
}
]
});
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
editor.commands.add("resizeMediaEmbed", new ResizeMediaEmbedCommand(editor));
this._registerConverters();
}
/**
* @inheritDoc
*/
afterInit() {
this._registerSchema();
}
_registerSchema() {
const schema = this.editor.model.schema;
schema.extend("media", { allowAttributes: ["resizedWidth"] });
schema.setAttributeProperties("resizedWidth", { isFormatting: true });
}
/**
* Registers media embed resize converters.
*/
_registerConverters() {
const editor = this.editor;
editor.conversion.for("downcast").add((dispatcher) => dispatcher.on("attribute:resizedWidth:media", (evt, data, conversionApi) => {
if (!conversionApi.consumable.consume(data.item, evt.name)) return;
const viewWriter = conversionApi.writer;
const figure = conversionApi.mapper.toViewElement(data.item);
if (data.attributeNewValue !== null) {
viewWriter.setStyle("width", data.attributeNewValue, figure);
viewWriter.addClass(RESIZED_MEDIA_CLASS, figure);
} else {
viewWriter.removeStyle("width", figure);
viewWriter.removeClass(RESIZED_MEDIA_CLASS, figure);
}
}));
editor.conversion.for("upcast").attributeToAttribute({
view: {
name: "figure",
styles: { width: /.+/ }
},
model: {
key: "resizedWidth",
value: (viewElement) => {
if (!viewElement.hasClass("media")) return null;
return viewElement.getStyle("width");
}
}
});
editor.conversion.for("upcast").add((dispatcher) => {
dispatcher.on("element:figure", (evt, data, conversionApi) => {
conversionApi.consumable.consume(data.viewItem, { classes: [RESIZED_MEDIA_CLASS] });
});
});
}
};
/**
* @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 media embed resize by handles feature.
*
* It adds the ability to resize each media embed using handles.
*/
var MediaEmbedResizeHandles = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [WidgetResize];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "MediaEmbedResizeHandles";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
init() {
const command = this.editor.commands.get("resizeMediaEmbed");
this.bind("isEnabled").to(command);
this._setupResizerCreator();
}
/**
* Attaches a resizer to every newly inserted media widget. Walks only the ranges
* reported by the differ — never the whole document — so unrelated inserts (e.g.
* pressing Enter to create a paragraph) cost only the differ check.
*
* Each resizer's `isEnabled` is bound to the plugin in {@link #_attachResizer},
* so it auto-tracks the resize command's state.
*/
_setupResizerCreator() {
const editor = this.editor;
const model = editor.model;
const widgetResize = editor.plugins.get(WidgetResize);
this.listenTo(model.document, "change:data", () => {
for (const change of model.document.differ.getChanges()) {
if (change.type !== "insert" || change.name === "$text") continue;
const insertedRange = model.createRange(change.position, change.position.getShiftedBy(change.length));
for (const item of insertedRange.getItems()) {
if (!item.is("element", "media")) continue;
const viewElement = editor.editing.mapper.toViewElement(item);
/* v8 ignore next -- @preserve */
if (!viewElement) continue;
/* v8 ignore else -- @preserve */
if (!widgetResize.getResizerByViewElement(viewElement)) this._attachResizer(item, viewElement);
}
}
}, { priority: "low" });
}
/**
* Attaches a resizer to a single media widget.
*/
_attachResizer(modelElement, widgetView) {
const editor = this.editor;
const editingView = editor.editing.view;
const resizer = editor.plugins.get(WidgetResize).attachTo({
unit: editor.config.get("mediaEmbed.resizeUnit"),
modelElement,
viewElement: widgetView,
editor,
getHandleHost: (domWidgetElement) => domWidgetElement.querySelector(".ck-media__wrapper"),
getResizeHost: (domWidgetElement) => domWidgetElement,
onCommit: (newValue) => {
editingView.change((writer) => writer.removeClass(RESIZED_MEDIA_CLASS, widgetView));
editor.execute("resizeMediaEmbed", { width: newValue });
}
});
resizer.bind("isEnabled").to(this);
resizer.on("updateSize", () => {
if (!widgetView.hasClass("media_resized")) editingView.change((writer) => writer.addClass(RESIZED_MEDIA_CLASS, widgetView));
});
editingView.once("render", () => resizer.redraw());
}
};
/**
* @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 media-embed/mediaembedresize/mediaembedresizebuttons
*/
const RESIZE_ICONS = /* #__PURE__ */ (() => ({
small: IconObjectSizeSmall,
medium: IconObjectSizeMedium,
large: IconObjectSizeLarge,
custom: IconObjectSizeCustom,
original: IconObjectSizeFull
}))();
/**
* The media embed resize buttons plugin.
*
* It adds a possibility to resize media embeds using the toolbar dropdown or individual buttons,
* depending on the plugin configuration.
*/
var MediaEmbedResizeButtons = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [MediaEmbedResizeEditing];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "MediaEmbedResizeButtons";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
_resizeUnit;
/**
* @inheritDoc
*/
constructor(editor) {
super(editor);
this._resizeUnit = editor.config.get("mediaEmbed.resizeUnit");
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const options = editor.config.get("mediaEmbed.resizeOptions");
const command = editor.commands.get("resizeMediaEmbed");
this.bind("isEnabled").to(command);
for (const option of options) this._registerMediaEmbedResizeButton(option);
this._registerMediaEmbedResizeDropdown(options);
}
/**
* Creates a standalone button component for the given resize option.
*/
_registerMediaEmbedResizeButton(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("resizeMediaEmbed");
const labelText = this._getOptionLabelValue(option, true);
if (!RESIZE_ICONS[icon])
/**
* When configuring {@link module:media-embed/mediaembedconfig~MediaEmbedConfig#resizeOptions
* `config.mediaEmbed.resizeOptions`} for standalone buttons, a valid `icon` token must be set for each option.
*
* See all valid options described in the
* {@link module:media-embed/mediaembedconfig~MediaEmbedResizeOption plugin configuration}.
*
* @error mediaembedresizebuttons-missing-icon
* @param {module:media-embed/mediaembedconfig~MediaEmbedResizeOption} option Invalid media embed resize option.
*/
throw new CKEditorError("mediaembedresizebuttons-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("MediaEmbedCustomResizeUI") && isCustomMediaEmbedResizeOption(option)) {
const customResizeUI = editor.plugins.get("MediaEmbedCustomResizeUI");
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("resizeMediaEmbed", { width: optionValueWithUnit });
});
}
return button;
});
}
/**
* Creates the dropdown component containing all resize options.
*/
_registerMediaEmbedResizeDropdown(options) {
const editor = this.editor;
const t = editor.t;
const originalSizeOption = options.find((option) => !option.value);
const componentCreator = (locale) => {
const command = editor.commands.get("resizeMediaEmbed");
const dropdownView = createDropdown(locale, DropdownButtonView);
const dropdownButton = dropdownView.buttonView;
const accessibleLabel = t("Resize media");
dropdownButton.set({
tooltip: accessibleLabel,
commandValue: originalSizeOption ? originalSizeOption.value : null,
icon: RESIZE_ICONS.medium,
isToggleable: true,
label: originalSizeOption ? this._getOptionLabelValue(originalSizeOption) : "",
withText: true,
class: "ck-resize-media-embed-button",
ariaLabel: accessibleLabel,
ariaLabelledBy: void 0
});
dropdownButton.bind("label").to(command, "value", (commandValue) => {
if (commandValue) return commandValue;
return originalSizeOption ? this._getOptionLabelValue(originalSizeOption) : "";
});
dropdownView.bind("isEnabled").to(this);
addListToDropdown(dropdownView, () => this._getResizeDropdownListItemDefinitions(options, command), {
ariaLabel: t("Media 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("resizeMediaEmbed", componentCreator);
}
/**
* Returns a label for the given resize option.
*/
_getOptionLabelValue(option, forTooltip = false) {
const t = this.editor.t;
if (option.label) return option.label;
const isCustom = isCustomMediaEmbedResizeOption(option);
if (forTooltip) {
if (isCustom) return t("Custom media size");
return option.value ? t("Resize media to %0", option.value + this._resizeUnit) : t("Resize media to the original size");
}
if (isCustom) return t("Custom");
return option.value ? option.value + this._resizeUnit : t("Original");
}
/**
* Returns list item definitions for the resize dropdown.
*/
_getResizeDropdownListItemDefinitions(options, command) {
const { editor } = this;
const itemDefinitions = new Collection();
const optionsWithSerializedValues = options.map((option) => {
if (isCustomMediaEmbedResizeOption(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("MediaEmbedCustomResizeUI") && isCustomMediaEmbedResizeOption(option)) {
const customResizeUI = editor.plugins.get("MediaEmbedCustomResizeUI");
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: "resizeMediaEmbed",
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;
}
};
function isCustomMediaEmbedResizeOption(option) {
return option.value === "custom";
}
function getIsOnButtonCallback(value) {
return (commandValue, isEnabled) => {
if (commandValue === void 0 || !isEnabled) return false;
return commandValue === value;
};
}
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
*/
/**
* Finds model, view and DOM element for selected media embed element.
* Returns `null` if there is no media embed selected.
*
* @param editor Editor instance.
* @internal
*/
function getSelectedMediaEmbedEditorNodes(editor) {
const { editing } = editor;
const mediaModelElement = getSelectedMediaModelWidget(editor.model.document.selection);
if (!mediaModelElement) return null;
const mediaViewElement = editing.mapper.toViewElement(mediaModelElement);
return {
model: mediaModelElement,
view: mediaViewElement,
dom: editing.view.domConverter.mapViewToDom(mediaViewElement)
};
}
/**
* @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 media-embed/mediaembedresize/utils/getselectedmediaembedwidthinunits
*/
/**
* Returns media embed width in specified units after resize.
*
* * If no media embed is selected or command is disabled, `null` will be returned.
* * If `targetUnit` percentage is passed then it will return width percentage relative to its ancestor.
*
* @param editor Editor instance.
* @param targetUnit Unit in which dimension will be returned.
* @returns Parsed media embed width after resize (with unit).
* @internal
*/
function getSelectedMediaEmbedWidthInUnits(editor, targetUnit) {
const mediaNodes = getSelectedMediaEmbedEditorNodes(editor);
if (!mediaNodes) return null;
const parsedResizedWidth = _tryParseDimensionWithUnit(mediaNodes.model.getAttribute("resizedWidth") || null);
if (!parsedResizedWidth) return null;
if (parsedResizedWidth.unit === targetUnit) return parsedResizedWidth;
return _tryCastDimensionsToUnit(calculateResizeHostAncestorWidth(mediaNodes.dom), {
unit: "px",
value: new Rect(mediaNodes.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
*/
/**
* Returns the min and max resize values for the selected media embed in the specified unit.
*
* @param editor Editor instance.
* @param targetUnit Unit in which dimension will be returned.
* @returns Possible resize range in numeric form.
* @internal
*/
function getSelectedMediaEmbedPossibleResizeRange(editor, targetUnit) {
const mediaNodes = getSelectedMediaEmbedEditorNodes(editor);
if (!mediaNodes) return null;
const mediaParentWidthPx = calculateResizeHostAncestorWidth(mediaNodes.dom);
const minimumMediaWidth = _tryParseDimensionWithUnit(window.getComputedStyle(mediaNodes.dom).minWidth) || {
value: 1,
unit: "px"
};
return {
unit: targetUnit,
lower: Math.max(.1, _tryCastDimensionsToUnit(mediaParentWidthPx, minimumMediaWidth, targetUnit).value),
upper: targetUnit === "px" ? mediaParentWidthPx : 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 media-embed/mediaembedresize/ui/mediaembedcustomresizeformview
*/
/**
* The MediaEmbedCustomResizeFormView class.
*
* @internal
*/
var MediaEmbedCustomResizeFormView = 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-media-embed-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();
}
_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;
}
_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;
}
_createHeaderView() {
const t = this.locale.t;
const header = new FormHeaderView(this.locale, { label: t("Media Resize") });
header.children.add(this.backButtonView, 0);
return header;
}
_createLabeledInputView() {
const t = this.locale.t;
const labeledInput = new LabeledFieldView(this.locale, createLabeledInputNumber);
labeledInput.label = t("Resize media (in %0)", this.unit);
labeledInput.class = "ck-labeled-field-view_full-width";
labeledInput.fieldView.set({
min: .1,
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 error and information text of {@link #labeledInput}.
*/
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 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 media embed input size with unit.
* Returns `null` if value 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
*/
/**
* @module media-embed/mediaembedresize/mediaembedcustomresizeui
*/
/**
* The custom resize media embed UI plugin.
*
* The plugin uses the {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon}.
*/
var MediaEmbedCustomResizeUI = class extends Plugin {
/**
* The contextual balloon plugin instance.
*/
_balloon;
/**
* A form used to set the custom resize width.
*/
_form;
/**
* @inheritDoc
*/
static get requires() {
return [ContextualBalloon];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "MediaEmbedCustomResizeUI";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
destroy() {
super.destroy();
if (this._form) this._form.destroy();
}
/**
* Creates the {@link module:media-embed/mediaembedresize/ui/mediaembedcustomresizeformview~MediaEmbedCustomResizeFormView} form.
*/
_createForm(unit) {
const editor = this.editor;
this._balloon = this.editor.plugins.get("ContextualBalloon");
const FormViewClass = CssTransitionDisablerMixin(MediaEmbedCustomResizeFormView);
this._form = new FormViewClass(editor.locale, unit, getFormValidators(editor));
this._form.render();
this.listenTo(this._form, "submit", () => {
if (this._form.isValid()) {
editor.execute("resizeMediaEmbed", { 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();
/* v8 ignore else -- @preserve */
if (!this._isInBalloon) this._balloon.add({
view: this._form,
position: getBalloonPositionData(editor)
});
const currentParsedWidth = getSelectedMediaEmbedWidthInUnits(editor, unit);
const initialInputValue = currentParsedWidth ? currentParsedWidth.value.toFixed(1) : "";
const possibleRange = getSelectedMediaEmbedPossibleResizeRange(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);
}
};
function getBalloonPositionData(editor) {
const editingView = editor.editing.view;
const defaultPositions = BalloonPanelView.defaultPositions;
return {
target: editingView.domConverter.mapViewToDom(getSelectedMediaViewWidget(editingView.document.selection)),
positions: [
defaultPositions.northArrowSouth,
defaultPositions.northArrowSouthWest,
defaultPositions.northArrowSouthEast,
defaultPositions.southArrowNorth,
defaultPositions.southArrowNorthWest,
defaultPositions.southArrowNorthEast,
defaultPositions.viewportStickyNorth
]
};
}
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 media-embed/mediaembedresize
*/
/**
* The media embed resize plugin.
*
* It adds a possibility to resize each media embed using handles, toolbar buttons,
* or a balloon-hosted custom-width input.
*/
var MediaEmbedResize = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [
MediaEmbedResizeEditing,
MediaEmbedResizeHandles,
MediaEmbedCustomResizeUI,
MediaEmbedResizeButtons
];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "MediaEmbedResize";
}
/**
* @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 media-embed/mediaembedstyle/mediaembedstylecommand
*/
/**
* The media embed style command. It is used to apply a style option (e.g. an alignment) to a
* selected media embed.
*
* The set of accepted style values comes from the resolved
* {@link module:media-embed/mediaembedconfig~MediaEmbedConfig#styles `config.mediaEmbed.styles`}
* options. Values not in that set are silently rejected by {@link #execute}.
*/
var MediaEmbedStyleCommand = class extends Command {
/**
* Resolved styles indexed by `name`. Used to look up the `isDefault` flag at execute time
* (any default-marked style clears the attribute) and to validate that a requested style
* is part of the resolved options list.
*/
_styles;
/**
* The `name` of the first style with `isDefault: true` in the resolved options, or `null`
* when the integrator did not designate a default. Exposed via {@link #value} when the
* selected media has no `mediaStyle` attribute, so the default-state UI button can light up.
* Does not gate the "clear attribute" branch in {@link #execute} — that uses the per-style
* `isDefault` flag so multi-default configs behave consistently with the downcast.
*/
_defaultStyleName;
/**
* Creates an instance of the media embed style command.
*
* @param editor The editor instance.
* @param styles The resolved list of style options that this command will accept.
*/
constructor(editor, styles) {
super(editor);
this._styles = new Map(styles.map((style) => [style.name, style]));
const defaultStyle = styles.find((style) => style.isDefault);
this._defaultStyleName = defaultStyle ? defaultStyle.name : null;
}
/**
* @inheritDoc
*/
refresh() {
const element = getSelectedMediaModelWidget(this.editor.model.document.selection);
this.isEnabled = !!element;
if (!element) this.value = false;
else if (element.hasAttribute("mediaStyle")) {
const styleName = element.getAttribute("mediaStyle");
this.value = this._styles.has(styleName) ? styleName : this._defaultStyleName ?? false;
} else this.value = this._defaultStyleName ?? false;
}
/**
* Executes the command and applies the chosen style to the currently selected media embed.
*
* ```ts
* editor.execute( 'mediaStyle', { value: 'alignLeft' } );
* editor.execute( 'mediaStyle', { value: 'alignCenter' } ); // removes the attribute — alignCenter is the built-in default
* editor.execute( 'mediaStyle', { value: null } ); // removes the attribute
* ```
*
* The default style is encoded on the model as the absence of the `mediaStyle` attribute.
* Passing any `isDefault: true` style name (or `null`) therefore clears the attribute. Values
* that are neither falsy, an `isDefault` style, nor present in the resolved options list are
* silently rejected.
*
* @param options
* @param options.value The name of the style to apply, or `null` to clear the alignment.
* @fires execute
*/
execute(options) {
const model = this.editor.model;
const element = getSelectedMediaModelWidget(model.document.selection);
const requestedStyle = options.value;
if (!requestedStyle || this._styles.get(requestedStyle)?.isDefault) {
model.change((writer) => {
writer.removeAttribute("mediaStyle", element);
});
return;
}
if (!this._styles.has(requestedStyle)) return;
model.change((writer) => {
writer.setAttribute("mediaStyle", requestedStyle, element);
});
}
};
/**
* @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 media-embed/mediaembedstyle/mediaembedstyleediting
*/
/**
* The media embed style engine plugin. It extends the schema with the `mediaStyle` attribute,
* registers the {@link module:media-embed/mediaembedstyle/mediaembedstylecommand~MediaEmbedStyleCommand} command,
* and adds the converters that apply alignment CSS classes to the figure.
*/
var MediaEmbedStyleEditing = class extends Plugin {
/**
* The resolved list of media style options. Built once from
* {@link module:media-embed/mediaembedconfig~MediaEmbedConfig#styles `config.mediaEmbed.styles`}
* during {@link #init} and consumed by both the command and the UI plugin (single source of truth).
*
* @internal
* @readonly
*/
normalizedStyles;
/**
* @inheritDoc
*/
static get requires() {
return [MediaEmbedEditing];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "MediaEmbedStyleEditing";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const schema = editor.model.schema;
editor.config.define("mediaEmbed.styles", { options: Object.keys(DEFAULT_OPTIONS) });
this.normalizedStyles = normalizeStyles(editor.config.get("mediaEmbed.styles"));
schema.extend("media", { allowAttributes: ["mediaStyle"] });
schema.setAttributeProperties("mediaStyle", { isFormatting: true });
editor.commands.add("mediaStyle", new MediaEmbedStyleCommand(editor, this.normalizedStyles));
this._registerConverters();
}
/**
* Registers the downcast and upcast converters for the `mediaStyle` attribute.
*/
_registerConverters() {
const editor = this.editor;
const styleClassMap = new Map(this.normalizedStyles.filter((style) => !style.isDefault && style.className).map((style) => [style.name, style.className]));
editor.conversion.for("downcast").add((dispatcher) => dispatcher.on("attribute:mediaStyle:media", (evt, data, conversionApi) => {
if (!conversionApi.consumable.consume(data.item, evt.name)) return;
const figure = conversionApi.mapper.toViewElement(data.item);
const viewWriter = conversionApi.writer;
const oldClass = styleClassMap.get(data.attributeOldValue);
const newClass = styleClassMap.get(data.attributeNewValue);
if (oldClass) viewWriter.removeClass(oldClass, figure);
if (newClass) viewWriter.addClass(newClass, figure);
}));
editor.conversion.for("upcast").add((dispatcher) => {
dispatcher.on("element:figure", (_evt, data, conversionApi) => {
if (!data.modelRange) return;
const modelElement = first(data.modelRange.getItems());
if (!modelElement || !modelElement.is("element", "media")) return;
for (const [styleName, className] of styleClassMap) if (conversionApi.consumable.consume(data.viewItem, { classes: className })) conversionApi.writer.setAttribute("mediaStyle", styleName, modelElement);
}, { priority: "low" });
});
}
};
/**
* @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 media-embed/mediaembedstyle/mediaembedstyleui
*/
/**
* The media embed style UI plugin.
*
* It registers a button for every style in the resolved
* {@link module:media-embed/mediaembedconfig~MediaEmbedConfig#styles `config.mediaEmbed.styles`}
* list, and the default split-button dropdowns (`mediaEmbed:wrapText`, `mediaEmbed:breakText`)
* — filtered to the styles that survived configuration. The resulting components can be placed
* in the {@link module:media-embed/mediaembedconfig~MediaEmbedConfig#toolbar media embed toolbar}.
*/
var MediaEmbedStyleUI = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [MediaEmbedStyleEditing];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "MediaEmbedStyleUI";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
init() {
const titles = this._getLocalizedTitles();
for (const button of this._getButtonDefinitions(titles)) this._createButton(button);
for (const dropdown of this._getDropdownDefinitions(titles)) this._createDropdown(dropdown);
}
/**
* Returns the alignment button definitions sourced from the resolved options list.
*/
_getButtonDefinitions(titles) {
return this.editor.plugins.get(MediaEmbedStyleEditing).normalizedStyles.map((option) => ({
name: option.name,
label: titles[option.title] || option.title,
icon: option.icon
}));
}
/**
* Returns the localized titles of the built-in styles and dropdowns.
*/
_getLocalizedTitles() {
const t = this.editor.t;
return {
"Left aligned media": t("Left aligned media"),
"Centered media": t("Centered media"),
"Right aligned media": t("Right aligned media"),
"Wrap text": t("Wrap text"),
"Break text": t("Break text")
};
}
/**
* Returns the split-button dropdown definitions, filtered to the styles present in the
* resolved options list. Combines the {@link module:media-embed/mediaembedstyle/constants~DEFAULT_DROPDOWN_DEFINITIONS
* built-in dropdowns} with custom dropdowns declared inline in
* {@link module:media-embed/mediaembedconfig~MediaEmbedConfig#toolbar `config.mediaEmbed.toolbar`}.
*
* A dropdown with fewer than two items is skipped — a single-item dropdown carries no value
* over the flat button. If the configured `defaultItem` was filtered out, the first surviving
* item becomes the default.
*
* When a *custom* dropdown's items reference styles that are not in the resolved options list,
* a console warning is emitted (the integrator's config was not fully honored). Built-in
* dropdowns auto-skip silently — they are added by the plugin, not the integrator.
*/
_getDropdownDefinitions(titles) {
const editing = this.editor.plugins.get(MediaEmbedStyleEditing);
const availableComponentNames = new Set(editing.normalizedStyles.map(({ name }) => `mediaEmbed:${name}`));
const dropdowns = [];
const resolveDropdown = (definition, warnOnFilter) => {
const items = definition.items.filter((itemName) => availableComponentNames.has(itemName));
if (warnOnFilter && items.length !== definition.items.length) warnInvalidDropdown({ dropdown: definition });
if (items.length < 2) return;
const defaultItem = availableComponentNames.has(definition.defaultItem) ? definition.defaultItem : items[0];
dropdowns.push({
name: definition.name,
title: titles[definition.title] || definition.title,
items,
defaultItem
});
};
for (const definition of DEFAULT_DROPDOWN_DEFINITIONS) resolveDropdown(definition, false);
for (const definition of this._collectCustomDropdowns()) resolveDropdown(definition, true);
return dropdowns;
}
/**
* Scans `config.mediaEmbed.toolbar` for entries shaped like a dropdown definition
* (objects with both `items` and `defaultItem`) and returns the valid ones. `defaultItem`
* is the discriminator between our split-button dropdowns and generic toolbar groupings
* (which use `items` + `label` and have no `defaultItem`).
*
* Invalid entries (wrong name prefix, `defaultItem` missing from `items`) are warned and
* dropped here. Items that reference filtered-out styles are filtered later by
* {@link #_getDropdownDefinitions}, alongside the same logic that applies to built-in
* dropdowns.
*/
_collectCustomDropdowns() {
return (this.editor.config.get("mediaEmbed.toolbar") || []).filter((item) => isMediaStyleDropdown(item) && isValidCustomDropdown(item));
}
/**
* Registers a single alignment toggle button in the component factory.
*/
_createButton(definition) {
const editor = this.editor;
const componentName = `mediaEmbed:${definition.name}`;
editor.ui.componentFactory.add(componentName, (locale) => {
const command = editor.commands.get("mediaStyle");
const view = new ButtonView(locale);
view.set({
label: definition.label,
icon: definition.icon,
tooltip: true,
isToggleable: true
});
view.bind("isEnabled").to(command, "isEnabled");
view.bind("isOn").to(command, "value", (value) => value === definition.name);
view.on("execute", () => {
editor.execute("mediaStyle", { value: definition.name });
editor.editing.view.focus();
});
return view;
});
}
/**
* Registers a split-button dropdown grouping a set of alignment buttons. The action button
* reflects whichever child option is currently `isOn`, falling back to the dropdown's
* `defaultItem` when nothing is active.
*/
_createDropdown(definition) {
const editor = this.editor;
const factory = editor.ui.componentFactory;
factory.add(definition.name, (locale) => {
const buttonViews = definition.items.map((itemName) => factory.create(itemName));
const defaultButton = buttonViews[definition.items.indexOf(definition.defaultItem)];
const activeOrDefault = (...areOn) => {
const index = areOn.findIndex(Boolean);
return index < 0 ? defaultButton : buttonViews[index];
};
const dropdownView = createDropdown(locale, SplitButtonView);
const splitButtonView = dropdownView.buttonView;
addToolbarToDropdown(dropdownView, buttonViews, { enableActiveItemFocusOnDropdownOpen: true });
splitButtonView.set({
label: getDropdownButtonTitle(definition.title, defaultButton.label),
class: null,
tooltip: true
});
splitButtonView.arrowView.unbind("label");
splitButtonView.arrowView.set({ label: definition.title });
splitButtonView.bind("icon").toMany(buttonViews, "isOn", (...areOn) => activeOrDefault(...areOn).icon);
splitButtonView.bind("label").toMany(buttonViews, "isOn", (...areOn) => getDropdownButtonTitle(definition.title, activeOrDefault(...areOn).label));
splitButtonView.bind("isOn").toMany(buttonViews, "isOn", (...areOn) => areOn.some(Boolean));
splitButtonView.bind("class").toMany(buttonViews, "isOn", (...areOn) => areOn.some(Boolean) ? "ck-splitbutton_flatten" : void 0);
this.listenTo(splitButtonView, "execute", () => {
if (buttonViews.some(({ isOn }) => isOn)) dropdownView.isOpen = !dropdownView.isOpen;
else defaultButton.fire("execute");
});
dropdownView.bind("isEnabled").toMany(buttonViews, "isEnabled", (...areEnabled) => areEnabled.some(Boolean));
this.listenTo(dropdownView, "execute", () => {
editor.editing.view.focus();
});
return dropdownView;
});
}
};
/**
* Combines the dropdown title and the default action item label for the split-button label.
*/
function getDropdownButtonTitle(dropdownTitle, buttonTitle) {
return `${dropdownTitle}: ${buttonTitle}`;
}
/**
* Validates a user-supplied dropdown definition. Emits a console warning under
* `media-style-configuration-definition-invalid` and returns `false` when any of these rules is
* broken: `name` must start with `mediaEmbed:`, `title` must be a non-empty string, `items` must
* be non-empty and every entry must be a `mediaEmbed:`-prefixed string, and `defaultItem` must be
* one of the `items`.
*
* Item-membership against the resolved styles is checked separately, downstream, alongside the
* same logic that applies to built-in dropdowns.
*/
function isValidCustomDropdown(definition) {
const valid = definition.name.startsWith("mediaEmbed:") && typeof definition.title === "string" && definition.title.length > 0 && definition.items.length > 0 && definition.items.every((name) => typeof name === "string" && name.startsWith("mediaEmbed:")) && definition.items.includes(definition.defaultItem);
if (!valid) warnInvalidDropdown({ dropdown: definition });
return valid;
}
/**
* Emits a console warning under `media-style-configuration-definition-invalid` for an invalid
* or partially-honored dropdown definition. Called from {@link ~isValidCustomDropdown} for
* structural problems and from {@link MediaEmbedStyleUI#_getDropdownDefinitions} when items
* reference styles that are not in the resolved options list.
*/
function warnInvalidDropdown(info) {
logWarning("media-style-configuration-definition-invalid", info);
}
/**
* @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 media-embed/mediaembedstyle
*/
/**
* The media embed style plugin.
*
* This is a "glue" plugin which loads the following plugins:
* * {@link module:media-embed/mediaembedstyle/mediaembedstyleediting~MediaEmbedStyleEditing},
* * {@link module:media-embed/mediaembedstyle/mediaembedstyleui~MediaEmbedStyleUI}
*
* For a detailed overview, check the {@glink features/media-embed/media-embed-styles Media embed styles feature documentation}.
*/
var MediaEmbedStyle = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [MediaEmbedStyleEditing, MediaEmbedStyleUI];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "MediaEmbedStyle";
}
/**
* @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
*/
export { AutoMediaEmbed, MediaEmbed, MediaEmbedCommand, MediaEmbedCustomResizeUI, MediaEmbedEditing, MediaEmbedResize, MediaEmbedResizeButtons, MediaEmbedResizeEditing, MediaEmbedResizeHandles, MediaEmbedStyle, MediaEmbedStyleCommand, MediaEmbedStyleEditing, MediaEmbedStyleUI, MediaEmbedToolbar, MediaEmbedUI, MediaRegistry, ResizeMediaEmbedCommand, MediaFormView as _MediaFormView, createMediaFigureElement as _createMediaFigureElement, getSelectedMediaModelWidget as _getSelectedMediaModelWidget, getSelectedMediaViewWidget as _getSelectedMediaViewWidget, insertMedia as _insertMedia, isMediaWidget as _isMediaWidget, modelToViewUrlAttributeConverter as _modelToViewUrlAttributeMediaConverter, toMediaWidget as _toMediaWidget };
//# sourceMappingURL=index.js.map