UNPKG

@steroidsjs/ckeditor5

Version:

The development environment of CKEditor 5 – the best browser-based rich text editor.

395 lines (329 loc) 12.2 kB
/** * @license Copyright (c) 2003-2021, 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, icons } from 'ckeditor5/src/core'; import { ButtonView } from 'ckeditor5/src/ui'; import { toWidget } from 'ckeditor5/src/widget'; import { logWarning, createElement } from 'ckeditor5/src/utils'; import InsertHtmlEmbedCommand from './inserthtmlembedcommand'; import UpdateHtmlEmbedCommand from './updatehtmlembedcommand'; 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 the `htmlEmbed.showPreviews=true` option, it is strongly recommended to * define a sanitize function that will clean up the 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' ); // Register div.raw-html-embed as a raw content element so all of it's content will be provided // as a view element's custom property while data upcasting. editor.data.registerRawContentMatcher( { name: 'div', classes: 'raw-html-embed' } ); editor.conversion.for( 'upcast' ).elementToElement( { view: { name: 'div', classes: 'raw-html-embed' }, model: ( viewElement, { writer } ) => { // The div.raw-html-embed is registered as a raw content element, // so all it's content is available in a custom property. return writer.createElement( 'rawHtml', { value: viewElement.getCustomProperty( '$rawContent' ) } ); } } ); 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', 'data-html-embed-label': t( 'HTML snippet' ), dir: editor.locale.uiLanguageDirection } ); // 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 } ); // Since there is a `data-cke-ignore-events` attribute set on the wrapper element in the editable mode, // the explicit `mousedown` handler on the `capture` phase is needed to move the selection onto the whole // HTML embed widget. domContentWrapper.addEventListener( 'mousedown', () => { if ( state.isEditable ) { const model = editor.model; const selectedElement = model.document.selection.getSelectedElement(); // Move the selection onto the whole HTML embed widget if it's currently not selected. if ( selectedElement !== modelElement ) { model.change( writer => writer.setSelection( modelElement, 'on' ) ); } } }, true ); } ); // 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 ); editor.editing.view.focus(); } else { this.cancel(); } }, cancel() { state = Object.assign( {}, state, { isEditable: false } ); renderContent( { domElement: domContentWrapper, editor, state, props } ); editor.editing.view.focus(); 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, editor } ) ); } 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, 'edit' ); const domSaveButton = createDomButton( editor, 'save' ); const domCancelButton = createDomButton( editor, '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, editor } ) { const sanitizedOutput = props.sanitizeHtml( state.getRawHtmlValue() ); const placeholderText = state.getRawHtmlValue().length > 0 ? t( 'No preview available' ) : t( 'Empty snippet content' ); const domPreviewPlaceholder = createElement( domDocument, 'div', { class: 'ck ck-reset_all raw-html-embed__preview-placeholder' }, placeholderText ); const domPreviewContent = createElement( domDocument, 'div', { class: 'raw-html-embed__preview-content', dir: editor.locale.contentLanguageDirection } ); domPreviewContent.innerHTML = sanitizedOutput.html; const domPreviewContainer = createElement( domDocument, 'div', { class: 'raw-html-embed__preview' }, [ domPreviewPlaceholder, domPreviewContent ] ); 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( editor, type ) { const t = editor.locale.t; const buttonView = new ButtonView( editor.locale ); const command = editor.commands.get( 'updateHtmlEmbed' ); buttonView.set( { tooltipPosition: editor.locale.uiLanguageDirection === 'rtl' ? 'e' : 'w', icon: icons.pencil, tooltip: true } ); buttonView.render(); if ( type === 'edit' ) { buttonView.set( { icon: icons.pencil, label: t( 'Edit source' ), class: 'raw-html-embed__edit-button' } ); } else if ( type === 'save' ) { buttonView.set( { icon: icons.check, label: t( 'Save changes' ), class: 'raw-html-embed__save-button' } ); buttonView.bind( 'isEnabled' ).to( command, 'isEnabled' ); } else { buttonView.set( { icon: icons.cancel, label: t( 'Cancel' ), class: 'raw-html-embed__cancel-button' } ); } buttonView.destroy(); return buttonView.element.cloneNode( true ); }