@ckeditor/ckeditor5-media-embed
Version:
Media embed feature for CKEditor 5.
1,593 lines (1,575 loc) • 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 }) => {
return toMediaWidget(createMediaFigureElement(writer, registry, modelElement.getAttribute("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";