Browse Source

Rewrote the widget internals using a more functional approach and single source of truth.

Piotrek Koszuliński 5 years ago
parent
commit
35c1182334

+ 158 - 143
packages/ckeditor5-html-embed/src/htmlembedediting.js

@@ -16,15 +16,14 @@ 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';
 
-const DISPLAY_PREVIEW_CLASS = 'raw-html-embed_preview_visible';
-const IGNORE_EVENTS_ATTRIBUTE = 'data-cke-ignore-events';
-
 /**
  * The HTML embed editing feature.
  *
@@ -128,186 +127,202 @@ export default class HtmlEmbedEditing extends Plugin {
 			model: 'rawHtml',
 			view: ( modelElement, { writer } ) => {
 				const widgetLabel = t( 'HTML snippet' );
-				const placeholder = t( 'Paste raw HTML here...' );
+
+				let domContentWrapper, state, props;
 
 				const viewContainer = writer.createContainerElement( 'div', {
 					class: 'raw-html-embed'
 				} );
-
-				const viewRawHtmlContainer = writer.createContainerElement( 'div', {
+				// 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 } );
 				} );
 
-				const viewTextarea = writer.createUIElement(
-					'textarea',
-					{
-						placeholder,
-						disabled: true,
-						class: 'ck ck-reset ck-input ck-input-text raw-html-embed__source'
+				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();
 					},
-					domDocument => {
-						return createDomTextarea( { editor, writer, viewTextarea, modelElement, domDocument } );
-					}
-				);
+					save( newValue ) {
+						state = Object.assign( {}, state, {
+							isEditable: false
+						} );
+
+						editor.execute( 'updateHtmlEmbed', newValue );
+
+						renderContent( { domElement: domContentWrapper, editor, state, props } );
 
-				// The switch button between preview and editing HTML.
-				const viewButtonsWrapper = writer.createUIElement(
-					'div',
-					{
-						class: 'raw-html-embed__buttons-wrapper'
+						view.change( writer => {
+							writer.removeAttribute( 'data-cke-ignore-events', viewContentWrapper );
+						} );
 					},
-					function( domDocument ) {
-						return createDomButtonsWrapper( {
-							editor, domDocument, viewButtonsWrapper, htmlEmbedConfig,
-							writer, viewContainer, viewRawHtmlContainer, viewTextarea
+					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,
 
-				writer.insert( writer.createPositionAt( viewContainer, 0 ), viewRawHtmlContainer );
-				writer.insert( writer.createPositionAt( viewRawHtmlContainer, 0 ), viewButtonsWrapper );
-				writer.insert( writer.createPositionAt( viewRawHtmlContainer, 1 ), viewTextarea );
+					isEditable: false,
+					getRawHtmlValue: () => modelElement.getAttribute( 'value' ) || ''
+				};
 
-				// The container that renders the HTML should be created only when `htmlEmbed.showPreviews=true` in the config.
-				if ( htmlEmbedConfig.showPreviews ) {
-					writer.addClass( [ 'raw-html-embed_preview_enabled', DISPLAY_PREVIEW_CLASS ], viewContainer );
+				props = {
+					sanitizeHtml: htmlEmbedConfig.sanitizeHtml,
+					textareaPlaceholder: t( 'Paste raw HTML here...' ),
 
-					const viewPreviewContainer = writer.createRawElement(
-						'div',
-						{ class: 'raw-html-embed__preview' },
-						function( domElement ) {
-							writer.setCustomProperty( 'domElement', domElement, viewPreviewContainer );
+					onEditClick() {
+						rawHtmlApi.makeEditable();
+					},
+					onSaveClick( newValue ) {
+						rawHtmlApi.save( newValue );
+					},
+					onCancelClick() {
+						rawHtmlApi.cancel();
+					}
+				};
 
-							const sanitizedOutput = htmlEmbedConfig.sanitizeHtml( modelElement.getAttribute( 'value' ) || '' );
-							domElement.innerHTML = sanitizedOutput.html;
-						}
-					);
+				writer.insert( writer.createPositionAt( viewContainer, 0 ), viewContentWrapper );
 
-					writer.insert( writer.createPositionAt( viewRawHtmlContainer, 2 ), viewPreviewContainer );
-				}
+				writer.setCustomProperty( 'rawHtmlApi', rawHtmlApi, viewContainer );
+				writer.setCustomProperty( 'rawHtml', true, viewContainer );
 
-				return toRawHtmlWidget( viewContainer, writer, widgetLabel );
+				return toWidget( viewContainer, writer, {
+					widgetLabel,
+					hasSelectionHandle: true
+				} );
 			}
 		} );
 
-		editor.conversion.for( 'editingDowncast' ).add( downcastRawHtmlValueAttribute( htmlEmbedConfig ) );
-	}
-}
+		// editor.conversion.for( 'editingDowncast' ).add( downcastRawHtmlValueAttribute( htmlEmbedConfig ) );
 
-function createDomTextarea( { editor, writer, viewTextarea, modelElement, domDocument } ) {
-	const domTextarea = viewTextarea.toDomElement( domDocument );
+		function renderContent( { domElement, editor, state, props } ) {
+			// Remove all children;
+			domElement.textContent = '';
 
-	writer.setCustomProperty( 'domElement', domTextarea, viewTextarea );
+			const domDocument = domElement.ownerDocument;
+			let domTextarea;
 
-	domTextarea.value = modelElement.getAttribute( 'value' ) || '';
+			if ( state.isEditable ) {
+				const textareaProps = {
+					isDisabled: false,
+					placeholder: props.textareaPlaceholder
+				};
 
-	// When focusing the "edit source" element, the model selection must be updated.
-	// If we do not do it, the `updateHtmlEmbed` command will fail because it
-	// searches the `rawHtml` element based on selection.
-	domTextarea.addEventListener( 'focus', () => {
-		editor.model.change( writer => {
-			writer.setSelection( modelElement, 'on' );
-		} );
-	} );
+				domTextarea = createDomTextarea( { domDocument, state, props: textareaProps } );
 
-	domTextarea.addEventListener( 'input', () => {
-		editor.execute( 'updateHtmlEmbed', domTextarea.value );
-	} );
+				domElement.append( domTextarea );
+			} else if ( state.showPreviews ) {
+				const previewContainerProps = {
+					sanitizeHtml: props.sanitizeHtml
+				};
 
-	return domTextarea;
-}
+				domElement.append( createPreviewContainer( { domDocument, state, props: previewContainerProps } ) );
+			} else {
+				const textareaProps = {
+					isDisabled: true,
+					placeholder: props.textareaPlaceholder
+				};
 
-function createDomButtonsWrapper( {
-	editor, domDocument, viewButtonsWrapper, htmlEmbedConfig,
-	writer, viewContainer, viewRawHtmlContainer, viewTextarea
-} ) {
-	const domButtonsWrapper = viewButtonsWrapper.toDomElement( domDocument );
-
-	const domEditButton = createDomButton( editor.locale, 'edit' );
-	const domSaveButton = createDomButton( editor.locale, 'save' );
-	const domCancelButton = createDomButton( editor.locale, 'cancel' );
-
-	domButtonsWrapper.appendChild( domEditButton.cloneNode( true ) );
-
-	writer.setCustomProperty( 'domElement', domButtonsWrapper, viewButtonsWrapper );
-
-	domButtonsWrapper.addEventListener( 'click', evt => {
-		editor.editing.view.change( writer => {
-			if ( htmlEmbedConfig.showPreviews ) {
-				if ( viewContainer.hasClass( DISPLAY_PREVIEW_CLASS ) ) {
-					writer.setAttribute( IGNORE_EVENTS_ATTRIBUTE, 'true', viewRawHtmlContainer );
-					writer.removeClass( DISPLAY_PREVIEW_CLASS, viewContainer );
-				} else {
-					writer.addClass( DISPLAY_PREVIEW_CLASS, viewContainer );
-					writer.removeAttribute( IGNORE_EVENTS_ATTRIBUTE, viewRawHtmlContainer );
-				}
+				domElement.append( createDomTextarea( { domDocument, state, props: textareaProps } ) );
 			}
 
-			const textarea = viewTextarea.getCustomProperty( 'domElement' );
-			textarea.disabled = !textarea.disabled;
+			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( );
+				} );
 
-			while ( domButtonsWrapper.firstChild ) {
-				domButtonsWrapper.firstChild.remove();
-			}
+				clonedDomCancelButton.addEventListener( 'click', evt => {
+					evt.preventDefault();
+					props.onCancelClick( );
+				} );
 
-			if ( textarea.disabled ) {
-				domButtonsWrapper.appendChild( domEditButton.cloneNode( true ) );
+				domButtonsWrapper.appendChild( clonedDomSaveButton );
+				domButtonsWrapper.appendChild( clonedDomCancelButton );
 			} else {
-				domButtonsWrapper.appendChild( domSaveButton.cloneNode( true ) );
-				domButtonsWrapper.appendChild( domCancelButton.cloneNode( true ) );
+				const clonedDomEditButton = domEditButton.cloneNode( true );
+
+				clonedDomEditButton.addEventListener( 'click', evt => {
+					evt.preventDefault();
+					props.onEditClick();
+				} );
+
+				domButtonsWrapper.appendChild( clonedDomEditButton );
 			}
-		} );
 
-		evt.preventDefault();
-	} );
+			return domButtonsWrapper;
+		}
 
-	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'
+			} );
 
-// 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.showPreviews=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 viewRawHtmlContainer = widgetView.getChild( 1 );
-			const textareaDomElement = viewRawHtmlContainer.getChild( 1 ).getCustomProperty( 'domElement' );
-
-			if ( textareaDomElement ) {
-				textareaDomElement.value = data.item.getAttribute( 'value' );
-			}
+			domTextarea.disabled = props.isDisabled;
+			domTextarea.value = state.getRawHtmlValue();
 
-			if ( htmlEmbedConfig.showPreviews ) {
-				const previewDomElement = viewRawHtmlContainer.getChild( 2 ).getCustomProperty( 'domElement' );
+			return domTextarea;
+		}
 
-				if ( previewDomElement ) {
-					const sanitizeOutput = htmlEmbedConfig.sanitizeHtml( data.item.getAttribute( 'value' ) || '' );
-					previewDomElement.innerHTML = sanitizeOutput.html;
-				}
-			}
-		} );
-	};
-}
+		function createPreviewContainer( { domDocument, state, props } ) {
+			const domPreviewContainer = createElement( domDocument, 'div', {
+				class: 'raw-html-embed__preview'
+			} );
 
-// 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,
-		hasSelectionHandle: true
-	} );
+			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.

+ 1 - 6
packages/ckeditor5-html-embed/src/htmlembedui.js

@@ -41,13 +41,8 @@ export default class HtmlEmbedUI extends Plugin {
 				editor.editing.view.focus();
 
 				const widgetWrapper = editor.editing.view.document.selection.getSelectedElement();
-				const rawHtmlContainer = widgetWrapper.getChild( 1 );
 
-				// 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();
+				widgetWrapper.getCustomProperty( 'rawHtmlApi' ).makeEditable();
 			} );
 
 			return view;

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

@@ -15,24 +15,6 @@
 		display: flex;
 		flex-direction: column;
 	}
-
-	/* Using the feature with enabled previews in data. */
-	&.raw-html-embed_preview_enabled {
-		&.raw-html-embed_preview_visible {
-			& .raw-html-embed__source {
-				display: none;
-			}
-
-			& .raw-html-embed__preview {
-				display: block;
-			}
-		}
-
-		/* Mode: edit the HTML source. */
-		&:not(.raw-html-embed_preview_visible) .raw-html-embed__preview {
-			display: none;
-		}
-	}
 }
 
 .ck-content .raw-html-embed {

+ 14 - 21
packages/ckeditor5-theme-lark/theme/ckeditor5-html-embed/htmlembed.css

@@ -12,6 +12,7 @@
 /* The feature container. */
 .ck-widget.raw-html-embed {
 	font-size: var(--ck-font-size-base);
+	background-color: var(--ck-color-base-foreground);
 
 	& .raw-html-embed__content-wrapper {
 		padding: var(--ck-spacing-standard);
@@ -55,30 +56,22 @@
 		}
 	}
 
-	/* Using the feature with previews enabled. */
-	&.raw-html-embed_preview_enabled {
-		/* The preview data container. */
-		& .raw-html-embed__preview {
-			overflow: hidden;
-			box-sizing: border-box;
-			width: var(--ck-html-embed-content-width);
-			min-height: var(--ck-html-embed-content-min-height);
-			text-align: center;
-
-			& > table {
-				margin-left: auto;
-				margin-right: auto;
-			}
+	/* The preview data container. */
+	& .raw-html-embed__preview {
+		overflow: hidden;
+		box-sizing: border-box;
+		width: var(--ck-html-embed-content-width);
+		min-height: var(--ck-html-embed-content-min-height);
+		text-align: center;
 
-			/* Disable all mouse interaction as long as the editor is not read–only. */
-			@nest .ck-editor__editable:not(.ck-read-only) & {
-				pointer-events: none;
-			}
+		& > table {
+			margin-left: auto;
+			margin-right: auto;
 		}
 
-		/* Mode: preview the HTML. */
-		&.raw-html-embed_preview_visible {
-			background-color: var(--ck-color-base-foreground);
+		/* Disable all mouse interaction as long as the editor is not read–only. */
+		@nest .ck-editor__editable:not(.ck-read-only) & {
+			pointer-events: none;
 		}
 	}
 }