UNPKG

@ckeditor/ckeditor5-media-embed

Version:

Media embed feature for CKEditor 5.

1,593 lines (1,575 loc) 88 kB
/** * @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";