|
|
@@ -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;
|
|
|
+ }
|
|
|
+ } );
|
|
|
+ };
|
|
|
}
|