8
0
Просмотр исходного кода

Merge pull request #2 from ckeditor/t/1

Feature: The basic media embed feature. Closes #1.
Piotrek Koszuliński 7 лет назад
Родитель
Сommit
0ed8a8070a
31 измененных файлов с 3792 добавлено и 0 удалено
  1. 0 0
      packages/ckeditor5-media-embed/docs/_snippets/features/build-media-source.html
  2. 13 0
      packages/ckeditor5-media-embed/docs/_snippets/features/build-media-source.js
  3. 36 0
      packages/ckeditor5-media-embed/docs/_snippets/features/media-embed.html
  4. 28 0
      packages/ckeditor5-media-embed/docs/_snippets/features/media-embed.js
  5. 30 0
      packages/ckeditor5-media-embed/docs/api/media-embed.md
  6. 313 0
      packages/ckeditor5-media-embed/docs/features/media-embed.md
  7. 6 0
      packages/ckeditor5-media-embed/package.json
  8. 66 0
      packages/ckeditor5-media-embed/src/converters.js
  9. 246 0
      packages/ckeditor5-media-embed/src/mediaembed.js
  10. 78 0
      packages/ckeditor5-media-embed/src/mediaembedcommand.js
  11. 226 0
      packages/ckeditor5-media-embed/src/mediaembedediting.js
  12. 116 0
      packages/ckeditor5-media-embed/src/mediaembedui.js
  13. 287 0
      packages/ckeditor5-media-embed/src/mediaregistry.js
  14. 306 0
      packages/ckeditor5-media-embed/src/ui/mediaformview.js
  15. 85 0
      packages/ckeditor5-media-embed/src/utils.js
  16. 112 0
      packages/ckeditor5-media-embed/tests/insertmediacommand.js
  17. 54 0
      packages/ckeditor5-media-embed/tests/manual/mediaembed.html
  18. 24 0
      packages/ckeditor5-media-embed/tests/manual/mediaembed.js
  19. 47 0
      packages/ckeditor5-media-embed/tests/manual/mediaembed.md
  20. 54 0
      packages/ckeditor5-media-embed/tests/manual/semanticmediaembed.html
  21. 27 0
      packages/ckeditor5-media-embed/tests/manual/semanticmediaembed.js
  22. 47 0
      packages/ckeditor5-media-embed/tests/manual/semanticmediaembed.md
  23. 54 0
      packages/ckeditor5-media-embed/tests/mediaembed.js
  24. 927 0
      packages/ckeditor5-media-embed/tests/mediaembedediting.js
  25. 225 0
      packages/ckeditor5-media-embed/tests/mediaembedui.js
  26. 293 0
      packages/ckeditor5-media-embed/tests/ui/mediaformview.js
  27. 1 0
      packages/ckeditor5-media-embed/theme/icons/media-placeholder.svg
  28. 1 0
      packages/ckeditor5-media-embed/theme/icons/media.svg
  29. 20 0
      packages/ckeditor5-media-embed/theme/icons/media/twitter.svg
  30. 53 0
      packages/ckeditor5-media-embed/theme/mediaembedediting.css
  31. 17 0
      packages/ckeditor5-media-embed/theme/mediaform.css

+ 0 - 0
packages/ckeditor5-media-embed/docs/_snippets/features/build-media-source.html


+ 13 - 0
packages/ckeditor5-media-embed/docs/_snippets/features/build-media-source.js

@@ -0,0 +1,13 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals window */
+
+import ClassicEditor from '@ckeditor/ckeditor5-build-classic/src/ckeditor';
+import MediaEmbed from '@ckeditor/ckeditor5-media-embed/src/mediaembed';
+
+ClassicEditor.builtinPlugins.push( MediaEmbed );
+
+window.ClassicEditor = ClassicEditor;

+ 36 - 0
packages/ckeditor5-media-embed/docs/_snippets/features/media-embed.html

@@ -0,0 +1,36 @@
+<div id="snippet-media-embed">
+	<h3>YouTube</h3>
+
+	<figure class="media">
+		<oembed url="https://www.youtube.com/watch?v=DgM5DfAPQTI"></oembed>
+	</figure>
+
+	<h3>Vimeo</h3>
+
+	<figure class="media">
+		<oembed url="https://vimeo.com/1084537"></oembed>
+	</figure>
+
+	<h3>Twitter</h3>
+
+	<figure>
+		<oembed url="https://twitter.com/ckeditor/status/1027571541989572612"></oembed>
+	</figure>
+
+	<h3>Google Maps</h3>
+
+	<figure class="media">
+		<oembed url="https://www.google.com/maps/place/Poland/@51.9537505,19.1343786,6z/data=!3m1!4b1!4m5!3m4!1s0x47009964a4640bbb:0x97573ca49cc55ea!8m2!3d51.919438!4d19.145136?hl=en"></oembed>
+	</figure>
+</div>
+
+<style>
+	.ck.ck-content .media iframe {
+		/* Override default Umberto guide styles. */
+		margin: 0;
+	}
+
+	input.example-input {
+		min-width: 600px;
+	}
+</style>

+ 28 - 0
packages/ckeditor5-media-embed/docs/_snippets/features/media-embed.js

@@ -0,0 +1,28 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals ClassicEditor, console, window, document */
+
+import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config';
+
+ClassicEditor
+	.create( document.querySelector( '#snippet-media-embed' ), {
+		cloudServices: CS_CONFIG,
+		toolbar: {
+			items: [
+				'mediaEmbed', '|', 'heading', '|', 'bold', 'italic', '|', 'undo', 'redo'
+			],
+			viewportTopOffset: 60
+		},
+		mediaEmbed: {
+			semanticDataOutput: true
+		}
+	} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 30 - 0
packages/ckeditor5-media-embed/docs/api/media-embed.md

@@ -0,0 +1,30 @@
+---
+category: api-reference
+---
+
+# Media embed feature for CKEditor 5
+
+[![npm version](https://badge.fury.io/js/%40ckeditor%2Fckeditor5-media-embed.svg)](https://www.npmjs.com/package/@ckeditor/ckeditor5-media-embed)
+
+This package implements the media embed feature for CKEditor 5.
+
+## Documentation
+
+See the {@link features/media-embed Media embed feature} guide and the {@link module:media-embed/mediaembed~MediaEmbed} plugin documentation.
+
+## Installation
+
+```bash
+npm install --save @ckeditor/ckeditor5-media-embed
+```
+
+## Contribute
+
+The source code of this package is available on GitHub in https://github.com/ckeditor/ckeditor5-media-embed.
+
+## External links
+
+* [`@ckeditor/ckeditor5-media-embed` on npm](https://www.npmjs.com/package/@ckeditor/ckeditor5-media-embed)
+* [`ckeditor/ckeditor5-media-embed` on GitHub](https://github.com/ckeditor/ckeditor5-media-embed)
+* [Issue tracker](https://github.com/ckeditor/ckeditor5-media-embed/issues)
+* [Changelog](https://github.com/ckeditor/ckeditor5-media-embed/blob/master/CHANGELOG.md)

+ 313 - 0
packages/ckeditor5-media-embed/docs/features/media-embed.md

@@ -0,0 +1,313 @@
+---
+category: features
+---
+
+{@snippet features/build-media-source}
+
+# Media embed
+
+The {@link module:media-embed/mediaembed~MediaEmbed} feature brings support for embeddable media (such as YouTube videos or tweets) in the editor content.
+
+## Demo
+
+Example URLs:
+
+* <input class="example-input" type="text" value="https://www.youtube.com/watch?v=H08tGjXNHO4">
+* <input class="example-input" type="text" value="https://open.spotify.com/album/2IXlgvecaDqOeF3viUZnPI?si=ogVw7KlcQAGZKK4Jz9QzvA">
+* <input class="example-input" type="text" value="https://www.instagram.com/p/BmMZgokAGGQ/?taken-by=nasa">
+
+{@snippet features/media-embed}
+
+## Installation
+
+To add this feature to your editor, install the [`@ckeditor/ckeditor5-media-embed`](https://www.npmjs.com/package/@ckeditor/ckeditor5-media-embed) package:
+
+```bash
+npm install --save @ckeditor/ckeditor5-media-embed
+```
+
+Then add `'MediaEmbed'` to your plugin list and {@link module:media-embed/mediaembed~MediaEmbedConfig configure} the feature:
+
+```js
+import MediaEmbed from '@ckeditor/ckeditor5-media-embed/src/mediaembed';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ MediaEmbed, ... ],
+		toolbar: [ 'mediaEmbed', ... ]
+		mediaEmbed: {
+			// configuration...
+		}
+	} )
+	.then( ... )
+	.catch( ... );
+```
+
+<info-box>
+	Depending on how you will configure this feature, you may need to use services like [Iframely](http://iframe.ly/) or [Embedly](https://embed.ly/) to display content of embedded media on your target website. Read more about [displaying embedded media](#displaying-embedded-media-on-your-website).
+</info-box>
+
+## Configuration
+
+### Output type
+
+The data output format of the feature can be configured using the {@link module:media-embed/mediaembed~MediaEmbedConfig#semanticDataOutput `config.mediaEmbed.semanticDataOutput`} option.
+
+#### Non–semantic output
+
+By default the media embed feature outputs media in the same way it works in the editor, this is, if this media is "previewable", the media preview (HTML) is saved to the database:
+
+```html
+<figure class="media">
+	<div data-oembed-url="https://media-url">
+		<iframe src="https://media-preview-url"></iframe>
+	</div>
+</figure>
+```
+
+Currently, the preview is only available for content providers for which CKEditor 5 can predict an `<iframe>` code – this is YouTube, Vimeo, Dailymotion, Spotify, etc. For other providers like Twitter or Instagram the editor cannot produce an `<iframe>` code and it does not, so far, allows retriving this code from an external oEmbed service. Therefore, for non previewable media it produces the semantic output:
+
+```html
+<figure class="media">
+	<oembed url="https://media-url"></oembed>
+</figure>
+```
+
+This means that, unless you [limited the list of providers](#media-providers) to only those which are previewable, you need to [make sure that media are displayed on your website](#displaying-embedded-media-on-your-website).
+
+#### Semantic output
+
+Optionally, by setting `mediaEmbed.semanticDataOutput` to `true` you can configure the media embed feature to output semantic `<oembed>` tags for previewable and non-previewable media. This option works best when the application processes (expands) the media on the server–side or [directly in the front–end](#displaying-embedded-media-on-your-website), preserving the versatile database representation:
+
+```html
+<figure class="media">
+	<oembed url="https://media-url"></oembed>
+</figure>
+```
+
+<info-box info>
+	This option does not change how media are displayed inside the editor – the previewable ones will still be displayed with previews.
+</info-box>
+
+### Media providers
+
+CKEditor comes with several supported media providers which can be extended or altered.
+
+Names of providers **with previews**:
+
+* `'dailymotion'`,
+* `'spotify'`,
+* `'youtube'`,
+* `'vimeo'`
+
+Names of providers **without previews**:
+
+* `'instagram'`,
+* `'twitter'`,
+* `'googleMaps'`,
+* `'flickr'`,
+* `'facebook'`
+
+<info-box notice>
+	The default media provider configuration does not support all possible media URLs, only the most common are included. Services like Iframely or Embedly support thousands of media providers and it is up to you to define which you want to allow.
+</info-box>
+
+#### Extending
+
+To extend the default list of default providers, use {@link module:media-embed/mediaembed~MediaEmbedConfig#extraProviders `config.mediaEmbed.extraProviders`}.
+
+#### Removing
+
+To remove certain providers, use {@link module:media-embed/mediaembed~MediaEmbedConfig#removeProviders `config.mediaEmbed.removeProviders`}.
+
+For instance, to leave only the previewable providers use this
+
+```js
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ MediaEmbed, ... ],
+		toolbar: [ 'mediaEmbed', ... ]
+		mediaEmbed: {
+			removeProviders: [ 'instagram', 'twitter', 'googleMaps', 'flickr', 'facebook' ]
+		}
+	} )
+	.then( ... )
+	.catch( ... );
+```
+
+#### Overriding
+
+To override the default providers, use {@link module:media-embed/mediaembed~MediaEmbedConfig#providers `config.mediaEmbed.providers`} and define your set according to the {@link module:media-embed/mediaembed~MediaEmbedProvider provider syntax}:
+
+```js
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ MediaEmbed, ... ],,
+		toolbar: [ 'mediaEmbed', ... ]
+		mediaEmbed: {
+			providers: [
+				{
+					// An URL regexp or array of URL regexps:
+					url: /^(https:\/\/)?(www\.)?example\.com\/media\/(\w+)/,
+
+					// To be defined only if the media is previewable:
+					html: mediaId => '...'
+				},
+				...
+			]
+		}
+	} )
+	.then( ... )
+	.catch( ... );
+```
+
+## Displaying embedded media on your website
+
+The media embed feature produces output that may not contain previews of some embedded media. That happens for all media types when the feature is configured to produce a [semantic output](#semantic-output) and for non-previewable media in the default configuration. That means that you need to transform the output `<oembed>` elements into real media on your target website.
+
+There are many ways to do that. The simplest, plug-and-play solutions are described here. You can also implement this transformation as part of your backend service and you can use different services than described in this section.
+
+### Iframely
+
+[Iframely](https://iframely.com) offers the [embed.js](https://iframely.com/docs/embedjs) library which converts [various media](https://iframely.com/docs/providers) URLs into rich previews. It works in the front–end and remains fully compatible with the output produced by CKEditor.
+
+First, having [secured the API key](https://iframely.com/docs/allow-origins), load the `embed.js` library from the CDN into your website:
+
+```html
+<head>
+	...
+	<script charset="utf-8" src="//cdn.iframe.ly/embed.js?api_key={API KEY}"></script>
+	...
+</head>
+```
+
+#### Semantic data
+
+You can convert all `<oembed>` elements like the following Twitter post produced by CKEditor 5:
+
+```html
+<figure class="media">
+	<oembed url="https://twitter.com/ckeditor/status/1021777799844126720"></oembed>
+</figure>
+```
+
+using this short code snippet:
+
+```html
+<script>
+	document.querySelectorAll( 'oembed[url]' ).forEach( element => {
+		iframely.load( element, element.attributes.url.value );
+	} );
+</script>
+```
+
+#### Non-semantic data
+
+Additionally, despite the fact that the media preview is included for some media types (unless you [configured the media embed feature otherwise](#semantic-output)), you can still use Iframely for media embeds like the following one:
+
+```html
+<figure class="media">
+	<div data-oembed-url="https://twitter.com/ckeditor/status/1021777799844126720">
+		[Media preview]
+	</div>
+</figure>
+```
+
+The above data can still be converted by Iframely with just a few extra lines of code. To do that, in addition to the code snippet from the previous section, use a slightly longer code snippet which discards the media preview saved in the database before using `iframely.load()`:
+
+```html
+<script>
+	document.querySelectorAll( 'div[data-oembed-url]' ).forEach( element => {
+		// Discard the static media preview from the database (empty the <div data-oembed-url="...">).
+		while ( element.firstChild ) {
+			element.removeChild( element.firstChild );
+		}
+
+		// Generate the media preview using Iframely.
+		iframely.load( element, element.dataset.oembedUrl ) ;
+	} );
+</script>
+```
+
+### Embedly
+
+Just like Iframely, [Embedly](https://embed.ly) offers the client–side API which converts media URLs into rich previews.
+
+To start using it, load the library from the CDN into your website:
+
+```html
+<head>
+	...
+	<script async charset="utf-8" src="//cdn.embedly.com/widgets/platform.js"></script>
+	...
+</head>
+```
+
+#### Semantic data
+
+You can convert `<oembed>` elements like the following Twitter post produced by CKEditor 5:
+
+```html
+<figure class="media">
+	<oembed url="https://twitter.com/ckeditor/status/1021777799844126720"></oembed>
+</figure>
+```
+
+using this code snippet:
+
+```html
+<script>
+	document.querySelectorAll( 'oembed[url]' ).forEach( element => {
+		// Create the <a href="..." class="embedly-card"></a> element that Embedly uses
+		// to discover the media.
+		const anchor = document.createElement( 'a' );
+
+		anchor.setAttribute( 'href', element.getAttribute( 'url ') );
+		anchor.className = 'embedly-card';
+
+		element.appendChild( anchor );
+	} );
+</script>
+```
+
+Embedly automatically discovers links like `<a href="..." class="embedly-card"></a>` and replaces them with rich media previews.
+
+#### Non-semantic data
+
+In this case, the code is almost the same as with the semantic data but you should discard the media preview saved in the database before using Embedly to avoid code duplication:
+
+```html
+<script>
+	document.querySelectorAll( 'div[data-oembed-url]' ).forEach( element => {
+		// Discard the static media preview from the database (empty the <div data-oembed-url="...">).
+		while ( element.firstChild ) {
+			element.removeChild( element.firstChild );
+		}
+
+		// Create the <a href="..." class="embedly-card"></a> element that Embedly uses
+		// to discover the media.
+		const anchor = document.createElement( 'a' );
+
+		anchor.setAttribute( 'href', element.dataset.oembedUrl );
+		anchor.className = 'embedly-card';
+
+		element.appendChild( anchor );
+	} );
+</script>
+```
+
+## Common API
+
+The {@link module:media-embed/mediaembed~MediaEmbed} plugin registers:
+* the `'mediaEmbed'` UI button component,
+* the `'mediaEmbed'` command implemented by {@link module:media-embed/mediaembedcommand~MediaEmbedCommand}.
+
+	You can insert a new media or update the selected media URL by executing the following code:
+
+	```js
+	editor.execute( 'mediaEmbed', { url: 'http://url.to.the/media' } );
+	```
+
+## Contribute
+
+The source code of the feature is available on GitHub in https://github.com/ckeditor/ckeditor5-media-embed.

+ 6 - 0
packages/ckeditor5-media-embed/package.json

@@ -9,8 +9,14 @@
     "ckeditor5-feature"
     "ckeditor5-feature"
   ],
   ],
   "dependencies": {
   "dependencies": {
+    "@ckeditor/ckeditor5-core": "^11.0.0",
+    "@ckeditor/ckeditor5-engine": "^10.2.0",
+    "@ckeditor/ckeditor5-ui": "^11.0.0",
+    "@ckeditor/ckeditor5-utils": "^10.2.0",
+    "@ckeditor/ckeditor5-widget": "^10.2.0"
   },
   },
   "devDependencies": {
   "devDependencies": {
+    "@ckeditor/ckeditor5-editor-classic": "^11.0.0",
     "eslint": "^4.15.0",
     "eslint": "^4.15.0",
     "eslint-config-ckeditor5": "^1.0.7",
     "eslint-config-ckeditor5": "^1.0.7",
     "husky": "^0.14.3",
     "husky": "^0.14.3",

+ 66 - 0
packages/ckeditor5-media-embed/src/converters.js

@@ -0,0 +1,66 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module media-embed/converters
+ */
+
+import ViewRange from '@ckeditor/ckeditor5-engine/src/view/range';
+import ViewPosition from '@ckeditor/ckeditor5-engine/src/view/position';
+
+/**
+ * Returns a function that converts the model "url" attribute to the view representation.
+ *
+ * Depending on the configuration the view representation can be "sementaic" (for data pipeline):
+ *
+ *		<figure class="media">
+ *			<oembed url="foo"></div>
+ *		</figure>
+ *
+ * or "non-semantic" (for editing view pipeline):
+ *
+ *		<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 {module:media-embed/mediaregistry~MediaRegistry} mediaRegistry The registry providing
+ * the media and their content.
+ * @param {Object} options
+ * @param {String} [options.semanticDataOutput] When `true`, the converter will create view in the 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( mediaRegistry, options ) {
+	const mediaViewElementOptions = {
+		useSemanticWrapper: options.semanticDataOutput,
+		renderContent: !options.semanticDataOutput,
+		renderForEditingView: options.renderForEditingView
+	};
+
+	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 );
+
+		// TODO: removing it and creating it from scratch is a hack. We can do better than that.
+		viewWriter.remove( ViewRange.createIn( figure ) );
+
+		const mediaViewElement = mediaRegistry.getMediaViewElement( viewWriter, url, mediaViewElementOptions );
+
+		viewWriter.insert( ViewPosition.createAt( figure ), mediaViewElement );
+	}
+}

+ 246 - 0
packages/ckeditor5-media-embed/src/mediaembed.js

@@ -0,0 +1,246 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module media-embed/mediaembed
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+
+import MediaEmbedEditing from './mediaembedediting';
+import MediaEmbedUI from './mediaembedui';
+import Widget from '@ckeditor/ckeditor5-widget/src/widget';
+
+/**
+ * The media embed plugin.
+ *
+ * It loads the {@link module:media-embed/mediaembedediting~MediaEmbedEditing media embed editing feature}
+ * and {@link module:media-embed/mediaembedui~MediaEmbedUI media embed UI feature}.
+ *
+ * For a detailed overview, check the {@glink features/media-embed Media Embed feature documentation}.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class MediaEmbed extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ MediaEmbedEditing, MediaEmbedUI, 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}
+ *			// with optional "https://" and "www" prefixes.
+ *			url: /^(https:\/\/)?(www\.)?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: mediaId => `The HTML representing the media with ID=${ mediaId }.`
+ *		}
+ *
+ * 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: /^(https:\/\/)?(www\.)?.+/
+ *		}
+ *
+ * To implement a responsive media, you can use the following HTML structure:
+ *
+ *		{
+ *			...
+ *			html: mediaId =>
+ *				'<div style="position:relative; padding-bottom:100%; height:0">' +
+ *					'<iframe src="..." frameborder="0" ' +
+ *						'style="position:absolute; width:100%; height:100%; top:0; left:0">' +
+ *					'</iframe>' +
+ *				'</div>'
+ *		}
+ *
+ * @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.<RegExp>} 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 editor model, as defined by the provider. The content
+ * of the last matching group is passed to the `html` rendering function of the media.
+ * @property {Function} [html] (optional) Rendering function of the media. The function receives the content of
+ * the last matching group from the corresponding `url` `RegExp` as an argument, allowing rendering a dedicated
+ * preview of a 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#semanticDataOutput `config.mediaEmbed.semanticDataOutput`}
+ * is `true`, the rendering function **will not** 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.
+ * Used by the media embed features in the `@ckeditor/ckeditor5-media-embed` package.
+ *
+ *		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.
+ *
+ * Names of providers with rendering functions (previews):
+ *
+ * * "dailymotion",
+ * * "spotify",
+ * * "youtube",
+ * * "vimeo"
+ *
+ * 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.
+ *
+ * **Note**: Media without are always represented in the data using the "semantic" markup. See
+ * {@link module:media-embed/mediaembed~MediaEmbedConfig#semanticDataOutput `config.mediaEmbed.semanticDataOutput`} to
+ * learn more about possible data outputs.
+ *
+ * **Note:**: 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 which support a particular URL.
+ * The URL is never matched against remaining providers.
+ *
+ * To discard **all** default media providers, simply override this config with your own
+ * {@link module:media-embed/mediaembed~MediaEmbedProvider definitions}:
+ *
+ *		ClassicEditor
+ *			.create( editorElement, {
+ *				plugins: [ MediaEmbed, ... ],
+ *				mediaEmbed: {
+ *					providers: [
+ *						{
+ *							 name: 'myProvider',
+ *							 url: /^(https:\/\/)?(www\.)?example\.com\/media\/(\w+)/,
+ *							 html: mediaId => '...'
+ *						},
+ *						...
+ * 					]
+ *				}
+ *			} )
+ *			.then( ... )
+ *			.catch( ... );
+ *
+ * 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~MediaEmbedProvider>} 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: /^(https:\/\/)?(www\.)?example\.com\/media\/(\w+)/,
+ *							 html: mediaId => '...'
+ *						},
+ *						...
+ * 					]
+ *				}
+ *			} )
+ *			.then( ... )
+ *			.catch( ... );
+ *
+ * See the {@link module:media-embed/mediaembed~MediaEmbedProvider provider syntax} to learn more.
+ *
+ * @member {Array.<module:media-embed/mediaembed~MediaEmbedProvider>} module:media-embed/mediaembed~MediaEmbedConfig#extraProviders
+ */
+
+/**
+ * The list of media providers which 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.<String>} module:media-embed/mediaembed~MediaEmbedConfig#removeProviders
+ */
+
+/**
+ * Controls the data format produced by the feature.
+ *
+ * When `true`, the feature produces "semantic" data, i.e. it does not include the preview of
+ * the media, just the `<oembed>` tag with the `url` attribute:
+ *
+ *		<figure class="media">
+ *			<oembed url="https://url"></oembed>
+ *		</figure>
+ *
+ * when `false` (default), 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:
+ *
+ *		<figure class="media">
+ *			<div data-oembed-url="https://url">
+ *				<iframe src="https://preview"></iframe>
+ *			</div>
+ *		</figure>
+ *
+ * **Note:** Preview–less media are always represented in the data using the "semantic" markup
+ * regardless of the value of the `semanticDataOutput`. 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#semanticDataOutput=false]
+ */

+ 78 - 0
packages/ckeditor5-media-embed/src/mediaembedcommand.js

@@ -0,0 +1,78 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module media-embed/mediaembedcommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+import ModelPosition from '@ckeditor/ckeditor5-engine/src/model/position';
+import { getSelectedMediaElement } from './utils';
+
+/**
+ * The insert media command.
+ *
+ * The command is registered by the {@link module:media-embed/mediaembedediting~MediaEmbedEditing} as `'mediaEmbed'`.
+ *
+ * To insert a 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 schema = model.schema;
+		const position = selection.getFirstPosition();
+		const selectedMedia = getSelectedMediaElement( selection );
+
+		let parent = position.parent;
+
+		if ( parent != parent.root ) {
+			parent = parent.parent;
+		}
+
+		this.value = selectedMedia ? selectedMedia.getAttribute( 'url' ) : null;
+		this.isEnabled = schema.checkChild( parent, 'media' );
+	}
+
+	/**
+	 * Executes the command, which either:
+	 *
+	 * * updates the URL of a 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 = getSelectedMediaElement( selection );
+
+		if ( selectedMedia ) {
+			model.change( writer => {
+				writer.setAttribute( 'url', url, selectedMedia );
+			} );
+		} else {
+			const firstPosition = selection.getFirstPosition();
+			const isRoot = firstPosition.parent === firstPosition.root;
+			const insertPosition = isRoot ? ModelPosition.createAt( firstPosition ) : ModelPosition.createAfter( firstPosition.parent );
+
+			model.change( writer => {
+				const mediaElement = writer.createElement( 'media', { url } );
+
+				writer.insert( mediaElement, insertPosition );
+				writer.setSelection( mediaElement, 'on' );
+			} );
+		}
+	}
+}
+

+ 226 - 0
packages/ckeditor5-media-embed/src/mediaembedediting.js

@@ -0,0 +1,226 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module media-embed/mediaembedediting
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+
+import { modelToViewUrlAttributeConverter } from './converters';
+import MediaEmbedCommand from './mediaembedcommand';
+import { toMediaWidget, createMediaFigureElement } from './utils';
+import { MediaRegistry } from './mediaregistry';
+import { downcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/downcast-converters';
+import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters';
+
+import '../theme/mediaembedediting.css';
+
+/**
+ * The media embed editing feature.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class MediaEmbedEditing extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	constructor( editor ) {
+		super( editor );
+
+		editor.config.define( 'mediaEmbed', {
+			providers: [
+				{
+					name: 'dailymotion',
+					url: /^(https:\/\/)?(www\.)?dailymotion\.com\/video\/(\w+)/,
+					html: id =>
+						'<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: [
+						/^(https:\/\/)?(www\.)?open\.spotify\.com\/(artist\/\w+)/,
+						/^(https:\/\/)?(www\.)?open\.spotify\.com\/(album\/\w+)/,
+						/^(https:\/\/)?(www\.)?open\.spotify\.com\/(track\/\w+)/
+					],
+					html: id =>
+						'<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: [
+						/^(https:\/\/)?(www\.)?youtube\.com\/watch\?v=(\w+)/,
+						/^(https:\/\/)?(www\.)?youtube\.com\/v\/(\w+)/,
+						/^(https:\/\/)?(www\.)?youtube\.com\/embed\/(\w+)/,
+						/^(https:\/\/)?youtu\.be\/(\w+)/
+					],
+					html: id =>
+						'<div style="position: relative; padding-bottom: 100%; height: 0; padding-bottom: 56.2493%;">' +
+							`<iframe src="https://www.youtube.com/embed/${ id }" ` +
+								'style="position: absolute; width: 100%; height: 100%; top: 0; left: 0;" ' +
+								'frameborder="0" allow="autoplay; encrypted-media" allowfullscreen>' +
+							'</iframe>' +
+						'</div>'
+				},
+
+				{
+					name: 'vimeo',
+					url: [
+						/^(https:\/\/)?(www\.)?vimeo\.com\/(\d+)/,
+						/^(https:\/\/)?(www\.)?vimeo\.com\/[^/]+\/[^/]+\/video\/(\d+)/,
+						/^(https:\/\/)?(www\.)?vimeo\.com\/album\/[^/]+\/video\/(\d+)/,
+						/^(https:\/\/)?(www\.)?vimeo\.com\/channels\/[^/]+\/(\d+)/,
+						/^(https:\/\/)?(www\.)?vimeo\.com\/groups\/[^/]+\/videos\/(\d+)/,
+						/^(https:\/\/)?(www\.)?vimeo\.com\/ondemand\/[^/]+\/(\d+)/,
+						/^(https:\/\/)?(www\.)?player\.vimeo\.com\/video\/(\d+)/
+					],
+					html: id =>
+						'<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: /^(https:\/\/)?(www\.)?instagram\.com\/p\/(\w+)/
+				},
+				{
+					name: 'twitter',
+					url: /^(https:\/\/)?(www\.)?twitter\.com/
+				},
+				{
+					name: 'googleMaps',
+					url: /^(https:\/\/)?(www\.)?google\.com\/maps/
+				},
+				{
+					name: 'flickr',
+					url: /^(https:\/\/)?(www\.)?flickr\.com/
+				},
+				{
+					name: 'facebook',
+					url: /^(https:\/\/)?(www\.)?facebook\.com/
+				},
+			]
+		} );
+
+		/**
+		 * The media registry managing the media providers in the editor.
+		 *
+		 * @member {module:media-embed/mediaregistry~MediaRegistry} #mediaRegistry
+		 */
+		this.mediaRegistry = 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 semanticDataOutput = editor.config.get( 'mediaEmbed.semanticDataOutput' );
+		const mediaRegistry = this.mediaRegistry;
+
+		editor.commands.add( 'mediaEmbed', new MediaEmbedCommand( editor ) );
+
+		// Configure the schema.
+		schema.register( 'media', {
+			isObject: true,
+			isBlock: true,
+			allowWhere: '$block',
+			allowAttributes: [ 'url' ]
+		} );
+
+		// Model -> Data
+		conversion.for( 'dataDowncast' ).add( downcastElementToElement( {
+			model: 'media',
+			view: ( modelElement, viewWriter ) => {
+				const url = modelElement.getAttribute( 'url' );
+
+				return createMediaFigureElement( viewWriter, mediaRegistry, url, {
+					useSemanticWrapper: semanticDataOutput || !url,
+					renderContent: !semanticDataOutput
+				} );
+			}
+		} ) );
+
+		// Model -> Data (url -> data-oembed-url)
+		conversion.for( 'dataDowncast' ).add(
+			modelToViewUrlAttributeConverter( mediaRegistry, {
+				semanticDataOutput
+			} ) );
+
+		// Model -> View (element)
+		conversion.for( 'editingDowncast' ).add( downcastElementToElement( {
+			model: 'media',
+			view: ( modelElement, viewWriter ) => {
+				const url = modelElement.getAttribute( 'url' );
+				const figure = createMediaFigureElement( viewWriter, mediaRegistry, url, {
+					renderForEditingView: true,
+					renderContent: true
+				} );
+
+				return toMediaWidget( figure, viewWriter, t( 'media widget' ) );
+			}
+		} ) );
+
+		// Model -> View (url -> data-oembed-url)
+		conversion.for( 'editingDowncast' ).add(
+			modelToViewUrlAttributeConverter( mediaRegistry, {
+				renderForEditingView: true
+			} ) );
+
+		// View -> Model (data-oembed-url -> url)
+		conversion.for( 'upcast' )
+			// Upcast semantic media.
+			.add( upcastElementToElement( {
+				view: {
+					name: 'oembed',
+					attributes: {
+						url: true
+					}
+				},
+				model: ( viewMedia, modelWriter ) => {
+					const url = viewMedia.getAttribute( 'url' );
+
+					if ( mediaRegistry.hasMedia( url ) ) {
+						return modelWriter.createElement( 'media', { url } );
+					}
+				}
+			} ) )
+			// Upcast non-semantic media.
+			.add( upcastElementToElement( {
+				view: {
+					name: 'div',
+					attributes: {
+						'data-oembed-url': true
+					}
+				},
+				model: ( viewMedia, modelWriter ) => {
+					const url = viewMedia.getAttribute( 'data-oembed-url' );
+
+					if ( mediaRegistry.hasMedia( url ) ) {
+						return modelWriter.createElement( 'media', { url } );
+					}
+				}
+			} ) );
+	}
+}

+ 116 - 0
packages/ckeditor5-media-embed/src/mediaembedui.js

@@ -0,0 +1,116 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module media-embed/mediaembedui
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import { createDropdown } from '@ckeditor/ckeditor5-ui/src/dropdown/utils';
+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
+	 */
+	init() {
+		const editor = this.editor;
+		const command = editor.commands.get( 'mediaEmbed' );
+		const mediaRegistry = editor.plugins.get( MediaEmbedEditing ).mediaRegistry;
+
+		// Setup `imageUpload` button.
+		editor.ui.componentFactory.add( 'mediaEmbed', locale => {
+			const form = new MediaFormView( getFormValidators( editor.t, mediaRegistry ), locale );
+			const dropdown = createDropdown( locale );
+
+			this._setUpDropdown( dropdown, form, command, editor );
+			this._setUpForm( form, dropdown, command );
+
+			return dropdown;
+		} );
+	}
+
+	_setUpDropdown( dropdown, form, command ) {
+		const editor = this.editor;
+		const t = editor.t;
+		const button = dropdown.buttonView;
+
+		dropdown.bind( 'isEnabled' ).to( command );
+		dropdown.panelView.children.add( form );
+
+		button.set( {
+			label: t( 'Insert media' ),
+			icon: mediaIcon,
+			tooltip: true
+		} );
+
+		// 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', () => {
+			// 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#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.select();
+			form.focus();
+		}, { priority: 'low' } );
+
+		dropdown.on( 'submit', () => {
+			if ( form.isValid() ) {
+				editor.execute( 'mediaEmbed', form.url );
+				closeUI();
+			}
+		} );
+
+		dropdown.on( 'change:isOpen', () => form.resetErrors() );
+		dropdown.on( 'cancel', () => closeUI() );
+
+		function closeUI() {
+			editor.editing.view.focus();
+			dropdown.isOpen = false;
+		}
+	}
+
+	_setUpForm( form, dropdown, command ) {
+		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 );
+		form.saveButtonView.bind( 'isEnabled' ).to( command );
+	}
+}
+
+function getFormValidators( t, mediaRegistry ) {
+	return [
+		form => {
+			if ( !form.url.length ) {
+				return t( 'The URL must not be empty.' );
+			}
+		},
+		form => {
+			if ( !mediaRegistry.hasMedia( form.url ) ) {
+				return t( 'This media URL is not supported.' );
+			}
+		}
+	];
+}

+ 287 - 0
packages/ckeditor5-media-embed/src/mediaregistry.js

@@ -0,0 +1,287 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module media-embed/mediaregistry
+ */
+
+import mediaPlaceholderIcon from '../theme/icons/media-placeholder.svg';
+import log from '@ckeditor/ckeditor5-utils/src/log';
+import TooltipView from '@ckeditor/ckeditor5-ui/src/tooltip/tooltipview';
+import IconView from '@ckeditor/ckeditor5-ui/src/icon/iconview';
+import Template from '@ckeditor/ckeditor5-ui/src/template';
+
+const mediaPlaceholderIconViewBox = '0 0 64 42';
+
+/**
+ * A bridge between the raw media content provider definitions and 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 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 extraProviders) specified in the mediaEmbed 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.
+					 *
+					 * @warning media-embed-no-provider-name
+					 */
+					log.warn( 'media-embed-no-provider-name: The configured media provider has no name and cannot be used.',
+						{ provider } );
+
+					return false;
+				}
+
+				return !removedProviders.has( name );
+			} );
+
+		/**
+		 * The locale {@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/writer~Writer} 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.renderContent]
+	 * @param {String} [options.useSemanticWrapper]
+	 * @param {String} [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.
+	 *
+	 * @private
+	 * @param {String} url The url of the media.
+	 * @returns {module:media-embed/mediaregistry~Media|null} The `Media` instance or `null` when there's none.
+	 */
+	_getMedia( url ) {
+		if ( !url ) {
+			return new Media( this.locale );
+		}
+
+		url = url.trim();
+
+		for ( const definition of this.providerDefinitions ) {
+			const contentRenderer = definition.html;
+			let pattern = definition.url;
+
+			if ( !Array.isArray( pattern ) ) {
+				pattern = [ pattern ];
+			}
+
+			for ( const subPattern of pattern ) {
+				const match = url.match( subPattern );
+
+				if ( match ) {
+					return new Media( this.locale, url, match, contentRenderer );
+				}
+			}
+		}
+
+		return null;
+	}
+}
+
+/**
+ * Represents a media defined by the provider configuration.
+ *
+ * It can be rendered to the {@link module:engine/view/element~Element view element} and used in editing or data pipeline.
+ *
+ * @private
+ */
+class Media {
+	constructor( locale, url, match, contentRenderer ) {
+		/**
+		 * The URL this Media instance represents.
+		 *
+		 * @member {String}
+		 */
+		this.url = url;
+
+		/**
+		 * Shorthand for {@link module:utils/locale~Locale#t}.
+		 *
+		 * @see module:utils/locale~Locale#t
+		 * @method
+		 */
+		this._t = locale.t;
+
+		/**
+		 * 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._contentRenderer = contentRenderer;
+	}
+
+	/**
+	 * Returns view element representation of the media.
+	 *
+	 * @param {module:engine/view/writer~Writer} writer The view writer used to produce a view element.
+	 * @param {Object} options
+	 * @param {String} [options.renderContent]
+	 * @param {String} [options.useSemanticWrapper]
+	 * @param {String} [options.renderForEditingView]
+	 * @returns {module:engine/view/element~Element}
+	 */
+	getViewElement( writer, options ) {
+		const attributes = {};
+
+		if ( options.useSemanticWrapper || ( this.url && !this._contentRenderer && !options.renderForEditingView ) ) {
+			if ( this.url ) {
+				attributes.url = this.url;
+			}
+
+			return writer.createEmptyElement( 'oembed', attributes );
+		} else {
+			if ( this.url ) {
+				attributes[ 'data-oembed-url' ] = this.url;
+			}
+
+			if ( options.renderForEditingView ) {
+				attributes.class = 'ck-media__wrapper';
+			}
+
+			const mediaHtml = this._getContentHtml( options );
+
+			return writer.createUIElement( 'div', attributes, function( domDocument ) {
+				const domElement = this.toDomElement( domDocument );
+
+				domElement.innerHTML = mediaHtml;
+
+				return domElement;
+			} );
+		}
+	}
+
+	/**
+	 * Returns the HTML string of the media content preview.
+	 *
+	 * @param {module:engine/view/writer~Writer} writer The view writer used to produce a view element.
+	 * @param {Object} options
+	 * @param {String} [options.renderForEditingView]
+	 * @returns {String}
+	 */
+	_getContentHtml( options ) {
+		if ( this._contentRenderer ) {
+			return this._contentRenderer( this._match.pop() );
+		} 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 media has no content preview.
+	 *
+	 * @returns {String}
+	 */
+	_getPlaceholderHtml() {
+		const tooltip = new TooltipView();
+		const icon = new IconView();
+
+		tooltip.text = this._t( 'Open media in new tab' );
+		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: 'new',
+						href: this.url
+					},
+					children: [
+						{
+							tag: 'span',
+							attributes: {
+								class: 'ck-media__placeholder__url__text'
+							},
+							children: [ this.url ]
+						},
+						tooltip
+					]
+				}
+			]
+		} ).render();
+
+		return placeholder.outerHTML;
+	}
+}

+ 306 - 0
packages/ckeditor5-media-embed/src/ui/mediaformview.js

@@ -0,0 +1,306 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module media-embed/ui/mediaformview
+ */
+
+import View from '@ckeditor/ckeditor5-ui/src/view';
+import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection';
+
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import LabeledInputView from '@ckeditor/ckeditor5-ui/src/labeledinput/labeledinputview';
+import InputTextView from '@ckeditor/ckeditor5-ui/src/inputtext/inputtextview';
+
+import submitHandler from '@ckeditor/ckeditor5-ui/src/bindings/submithandler';
+import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
+import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler';
+import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
+
+import checkIcon from '@ckeditor/ckeditor5-core/theme/icons/check.svg';
+import cancelIcon from '@ckeditor/ckeditor5-core/theme/icons/cancel.svg';
+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.<Function>} 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 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 URL input view.
+		 *
+		 * @member {module:ui/labeledinput/labeledinputview~LabeledInputView}
+		 */
+		this.urlInputView = this._createUrlInput();
+
+		/**
+		 * The Save button view.
+		 *
+		 * @member {module:ui/button/buttonview~ButtonView}
+		 */
+		this.saveButtonView = this._createButton( t( 'Save' ), checkIcon, 'ck-button-save' );
+		this.saveButtonView.type = 'submit';
+
+		/**
+		 * The Cancel button view.
+		 *
+		 * @member {module:ui/button/buttonview~ButtonView}
+		 */
+		this.cancelButtonView = this._createButton( t( 'Cancel' ), cancelIcon, 'ck-button-cancel', 'cancel' );
+
+		/**
+		 * A collection of views which 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 the form validators used by {@link #isValid}.
+		 *
+		 * @readonly
+		 * @protected
+		 * @member {Array.<Function>}
+		 */
+		this._validators = validators;
+
+		this.setTemplate( {
+			tag: 'form',
+
+			attributes: {
+				class: [
+					'ck',
+					'ck-media-form'
+				],
+
+				tabindex: '-1'
+			},
+
+			children: [
+				this.urlInputView,
+				this.saveButtonView,
+				this.cancelButtonView
+			]
+		} );
+	}
+
+	/**
+	 * @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' } );
+	}
+
+	/**
+	 * 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 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 DOM.
+	 *
+	 * @type {Number}
+	 */
+	get url() {
+		return this.urlInputView.inputView.element.value.trim();
+	}
+
+	/**
+	 * Sets the native DOM `value` of the {@link #urlInputView} element.
+	 *
+	 * **Note**: Do not confuse 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 DOM.
+	 *
+	 * @param {String} url
+	 */
+	set url( url ) {
+		this.urlInputView.inputView.element.value = url.trim();
+	}
+
+	/**
+	 * Validates the form and returns `false` when some fields are invalid.
+	 *
+	 * @returns {Boolean}
+	 */
+	isValid() {
+		this.resetErrors();
+
+		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 errors in all form fields. See {@link #isValid}.
+	 */
+	resetErrors() {
+		this.urlInputView.errorText = false;
+	}
+
+	/**
+	 * Creates a labeled input view.
+	 *
+	 * @private
+	 * @returns {module:ui/labeledinput/labeledinputview~LabeledInputView} Labeled input view instance.
+	 */
+	_createUrlInput() {
+		const t = this.locale.t;
+
+		const labeledInput = new LabeledInputView( this.locale, InputTextView );
+
+		labeledInput.label = t( 'Media URL' );
+		labeledInput.inputView.placeholder = 'https://example.com';
+
+		return labeledInput;
+	}
+
+	/**
+	 * Creates a button view.
+	 *
+	 * @private
+	 * @param {String} label The button label.
+	 * @param {String} icon The button's 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. click on {@link #cancelButtonView}.
+ *
+ * @event cancel
+ */

+ 85 - 0
packages/ckeditor5-media-embed/src/utils.js

@@ -0,0 +1,85 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module media-embed/utils
+ */
+
+import ViewPosition from '@ckeditor/ckeditor5-engine/src/view/position';
+import { toWidget } from '@ckeditor/ckeditor5-widget/src/utils';
+
+const mediaSymbol = Symbol( 'isMedia' );
+
+/**
+ * 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/writer~Writer} 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( mediaSymbol, true, viewElement );
+
+	return toWidget( viewElement, writer, { label } );
+}
+
+/**
+ * Creates a view element representing the media. Either "semantic" one for the data pipeline:
+ *
+ *		<figure class="media">
+ *			<oembed url="foo"></div>
+ *		</figure>
+ *
+ * or "non-semantic" (for editing view pipeline):
+ *
+ *		<figure class="media">
+ *			<div data-oembed-url="foo">[ non-semantic media preview for "foo" ]</div>
+ *		</figure>
+ *
+ * @param {module:engine/view/writer~Writer} writer
+ * @param {module:media-embed/mediaregistry~MediaRegistry} mediaRegistry
+ * @param {String} url
+ * @param {Object} options
+ * @param {String} [options.renderContent]
+ * @param {String} [options.useSemanticWrapper]
+ * @param {String} [options.renderForEditingView]
+ * @returns {module:engine/view/containerelement~ContainerElement}
+ */
+export function createMediaFigureElement( writer, mediaRegistry, url, options ) {
+	const figure = writer.createContainerElement( 'figure', { class: 'media' } );
+
+	// TODO: This is a hack. Without it, the figure in the data pipeline will contain &nbsp; because
+	// its only child is the UIElement (wrapper).
+	//
+	// Note: The hack is a copy&paste from widget utils; it makes the figure act like it's a widget.
+	figure.getFillerOffset = getFillerOffset;
+
+	writer.insert( ViewPosition.createAt( figure ), mediaRegistry.getMediaViewElement( writer, url, options ) );
+
+	return figure;
+}
+
+/**
+ * Returns a selected media element in model, if any.
+ *
+ * @param {module:engine/model/selection~Selection} selection
+ * @returns {module:engine/model/element~Element|null}
+ */
+export function getSelectedMediaElement( selection ) {
+	const selectedElement = selection.getSelectedElement();
+
+	if ( selectedElement && selectedElement.is( 'media' ) ) {
+		return selectedElement;
+	}
+
+	return null;
+}
+
+function getFillerOffset() {
+	return null;
+}

+ 112 - 0
packages/ckeditor5-media-embed/tests/insertmediacommand.js

@@ -0,0 +1,112 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import MediaEmbedEditing from '../src/mediaembedediting';
+import MediaEmbedCommand from '../src/mediaembedcommand';
+
+describe( 'MediaEmbedCommand', () => {
+	let editor, model, command;
+
+	beforeEach( () => {
+		return ModelTestEditor
+			.create( {
+				plugins: [ MediaEmbedEditing ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				command = new MediaEmbedCommand( editor );
+
+				model.schema.register( 'p', { inheritAllFrom: '$block' } );
+			} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	describe( 'isEnabled', () => {
+		it( 'should be true if in a root', () => {
+			setData( model, '[]' );
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be true if in a paragraph (collapsed)', () => {
+			setData( model, '<p>foo[]</p>' );
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be true if in a paragraph (not collapsed)', () => {
+			setData( model, '<p>[foo]</p>' );
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be true if a media is selected', () => {
+			setData( model, '[<media url="http://ckeditor.com"></media>]' );
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be true when the selection directly in a block', () => {
+			model.schema.register( 'block', { inheritAllFrom: '$block' } );
+			model.schema.extend( '$text', { allowIn: 'block' } );
+
+			setData( model, '<block>foo[]</block>' );
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be false when the selection in a limit element', () => {
+			model.schema.register( 'block', { inheritAllFrom: '$block' } );
+			model.schema.register( 'limit', { allowIn: 'block', isLimit: true } );
+			model.schema.extend( '$text', { allowIn: 'limit' } );
+
+			setData( model, '<block><limit>foo[]</limit></block>' );
+			expect( command.isEnabled ).to.be.false;
+		} );
+	} );
+
+	describe( 'value', () => {
+		it( 'should be null when no media is selected (paragraph)', () => {
+			setData( model, '<p>foo[]</p>' );
+			expect( command.value ).to.be.null;
+		} );
+
+		it( 'should equal the url of the selected media', () => {
+			setData( model, '[<media url="http://ckeditor.com"></media>]' );
+			expect( command.value ).to.equal( 'http://ckeditor.com' );
+		} );
+	} );
+
+	describe( 'execute()', () => {
+		it( 'should create a single batch', () => {
+			setData( model, '<p>foo[]</p>' );
+
+			const spy = sinon.spy();
+
+			model.document.on( 'change', spy );
+
+			command.execute( 'http://ckeditor.com' );
+
+			sinon.assert.calledOnce( spy );
+		} );
+
+		it( 'should insert a media in an empty root and select it', () => {
+			setData( model, '[]' );
+
+			command.execute( 'http://ckeditor.com' );
+
+			expect( getData( model ) ).to.equal( '[<media url="http://ckeditor.com"></media>]' );
+		} );
+
+		it( 'should update media url', () => {
+			setData( model, '[<media url="http://ckeditor.com"></media>]' );
+
+			command.execute( 'http://cksource.com' );
+
+			expect( getData( model ) ).to.equal( '[<media url="http://cksource.com"></media>]' );
+		} );
+	} );
+} );

+ 54 - 0
packages/ckeditor5-media-embed/tests/manual/mediaembed.html

@@ -0,0 +1,54 @@
+<style>
+	input {
+		width: 500px;
+	}
+</style>
+
+<h2>Example URLs</h2>
+<ul>
+	<li><input type="text" value="https://www.youtube.com/watch?v=H08tGjXNHO4"></li>
+	<li><input type="text" value="https://open.spotify.com/album/2IXlgvecaDqOeF3viUZnPI?si=ogVw7KlcQAGZKK4Jz9QzvA"></li>
+</ul>
+
+<h2>Test editor</h2>
+<div id="editor">
+	<h2>Media with previews</h2>
+
+	<figure class="media">
+		<div data-oembed-url="https://www.youtube.com/watch?v=ZVv7UMQPEWk"><div style="position: relative; padding-bottom: 100%; height: 0; padding-bottom: 56.2493%;"><iframe src="https://www.youtube.com/embed/ZVv7UMQPEWk" style="position: absolute; width: 100%; height: 100%; top: 0; left: 0;" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen=""></iframe></div></div>
+	</figure>
+
+	<figure class="media">
+		<div data-oembed-url="https://vimeo.com/1084537"><div style="position: relative; padding-bottom: 100%; height: 0; padding-bottom: 56.2493%;"><iframe src="https://player.vimeo.com/video/1084537" style="position: absolute; width: 100%; height: 100%; top: 0; left: 0;" frameborder="0" webkitallowfullscreen="" mozallowfullscreen="" allowfullscreen=""></iframe></div></div>
+	</figure>
+
+	<figure class="media">
+		<div data-oembed-url="https://open.spotify.com/artist/7GaxyUddsPok8BuhxN6OUW?si=LM7Q50vqQ4-NYnAIcMTNuQ"><div style="position: relative; padding-bottom: 100%; height: 0; padding-bottom: 126%;"><iframe src="https://open.spotify.com/embed/artist/7GaxyUddsPok8BuhxN6OUW" style="position: absolute; width: 100%; height: 100%; top: 0; left: 0;" frameborder="0" allowtransparency="true" allow="encrypted-media"></iframe></div></div>
+	</figure>
+
+	<h2>Generic media</h2>
+
+		<figure class="media">
+			<oembed url="https://twitter.com/ckeditor/status/1021777799844126720" />
+		</figure>
+
+		<figure class="media">
+			<oembed url="https://www.flickr.com/photos/nasahqphoto/28396473968/" />
+		</figure>
+
+		<figure class="media">
+			<oembed url="https://www.instagram.com/p/BmMZgokAGGQ/?taken-by=nasa" />
+		</figure>
+
+		<figure class="media">
+			<oembed url="https://www.google.com/maps/place/Poland/@51.9537505,19.1343786,6z/data=!3m1!4b1!4m5!3m4!1s0x47009964a4640bbb:0x97573ca49cc55ea!8m2!3d51.919438!4d19.145136?hl=en" />
+		</figure>
+
+		<figure class="media">
+			<oembed url="https://www.facebook.com/ckeditor/posts/1885390224856207" />
+		</figure>
+
+		<figure class="media">
+			<oembed url="https://ckeditor.com" />
+		</figure>
+</div>

+ 24 - 0
packages/ckeditor5-media-embed/tests/manual/mediaembed.js

@@ -0,0 +1,24 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals console, window, document */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import MediaEmbed from '../../src/mediaembed';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ ArticlePluginSet, MediaEmbed ],
+		toolbar: [
+			'heading', '|', 'mediaEmbed', '|', 'bold', 'italic', 'bulletedList', 'numberedList', 'blockQuote', 'link', 'undo', 'redo'
+		]
+	} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 47 - 0
packages/ckeditor5-media-embed/tests/manual/mediaembed.md

@@ -0,0 +1,47 @@
+## Media Embed
+
+### Embed the media
+
+1. Put an URL in the clipboard,
+1. Use the button in the dropdown,
+1. Insert the media,
+1. Check if media was inserted and is selected.
+
+### Update the media
+
+1. Put an URL in the clipboard,
+1. Select some media,
+1. Use the button in the dropdown,
+1. Update the URL,
+1. Check if media was updated and remains selected.
+
+### Data output
+
+1. Call `editor.getData()`,
+1. Media with previews should include their previews. Preview–less media should be represented using only the `<oembed>` tag.
+
+### URL validation
+
+#### Invalid
+
+1. In the previous scenarios try using a non–media URL,
+1. The error should be displayed next to the URL field,
+1. Nothing should be inserted/updated.
+
+#### Empty
+
+1. In the previous scenarios try using an empty URL,
+1. The error should be displayed next to the URL field,
+1. Nothing should be inserted/updated.
+
+### Copy&Paste
+
+1. Play with media copy&paste.
+1. It should work just like images or any other widget.
+
+### Open media in new tab
+
+1. Locate any generic media in the content,
+1. Hover the URL in the content (the tooltip should show up),
+1. Click the URL,
+1. A new browser tab should open with the media URL.

+ 54 - 0
packages/ckeditor5-media-embed/tests/manual/semanticmediaembed.html

@@ -0,0 +1,54 @@
+<style>
+	input {
+		width: 500px;
+	}
+</style>
+
+<h2>Example URLs</h2>
+<ul>
+	<li><input type="text" value="https://www.youtube.com/watch?v=H08tGjXNHO4"></li>
+	<li><input type="text" value="https://open.spotify.com/album/2IXlgvecaDqOeF3viUZnPI?si=ogVw7KlcQAGZKK4Jz9QzvA"></li>
+</ul>
+
+<h2>Test editor</h2>
+<div id="editor">
+	<h2>Media with previews</h2>
+
+		<figure class="media">
+			<oembed url="https://www.youtube.com/watch?v=ZVv7UMQPEWk" />
+		</figure>
+
+		<figure class="media">
+			<oembed url="https://vimeo.com/1084537" />
+		</figure>
+
+		<figure class="media">
+			<oembed url="https://open.spotify.com/artist/7GaxyUddsPok8BuhxN6OUW?si=LM7Q50vqQ4-NYnAIcMTNuQ" />
+		</figure>
+
+	<h2>Generic media</h2>
+
+		<figure class="media">
+			<oembed url="https://twitter.com/ckeditor/status/1021777799844126720" />
+		</figure>
+
+		<figure class="media">
+			<oembed url="https://www.flickr.com/photos/nasahqphoto/28396473968/" />
+		</figure>
+
+		<figure class="media">
+			<oembed url="https://www.instagram.com/p/BmMZgokAGGQ/?taken-by=nasa" />
+		</figure>
+
+		<figure class="media">
+			<oembed url="https://www.google.com/maps/place/Poland/@51.9537505,19.1343786,6z/data=!3m1!4b1!4m5!3m4!1s0x47009964a4640bbb:0x97573ca49cc55ea!8m2!3d51.919438!4d19.145136?hl=en" />
+		</figure>
+
+		<figure class="media">
+			<oembed url="https://www.facebook.com/ckeditor/posts/1885390224856207" />
+		</figure>
+
+		<figure class="media">
+			<oembed url="https://ckeditor.com" />
+		</figure>
+</div>

+ 27 - 0
packages/ckeditor5-media-embed/tests/manual/semanticmediaembed.js

@@ -0,0 +1,27 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals console, window, document */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import MediaEmbed from '../../src/mediaembed';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ ArticlePluginSet, MediaEmbed ],
+		toolbar: [
+			'heading', '|', 'mediaEmbed', '|', 'bold', 'italic', 'bulletedList', 'numberedList', 'blockQuote', 'link', 'undo', 'redo'
+		],
+		mediaEmbed: {
+			semanticDataOutput: true
+		}
+	} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 47 - 0
packages/ckeditor5-media-embed/tests/manual/semanticmediaembed.md

@@ -0,0 +1,47 @@
+## Media Embed
+
+### Embed the media
+
+1. Put an URL in the clipboard,
+1. Use the button in the dropdown,
+1. Insert the media,
+1. Check if media was inserted and is selected.
+
+### Update the media
+
+1. Put an URL in the clipboard,
+1. Select some media,
+1. Use the button in the dropdown,
+1. Update the URL,
+1. Check if media was updated and remains selected.
+
+### Data output
+
+1. Call `editor.getData()`,
+1. All the media should be represented without previews, using only the `<oembed>` tag.
+
+### URL validation
+
+#### Invalid
+
+1. In the previous scenarios try using a non–media URL,
+1. The error should be displayed next to the URL field,
+1. Nothing should be inserted/updated.
+
+#### Empty
+
+1. In the previous scenarios try using an empty URL,
+1. The error should be displayed next to the URL field,
+1. Nothing should be inserted/updated.
+
+### Copy&Paste
+
+1. Play with media copy&paste.
+1. It should work just like images or any other widget.
+
+### Open media in new tab
+
+1. Locate any generic media in the content,
+1. Hover the URL in the content (the tooltip should show up),
+1. Click the URL,
+1. A new browser tab should open with the media URL.

+ 54 - 0
packages/ckeditor5-media-embed/tests/mediaembed.js

@@ -0,0 +1,54 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import MediaEmbed from '../src/mediaembed';
+import MediaEmbedEditing from '../src/mediaembedediting';
+import MediaEmbedUI from '../src/mediaembedui';
+import Widget from '@ckeditor/ckeditor5-widget/src/widget';
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+
+describe( 'MediaEmbed', () => {
+	let editorElement, editor;
+
+	beforeEach( () => {
+		editorElement = global.document.createElement( 'div' );
+		global.document.body.appendChild( editorElement );
+
+		return ClassicTestEditor
+			.create( editorElement, {
+				plugins: [ MediaEmbed ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+			} );
+	} );
+
+	afterEach( () => {
+		editorElement.remove();
+
+		return editor.destroy();
+	} );
+
+	it( 'should be loaded', () => {
+		expect( editor.plugins.get( MediaEmbed ) ).to.instanceOf( MediaEmbed );
+	} );
+
+	it( 'should load MediaEmbedEditing plugin', () => {
+		expect( editor.plugins.get( MediaEmbedEditing ) ).to.instanceOf( MediaEmbedEditing );
+	} );
+
+	it( 'should load Widget plugin', () => {
+		expect( editor.plugins.get( Widget ) ).to.instanceOf( Widget );
+	} );
+
+	it( 'should load MediaEmbedUI plugin', () => {
+		expect( editor.plugins.get( MediaEmbedUI ) ).to.instanceOf( MediaEmbedUI );
+	} );
+
+	it( 'has proper name', () => {
+		expect( MediaEmbed.pluginName ).to.equal( 'MediaEmbed' );
+	} );
+} );

+ 927 - 0
packages/ckeditor5-media-embed/tests/mediaembedediting.js

@@ -0,0 +1,927 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import MediaEmbedEditing from '../src/mediaembedediting';
+import { setData as setModelData, getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+import normalizeHtml from '@ckeditor/ckeditor5-utils/tests/_utils/normalizehtml';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import log from '@ckeditor/ckeditor5-utils/src/log';
+
+describe( 'MediaEmbedEditing', () => {
+	let editor, model, doc, view;
+
+	testUtils.createSinonSandbox();
+
+	const testProviders = {
+		A: {
+			name: 'A',
+			url: /^foo\.com\/(\w+)/,
+			html: id => `A, id=${ id }`
+		},
+		B: {
+			name: 'B',
+			url: /^bar\.com\/(\w+)/,
+			html: id => `B, id=${ id }`
+		},
+		C: {
+			name: 'C',
+			url: /^\w+\.com\/(\w+)/,
+			html: id => `C, id=${ id }`
+		},
+
+		extraA: {
+			name: 'extraA',
+			url: /^foo\.com\/(\w+)/,
+			html: id => `extraA, id=${ id }`
+		},
+		extraB: {
+			name: 'extraB',
+			url: /^\w+\.com\/(\w+)/,
+			html: id => `extraB, id=${ id }`
+		},
+
+		previewLess: {
+			name: 'preview-less',
+			url: /^https:\/\/preview-less/
+		},
+		allowEverything: {
+			name: 'allow-everything',
+			url: /(.*)/,
+			html: id => `allow-everything, id=${ id }`
+		}
+	};
+
+	describe( 'constructor()', () => {
+		describe( 'configuration', () => {
+			describe( '#providers', () => {
+				it( 'should warn when provider has no name', () => {
+					const logSpy = testUtils.sinon.stub( log, 'warn' );
+					const provider = {
+						url: /.*/
+					};
+
+					return createTestEditor( {
+						providers: [ provider ]
+					} ).then( () => {
+						expect( logSpy.calledOnce ).to.equal( true );
+						expect( logSpy.firstCall.args[ 0 ] ).to.match( /^media-embed-no-provider-name:/ );
+						expect( logSpy.firstCall.args[ 1 ].provider ).to.equal( provider );
+					} );
+				} );
+
+				it( 'can override all providers', () => {
+					return createTestEditor( {
+						providers: []
+					} ).then( editor => {
+						editor.setData( '<figure class="media"><div data-oembed-url="foo.com"></div></figure>' );
+
+						expect( getViewData( editor.editing.view, { withoutSelection: true, renderUIElements: true } ) ).to.equal( '' );
+					} );
+				} );
+
+				it( 'upcast media according to the order', () => {
+					return createTestEditor( {
+						providers: [
+							testProviders.A,
+							testProviders.B,
+							testProviders.C,
+						]
+					} ).then( editor => {
+						editor.setData( '<figure class="media"><div data-oembed-url="foo.com/123"></div></figure>' );
+
+						expect( getViewData( editor.editing.view, { withoutSelection: true, renderUIElements: true } ) ).to.equal(
+							'<figure class="ck-widget media" contenteditable="false">' +
+								'<div class="ck-media__wrapper" data-oembed-url="foo.com/123">' +
+									'A, id=123' +
+								'</div>' +
+							'</figure>'
+						);
+
+						editor.setData( '<figure class="media"><div data-oembed-url="bar.com/123"></div></figure>' );
+
+						expect( getViewData( editor.editing.view, { withoutSelection: true, renderUIElements: true } ) ).to.equal(
+							'<figure class="ck-widget media" contenteditable="false">' +
+								'<div class="ck-media__wrapper" data-oembed-url="bar.com/123">' +
+									'B, id=123' +
+								'</div>' +
+							'</figure>'
+						);
+
+						editor.setData( '<figure class="media"><div data-oembed-url="anything.com/123"></div></figure>' );
+
+						expect( getViewData( editor.editing.view, { withoutSelection: true, renderUIElements: true } ) ).to.equal(
+							'<figure class="ck-widget media" contenteditable="false">' +
+								'<div class="ck-media__wrapper" data-oembed-url="anything.com/123">' +
+									'C, id=123' +
+								'</div>' +
+							'</figure>'
+						);
+					} );
+				} );
+
+				describe( 'default value', () => {
+					beforeEach( () => {
+						return createTestEditor( {
+							semanticDataOutput: true
+						} )
+							.then( newEditor => {
+								editor = newEditor;
+								view = editor.editing.view;
+							} );
+					} );
+
+					describe( 'with preview', () => {
+						it( 'upcasts the URL (dailymotion)', () => {
+							testMediaUpcast( [
+								'https://www.dailymotion.com/video/foo',
+								'www.dailymotion.com/video/foo',
+								'dailymotion.com/video/foo',
+							],
+							'<div style="position: relative; padding-bottom: 100%; height: 0; ">' +
+								'<iframe src="https://www.dailymotion.com/embed/video/foo" ' +
+									'style="position: absolute; width: 100%; height: 100%; top: 0; left: 0;" ' +
+									'frameborder="0" width="480" height="270" allowfullscreen="" allow="autoplay">' +
+								'</iframe>' +
+							'</div>' );
+						} );
+
+						describe( 'spotify', () => {
+							it( 'upcasts the URL (artist)', () => {
+								testMediaUpcast( [
+									'https://www.open.spotify.com/artist/foo',
+									'www.open.spotify.com/artist/foo',
+									'open.spotify.com/artist/foo',
+								],
+								'<div style="position: relative; padding-bottom: 100%; height: 0; padding-bottom: 126%;">' +
+									'<iframe src="https://open.spotify.com/embed/artist/foo" ' +
+										'style="position: absolute; width: 100%; height: 100%; top: 0; left: 0;" ' +
+										'frameborder="0" allowtransparency="true" allow="encrypted-media">' +
+									'</iframe>' +
+								'</div>' );
+							} );
+
+							it( 'upcasts the URL (album)', () => {
+								testMediaUpcast( [
+									'https://www.open.spotify.com/album/foo',
+									'www.open.spotify.com/album/foo',
+									'open.spotify.com/album/foo',
+								],
+								'<div style="position: relative; padding-bottom: 100%; height: 0; padding-bottom: 126%;">' +
+									'<iframe src="https://open.spotify.com/embed/album/foo" ' +
+										'style="position: absolute; width: 100%; height: 100%; top: 0; left: 0;" ' +
+										'frameborder="0" allowtransparency="true" allow="encrypted-media">' +
+									'</iframe>' +
+								'</div>' );
+							} );
+
+							it( 'upcasts the URL (track)', () => {
+								testMediaUpcast( [
+									'https://www.open.spotify.com/track/foo',
+									'www.open.spotify.com/track/foo',
+									'open.spotify.com/track/foo',
+								],
+								'<div style="position: relative; padding-bottom: 100%; height: 0; padding-bottom: 126%;">' +
+									'<iframe src="https://open.spotify.com/embed/track/foo" ' +
+										'style="position: absolute; width: 100%; height: 100%; top: 0; left: 0;" ' +
+										'frameborder="0" allowtransparency="true" allow="encrypted-media">' +
+									'</iframe>' +
+								'</div>' );
+							} );
+						} );
+
+						it( 'upcasts the URL (youtube)', () => {
+							testMediaUpcast( [
+								'https://www.youtube.com/watch?v=foo',
+								'www.youtube.com/watch?v=foo',
+								'youtube.com/watch?v=foo',
+
+								'https://www.youtube.com/v/foo',
+								'www.youtube.com/v/foo',
+								'youtube.com/v/foo',
+
+								'https://www.youtube.com/embed/foo',
+								'www.youtube.com/embed/foo',
+								'youtube.com/embed/foo',
+
+								'https://youtu.be/foo',
+								'youtu.be/foo'
+							],
+							'<div style="position: relative; padding-bottom: 100%; height: 0; padding-bottom: 56.2493%;">' +
+								'<iframe src="https://www.youtube.com/embed/foo" ' +
+									'style="position: absolute; width: 100%; height: 100%; top: 0; left: 0;" ' +
+									'frameborder="0" allow="autoplay; encrypted-media" allowfullscreen="">' +
+								'</iframe>' +
+							'</div>' );
+						} );
+
+						it( 'upcasts the URL (vimeo)', () => {
+							testMediaUpcast( [
+								'https://www.vimeo.com/1234',
+								'www.vimeo.com/1234',
+								'vimeo.com/1234',
+
+								'https://www.vimeo.com/foo/foo/video/1234',
+								'www.vimeo.com/foo/foo/video/1234',
+								'vimeo.com/foo/foo/video/1234',
+
+								'https://www.vimeo.com/album/foo/video/1234',
+								'www.vimeo.com/album/foo/video/1234',
+								'vimeo.com/album/foo/video/1234',
+
+								'https://www.vimeo.com/channels/foo/1234',
+								'www.vimeo.com/channels/foo/1234',
+								'vimeo.com/channels/foo/1234',
+
+								'https://www.vimeo.com/groups/foo/videos/1234',
+								'www.vimeo.com/groups/foo/videos/1234',
+								'vimeo.com/groups/foo/videos/1234',
+
+								'https://www.vimeo.com/ondemand/foo/1234',
+								'www.vimeo.com/ondemand/foo/1234',
+								'vimeo.com/ondemand/foo/1234',
+
+								'https://www.player.vimeo.com/video/1234',
+								'www.player.vimeo.com/video/1234',
+								'player.vimeo.com/video/1234'
+							],
+							'<div style="position: relative; padding-bottom: 100%; height: 0; padding-bottom: 56.2493%;">' +
+								'<iframe src="https://player.vimeo.com/video/1234" ' +
+									'style="position: absolute; width: 100%; height: 100%; top: 0; left: 0;" ' +
+									'frameborder="0" webkitallowfullscreen="" mozallowfullscreen="" allowfullscreen="">' +
+								'</iframe>' +
+							'</div>' );
+						} );
+					} );
+
+					describe( 'preview-less', () => {
+						it( 'upcasts the URL (instagram)', () => {
+							testMediaUpcast( [
+								'https://www.instagram.com/p/foo',
+								'www.instagram.com/p/foo',
+								'instagram.com/p/foo',
+							] );
+						} );
+
+						it( 'upcasts the URL (twitter)', () => {
+							testMediaUpcast( [
+								'https://www.twitter.com/foo/bar',
+								'www.twitter.com/foo/bar',
+								'twitter.com/foo/bar',
+							] );
+						} );
+
+						it( 'upcasts the URL (google maps)', () => {
+							testMediaUpcast( [
+								'https://www.google.com/maps/foo',
+								'www.google.com/maps/foo',
+								'google.com/maps/foo',
+							] );
+						} );
+
+						it( 'upcasts the URL (flickr)', () => {
+							testMediaUpcast( [
+								'https://www.flickr.com/foo/bar',
+								'www.flickr.com/foo/bar',
+								'flickr.com/foo/bar',
+							] );
+						} );
+
+						it( 'upcasts the URL (facebook)', () => {
+							testMediaUpcast( [
+								'https://www.facebook.com/foo/bar',
+								'www.facebook.com/foo/bar',
+								'facebook.com/foo/bar',
+							] );
+						} );
+					} );
+				} );
+			} );
+
+			describe( '#extraProviders', () => {
+				it( 'should warn when provider has no name', () => {
+					const logSpy = testUtils.sinon.stub( log, 'warn' );
+					const provider = {
+						url: /.*/
+					};
+
+					return createTestEditor( {
+						extraProviders: [ provider ]
+					} ).then( () => {
+						expect( logSpy.calledOnce ).to.equal( true );
+						expect( logSpy.firstCall.args[ 0 ] ).to.match( /^media-embed-no-provider-name:/ );
+						expect( logSpy.firstCall.args[ 1 ].provider ).to.equal( provider );
+					} );
+				} );
+
+				it( 'extend #providers but with the lower priority', () => {
+					return createTestEditor( {
+						providers: [
+							testProviders.A,
+						],
+						extraProviders: [
+							testProviders.extraA,
+							testProviders.extraB,
+						]
+					} ).then( editor => {
+						editor.setData( '<figure class="media"><div data-oembed-url="foo.com/123"></div></figure>' );
+
+						expect( getViewData( editor.editing.view, { withoutSelection: true, renderUIElements: true } ) ).to.equal(
+							'<figure class="ck-widget media" contenteditable="false">' +
+								'<div class="ck-media__wrapper" data-oembed-url="foo.com/123">' +
+									'A, id=123' +
+								'</div>' +
+							'</figure>'
+						);
+
+						editor.setData( '<figure class="media"><div data-oembed-url="anything.com/123"></div></figure>' );
+
+						expect( getViewData( editor.editing.view, { withoutSelection: true, renderUIElements: true } ) ).to.equal(
+							'<figure class="ck-widget media" contenteditable="false">' +
+								'<div class="ck-media__wrapper" data-oembed-url="anything.com/123">' +
+									'extraB, id=123' +
+								'</div>' +
+							'</figure>'
+						);
+					} );
+				} );
+			} );
+
+			describe( '#removeProviders', () => {
+				it( 'removes #providers', () => {
+					return createTestEditor( {
+						providers: [
+							testProviders.A,
+							testProviders.B
+						],
+						removeProviders: [ 'A' ]
+					} ).then( editor => {
+						editor.setData(
+							'<figure class="media"><div data-oembed-url="foo.com/123"></div></figure>' +
+							'<figure class="media"><div data-oembed-url="bar.com/123"></div></figure>' );
+
+						expect( getViewData( editor.editing.view, { withoutSelection: true, renderUIElements: true } ) ).to.equal(
+							'<figure class="ck-widget media" contenteditable="false">' +
+								'<div class="ck-media__wrapper" data-oembed-url="bar.com/123">' +
+									'B, id=123' +
+								'</div>' +
+							'</figure>'
+						);
+					} );
+				} );
+
+				it( 'removes #extraProviders', () => {
+					return createTestEditor( {
+						providers: [],
+						extraProviders: [
+							testProviders.A,
+							testProviders.B,
+						],
+						removeProviders: [ 'A' ]
+					} ).then( editor => {
+						editor.setData(
+							'<figure class="media"><div data-oembed-url="foo.com/123"></div></figure>' +
+							'<figure class="media"><div data-oembed-url="bar.com/123"></div></figure>' );
+
+						expect( getViewData( editor.editing.view, { withoutSelection: true, renderUIElements: true } ) ).to.equal(
+							'<figure class="ck-widget media" contenteditable="false">' +
+								'<div class="ck-media__wrapper" data-oembed-url="bar.com/123">' +
+									'B, id=123' +
+								'</div>' +
+							'</figure>'
+						);
+					} );
+				} );
+
+				it( 'removes even when the name of the provider repeats', () => {
+					return createTestEditor( {
+						providers: [
+							testProviders.A,
+							testProviders.A
+						],
+						extraProviders: [
+							testProviders.A,
+							testProviders.A,
+							testProviders.B
+						],
+						removeProviders: [ 'A' ]
+					} ).then( editor => {
+						editor.setData(
+							'<figure class="media"><div data-oembed-url="foo.com/123"></div></figure>' +
+							'<figure class="media"><div data-oembed-url="bar.com/123"></div></figure>' );
+
+						expect( getViewData( editor.editing.view, { withoutSelection: true, renderUIElements: true } ) ).to.equal(
+							'<figure class="ck-widget media" contenteditable="false">' +
+								'<div class="ck-media__wrapper" data-oembed-url="bar.com/123">' +
+									'B, id=123' +
+								'</div>' +
+							'</figure>'
+						);
+					} );
+				} );
+			} );
+		} );
+	} );
+
+	describe( 'init()', () => {
+		const providerDefinitions = [
+			testProviders.previewLess,
+			testProviders.allowEverything
+		];
+
+		it( 'should be loaded', () => {
+			return createTestEditor()
+				.then( newEditor => {
+					expect( newEditor.plugins.get( MediaEmbedEditing ) ).to.be.instanceOf( MediaEmbedEditing );
+				} );
+		} );
+
+		it( 'should set proper schema rules', () => {
+			return createTestEditor()
+				.then( newEditor => {
+					model = newEditor.model;
+
+					expect( model.schema.checkChild( [ '$root' ], 'media' ) ).to.be.true;
+					expect( model.schema.checkAttribute( [ '$root', 'media' ], 'url' ) ).to.be.true;
+
+					expect( model.schema.isObject( 'media' ) ).to.be.true;
+
+					expect( model.schema.checkChild( [ '$root', 'media' ], 'media' ) ).to.be.false;
+					expect( model.schema.checkChild( [ '$root', 'media' ], '$text' ) ).to.be.false;
+					expect( model.schema.checkChild( [ '$root', '$block' ], 'image' ) ).to.be.false;
+				} );
+		} );
+
+		describe( 'conversion in the data pipeline', () => {
+			describe( 'semanticDataOutput=true', () => {
+				beforeEach( () => {
+					return createTestEditor( {
+						semanticDataOutput: true,
+						providers: providerDefinitions
+					} )
+						.then( newEditor => {
+							editor = newEditor;
+							model = editor.model;
+							doc = model.document;
+							view = editor.editing.view;
+						} );
+				} );
+
+				describe( 'model to view', () => {
+					it( 'should convert', () => {
+						setModelData( model, '<media url="https://ckeditor.com"></media>' );
+
+						expect( editor.getData() ).to.equal(
+							'<figure class="media">' +
+								'<oembed url="https://ckeditor.com"></oembed>' +
+							'</figure>' );
+					} );
+
+					it( 'should convert (no url)', () => {
+						setModelData( model, '<media></media>' );
+
+						expect( editor.getData() ).to.equal(
+							'<figure class="media">' +
+								'<oembed></oembed>' +
+							'</figure>' );
+					} );
+
+					it( 'should convert (preview-less media)', () => {
+						setModelData( model, '<media url="https://preview-less"></media>' );
+
+						expect( editor.getData() ).to.equal(
+							'<figure class="media">' +
+								'<oembed url="https://preview-less"></oembed>' +
+							'</figure>' );
+					} );
+				} );
+
+				describe( 'view to model', () => {
+					it( 'should convert media figure', () => {
+						editor.setData( '<figure class="media"><oembed url="https://ckeditor.com"></oembed></figure>' );
+
+						expect( getModelData( model, { withoutSelection: true } ) )
+							.to.equal( '<media url="https://ckeditor.com"></media>' );
+					} );
+
+					it( 'should not convert if there is no media class', () => {
+						editor.setData( '<figure class="quote">My quote</figure>' );
+
+						expect( getModelData( model, { withoutSelection: true } ) )
+							.to.equal( '' );
+					} );
+
+					it( 'should not convert if there is no oembed wrapper inside #1', () => {
+						editor.setData( '<figure class="media"></figure>' );
+
+						expect( getModelData( model, { withoutSelection: true } ) )
+							.to.equal( '' );
+					} );
+
+					it( 'should not convert if there is no oembed wrapper inside #2', () => {
+						editor.setData( '<figure class="media">test</figure>' );
+
+						expect( getModelData( model, { withoutSelection: true } ) )
+							.to.equal( '' );
+					} );
+
+					it( 'should not convert when the wrapper has no data-oembed-url attribute', () => {
+						editor.setData( '<figure class="media"><div></div></figure>' );
+
+						expect( getModelData( model, { withoutSelection: true } ) )
+							.to.equal( '' );
+					} );
+
+					it( 'should not convert in the wrong context', () => {
+						model.schema.register( 'blockquote', { inheritAllFrom: '$block' } );
+						model.schema.addChildCheck( ( ctx, childDef ) => {
+							if ( ctx.endsWith( '$root' ) && childDef.name == 'media' ) {
+								return false;
+							}
+						} );
+
+						editor.conversion.elementToElement( { model: 'blockquote', view: 'blockquote' } );
+
+						editor.setData(
+							'<blockquote><figure class="media"><oembed url="https://ckeditor.com"></oembed></figure></blockquote>' );
+
+						expect( getModelData( model, { withoutSelection: true } ) )
+							.to.equal( '<blockquote></blockquote>' );
+					} );
+
+					it( 'should not convert if the oembed wrapper is already consumed', () => {
+						editor.data.upcastDispatcher.on( 'element:figure', ( evt, data, conversionApi ) => {
+							const img = data.viewItem.getChild( 0 );
+							conversionApi.consumable.consume( img, { name: true } );
+						}, { priority: 'high' } );
+
+						editor.setData( '<figure class="media"><oembed url="https://ckeditor.com"></oembed></figure>' );
+
+						expect( getModelData( model, { withoutSelection: true } ) )
+							.to.equal( '' );
+					} );
+
+					it( 'should not convert if the figure is already consumed', () => {
+						editor.data.upcastDispatcher.on( 'element:figure', ( evt, data, conversionApi ) => {
+							conversionApi.consumable.consume( data.viewItem, { name: true, class: 'image' } );
+						}, { priority: 'high' } );
+
+						editor.setData( '<figure class="media"><oembed url="https://ckeditor.com"></oembed></figure>' );
+
+						expect( getModelData( model, { withoutSelection: true } ) )
+							.to.equal( '' );
+					} );
+
+					it( 'should discard the contents of the media', () => {
+						editor.setData( '<figure class="media"><oembed url="https://ckeditor.com">foo bar</oembed></figure>' );
+
+						expect( getModelData( model, { withoutSelection: true } ) )
+							.to.equal( '<media url="https://ckeditor.com"></media>' );
+					} );
+
+					it( 'should not convert unknown media', () => {
+						return createTestEditor( {
+							semanticDataOutput: true,
+							providers: [
+								testProviders.A
+							]
+						} )
+							.then( newEditor => {
+								newEditor.setData(
+									'<figure class="media"><oembed url="unknown.media"></oembed></figure>' +
+									'<figure class="media"><oembed url="foo.com/123"></oembed></figure>' );
+
+								expect( getModelData( newEditor.model, { withoutSelection: true } ) )
+									.to.equal( '<media url="foo.com/123"></media>' );
+
+								return newEditor.destroy();
+							} );
+					} );
+				} );
+			} );
+
+			describe( 'semanticDataOutput=false', () => {
+				beforeEach( () => {
+					return createTestEditor( {
+						providers: providerDefinitions
+					} )
+						.then( newEditor => {
+							editor = newEditor;
+							model = editor.model;
+							doc = model.document;
+							view = editor.editing.view;
+						} );
+				} );
+
+				describe( 'conversion in the data pipeline', () => {
+					describe( 'model to view', () => {
+						it( 'should convert', () => {
+							setModelData( model, '<media url="https://ckeditor.com"></media>' );
+
+							expect( editor.getData() ).to.equal(
+								'<figure class="media">' +
+									'<div data-oembed-url="https://ckeditor.com">' +
+										'allow-everything, id=https://ckeditor.com' +
+									'</div>' +
+								'</figure>' );
+						} );
+
+						it( 'should convert (no url)', () => {
+							setModelData( model, '<media></media>' );
+
+							expect( editor.getData() ).to.equal(
+								'<figure class="media">' +
+									'<oembed>' +
+									'</oembed>' +
+								'</figure>' );
+						} );
+
+						it( 'should convert (preview-less media)', () => {
+							setModelData( model, '<media url="https://preview-less"></media>' );
+
+							expect( editor.getData() ).to.equal(
+								'<figure class="media">' +
+									'<oembed url="https://preview-less"></oembed>' +
+								'</figure>' );
+						} );
+					} );
+
+					describe( 'view to model', () => {
+						it( 'should convert media figure', () => {
+							editor.setData(
+								'<figure class="media">' +
+									'<div data-oembed-url="https://ckeditor.com">' +
+										'allow-everything, id=https://cksource.com></iframe>' +
+									'</div>' +
+								'</figure>' );
+
+							expect( getModelData( model, { withoutSelection: true } ) )
+								.to.equal( '<media url="https://ckeditor.com"></media>' );
+						} );
+
+						it( 'should not convert if there is no media class', () => {
+							editor.setData( '<figure class="quote">My quote</figure>' );
+
+							expect( getModelData( model, { withoutSelection: true } ) )
+								.to.equal( '' );
+						} );
+
+						it( 'should not convert if there is no oembed wrapper inside #1', () => {
+							editor.setData( '<figure class="media"></figure>' );
+
+							expect( getModelData( model, { withoutSelection: true } ) )
+								.to.equal( '' );
+						} );
+
+						it( 'should not convert if there is no oembed wrapper inside #2', () => {
+							editor.setData( '<figure class="media">test</figure>' );
+
+							expect( getModelData( model, { withoutSelection: true } ) )
+								.to.equal( '' );
+						} );
+
+						it( 'should not convert in the wrong context', () => {
+							model.schema.register( 'div', { inheritAllFrom: '$block' } );
+							model.schema.addChildCheck( ( ctx, childDef ) => {
+								if ( ctx.endsWith( '$root' ) && childDef.name == 'media' ) {
+									return false;
+								}
+							} );
+
+							editor.conversion.elementToElement( { model: 'div', view: 'div' } );
+
+							editor.setData(
+								'<div>' +
+									'<figure class="media">' +
+										'<div data-oembed-url="https://ckeditor.com">' +
+											'<iframe src="this should be discarded"></iframe>' +
+										'</div>' +
+									'</figure>' +
+								'</div>' );
+
+							expect( getModelData( model, { withoutSelection: true } ) )
+								.to.equal( '<div></div>' );
+						} );
+
+						it( 'should not convert if the oembed wrapper is already consumed', () => {
+							editor.data.upcastDispatcher.on( 'element:figure', ( evt, data, conversionApi ) => {
+								const img = data.viewItem.getChild( 0 );
+								conversionApi.consumable.consume( img, { name: true } );
+							}, { priority: 'high' } );
+
+							editor.setData(
+								'<div>' +
+									'<figure class="media">' +
+										'<div data-oembed-url="https://ckeditor.com">' +
+											'<iframe src="this should be discarded"></iframe>' +
+										'</div>' +
+									'</figure>' +
+								'</div>' );
+
+							expect( getModelData( model, { withoutSelection: true } ) )
+								.to.equal( '' );
+						} );
+
+						it( 'should not convert if the figure is already consumed', () => {
+							editor.data.upcastDispatcher.on( 'element:figure', ( evt, data, conversionApi ) => {
+								conversionApi.consumable.consume( data.viewItem, { name: true, class: 'image' } );
+							}, { priority: 'high' } );
+
+							editor.setData( '<figure class="media"><div data-oembed-url="https://ckeditor.com"></div></figure>' );
+
+							expect( getModelData( model, { withoutSelection: true } ) )
+								.to.equal( '' );
+						} );
+
+						it( 'should discard the contents of the media', () => {
+							editor.setData(
+								'<figure class="media">' +
+									'<div data-oembed-url="https://ckeditor.com">' +
+										'foo bar baz' +
+									'</div>' +
+								'</figure>' );
+
+							expect( getModelData( model, { withoutSelection: true } ) )
+								.to.equal( '<media url="https://ckeditor.com"></media>' );
+						} );
+
+						it( 'should not convert unknown media', () => {
+							return createTestEditor( {
+								providers: [
+									testProviders.A
+								]
+							} )
+								.then( newEditor => {
+									newEditor.setData(
+										'<figure class="media">' +
+											'<div data-oembed-url="foo.com/123"></div>' +
+										'</figure>' +
+										'<figure class="media">' +
+											'<div data-oembed-url="unknown.media/123"></div>' +
+										'</figure>' );
+
+									expect( getModelData( newEditor.model, { withoutSelection: true } ) )
+										.to.equal( '<media url="foo.com/123"></media>' );
+
+									return newEditor.destroy();
+								} );
+						} );
+					} );
+				} );
+			} );
+		} );
+
+		describe( 'conversion in the editing pipeline', () => {
+			describe( 'semanticDataOutput=true', () => {
+				beforeEach( () => {
+					return createTestEditor( {
+						providers: providerDefinitions,
+						semanticDataOutput: true
+					} )
+						.then( newEditor => {
+							editor = newEditor;
+							model = editor.model;
+							doc = model.document;
+							view = editor.editing.view;
+						} );
+				} );
+
+				test();
+			} );
+
+			describe( 'semanticDataOutput=false', () => {
+				beforeEach( () => {
+					return createTestEditor( {
+						providers: providerDefinitions
+					} )
+						.then( newEditor => {
+							editor = newEditor;
+							model = editor.model;
+							doc = model.document;
+							view = editor.editing.view;
+						} );
+				} );
+
+				test();
+			} );
+
+			function test() {
+				describe( 'model to view', () => {
+					it( 'should convert', () => {
+						setModelData( model, '<media url="https://ckeditor.com"></media>' );
+
+						expect( getViewData( view, { withoutSelection: true, renderUIElements: true } ) ).to.equal(
+							'<figure class="ck-widget media" contenteditable="false">' +
+								'<div class="ck-media__wrapper" data-oembed-url="https://ckeditor.com">' +
+									'allow-everything, id=https://ckeditor.com' +
+								'</div>' +
+							'</figure>'
+						);
+					} );
+
+					it( 'should convert the url attribute change', () => {
+						setModelData( model, '<media url="https://ckeditor.com"></media>' );
+						const media = doc.getRoot().getChild( 0 );
+
+						model.change( writer => {
+							writer.setAttribute( 'url', 'https://cksource.com', media );
+						} );
+
+						expect( getViewData( view, { withoutSelection: true, renderUIElements: true } ) ).to.equal(
+							'<figure class="ck-widget media" contenteditable="false">' +
+								'<div class="ck-media__wrapper" data-oembed-url="https://cksource.com">' +
+									'allow-everything, id=https://cksource.com' +
+								'</div>' +
+							'</figure>'
+						);
+					} );
+
+					it( 'should convert the url attribute removal', () => {
+						setModelData( model, '<media url="https://ckeditor.com"></media>' );
+						const media = doc.getRoot().getChild( 0 );
+
+						model.change( writer => {
+							writer.removeAttribute( 'url', media );
+						} );
+
+						expect( getViewData( view, { withoutSelection: true, renderUIElements: true } ) )
+							.to.equal(
+								'<figure class="ck-widget media" contenteditable="false">' +
+									'<div class="ck-media__wrapper">' +
+									'</div>' +
+								'</figure>'
+							);
+					} );
+
+					it( 'should not convert the url attribute removal if is already consumed', () => {
+						setModelData( model, '<media url="https://ckeditor.com"></media>' );
+						const media = doc.getRoot().getChild( 0 );
+
+						editor.editing.downcastDispatcher.on( 'attribute:url:media', ( evt, data, conversionApi ) => {
+							conversionApi.consumable.consume( data.item, 'attribute:url' );
+						}, { priority: 'high' } );
+
+						model.change( writer => {
+							writer.removeAttribute( 'url', media );
+						} );
+
+						expect( getViewData( view, { withoutSelection: true, renderUIElements: true } ) ).to.equal(
+							'<figure class="ck-widget media" contenteditable="false">' +
+								'<div class="ck-media__wrapper" data-oembed-url="https://ckeditor.com">' +
+									'allow-everything, id=https://ckeditor.com' +
+								'</div>' +
+							'</figure>'
+						);
+					} );
+				} );
+			}
+		} );
+	} );
+
+	function testMediaUpcast( urls, expected ) {
+		for ( const url of urls ) {
+			editor.setData(
+				`<figure class="media"><div data-oembed-url="${ url }"></div></figure>` );
+
+			const viewData = getViewData( view, { withoutSelection: true, renderUIElements: true } );
+			let expectedRegExp;
+
+			if ( expected ) {
+				expectedRegExp = new RegExp(
+					'<figure[^>]+>' +
+						'<div[^>]+>' +
+							normalizeHtml( expected ) +
+						'</div>' +
+					'</figure>' );
+			} else {
+				expectedRegExp = new RegExp(
+					'<figure[^>]+>' +
+						'<div[^>]+>' +
+							'<div class="ck ck-media__placeholder ck-reset_all">' +
+								'<div class="ck-media__placeholder__icon">.*</div>' +
+								`<a class="ck-media__placeholder__url" href="${ url }" target="new">` +
+									`<span class="ck-media__placeholder__url__text">${ url }</span>` +
+									'<span class="ck ck-tooltip ck-tooltip_s">' +
+										'<span class="ck ck-tooltip__text">Open media in new tab</span>' +
+									'</span>' +
+								'</a>' +
+							'</div>' +
+						'</div>' +
+					'</figure>' );
+			}
+
+			expect( normalizeHtml( viewData ) ).to.match( expectedRegExp, `assertion for "${ url }"` );
+		}
+	}
+
+	function createTestEditor( mediaEmbedConfig ) {
+		return VirtualTestEditor
+			.create( {
+				plugins: [ MediaEmbedEditing ],
+				mediaEmbed: mediaEmbedConfig
+			} );
+	}
+} );

+ 225 - 0
packages/ckeditor5-media-embed/tests/mediaembedui.js

@@ -0,0 +1,225 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import MediaEmbed from '../src/mediaembed';
+import MediaFormView from '../src/ui/mediaformview';
+import DropdownView from '@ckeditor/ckeditor5-ui/src/dropdown/dropdownview';
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+import mediaIcon from '../theme/icons/media.svg';
+
+describe( 'MediaEmbedUI', () => {
+	let editorElement, editor, dropdown, button, form;
+
+	beforeEach( () => {
+		editorElement = global.document.createElement( 'div' );
+		global.document.body.appendChild( editorElement );
+
+		return ClassicTestEditor
+			.create( editorElement, {
+				plugins: [ MediaEmbed ],
+				mediaEmbed: {
+					providers: [
+						{
+							name: 'valid-media',
+							url: /^https:\/\/valid\/(.*)/,
+							html: id => `<iframe src="${ id }"></iframe>`
+						}
+					]
+				}
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				dropdown = editor.ui.componentFactory.create( 'mediaEmbed' );
+				button = dropdown.buttonView;
+				form = dropdown.panelView.children.get( 0 );
+			} );
+	} );
+
+	afterEach( () => {
+		editorElement.remove();
+
+		return editor.destroy();
+	} );
+
+	it( 'should add the "mediaEmbed" component to the factory', () => {
+		expect( dropdown ).to.be.instanceOf( DropdownView );
+	} );
+
+	describe( 'dropdown', () => {
+		it( 'should bind #isEnabled to the command', () => {
+			const command = editor.commands.get( 'mediaEmbed' );
+
+			expect( dropdown.isEnabled ).to.be.true;
+
+			command.isEnabled = false;
+			expect( dropdown.isEnabled ).to.be.false;
+		} );
+
+		it( 'should add a form to the panelView#children collection', () => {
+			expect( dropdown.panelView.children.length ).to.equal( 1 );
+			expect( dropdown.panelView.children.get( 0 ) ).to.be.instanceOf( MediaFormView );
+		} );
+
+		describe( 'button', () => {
+			it( 'should set a #label of the #buttonView', () => {
+				expect( dropdown.buttonView.label ).to.equal( 'Insert media' );
+			} );
+
+			it( 'should set an #icon of the #buttonView', () => {
+				expect( dropdown.buttonView.icon ).to.equal( mediaIcon );
+			} );
+
+			it( 'should enable tooltips for the #buttonView', () => {
+				expect( dropdown.buttonView.tooltip ).to.be.true;
+			} );
+
+			describe( '#open event', () => {
+				it( 'executes the actions with the "low" priority', () => {
+					const spy = sinon.spy();
+					const selectSpy = sinon.spy( form.urlInputView, 'select' );
+
+					button.on( 'open', () => {
+						spy();
+					} );
+
+					button.fire( 'open' );
+					sinon.assert.callOrder( spy, selectSpy );
+				} );
+
+				it( 'should update form\'s #url', () => {
+					const command = editor.commands.get( 'mediaEmbed' );
+
+					button.fire( 'open' );
+					expect( form.url ).to.equal( '' );
+
+					command.value = 'foo';
+					button.fire( 'open' );
+					expect( form.url ).to.equal( 'foo' );
+				} );
+
+				it( 'should select the content of the input', () => {
+					const spy = sinon.spy( form.urlInputView, 'select' );
+
+					button.fire( 'open' );
+					sinon.assert.calledOnce( spy );
+				} );
+
+				it( 'should focus the form', () => {
+					const spy = sinon.spy( form, 'focus' );
+
+					button.fire( 'open' );
+					sinon.assert.calledOnce( spy );
+				} );
+			} );
+		} );
+
+		describe( '#submit event', () => {
+			it( 'checks if the form is valid', () => {
+				const spy = sinon.spy( form, 'isValid' );
+
+				dropdown.fire( 'submit' );
+
+				sinon.assert.calledOnce( spy );
+			} );
+
+			it( 'executes the command and closes the UI (if the form is valid)', () => {
+				const viewFocusSpy = sinon.spy( editor.editing.view, 'focus' );
+				const commandSpy = sinon.spy( editor.commands.get( 'mediaEmbed' ), 'execute' );
+
+				// The form is invalid.
+				form.url = 'https://invalid/url';
+				dropdown.isOpen = true;
+
+				dropdown.fire( 'submit' );
+
+				sinon.assert.notCalled( commandSpy );
+				sinon.assert.notCalled( viewFocusSpy );
+				expect( dropdown.isOpen ).to.be.true;
+
+				// The form is valid.
+				form.url = 'https://valid/url';
+				dropdown.fire( 'submit' );
+
+				sinon.assert.calledOnce( commandSpy );
+				sinon.assert.calledWithExactly( commandSpy, 'https://valid/url' );
+				sinon.assert.calledOnce( viewFocusSpy );
+				expect( dropdown.isOpen ).to.be.false;
+			} );
+		} );
+
+		describe( '#change:isOpen event', () => {
+			it( 'resets form errors', () => {
+				const spy = sinon.spy( form, 'resetErrors' );
+
+				dropdown.fire( 'change:isOpen' );
+
+				sinon.assert.calledOnce( spy );
+			} );
+		} );
+
+		describe( '#cancel event', () => {
+			it( 'closes the UI', () => {
+				const viewFocusSpy = sinon.spy( editor.editing.view, 'focus' );
+
+				dropdown.isOpen = true;
+				dropdown.fire( 'cancel' );
+
+				sinon.assert.calledOnce( viewFocusSpy );
+				expect( dropdown.isOpen ).to.be.false;
+			} );
+		} );
+	} );
+
+	describe( 'form', () => {
+		it( 'delegates #submit to the dropdown', done => {
+			dropdown.once( 'submit', () => done() );
+
+			form.fire( 'submit' );
+		} );
+
+		it( 'delegates #cancel to the dropdown', done => {
+			dropdown.once( 'submit', () => done() );
+
+			form.fire( 'submit' );
+		} );
+
+		it( 'binds urlInputView#isReadOnly to command#isEnabled', () => {
+			const command = editor.commands.get( 'mediaEmbed' );
+
+			expect( form.urlInputView.isReadOnly ).to.be.false;
+
+			command.isEnabled = false;
+			expect( form.urlInputView.isReadOnly ).to.be.true;
+		} );
+
+		it( 'binds saveButtonView#isEnabled to command#isEnabled', () => {
+			const command = editor.commands.get( 'mediaEmbed' );
+
+			expect( form.saveButtonView.isEnabled ).to.be.true;
+
+			command.isEnabled = false;
+			expect( form.saveButtonView.isEnabled ).to.be.false;
+		} );
+
+		describe( 'validators', () => {
+			it( 'check the empty URL', () => {
+				form.url = '';
+				expect( form.isValid() ).to.be.false;
+
+				form.url = 'https://valid/url';
+				expect( form.isValid() ).to.be.true;
+			} );
+
+			it( 'check the supported media', () => {
+				form.url = 'https://invalid/url';
+				expect( form.isValid() ).to.be.false;
+
+				form.url = 'https://valid/url';
+				expect( form.isValid() ).to.be.true;
+			} );
+		} );
+	} );
+} );

+ 293 - 0
packages/ckeditor5-media-embed/tests/ui/mediaformview.js

@@ -0,0 +1,293 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals Event */
+
+import MediaFormView from '../../src/ui/mediaformview';
+import View from '@ckeditor/ckeditor5-ui/src/view';
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
+import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
+import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
+import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler';
+import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+
+describe( 'MediaFormView', () => {
+	let view;
+
+	testUtils.createSinonSandbox();
+
+	beforeEach( () => {
+		view = new MediaFormView( [], { t: val => val } );
+		view.render();
+	} );
+
+	describe( 'constructor()', () => {
+		it( 'accepts validators', () => {
+			const validators = [];
+			const view = new MediaFormView( validators, { t: val => val } );
+
+			expect( view._validators ).to.equal( validators );
+		} );
+
+		it( 'should create element from template', () => {
+			expect( view.element.classList.contains( 'ck' ) ).to.true;
+			expect( view.element.classList.contains( 'ck-media-form' ) ).to.true;
+			expect( view.element.getAttribute( 'tabindex' ) ).to.equal( '-1' );
+		} );
+
+		it( 'should create child views', () => {
+			expect( view.urlInputView ).to.be.instanceOf( View );
+			expect( view.saveButtonView ).to.be.instanceOf( View );
+			expect( view.cancelButtonView ).to.be.instanceOf( View );
+
+			expect( view.saveButtonView.element.classList.contains( 'ck-button-save' ) ).to.be.true;
+			expect( view.cancelButtonView.element.classList.contains( 'ck-button-cancel' ) ).to.be.true;
+
+			expect( view._unboundChildren.get( 0 ) ).to.equal( view.urlInputView );
+			expect( view._unboundChildren.get( 1 ) ).to.equal( view.saveButtonView );
+			expect( view._unboundChildren.get( 2 ) ).to.equal( view.cancelButtonView );
+		} );
+
+		it( 'should create #focusTracker instance', () => {
+			expect( view.focusTracker ).to.be.instanceOf( FocusTracker );
+		} );
+
+		it( 'should create #keystrokes instance', () => {
+			expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler );
+		} );
+
+		it( 'should create #_focusCycler instance', () => {
+			expect( view._focusCycler ).to.be.instanceOf( FocusCycler );
+		} );
+
+		it( 'should create #_focusables view collection', () => {
+			expect( view._focusables ).to.be.instanceOf( ViewCollection );
+		} );
+
+		it( 'should fire "cancel" event on cancelButtonView#execute', () => {
+			const spy = sinon.spy();
+
+			view.on( 'cancel', spy );
+
+			view.cancelButtonView.fire( 'execute' );
+
+			expect( spy.calledOnce ).to.true;
+		} );
+
+		describe( 'url input view', () => {
+			it( 'has placeholder', () => {
+				expect( view.urlInputView.inputView.placeholder ).to.equal( 'https://example.com' );
+			} );
+		} );
+
+		describe( 'template', () => {
+			it( 'has url input view', () => {
+				expect( view.template.children[ 0 ] ).to.equal( view.urlInputView );
+			} );
+
+			it( 'has button views', () => {
+				expect( view.template.children[ 1 ] ).to.equal( view.saveButtonView );
+				expect( view.template.children[ 2 ] ).to.equal( view.cancelButtonView );
+			} );
+		} );
+	} );
+
+	describe( 'render()', () => {
+		it( 'should register child views in #_focusables', () => {
+			expect( view._focusables.map( f => f ) ).to.have.members( [
+				view.urlInputView,
+				view.saveButtonView,
+				view.cancelButtonView,
+			] );
+		} );
+
+		it( 'should register child views\' #element in #focusTracker', () => {
+			const spy = testUtils.sinon.spy( FocusTracker.prototype, 'add' );
+
+			view = new MediaFormView( [], { t: () => {} } );
+			view.render();
+
+			sinon.assert.calledWithExactly( spy.getCall( 0 ), view.urlInputView.element );
+			sinon.assert.calledWithExactly( spy.getCall( 1 ), view.saveButtonView.element );
+			sinon.assert.calledWithExactly( spy.getCall( 2 ), view.cancelButtonView.element );
+		} );
+
+		it( 'starts listening for #keystrokes coming from #element', () => {
+			view = new MediaFormView( [], { t: () => {} } );
+
+			const spy = sinon.spy( view.keystrokes, 'listenTo' );
+
+			view.render();
+			sinon.assert.calledOnce( spy );
+			sinon.assert.calledWithExactly( spy, view.element );
+		} );
+
+		describe( 'activates keyboard navigation for the toolbar', () => {
+			it( 'so "tab" focuses the next focusable item', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.tab,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
+
+				// Mock the url input is focused.
+				view.focusTracker.isFocused = true;
+				view.focusTracker.focusedElement = view.urlInputView.element;
+
+				const spy = sinon.spy( view.saveButtonView, 'focus' );
+
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.calledOnce( keyEvtData.preventDefault );
+				sinon.assert.calledOnce( keyEvtData.stopPropagation );
+				sinon.assert.calledOnce( spy );
+			} );
+
+			it( 'so "shift + tab" focuses the previous focusable item', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.tab,
+					shiftKey: true,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
+
+				// Mock the cancel button is focused.
+				view.focusTracker.isFocused = true;
+				view.focusTracker.focusedElement = view.cancelButtonView.element;
+
+				const spy = sinon.spy( view.saveButtonView, 'focus' );
+
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.calledOnce( keyEvtData.preventDefault );
+				sinon.assert.calledOnce( keyEvtData.stopPropagation );
+				sinon.assert.calledOnce( spy );
+			} );
+		} );
+
+		it( 'intercepts the arrow* events and overrides the default toolbar behavior', () => {
+			const keyEvtData = {
+				stopPropagation: sinon.spy()
+			};
+
+			keyEvtData.keyCode = keyCodes.arrowdown;
+			view.keystrokes.press( keyEvtData );
+			sinon.assert.calledOnce( keyEvtData.stopPropagation );
+
+			keyEvtData.keyCode = keyCodes.arrowup;
+			view.keystrokes.press( keyEvtData );
+			sinon.assert.calledTwice( keyEvtData.stopPropagation );
+
+			keyEvtData.keyCode = keyCodes.arrowleft;
+			view.keystrokes.press( keyEvtData );
+			sinon.assert.calledThrice( keyEvtData.stopPropagation );
+
+			keyEvtData.keyCode = keyCodes.arrowright;
+			view.keystrokes.press( keyEvtData );
+			sinon.assert.callCount( keyEvtData.stopPropagation, 4 );
+		} );
+
+		it( 'intercepts the "selectstart" event of the #urlInputView with the high priority', () => {
+			const spy = sinon.spy();
+			const event = new Event( 'selectstart', {
+				bubbles: true,
+				cancelable: true
+			} );
+
+			event.stopPropagation = spy;
+
+			view.urlInputView.element.dispatchEvent( event );
+			sinon.assert.calledOnce( spy );
+		} );
+	} );
+
+	describe( 'DOM bindings', () => {
+		describe( 'submit event', () => {
+			it( 'should trigger submit event', () => {
+				const spy = sinon.spy();
+
+				view.on( 'submit', spy );
+				view.element.dispatchEvent( new Event( 'submit' ) );
+
+				expect( spy.calledOnce ).to.true;
+			} );
+		} );
+	} );
+
+	describe( 'focus()', () => {
+		it( 'focuses the #urlInputView', () => {
+			const spy = sinon.spy( view.urlInputView, 'focus' );
+
+			view.focus();
+
+			sinon.assert.calledOnce( spy );
+		} );
+	} );
+
+	describe( 'url()', () => {
+		it( 'returns the #inputView DOM value', () => {
+			view.urlInputView.inputView.element.value = 'foo';
+
+			expect( view.url ).to.equal( 'foo' );
+		} );
+
+		it( 'sets the #inputView DOM value', () => {
+			view.urlInputView.inputView.element.value = 'bar';
+
+			view.url = 'foo';
+			expect( view.urlInputView.inputView.element.value ).to.equal( 'foo' );
+
+			view.url = ' baz ';
+			expect( view.urlInputView.inputView.element.value ).to.equal( 'baz' );
+		} );
+	} );
+
+	describe( 'isValid()', () => {
+		it( 'calls resetErrors()', () => {
+			const spy = sinon.spy( view, 'resetErrors' );
+
+			view.isValid();
+
+			sinon.assert.calledOnce( spy );
+		} );
+
+		it( 'returns false when at least one validator has failed', () => {
+			const val1 = sinon.stub().returns( 'some error' );
+			const val2 = sinon.stub().returns( false );
+			const validators = [ val1, val2 ];
+			const view = new MediaFormView( validators, { t: val => val } );
+
+			expect( view.isValid() ).to.be.false;
+
+			sinon.assert.calledOnce( val1 );
+			sinon.assert.notCalled( val2 );
+
+			expect( view.urlInputView.errorText ).to.equal( 'some error' );
+		} );
+
+		it( 'returns true when all validators passed', () => {
+			const val1 = sinon.stub().returns( false );
+			const val2 = sinon.stub().returns( false );
+			const validators = [ val1, val2 ];
+			const view = new MediaFormView( validators, { t: val => val } );
+
+			expect( view.isValid() ).to.be.true;
+
+			sinon.assert.calledOnce( val1 );
+			sinon.assert.calledOnce( val2 );
+
+			expect( view.urlInputView.errorText ).to.be.false;
+		} );
+	} );
+
+	describe( 'resetErrors()', () => {
+		it( 'resets urlInputView#errorText', () => {
+			view.urlInputView.errorText = 'foo';
+
+			view.resetErrors();
+
+			expect( view.urlInputView.errorText ).to.be.false;
+		} );
+	} );
+} );

+ 1 - 0
packages/ckeditor5-media-embed/theme/icons/media-placeholder.svg

@@ -0,0 +1 @@
+<svg width="64" height="42" xmlns="http://www.w3.org/2000/svg"><path d="M47.426 17V3.713L63.102 0v19.389h-.001l.001.272c0 1.595-2.032 3.43-4.538 4.098-2.506.668-4.538-.083-4.538-1.678 0-1.594 2.032-3.43 4.538-4.098.914-.244 2.032-.565 2.888-.603V4.516L49.076 7.447v9.556A1.014 1.014 0 0 0 49 17h-1.574zM29.5 17h-8.343a7.073 7.073 0 1 0-4.657 4.06v3.781H3.3a2.803 2.803 0 0 1-2.8-2.804V8.63a2.803 2.803 0 0 1 2.8-2.805h4.082L8.58 2.768A1.994 1.994 0 0 1 10.435 1.5h8.985c.773 0 1.477.448 1.805 1.149l1.488 3.177H26.7c1.546 0 2.8 1.256 2.8 2.805V17zm-11.637 0H17.5a1 1 0 0 0-1 1v.05A4.244 4.244 0 1 1 17.863 17zm29.684 2c.97 0 .953-.048.953.889v20.743c0 .953.016.905-.953.905H19.453c-.97 0-.953.048-.953-.905V19.89c0-.937-.016-.889.97-.889h28.077zm-4.701 19.338V22.183H24.154v16.155h18.692zM20.6 21.375v1.616h1.616v-1.616H20.6zm0 3.231v1.616h1.616v-1.616H20.6zm0 3.231v1.616h1.616v-1.616H20.6zm0 3.231v1.616h1.616v-1.616H20.6zm0 3.231v1.616h1.616v-1.616H20.6zm0 3.231v1.616h1.616V37.53H20.6zm24.233-16.155v1.616h1.615v-1.616h-1.615zm0 3.231v1.616h1.615v-1.616h-1.615zm0 3.231v1.616h1.615v-1.616h-1.615zm0 3.231v1.616h1.615v-1.616h-1.615zm0 3.231v1.616h1.615v-1.616h-1.615zm0 3.231v1.616h1.615V37.53h-1.615zM29.485 25.283a.4.4 0 0 1 .593-.35l9.05 4.977a.4.4 0 0 1 0 .701l-9.05 4.978a.4.4 0 0 1-.593-.35v-9.956z" fill="#000" fill-rule="nonzero"/></svg>

+ 1 - 0
packages/ckeditor5-media-embed/theme/icons/media.svg

@@ -0,0 +1 @@
+<svg width="20" height="20" xmlns="http://www.w3.org/2000/svg"><path d="M18.68 2.53c.6 0 .59-.03.59.55v12.84c0 .59.01.56-.59.56H1.29c-.6 0-.59.03-.59-.56V3.08c0-.58-.01-.55.6-.55h17.38zM15.77 14.5v-10H4.2v10h11.57zM2 4v1h1V4H2zm0 2v1h1V6H2zm0 2v1h1V8H2zm0 2v1h1v-1H2zm0 2v1h1v-1H2zm0 2v1h1v-1H2zM17 4v1h1V4h-1zm0 2v1h1V6h-1zm0 2v1h1V8h-1zm0 2v1h1v-1h-1zm0 2v1h1v-1h-1zm0 2v1h1v-1h-1zM7.5 6.677a.4.4 0 0 1 .593-.351l5.133 2.824a.4.4 0 0 1 0 .7l-5.133 2.824a.4.4 0 0 1-.593-.35V6.676z" fill="#000" fill-rule="nonzero"/></svg>

+ 20 - 0
packages/ckeditor5-media-embed/theme/icons/media/twitter.svg

@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 20.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 400 400" style="enable-background:new 0 0 400 400;" xml:space="preserve">
+<style type="text/css">
+	.st0{fill:#1DA1F2;}
+	.st1{fill:#FFFFFF;}
+</style>
+<g id="Dark_Blue">
+	<circle class="st0" cx="200" cy="200" r="200"/>
+</g>
+<g id="Logo__x2014__FIXED">
+	<path class="st1" d="M163.4,305.5c88.7,0,137.2-73.5,137.2-137.2c0-2.1,0-4.2-0.1-6.2c9.4-6.8,17.6-15.3,24.1-25
+		c-8.6,3.8-17.9,6.4-27.7,7.6c10-6,17.6-15.4,21.2-26.7c-9.3,5.5-19.6,9.5-30.6,11.7c-8.8-9.4-21.3-15.2-35.2-15.2
+		c-26.6,0-48.2,21.6-48.2,48.2c0,3.8,0.4,7.5,1.3,11c-40.1-2-75.6-21.2-99.4-50.4c-4.1,7.1-6.5,15.4-6.5,24.2
+		c0,16.7,8.5,31.5,21.5,40.1c-7.9-0.2-15.3-2.4-21.8-6c0,0.2,0,0.4,0,0.6c0,23.4,16.6,42.8,38.7,47.3c-4,1.1-8.3,1.7-12.7,1.7
+		c-3.1,0-6.1-0.3-9.1-0.9c6.1,19.2,23.9,33.1,45,33.5c-16.5,12.9-37.3,20.6-59.9,20.6c-3.9,0-7.7-0.2-11.5-0.7
+		C110.8,297.5,136.2,305.5,163.4,305.5"/>
+</g>
+</svg>

+ 53 - 0
packages/ckeditor5-media-embed/theme/mediaembedediting.css

@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/*
+ * Note: This file should contain the wireframe styles only. But since there are no such styles,
+ * it acts as a message to the builder telling that it should look for the corresponding styles
+ * **in the theme** when compiling the editor.
+ */
+
+@import "@ckeditor/ckeditor5-ui/theme/components/tooltip/mixins/_tooltip.css";
+
+.ck-content .media {
+    & .ck-media__wrapper {
+		& > *:not(.ck-media__placeholder) {
+			pointer-events: none;
+		}
+
+		& .ck-media__placeholder {
+			display: flex;
+			flex-direction: column;
+			align-items: center;
+
+			& .ck-media__placeholder__url {
+				@mixin ck-tooltip_enabled;
+
+				/* Otherwise the URL will overflow when the content is very narrow. */
+				max-width: 100%;
+
+				position: relative;
+
+				&:hover {
+					@mixin ck-tooltip_visible;
+				}
+
+				& .ck-media__placeholder__url__text {
+					overflow: hidden;
+					display: block;
+				}
+			}
+		}
+
+		&[data-oembed-url*="twitter.com"],
+		&[data-oembed-url*="google.com/maps"],
+		&[data-oembed-url*="facebook.com"],
+		&[data-oembed-url*="instagram.com"] {
+			& .ck-media__placeholder__icon * {
+				display: none;
+			}
+		}
+	}
+}

+ 17 - 0
packages/ckeditor5-media-embed/theme/mediaform.css

@@ -0,0 +1,17 @@
+/*
+ * Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+.ck.ck-media-form {
+	display: flex;
+	align-items: flex-start;
+
+	& .ck-labeled-input {
+		display: inline-block;
+	}
+
+	& .ck-label {
+		display: none;
+	}
+}