| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363 |
- /**
- * @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 InsertHtmlEmbedCommand from './inserthtmlembedcommand';
- import UpdateHtmlEmbedCommand from './updatehtmlembedcommand';
- import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
- import createElement from '@ckeditor/ckeditor5-utils/src/dom/createelement';
- import pencilIcon from '@ckeditor/ckeditor5-core/theme/icons/pencil.svg';
- import checkIcon from '@ckeditor/ckeditor5-core/theme/icons/check.svg';
- import cancelIcon from '@ckeditor/ckeditor5-core/theme/icons/cancel.svg';
- import '../theme/htmlembed.css';
- /**
- * 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', {
- showPreviews: false,
- sanitizeHtml: rawHtml => {
- /**
- * When using the HTML embed feature with `htmlEmbed.showPreviews=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,
- hasChanged: false
- };
- }
- } );
- }
- /**
- * @inheritDoc
- */
- init() {
- const editor = this.editor;
- const schema = editor.model.schema;
- schema.register( 'rawHtml', {
- isObject: true,
- allowWhere: '$block',
- allowAttributes: [ 'value' ]
- } );
- editor.commands.add( 'updateHtmlEmbed', new UpdateHtmlEmbedCommand( editor ) );
- editor.commands.add( 'insertHtmlEmbed', new InsertHtmlEmbedCommand( 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( {
- triggerBy: {
- attributes: [ 'value' ]
- },
- model: 'rawHtml',
- view: ( modelElement, { writer } ) => {
- let domContentWrapper, state, props;
- const viewContainer = writer.createContainerElement( 'div', {
- class: 'raw-html-embed'
- } );
- // Widget cannot be a raw element because the widget system would not be able
- // to add its UI to it. Thus, we need this wrapper.
- const viewContentWrapper = writer.createRawElement( 'div', {
- class: 'raw-html-embed__content-wrapper'
- }, function( domElement ) {
- domContentWrapper = domElement;
- renderContent( { domElement, editor, state, props } );
- } );
- // API exposed on each raw HTML embed widget so other features can control a particular widget.
- const rawHtmlApi = {
- makeEditable() {
- state = Object.assign( {}, state, {
- isEditable: true
- } );
- renderContent( { domElement: domContentWrapper, editor, state, props } );
- view.change( writer => {
- writer.setAttribute( 'data-cke-ignore-events', 'true', viewContentWrapper );
- } );
- // This could be potentially pulled to a separate method called focusTextarea().
- domContentWrapper.querySelector( 'textarea' ).focus();
- },
- save( newValue ) {
- // If the value didn't change, we just cancel. If it changed,
- // it's enough to update the model – the entire widget will be reconverted.
- if ( newValue !== state.getRawHtmlValue() ) {
- editor.execute( 'updateHtmlEmbed', newValue );
- } else {
- this.cancel();
- }
- },
- cancel() {
- state = Object.assign( {}, state, {
- isEditable: false
- } );
- renderContent( { domElement: domContentWrapper, editor, state, props } );
- view.change( writer => {
- writer.removeAttribute( 'data-cke-ignore-events', viewContentWrapper );
- } );
- }
- };
- state = {
- showPreviews: htmlEmbedConfig.showPreviews,
- isEditable: false,
- getRawHtmlValue: () => modelElement.getAttribute( 'value' ) || ''
- };
- props = {
- sanitizeHtml: htmlEmbedConfig.sanitizeHtml,
- textareaPlaceholder: t( 'Paste raw HTML here...' ),
- onEditClick() {
- rawHtmlApi.makeEditable();
- },
- onSaveClick( newValue ) {
- rawHtmlApi.save( newValue );
- },
- onCancelClick() {
- rawHtmlApi.cancel();
- }
- };
- writer.insert( writer.createPositionAt( viewContainer, 0 ), viewContentWrapper );
- writer.setCustomProperty( 'rawHtmlApi', rawHtmlApi, viewContainer );
- writer.setCustomProperty( 'rawHtml', true, viewContainer );
- return toWidget( viewContainer, writer, {
- widgetLabel: t( 'HTML snippet' ),
- hasSelectionHandle: true
- } );
- }
- } );
- function renderContent( { domElement, editor, state, props } ) {
- // Remove all children;
- domElement.textContent = '';
- const domDocument = domElement.ownerDocument;
- let domTextarea;
- if ( state.isEditable ) {
- const textareaProps = {
- isDisabled: false,
- placeholder: props.textareaPlaceholder
- };
- domTextarea = createDomTextarea( { domDocument, state, props: textareaProps } );
- domElement.append( domTextarea );
- } else if ( state.showPreviews ) {
- const previewContainerProps = {
- sanitizeHtml: props.sanitizeHtml
- };
- domElement.append( createPreviewContainer( { domDocument, state, props: previewContainerProps } ) );
- } else {
- const textareaProps = {
- isDisabled: true,
- placeholder: props.textareaPlaceholder
- };
- domElement.append( createDomTextarea( { domDocument, state, props: textareaProps } ) );
- }
- const buttonsWrapperProps = {
- onEditClick: props.onEditClick,
- onSaveClick: () => {
- props.onSaveClick( domTextarea.value );
- },
- onCancelClick: props.onCancelClick
- };
- domElement.prepend( createDomButtonsWrapper( { editor, domDocument, state, props: buttonsWrapperProps } ) );
- }
- function createDomButtonsWrapper( { editor, domDocument, state, props } ) {
- const domButtonsWrapper = createElement( domDocument, 'div', {
- class: 'raw-html-embed__buttons-wrapper'
- } );
- // TODO these should be cached and we should only clone here these cached nodes!
- const domEditButton = createDomButton( editor.locale, 'edit' );
- const domSaveButton = createDomButton( editor.locale, 'save' );
- const domCancelButton = createDomButton( editor.locale, 'cancel' );
- if ( state.isEditable ) {
- const clonedDomSaveButton = domSaveButton.cloneNode( true );
- const clonedDomCancelButton = domCancelButton.cloneNode( true );
- clonedDomSaveButton.addEventListener( 'click', evt => {
- evt.preventDefault();
- props.onSaveClick( );
- } );
- clonedDomCancelButton.addEventListener( 'click', evt => {
- evt.preventDefault();
- props.onCancelClick( );
- } );
- domButtonsWrapper.appendChild( clonedDomSaveButton );
- domButtonsWrapper.appendChild( clonedDomCancelButton );
- } else {
- const clonedDomEditButton = domEditButton.cloneNode( true );
- clonedDomEditButton.addEventListener( 'click', evt => {
- evt.preventDefault();
- props.onEditClick();
- } );
- domButtonsWrapper.appendChild( clonedDomEditButton );
- }
- return domButtonsWrapper;
- }
- function createDomTextarea( { domDocument, state, props } ) {
- const domTextarea = createElement( domDocument, 'textarea', {
- placeholder: props.placeholder,
- class: 'ck ck-reset ck-input ck-input-text raw-html-embed__source'
- } );
- domTextarea.disabled = props.isDisabled;
- domTextarea.value = state.getRawHtmlValue();
- return domTextarea;
- }
- function createPreviewContainer( { domDocument, state, props } ) {
- const domPreviewContainer = createElement( domDocument, 'div', {
- class: 'raw-html-embed__preview'
- } );
- const sanitizeOutput = props.sanitizeHtml( state.getRawHtmlValue() );
- domPreviewContainer.innerHTML = sanitizeOutput.html;
- return domPreviewContainer;
- }
- }
- }
- // Returns a toggle mode button DOM element that can be cloned and used in conversion.
- //
- // @param {module:utils/locale~Locale} locale Editor locale.
- // @param {'edit'|'save'|'cancel'} type Type of button to create.
- // @returns {HTMLElement}
- function createDomButton( locale, type ) {
- const t = locale.t;
- const buttonView = new ButtonView( locale );
- buttonView.set( {
- tooltipPosition: 'sw',
- icon: pencilIcon,
- tooltip: true
- } );
- buttonView.render();
- if ( type === 'edit' ) {
- buttonView.set( {
- icon: pencilIcon,
- label: t( 'Edit source' ),
- class: 'raw-html-embed__edit-button'
- } );
- } else if ( type === 'save' ) {
- buttonView.set( {
- icon: checkIcon,
- label: t( 'Save changes' ),
- class: 'raw-html-embed__save-button'
- } );
- } else {
- buttonView.set( {
- icon: cancelIcon,
- label: t( 'Cancel' ),
- class: 'raw-html-embed__cancel-button'
- } );
- }
- buttonView.destroy();
- return buttonView.element.cloneNode( true );
- }
|