@ckeditor/ckeditor5-media-embed
Version:
Media embed feature for CKEditor 5.
1,126 lines (1,114 loc) • 41.9 kB
JavaScript
/**
* @license Copyright (c) 2003-2025, 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/dist/index.js';
import { toWidget, isWidget, findOptimalInsertionRange, Widget, WidgetToolbarRepository } from '@ckeditor/ckeditor5-widget/dist/index.js';
import { logWarning, toArray, first, global, FocusTracker, KeystrokeHandler } from '@ckeditor/ckeditor5-utils/dist/index.js';
import { IconView, Template, View, submitHandler, LabeledFieldView, createLabeledInputText, Dialog, ButtonView, MenuBarMenuListItemButtonView, CssTransitionDisablerMixin } from '@ckeditor/ckeditor5-ui/dist/index.js';
import { IconMediaPlaceholder, IconMedia } from '@ckeditor/ckeditor5-icons/dist/index.js';
import { LivePosition, LiveRange } from '@ckeditor/ckeditor5-engine/dist/index.js';
import { Clipboard } from '@ckeditor/ckeditor5-clipboard/dist/index.js';
import { Delete } from '@ckeditor/ckeditor5-typing/dist/index.js';
import { Undo } from '@ckeditor/ckeditor5-undo/dist/index.js';
/**
* @license Copyright (c) 2003-2025, 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/converters
*/ /**
* 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).
*/ 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'));
// TODO: removing the wrapper and creating it from scratch is a hack. We can do better than that.
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);
};
}
/**
* Converts a given {@link module:engine/view/element~Element} to a media embed widget:
* * Adds a {@link module:engine/view/element~Element#_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.
*/ 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.
*/ 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.
*/ 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>
* ```
*/ 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.
*/ 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.
*/ 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' : undefined
});
});
}
/**
* 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' );
* ```
*/ class MediaEmbedCommand extends Command {
/**
* @inheritDoc
*/ refresh() {
const model = this.editor.model;
const selection = model.document.selection;
const selectedMedia = getSelectedMediaModelWidget(selection);
this.value = selectedMedia ? selectedMedia.getAttribute('url') : undefined;
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) {
const insertionRange = findOptimalInsertionRange(selection, model);
let parent = insertionRange.start.parent;
// The model.insertContent() will remove empty parent (unless it is a $root or a limit).
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';
}
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~Element view elements}.
*
* Mostly used by the {@link module:media-embed/mediaembedediting~MediaEmbedEditing} plugin.
*/ class MediaRegistry {
/**
* 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~Element 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) {
// 1. Try to match without stripping the protocol and "www" subdomain.
let match = url.match(pattern);
if (match) {
return match;
}
// 2. Try to match after stripping the protocol.
let rawUrl = url.replace(/^https?:\/\//, '');
match = rawUrl.match(pattern);
if (match) {
return match;
}
// 3. Try to match after stripping the "www" subdomain.
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~Element view element} and used in the editing or data pipeline.
*/ class Media {
/**
* 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 {
// The placeholder only makes sense for editing view and media which have URLs.
// Placeholder is never displayed in data and URL-less media have no content.
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;
const placeholder = 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();
return placeholder.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;
}
}
/**
* The media embed editing feature.
*/ class MediaEmbedEditing 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)=>{
const id = match[1];
return '<div style="position: relative; padding-bottom: 100%; height: 0; ">' + `<iframe src="https://www.dailymotion.com/embed/video/${id}" ` + 'style="position: absolute; width: 100%; height: 100%; top: 0; left: 0;" ' + 'frameborder="0" width="480" height="270" 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];
return '<div style="position: relative; padding-bottom: 100%; height: 0; padding-bottom: 126%;">' + `<iframe src="https://open.spotify.com/embed/${id}" ` + 'style="position: absolute; width: 100%; height: 100%; top: 0; left: 0;" ' + '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 style="position: relative; padding-bottom: 100%; height: 0; padding-bottom: 56.2493%;">' + `<iframe src="https://www.youtube.com/embed/${id}${time ? `?start=${time}` : ''}" ` + 'style="position: absolute; width: 100%; height: 100%; top: 0; left: 0;" ' + 'frameborder="0" allow="autoplay; encrypted-media" 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)=>{
const id = match[1];
return '<div style="position: relative; padding-bottom: 100%; height: 0; padding-bottom: 56.2493%;">' + `<iframe src="https://player.vimeo.com/video/${id}" ` + 'style="position: absolute; width: 100%; height: 100%; top: 0; left: 0;" ' + '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));
// Configure the schema.
schema.register('media', {
inheritAllFrom: '$blockObject',
allowAttributes: [
'url'
]
});
// Model -> Data
conversion.for('dataDowncast').elementToStructure({
model: 'media',
view: (modelElement, { writer })=>{
const url = modelElement.getAttribute('url');
return createMediaFigureElement(writer, registry, url, {
elementName,
renderMediaPreview: !!url && renderMediaPreview
});
}
});
// Model -> Data (url -> data-oembed-url)
conversion.for('dataDowncast').add(modelToViewUrlAttributeConverter(registry, {
elementName,
renderMediaPreview
}));
// Model -> View (element)
conversion.for('editingDowncast').elementToStructure({
model: 'media',
view: (modelElement, { writer })=>{
const url = modelElement.getAttribute('url');
const figure = createMediaFigureElement(writer, registry, url, {
elementName,
renderForEditingView: true
});
return toMediaWidget(figure, writer, t('media widget'));
}
});
// Model -> View (url -> data-oembed-url)
conversion.for('editingDowncast').add(modelToViewUrlAttributeConverter(registry, {
elementName,
renderForEditingView: true
}));
// View -> Model (data-oembed-url -> url)
conversion.for('upcast')// Upcast semantic media.
.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;
}
})// Upcast non-semantic media.
.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;
}
})// Consume `<figure class="media">` elements, that were left after upcast.
.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;
const modelElement = first(modelRange.getItems());
if (!modelElement) {
// Revert consumed figure so other features can convert it.
conversionApi.consumable.revert(data.viewItem, {
name: true,
classes: 'media'
});
}
};
dispatcher.on('element:figure', converter);
});
}
}
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.
*/ class AutoMediaEmbed 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;
// We need to listen on `Clipboard#inputTransformation` because we need to save positions of selection.
// After pasting, the content between those positions will be checked for a URL that could be transformed
// into media.
const clipboardPipeline = editor.plugins.get('ClipboardPipeline');
this.listenTo(clipboardPipeline, 'inputTransformation', ()=>{
const firstRange = modelDocument.selection.getFirstRange();
const leftLivePosition = LivePosition.fromPosition(firstRange.start);
leftLivePosition.stickiness = 'toPrevious';
const rightLivePosition = LivePosition.fromPosition(firstRange.end);
rightLivePosition.stickiness = 'toNext';
modelDocument.once('change:data', ()=>{
this._embedMediaBetweenPositions(leftLivePosition, rightLivePosition);
leftLivePosition.detach();
rightLivePosition.detach();
}, {
priority: 'high'
});
});
const undoCommand = editor.commands.get('undo');
undoCommand.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;
// TODO: Use marker instead of LiveRange & LivePositions.
const urlRange = new LiveRange(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 the URL does not match to universal URL regexp, let's skip that.
if (!url.match(URL_REGEXP)) {
urlRange.detach();
return;
}
// If the URL represents a media, let's use it.
if (!mediaRegistry.hasMedia(url)) {
urlRange.detach();
return;
}
const mediaEmbedCommand = editor.commands.get('mediaEmbed');
// Do not anything if media element cannot be inserted at the current position (#47).
if (!mediaEmbedCommand.isEnabled) {
urlRange.detach();
return;
}
// Position won't be available in the `setTimeout` function so let's clone it.
this._positionToInsert = LivePosition.fromPosition(leftPosition);
// This action mustn't be executed if undo was called between pasting and auto-embedding.
this._timeoutId = global.window.setTimeout(()=>{
editor.model.change((writer)=>{
this._timeoutId = null;
writer.remove(urlRange);
urlRange.detach();
let insertionPosition = null;
// Check if position where the media element should be inserted is still valid.
// Otherwise leave it as undefined to use document.selection - default behavior of model.insertContent().
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);
}
}
/**
* The media form view controller class.
*
* See {@link module:media-embed/ui/mediaformview~MediaFormView}.
*/ class MediaFormView 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
});
// Register the view in the focus tracker.
this.focusTracker.add(this.urlInputView.element);
// Start listening for the keystrokes coming from #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);
// One error per field is enough.
if (errorText) {
// Apply updated error.
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', ()=>{
// Display the tip text only when there is some value. Otherwise fall back to the default info text.
labeledInput.infoText = inputField.element.value ? this._urlInputViewInfoTip : this._urlInputViewInfoDefault;
this.mediaURLInputValue = inputField.element.value.trim();
});
return labeledInput;
}
}
/**
* The media embed UI plugin.
*/ class MediaEmbedUI 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 !== undefined;
if (!this._formView) {
const registry = editor.plugins.get(MediaEmbedEditing).registry;
this._formView = new (CssTransitionDisablerMixin(MediaFormView))(getFormValidators(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(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.');
}
}
];
}
/**
* The media embed plugin.
*
* For a detailed overview, check the {@glink features/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}.
*/ class MediaEmbed extends Plugin {
/**
* @inheritDoc
*/ static get requires() {
return [
MediaEmbedEditing,
MediaEmbedUI,
AutoMediaEmbed,
Widget
];
}
/**
* @inheritDoc
*/ static get pluginName() {
return 'MediaEmbed';
}
/**
* @inheritDoc
*/ static get isOfficialPlugin() {
return true;
}
}
/**
* 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}.
*/ class MediaEmbedToolbar 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;
const widgetToolbarRepository = editor.plugins.get(WidgetToolbarRepository);
widgetToolbarRepository.register('mediaEmbed', {
ariaLabel: t('Media toolbar'),
items: editor.config.get('mediaEmbed.toolbar') || [],
getRelatedElement: getSelectedMediaViewWidget
});
}
}
export { AutoMediaEmbed, MediaEmbed, MediaEmbedEditing, MediaEmbedToolbar, MediaEmbedUI };
//# sourceMappingURL=index.js.map