diff --git a/packages/ckeditor5-media-embed/_src/automediaembed.js b/packages/ckeditor5-media-embed/_src/automediaembed.js new file mode 100644 index 00000000000..df73979a475 --- /dev/null +++ b/packages/ckeditor5-media-embed/_src/automediaembed.js @@ -0,0 +1,182 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module media-embed/automediaembed + */ + +import { Plugin } from 'ckeditor5/src/core'; +import { LiveRange, LivePosition } from 'ckeditor5/src/engine'; +import { Clipboard } from 'ckeditor5/src/clipboard'; +import { Delete } from 'ckeditor5/src/typing'; +import { Undo } from 'ckeditor5/src/undo'; +import { global } from 'ckeditor5/src/utils'; + +import MediaEmbedEditing from './mediaembedediting'; +import { insertMedia } from './utils'; + +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. + * + * @extends module:core/plugin~Plugin + */ +export default class AutoMediaEmbed extends Plugin { + /** + * @inheritDoc + */ + static get requires() { + return [ Clipboard, Delete, Undo ]; + } + + /** + * @inheritDoc + */ + static get pluginName() { + return 'AutoMediaEmbed'; + } + + /** + * @inheritDoc + */ + constructor( editor ) { + super( editor ); + + /** + * The paste–to–embed `setTimeout` ID. Stored as a property to allow + * cleaning of the timeout. + * + * @private + * @member {Number} #_timeoutId + */ + this._timeoutId = null; + + /** + * The position where the `` element will be inserted after the timeout, + * determined each time the new content is pasted into the document. + * + * @private + * @member {module:engine/model/liveposition~LivePosition} #_positionToInsert + */ + 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. + this.listenTo( editor.plugins.get( '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' } ); + } ); + + 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. + * + * @protected + * @param {module:engine/model/liveposition~LivePosition} leftPosition Left position of the selection. + * @param {module:engine/model/liveposition~LivePosition} 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; + + // 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 ); + } +} diff --git a/packages/ckeditor5-media-embed/_src/converters.js b/packages/ckeditor5-media-embed/_src/converters.js new file mode 100644 index 00000000000..99cd5fcb1e3 --- /dev/null +++ b/packages/ckeditor5-media-embed/_src/converters.js @@ -0,0 +1,60 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @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): + * + *
+ * + *
+ * + * or "non-semantic" (for the editing view pipeline): + * + *
+ *
[ non-semantic media preview for "foo" ]
+ *
+ * + * **Note:** Changing the model "url" attribute replaces the entire content of the + * `
` in the view. + * + * @param {module:media-embed/mediaregistry~MediaRegistry} registry The registry providing + * the media and their content. + * @param {Object} options + * @param {String} [options.elementName] When set, overrides the default element name for semantic media embeds. + * @param {String} [options.renderMediaPreview] When `true`, the converter will create the view in the non-semantic form. + * @param {String} [options.renderForEditingView] When `true`, the converter will create a view specific for the + * editing pipeline (e.g. including CSS classes, content placeholders). + * @returns {Function} + */ +export function modelToViewUrlAttributeConverter( registry, options ) { + return dispatcher => { + dispatcher.on( 'attribute:url:media', converter ); + }; + + function 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 ); + } +} diff --git a/packages/ckeditor5-media-embed/_src/index.js b/packages/ckeditor5-media-embed/_src/index.js new file mode 100644 index 00000000000..ecd4a3f22eb --- /dev/null +++ b/packages/ckeditor5-media-embed/_src/index.js @@ -0,0 +1,14 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module media-embed + */ + +export { default as MediaEmbed } from './mediaembed'; +export { default as MediaEmbedEditing } from './mediaembedediting'; +export { default as MediaEmbedUI } from './mediaembedui'; +export { default as AutoMediaEmbed } from './automediaembed'; +export { default as MediaEmbedToolbar } from './mediaembedtoolbar'; diff --git a/packages/ckeditor5-media-embed/_src/mediaembed.js b/packages/ckeditor5-media-embed/_src/mediaembed.js new file mode 100644 index 00000000000..6648ae53d35 --- /dev/null +++ b/packages/ckeditor5-media-embed/_src/mediaembed.js @@ -0,0 +1,292 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module media-embed/mediaembed + */ + +import { Plugin } from 'ckeditor5/src/core'; +import { Widget } from 'ckeditor5/src/widget'; + +import MediaEmbedEditing from './mediaembedediting'; +import AutoMediaEmbed from './automediaembed'; +import MediaEmbedUI from './mediaembedui'; + +import '../theme/mediaembed.css'; + +/** + * 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}. + * + * @extends module:core/plugin~Plugin + */ +export default class MediaEmbed extends Plugin { + /** + * @inheritDoc + */ + static get requires() { + return [ MediaEmbedEditing, MediaEmbedUI, AutoMediaEmbed, Widget ]; + } + + /** + * @inheritDoc + */ + static get pluginName() { + return 'MediaEmbed'; + } +} + +/** + * The media embed provider descriptor. Used in + * {@link module:media-embed/mediaembed~MediaEmbedConfig#providers `config.mediaEmbed.providers`} and + * {@link module:media-embed/mediaembed~MediaEmbedConfig#extraProviders `config.mediaEmbed.extraProviders`}. + * + * See {@link module:media-embed/mediaembed~MediaEmbedConfig} to learn more. + * + * { + * name: 'example', + * + * // The following RegExp matches https://www.example.com/media/{media id}, + * // (either with "http(s)://" and "www" or without), so the valid URLs are: + * // + * // * https://www.example.com/media/{media id}, + * // * http://www.example.com/media/{media id}, + * // * www.example.com/media/{media id}, + * // * example.com/media/{media id} + * url: /^example\.com\/media\/(\w+)/, + * + * // The rendering function of the provider. + * // Used to represent the media when editing the content (i.e. in the view) + * // and also in the data output of the editor if semantic data output is disabled. + * html: match => `The HTML representing the media with ID=${ match[ 1 ] }.` + * } + * + * You can allow any sort of media in the editor using the "allow–all" `RegExp`. + * But mind that, since URLs are processed in the order of configuration, if one of the previous + * `RegExps` matches the URL, it will have a precedence over this one. + * + * { + * name: 'allow-all', + * url: /^.+/ + * } + * + * To implement responsive media, you can use the following HTML structure: + * + * { + * ... + * html: match => + * '
' + + * '' + + * '
' + * } + * + * @typedef {Object} module:media-embed/mediaembed~MediaEmbedProvider + * @property {String} name The name of the provider. Used e.g. when + * {@link module:media-embed/mediaembed~MediaEmbedConfig#removeProviders removing providers}. + * @property {RegExp|Array.} url The `RegExp` object (or array of objects) defining the URL of the media. + * If any URL matches the `RegExp`, it becomes the media in the editor model, as defined by the provider. The result + * of matching (output of `String.prototype.match()`) is passed to the `html` rendering function of the media. + * + * **Note:** You do not need to include the protocol (`http://`, `https://`) and `www` subdomain in your `RegExps`, + * they are stripped from the URLs before matching anyway. + * @property {Function} [html] (optional) The rendering function of the media. The function receives the entire matching + * array from the corresponding `url` `RegExp` as an argument, allowing rendering a dedicated + * preview of the media identified by a certain ID or a hash. When not defined, the media embed feature + * will use a generic media representation in the view and output data. + * Note that when + * {@link module:media-embed/mediaembed~MediaEmbedConfig#previewsInData `config.mediaEmbed.previewsInData`} + * is `true`, the rendering function **will always** be used for the media in the editor data output. + */ + +/** + * The configuration of the {@link module:media-embed/mediaembed~MediaEmbed} feature. + * + * Read more in {@link module:media-embed/mediaembed~MediaEmbedConfig}. + * + * @member {module:media-embed/mediaembed~MediaEmbedConfig} module:core/editor/editorconfig~EditorConfig#mediaEmbed + */ + +/** + * The configuration of the media embed features. + * + * Read more about {@glink features/media-embed#configuration configuring the media embed feature}. + * + * ClassicEditor + * .create( editorElement, { + * mediaEmbed: ... // Media embed feature options. + * } ) + * .then( ... ) + * .catch( ... ); + * + * See {@link module:core/editor/editorconfig~EditorConfig all editor options}. + * + * @interface MediaEmbedConfig + */ + +/** + * The default media providers supported by the editor. + * + * The names of providers with rendering functions (previews): + * + * * "dailymotion", + * * "spotify", + * * "youtube", + * * "vimeo" + * + * The names of providers without rendering functions: + * + * * "instagram", + * * "twitter", + * * "googleMaps", + * * "flickr", + * * "facebook" + * + * See the {@link module:media-embed/mediaembed~MediaEmbedProvider provider syntax} to learn more about + * different kinds of media and media providers. + * + * **Note**: The default media provider configuration may not support all possible media URLs, + * only the most common are included. + * + * Media without rendering functions are always represented in the data using the "semantic" markup. See + * {@link module:media-embed/mediaembed~MediaEmbedConfig#previewsInData `config.mediaEmbed.previewsInData`} to + * learn more about possible data outputs. + * + * The priority of media providers corresponds to the order of configuration. The first provider + * to match the URL is always used, even if there are other providers that support a particular URL. + * The URL is never matched against the remaining providers. + * + * To discard **all** default media providers, simply override this configuration with your own + * {@link module:media-embed/mediaembed~MediaEmbedProvider definitions}: + * + * ClassicEditor + * .create( editorElement, { + * plugins: [ MediaEmbed, ... ], + * mediaEmbed: { + * providers: [ + * { + * name: 'myProvider', + * url: /^example\.com\/media\/(\w+)/, + * html: match => '...' + * }, + * ... + * ] + * } + * } ) + * .then( ... ) + * .catch( ... ); + * + * You can take inspiration from the default configuration of this feature which you can find in: + * https://github.com/ckeditor/ckeditor5-media-embed/blob/master/src/mediaembedediting.js + * + * To **extend** the list of default providers, use + * {@link module:media-embed/mediaembed~MediaEmbedConfig#extraProviders `config.mediaEmbed.extraProviders`}. + * + * To **remove** certain providers, use + * {@link module:media-embed/mediaembed~MediaEmbedConfig#removeProviders `config.mediaEmbed.removeProviders`}. + * + * @member {Array.} module:media-embed/mediaembed~MediaEmbedConfig#providers + */ + +/** + * The additional media providers supported by the editor. This configuration helps extend the default + * {@link module:media-embed/mediaembed~MediaEmbedConfig#providers}. + * + * ClassicEditor + * .create( editorElement, { + * plugins: [ MediaEmbed, ... ], + * mediaEmbed: { + * extraProviders: [ + * { + * name: 'extraProvider', + * url: /^example\.com\/media\/(\w+)/, + * html: match => '...' + * }, + * ... + * ] + * } + * } ) + * .then( ... ) + * .catch( ... ); + * + * See the {@link module:media-embed/mediaembed~MediaEmbedProvider provider syntax} to learn more. + * + * @member {Array.} module:media-embed/mediaembed~MediaEmbedConfig#extraProviders + */ + +/** + * The list of media providers that should not be used despite being available in + * {@link module:media-embed/mediaembed~MediaEmbedConfig#providers `config.mediaEmbed.providers`} and + * {@link module:media-embed/mediaembed~MediaEmbedConfig#extraProviders `config.mediaEmbed.extraProviders`} + * + * mediaEmbed: { + * removeProviders: [ 'youtube', 'twitter' ] + * } + * + * @member {Array.} module:media-embed/mediaembed~MediaEmbedConfig#removeProviders + */ + +/** + * Overrides the element name used for "semantic" data. + * + * This is not relevant if {@link module:media-embed/mediaembed~MediaEmbedConfig#previewsInData `config.mediaEmbed.previewsInData`} + * is set to `true`. + * + * When not set, the feature produces the `` tag: + * + *
+ * + *
+ * + * To override the element name with, for instance, the `o-embed` name: + * + * mediaEmbed: { + * elementName: 'o-embed' + * } + * + * This will produce semantic data with the `` tag: + * + *
+ * + *
+ * + * @default 'oembed' + * @member {String} [module:media-embed/mediaembed~MediaEmbedConfig#elementName] + */ + +/** + * Controls the data format produced by the feature. + * + * When `false` (default), the feature produces "semantic" data, i.e. it does not include the preview of + * the media, just the `` tag with the `url` attribute: + * + *
+ * + *
+ * + * When `true`, the media is represented in the output in the same way it looks in the editor, + * i.e. the media preview is saved to the database: + * + *
+ *
+ * + *
+ *
+ * + * **Note:** Media without preview are always represented in the data using the "semantic" markup + * regardless of the value of the `previewsInData`. Learn more about different kinds of media + * in the {@link module:media-embed/mediaembed~MediaEmbedConfig#providers `config.mediaEmbed.providers`} + * configuration description. + * + * @member {Boolean} [module:media-embed/mediaembed~MediaEmbedConfig#previewsInData=false] + */ diff --git a/packages/ckeditor5-media-embed/_src/mediaembedcommand.js b/packages/ckeditor5-media-embed/_src/mediaembedcommand.js new file mode 100644 index 00000000000..c17ddf81680 --- /dev/null +++ b/packages/ckeditor5-media-embed/_src/mediaembedcommand.js @@ -0,0 +1,87 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module media-embed/mediaembedcommand + */ + +import { Command } from 'ckeditor5/src/core'; +import { findOptimalInsertionRange } from 'ckeditor5/src/widget'; +import { getSelectedMediaModelWidget, insertMedia } from './utils'; + +/** + * 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: + * + * editor.execute( 'mediaEmbed', 'http://url.to.the/media' ); + * + * @extends module:core/command~Command + */ +export default 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' ) : null; + + 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 {String} 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 table is allowed in the parent. +// +// @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection +// @param {module:engine/model/model~Model} model +// @returns {Boolean} +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. +// +// @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection +// @returns {Boolean} +function isMediaSelected( selection ) { + const element = selection.getSelectedElement(); + return !!element && element.name === 'media'; +} diff --git a/packages/ckeditor5-media-embed/_src/mediaembedediting.js b/packages/ckeditor5-media-embed/_src/mediaembedediting.js new file mode 100644 index 00000000000..17d52e3139c --- /dev/null +++ b/packages/ckeditor5-media-embed/_src/mediaembedediting.js @@ -0,0 +1,280 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module media-embed/mediaembedediting + */ + +import { Plugin } from 'ckeditor5/src/core'; +import { first } from 'ckeditor5/src/utils'; + +import { modelToViewUrlAttributeConverter } from './converters'; +import MediaEmbedCommand from './mediaembedcommand'; +import MediaRegistry from './mediaregistry'; +import { toMediaWidget, createMediaFigureElement } from './utils'; + +import '../theme/mediaembedediting.css'; + +/** + * The media embed editing feature. + * + * @extends module:core/plugin~Plugin + */ +export default class MediaEmbedEditing extends Plugin { + /** + * @inheritDoc + */ + static get pluginName() { + return 'MediaEmbedEditing'; + } + + /** + * @inheritDoc + */ + constructor( editor ) { + super( editor ); + + editor.config.define( 'mediaEmbed', { + elementName: 'oembed', + providers: [ + { + name: 'dailymotion', + url: /^dailymotion\.com\/video\/(\w+)/, + html: match => { + const id = match[ 1 ]; + + return ( + '
' + + `' + + '
' + ); + } + }, + + { + 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 ( + '
' + + `' + + '
' + ); + } + }, + + { + name: 'youtube', + url: [ + /^(?:m\.)?youtube\.com\/watch\?v=([\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 ( + '
' + + `' + + '
' + ); + } + }, + + { + 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 ( + '
' + + `' + + '
' + ); + } + }, + + { + name: 'instagram', + url: /^instagram\.com\/p\/(\w+)/ + }, + { + name: 'twitter', + url: /^twitter\.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/ + } + ] + } ); + + /** + * The media registry managing the media providers in the editor. + * + * @member {module:media-embed/mediaregistry~MediaRegistry} #registry + */ + 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 } ); + } + } + } ) + // 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 } ); + } + } + } ) + // Consume `
` elements, that were left after upcast. + .add( dispatcher => { + dispatcher.on( 'element:figure', converter ); + + function 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' } ); + } + } + } ); + } +} diff --git a/packages/ckeditor5-media-embed/_src/mediaembedtoolbar.js b/packages/ckeditor5-media-embed/_src/mediaembedtoolbar.js new file mode 100644 index 00000000000..0a9db20d75e --- /dev/null +++ b/packages/ckeditor5-media-embed/_src/mediaembedtoolbar.js @@ -0,0 +1,61 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module media-embed/mediaembedtoolbar + */ + +import { Plugin } from 'ckeditor5/src/core'; +import { WidgetToolbarRepository } from 'ckeditor5/src/widget'; + +import { getSelectedMediaViewWidget } from './utils'; + +/** + * 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/mediaembed~MediaEmbedConfig#toolbar `media.toolbar` configuration option}. + * + * @extends module:core/plugin~Plugin + */ +export default class MediaEmbedToolbar extends Plugin { + /** + * @inheritDoc + */ + static get requires() { + return [ WidgetToolbarRepository ]; + } + + /** + * @inheritDoc + */ + static get pluginName() { + return 'MediaEmbedToolbar'; + } + + /** + * @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 + } ); + } +} + +/** + * Items to be placed in the media embed toolbar. + * This option requires adding {@link module:media-embed/mediaembedtoolbar~MediaEmbedToolbar} to the plugin list. + * + * Read more about configuring toolbar in {@link module:core/editor/editorconfig~EditorConfig#toolbar}. + * + * @member {Array.} module:media-embed/mediaembed~MediaEmbedConfig#toolbar + */ diff --git a/packages/ckeditor5-media-embed/_src/mediaembedui.js b/packages/ckeditor5-media-embed/_src/mediaembedui.js new file mode 100644 index 00000000000..2db166030ae --- /dev/null +++ b/packages/ckeditor5-media-embed/_src/mediaembedui.js @@ -0,0 +1,127 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module media-embed/mediaembedui + */ + +import { Plugin } from 'ckeditor5/src/core'; +import { createDropdown } from 'ckeditor5/src/ui'; + +import MediaFormView from './ui/mediaformview'; +import MediaEmbedEditing from './mediaembedediting'; +import mediaIcon from '../theme/icons/media.svg'; + +/** + * The media embed UI plugin. + * + * @extends module:core/plugin~Plugin + */ +export default class MediaEmbedUI extends Plugin { + /** + * @inheritDoc + */ + static get requires() { + return [ MediaEmbedEditing ]; + } + + /** + * @inheritDoc + */ + static get pluginName() { + return 'MediaEmbedUI'; + } + + /** + * @inheritDoc + */ + init() { + const editor = this.editor; + const command = editor.commands.get( 'mediaEmbed' ); + + editor.ui.componentFactory.add( 'mediaEmbed', locale => { + const dropdown = createDropdown( locale ); + + this._setUpDropdown( dropdown, command ); + + return dropdown; + } ); + } + + /** + * @private + * @param {module:ui/dropdown/dropdownview~DropdownView} dropdown + * @param {module:media-embed/mediaembedcommand~MediaEmbedCommand} command + */ + _setUpDropdown( dropdown, command ) { + const editor = this.editor; + const t = editor.t; + const button = dropdown.buttonView; + const registry = editor.plugins.get( MediaEmbedEditing ).registry; + + dropdown.once( 'change:isOpen', () => { + const form = new MediaFormView( getFormValidators( editor.t, registry ), editor.locale ); + + dropdown.panelView.children.add( form ); + + // Note: Use the low priority to make sure the following listener starts working after the + // default action of the drop-down is executed (i.e. the panel showed up). Otherwise, the + // invisible form/input cannot be focused/selected. + button.on( 'open', () => { + form.disableCssTransitions(); + + // Make sure that each time the panel shows up, the URL field remains in sync with the value of + // the command. If the user typed in the input, then canceled (`urlInputView#fieldView#value` stays + // unaltered) and re-opened it without changing the value of the media command (e.g. because they + // didn't change the selection), they would see the old value instead of the actual value of the + // command. + form.url = command.value || ''; + form.urlInputView.fieldView.select(); + form.enableCssTransitions(); + }, { priority: 'low' } ); + + dropdown.on( 'submit', () => { + if ( form.isValid() ) { + editor.execute( 'mediaEmbed', form.url ); + editor.editing.view.focus(); + } + } ); + + dropdown.on( 'change:isOpen', () => form.resetFormStatus() ); + dropdown.on( 'cancel', () => { + editor.editing.view.focus(); + } ); + + form.delegate( 'submit', 'cancel' ).to( dropdown ); + form.urlInputView.bind( 'value' ).to( command, 'value' ); + + // Form elements should be read-only when corresponding commands are disabled. + form.urlInputView.bind( 'isReadOnly' ).to( command, 'isEnabled', value => !value ); + } ); + + dropdown.bind( 'isEnabled' ).to( command ); + + button.set( { + label: t( 'Insert media' ), + icon: mediaIcon, + tooltip: true + } ); + } +} + +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.' ); + } + } + ]; +} diff --git a/packages/ckeditor5-media-embed/_src/mediaregistry.js b/packages/ckeditor5-media-embed/_src/mediaregistry.js new file mode 100644 index 00000000000..44818e45ca4 --- /dev/null +++ b/packages/ckeditor5-media-embed/_src/mediaregistry.js @@ -0,0 +1,335 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module media-embed/mediaregistry + */ + +import { IconView, Template } from 'ckeditor5/src/ui'; +import { logWarning, toArray } from 'ckeditor5/src/utils'; + +import mediaPlaceholderIcon from '../theme/icons/media-placeholder.svg'; + +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. + */ +export default class MediaRegistry { + /** + * Creates an instance of the {@link module:media-embed/mediaregistry~MediaRegistry} class. + * + * @param {module:utils/locale~Locale} locale The localization services instance. + * @param {module:media-embed/mediaembed~MediaEmbedConfig} 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 ); + } ); + + /** + * The {@link module:utils/locale~Locale} instance. + * + * @member {module:utils/locale~Locale} + */ + this.locale = locale; + + /** + * The media provider definitions available for the registry. Usually corresponding with the + * {@link module:media-embed/mediaembed~MediaEmbedConfig media configuration}. + * + * @member {Array} + */ + this.providerDefinitions = providerDefinitions; + } + + /** + * Checks whether the passed URL is representing a certain media type allowed in the editor. + * + * @param {String} url The URL to be checked + * @returns {Boolean} + */ + 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 {module:engine/view/downcastwriter~DowncastWriter} writer The view writer used to produce a view element. + * @param {String} url The URL to be translated into a view element. + * @param {Object} options + * @param {String} [options.elementName] + * @param {Boolean} [options.renderMediaPreview] + * @param {Boolean} [options.renderForEditingView] + * @returns {module:engine/view/element~Element} + */ + getMediaViewElement( writer, url, options ) { + return this._getMedia( url ).getViewElement( writer, options ); + } + + /** + * Returns a `Media` instance for the given URL. + * + * @protected + * @param {String} url The URL of the media. + * @returns {module:media-embed/mediaregistry~Media|null} 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`. + * + * @private + * @param {String} url The URL of the media. + * @param {RegExp} pattern The pattern that should accept the media URL. + * @returns {Array|null} + */ + _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. + * + * @private + */ +class Media { + constructor( locale, url, match, previewRenderer ) { + /** + * The URL this Media instance represents. + * + * @member {String} + */ + this.url = this._getValidUrl( url ); + + /** + * Shorthand for {@link module:utils/locale~Locale#t}. + * + * @see module:utils/locale~Locale#t + * @method + */ + this._locale = locale; + + /** + * The output of the `RegExp.match` which validated the {@link #url} of this media. + * + * @member {Object} + */ + this._match = match; + + /** + * The function returning the HTML string preview of this media. + * + * @member {Function} + */ + this._previewRenderer = previewRenderer; + } + + /** + * Returns the view element representation of the media. + * + * @param {module:engine/view/downcastwriter~DowncastWriter} writer The view writer used to produce a view element. + * @param {Object} options + * @param {String} [options.elementName] + * @param {Boolean} [options.renderMediaPreview] + * @param {Boolean} [options.renderForEditingView] + * @returns {module:engine/view/element~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. + * + * @param {module:engine/view/downcastwriter~DowncastWriter} writer The view writer used to produce a view element. + * @param {Object} options + * @param {Boolean} [options.renderForEditingView] + * @returns {String} + */ + _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. + * + * @returns {String} + */ + _getPlaceholderHtml() { + const icon = new IconView(); + const t = this._locale.t; + + icon.content = mediaPlaceholderIcon; + 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 {String} url The URL of the media. + * @returns {String|null} + */ + _getValidUrl( url ) { + if ( !url ) { + return null; + } + + if ( url.match( /^https?/ ) ) { + return url; + } + + return 'https://' + url; + } +} diff --git a/packages/ckeditor5-media-embed/_src/ui/mediaformview.js b/packages/ckeditor5-media-embed/_src/ui/mediaformview.js new file mode 100644 index 00000000000..9cc337a9ac8 --- /dev/null +++ b/packages/ckeditor5-media-embed/_src/ui/mediaformview.js @@ -0,0 +1,352 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module media-embed/ui/mediaformview + */ + +import { + ButtonView, + FocusCycler, + LabeledFieldView, + View, + ViewCollection, + createLabeledInputText, + injectCssTransitionDisabler, + submitHandler +} from 'ckeditor5/src/ui'; +import { FocusTracker, KeystrokeHandler } from 'ckeditor5/src/utils'; +import { icons } from 'ckeditor5/src/core'; + +// See: #8833. +// eslint-disable-next-line ckeditor5-rules/ckeditor-imports +import '@ckeditor/ckeditor5-ui/theme/components/responsive-form/responsiveform.css'; +import '../../theme/mediaform.css'; + +/** + * The media form view controller class. + * + * See {@link module:media-embed/ui/mediaformview~MediaFormView}. + * + * @extends module:ui/view~View + */ +export default class MediaFormView extends View { + /** + * @param {Array.} validators Form validators used by {@link #isValid}. + * @param {module:utils/locale~Locale} [locale] The localization services instance. + */ + constructor( validators, locale ) { + super( locale ); + + const t = locale.t; + + /** + * Tracks information about the DOM focus in the form. + * + * @readonly + * @member {module:utils/focustracker~FocusTracker} + */ + this.focusTracker = new FocusTracker(); + + /** + * An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}. + * + * @readonly + * @member {module:utils/keystrokehandler~KeystrokeHandler} + */ + this.keystrokes = new KeystrokeHandler(); + + /** + * The value of the URL input. + * + * @member {String} #mediaURLInputValue + * @observable + */ + this.set( 'mediaURLInputValue', '' ); + + /** + * The URL input view. + * + * @member {module:ui/labeledfield/labeledfieldview~LabeledFieldView} + */ + this.urlInputView = this._createUrlInput(); + + /** + * The Save button view. + * + * @member {module:ui/button/buttonview~ButtonView} + */ + this.saveButtonView = this._createButton( t( 'Save' ), icons.check, 'ck-button-save' ); + this.saveButtonView.type = 'submit'; + this.saveButtonView.bind( 'isEnabled' ).to( this, 'mediaURLInputValue', value => !!value ); + + /** + * The Cancel button view. + * + * @member {module:ui/button/buttonview~ButtonView} + */ + this.cancelButtonView = this._createButton( t( 'Cancel' ), icons.cancel, 'ck-button-cancel', 'cancel' ); + + /** + * A collection of views that can be focused in the form. + * + * @readonly + * @protected + * @member {module:ui/viewcollection~ViewCollection} + */ + this._focusables = new ViewCollection(); + + /** + * Helps cycling over {@link #_focusables} in the form. + * + * @readonly + * @protected + * @member {module:ui/focuscycler~FocusCycler} + */ + this._focusCycler = new FocusCycler( { + focusables: this._focusables, + focusTracker: this.focusTracker, + keystrokeHandler: this.keystrokes, + actions: { + // Navigate form fields backwards using the Shift + Tab keystroke. + focusPrevious: 'shift + tab', + + // Navigate form fields forwards using the Tab key. + focusNext: 'tab' + } + } ); + + /** + * An array of form validators used by {@link #isValid}. + * + * @readonly + * @protected + * @member {Array.} + */ + this._validators = validators; + + this.setTemplate( { + tag: 'form', + + attributes: { + class: [ + 'ck', + 'ck-media-form', + 'ck-responsive-form' + ], + + tabindex: '-1' + }, + + children: [ + this.urlInputView, + this.saveButtonView, + this.cancelButtonView + ] + } ); + + // When rewriting to typescript try to change it to CssTransitionDisablerMixin. + injectCssTransitionDisabler( this ); + + /** + * The default info text for the {@link #urlInputView}. + * + * @private + * @member {String} #_urlInputViewInfoDefault + */ + + /** + * The info text with an additional tip for the {@link #urlInputView}, + * displayed when the input has some value. + * + * @private + * @member {String} #_urlInputViewInfoTip + */ + } + + /** + * @inheritDoc + */ + render() { + super.render(); + + submitHandler( { + view: this + } ); + + const childViews = [ + this.urlInputView, + this.saveButtonView, + this.cancelButtonView + ]; + + childViews.forEach( v => { + // Register the view as focusable. + this._focusables.add( v ); + + // Register the view in the focus tracker. + this.focusTracker.add( v.element ); + } ); + + // Start listening for the keystrokes coming from #element. + this.keystrokes.listenTo( this.element ); + + const stopPropagation = data => data.stopPropagation(); + + // Since the form is in the dropdown panel which is a child of the toolbar, the toolbar's + // keystroke handler would take over the key management in the URL input. We need to prevent + // this ASAP. Otherwise, the basic caret movement using the arrow keys will be impossible. + this.keystrokes.set( 'arrowright', stopPropagation ); + this.keystrokes.set( 'arrowleft', stopPropagation ); + this.keystrokes.set( 'arrowup', stopPropagation ); + this.keystrokes.set( 'arrowdown', stopPropagation ); + + // Intercept the `selectstart` event, which is blocked by default because of the default behavior + // of the DropdownView#panelView. + // TODO: blocking `selectstart` in the #panelView should be configurable per–drop–down instance. + this.listenTo( this.urlInputView.element, 'selectstart', ( evt, domEvt ) => { + domEvt.stopPropagation(); + }, { priority: 'high' } ); + } + + /** + * @inheritDoc + */ + destroy() { + super.destroy(); + + this.focusTracker.destroy(); + this.keystrokes.destroy(); + } + + /** + * Focuses the fist {@link #_focusables} in the form. + */ + focus() { + this._focusCycler.focusFirst(); + } + + /** + * 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. + * + * @type {String} + */ + get url() { + return this.urlInputView.fieldView.element.value.trim(); + } + + set url( url ) { + this.urlInputView.fieldView.element.value = url.trim(); + } + + /** + * Validates the form and returns `false` when some fields are invalid. + * + * @returns {Boolean} + */ + 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. + * + * @private + * @returns {module:ui/labeledfield/labeledfieldview~LabeledFieldView} 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.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; + } + + /** + * Creates a button view. + * + * @private + * @param {String} label The button label. + * @param {String} icon The button icon. + * @param {String} className The additional button CSS class name. + * @param {String} [eventName] An event name that the `ButtonView#execute` event will be delegated to. + * @returns {module:ui/button/buttonview~ButtonView} The button view instance. + */ + _createButton( label, icon, className, eventName ) { + const button = new ButtonView( this.locale ); + + button.set( { + label, + icon, + tooltip: true + } ); + + button.extendTemplate( { + attributes: { + class: className + } + } ); + + if ( eventName ) { + button.delegate( 'execute' ).to( this, eventName ); + } + + return button; + } +} + +/** + * Fired when the form view is submitted (when one of the children triggered the submit event), + * e.g. click on {@link #saveButtonView}. + * + * @event submit + */ + +/** + * Fired when the form view is canceled, e.g. by a click on {@link #cancelButtonView}. + * + * @event cancel + */ diff --git a/packages/ckeditor5-media-embed/_src/utils.js b/packages/ckeditor5-media-embed/_src/utils.js new file mode 100644 index 00000000000..ac3518560ca --- /dev/null +++ b/packages/ckeditor5-media-embed/_src/utils.js @@ -0,0 +1,122 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module media-embed/utils + */ + +import { isWidget, toWidget } from 'ckeditor5/src/widget'; + +/** + * 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 {module:engine/view/element~Element} viewElement + * @param {module:engine/view/downcastwriter~DowncastWriter} writer An instance of the view writer. + * @param {String} label The element's label. + * @returns {module:engine/view/element~Element} + */ +export 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. + * + * @param {module:engine/view/selection~Selection|module:engine/view/documentselection~DocumentSelection} selection + * @returns {module:engine/view/element~Element|null} + */ +export 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. + * + * @param {module:engine/view/element~Element} viewElement + * @returns {Boolean} + */ +export function isMediaWidget( viewElement ) { + return !!viewElement.getCustomProperty( 'media' ) && isWidget( viewElement ); +} + +/** + * Creates a view element representing the media. Either a "semantic" one for the data pipeline: + * + *
+ * + *
+ * + * or a "non-semantic" (for the editing view pipeline): + * + *
+ *
[ non-semantic media preview for "foo" ]
+ *
+ * + * @param {module:engine/view/downcastwriter~DowncastWriter} writer + * @param {module:media-embed/mediaregistry~MediaRegistry} registry + * @param {String} url + * @param {Object} options + * @param {String} [options.elementName] + * @param {Boolean} [options.useSemanticWrapper] + * @param {Boolean} [options.renderForEditingView] + * @returns {module:engine/view/containerelement~ContainerElement} + */ +export 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. + * + * @param {module:engine/model/selection~Selection} selection + * @returns {module:engine/model/element~Element|null} + */ +export 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 {module:engine/model/model~Model} model + * @param {String} url An URL of an embeddable media. + * @param {module:engine/model/range~Range} [insertRange] The range to insert the media. If not specified, + * the default behavior of {@link module:engine/model/model~Model#insertContent `model.insertContent()`} will + * be applied. + * @param {Boolean} findOptimalPosition If true it will try to find optimal position to insert media without breaking content + * in which a selection is. + */ +export function insertMedia( model, url, selectable, findOptimalPosition ) { + model.change( writer => { + const mediaElement = writer.createElement( 'media', { url } ); + + model.insertObject( mediaElement, selectable, null, { + setSelection: 'on', + findOptimalPosition + } ); + } ); +}