Przeglądaj źródła

The feature refactoring.

Kamil Piechaczek 5 lat temu
rodzic
commit
1d93042693

+ 2 - 1
packages/ckeditor5-html-embed/lang/contexts.json

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

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

@@ -35,3 +35,26 @@ export default class HTMLEmbed extends Plugin {
 		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 MediaEmbedConfig
+ */
+
+/**
+ * @member {Boolean} [module:html-embed/htmlembed~MediaEmbedConfig#previewsInData=false]
+ */
+
+/**
+ * @member {Function} [module:html-embed/htmlembed~MediaEmbedConfig#sanitizeHtml]
+ */

+ 0 - 73
packages/ckeditor5-html-embed/src/htmlembedcommand.js

@@ -1,73 +0,0 @@
-/**
- * @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/htmlembedcommand
- */
-
-import Command from '@ckeditor/ckeditor5-core/src/command';
-import { findOptimalInsertionPosition } from '@ckeditor/ckeditor5-widget/src/utils';
-import { getSelectedRawHtmlModelWidget, insertRawHtml } from './utils';
-
-/**
- * The HTML embed command.
- *
- * The command is registered by {@link module:html-embed/htmlembedediting~HTMLEmbedEditing} as `'htmlEmbed'`.
- *
- * To insert a HTML code at the current selection, execute the command:
- *
- *		editor.execute( 'htmlEmbed', { html: 'HTML to insert.' } );
- *
- * @extends module:core/command~Command
- */
-export default class HTMLEmbedCommand extends Command {
-	/**
-	 * @inheritDoc
-	 */
-	refresh() {
-		const model = this.editor.model;
-		const selection = model.document.selection;
-		const schema = model.schema;
-		const insertPosition = findOptimalInsertionPosition( selection, model );
-		const selectedRawHtml = getSelectedRawHtmlModelWidget( selection );
-
-		let parent = insertPosition.parent;
-
-		// The model.insertContent() will remove empty parent (unless it is a $root or a limit).
-		if ( parent.isEmpty && !model.schema.isLimit( parent ) ) {
-			parent = parent.parent;
-		}
-
-		this.value = selectedRawHtml ? selectedRawHtml.getAttribute( 'value' ) : null;
-		this.isEnabled = schema.checkChild( parent, 'rawHtml' );
-	}
-
-	/**
-	 * Executes the command, which either:
-	 *
-	 * * updates the URL of the selected media,
-	 * * inserts the new media into the editor and puts the selection around it.
-	 *
-	 * @fires execute
-	 * @param {Object} [options={}] The command options.
-	 * @param {String} [options.rawHtml] A HTML string that will be inserted into the editor.
-	 * @param {module:engine/model/element~Element|null} [options.element] If present, the `value` attribute will be updated
-	 * with the specified `options.rawHtml` value. Otherwise, a new element will be inserted into the editor.
-	 */
-	execute( options = {} ) {
-		const model = this.editor.model;
-
-		const rawHtml = options.rawHtml;
-		const element = options.element;
-
-		if ( element ) {
-			model.change( writer => {
-				writer.setAttribute( 'value', rawHtml, element );
-			} );
-		} else {
-			insertRawHtml( model, rawHtml );
-		}
-	}
-}

+ 151 - 90
packages/ckeditor5-html-embed/src/htmlembedediting.js

@@ -7,14 +7,15 @@
  * @module html-embed/htmlembedediting
  */
 
-import sanitizeHtml from 'sanitize-html';
 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 HTMLEmbedCommand from './htmlembedcommand';
-import { clone } from 'lodash-es';
+import HTMLEmbedInsertCommand from './htmlembedinsertcommand';
+import HTMLEmbedUpdateCommand from './htmlembedupdatecommand';
 import { toRawHtmlWidget } from './utils';
+import { logWarning } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
+import htmlEmbedModeIcon from '../theme/icons/htmlembedmode.svg';
 import '../theme/htmlembed.css';
 
 /**
@@ -30,21 +31,50 @@ export default class HTMLEmbedEditing extends Plugin {
 		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's strongly recommended to
+				 * define a sanitize function that will clean an input HTML in order to avoid XSS vulnerability.
+				 * TODO: Add a link to the feature documentation.
+				 *
+				 * @error html-embed-provide-sanitize-function
+				 * @param {String} name The name of the component.
+				 */
+				logWarning( 'html-embed-provide-sanitize-function' );
+
+				return {
+					html: rawHtml,
+					hasModified: false
+				};
+			}
+		} );
+
+		/**
+		 * A collection that contains all events that must be attached directly to the DOM elements.
+		 *
+		 * @private
+		 * @type {Set.<Object>}
+		 */
+		this._domListeners = new Set();
+	}
+
 	/**
 	 * @inheritDoc
 	 */
 	init() {
 		const editor = this.editor;
-		const t = editor.t;
 		const schema = editor.model.schema;
-		const conversion = editor.conversion;
-		const viewDocument = editor.editing.view.document;
 
-		const htmlEmbedCommand = new HTMLEmbedCommand( editor );
-		const upcastWriter = new UpcastWriter( viewDocument );
-		const htmlProcessor = new HtmlDataProcessor( viewDocument );
-
-		const sanitizeHtmlConfig = getSanitizeHtmlConfig( sanitizeHtml.defaults );
+		const htmlEmbedInsertCommand = new HTMLEmbedInsertCommand( editor );
+		const htmlEmbedUpdateCommand = new HTMLEmbedUpdateCommand( editor );
 
 		schema.register( 'rawHtml', {
 			isObject: true,
@@ -52,9 +82,45 @@ export default class HTMLEmbedEditing extends Plugin {
 			allowAttributes: [ 'value' ]
 		} );
 
-		editor.commands.add( 'htmlEmbed', htmlEmbedCommand );
+		editor.commands.add( 'htmlEmbedUpdate', htmlEmbedUpdateCommand );
+		editor.commands.add( 'htmlEmbedInsert', htmlEmbedInsertCommand );
+
+		this._setupConversion();
+	}
 
-		conversion.for( 'upcast' ).elementToElement( {
+	/**
+	 * @inheritDoc
+	 */
+	destroy() {
+		for ( const item of this._domListeners ) {
+			item.element.removeEventListener( item.event, item.listener );
+		}
+
+		this._domListeners.clear();
+
+		return super.destroy();
+	}
+
+	/**
+	 * Set-ups converters for the feature.
+	 *
+	 * @private
+	 */
+	_setupConversion() {
+		// TODO: Typing around the widget does not work after adding the 'data-cke-ignore-events` attribute.
+		// TODO: Wrapping the inner views in another container with the attribute resolved WTA issue but events
+		// TODO: inside the views are not triggered.
+		const editor = this.editor;
+		const t = editor.t;
+		const view = editor.editing.view;
+
+		const htmlEmbedConfig = editor.config.get( 'htmlEmbed' );
+		const domListeners = this._domListeners;
+
+		const upcastWriter = new UpcastWriter( view.document );
+		const htmlProcessor = new HtmlDataProcessor( view.document );
+
+		editor.conversion.for( 'upcast' ).elementToElement( {
 			view: {
 				name: 'div',
 				classes: 'raw-html-embed'
@@ -69,7 +135,7 @@ export default class HTMLEmbedEditing extends Plugin {
 			}
 		} );
 
-		conversion.for( 'dataDowncast' ).elementToElement( {
+		editor.conversion.for( 'dataDowncast' ).elementToElement( {
 			model: 'rawHtml',
 			view: ( modelElement, { writer } ) => {
 				return writer.createRawElement( 'div', { class: 'raw-html-embed' }, function( domElement ) {
@@ -78,124 +144,119 @@ export default class HTMLEmbedEditing extends Plugin {
 			}
 		} );
 
-		conversion.for( 'editingDowncast' ).elementToElement( {
+		editor.conversion.for( 'editingDowncast' ).elementToElement( {
 			model: 'rawHtml',
 			view: ( modelElement, { writer } ) => {
-				const label = t( 'HTML snippet' );
-				const viewWrapper = writer.createContainerElement( 'div', { 'data-cke-ignore-events': true } );
+				const widgetLabel = t( 'HTML snippet' );
+				const placeholder = t( 'Paste the raw code here.' );
+
+				const viewWrapper = writer.createContainerElement( 'div', {
+					class: 'raw-html',
+					'data-cke-ignore-events': true
+				} );
 
 				// Whether to show a preview mode or editing area.
-				let isPreviewActive = false;
+				writer.setCustomProperty( 'isEditingSourceActive', false, viewWrapper );
 
 				// The editing raw HTML field.
-				const textarea = writer.createUIElement( 'textarea', { rows: 5 }, function( domDocument ) {
+				const textarea = writer.createUIElement( 'textarea', { placeholder }, function( domDocument ) {
 					const root = this.toDomElement( domDocument );
 
+					writer.setCustomProperty( 'DOMElement', root, textarea );
+
 					root.value = modelElement.getAttribute( 'value' ) || '';
 
-					this.listenTo( root, 'input', () => {
-						htmlEmbedCommand.execute( {
-							rawHtml: root.value,
-							element: modelElement
-						} );
+					attachDomListener( root, 'input', () => {
+						editor.execute( 'htmlEmbedUpdate', root.value );
 					} );
 
 					return root;
 				} );
 
 				// The switch button between preview and editing HTML.
-				const toggleButton = writer.createUIElement( 'div', { class: 'raw-html__edit-preview' }, function( domDocument ) {
+				const toggleButton = writer.createUIElement( 'div', { class: 'raw-html__switch-mode' }, function( domDocument ) {
 					const root = this.toDomElement( domDocument );
 
-					// TODO: This event does not work.
-					this.listenTo( root, 'click', () => {
-						editor.editing.view.change( writer => {
-							if ( isPreviewActive ) {
-								writer.removeClass( 'raw-html--active-preview', viewWrapper );
+					writer.setCustomProperty( 'DOMElement', root, toggleButton );
+
+					attachDomListener( root, 'click', evt => {
+						view.change( writer => {
+							const isEditingSourceActive = viewWrapper.getCustomProperty( 'isEditingSourceActive' );
+
+							if ( isEditingSourceActive ) {
+								writer.removeClass( 'raw-html--edit-source', viewWrapper );
 							} else {
-								writer.addClass( 'raw-html--active-preview', viewWrapper );
+								writer.addClass( 'raw-html--edit-source', viewWrapper );
 							}
 
-							isPreviewActive = !isPreviewActive;
+							writer.setCustomProperty( 'isEditingSourceActive', !isEditingSourceActive, viewWrapper );
+							evt.preventDefault();
 						} );
 					} );
 
-					// The icon is used a temporary placeholder. Thanks to https://www.freepik.com/free-icon/eye_775336.htm.
-					// eslint-disable-next-line max-len
-					root.innerHTML = '<?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"> <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>';
+					root.innerHTML = htmlEmbedModeIcon;
 
 					return root;
 				} );
 
 				// The container that renders the HTML.
-				const rawElement = writer.createRawElement( 'div', { class: 'raw-html-embed' }, function( domElement ) {
-					domElement.innerHTML = sanitizeHtml( modelElement.getAttribute( 'value' ) || '', sanitizeHtmlConfig );
+				const previewContainer = writer.createRawElement( 'div', { class: 'raw-html__preview' }, function( domElement ) {
+					writer.setCustomProperty( 'DOMElement', domElement, previewContainer );
+
+					if ( htmlEmbedConfig.previewsInData ) {
+						const sanitizeOutput = htmlEmbedConfig.sanitizeHtml( modelElement.getAttribute( 'value' ) || '' );
+
+						domElement.innerHTML = sanitizeOutput.html;
+					} else {
+						domElement.innerHTML = '<div class="raw-html__preview-placeholder">Raw HTML snippet.</div>';
+					}
 				} );
 
 				writer.insert( writer.createPositionAt( viewWrapper, 0 ), toggleButton );
 				writer.insert( writer.createPositionAt( viewWrapper, 1 ), textarea );
-				writer.insert( writer.createPositionAt( viewWrapper, 2 ), rawElement );
+				writer.insert( writer.createPositionAt( viewWrapper, 2 ), previewContainer );
 
-				return toRawHtmlWidget( viewWrapper, writer, label );
+				return toRawHtmlWidget( viewWrapper, writer, widgetLabel );
 			}
 		} );
 
-		// TODO: How to re-render the `rawElement`?
-		// conversion.for( 'editingDowncast' ).add( dispatcher => {
-		// 	dispatcher.on( 'attribute:value:rawHtml', ( evt, data, conversionApi ) => {
-		// 		const viewWrapper = conversionApi.mapper.toViewElement( data.item );
+		editor.conversion.for( 'editingDowncast' ).add( downcastRawHtmlValueAttribute( htmlEmbedConfig ) );
+
+		// Attaches an event listener to the specified element.
 		//
-		// 		console.log( viewWrapper );
-		// 	} );
-		// } );
+		// @params {HTMLElement} element An element that the event will be attached.
+		// @params {String} event A name of the event.
+		// @params {Function} listener A listener that will be executed.
+		function attachDomListener( element, event, listener ) {
+			element.addEventListener( event, listener );
+			domListeners.add( { element, event, listener } );
+		}
 	}
 }
 
-// Modifies the `defaultConfig` configuration and returns a new object that matches our needs. See #8204.
+// Returns a converter that handles the `value` attribute of the `rawHtml` element.
 //
-// @params {String} defaultConfig The default configuration that will be extended.
-// @returns {Object}
-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' );
-	}
-
-	// Should we allow the `controls` attribute?
-	config.allowedAttributes.video.push( 'width', 'height', 'controls' );
-	config.allowedAttributes.audio.push( 'controls' );
+// It updates the source (`textarea`) value and passes an HTML to the preview element.
+//
+// @params {module:html-embed/htmlembed~MediaEmbedConfig} htmlEmbedConfig
+// @returns {Function}
+function downcastRawHtmlValueAttribute( htmlEmbedConfig ) {
+	return dispatcher => {
+		dispatcher.on( 'attribute:value:rawHtml', ( evt, data, conversionApi ) => {
+			const viewWrapper = conversionApi.mapper.toViewElement( data.item );
+
+			const sourceDOMElement = viewWrapper.getChild( 1 ).getCustomProperty( 'DOMElement' );
+			const previewDOMElement = viewWrapper.getChild( 2 ).getCustomProperty( 'DOMElement' );
+
+			if ( sourceDOMElement ) {
+				sourceDOMElement.value = data.item.getAttribute( 'value' );
+			}
 
-	config.allowedAttributes.iframe.push( 'src' );
-	config.allowedAttributes.img.push( 'srcset', 'sizes', 'src' );
-	config.allowedAttributes.source.push( 'src', 'srcset', 'media', 'sizes', 'type' );
+			if ( htmlEmbedConfig.previewsInData && previewDOMElement ) {
+				const sanitizeOutput = htmlEmbedConfig.sanitizeHtml( data.item.getAttribute( 'value' ) );
 
-	return config;
+				previewDOMElement.innerHTML = sanitizeOutput.html;
+			}
+		} );
+	};
 }

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

@@ -0,0 +1,98 @@
+/**
+ * @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 a page break 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 );
+		} );
+	}
+}
+
+// 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;
+}

+ 11 - 2
packages/ckeditor5-html-embed/src/htmlembedui.js

@@ -10,6 +10,7 @@
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 import htmlEmbedIcon from '../theme/icons/htmlembed.svg';
+import { getSelectedRawHtmlViewWidget } from './utils';
 
 /**
  * The HTML embed UI plugin.
@@ -23,7 +24,7 @@ export default class HTMLEmbedUI extends Plugin {
 
 		// Add the `htmlEmbed` button to feature components.
 		editor.ui.componentFactory.add( 'htmlEmbed', locale => {
-			const command = editor.commands.get( 'htmlEmbed' );
+			const command = editor.commands.get( 'htmlEmbedInsert' );
 			const view = new ButtonView( locale );
 
 			view.set( {
@@ -36,8 +37,16 @@ export default class HTMLEmbedUI extends Plugin {
 
 			// Execute the command.
 			this.listenTo( view, 'execute', () => {
-				editor.execute( 'htmlEmbed' );
+				editor.execute( 'htmlEmbedInsert' );
 				editor.editing.view.focus();
+
+				const rawHtmlWidget = getSelectedRawHtmlViewWidget( editor.editing.view.document.selection );
+
+				// After inserting a new element, switch to "Edit source" mode.
+				rawHtmlWidget.getChild( 0 ).getCustomProperty( 'DOMElement' ).click();
+
+				// And focus the edit source element (`textarea`).
+				rawHtmlWidget.getChild( 1 ).getCustomProperty( 'DOMElement' ).focus();
 			} );
 
 			return view;

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

@@ -0,0 +1,54 @@
+/**
+ * @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 { getSelectedRawHtmlModelWidget } from './utils';
+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 insert a page break at the current selection, execute the command:
+ *
+ *		editor.execute( 'htmlEmbedUpdate', 'HTML.' );
+ *
+ * @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;
+		this.value = rawHtmlElement ? rawHtmlElement.getAttribute( 'value' ) : '';
+	}
+
+	/**
+	 * 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 selectedMedia = getSelectedRawHtmlModelWidget( selection );
+
+		if ( selectedMedia ) {
+			model.change( writer => {
+				writer.setAttribute( 'value', value, selectedMedia );
+			} );
+		}
+	}
+}

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

@@ -1,3 +1,9 @@
+<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">
 <!--    <div class="raw-html-embed">-->
 <!--        <video width="320" height="240" controls>-->

+ 103 - 42
packages/ckeditor5-html-embed/tests/manual/htmlembed.js

@@ -3,55 +3,116 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
-/* globals console, window, document */
+/* globals window, document */
 
 import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
 import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
-import ImageUpload from '@ckeditor/ckeditor5-image/src/imageupload';
-import EasyImage from '@ckeditor/ckeditor5-easy-image/src/easyimage';
-import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config';
+import sanitizeHtml from 'sanitize-html';
+import { clone } from 'lodash-es';
 import HTMLEmbed from '../../src/htmlembed';
 
-ClassicEditor
-	.create( document.querySelector( '#editor' ), {
-		cloudServices: CS_CONFIG,
-		plugins: [ ArticlePluginSet, ImageUpload, EasyImage, 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 ],
 		toolbar: [
-			'heading',
-			'|',
-			'bold', 'italic', 'numberedList', 'bulletedList',
-			'|',
-			'link', 'blockquote', 'imageUpload', 'insertTable', 'mediaEmbed',
-			'|',
-			'undo', 'redo',
-			'|',
-			'htmlEmbed'
+			'heading', '|', 'bold', 'italic', 'link', '|',
+			'bulletedList', 'numberedList', 'blockQuote', 'insertTable', '|',
+			'undo', 'redo', '|', 'htmlEmbed'
 		],
 		image: {
-			styles: [
-				'full',
-				'alignLeft',
-				'alignRight'
-			],
-			toolbar: [
-				'imageStyle:alignLeft',
-				'imageStyle:full',
-				'imageStyle:alignRight',
-				'|',
-				'imageTextAlternative'
-			]
-		},
-		table: {
-			contentToolbar: [
-				'tableColumn',
-				'tableRow',
-				'mergeTableCells'
-			]
+			toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ]
 		}
-	} )
-	.then( editor => {
-		window.editor = editor;
-	} )
-	.catch( err => {
-		console.error( err.stack );
 	} );
+
+	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' );
+	}
+
+	// Should we allow the `controls` attribute?
+	config.allowedAttributes.video.push( 'width', 'height', 'controls' );
+	config.allowedAttributes.audio.push( 'controls' );
+
+	config.allowedAttributes.iframe.push( 'src' );
+	config.allowedAttributes.img.push( 'srcset', 'sizes', 'src' );
+	config.allowedAttributes.source.push( 'src', 'srcset', 'media', 'sizes', 'type' );
+
+	return config;
+}

+ 49 - 3
packages/ckeditor5-html-embed/theme/htmlembed.css

@@ -3,7 +3,53 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
-.raw-html__edit-preview {
-	width: 100px;
-	height: 100px;
+/* Widget container. */
+.ck-editor__editable .raw-html {
+	position: relative;
+	margin-top: 1em;
+	margin-bottom: 1em;
+}
+
+/* Switch mode button. */
+.ck-editor__editable .raw-html .raw-html__switch-mode {
+	position: absolute;
+	top: 0;
+	right: 0;
+	width: 5em;
+	height: 5em;
+	cursor: pointer;
+}
+
+/* Edit source element. */
+.ck-editor__editable .raw-html textarea {
+	width: 30em;
+	height: 5em;
+	resize: none;
+}
+
+/* Edit source mode is enabled. */
+.ck-editor__editable .raw-html.raw-html--edit-source textarea {
+	display: block;
+}
+
+.ck-editor__editable .raw-html .raw-html__preview {
+	min-height: 5em;
+	min-width: 30em;
+}
+
+/* Edit source mode is enabled. */
+.ck-editor__editable .raw-html.raw-html--edit-source .raw-html__preview {
+	display: none;
+}
+
+/* Preview mode is enabled. */
+.ck-editor__editable .raw-html:not(.raw-html--edit-source) textarea {
+	display: none;
+}
+
+.ck-editor__editable .raw-html__preview-placeholder {
+	height: 5em;
+	background-color: hsl(0, 0%, 50%);
+	text-align: center;
+	line-height: 5em;
 }

+ 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>