Parcourir la source

Merge pull request #8290 from ckeditor/i/8204

Feature (html-embed): Initial implementation. Closes #8204.

Feature (theme-lark): Styles for the HTML embed feature. See #8204.
Piotrek Koszuliński il y a 5 ans
Parent
commit
b529537086
29 fichiers modifiés avec 2096 ajouts et 0 suppressions
  1. 12 0
      README.md
  2. 1 0
      package.json
  3. 4 0
      packages/ckeditor5-html-embed/CHANGELOG.md
  4. 4 0
      packages/ckeditor5-html-embed/CONTRIBUTING.md
  5. 17 0
      packages/ckeditor5-html-embed/LICENSE.md
  6. 20 0
      packages/ckeditor5-html-embed/README.md
  7. 2 0
      packages/ckeditor5-html-embed/docs/_snippets/features/html-embed.html
  8. 67 0
      packages/ckeditor5-html-embed/docs/_snippets/features/html-embed.js
  9. 34 0
      packages/ckeditor5-html-embed/docs/api/html-embed.md
  10. 61 0
      packages/ckeditor5-html-embed/docs/features/html-embed.md
  11. 5 0
      packages/ckeditor5-html-embed/lang/contexts.json
  12. 44 0
      packages/ckeditor5-html-embed/package.json
  13. 64 0
      packages/ckeditor5-html-embed/src/htmlembed.js
  14. 255 0
      packages/ckeditor5-html-embed/src/htmlembedediting.js
  15. 99 0
      packages/ckeditor5-html-embed/src/htmlembedinsertcommand.js
  16. 55 0
      packages/ckeditor5-html-embed/src/htmlembedui.js
  17. 64 0
      packages/ckeditor5-html-embed/src/htmlembedupdatecommand.js
  18. 18 0
      packages/ckeditor5-html-embed/tests/htmlembed.js
  19. 555 0
      packages/ckeditor5-html-embed/tests/htmlembedediting.js
  20. 169 0
      packages/ckeditor5-html-embed/tests/htmlembedinsertcommand.js
  21. 72 0
      packages/ckeditor5-html-embed/tests/htmlembedui.js
  22. 96 0
      packages/ckeditor5-html-embed/tests/htmlembedupdatecommand.js
  23. 157 0
      packages/ckeditor5-html-embed/tests/manual/htmlembed.html
  24. 118 0
      packages/ckeditor5-html-embed/tests/manual/htmlembed.js
  25. 38 0
      packages/ckeditor5-html-embed/tests/manual/htmlembed.md
  26. 4 0
      packages/ckeditor5-html-embed/theme/htmlembed.css
  27. 1 0
      packages/ckeditor5-html-embed/theme/icons/htmlembed.svg
  28. 6 0
      packages/ckeditor5-html-embed/theme/icons/htmlembedmode.svg
  29. 54 0
      packages/ckeditor5-theme-lark/theme/ckeditor5-html-embed/htmlembed.css

+ 12 - 0
README.md

@@ -519,6 +519,18 @@ See CKEditor 5 release blog posts [on the CKEditor blog](https://ckeditor.com/bl
 	</td>
 </tr>
 
+<tr>
+	<td>
+		<a href="https://github.com/ckeditor/ckeditor5/tree/master/packages/ckeditor5-html-embed"><code>@ckeditor/ckeditor5-html-embed</code></a>
+	</td>
+	<td>
+		<a href="https://www.npmjs.com/package/@ckeditor/ckeditor5-html-embed"><img src="https://img.shields.io/npm/v/@ckeditor/ckeditor5-html-embed.svg" alt="@ckeditor/ckeditor5-html-embed npm package badge"></a>
+	</td>
+	<td>
+		The HTML embed feature.
+	</td>
+</tr>
+
 <tr>
 	<td>
 		<a href="https://github.com/ckeditor/ckeditor5/tree/master/packages/ckeditor5-image"><code>@ckeditor/ckeditor5-image</code></a>

+ 1 - 0
package.json

@@ -50,6 +50,7 @@
     "@ckeditor/ckeditor5-heading": "^23.0.0",
     "@ckeditor/ckeditor5-highlight": "^23.0.0",
     "@ckeditor/ckeditor5-horizontal-line": "^23.0.0",
+    "@ckeditor/ckeditor5-html-embed": "^0.0.1",
     "@ckeditor/ckeditor5-image": "^23.0.0",
     "@ckeditor/ckeditor5-indent": "^23.0.0",
     "@ckeditor/ckeditor5-link": "^23.0.0",

+ 4 - 0
packages/ckeditor5-html-embed/CHANGELOG.md

@@ -0,0 +1,4 @@
+Changelog
+=========
+
+All changes in the package are documented in the main repository. See: https://github.com/ckeditor/ckeditor5/blob/master/CHANGELOG.md.

+ 4 - 0
packages/ckeditor5-html-embed/CONTRIBUTING.md

@@ -0,0 +1,4 @@
+Contributing
+========================================
+
+See the [official contributors' guide to CKEditor 5](https://ckeditor.com/docs/ckeditor5/latest/framework/guides/contributing/contributing.html) to learn more.

+ 17 - 0
packages/ckeditor5-html-embed/LICENSE.md

@@ -0,0 +1,17 @@
+Software License Agreement
+==========================
+
+**CKEditor 5 HTML embed feature**  – https://github.com/ckeditor/ckeditor5-html-embed <br>
+Copyright (c) 2003-2020, [CKSource](http://cksource.com) Frederico Knabben. All rights reserved.
+
+Licensed under the terms of [GNU General Public License Version 2 or later](http://www.gnu.org/licenses/gpl.html).
+
+Sources of Intellectual Property Included in CKEditor
+-----------------------------------------------------
+
+Where not otherwise indicated, all CKEditor content is authored by CKSource engineers and consists of CKSource-owned intellectual property. In some specific instances, CKEditor will incorporate work done by developers outside of CKSource with their express permission.
+
+Trademarks
+----------
+
+**CKEditor** is a trademark of [CKSource](http://cksource.com) Frederico Knabben. All other brand and product names are trademarks, registered trademarks or service marks of their respective holders.

+ 20 - 0
packages/ckeditor5-html-embed/README.md

@@ -0,0 +1,20 @@
+CKEditor 5 HTML embed feature
+=============================
+
+[![npm version](https://badge.fury.io/js/%40ckeditor%2Fckeditor5-html-embed.svg)](https://www.npmjs.com/package/@ckeditor/ckeditor5-html-embed)
+[![Dependency Status](https://david-dm.org/ckeditor/ckeditor5-html-embed/status.svg)](https://david-dm.org/ckeditor/ckeditor5-html-embed)
+[![devDependency Status](https://david-dm.org/ckeditor/ckeditor5-html-embed/dev-status.svg)](https://david-dm.org/ckeditor/ckeditor5-html-embed?type=dev)
+
+This package implements the HTML embed feature for CKEditor 5.
+
+## Demo
+
+Check out the demo in the [HTML embed feature](https://ckeditor.com/docs/ckeditor5/latest/features/html-embed.html) guide.
+
+## Documentation
+
+See the [`@ckeditor/ckeditor5-html-embed` package](https://ckeditor.com/docs/ckeditor5/latest/api/html-embed.html) page as well as the [HTML embed feature guide](https://ckeditor.com/docs/ckeditor5/latest/features/html-embed.html) in [CKEditor 5 documentation](https://ckeditor.com/docs/ckeditor5/latest/).
+
+## License
+
+Licensed under the terms of [GNU General Public License Version 2 or later](http://www.gnu.org/licenses/gpl.html). For full details about the license, please check the `LICENSE.md` file or [https://ckeditor.com/legal/ckeditor-oss-license](https://ckeditor.com/legal/ckeditor-oss-license).

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

@@ -0,0 +1,2 @@
+<div id="snippet-html-embed">
+</div>

+ 67 - 0
packages/ckeditor5-html-embed/docs/_snippets/features/html-embed.js

@@ -0,0 +1,67 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals window, document, console */
+
+import ClassicEditor from '@ckeditor/ckeditor5-build-classic/src/ckeditor';
+import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config';
+import HtmlEmbed from '@ckeditor/ckeditor5-html-embed/src/htmlembed';
+
+ClassicEditor.builtinPlugins.push( HtmlEmbed );
+
+ClassicEditor
+	.create( document.querySelector( '#snippet-html-embed' ), {
+		toolbar: {
+			items: [
+				'heading',
+				'|',
+				'bold',
+				'italic',
+				'bulletedList',
+				'numberedList',
+				'|',
+				'outdent',
+				'indent',
+				'|',
+				'alignment',
+				'|',
+				'horizontalLine',
+				'blockQuote',
+				'link',
+				'imageUpload',
+				'mediaEmbed',
+				'htmlEmbed',
+				'insertTable',
+				'|',
+				'undo',
+				'redo'
+			],
+			viewportTopOffset: window.getViewportTopOffsetConfig()
+		},
+		image: {
+			styles: [
+				'full',
+				'alignLeft',
+				'alignRight'
+			],
+			toolbar: [
+				'imageStyle:alignLeft',
+				'imageStyle:full',
+				'imageStyle:alignRight',
+				'|',
+				'imageTextAlternative'
+			]
+		},
+		table: {
+			contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells' ]
+		},
+		cloudServices: CS_CONFIG
+	} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 34 - 0
packages/ckeditor5-html-embed/docs/api/html-embed.md

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

+ 61 - 0
packages/ckeditor5-html-embed/docs/features/html-embed.md

@@ -0,0 +1,61 @@
+---
+category: features
+menu-title: HTML embed
+---
+
+# HTML embed
+
+The {@link module:html-embed/htmlembed~HtmlEmbed} plugin provides the possibility to insert a HTML codeinto the rich-text editor.
+
+## Demo
+
+Use the editor below to see the {@link module:html-embed/htmlembed~HtmlEmbed} plugin in action.
+
+{@snippet features/html-embed}
+
+## Installation
+
+To add this feature to your rich-text editor, install the [`@ckeditor/ckeditor5-html-embed`](https://www.npmjs.com/package/@ckeditor/ckeditor5-html-embed) package:
+
+```plaintext
+npm install --save @ckeditor/ckeditor5-html-embed
+```
+
+And add it to your plugin list configuration:
+
+```js
+import HtmlEmbed from '@ckeditor/ckeditor5-html-embed/src/htmlembed';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ HtmlEmbed, ... ],
+		toolbar: [ 'htmlEmbed', ... ],
+	} )
+	.then( ... )
+	.catch( ... );
+```
+
+<info-box info>
+	Read more about {@link builds/guides/integration/installing-plugins installing plugins}.
+</info-box>
+
+## Common API
+
+The {@link module:html-embed/htmlembed~HtmlEmbed} plugin registers:
+* the UI button component (`'htmlEmbed'`),
+* the `'htmlEmbedUpdate'` command implemented by {@link module:html-embed/htmlembedupdatecommand~HtmlEmbedUpdateCommand}.
+* the `'htmlEmbedInsert'` command implemented by {@link module:html-embed/htmlembedinsertcommand~HtmlEmbedInsertCommand}.
+
+The command can be executed using the {@link module:core/editor/editor~Editor#execute `editor.execute()`} method:
+
+```js
+editor.execute( 'htmlEmbed', { html: 'HTML to insert.' } );
+```
+
+<info-box>
+	We recommend using the official {@link framework/guides/development-tools#ckeditor-5-inspector CKEditor 5 inspector} for development and debugging. It will give you tons of useful information about the state of the editor such as internal data structures, selection, commands, and many more.
+</info-box>
+
+## Contribute
+
+The source code of the feature is available on GitHub in https://github.com/ckeditor/ckeditor5/tree/master/packages/ckeditor5-html-embed.

+ 5 - 0
packages/ckeditor5-html-embed/lang/contexts.json

@@ -0,0 +1,5 @@
+{
+  "Insert HTML": "Toolbar button tooltip for the HTML embed feature.",
+  "HTML snippet": "The HTML snippet.",
+  "Paste the raw code here.": "A placeholder that will be displayed in the raw HTML textarea field."
+}

+ 44 - 0
packages/ckeditor5-html-embed/package.json

@@ -0,0 +1,44 @@
+{
+  "name": "@ckeditor/ckeditor5-html-embed",
+  "version": "0.0.1",
+  "description": "HTML embed feature for CKEditor 5.",
+  "keywords": [
+    "ckeditor",
+    "ckeditor5",
+    "ckeditor 5",
+    "ckeditor5-feature",
+    "ckeditor5-plugin"
+  ],
+  "dependencies": {
+    "@ckeditor/ckeditor5-core": "^23.0.0",
+    "@ckeditor/ckeditor5-engine": "^23.0.0",
+    "@ckeditor/ckeditor5-ui": "^23.0.0",
+    "@ckeditor/ckeditor5-utils": "^23.0.0",
+    "@ckeditor/ckeditor5-widget": "^23.0.0"
+  },
+  "devDependencies": {
+    "@ckeditor/ckeditor5-basic-styles": "^23.0.0",
+    "@ckeditor/ckeditor5-editor-classic": "^23.0.0",
+    "@ckeditor/ckeditor5-paragraph": "^23.0.0",
+    "lodash-es": "^4.17.15",
+    "sanitize-html": "^2.1.0"
+  },
+  "engines": {
+    "node": ">=12.0.0",
+    "npm": ">=5.7.1"
+  },
+  "author": "CKSource (http://cksource.com/)",
+  "license": "GPL-2.0-or-later",
+  "homepage": "https://ckeditor.com",
+  "bugs": "https://github.com/ckeditor/ckeditor5/issues",
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/ckeditor/ckeditor5.git",
+    "directory": "packages/ckeditor5-html-embed"
+  },
+  "files": [
+    "lang",
+    "src",
+    "theme"
+  ]
+}

+ 64 - 0
packages/ckeditor5-html-embed/src/htmlembed.js

@@ -0,0 +1,64 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module html-embed/htmlembed
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import HtmlEmbedEditing from './htmlembedediting';
+import HtmlEmbedUI from './htmlembedui';
+
+/**
+ * The HTML embed feature.
+ *
+ * It allows inserting HTML snippets directly to the editor.
+ *
+ * For a detailed overview, check the {@glink features/html-embed HTML embed feature} documentation.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class HtmlEmbed extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ HtmlEmbedEditing, HtmlEmbedUI ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'HtmlEmbed';
+	}
+}
+
+/**
+ * The configuration of the HTML embed feature.
+ *
+ *		ClassicEditor
+ *			.create( editorElement, {
+ * 				htmlEmbed: ... // HTML embed feature options.
+ *			} )
+ *			.then( ... )
+ *			.catch( ... );
+ *
+ * See {@link module:core/editor/editorconfig~EditorConfig all editor options}.
+ *
+ * @interface HtmlEmbedConfig
+ */
+
+/**
+ * TODO
+ *
+ * @member {Boolean} [module:html-embed/htmlembed~HtmlEmbedConfig#previewsInData=false]
+ */
+
+/**
+ * TODO
+ *
+ * @member {Function} [module:html-embed/htmlembed~HtmlEmbedConfig#sanitizeHtml]
+ */

+ 255 - 0
packages/ckeditor5-html-embed/src/htmlembedediting.js

@@ -0,0 +1,255 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module html-embed/htmlembedediting
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import HtmlDataProcessor from '@ckeditor/ckeditor5-engine/src/dataprocessor/htmldataprocessor';
+import UpcastWriter from '@ckeditor/ckeditor5-engine/src/view/upcastwriter';
+import { logWarning } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+import { toWidget } from '@ckeditor/ckeditor5-widget/src/utils';
+import HtmlEmbedInsertCommand from './htmlembedinsertcommand';
+import HtmlEmbedUpdateCommand from './htmlembedupdatecommand';
+
+import htmlEmbedModeIcon from '../theme/icons/htmlembedmode.svg';
+
+import '../theme/htmlembed.css';
+
+const DISPLAY_PREVIEW_CLASS = 'raw-html--display-preview';
+
+/**
+ * The HTML embed editing feature.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class HtmlEmbedEditing extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'HtmlEmbedEditing';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	constructor( editor ) {
+		super( editor );
+
+		editor.config.define( 'htmlEmbed', {
+			previewsInData: false,
+			sanitizeHtml: rawHtml => {
+				/**
+				 * When using the HTML embed feature with `htmlEmbed.previewsInData=true` option, it is strongly recommended to
+				 * define a sanitize function that will clean up an input HTML in order to avoid XSS vulnerability.
+				 *
+				 * For a detailed overview, check the {@glink features/html-embed HTML embed feature} documentation.
+				 *
+				 * @error html-embed-provide-sanitize-function
+				 */
+				logWarning( 'html-embed-provide-sanitize-function' );
+
+				return {
+					html: rawHtml,
+					hasModified: false
+				};
+			}
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const schema = editor.model.schema;
+
+		schema.register( 'rawHtml', {
+			isObject: true,
+			allowWhere: '$block',
+			allowAttributes: [ 'value' ]
+		} );
+
+		editor.commands.add( 'htmlEmbedUpdate', new HtmlEmbedUpdateCommand( editor ) );
+		editor.commands.add( 'htmlEmbedInsert', new HtmlEmbedInsertCommand( editor ) );
+
+		this._setupConversion();
+	}
+
+	/**
+	 * Prepares converters for the feature.
+	 *
+	 * @private
+	 */
+	_setupConversion() {
+		const editor = this.editor;
+		const t = editor.t;
+		const view = editor.editing.view;
+
+		const htmlEmbedConfig = editor.config.get( 'htmlEmbed' );
+		const upcastWriter = new UpcastWriter( view.document );
+		const htmlProcessor = new HtmlDataProcessor( view.document );
+
+		editor.conversion.for( 'upcast' ).elementToElement( {
+			view: {
+				name: 'div',
+				classes: 'raw-html-embed'
+			},
+			model: ( viewElement, { writer } ) => {
+				// Note: The below line has a side-effect – the children are *moved* to the DF so
+				// viewElement becomes empty. It's fine here.
+				const fragment = upcastWriter.createDocumentFragment( viewElement.getChildren() );
+				const innerHtml = htmlProcessor.toData( fragment );
+
+				return writer.createElement( 'rawHtml', {
+					value: innerHtml
+				} );
+			}
+		} );
+
+		editor.conversion.for( 'dataDowncast' ).elementToElement( {
+			model: 'rawHtml',
+			view: ( modelElement, { writer } ) => {
+				return writer.createRawElement( 'div', { class: 'raw-html-embed' }, function( domElement ) {
+					domElement.innerHTML = modelElement.getAttribute( 'value' ) || '';
+				} );
+			}
+		} );
+
+		editor.conversion.for( 'editingDowncast' ).elementToElement( {
+			model: 'rawHtml',
+			view: ( modelElement, { writer } ) => {
+				const widgetLabel = t( 'HTML snippet' );
+				const placeholder = t( 'Paste the raw code here.' );
+
+				const widgetView = writer.createContainerElement( 'div', {
+					class: 'raw-html'
+				} );
+
+				const rawHtmlContainer = writer.createContainerElement( 'div', {
+					'data-cke-ignore-events': true
+				} );
+
+				// Whether to show a preview mode or editing area.
+				writer.setCustomProperty( 'isEditingSourceActive', false, rawHtmlContainer );
+
+				const textareaAttributes = {
+					placeholder,
+					disabled: true,
+					class: 'raw-html__source'
+				};
+
+				// The editing raw HTML field.
+				const sourceElement = writer.createUIElement( 'textarea', textareaAttributes, function( domDocument ) {
+					const root = this.toDomElement( domDocument );
+
+					writer.setCustomProperty( 'domElement', root, sourceElement );
+					root.value = modelElement.getAttribute( 'value' ) || '';
+
+					root.addEventListener( 'input', () => {
+						editor.execute( 'htmlEmbedUpdate', root.value );
+					} );
+
+					return root;
+				} );
+
+				// The switch button between preview and editing HTML.
+				const toggleButton = writer.createUIElement( 'div', { class: 'raw-html__switch-mode' }, function( domDocument ) {
+					const root = this.toDomElement( domDocument );
+
+					root.innerHTML = htmlEmbedModeIcon;
+					writer.setCustomProperty( 'domElement', root, toggleButton );
+
+					root.addEventListener( 'click', evt => {
+						view.change( writer => {
+							if ( htmlEmbedConfig.previewsInData ) {
+								if ( widgetView.hasClass( DISPLAY_PREVIEW_CLASS ) ) {
+									writer.removeClass( DISPLAY_PREVIEW_CLASS, widgetView );
+								} else {
+									writer.addClass( DISPLAY_PREVIEW_CLASS, widgetView );
+								}
+							}
+
+							const textarea = sourceElement.getCustomProperty( 'domElement' );
+							textarea.disabled = !textarea.disabled;
+						} );
+
+						evt.preventDefault();
+					} );
+
+					return root;
+				} );
+
+				writer.insert( writer.createPositionAt( widgetView, 0 ), rawHtmlContainer );
+				writer.insert( writer.createPositionAt( rawHtmlContainer, 0 ), toggleButton );
+				writer.insert( writer.createPositionAt( rawHtmlContainer, 1 ), sourceElement );
+
+				// The container that renders the HTML should be created only when `htmlEmbed.previewsInData=true` in the config.
+				if ( htmlEmbedConfig.previewsInData ) {
+					writer.addClass( [ 'raw-html--preview-enabled', DISPLAY_PREVIEW_CLASS ], widgetView );
+
+					const previewContainer = writer.createRawElement( 'div', { class: 'raw-html__preview' }, function( domElement ) {
+						writer.setCustomProperty( 'domElement', domElement, previewContainer );
+
+						const sanitizeOutput = htmlEmbedConfig.sanitizeHtml( modelElement.getAttribute( 'value' ) || '' );
+						domElement.innerHTML = sanitizeOutput.html;
+					} );
+
+					writer.insert( writer.createPositionAt( rawHtmlContainer, 2 ), previewContainer );
+				}
+
+				return toRawHtmlWidget( widgetView, writer, widgetLabel );
+			}
+		} );
+
+		editor.conversion.for( 'editingDowncast' ).add( downcastRawHtmlValueAttribute( htmlEmbedConfig ) );
+	}
+}
+
+// Returns a converter that handles the `value` attribute of the `rawHtml` element.
+//
+// It updates the source (`textarea`) value and passes an HTML to the preview element if `htmlEmbed.previewsInData=true`.
+//
+// @params {module:html-embed/htmlembed~HtmlEmbedConfig} htmlEmbedConfig
+// @returns {Function}
+function downcastRawHtmlValueAttribute( htmlEmbedConfig ) {
+	return dispatcher => {
+		dispatcher.on( 'attribute:value:rawHtml', ( evt, data, conversionApi ) => {
+			const widgetView = conversionApi.mapper.toViewElement( data.item );
+			const rawHtmlContainer = widgetView.getChild( 0 );
+
+			const textareaDomElement = rawHtmlContainer.getChild( 1 ).getCustomProperty( 'domElement' );
+
+			if ( textareaDomElement ) {
+				textareaDomElement.value = data.item.getAttribute( 'value' );
+			}
+
+			if ( htmlEmbedConfig.previewsInData ) {
+				const previewDomElement = rawHtmlContainer.getChild( 2 ).getCustomProperty( 'domElement' );
+
+				if ( previewDomElement ) {
+					const sanitizeOutput = htmlEmbedConfig.sanitizeHtml( data.item.getAttribute( 'value' ) );
+					previewDomElement.innerHTML = sanitizeOutput.html;
+				}
+			}
+		} );
+	};
+}
+
+// Converts a given {@link module:engine/view/element~Element} to a raw html widget:
+// * Adds a {@link module:engine/view/element~Element#_setCustomProperty custom property} allowing to recognize the raw html widget element.
+// * Calls the {@link module:widget/utils~toWidget} function with the proper element's label creator.
+//
+//  @param {module:engine/view/element~Element} viewElement
+//  @param {module:engine/view/downcastwriter~DowncastWriter} writer An instance of the view writer.
+//  @param {String} label The element's label.
+//  @returns {module:engine/view/element~Element}
+function toRawHtmlWidget( viewElement, writer, label ) {
+	writer.setCustomProperty( 'rawHtml', true, viewElement );
+
+	return toWidget( viewElement, writer, { label } );
+}

+ 99 - 0
packages/ckeditor5-html-embed/src/htmlembedinsertcommand.js

@@ -0,0 +1,99 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module html-embed/htmlembedinsertcommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+import { findOptimalInsertionPosition } from '@ckeditor/ckeditor5-widget/src/utils';
+
+/**
+ * The insert raw HTML element command.
+ *
+ * The command is registered by {@link module:html-embed/htmlembedediting~HtmlEmbedEditing} as `'htmlEmbedInsert'`.
+ *
+ * To insert the raw HTML element at the current selection, execute the command:
+ *
+ *		editor.execute( 'htmlEmbedInsert' );
+ *
+ * @extends module:core/command~Command
+ */
+export default class HtmlEmbedInsertCommand extends Command {
+	/**
+	 * @inheritDoc
+	 */
+	refresh() {
+		this.isEnabled = isHtmlEmbedAllowed( this.editor.model );
+	}
+
+	/**
+	 * Executes the command, which creates and inserts a new html element.
+	 *
+	 * @fires execute
+	 */
+	execute() {
+		const model = this.editor.model;
+
+		model.change( writer => {
+			const rawHtmlElement = writer.createElement( 'rawHtml' );
+
+			model.insertContent( rawHtmlElement );
+			writer.setSelection( rawHtmlElement, 'on' );
+		} );
+	}
+}
+
+// Checks if the `htmlEmbed` element can be inserted at the current model selection.
+//
+// @param {module:engine/model/model~Model} model
+// @returns {Boolean}
+function isHtmlEmbedAllowed( model ) {
+	const schema = model.schema;
+	const selection = model.document.selection;
+
+	return isHtmlEmbedAllowedInParent( selection, schema, model ) &&
+		!checkSelectionOnObject( selection, schema );
+}
+
+// Checks if a html embed is allowed by the schema in the optimal insertion parent.
+//
+// @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection
+// @param {module:engine/model/schema~Schema} schema
+// @param {module:engine/model/model~Model} model Model instance.
+// @returns {Boolean}
+function isHtmlEmbedAllowedInParent( selection, schema, model ) {
+	const parent = getInsertPageBreakParent( selection, model );
+
+	return schema.checkChild( parent, 'rawHtml' );
+}
+
+// Checks if the selection is on object.
+//
+// @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection
+// @param {module:engine/model/schema~Schema} schema
+// @returns {Boolean}
+function checkSelectionOnObject( selection, schema ) {
+	const selectedElement = selection.getSelectedElement();
+
+	return selectedElement && schema.isObject( selectedElement );
+}
+
+// Returns a node that will be used to insert a page break with `model.insertContent` to check if a html embed element can be placed there.
+//
+// @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection
+// @param {module:engine/model/model~Model} model Model instance.
+// @returns {module:engine/model/element~Element}
+function getInsertPageBreakParent( selection, model ) {
+	const insertAt = findOptimalInsertionPosition( selection, model );
+
+	const parent = insertAt.parent;
+
+	if ( parent.isEmpty && !parent.is( 'element', '$root' ) ) {
+		return parent.parent;
+	}
+
+	return parent;
+}

+ 55 - 0
packages/ckeditor5-html-embed/src/htmlembedui.js

@@ -0,0 +1,55 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module html-embed/htmlembedui
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import htmlEmbedIcon from '../theme/icons/htmlembed.svg';
+
+/**
+ * The HTML embed UI plugin.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class HtmlEmbedUI extends Plugin {
+	init() {
+		const editor = this.editor;
+		const t = editor.t;
+
+		// Add the `htmlEmbed` button to feature components.
+		editor.ui.componentFactory.add( 'htmlEmbed', locale => {
+			const command = editor.commands.get( 'htmlEmbedInsert' );
+			const view = new ButtonView( locale );
+
+			view.set( {
+				label: t( 'Insert HTML' ),
+				icon: htmlEmbedIcon,
+				tooltip: true
+			} );
+
+			view.bind( 'isEnabled' ).to( command, 'isEnabled' );
+
+			// Execute the command.
+			this.listenTo( view, 'execute', () => {
+				editor.execute( 'htmlEmbedInsert' );
+				editor.editing.view.focus();
+
+				const widgetWrapper = editor.editing.view.document.selection.getSelectedElement();
+				const rawHtmlContainer = widgetWrapper.getChild( 0 );
+
+				// After inserting a new element, switch to "Edit source" mode.
+				rawHtmlContainer.getChild( 0 ).getCustomProperty( 'domElement' ).click();
+
+				// And focus the edit source element (`textarea`).
+				rawHtmlContainer.getChild( 1 ).getCustomProperty( 'domElement' ).focus();
+			} );
+
+			return view;
+		} );
+	}
+}

+ 64 - 0
packages/ckeditor5-html-embed/src/htmlembedupdatecommand.js

@@ -0,0 +1,64 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module html-embed/htmlembedupdatecommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+
+/**
+ * The update raw HTML value command.
+ *
+ * The command is registered by {@link module:html-embed/htmlembedediting~HtmlEmbedEditing} as `'htmlEmbedUpdate'`.
+ *
+ * To update the value of the raw HTML element at the current selection, execute the command:
+ *
+ *		editor.execute( 'htmlEmbedUpdate', '<b>HTML.</b>' );
+ *
+ * @extends module:core/command~Command
+ */
+export default class HtmlEmbedUpdateCommand extends Command {
+	/**
+	 * @inheritDoc
+	 */
+	refresh() {
+		const model = this.editor.model;
+		const selection = model.document.selection;
+		const rawHtmlElement = getSelectedRawHtmlModelWidget( selection );
+
+		this.isEnabled = !!rawHtmlElement;
+	}
+
+	/**
+	 * Executes the command, which updates the `value` attribute of the embedded HTML element:
+	 *
+	 * @fires execute
+	 * @param {String} value HTML as a string.
+	 */
+	execute( value ) {
+		const model = this.editor.model;
+		const selection = model.document.selection;
+		const selectedRawHtmlElement = getSelectedRawHtmlModelWidget( selection );
+
+		model.change( writer => {
+			writer.setAttribute( 'value', value, selectedRawHtmlElement );
+		} );
+	}
+}
+
+// Returns a selected raw html element in the model, if any.
+//
+// @param {module:engine/model/selection~Selection} selection
+// @returns {module:engine/model/element~Element|null}
+function getSelectedRawHtmlModelWidget( selection ) {
+	const selectedElement = selection.getSelectedElement();
+
+	if ( selectedElement && selectedElement.is( 'element', 'rawHtml' ) ) {
+		return selectedElement;
+	}
+
+	return null;
+}

+ 18 - 0
packages/ckeditor5-html-embed/tests/htmlembed.js

@@ -0,0 +1,18 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import HtmlEmbed from '../src/htmlembed';
+import HtmlEmbedUI from '../src/htmlembedui';
+import HtmlEmbedEditing from '../src/htmlembedediting';
+
+describe( 'HtmlEmbed', () => {
+	it( 'should require HtmlEmbedEditing and HtmlEmbedUI', () => {
+		expect( HtmlEmbed.requires ).to.deep.equal( [ HtmlEmbedEditing, HtmlEmbedUI ] );
+	} );
+
+	it( 'should be named', () => {
+		expect( HtmlEmbed.pluginName ).to.equal( 'HtmlEmbed' );
+	} );
+} );

+ 555 - 0
packages/ckeditor5-html-embed/tests/htmlembedediting.js

@@ -0,0 +1,555 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* global console, document, HTMLTextAreaElement, HTMLDivElement, Event */
+
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import HtmlEmbedEditing from '../src/htmlembedediting';
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import HtmlEmbedUpdateCommand from '../src/htmlembedupdatecommand';
+import HtmlEmbedInsertCommand from '../src/htmlembedinsertcommand';
+import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { isWidget } from '@ckeditor/ckeditor5-widget/src/utils';
+
+describe( 'HtmlEmbedEditing', () => {
+	let element, editor, model, view, viewDocument;
+
+	testUtils.createSinonSandbox();
+
+	beforeEach( () => {
+		element = document.createElement( 'div' );
+		document.body.appendChild( element );
+
+		return ClassicTestEditor
+			.create( element, {
+				plugins: [ HtmlEmbedEditing ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				view = editor.editing.view;
+				viewDocument = view.document;
+			} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy()
+			.then( () => {
+				element.remove();
+			} );
+	} );
+
+	it( 'should have pluginName', () => {
+		expect( HtmlEmbedEditing.pluginName ).to.equal( 'HtmlEmbedEditing' );
+	} );
+
+	it( 'should be loaded', () => {
+		expect( editor.plugins.get( HtmlEmbedEditing ) ).to.be.instanceOf( HtmlEmbedEditing );
+	} );
+
+	it( 'should set proper schema rules', () => {
+		expect( model.schema.checkChild( [ '$root' ], 'rawHtml' ) ).to.be.true;
+		expect( model.schema.checkAttribute( [ '$root', 'rawHtml' ], 'value' ) ).to.be.true;
+
+		expect( model.schema.isObject( 'rawHtml' ) ).to.be.true;
+
+		expect( model.schema.checkChild( [ '$root', 'rawHtml' ], '$text' ) ).to.be.false;
+		expect( model.schema.checkChild( [ '$root', '$block' ], 'rawHtml' ) ).to.be.false;
+	} );
+
+	describe( 'commands', () => {
+		it( 'should register htmlEmbedUpdate command', () => {
+			expect( editor.commands.get( 'htmlEmbedUpdate' ) ).to.be.instanceOf( HtmlEmbedUpdateCommand );
+		} );
+
+		it( 'should register htmlEmbedInsert command', () => {
+			expect( editor.commands.get( 'htmlEmbedInsert' ) ).to.be.instanceOf( HtmlEmbedInsertCommand );
+		} );
+	} );
+
+	describe( 'config', () => {
+		let htmlEmbed;
+
+		beforeEach( () => {
+			htmlEmbed = editor.config.get( 'htmlEmbed' );
+		} );
+
+		describe( 'htmlEmbed.previewsInData', () => {
+			it( 'should be set to `false` by default', () => {
+				expect( htmlEmbed.previewsInData ).to.equal( false );
+			} );
+		} );
+
+		describe( 'htmlEmbed.sanitizeHtml', () => {
+			beforeEach( () => {
+				sinon.stub( console, 'warn' );
+			} );
+
+			it( 'should return an object with cleaned html and a note whether something has changed', () => {
+				expect( htmlEmbed.sanitizeHtml( 'foo' ) ).to.deep.equal( {
+					html: 'foo',
+					hasModified: false
+				} );
+			} );
+
+			it( 'should return an input string (without any modifications)', () => {
+				const unsafeHtml = '<img src="data:/xxx,<script>void</script>" onload="void;">';
+
+				expect( htmlEmbed.sanitizeHtml( unsafeHtml ).html ).to.deep.equal( unsafeHtml );
+			} );
+
+			it( 'should display a warning when using the default sanitizer', () => {
+				htmlEmbed.sanitizeHtml( 'foo' );
+
+				expect( console.warn.callCount ).to.equal( 1 );
+				expect( console.warn.firstCall.args[ 0 ] ).to.equal( 'html-embed-provide-sanitize-function' );
+			} );
+		} );
+	} );
+
+	describe( 'conversion in data pipeline', () => {
+		describe( 'model to view', () => {
+			it( 'should convert an empty `rawHtml` element', () => {
+				setModelData( model, '[<rawHtml></rawHtml>]' );
+
+				expect( editor.getData() ).to.equal( '<div class="raw-html-embed"></div>' );
+			} );
+
+			it( 'should put the HTML from the `value` attribute (in `rawHtml`) into the data', () => {
+				setModelData( model, '[<rawHtml></rawHtml>]' );
+
+				model.change( writer => {
+					writer.setAttribute( 'value', '<b>Foo.</b>', model.document.getRoot().getChild( 0 ) );
+				} );
+
+				expect( editor.getData() ).to.equal(
+					'<div class="raw-html-embed">' +
+						'<b>Foo.</b>' +
+					'</div>'
+				);
+			} );
+		} );
+
+		describe( 'view to model', () => {
+			it( 'should convert innerHTML (single element) of div.raw-html-embed', () => {
+				editor.setData(
+					'<div class="raw-html-embed">' +
+						'<b>Foo.</b>' +
+					'</div>'
+				);
+
+				const rawHtml = model.document.getRoot().getChild( 0 );
+				expect( rawHtml.getAttribute( 'value' ) ).to.equal( '<b>Foo.</b>' );
+			} );
+
+			it( 'should convert innerHTML (single element with children) of div.raw-html-embed', () => {
+				editor.setData(
+					'<div class="raw-html-embed">' +
+						'<p>' +
+							'<b>Foo B.</b>' +
+							'<i>Foo I.</i>' +
+						'</p>' +
+					'</div>'
+				);
+
+				const rawHtml = model.document.getRoot().getChild( 0 );
+
+				expect( rawHtml.getAttribute( 'value' ) ).to.equal(
+					'<p>' +
+						'<b>Foo B.</b>' +
+						'<i>Foo I.</i>' +
+					'</p>'
+				);
+			} );
+
+			it( 'should convert innerHTML (few elements) of div.raw-html-embed', () => {
+				editor.setData(
+					'<div class="raw-html-embed">' +
+						'<b>Foo B.</b>' +
+						'<i>Foo I.</i>' +
+						'<u>Foo U.</u>' +
+					'</div>'
+				);
+
+				const rawHtml = model.document.getRoot().getChild( 0 );
+
+				expect( rawHtml.getAttribute( 'value' ) ).to.equal(
+					'<b>Foo B.</b>' +
+					'<i>Foo I.</i>' +
+					'<u>Foo U.</u>'
+				);
+			} );
+
+			it( 'should not convert in wrong context', () => {
+				model.schema.register( 'div', { inheritAllFrom: '$block' } );
+				model.schema.addChildCheck( ( ctx, childDef ) => {
+					if ( ctx.endsWith( '$root' ) && childDef.name == 'rawHtml' ) {
+						return false;
+					}
+				} );
+
+				editor.conversion.elementToElement( { model: 'div', view: 'div' } );
+
+				editor.setData(
+					'<div>' +
+						'<div class="raw-html-embed">' +
+							'<b>Foo.</b>' +
+						'</div>' +
+					'</div>'
+				);
+
+				expect( getModelData( model, { withoutSelection: true } ) )
+					.to.equal( '<div></div>' );
+			} );
+
+			it( 'should not convert inner `div.raw-html-embed` that is a child of outer div.raw-html-embed', () => {
+				editor.setData(
+					'<div class="raw-html-embed">' +
+						'<div class="raw-html-embed">' +
+							'<b>Foo B.</b>' +
+							'<i>Foo I.</i>' +
+							'<u>Foo U.</u>' +
+						'</div>' +
+					'</div>'
+				);
+
+				const rawHtml = model.document.getRoot().getChild( 0 );
+
+				expect( rawHtml.getAttribute( 'value' ) ).to.equal(
+					'<div class="raw-html-embed">' +
+						'<b>Foo B.</b>' +
+						'<i>Foo I.</i>' +
+						'<u>Foo U.</u>' +
+					'</div>'
+				);
+			} );
+		} );
+	} );
+
+	describe( 'conversion in editing pipeline (model to view)', () => {
+		describe( 'without previews (htmlEmbed.dataInPreviews=false)', () => {
+			it( 'converted element should be widgetized', () => {
+				setModelData( model, '<rawHtml></rawHtml>' );
+				const widget = viewDocument.getRoot().getChild( 0 );
+
+				expect( widget.name ).to.equal( 'div' );
+				expect( isRawHtmlWidget( widget ) ).to.be.true;
+
+				const rawHtmlContainer = widget.getChild( 0 );
+
+				expect( rawHtmlContainer ).to.not.be.undefined;
+			} );
+
+			it( 'widget should not contain a class that informs about available preview mode', () => {
+				setModelData( model, '<rawHtml></rawHtml>' );
+
+				const widget = viewDocument.getRoot().getChild( 0 );
+
+				expect( widget.hasClass( 'raw-html--preview-enabled' ) ).to.equal( false );
+			} );
+
+			it( 'should render the toggle mode icon and edit source elements', () => {
+				setModelData( model, '<rawHtml></rawHtml>' );
+
+				const widget = viewDocument.getRoot().getChild( 0 );
+				const viewHtmlContainer = widget.getChild( 0 );
+
+				expect( viewHtmlContainer.childCount ).to.equal( 2 );
+
+				const toggleIconElement = viewHtmlContainer.getChild( 0 );
+
+				expect( toggleIconElement.is( 'uiElement' ) ).to.equal( true );
+				expect( toggleIconElement.getCustomProperty( 'domElement' ) ).to.be.an.instanceOf( HTMLDivElement );
+
+				const sourceElement = viewHtmlContainer.getChild( 1 );
+
+				expect( sourceElement.is( 'uiElement' ) ).to.equal( true );
+				expect( sourceElement.getCustomProperty( 'domElement' ) ).to.be.an.instanceOf( HTMLTextAreaElement );
+			} );
+
+			it( 'source element should have a placeholder', () => {
+				setModelData( model, '<rawHtml></rawHtml>' );
+
+				const widget = viewDocument.getRoot().getChild( 0 );
+				const viewHtmlContainer = widget.getChild( 0 );
+				const sourceElement = viewHtmlContainer.getChild( 1 );
+
+				expect( sourceElement.getAttribute( 'placeholder' ) ).to.equal( 'Paste the raw code here.' );
+			} );
+
+			it( 'should update the edit source element when the `value` attribute has changed', () => {
+				setModelData( model, '<rawHtml></rawHtml>' );
+
+				model.change( writer => {
+					writer.setAttribute( 'value', '<b>Foo.</b>', model.document.getRoot().getChild( 0 ) );
+				} );
+
+				const widget = viewDocument.getRoot().getChild( 0 );
+				const viewHtmlContainer = widget.getChild( 0 );
+				const sourceElement = viewHtmlContainer.getChild( 1 );
+
+				expect( sourceElement.getCustomProperty( 'domElement' ).value ).to.equal( '<b>Foo.</b>' );
+			} );
+
+			it( 'should update the `value` attribute after applying changes in the edit source element', () => {
+				setModelData( model, '<rawHtml></rawHtml>' );
+
+				const widget = viewDocument.getRoot().getChild( 0 );
+				const viewHtmlContainer = widget.getChild( 0 );
+				const sourceElement = viewHtmlContainer.getChild( 1 );
+
+				const textarea = sourceElement.getCustomProperty( 'domElement' );
+
+				textarea.value = '<b>Foo.</b>';
+				textarea.dispatchEvent( new Event( 'input' ) );
+
+				expect( model.document.getRoot().getChild( 0 ).getAttribute( 'value' ) ).to.equal( '<b>Foo.</b>' );
+			} );
+
+			it( 'should show the preview element by default (source element should be disabled)', () => {
+				setModelData( model, '<rawHtml value="Foo"></rawHtml>' );
+
+				const widget = viewDocument.getRoot().getChild( 0 );
+				const viewHtmlContainer = widget.getChild( 0 );
+				const sourceElement = viewHtmlContainer.getChild( 1 );
+
+				const textarea = sourceElement.getCustomProperty( 'domElement' );
+
+				expect( textarea.disabled ).to.equal( true );
+				expect( textarea.value ).to.equal( 'Foo' );
+			} );
+
+			it( 'should allows modifying the source after clicking the toggle icon', () => {
+				setModelData( model, '<rawHtml></rawHtml>' );
+
+				const widget = viewDocument.getRoot().getChild( 0 );
+				const viewHtmlContainer = widget.getChild( 0 );
+
+				const toggleIconElement = viewHtmlContainer.getChild( 0 );
+				toggleIconElement.getCustomProperty( 'domElement' ).click();
+
+				const sourceElement = viewHtmlContainer.getChild( 1 );
+
+				expect( sourceElement.is( 'uiElement' ) ).to.equal( true );
+				expect( sourceElement.getCustomProperty( 'domElement' ).disabled ).to.equal( false );
+			} );
+
+			it( 'should disable the source element after clicking the toggle icon when edit source mode is enabled', () => {
+				setModelData( model, '<rawHtml></rawHtml>' );
+
+				const widget = viewDocument.getRoot().getChild( 0 );
+				const viewHtmlContainer = widget.getChild( 0 );
+
+				const toggleIconElement = viewHtmlContainer.getChild( 0 );
+				// Switch to edit source mode.
+				toggleIconElement.getCustomProperty( 'domElement' ).click();
+				// Switch to preview mode.
+				toggleIconElement.getCustomProperty( 'domElement' ).click();
+
+				const sourceElement = viewHtmlContainer.getChild( 1 );
+
+				expect( sourceElement.getCustomProperty( 'domElement' ).disabled ).to.equal( true );
+			} );
+		} );
+
+		describe( 'with previews (htmlEmbed.dataInPreviews=true)', () => {
+			let element, editor, model, view, viewDocument, sanitizeHtml;
+
+			testUtils.createSinonSandbox();
+
+			beforeEach( () => {
+				element = document.createElement( 'div' );
+				document.body.appendChild( element );
+
+				// The default sanitize function without `console.warn`.
+				sanitizeHtml = input => ( { html: input, hasChanged: false } );
+
+				return ClassicTestEditor
+					.create( element, {
+						plugins: [ HtmlEmbedEditing ],
+						htmlEmbed: {
+							previewsInData: true,
+							sanitizeHtml
+						}
+					} )
+					.then( newEditor => {
+						editor = newEditor;
+						model = editor.model;
+						view = editor.editing.view;
+						viewDocument = view.document;
+					} );
+			} );
+
+			afterEach( () => {
+				return editor.destroy()
+					.then( () => {
+						element.remove();
+					} );
+			} );
+
+			it( 'converted element should be widgetized', () => {
+				setModelData( model, '<rawHtml></rawHtml>' );
+				const widget = viewDocument.getRoot().getChild( 0 );
+
+				expect( widget.name ).to.equal( 'div' );
+				expect( isRawHtmlWidget( widget ) ).to.be.true;
+
+				const rawHtmlContainer = widget.getChild( 0 );
+
+				expect( rawHtmlContainer ).to.not.be.undefined;
+			} );
+
+			it( 'widget should contain a class that informs about available preview mode', () => {
+				setModelData( model, '<rawHtml></rawHtml>' );
+
+				const widget = viewDocument.getRoot().getChild( 0 );
+
+				expect( widget.hasClass( 'raw-html--preview-enabled' ) ).to.equal( true );
+			} );
+
+			it( 'should render the toggle mode icon, edit source and preview container elements', () => {
+				setModelData( model, '<rawHtml></rawHtml>' );
+
+				const widget = viewDocument.getRoot().getChild( 0 );
+				const viewHtmlContainer = widget.getChild( 0 );
+
+				expect( viewHtmlContainer.childCount ).to.equal( 3 );
+
+				const toggleIconElement = viewHtmlContainer.getChild( 0 );
+
+				expect( toggleIconElement.is( 'uiElement' ) ).to.equal( true );
+				expect( toggleIconElement.getCustomProperty( 'domElement' ) ).to.be.an.instanceOf( HTMLDivElement );
+
+				const sourceElement = viewHtmlContainer.getChild( 1 );
+
+				expect( sourceElement.is( 'uiElement' ) ).to.equal( true );
+				expect( sourceElement.getCustomProperty( 'domElement' ) ).to.be.an.instanceOf( HTMLTextAreaElement );
+
+				const previewElement = viewHtmlContainer.getChild( 2 );
+
+				expect( previewElement.is( 'rawElement' ) ).to.equal( true );
+				expect( previewElement.getCustomProperty( 'domElement' ) ).to.be.an.instanceOf( HTMLDivElement );
+			} );
+
+			it( 'source element should have a placeholder', () => {
+				setModelData( model, '<rawHtml></rawHtml>' );
+
+				const widget = viewDocument.getRoot().getChild( 0 );
+				const viewHtmlContainer = widget.getChild( 0 );
+				const sourceElement = viewHtmlContainer.getChild( 1 );
+
+				expect( sourceElement.getAttribute( 'placeholder' ) ).to.equal( 'Paste the raw code here.' );
+			} );
+
+			it( 'should update the source and preview elements when the `value` attribute has changed', () => {
+				setModelData( model, '<rawHtml></rawHtml>' );
+
+				model.change( writer => {
+					writer.setAttribute( 'value', '<b>Foo.</b>', model.document.getRoot().getChild( 0 ) );
+				} );
+
+				const widget = viewDocument.getRoot().getChild( 0 );
+				const viewHtmlContainer = widget.getChild( 0 );
+				const sourceElement = viewHtmlContainer.getChild( 1 );
+				const previewElement = viewHtmlContainer.getChild( 2 );
+
+				expect( sourceElement.getCustomProperty( 'domElement' ).value ).to.equal( '<b>Foo.</b>' );
+				expect( previewElement.getCustomProperty( 'domElement' ).innerHTML ).to.equal( '<b>Foo.</b>' );
+			} );
+
+			it( 'should update the `value` attribute after applying changes in the edit source element', () => {
+				setModelData( model, '<rawHtml></rawHtml>' );
+
+				const event = new Event( 'input' );
+
+				const widget = viewDocument.getRoot().getChild( 0 );
+				const viewHtmlContainer = widget.getChild( 0 );
+				const sourceElement = viewHtmlContainer.getChild( 1 );
+
+				const textarea = sourceElement.getCustomProperty( 'domElement' );
+
+				textarea.value = '<b>Foo.</b>';
+				textarea.dispatchEvent( event );
+
+				expect( model.document.getRoot().getChild( 0 ).getAttribute( 'value' ) ).to.equal( '<b>Foo.</b>' );
+			} );
+
+			it( 'should re-render the preview element after applying changes in the edit source element', () => {
+				setModelData( model, '<rawHtml></rawHtml>' );
+
+				const event = new Event( 'input' );
+
+				const widget = viewDocument.getRoot().getChild( 0 );
+				const viewHtmlContainer = widget.getChild( 0 );
+				const sourceElement = viewHtmlContainer.getChild( 1 );
+
+				const textarea = sourceElement.getCustomProperty( 'domElement' );
+
+				textarea.value = '<b>Foo.</b>';
+				textarea.dispatchEvent( event );
+
+				const previewElement = viewHtmlContainer.getChild( 2 );
+
+				expect( previewElement.getCustomProperty( 'domElement' ).innerHTML ).to.equal( '<b>Foo.</b>' );
+			} );
+
+			it( 'should show the preview element by default', () => {
+				setModelData( model, '<rawHtml value="Foo"></rawHtml>' );
+
+				const widget = viewDocument.getRoot().getChild( 0 );
+
+				expect( widget.hasClass( 'raw-html--display-preview' ) ).to.equal( true );
+
+				const viewHtmlContainer = widget.getChild( 0 );
+				const sourceElement = viewHtmlContainer.getChild( 1 );
+
+				const textarea = sourceElement.getCustomProperty( 'domElement' );
+
+				expect( textarea.disabled ).to.equal( true );
+				expect( textarea.value ).to.equal( 'Foo' );
+
+				const previewElement = viewHtmlContainer.getChild( 2 ).getCustomProperty( 'domElement' );
+				expect( previewElement.innerHTML ).to.equal( 'Foo' );
+			} );
+
+			it( 'should allows modifying the source after clicking the toggle icon', () => {
+				setModelData( model, '<rawHtml></rawHtml>' );
+
+				const widget = viewDocument.getRoot().getChild( 0 );
+				const viewHtmlContainer = widget.getChild( 0 );
+
+				const toggleIconElement = viewHtmlContainer.getChild( 0 );
+				toggleIconElement.getCustomProperty( 'domElement' ).click();
+
+				expect( widget.hasClass( 'raw-html--display-preview' ) ).to.equal( false );
+
+				const sourceElement = viewHtmlContainer.getChild( 1 );
+				expect( sourceElement.getCustomProperty( 'domElement' ).disabled ).to.equal( false );
+			} );
+
+			it( 'should display preview element after clicking the toggle icon when displaying edit source mode', () => {
+				setModelData( model, '<rawHtml></rawHtml>' );
+
+				const widget = viewDocument.getRoot().getChild( 0 );
+				const viewHtmlContainer = widget.getChild( 0 );
+
+				const toggleIconElement = viewHtmlContainer.getChild( 0 );
+				// Switch to edit source mode.
+				toggleIconElement.getCustomProperty( 'domElement' ).click();
+				// Switch to preview mode.
+				toggleIconElement.getCustomProperty( 'domElement' ).click();
+
+				expect( widget.hasClass( 'raw-html--display-preview' ) ).to.equal( true );
+
+				const sourceElement = viewHtmlContainer.getChild( 1 );
+				expect( sourceElement.getCustomProperty( 'domElement' ).disabled ).to.equal( true );
+			} );
+		} );
+	} );
+} );
+
+function isRawHtmlWidget( viewElement ) {
+	return !!viewElement.getCustomProperty( 'rawHtml' ) && isWidget( viewElement );
+}

+ 169 - 0
packages/ckeditor5-html-embed/tests/htmlembedinsertcommand.js

@@ -0,0 +1,169 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals document */
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import HtmlEmbedEditing from '../src/htmlembedediting';
+import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+describe( 'HtmlEmbedInsertCommand', () => {
+	let editor, model, editorElement, command;
+
+	testUtils.createSinonSandbox();
+
+	beforeEach( () => {
+		editorElement = document.createElement( 'div' );
+		document.body.appendChild( editorElement );
+
+		return ClassicTestEditor
+			.create( editorElement, {
+				plugins: [ HtmlEmbedEditing, Paragraph ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				command = editor.commands.get( 'htmlEmbedInsert' );
+			} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy()
+			.then( () => {
+				editorElement.remove();
+			} );
+	} );
+
+	describe( 'isEnabled', () => {
+		it( 'should be true when the selection directly in the root', () => {
+			model.enqueueChange( 'transparent', () => {
+				setModelData( model, '[]' );
+
+				command.refresh();
+				expect( command.isEnabled ).to.be.true;
+			} );
+		} );
+
+		it( 'should be true when the selection is in empty block', () => {
+			setModelData( model, '<paragraph>[]</paragraph>' );
+
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be true when the selection directly in a paragraph', () => {
+			setModelData( model, '<paragraph>foo[]</paragraph>' );
+			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' } );
+			editor.conversion.for( 'downcast' ).elementToElement( { model: 'block', view: 'block' } );
+
+			setModelData( model, '<block>foo[]</block>' );
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be false when the selection is on other raw html element', () => {
+			setModelData( model, '[<rawHtml></rawHtml>]' );
+			expect( command.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be false when the selection is on other object', () => {
+			model.schema.register( 'object', { isObject: true, allowIn: '$root' } );
+			editor.conversion.for( 'downcast' ).elementToElement( { model: 'object', view: 'object' } );
+			setModelData( model, '[<object></object>]' );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be true when the selection is inside block element inside isLimit element which allows raw html', () => {
+			model.schema.register( 'table', { allowWhere: '$block', isLimit: true, isObject: true, isBlock: true } );
+			model.schema.register( 'tableRow', { allowIn: 'table', isLimit: true } );
+			model.schema.register( 'tableCell', { allowIn: 'tableRow', isLimit: true, isSelectable: true } );
+			model.schema.extend( '$block', { allowIn: 'tableCell' } );
+			editor.conversion.for( 'downcast' ).elementToElement( { model: 'table', view: 'table' } );
+			editor.conversion.for( 'downcast' ).elementToElement( { model: 'tableRow', view: 'tableRow' } );
+			editor.conversion.for( 'downcast' ).elementToElement( { model: 'tableCell', view: 'tableCell' } );
+
+			setModelData( model, '<table><tableRow><tableCell><paragraph>foo[]</paragraph></tableCell></tableRow></table>' );
+		} );
+
+		it( 'should be false when schema disallows raw html', () => {
+			model.schema.register( 'block', { inheritAllFrom: '$block' } );
+			model.schema.extend( 'paragraph', { allowIn: 'block' } );
+			// Block raw html in block.
+			model.schema.addChildCheck( ( context, childDefinition ) => {
+				if ( childDefinition.name === 'rawHtml' && context.last.name === 'block' ) {
+					return false;
+				}
+			} );
+			editor.conversion.for( 'downcast' ).elementToElement( { model: 'block', view: 'block' } );
+
+			setModelData( model, '<block><paragraph>[]</paragraph></block>' );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+	} );
+
+	describe( 'execute()', () => {
+		beforeEach( () => {
+			model.schema.register( 'heading1', { inheritAllFrom: '$block' } );
+			editor.conversion.elementToElement( { model: 'heading1', view: 'h1' } );
+
+			model.schema.register( 'media', { allowWhere: '$block' } );
+			editor.conversion.elementToElement( { model: 'media', view: 'div' } );
+		} );
+
+		it( 'should create a single batch', () => {
+			setModelData( model, '<paragraph>foo[]</paragraph>' );
+
+			const spy = sinon.spy();
+
+			model.document.on( 'change', spy );
+
+			command.execute();
+
+			sinon.assert.calledOnce( spy );
+		} );
+
+		it( 'should insert a raw html in an empty root and select it (a paragraph cannot be inserted)', () => {
+			// Block a paragraph in $root.
+			model.schema.addChildCheck( ( context, childDefinition ) => {
+				if ( childDefinition.name === 'paragraph' && context.last.name === '$root' ) {
+					return false;
+				}
+			} );
+
+			setModelData( model, '[]' );
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal( '[<rawHtml></rawHtml>]' );
+		} );
+
+		it( 'should split an element where selection is placed and insert a raw html (non-collapsed selection)', () => {
+			setModelData( model, '<paragraph>f[o]o</paragraph>' );
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>f</paragraph>[<rawHtml></rawHtml>]<paragraph>o</paragraph>'
+			);
+		} );
+
+		it( 'should split an element where selection is placed and insert a raw html (collapsed selection)', () => {
+			setModelData( model, '<paragraph>fo[]o</paragraph>' );
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>fo</paragraph>[<rawHtml></rawHtml>]<paragraph>o</paragraph>'
+			);
+		} );
+	} );
+} );

+ 72 - 0
packages/ckeditor5-html-embed/tests/htmlembedui.js

@@ -0,0 +1,72 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* global document */
+
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import HtmlEmbedEditing from '../src/htmlembedediting';
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import HtmlEmbedUI from '../src/htmlembedui';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+
+describe( 'HtmlEmbedEditing', () => {
+	let element, editor, view, viewDocument, htmlEmbedView;
+
+	testUtils.createSinonSandbox();
+
+	beforeEach( () => {
+		element = document.createElement( 'div' );
+		document.body.appendChild( element );
+
+		return ClassicTestEditor
+			.create( element, {
+				plugins: [ HtmlEmbedUI, HtmlEmbedEditing ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				view = editor.editing.view;
+				viewDocument = view.document;
+
+				htmlEmbedView = editor.ui.componentFactory.create( 'htmlEmbed' );
+			} );
+	} );
+
+	afterEach( () => {
+		element.remove();
+		return editor.destroy();
+	} );
+
+	it( 'should register htmlEmbed feature component', () => {
+		expect( htmlEmbedView ).to.be.instanceOf( ButtonView );
+		expect( htmlEmbedView.label ).to.equal( 'Insert HTML' );
+		expect( htmlEmbedView.icon ).to.match( /<svg / );
+		expect( htmlEmbedView.isToggleable ).to.be.false;
+	} );
+
+	it( 'should execute htmlEmbedInsert command on model execute event', () => {
+		const executeSpy = testUtils.sinon.spy( editor, 'execute' );
+
+		htmlEmbedView.fire( 'execute' );
+
+		sinon.assert.calledOnce( executeSpy );
+		sinon.assert.calledWithExactly( executeSpy, 'htmlEmbedInsert' );
+	} );
+
+	it( 'should bind model to htmlEmbedInsert command', () => {
+		const command = editor.commands.get( 'htmlEmbedInsert' );
+
+		expect( htmlEmbedView.isEnabled ).to.be.true;
+
+		command.isEnabled = false;
+		expect( htmlEmbedView.isEnabled ).to.be.false;
+	} );
+
+	it( 'should switch to edit source mode after inserting the element', () => {
+		htmlEmbedView.fire( 'execute' );
+
+		const editSourceView = viewDocument.getRoot().getChild( 0 ).getChild( 0 ).getChild( 1 );
+		expect( document.activeElement ).to.equal( editSourceView.getCustomProperty( 'domElement' ) );
+	} );
+} );

+ 96 - 0
packages/ckeditor5-html-embed/tests/htmlembedupdatecommand.js

@@ -0,0 +1,96 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals document */
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import HtmlEmbedEditing from '../src/htmlembedediting';
+import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+describe( 'HtmlEmbedUpdateCommand', () => {
+	let editor, model, editorElement, command;
+
+	testUtils.createSinonSandbox();
+
+	beforeEach( () => {
+		editorElement = document.createElement( 'div' );
+		document.body.appendChild( editorElement );
+
+		return ClassicTestEditor
+			.create( editorElement, {
+				plugins: [ HtmlEmbedEditing, Paragraph ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				command = editor.commands.get( 'htmlEmbedUpdate' );
+			} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy()
+			.then( () => {
+				editorElement.remove();
+			} );
+	} );
+
+	describe( 'isEnabled', () => {
+		it( 'should be true when the selection contains single the `rawHtml` element', () => {
+			setModelData( model, '[<rawHtml></rawHtml>]' );
+
+			command.refresh();
+
+			expect( command.isEnabled ).to.equal( true );
+		} );
+
+		it( 'should be false when the selection contains more than single `rawHtml` element', () => {
+			setModelData( model, '[<rawHtml></rawHtml><rawHtml></rawHtml>]' );
+
+			command.refresh();
+
+			expect( command.isEnabled ).to.equal( false );
+		} );
+
+		it( 'should be false when the selection contains other element than the `rawHtml` element', () => {
+			setModelData( model, '[<paragraph></paragraph>]' );
+
+			command.refresh();
+
+			expect( command.isEnabled ).to.equal( false );
+		} );
+	} );
+
+	describe( 'execute()', () => {
+		it( 'should create a single batch', () => {
+			setModelData( model, '[<rawHtml></rawHtml>]' );
+
+			const spy = sinon.spy();
+
+			model.document.on( 'change', spy );
+
+			command.execute( '<b>Foo.</b>' );
+
+			sinon.assert.calledOnce( spy );
+		} );
+
+		it( 'should update the `value` attribute of selected the `rawHtml` element', () => {
+			setModelData( model, '[<rawHtml></rawHtml>]' );
+			command.execute( '<b>Foo.</b>' );
+
+			const rawHtml = model.document.getRoot().getChild( 0 );
+
+			expect( rawHtml.getAttribute( 'value' ) ).to.equal( '<b>Foo.</b>' );
+		} );
+
+		it( 'should update nothing if selected other element than the `rawHtml` element', () => {
+			setModelData( model, '[<paragraph></paragraph>]' );
+			command.execute( '<b>Foo.</b>' );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>[]</paragraph>' );
+		} );
+	} );
+} );

+ 157 - 0
packages/ckeditor5-html-embed/tests/manual/htmlembed.html

@@ -0,0 +1,157 @@
+<head>
+    <meta http-equiv="Content-Security-Policy" content="default-src 'none'; media-src http://techslides.com/; connect-src 'self' https://cksource.com http://*.cke-cs.com; script-src 'self' https://cksource.com; img-src * data:; style-src 'self' 'unsafe-inline'; frame-src *">
+</head>
+
+<p>
+    <b>Mode of HTML previews</b>:
+    <input type="radio" id="mode-enabled" name="mode" value="enabled" checked><label for="mode-enabled">Enabled</label>
+    <input type="radio" id="mode-disabled" name="mode" value="disabled"><label for="mode-disabled">Disabled</label>
+</p>
+
+<div id="editor">
+    <h3><code>&lt;video&gt;</code> tag as HTML snippet</h3>
+        <div class="raw-html-embed">
+            <video width="320" height="240" controls>
+                <source src="http://techslides.com/demos/sample-videos/small.mp4" type="video/mp4">
+                <source src="http://techslides.com/demos/sample-videos/small.ogv" type="video/ogg">
+                Your browser does not support the video tag.
+            </video>
+        </div>
+
+
+    <h3><code>&lt;audio&gt;</code> tag as HTML snippet</h3>
+        <div class="raw-html-embed">
+            <audio controls>
+                <source src="http://techslides.com/demos/samples/sample.ogg" type="audio/ogg">
+                <source src="http://techslides.com/demos/samples/sample.mp3" type="audio/mpeg">
+                Your browser does not support the audio tag.
+            </audio>
+        </div>
+
+
+    <h3><code>&lt;picture&gt;</code> tag as HTML snippet</h3>
+        <div class="raw-html-embed">
+            <picture>
+                <source media="(min-width: 1200px)" srcset="http://techslides.com/demos/samples/sample.jpg">
+                <source media="(min-width: 650px)" srcset="http://techslides.com/demos/samples/sample.png">
+                <img src="http://techslides.com/demos/samples/sample.gif">
+            </picture>
+        </div>
+
+    <h3><code>&lt;iframe&gt;</code> tag as HTML snippet</h3>
+        <div class="raw-html-embed">
+            <iframe src="http://techslides.com/demos/samples/sample.txt"></iframe>
+        </div>
+
+
+    <h3><code>&lt;table&gt;</code> tag as HTML snippet (code coverage report)</h3>
+    <div class="raw-html-embed">
+        <table class="coverage-summary">
+            <thead>
+            <tr>
+                <th data-col="file" data-fmt="html" data-html="true" class="file">File</th>
+                <th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th>
+                <th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th>
+                <th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th>
+                <th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th>
+                <th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th>
+                <th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th>
+                <th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th>
+                <th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th>
+                <th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th>
+            </tr>
+            </thead>
+            <tbody>
+            <tr>
+                <td class="file high" data-value="htmlembed.js">htmlembed.js</td>
+                <td data-value="100" class="pic high">
+                    <div class="chart">
+                        <div class="cover-fill cover-full" style="width: 100%"></div>
+                        <div class="cover-empty" style="width: 0%"></div>
+                    </div>
+                </td>
+                <td data-value="100" class="pct high">100%</td>
+                <td data-value="2" class="abs high">2/2</td>
+                <td data-value="100" class="pct high">100%</td>
+                <td data-value="0" class="abs high">0/0</td>
+                <td data-value="100" class="pct high">100%</td>
+                <td data-value="2" class="abs high">2/2</td>
+                <td data-value="100" class="pct high">100%</td>
+                <td data-value="2" class="abs high">2/2</td>
+            </tr>
+
+            <tr>
+                <td class="file high" data-value="htmlembedediting.js">htmlembedediting.js</td>
+                <td data-value="100" class="pic high">
+                    <div class="chart">
+                        <div class="cover-fill cover-full" style="width: 100%"></div>
+                        <div class="cover-empty" style="width: 0%"></div>
+                    </div>
+                </td>
+                <td data-value="100" class="pct high">100%</td>
+                <td data-value="81" class="abs high">81/81</td>
+                <td data-value="100" class="pct high">100%</td>
+                <td data-value="18" class="abs high">18/18</td>
+                <td data-value="100" class="pct high">100%</td>
+                <td data-value="19" class="abs high">19/19</td>
+                <td data-value="100" class="pct high">100%</td>
+                <td data-value="81" class="abs high">81/81</td>
+            </tr>
+
+            <tr>
+                <td class="file high" data-value="htmlembedinsertcommand.js">htmlembedinsertcommand.js</td>
+                <td data-value="100" class="pic high">
+                    <div class="chart">
+                        <div class="cover-fill cover-full" style="width: 100%"></div>
+                        <div class="cover-empty" style="width: 0%"></div>
+                    </div>
+                </td>
+                <td data-value="100" class="pct high">100%</td>
+                <td data-value="18" class="abs high">18/18</td>
+                <td data-value="100" class="pct high">100%</td>
+                <td data-value="8" class="abs high">8/8</td>
+                <td data-value="100" class="pct high">100%</td>
+                <td data-value="7" class="abs high">7/7</td>
+                <td data-value="100" class="pct high">100%</td>
+                <td data-value="18" class="abs high">18/18</td>
+            </tr>
+
+            <tr>
+                <td class="file high" data-value="htmlembedui.js">htmlembedui.js</td>
+                <td data-value="100" class="pic high">
+                    <div class="chart">
+                        <div class="cover-fill cover-full" style="width: 100%"></div>
+                        <div class="cover-empty" style="width: 0%"></div>
+                    </div>
+                </td>
+                <td data-value="100" class="pct high">100%</td>
+                <td data-value="15" class="abs high">15/15</td>
+                <td data-value="100" class="pct high">100%</td>
+                <td data-value="0" class="abs high">0/0</td>
+                <td data-value="100" class="pct high">100%</td>
+                <td data-value="3" class="abs high">3/3</td>
+                <td data-value="100" class="pct high">100%</td>
+                <td data-value="15" class="abs high">15/15</td>
+            </tr>
+
+            <tr>
+                <td class="file high" data-value="htmlembedupdatecommand.js">htmlembedupdatecommand.js</td>
+                <td data-value="100" class="pic high">
+                    <div class="chart">
+                        <div class="cover-fill cover-full" style="width: 100%"></div>
+                        <div class="cover-empty" style="width: 0%"></div>
+                    </div>
+                </td>
+                <td data-value="100" class="pct high">100%</td>
+                <td data-value="13" class="abs high">13/13</td>
+                <td data-value="100" class="pct high">100%</td>
+                <td data-value="4" class="abs high">4/4</td>
+                <td data-value="100" class="pct high">100%</td>
+                <td data-value="4" class="abs high">4/4</td>
+                <td data-value="100" class="pct high">100%</td>
+                <td data-value="13" class="abs high">13/13</td>
+            </tr>
+            </tbody>
+        </table>
+    </div>
+</div>

+ 118 - 0
packages/ckeditor5-html-embed/tests/manual/htmlembed.js

@@ -0,0 +1,118 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals window, document */
+
+import sanitizeHtml from 'sanitize-html';
+import { clone } from 'lodash-es';
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import Code from '@ckeditor/ckeditor5-basic-styles/src/code';
+import HtmlEmbed from '../../src/htmlembed';
+
+const restrictedModeButton = document.getElementById( 'mode-enabled' );
+const standardModeButton = document.getElementById( 'mode-disabled' );
+
+restrictedModeButton.addEventListener( 'change', handleModeChange );
+standardModeButton.addEventListener( 'change', handleModeChange );
+
+startMode( document.querySelector( 'input[name="mode"]:checked' ).value );
+
+async function handleModeChange( evt ) {
+	await startMode( evt.target.value );
+}
+
+async function startMode( selectedMode ) {
+	if ( selectedMode === 'enabled' ) {
+		await startEnabledPreviewsMode();
+	} else {
+		await startDisabledPreviewsMode();
+	}
+}
+
+async function startEnabledPreviewsMode() {
+	await reloadEditor( {
+		htmlEmbed: {
+			previewsInData: true,
+			sanitizeHtml( rawHtml ) {
+				const config = getSanitizeHtmlConfig( sanitizeHtml.defaults );
+				const cleanHtml = sanitizeHtml( rawHtml, config );
+
+				return {
+					html: cleanHtml,
+					hasModified: rawHtml !== cleanHtml
+				};
+			}
+		}
+	} );
+}
+
+async function startDisabledPreviewsMode() {
+	await reloadEditor();
+}
+
+async function reloadEditor( config = {} ) {
+	if ( window.editor ) {
+		await window.editor.destroy();
+	}
+
+	config = Object.assign( config, {
+		plugins: [ ArticlePluginSet, HtmlEmbed, Code ],
+		toolbar: [
+			'heading', '|', 'bold', 'italic', 'link', '|',
+			'bulletedList', 'numberedList', 'blockQuote', 'insertTable', '|',
+			'undo', 'redo', '|', 'htmlEmbed'
+		],
+		image: {
+			toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ]
+		}
+	} );
+
+	window.editor = await ClassicEditor.create( document.querySelector( '#editor' ), config );
+}
+
+function getSanitizeHtmlConfig( defaultConfig ) {
+	const config = clone( defaultConfig );
+
+	config.allowedTags.push(
+		// Allows embedding iframes.
+		'iframe',
+
+		// Allows embedding media.
+		'audio',
+		'video',
+		'picture',
+		'source',
+		'img'
+	);
+
+	config.selfClosing.push( 'source' );
+
+	// Remove duplicates.
+	config.allowedTags = [ ...new Set( config.allowedTags ) ];
+
+	config.allowedSchemesAppliedToAttributes.push(
+		// Responsive images.
+		'srcset'
+	);
+
+	for ( const htmlTag of config.allowedTags ) {
+		if ( !Array.isArray( config.allowedAttributes[ htmlTag ] ) ) {
+			config.allowedAttributes[ htmlTag ] = [];
+		}
+
+		// Allow inlining styles for all elements.
+		config.allowedAttributes[ htmlTag ].push( 'style' );
+	}
+
+	config.allowedAttributes.audio.push( 'controls' );
+	config.allowedAttributes.video.push( 'width', 'height', 'controls' );
+
+	config.allowedAttributes.iframe.push( 'src' );
+	config.allowedAttributes.img.push( 'srcset', 'sizes', 'src' );
+	config.allowedAttributes.source.push( 'src', 'srcset', 'media', 'sizes', 'type' );
+
+	return config;
+}

+ 38 - 0
packages/ckeditor5-html-embed/tests/manual/htmlembed.md

@@ -0,0 +1,38 @@
+## HTML embed feature
+
+--- 
+
+**This test has changed the CSP definitions:**
+
+```html
+<meta http-equiv="Content-Security-Policy" content="
+    default-src 'none'; 
+    media-src http://techslides.com/; 
+    connect-src 'self' https://cksource.com http://*.cke-cs.com; 
+    script-src 'self' https://cksource.com; 
+    img-src * data:; 
+    style-src 'self' 'unsafe-inline'; 
+    frame-src *"
+>
+```
+
+---
+
+After the editor initialization, it should contain 5 widgets with embedded HTML:
+
+- `video`
+- `audio`
+- `picture` (resize the window to see other images, limits: 1200px, 650px)
+- `iframe`
+- `table`
+
+All resources are provided by the ["Sample Files for Development"](http://techslides.com/sample-files-for-development) article. Thanks!
+
+By default, the previews in data mode is enabled. It means that previews should be visible. 
+
+We use the [`sanitize-html`](https://www.npmjs.com/package/sanitize-html) package to clean up the input HTML. It means that some of the 
+elements or attributes may be not rendered in the editing view. However, they still will be returned in the editor's data.
+
+You can disable the preview mode and have the `textarea` element that will be blocked or enabled (depends on feature state).
+
+For toggling the state (edit source / see preview), click the icon in the right-top corner.

+ 4 - 0
packages/ckeditor5-html-embed/theme/htmlembed.css

@@ -0,0 +1,4 @@
+/*
+ * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */

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

@@ -0,0 +1 @@
+<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M2 9h16v2H2z"/></svg>

+ 6 - 0
packages/ckeditor5-html-embed/theme/icons/htmlembedmode.svg

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="iso-8859-1"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN""http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<!-- The icon is used a temporary placeholder. Thanks to https://www.freepik.com/free-icon/eye_775336.htm. -->
+<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+     viewBox="0 0 456.795 456.795" style="enable-background:new 0 0 456.795 456.795;" xml:space="preserve"> <g> <g> <path
+        d="M448.947,218.475c-0.922-1.168-23.055-28.933-61-56.81c-50.705-37.253-105.877-56.944-159.551-56.944 c-53.672,0-108.844,19.691-159.551,56.944c-37.944,27.876-60.077,55.642-61,56.81L0,228.397l7.846,9.923 c0.923,1.168,23.056,28.934,61,56.811c50.707,37.252,105.879,56.943,159.551,56.943c53.673,0,108.845-19.691,159.55-56.943 c37.945-27.877,60.078-55.643,61-56.811l7.848-9.923L448.947,218.475z M228.396,315.039c-47.774,0-86.642-38.867-86.642-86.642 c0-7.485,0.954-14.751,2.747-21.684l-19.781-3.329c-1.938,8.025-2.966,16.401-2.966,25.013c0,30.86,13.182,58.696,34.204,78.187 c-27.061-9.996-50.072-24.023-67.439-36.709c-21.516-15.715-37.641-31.609-46.834-41.478c9.197-9.872,25.32-25.764,46.834-41.478 c17.367-12.686,40.379-26.713,67.439-36.71l13.27,14.958c15.498-14.512,36.312-23.412,59.168-23.412 c47.774,0,86.641,38.867,86.641,86.642C315.037,276.172,276.17,315.039,228.396,315.039z M368.273,269.875 c-17.369,12.686-40.379,26.713-67.439,36.709c21.021-19.49,34.203-47.326,34.203-78.188s-13.182-58.697-34.203-78.188 c27.061,9.997,50.07,24.024,67.439,36.71c21.516,15.715,37.641,31.609,46.834,41.477 C405.91,238.269,389.787,254.162,368.273,269.875z"/>
+    <path d="M173.261,211.555c-1.626,5.329-2.507,10.982-2.507,16.843c0,31.834,25.807,57.642,57.642,57.642 c31.834,0,57.641-25.807,57.641-57.642s-25.807-57.642-57.641-57.642c-15.506,0-29.571,6.134-39.932,16.094l28.432,32.048 L173.261,211.555z"/> </g> </g></svg>

+ 54 - 0
packages/ckeditor5-theme-lark/theme/ckeditor5-html-embed/htmlembed.css

@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+.ck-editor__editable {
+	/* The feature container. */
+	& .raw-html {
+		position: relative;
+
+		/* The switch mode button. */
+		& .raw-html__switch-mode {
+			position: absolute;
+			top: 0;
+			right: 0;
+			width: 5em;
+			height: 5em;
+			cursor: pointer;
+		}
+
+		/* The edit source element. */
+		& .raw-html__source {
+			width: 30em;
+			height: 5em;
+			resize: none;
+		}
+	}
+
+	/* Using the feature with enabled previews in data. */
+	& .raw-html--preview-enabled {
+		/* The preview data container. */
+		& .raw-html__preview {
+			min-height: 5em;
+			min-width: 30em;
+			width: calc(100% - 5em);
+		}
+
+		/* Mode: preview the HTML. */
+		&.raw-html--display-preview {
+			& .raw-html__source {
+				display: none;
+			}
+
+			& .raw-html__preview {
+				display: block;
+			}
+		}
+
+		/* Mode: edit the HTML source. */
+		&:not(.raw-html--display-preview) .raw-html__preview {
+			display: none;
+		}
+	}
+}