Ver código fonte

Merge pull request #27 from ckeditor/t/6

Feature: Introduced the automatic media embedding. Closes #6.
Aleksander Nowodzinski 7 anos atrás
pai
commit
caf10b8b57

+ 14 - 2
packages/ckeditor5-media-embed/docs/features/media-embed.md

@@ -10,7 +10,7 @@ The {@link module:media-embed/mediaembed~MediaEmbed} feature brings support for
 
 ## Demo
 
-Example URLs:
+You can use the "Insert media" button in the toolbar to embed media like the following examples. You can also paste the media URL directly into the editor content and it will be [automatically embedded](#automatic-media-embed-on-paste).
 
 * <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">
@@ -65,7 +65,7 @@ By default the media embed feature outputs media in the same way it works in the
 </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:
+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 retrieving this code from an external oEmbed service. Therefore, for non previewable media it produces the semantic output:
 
 ```html
 <figure class="media">
@@ -296,6 +296,18 @@ In this case, the code is almost the same as with the semantic data but you shou
 </script>
 ```
 
+## Automatic media embed on paste
+
+By default, the `'MediaEmbed'` plugin loads the {@link module:media-embed/automediaembed~AutoMediaEmbed `'AutoMediaEmbed'`} as a dependency.
+
+The `AutoMediaEmbed` plugin recognizes media links in the pasted content and embeds them shortly after they are injected into the document to speed up the editing. Just like the "traditional" embedding (i.e. using the button in the toolbar), the automatic embedding works for all media providers specified in the [configuration](#media-providers).
+
+<info-box>
+	The media URL must be the only content pasted to be properly embedded. Multiple links (`"http://media.url http://another.media.url"`) as well as bigger chunks of the content (`"This link http://media.url will not be auto–embedded when pasted."`) are ignored.
+</info-box>
+
+If the automatic embedding was unexpected, for instance when the link was meant to remain in the content as text, simply undo the action. To do that, use the toolbar button or the {@link features/keyboard-support keystroke}.
+
 ## Styling media in editor content
 
 While the editor comes with default styles for popular media providers like Facebook, Instagram or Twitter, you can create additional styles for non-previewable media in your editor content to help users identify them.

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

@@ -10,13 +10,21 @@
   ],
   "dependencies": {
     "@ckeditor/ckeditor5-core": "^11.0.0",
+    "@ckeditor/ckeditor5-clipboard": "^10.0.2",
     "@ckeditor/ckeditor5-engine": "^10.2.0",
+    "@ckeditor/ckeditor5-enter": "^10.1.1",
     "@ckeditor/ckeditor5-ui": "^11.0.0",
     "@ckeditor/ckeditor5-utils": "^10.2.0",
+    "@ckeditor/ckeditor5-undo": "^10.0.2",
     "@ckeditor/ckeditor5-widget": "^10.2.0"
   },
   "devDependencies": {
+    "@ckeditor/ckeditor5-basic-styles": "^10.0.2",
     "@ckeditor/ckeditor5-editor-classic": "^11.0.0",
+    "@ckeditor/ckeditor5-link": "^10.0.3",
+    "@ckeditor/ckeditor5-list": "^11.0.1",
+    "@ckeditor/ckeditor5-paragraph": "^10.0.2",
+    "@ckeditor/ckeditor5-typing": "^11.0.0",
     "eslint": "^5.5.0",
     "eslint-config-ckeditor5": "^1.0.7",
     "husky": "^0.14.3",

+ 159 - 0
packages/ckeditor5-media-embed/src/automediaembed.js

@@ -0,0 +1,159 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module media-embed/automediaembed
+ */
+
+import MediaEmbedEditing from './mediaembedediting';
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
+import LiveRange from '@ckeditor/ckeditor5-engine/src/model/liverange';
+import LivePosition from '@ckeditor/ckeditor5-engine/src/model/liveposition';
+import TreeWalker from '@ckeditor/ckeditor5-engine/src/model/treewalker';
+import Undo from '@ckeditor/ckeditor5-undo/src/undo';
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+
+const URL_REGEXP = /^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w.-]+)+[\w\-._~:/?#[\]@!$&'()*+,;=]+$/;
+
+/**
+ * The auto-media embed plugin. It recognizes media links in the pasted content and embeds
+ * them shortly after they are injected into the document.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class AutoMediaEmbed extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ Clipboard, Undo ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'AutoMediaEmbed';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	constructor( editor ) {
+		super( editor );
+
+		/**
+		 * A paste–to–embed `setTimeout` id. Stored as a property to allow
+		 * cleaning of the timeout.
+		 *
+		 * @private
+		 * @member {Number} #_timeoutId
+		 */
+		this._timeoutId = null;
+
+		/**
+		 * A position where the `<media>` element will be inserted after the timeout,
+		 * determined each time the new content is pasted into the document.
+		 *
+		 * @private
+		 * @member {module:engine/model/liveposition~LivePosition} #_positionToInsert
+		 */
+		this._positionToInsert = null;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const modelDocument = editor.model.document;
+
+		// We need to listen on `Clipboard#inputTransformation` because we need to save positions of selection.
+		// After pasting, the content between those positions will be checked for a URL that could be transformed
+		// into media.
+		this.listenTo( editor.plugins.get( Clipboard ), 'inputTransformation', () => {
+			const firstRange = modelDocument.selection.getFirstRange();
+
+			const leftLivePosition = LivePosition.createFromPosition( firstRange.start );
+			leftLivePosition.stickiness = 'toPrevious';
+
+			const rightLivePosition = LivePosition.createFromPosition( firstRange.end );
+			rightLivePosition.stickiness = 'toNext';
+
+			modelDocument.once( 'change:data', () => {
+				this._embedMediaBetweenPositions( leftLivePosition, rightLivePosition );
+
+				leftLivePosition.detach();
+				rightLivePosition.detach();
+			}, { priority: 'high' } );
+		} );
+
+		editor.commands.get( 'undo' ).on( 'execute', () => {
+			if ( this._timeoutId ) {
+				global.window.clearTimeout( this._timeoutId );
+				this._positionToInsert.detach();
+
+				this._timeoutId = null;
+				this._positionToInsert = null;
+			}
+		}, { priority: 'high' } );
+	}
+
+	/**
+	 * Analyzes the part of the document between provided positions in search for a URL representing a media.
+	 * When the URL is found, it is automatically converted into media.
+	 *
+	 * @protected
+	 * @param {module:engine/model/liveposition~LivePosition} leftPosition Left position of the selection.
+	 * @param {module:engine/model/liveposition~LivePosition} rightPosition Right position of the selection.
+	 */
+	_embedMediaBetweenPositions( leftPosition, rightPosition ) {
+		const editor = this.editor;
+		const mediaRegistry = editor.plugins.get( MediaEmbedEditing ).registry;
+		const urlRange = new LiveRange( leftPosition, rightPosition );
+		const walker = new TreeWalker( { boundaries: urlRange, ignoreElementEnd: true } );
+
+		let url = '';
+
+		for ( const node of walker ) {
+			url += node.item.data;
+		}
+
+		url = url.trim();
+
+		// If the URL does not match to universal URL regexp, let's skip that.
+		if ( !url.match( URL_REGEXP ) ) {
+			return;
+		}
+
+		// If the URL represents a media, let's use it.
+		if ( !mediaRegistry.hasMedia( url ) ) {
+			return;
+		}
+
+		// Position won't be available in the `setTimeout` function so let's clone it.
+		this._positionToInsert = LivePosition.createFromPosition( leftPosition );
+
+		// This action mustn't be executed if undo was called between pasting and auto-embedding.
+		this._timeoutId = global.window.setTimeout( () => {
+			editor.model.change( writer => {
+				this._timeoutId = null;
+
+				writer.remove( urlRange );
+
+				// Check if position where the media element should be inserted is still valid.
+				if ( this._positionToInsert.root.rootName !== '$graveyard' ) {
+					writer.setSelection( this._positionToInsert );
+				}
+
+				editor.commands.execute( 'mediaEmbed', url );
+
+				this._positionToInsert.detach();
+				this._positionToInsert = null;
+			} );
+		}, 100 );
+	}
+}

+ 6 - 5
packages/ckeditor5-media-embed/src/mediaembed.js

@@ -7,17 +7,18 @@
  * @module media-embed/mediaembed
  */
 
-import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
-
 import MediaEmbedEditing from './mediaembedediting';
+import AutoMediaEmbed from './automediaembed';
 import MediaEmbedUI from './mediaembedui';
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 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}.
+ * It loads the {@link module:media-embed/mediaembedediting~MediaEmbedEditing media embed editing feature},
+ * {@link module:media-embed/mediaembedui~MediaEmbedUI media embed UI feature} and
+ * {@link module:media-embed/automediaembed~AutoMediaEmbed auto-media embed feature}.
  *
  * For a detailed overview, check the {@glink features/media-embed Media Embed feature documentation}.
  *
@@ -28,7 +29,7 @@ export default class MediaEmbed extends Plugin {
 	 * @inheritDoc
 	 */
 	static get requires() {
-		return [ MediaEmbedEditing, MediaEmbedUI, Widget ];
+		return [ MediaEmbedEditing, MediaEmbedUI, AutoMediaEmbed, Widget ];
 	}
 
 	/**

+ 530 - 0
packages/ckeditor5-media-embed/tests/automediaembed.js

@@ -0,0 +1,530 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global setTimeout */
+
+import MediaEmbed from '../src/mediaembed';
+import AutoMediaEmbed from '../src/automediaembed';
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Link from '@ckeditor/ckeditor5-link/src/link';
+import List from '@ckeditor/ckeditor5-list/src/list';
+import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
+import Undo from '@ckeditor/ckeditor5-undo/src/undo';
+import Typing from '@ckeditor/ckeditor5-typing/src/typing';
+import ModelRange from '@ckeditor/ckeditor5-engine/src/model/range';
+import ModelPosition from '@ckeditor/ckeditor5-engine/src/model/position';
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+describe( 'AutoMediaEmbed - integration', () => {
+	let editorElement, editor;
+
+	beforeEach( () => {
+		editorElement = global.document.createElement( 'div' );
+		global.document.body.appendChild( editorElement );
+
+		return ClassicTestEditor
+			.create( editorElement, {
+				plugins: [ MediaEmbed, AutoMediaEmbed, Link, List, Bold, Typing ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+			} );
+	} );
+
+	afterEach( () => {
+		editorElement.remove();
+
+		return editor.destroy();
+	} );
+
+	it( 'should load Clipboard plugin', () => {
+		expect( editor.plugins.get( Clipboard ) ).to.instanceOf( Clipboard );
+	} );
+
+	it( 'should load Undo plugin', () => {
+		expect( editor.plugins.get( Undo ) ).to.instanceOf( Undo );
+	} );
+
+	it( 'has proper name', () => {
+		expect( AutoMediaEmbed.pluginName ).to.equal( 'AutoMediaEmbed' );
+	} );
+
+	describe( 'use fake timers', () => {
+		let clock;
+
+		beforeEach( () => {
+			clock = sinon.useFakeTimers();
+		} );
+
+		afterEach( () => {
+			clock.restore();
+		} );
+
+		it( 'replaces pasted text with media element after 100ms', () => {
+			setData( editor.model, '<paragraph>[]</paragraph>' );
+			pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' );
+
+			expect( getData( editor.model ) ).to.equal(
+				'<paragraph>https://www.youtube.com/watch?v=H08tGjXNHO4[]</paragraph>'
+			);
+
+			clock.tick( 100 );
+
+			expect( getData( editor.model ) ).to.equal(
+				'<paragraph></paragraph>[<media url="https://www.youtube.com/watch?v=H08tGjXNHO4"></media>]'
+			);
+		} );
+
+		it( 'can undo auto-embeding', () => {
+			setData( editor.model, '<paragraph>[]</paragraph>' );
+			pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' );
+
+			expect( getData( editor.model ) ).to.equal(
+				'<paragraph>https://www.youtube.com/watch?v=H08tGjXNHO4[]</paragraph>'
+			);
+
+			clock.tick( 100 );
+
+			editor.commands.execute( 'undo' );
+
+			expect( getData( editor.model ) ).to.equal(
+				'<paragraph>https://www.youtube.com/watch?v=H08tGjXNHO4[]</paragraph>'
+			);
+		} );
+
+		it( 'works for a full URL (https + "www" sub-domain)', () => {
+			setData( editor.model, '<paragraph>[]</paragraph>' );
+			pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' );
+
+			clock.tick( 100 );
+
+			expect( getData( editor.model ) ).to.equal(
+				'<paragraph></paragraph>[<media url="https://www.youtube.com/watch?v=H08tGjXNHO4"></media>]'
+			);
+		} );
+
+		it( 'works for a full URL (https without "wwww" sub-domain)', () => {
+			setData( editor.model, '<paragraph>[]</paragraph>' );
+			pasteHtml( editor, 'https://youtube.com/watch?v=H08tGjXNHO4' );
+
+			clock.tick( 100 );
+
+			expect( getData( editor.model ) ).to.equal(
+				'<paragraph></paragraph>[<media url="https://youtube.com/watch?v=H08tGjXNHO4"></media>]'
+			);
+		} );
+
+		it( 'works for a full URL (http + "www" sub-domain)', () => {
+			setData( editor.model, '<paragraph>[]</paragraph>' );
+			pasteHtml( editor, 'http://www.youtube.com/watch?v=H08tGjXNHO4' );
+
+			clock.tick( 100 );
+
+			expect( getData( editor.model ) ).to.equal(
+				'<paragraph></paragraph>[<media url="http://www.youtube.com/watch?v=H08tGjXNHO4"></media>]'
+			);
+		} );
+
+		it( 'works for a full URL (http without "wwww" sub-domain)', () => {
+			setData( editor.model, '<paragraph>[]</paragraph>' );
+			pasteHtml( editor, 'http://youtube.com/watch?v=H08tGjXNHO4' );
+
+			clock.tick( 100 );
+
+			expect( getData( editor.model ) ).to.equal(
+				'<paragraph></paragraph>[<media url="http://youtube.com/watch?v=H08tGjXNHO4"></media>]'
+			);
+		} );
+
+		it( 'works for a URL without protocol (with "www" sub-domain)', () => {
+			setData( editor.model, '<paragraph>[]</paragraph>' );
+			pasteHtml( editor, 'www.youtube.com/watch?v=H08tGjXNHO4' );
+
+			clock.tick( 100 );
+
+			expect( getData( editor.model ) ).to.equal(
+				'<paragraph></paragraph>[<media url="www.youtube.com/watch?v=H08tGjXNHO4"></media>]'
+			);
+		} );
+
+		it( 'works for a URL without protocol (without "www" sub-domain)', () => {
+			setData( editor.model, '<paragraph>[]</paragraph>' );
+			pasteHtml( editor, 'youtube.com/watch?v=H08tGjXNHO4' );
+
+			clock.tick( 100 );
+
+			expect( getData( editor.model ) ).to.equal(
+				'<paragraph></paragraph>[<media url="youtube.com/watch?v=H08tGjXNHO4"></media>]'
+			);
+		} );
+
+		it( 'works fine if a media has no preview', () => {
+			setData( editor.model, '<paragraph>[]</paragraph>' );
+			pasteHtml( editor, 'https://twitter.com/ckeditor/status/1035181110140063749' );
+
+			clock.tick( 100 );
+
+			expect( getData( editor.model ) ).to.equal(
+				'<paragraph></paragraph>[<media url="https://twitter.com/ckeditor/status/1035181110140063749"></media>]'
+			);
+		} );
+
+		it( 'works for URL that was pasted as a link', () => {
+			setData( editor.model, '<paragraph>[]</paragraph>' );
+			pasteHtml( editor, '<a href="https://www.youtube.com/watch?v=H08tGjXNHO4">https://www.youtube.com/watch?v=H08tGjXNHO4</a>' );
+
+			clock.tick( 100 );
+
+			expect( getData( editor.model ) ).to.equal(
+				'<paragraph></paragraph>[<media url="https://www.youtube.com/watch?v=H08tGjXNHO4"></media>]'
+			);
+		} );
+
+		it( 'works for URL that contains some inline styles', () => {
+			setData( editor.model, '<paragraph>[]</paragraph>' );
+			pasteHtml( editor, '<b>https://www.youtube.com/watch?v=H08tGjXNHO4</b>' );
+
+			clock.tick( 100 );
+
+			expect( getData( editor.model ) ).to.equal(
+				'<paragraph></paragraph>[<media url="https://www.youtube.com/watch?v=H08tGjXNHO4"></media>]'
+			);
+		} );
+
+		it( 'works for not collapsed selection inside single element', () => {
+			setData( editor.model, '<paragraph>[Foo]</paragraph>' );
+			pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' );
+
+			clock.tick( 100 );
+
+			expect( getData( editor.model ) ).to.equal(
+				'<paragraph></paragraph>[<media url="https://www.youtube.com/watch?v=H08tGjXNHO4"></media>]'
+			);
+		} );
+
+		it( 'works for not collapsed selection over a few elements', () => {
+			setData( editor.model, '<paragraph>Fo[o</paragraph><paragraph>Ba]r</paragraph>' );
+			pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' );
+
+			clock.tick( 100 );
+
+			expect( getData( editor.model ) ).to.equal(
+				'<paragraph>For</paragraph>[<media url="https://www.youtube.com/watch?v=H08tGjXNHO4"></media>]'
+			);
+		} );
+
+		it( 'does nothing if a URL is invalid', () => {
+			setData( editor.model, '<paragraph>[]</paragraph>' );
+			pasteHtml( editor, 'https://youtube.com' );
+
+			clock.tick( 100 );
+
+			expect( getData( editor.model ) ).to.equal(
+				'<paragraph>https://youtube.com[]</paragraph>'
+			);
+		} );
+
+		it( 'does nothing if pasted two links as text', () => {
+			setData( editor.model, '<paragraph>[]</paragraph>' );
+			pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4 https://www.youtube.com/watch?v=H08tGjXNHO4' );
+
+			clock.tick( 100 );
+
+			expect( getData( editor.model ) ).to.equal(
+				'<paragraph>https://www.youtube.com/watch?v=H08tGjXNHO4 https://www.youtube.com/watch?v=H08tGjXNHO4[]</paragraph>'
+			);
+		} );
+
+		it( 'does nothing if pasted text contains a valid URL', () => {
+			setData( editor.model, '<paragraph>[]</paragraph>' );
+			pasteHtml( editor, 'Foo bar https://www.youtube.com/watch?v=H08tGjXNHO4 bar foo.' );
+
+			clock.tick( 100 );
+
+			expect( getData( editor.model ) ).to.equal(
+				'<paragraph>Foo bar https://www.youtube.com/watch?v=H08tGjXNHO4 bar foo.[]</paragraph>'
+			);
+		} );
+
+		it( 'does nothing if pasted more than single node', () => {
+			setData( editor.model, '<paragraph>[]</paragraph>' );
+			pasteHtml( editor,
+				'https://www.youtube.com/watch?v=H08tGjXNHO4 ' +
+				'<a href="https://www.youtube.com/watch?v=H08tGjXNHO4">https://www.youtube.com/watch?v=H08tGjXNHO4</a>'
+			);
+
+			clock.tick( 100 );
+
+			expect( getData( editor.model ) ).to.equal(
+				'<paragraph>https://www.youtube.com/watch?v=H08tGjXNHO4 ' +
+				'<$text linkHref="https://www.youtube.com/watch?v=H08tGjXNHO4">https://www.youtube.com/watch?v=H08tGjXNHO4[]</$text>' +
+				'</paragraph>'
+			);
+		} );
+
+		it( 'does nothing if pasted a paragraph with the url', () => {
+			setData( editor.model, '<paragraph>[]</paragraph>' );
+			pasteHtml( editor, '<p>https://www.youtube.com/watch?v=H08tGjXNHO4</p>' );
+
+			clock.tick( 100 );
+
+			expect( getData( editor.model ) ).to.equal(
+				'<paragraph>https://www.youtube.com/watch?v=H08tGjXNHO4[]</paragraph>'
+			);
+		} );
+
+		it( 'does nothing if pasted a block of content that looks like a URL', () => {
+			setData( editor.model, '<paragraph>[]</paragraph>' );
+			pasteHtml( editor, '<ul><li>https://</li><li>youtube.com/watch?</li></ul><p>v=H08tGjXNHO4</p>' );
+
+			clock.tick( 100 );
+
+			expect( getData( editor.model ) ).to.equal(
+				'<listItem listIndent="0" listType="bulleted">https://</listItem>' +
+				'<listItem listIndent="0" listType="bulleted">youtube.com/watch?</listItem>' +
+				'<paragraph>v=H08tGjXNHO4[]</paragraph>'
+			);
+		} );
+
+		it( 'does nothing if a URL is invalid (space inside URL)', () => {
+			setData( editor.model, '<paragraph>[]</paragraph>' );
+			pasteHtml( editor, 'youtube.com/watch?v=H08tGjXNHO4&amp;param=foo bar' );
+
+			clock.tick( 100 );
+
+			expect( getData( editor.model ) ).to.equal(
+				'<paragraph>youtube.com/watch?v=H08tGjXNHO4&param=foo bar[]</paragraph>'
+			);
+		} );
+
+		it( 'does nothing if URL match to media but it was removed', () => {
+			return ClassicTestEditor
+				.create( editorElement, {
+					plugins: [ MediaEmbed, AutoMediaEmbed, Paragraph ],
+					mediaEmbed: {
+						removeProviders: [ 'youtube' ]
+					}
+				} )
+				.then( newEditor => {
+					editor = newEditor;
+
+					setData( editor.model, '<paragraph>[]</paragraph>' );
+					pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' );
+
+					clock.tick( 100 );
+
+					expect( getData( editor.model ) ).to.equal(
+						'<paragraph>https://www.youtube.com/watch?v=H08tGjXNHO4[]</paragraph>'
+					);
+
+					editorElement.remove();
+
+					return editor.destroy();
+				} );
+		} );
+	} );
+
+	describe( 'use real timers', () => {
+		const characters = Array( 10 ).fill( 1 ).map( ( x, i ) => String.fromCharCode( 65 + i ) );
+
+		it( 'undo breaks the auto-media embed feature (undo was done before auto-media embed)', done => {
+			setData( editor.model, '<paragraph>[]</paragraph>' );
+			pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' );
+
+			expect( getData( editor.model ) ).to.equal(
+				'<paragraph>https://www.youtube.com/watch?v=H08tGjXNHO4[]</paragraph>'
+			);
+
+			setTimeout( () => {
+				editor.commands.execute( 'undo' );
+
+				expect( getData( editor.model ) ).to.equal(
+					'<paragraph>[]</paragraph>'
+				);
+
+				done();
+			} );
+		} );
+
+		// Checking whether paste+typing calls the auto-media handler once.
+		it( 'pasting handler should be executed once', done => {
+			const autoMediaEmbedPlugin = editor.plugins.get( AutoMediaEmbed );
+			const autoMediaHandler = autoMediaEmbedPlugin._embedMediaBetweenPositions;
+			let counter = 0;
+
+			autoMediaEmbedPlugin._embedMediaBetweenPositions = function( ...args ) {
+				counter += 1;
+
+				return autoMediaHandler.apply( this, args );
+			};
+
+			setData( editor.model, '<paragraph>[]</paragraph>' );
+			pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' );
+			simulateTyping( 'Foo. Bar.' );
+
+			setTimeout( () => {
+				autoMediaEmbedPlugin._embedMediaBetweenPositions = autoMediaHandler;
+
+				expect( counter ).to.equal( 1 );
+
+				done();
+			}, 100 );
+		} );
+
+		it( 'typing before pasted link during collaboration should not blow up', done => {
+			setData( editor.model, '<paragraph>[]</paragraph>' );
+
+			pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' );
+
+			for ( let i = 0; i < 10; ++i ) {
+				const rootEl = editor.model.document.getRoot();
+
+				setTimeout( () => {
+					editor.model.enqueueChange( 'transparent', writer => {
+						writer.insertText( characters[ i ], new ModelPosition( rootEl, [ 0, i ] ) );
+					} );
+				}, i * 5 );
+			}
+
+			setTimeout( () => {
+				expect( getData( editor.model ) ).to.equal(
+					'<paragraph>ABCDEFGHIJ</paragraph>[<media url="https://www.youtube.com/watch?v=H08tGjXNHO4"></media>]'
+				);
+
+				done();
+			}, 100 );
+		} );
+
+		it( 'typing after pasted link during collaboration should not blow up', done => {
+			setData( editor.model, '<paragraph>[]</paragraph>' );
+
+			pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' );
+
+			for ( let i = 0; i < 10; ++i ) {
+				setTimeout( () => {
+					editor.model.enqueueChange( 'transparent', writer => {
+						writer.insertText( characters[ i ], editor.model.document.selection.getFirstPosition() );
+					} );
+				}, i * 5 );
+			}
+
+			setTimeout( () => {
+				expect( getData( editor.model ) ).to.equal(
+					'<paragraph>ABCDEFGHIJ</paragraph>[<media url="https://www.youtube.com/watch?v=H08tGjXNHO4"></media>]'
+				);
+
+				done();
+			}, 100 );
+		} );
+
+		it( 'should insert the media element even if parent element where the URL was pasted has been deleted', done => {
+			setData( editor.model, '<paragraph>Foo.</paragraph><paragraph>Bar.[]</paragraph>' );
+
+			pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' );
+
+			editor.model.enqueueChange( 'transparent', writer => {
+				writer.remove( ModelRange.createOn( editor.model.document.getRoot().getChild( 1 ) ) );
+			} );
+
+			setTimeout( () => {
+				expect( getData( editor.model ) ).to.equal(
+					'<paragraph>Foo.</paragraph>[<media url="https://www.youtube.com/watch?v=H08tGjXNHO4"></media>]'
+				);
+
+				done();
+			}, 100 );
+		} );
+
+		it( 'should insert the media element even if new element appeared above the pasted URL', done => {
+			setData( editor.model, '<paragraph>Foo.</paragraph><paragraph>Bar.[]</paragraph>' );
+
+			pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' );
+
+			editor.model.enqueueChange( 'transparent', writer => {
+				const paragraph = writer.createElement( 'paragraph' );
+				writer.insert( paragraph, ModelPosition.createAfter( editor.model.document.getRoot().getChild( 0 ) ) );
+				writer.setSelection( paragraph, 'in' );
+			} );
+
+			for ( let i = 0; i < 10; ++i ) {
+				setTimeout( () => {
+					editor.model.enqueueChange( 'transparent', writer => {
+						writer.insertText( characters[ i ], editor.model.document.selection.getFirstPosition() );
+					} );
+				}, i * 5 );
+			}
+
+			setTimeout( () => {
+				expect( getData( editor.model ) ).to.equal(
+					'<paragraph>Foo.</paragraph>' +
+					'<paragraph>ABCDEFGHIJ</paragraph>' +
+					'<paragraph>Bar.</paragraph>' +
+					'[<media url="https://www.youtube.com/watch?v=H08tGjXNHO4"></media>]'
+				);
+
+				done();
+			}, 100 );
+		} );
+
+		it( 'should insert the media element even if new element appeared below the pasted URL', done => {
+			setData( editor.model, '<paragraph>Foo.</paragraph><paragraph>Bar.[]</paragraph>' );
+
+			pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' );
+
+			editor.model.enqueueChange( 'transparent', writer => {
+				const paragraph = writer.createElement( 'paragraph' );
+				writer.insert( paragraph, ModelPosition.createAfter( editor.model.document.getRoot().getChild( 1 ) ) );
+				writer.setSelection( paragraph, 'in' );
+			} );
+
+			for ( let i = 0; i < 10; ++i ) {
+				setTimeout( () => {
+					editor.model.enqueueChange( 'transparent', writer => {
+						writer.insertText( characters[ i ], editor.model.document.selection.getFirstPosition() );
+					} );
+				}, i * 5 );
+			}
+
+			setTimeout( () => {
+				expect( getData( editor.model ) ).to.equal(
+					'<paragraph>Foo.</paragraph>' +
+					'<paragraph>Bar.</paragraph>' +
+					'[<media url="https://www.youtube.com/watch?v=H08tGjXNHO4"></media>]' +
+					'<paragraph>ABCDEFGHIJ</paragraph>'
+				);
+
+				done();
+			}, 100 );
+		} );
+	} );
+
+	function simulateTyping( text ) {
+		// While typing, every character is an atomic change.
+		text.split( '' ).forEach( character => {
+			editor.execute( 'input', {
+				text: character
+			} );
+		} );
+	}
+
+	function pasteHtml( editor, html ) {
+		editor.editing.view.document.fire( 'paste', {
+			dataTransfer: createDataTransfer( { 'text/html': html } ),
+			preventDefault() {
+			}
+		} );
+	}
+
+	function createDataTransfer( data ) {
+		return {
+			getData( type ) {
+				return data[ type ];
+			}
+		};
+	}
+} );

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

@@ -3,7 +3,7 @@
 ### Embed the media
 
 1. Put an URL in the clipboard,
-1. Use the button in the dropdown,
+1. Use the button in the dropdown or paste the URL directly in the editor,
 1. Insert the media,
 1. Check if media was inserted and is selected.
 

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

@@ -3,7 +3,7 @@
 ### Embed the media
 
 1. Put an URL in the clipboard,
-1. Use the button in the dropdown,
+1. Use the button in the dropdown or paste the URL directly in the editor,
 1. Insert the media,
 1. Check if media was inserted and is selected.
 

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

@@ -7,6 +7,7 @@ import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictest
 import MediaEmbed from '../src/mediaembed';
 import MediaEmbedEditing from '../src/mediaembedediting';
 import MediaEmbedUI from '../src/mediaembedui';
+import AutoMediaEmbed from '../src/automediaembed';
 import Widget from '@ckeditor/ckeditor5-widget/src/widget';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 
@@ -48,6 +49,10 @@ describe( 'MediaEmbed', () => {
 		expect( editor.plugins.get( MediaEmbedUI ) ).to.instanceOf( MediaEmbedUI );
 	} );
 
+	it( 'should load AutoMediaEmbed plugin', () => {
+		expect( editor.plugins.get( AutoMediaEmbed ) ).to.instanceOf( AutoMediaEmbed );
+	} );
+
 	it( 'has proper name', () => {
 		expect( MediaEmbed.pluginName ).to.equal( 'MediaEmbed' );
 	} );