8
0
Просмотр исходного кода

Merge branch 'master' into i/1098-accessible-input-labels

Aleksander Nowodzinski 5 лет назад
Родитель
Сommit
eed6700cd3
36 измененных файлов с 1027 добавлено и 263 удалено
  1. 9 0
      docs/builds/guides/frameworks/angular.md
  2. 4 0
      docs/framework/guides/tutorials/implementing-a-block-widget.md
  3. 4 0
      docs/framework/guides/tutorials/using-react-in-a-widget.md
  4. 1 1
      packages/ckeditor5-engine/src/view/observer/domeventobserver.js
  5. 24 0
      packages/ckeditor5-engine/src/view/observer/observer.js
  6. 10 4
      packages/ckeditor5-engine/src/view/observer/selectionobserver.js
  7. 41 0
      packages/ckeditor5-engine/tests/manual/tickets/4600/1.html
  8. 155 0
      packages/ckeditor5-engine/tests/manual/tickets/4600/1.js
  9. 19 0
      packages/ckeditor5-engine/tests/manual/tickets/4600/1.md
  10. 22 0
      packages/ckeditor5-engine/tests/view/observer/domeventobserver.js
  11. 44 0
      packages/ckeditor5-engine/tests/view/observer/observer.js
  12. 12 0
      packages/ckeditor5-engine/tests/view/observer/selectionobserver.js
  13. 2 2
      packages/ckeditor5-image/src/imageinsert/ui/imageinsertpanelview.js
  14. 3 1
      packages/ckeditor5-image/src/imagetextalternative/ui/textalternativeformview.js
  15. 27 0
      packages/ckeditor5-image/tests/imageinsert/ui/imageinsertpanelview.js
  16. 1 0
      packages/ckeditor5-image/tests/imagetextalternative/ui/textalternativeformview.js
  17. 3 1
      packages/ckeditor5-link/src/ui/linkactionsview.js
  18. 3 2
      packages/ckeditor5-link/src/ui/linkformview.js
  19. 1 0
      packages/ckeditor5-link/tests/ui/linkactionsview.js
  20. 1 0
      packages/ckeditor5-link/tests/ui/linkformview.js
  21. 1 1
      packages/ckeditor5-list/src/liststyleediting.js
  22. 37 0
      packages/ckeditor5-list/tests/liststyleediting.js
  23. 0 1
      packages/ckeditor5-media-embed/src/mediaembedui.js
  24. 13 1
      packages/ckeditor5-media-embed/src/ui/mediaformview.js
  25. 18 4
      packages/ckeditor5-media-embed/tests/mediaembedui.js
  26. 1 0
      packages/ckeditor5-media-embed/tests/ui/mediaformview.js
  27. 186 148
      packages/ckeditor5-table/src/tableclipboard.js
  28. 8 0
      packages/ckeditor5-table/src/tableutils.js
  29. 146 1
      packages/ckeditor5-table/tests/tableclipboard-paste.js
  30. 44 0
      packages/ckeditor5-table/tests/tableutils.js
  31. 0 25
      packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkactions.css
  32. 0 65
      packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkform.css
  33. 12 6
      packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/responsive-form/responsiveform.css
  34. 30 0
      packages/ckeditor5-ui/theme/components/responsive-form/responsiveform.css
  35. 13 0
      packages/ckeditor5-widget/docs/framework/guides/deep-dive/widget-internals.md
  36. 132 0
      packages/ckeditor5-widget/tests/widget-events.js

+ 9 - 0
docs/builds/guides/frameworks/angular.md

@@ -16,6 +16,15 @@ Currently, the CKEditor 5 component for Angular supports integrating CKEditor 5
 	While there is no support to integrate CKEditor 5 from source yet, you can still {@link builds/guides/development/custom-builds create a custom build of CKEditor 5} and include it in your Angular application.
 </info-box>
 
+## Supported Angular versions
+
+Because of the breaking changes in the Angular library output format, the `ckeditor5-angular` package is released in the following versions to support various Angular ecosystems:
+
+* Versions `1.x.x` &ndash; For **Angular 5-8** applications. Support for this version will end when the official support for Angular 8 is dropped (planned date: November 2020),
+* Versions `2.x.x` &ndash; For **Angular 9.1+** applications. This version is currently actively supported.
+
+All available versions are [listed on npm](https://www.npmjs.com/package/@ckeditor/ckeditor5-angular), where they can be pulled from.
+
 ## Quick start
 
 In your existing Angular project, install the [CKEditor 5 WYSIWYG editor component for Angular](https://www.npmjs.com/package/@ckeditor/ckeditor5-angular):

+ 4 - 0
docs/framework/guides/tutorials/implementing-a-block-widget.md

@@ -21,6 +21,10 @@ While it is not strictly necessary to read the {@link framework/guides/quick-sta
 
 The tutorial will also reference various parts of the {@link framework/guides/architecture/intro CKEditor 5 architecture} section as you go. While reading them is not necessary to finish this tutorial, it is recommended to read these guides at some point to get a better understanding of the mechanisms used in this tutorial.
 
+<info-box>
+	If you want to use own event handler for events triggered by your widget then you must wrap it by a container that has a `data-cke-ignore-events` attribute to exclude it from editor's default handlers. Refer to {@link framework/guides/deep-dive/widget-internals#exclude-dom-events-from-default-handlers Exclude DOM events from default handlers} for more details.
+</info-box>
+
 ## Let's start
 
 This guide assumes that you are familiar with npm and your project uses npm already. If not, see the [npm documentation](https://docs.npmjs.com/getting-started/what-is-npm) or call `npm init` in an empty directory and keep your fingers crossed.

+ 4 - 0
docs/framework/guides/tutorials/using-react-in-a-widget.md

@@ -25,6 +25,10 @@ There are a couple of things you should know before you start:
 * Also, while it is not strictly necessary to read the {@link framework/guides/quick-start Quick start} guide before going through this tutorial, it may help you to get more comfortable with CKEditor 5 Framework before you dive into this tutorial.
 * Various parts of the {@link framework/guides/architecture/intro CKEditor 5 architecture} section will be referenced as you go. While reading them is not necessary to finish this tutorial, it is recommended to read those guides at some point to get a better understanding of the mechanisms used in this tutorial.
 
+<info-box>
+	If you want to use own event handler for events triggered by your React component then you must wrap it by a container that has a `data-cke-ignore-events` attribute to exclude it from editor's default handlers. Refer to {@link framework/guides/deep-dive/widget-internals#exclude-dom-events-from-default-handlers Exclude DOM events from default handlers} for more details.
+</info-box>
+
 ## Let's start
 
 This guide assumes that you are familiar with [yarn](https://yarnpkg.com) and your project uses yarn already. If not, see the [yarn documentation](https://yarnpkg.com/en/docs/getting-started). If you are using [npm](https://www.npmjs.com/get-npm) you do not have to worry — you can perform the same installation tasks just as easily using [corresponding npm commands](https://docs.npmjs.com/getting-packages-from-the-registry).

+ 1 - 1
packages/ckeditor5-engine/src/view/observer/domeventobserver.js

@@ -75,7 +75,7 @@ export default class DomEventObserver extends Observer {
 
 		types.forEach( type => {
 			this.listenTo( domElement, type, ( eventInfo, domEvent ) => {
-				if ( this.isEnabled ) {
+				if ( this.isEnabled && !this.checkShouldIgnoreEventFromTarget( domEvent.target ) ) {
 					this.onDomEvent( domEvent );
 				}
 			}, { useCapture: this.useCapture } );

+ 24 - 0
packages/ckeditor5-engine/src/view/observer/observer.js

@@ -82,6 +82,30 @@ export default class Observer {
 		this.stopListening();
 	}
 
+	/**
+	 * Checks whether the given DOM event should be ignored (should not be turned into a synthetic view document event).
+	 *
+	 * Currently, an event will be ignored only if its target or any of its ancestors has the `data-cke-ignore-events` attribute.
+	 * This attribute can be used inside structures generated by
+	 * {@link module:engine/view/downcastwriter~DowncastWriter#createUIElement `DowncastWriter#createUIElement()`} to ignore events
+	 * fired within a UI that should be excluded from CKEditor 5's realms.
+	 *
+	 * @param {Node} domTarget The DOM event target to check (usually an element, sometimes a text node and
+	 * potentially sometimes a document too).
+	 * @returns {Boolean} Whether this event should be ignored by the observer.
+	 */
+	checkShouldIgnoreEventFromTarget( domTarget ) {
+		if ( domTarget && domTarget.nodeType === 3 ) {
+			domTarget = domTarget.parentNode;
+		}
+
+		if ( !domTarget || domTarget.nodeType !== 1 ) {
+			return false;
+		}
+
+		return domTarget.matches( '[data-cke-ignore-events], [data-cke-ignore-events] *' );
+	}
+
 	/**
 	 * Starts observing the given root element.
 	 *

+ 10 - 4
packages/ckeditor5-engine/src/view/observer/selectionobserver.js

@@ -100,8 +100,8 @@ export default class SelectionObserver extends Observer {
 			return;
 		}
 
-		this.listenTo( domDocument, 'selectionchange', () => {
-			this._handleSelectionChange( domDocument );
+		this.listenTo( domDocument, 'selectionchange', ( evt, domEvent ) => {
+			this._handleSelectionChange( domEvent, domDocument );
 		} );
 
 		this._documents.add( domDocument );
@@ -123,19 +123,25 @@ export default class SelectionObserver extends Observer {
 	 * and {@link module:engine/view/document~Document#event:selectionChangeDone} when selection stop changing.
 	 *
 	 * @private
+	 * @param {Event} domEvent DOM event.
 	 * @param {Document} domDocument DOM document.
 	 */
-	_handleSelectionChange( domDocument ) {
+	_handleSelectionChange( domEvent, domDocument ) {
 		if ( !this.isEnabled ) {
 			return;
 		}
 
+		const domSelection = domDocument.defaultView.getSelection();
+
+		if ( this.checkShouldIgnoreEventFromTarget( domSelection.anchorNode ) ) {
+			return;
+		}
+
 		// Ensure the mutation event will be before selection event on all browsers.
 		this.mutationObserver.flush();
 
 		// If there were mutations then the view will be re-rendered by the mutation observer and selection
 		// will be updated, so selections will equal and event will not be fired, as expected.
-		const domSelection = domDocument.defaultView.getSelection();
 		const newViewSelection = this.domConverter.domSelectionToView( domSelection );
 
 		// Do not convert selection change if the new view selection has no ranges in it.

+ 41 - 0
packages/ckeditor5-engine/tests/manual/tickets/4600/1.html

@@ -0,0 +1,41 @@
+<style>
+	.simple-widget-container {
+		margin: 1em 0;
+		font-family: sans-serif;
+	}
+
+	.simple-widget-element {
+		display: flex;
+		height: 14em;
+	}
+
+	.simple-widget-element>fieldset {
+		display: flex;
+		align-items: center;
+		margin: 3em;
+		width: 50%;
+		border: 1px solid #ddd;
+		border-radius: 4px;
+	}
+
+	.simple-widget-element>fieldset>legend {
+		font-size: 0.85em;
+		padding: .2em 2em;
+		border: 1px solid #ddd;
+		border-radius: 4px;
+		background: #fff;
+	}
+
+	.simple-widget-element>fieldset>input {
+		flex: 1;
+		margin: 1em 1em 1em 0;
+	}
+
+	.simple-widget-element>fieldset>button {
+		padding: .2em 3em;
+	}
+</style>
+
+<div id="editor">
+	<section class="simple-widget-container" />
+</div>

+ 155 - 0
packages/ckeditor5-engine/tests/manual/tickets/4600/1.js

@@ -0,0 +1,155 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals console, document, window, Event */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import Widget from '@ckeditor/ckeditor5-widget/src/widget';
+import { toWidget } from '@ckeditor/ckeditor5-widget/src/utils';
+
+import ClickObserver from '../../../../src/view/observer/clickobserver';
+import CompositionObserver from '../../../../src/view/observer/compositionobserver';
+import FocusObserver from '../../../../src/view/observer/focusobserver';
+import InputObserver from '../../../../src/view/observer/inputobserver';
+import KeyObserver from '../../../../src/view/observer/keyobserver';
+import MouseObserver from '../../../../src/view/observer/mouseobserver';
+import MouseEventsObserver from '@ckeditor/ckeditor5-table/src/tablemouse/mouseeventsobserver';
+
+class SimpleWidgetEditing extends Plugin {
+	static get requires() {
+		return [ Widget ];
+	}
+
+	init() {
+		this._defineSchema();
+		this._defineConverters();
+		this._addObservers();
+	}
+
+	_defineSchema() {
+		const schema = this.editor.model.schema;
+
+		schema.register( 'simpleWidgetElement', {
+			inheritAllFrom: '$block',
+			isObject: true
+		} );
+	}
+
+	_defineConverters() {
+		const conversion = this.editor.conversion;
+
+		conversion.for( 'editingDowncast' ).elementToElement( {
+			model: 'simpleWidgetElement',
+			view: ( modelElement, { writer } ) => {
+				const widgetElement = createWidgetView( modelElement, { writer } );
+
+				return toWidget( widgetElement, writer );
+			}
+		} );
+
+		conversion.for( 'dataDowncast' ).elementToElement( {
+			model: 'simpleWidgetElement',
+			view: createWidgetView
+		} );
+
+		conversion.for( 'upcast' ).elementToElement( {
+			model: 'simpleWidgetElement',
+			view: {
+				name: 'section',
+				classes: 'simple-widget-container'
+			}
+		} );
+
+		function createWidgetView( modelElement, { writer } ) {
+			const simpleWidgetContainer = writer.createContainerElement( 'section', { class: 'simple-widget-container' } );
+			const simpleWidgetElement = writer.createRawElement( 'div', { class: 'simple-widget-element' }, domElement => {
+				domElement.innerHTML = `
+					<fieldset data-cke-ignore-events="true">
+						<legend>Ignored container with <strong>data-cke-ignore-events="true"</strong></legend>
+						<input>
+						<button>Click!</button>
+					</fieldset>
+					<fieldset>
+						<legend>Regular container</legend>
+						<input>
+						<button>Click!</button>
+					</fieldset>
+				`;
+			} );
+
+			writer.insert( writer.createPositionAt( simpleWidgetContainer, 0 ), simpleWidgetElement );
+
+			return simpleWidgetContainer;
+		}
+	}
+
+	_addObservers() {
+		const view = this.editor.editing.view;
+
+		const observers = new Map( [
+			[ ClickObserver, [ 'click' ] ],
+			[ CompositionObserver, [ 'compositionstart', 'compositionupdate', 'compositionend' ] ],
+			[ FocusObserver, [ 'focus', 'blur' ] ],
+			[ InputObserver, [ 'beforeinput' ] ],
+			[ KeyObserver, [ 'keydown', 'keyup' ] ],
+			[ MouseEventsObserver, [ 'mousemove', 'mouseup', 'mouseleave' ] ],
+			[ MouseObserver, [ 'mousedown' ] ]
+		] );
+
+		observers.forEach( ( events, observer ) => {
+			view.addObserver( observer );
+
+			events.forEach( eventName => {
+				this.listenTo( view.document, eventName, () => {
+					console.log( `Received ${ eventName } event.` );
+				} );
+			} );
+		} );
+	}
+}
+
+class SimpleWidgetUI extends Plugin {}
+
+class SimpleWidget extends Plugin {
+	static get requires() {
+		return [ SimpleWidgetEditing, SimpleWidgetUI ];
+	}
+}
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ Essentials, SimpleWidget ]
+	} )
+	.then( editor => {
+		window.editor = editor;
+		addEventDispatcherForButtons( editor, 'click' );
+	} )
+	.catch( error => {
+		console.error( error.stack );
+	} );
+
+function addEventDispatcherForButtons( editor, eventName ) {
+	const view = editor.editing.view;
+	const container = Array
+		.from( view.document.getRoot().getChildren() )
+		.find( element => element.hasClass( 'simple-widget-container' ) );
+
+	view.domConverter
+		.viewToDom( container )
+		.querySelectorAll( 'button' )
+		.forEach( button => {
+			button.addEventListener( 'click', event => {
+				if ( !event.isTrusted ) {
+					return;
+				}
+
+				console.log( `Dispatched ${ eventName } event.` );
+				button.dispatchEvent( new Event( eventName, { bubbles: true } ) );
+			} );
+		} );
+}

+ 19 - 0
packages/ckeditor5-engine/tests/manual/tickets/4600/1.md

@@ -0,0 +1,19 @@
+### Ignoring events fired by certain elements [#4600](https://github.com/ckeditor/ckeditor5/issues/4600)
+
+Events are logged in console.
+
+### Case 1: Events are ignored and they are not handled by default listeners.
+1. Move mouse cursor over a left container named `Ignored container with data-cke-ignore-events="true"`.
+2. When it is already there, start moving it around within container boundaries, start typing in text field and start clicking on text field and button.
+3. Click on the button.
+
+**Expected results**: Only then, when the mouse cursor is over the left container, new logs will stop appearing in the console. Clicking inside it, typing in text field and moving mouse cursor inside the container boundaries should not be logged in console. Clicking on a button dispatches the `click` event (the `Dispatched click event` message should be logged), but `Received click event` shouldn't be present in console.
+
+One note for `focus` nad `blur` events: they will be logged in console, but only when container lost focus or gets it back, respectively.
+
+### Case 2: Events are not ignored and they are handled by default listeners.
+1. Move mouse cursor over a right container named `Regular container`.
+2. When it is already there, start moving it around within container boundaries, start typing in text field and start clicking on text field and button.
+3. Click on the button.
+
+**Expected results**: Events should be logged in console. It shouldn't be possible to focus the text field and type anything there. Clicking on a button dispatches the `click` event (the `Dispatched click event` message should be logged) and doubled `Received click event` should be present in console: one from "real" user mouse click and second one from script-generated `click` event.

+ 22 - 0
packages/ckeditor5-engine/tests/view/observer/domeventobserver.js

@@ -122,6 +122,28 @@ describe( 'DomEventObserver', () => {
 		expect( evtSpy.called ).to.be.false;
 	} );
 
+	it( 'should not fire event if target is ignored', () => {
+		const domElement = document.createElement( 'p' );
+		const domEvent = new MouseEvent( 'click' );
+		const evtSpy = sinon.spy();
+
+		const ignoreEventFromTargetStub = sinon
+			.stub( Observer.prototype, 'checkShouldIgnoreEventFromTarget' )
+			.returns( true );
+
+		createViewRoot( viewDocument );
+		view.attachDomRoot( domElement );
+		view.addObserver( ClickObserver );
+		viewDocument.on( 'click', evtSpy );
+
+		domElement.dispatchEvent( domEvent );
+
+		expect( ignoreEventFromTargetStub.called ).to.be.true;
+		expect( evtSpy.called ).to.be.false;
+
+		ignoreEventFromTargetStub.restore();
+	} );
+
 	it( 'should fire event if observer is disabled and re-enabled', () => {
 		const domElement = document.createElement( 'p' );
 		const domEvent = new MouseEvent( 'click' );

+ 44 - 0
packages/ckeditor5-engine/tests/view/observer/observer.js

@@ -3,6 +3,8 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
+/* globals document */
+
 import Observer from '../../../src/view/observer/observer';
 import View from '../../../src/view/view';
 import { StylesProcessor } from '../../../src/view/stylesmap';
@@ -44,4 +46,46 @@ describe( 'Observer', () => {
 			expect( observer.isEnabled ).to.be.false;
 		} );
 	} );
+
+	describe( 'checkShouldIgnoreEventFromTarget()', () => {
+		it( 'should not ignore on targets which are non-element node types', () => {
+			const observer = new Observer( {} );
+
+			expect( observer.checkShouldIgnoreEventFromTarget( {} ) ).to.be.false;
+			expect( observer.checkShouldIgnoreEventFromTarget( { nodeType: 2 } ) ).to.be.false;
+			expect( observer.checkShouldIgnoreEventFromTarget( { nodeType: 3 } ) ).to.be.false;
+			expect( observer.checkShouldIgnoreEventFromTarget( { nodeType: 3, parentNode: null } ) ).to.be.false;
+		} );
+
+		it( 'should not ignore on targets without the `data-cke-ignore-events` attribute neither on itself nor in any ancestor', () => {
+			const documentFragment = document.createDocumentFragment();
+			const section = document.createElement( 'section' );
+			const div = document.createElement( 'div' );
+			const button = document.createElement( 'button' );
+
+			documentFragment.appendChild( section ).appendChild( div ).appendChild( button );
+
+			const observer = new Observer( {} );
+
+			expect( observer.checkShouldIgnoreEventFromTarget( section ) ).to.be.false;
+			expect( observer.checkShouldIgnoreEventFromTarget( div ) ).to.be.false;
+			expect( observer.checkShouldIgnoreEventFromTarget( button ) ).to.be.false;
+		} );
+
+		it( 'should ignore on targets with the `data-cke-ignore-events` attribute set on itself or on any ancestor', () => {
+			const documentFragment = document.createDocumentFragment();
+			const section = document.createElement( 'section' );
+			const div = document.createElement( 'div' );
+			const button = document.createElement( 'button' );
+
+			section.setAttribute( 'data-cke-ignore-events', 'true' );
+			documentFragment.appendChild( section ).appendChild( div ).appendChild( button );
+
+			const observer = new Observer( {} );
+
+			expect( observer.checkShouldIgnoreEventFromTarget( section ) ).to.be.true;
+			expect( observer.checkShouldIgnoreEventFromTarget( div ) ).to.be.true;
+			expect( observer.checkShouldIgnoreEventFromTarget( button ) ).to.be.true;
+		} );
+	} );
 } );

+ 12 - 0
packages/ckeditor5-engine/tests/view/observer/selectionobserver.js

@@ -95,6 +95,18 @@ describe( 'SelectionObserver', () => {
 		changeDomSelection();
 	} );
 
+	it( 'should not fire selectionChange for ignored target', done => {
+		viewDocument.on( 'selectionChange', () => {
+			throw 'selectionChange fired in ignored elements';
+		} );
+
+		domMain.childNodes[ 1 ].setAttribute( 'data-cke-ignore-events', 'true' );
+
+		changeDomSelection();
+
+		setTimeout( done, 100 );
+	} );
+
 	it( 'should not fire selectionChange on render', done => {
 		viewDocument.on( 'selectionChange', () => {
 			throw 'selectionChange on render';

+ 2 - 2
packages/ckeditor5-image/src/imageinsert/ui/imageinsertpanelview.js

@@ -135,7 +135,7 @@ export default class ImageInsertPanelView extends View {
 					integrationView.fieldView.bind( 'value' ).to( this, 'imageURLInputValue', value => value || '' );
 
 					integrationView.fieldView.on( 'input', () => {
-						this.imageURLInputValue = integrationView.fieldView.element.value;
+						this.imageURLInputValue = integrationView.fieldView.element.value.trim();
 					} );
 				}
 
@@ -286,7 +286,7 @@ export default class ImageInsertPanelView extends View {
 			withText: true
 		} );
 
-		insertButtonView.bind( 'isEnabled' ).to( this, 'imageURLInputValue' );
+		insertButtonView.bind( 'isEnabled' ).to( this, 'imageURLInputValue', value => !!value );
 		insertButtonView.delegate( 'execute' ).to( this, 'submit' );
 		cancelButtonView.delegate( 'execute' ).to( this, 'cancel' );
 

+ 3 - 1
packages/ckeditor5-image/src/imagetextalternative/ui/textalternativeformview.js

@@ -24,6 +24,7 @@ import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler';
 import checkIcon from '@ckeditor/ckeditor5-core/theme/icons/check.svg';
 import cancelIcon from '@ckeditor/ckeditor5-core/theme/icons/cancel.svg';
 import '../../../theme/textalternativeform.css';
+import '@ckeditor/ckeditor5-ui/theme/components/responsive-form/responsiveform.css';
 
 /**
  * The TextAlternativeFormView class.
@@ -112,7 +113,8 @@ export default class TextAlternativeFormView extends View {
 			attributes: {
 				class: [
 					'ck',
-					'ck-text-alternative-form'
+					'ck-text-alternative-form',
+					'ck-responsive-form'
 				],
 
 				// https://github.com/ckeditor/ckeditor5-image/issues/40

+ 27 - 0
packages/ckeditor5-image/tests/imageinsert/ui/imageinsertpanelview.js

@@ -295,5 +295,32 @@ describe( 'ImageUploadPanelView', () => {
 
 			expect( view.imageURLInputValue ).to.equal( 'xyz' );
 		} );
+
+		it( 'should trim input value', () => {
+			const form = view.getIntegration( 'insertImageViaUrl' );
+
+			form.fieldView.element.value = '   ';
+			form.fieldView.fire( 'input' );
+
+			expect( view.imageURLInputValue ).to.equal( '' );
+
+			form.fieldView.element.value = '   test   ';
+			form.fieldView.fire( 'input' );
+
+			expect( view.imageURLInputValue ).to.equal( 'test' );
+		} );
+
+		it( 'binds saveButtonView#isEnabled to URL input value', () => {
+			const form = view.getIntegration( 'insertImageViaUrl' );
+			const saveButtonView = view.template.children[ 1 ].children.first;
+
+			expect( saveButtonView.isEnabled ).to.be.false;
+
+			form.fieldView.element.value = 'test';
+			form.fieldView.fire( 'input' );
+
+			expect( view.imageURLInputValue ).to.equal( 'test' );
+			expect( !!saveButtonView.isEnabled ).to.be.true;
+		} );
 	} );
 } );

+ 1 - 0
packages/ckeditor5-image/tests/imagetextalternative/ui/textalternativeformview.js

@@ -29,6 +29,7 @@ describe( 'TextAlternativeFormView', () => {
 
 			expect( view.element.classList.contains( 'ck' ) ).to.be.true;
 			expect( view.element.classList.contains( 'ck-text-alternative-form' ) ).to.be.true;
+			expect( view.element.classList.contains( 'ck-responsive-form' ) ).to.be.true;
 			expect( view.element.getAttribute( 'tabindex' ) ).to.equal( '-1' );
 		} );
 

+ 3 - 1
packages/ckeditor5-link/src/ui/linkactionsview.js

@@ -21,6 +21,7 @@ import { ensureSafeUrl } from '../utils';
 import unlinkIcon from '../../theme/icons/unlink.svg';
 import pencilIcon from '@ckeditor/ckeditor5-core/theme/icons/pencil.svg';
 import '../../theme/linkactions.css';
+import '@ckeditor/ckeditor5-ui/theme/components/responsive-form/responsiveform.css';
 
 /**
  * The link actions view class. This view displays the link preview, allows
@@ -117,7 +118,8 @@ export default class LinkActionsView extends View {
 			attributes: {
 				class: [
 					'ck',
-					'ck-link-actions'
+					'ck-link-actions',
+					'ck-responsive-form'
 				],
 
 				// https://github.com/ckeditor/ckeditor5-link/issues/90

+ 3 - 2
packages/ckeditor5-link/src/ui/linkformview.js

@@ -25,6 +25,7 @@ import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
 import checkIcon from '@ckeditor/ckeditor5-core/theme/icons/check.svg';
 import cancelIcon from '@ckeditor/ckeditor5-core/theme/icons/cancel.svg';
 import '../../theme/linkform.css';
+import '@ckeditor/ckeditor5-ui/theme/components/responsive-form/responsiveform.css';
 
 /**
  * The link form view controller class.
@@ -134,10 +135,10 @@ export default class LinkFormView extends View {
 			}
 		} );
 
-		const classList = [ 'ck', 'ck-link-form' ];
+		const classList = [ 'ck', 'ck-link-form', 'ck-responsive-form' ];
 
 		if ( linkCommand.manualDecorators.length ) {
-			classList.push( 'ck-link-form_layout-vertical' );
+			classList.push( 'ck-link-form_layout-vertical', 'ck-vertical-form' );
 		}
 
 		this.setTemplate( {

+ 1 - 0
packages/ckeditor5-link/tests/ui/linkactionsview.js

@@ -26,6 +26,7 @@ describe( 'LinkActionsView', () => {
 		it( 'should create element from template', () => {
 			expect( view.element.classList.contains( 'ck' ) ).to.true;
 			expect( view.element.classList.contains( 'ck-link-actions' ) ).to.true;
+			expect( view.element.classList.contains( 'ck-responsive-form' ) ).to.true;
 			expect( view.element.getAttribute( 'tabindex' ) ).to.equal( '-1' );
 		} );
 

+ 1 - 0
packages/ckeditor5-link/tests/ui/linkformview.js

@@ -39,6 +39,7 @@ describe( 'LinkFormView', () => {
 		it( 'should create element from template', () => {
 			expect( view.element.classList.contains( 'ck' ) ).to.true;
 			expect( view.element.classList.contains( 'ck-link-form' ) ).to.true;
+			expect( view.element.classList.contains( 'ck-responsive-form' ) ).to.true;
 			expect( view.element.getAttribute( 'tabindex' ) ).to.equal( '-1' );
 		} );
 

+ 1 - 1
packages/ckeditor5-list/src/liststyleediting.js

@@ -213,7 +213,7 @@ function upcastListItemStyle() {
 		dispatcher.on( 'element:li', ( evt, data, conversionApi ) => {
 			const listParent = data.viewItem.parent;
 			const listStyle = listParent.getStyle( 'list-style-type' ) || DEFAULT_LIST_TYPE;
-			const listItem = data.modelRange.start.nodeAfter;
+			const listItem = data.modelRange.start.nodeAfter || data.modelRange.end.nodeBefore;
 
 			conversionApi.writer.setAttribute( 'listStyle', listStyle, listItem );
 		}, { priority: 'low' } );

+ 37 - 0
packages/ckeditor5-list/tests/liststyleediting.js

@@ -333,6 +333,43 @@ describe( 'ListStyleEditing', () => {
 					'<paragraph>Paragraph.</paragraph>'
 				);
 			} );
+
+			// See: #8262.
+			describe( 'list conversion with surrounding text nodes', () => {
+				let editor;
+
+				beforeEach( () => {
+					return VirtualTestEditor
+						.create( {
+							plugins: [ ListStyleEditing ]
+						} )
+						.then( newEditor => {
+							editor = newEditor;
+						} );
+				} );
+
+				afterEach( () => {
+					return editor.destroy();
+				} );
+
+				it( 'should convert a list if raw text is before the list', () => {
+					editor.setData( 'Foo<ul><li>Foo</li></ul>' );
+
+					expect( editor.getData() ).to.equal( '<p>Foo</p><ul><li>Foo</li></ul>' );
+				} );
+
+				it( 'should convert a list if raw text is after the list', () => {
+					editor.setData( '<ul><li>Foo</li></ul>Foo' );
+
+					expect( editor.getData() ).to.equal( '<ul><li>Foo</li></ul><p>Foo</p>' );
+				} );
+
+				it( 'should convert a list if it is surrender by two text nodes', () => {
+					editor.setData( 'Foo<ul><li>Foo</li></ul>Foo' );
+
+					expect( editor.getData() ).to.equal( '<p>Foo</p><ul><li>Foo</li></ul><p>Foo</p>' );
+				} );
+			} );
 		} );
 	} );
 

+ 0 - 1
packages/ckeditor5-media-embed/src/mediaembedui.js

@@ -119,7 +119,6 @@ export default class MediaEmbedUI extends Plugin {
 
 		// Form elements should be read-only when corresponding commands are disabled.
 		form.urlInputView.bind( 'isReadOnly' ).to( command, 'isEnabled', value => !value );
-		form.saveButtonView.bind( 'isEnabled' ).to( command );
 	}
 }
 

+ 13 - 1
packages/ckeditor5-media-embed/src/ui/mediaformview.js

@@ -24,6 +24,7 @@ import injectCSSTransitionDisabling from '@ckeditor/ckeditor5-ui/src/bindings/in
 import checkIcon from '@ckeditor/ckeditor5-core/theme/icons/check.svg';
 import cancelIcon from '@ckeditor/ckeditor5-core/theme/icons/cancel.svg';
 import '../../theme/mediaform.css';
+import '@ckeditor/ckeditor5-ui/theme/components/responsive-form/responsiveform.css';
 
 /**
  * The media form view controller class.
@@ -58,6 +59,14 @@ export default class MediaFormView extends View {
 		 */
 		this.keystrokes = new KeystrokeHandler();
 
+		/**
+		 * The value of the URL input.
+		 *
+		 * @member {String} #mediaURLInputValue
+		 * @observable
+		 */
+		this.set( 'mediaURLInputValue', '' );
+
 		/**
 		 * The URL input view.
 		 *
@@ -72,6 +81,7 @@ export default class MediaFormView extends View {
 		 */
 		this.saveButtonView = this._createButton( t( 'Save' ), checkIcon, 'ck-button-save' );
 		this.saveButtonView.type = 'submit';
+		this.saveButtonView.bind( 'isEnabled' ).to( this, 'mediaURLInputValue', value => !!value );
 
 		/**
 		 * The Cancel button view.
@@ -124,7 +134,8 @@ export default class MediaFormView extends View {
 			attributes: {
 				class: [
 					'ck',
-					'ck-media-form'
+					'ck-media-form',
+					'ck-responsive-form'
 				],
 
 				tabindex: '-1'
@@ -278,6 +289,7 @@ export default class MediaFormView extends View {
 		inputField.on( 'input', () => {
 			// Display the tip text only when there's some value. Otherwise fall back to the default info text.
 			labeledInput.infoText = inputField.element.value ? this._urlInputViewInfoTip : this._urlInputViewInfoDefault;
+			this.mediaURLInputValue = inputField.element.value.trim();
 		} );
 
 		return labeledInput;

+ 18 - 4
packages/ckeditor5-media-embed/tests/mediaembedui.js

@@ -219,13 +219,27 @@ describe( 'MediaEmbedUI', () => {
 			expect( form.urlInputView.isReadOnly ).to.be.true;
 		} );
 
-		it( 'binds saveButtonView#isEnabled to command#isEnabled', () => {
-			const command = editor.commands.get( 'mediaEmbed' );
+		it( 'should trim URL input value', () => {
+			form.urlInputView.fieldView.element.value = '   ';
+			form.urlInputView.fieldView.fire( 'input' );
 
-			expect( form.saveButtonView.isEnabled ).to.be.true;
+			expect( form.mediaURLInputValue ).to.equal( '' );
+
+			form.urlInputView.fieldView.element.value = '   test   ';
+			form.urlInputView.fieldView.fire( 'input' );
+
+			expect( form.mediaURLInputValue ).to.equal( 'test' );
+		} );
+
+		it( 'binds saveButtonView#isEnabled to trimmed URL input value', () => {
+			form.urlInputView.fieldView.fire( 'input' );
 
-			command.isEnabled = false;
 			expect( form.saveButtonView.isEnabled ).to.be.false;
+
+			form.urlInputView.fieldView.element.value = 'test';
+			form.urlInputView.fieldView.fire( 'input' );
+
+			expect( form.saveButtonView.isEnabled ).to.be.true;
 		} );
 
 		describe( 'validators', () => {

+ 1 - 0
packages/ckeditor5-media-embed/tests/ui/mediaformview.js

@@ -35,6 +35,7 @@ describe( 'MediaFormView', () => {
 		it( 'should create element from template', () => {
 			expect( view.element.classList.contains( 'ck' ) ).to.true;
 			expect( view.element.classList.contains( 'ck-media-form' ) ).to.true;
+			expect( view.element.classList.contains( 'ck-responsive-form' ) ).to.true;
 			expect( view.element.getAttribute( 'tabindex' ) ).to.equal( '-1' );
 		} );
 

+ 186 - 148
packages/ckeditor5-table/src/tableclipboard.js

@@ -56,6 +56,8 @@ export default class TableClipboard extends Plugin {
 		this.listenTo( viewDocument, 'copy', ( evt, data ) => this._onCopyCut( evt, data ) );
 		this.listenTo( viewDocument, 'cut', ( evt, data ) => this._onCopyCut( evt, data ) );
 		this.listenTo( editor.model, 'insertContent', ( evt, args ) => this._onInsertContent( evt, ...args ), { priority: 'high' } );
+
+		this.decorate( '_replaceTableSlotCell' );
 	}
 
 	/**
@@ -164,7 +166,7 @@ export default class TableClipboard extends Plugin {
 			// Content table to which we insert a pasted table.
 			const selectedTable = selectedTableCells[ 0 ].findAncestor( 'table' );
 
-			const cellsToSelect = replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selectedTable, selection, writer );
+			const cellsToSelect = this._replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selectedTable, selection, writer );
 
 			if ( this.editor.plugins.get( 'TableSelection' ).isEnabled ) {
 				// Selection ranges must be sorted because the first and last selection ranges are considered
@@ -178,6 +180,189 @@ export default class TableClipboard extends Plugin {
 			}
 		} );
 	}
+
+	/**
+	 * Replaces the part of selectedTable with pastedTable.
+	 *
+	 * @private
+	 * @param {module:engine/model/element~Element} pastedTable
+	 * @param {Object} pastedDimensions
+	 * @param {Number} pastedDimensions.height
+	 * @param {Number} pastedDimensions.width
+	 * @param {module:engine/model/element~Element} selectedTable
+	 * @param {Object} selection
+	 * @param {Number} selection.firstColumn
+	 * @param {Number} selection.firstRow
+	 * @param {Number} selection.lastColumn
+	 * @param {Number} selection.lastRow
+	 * @param {module:engine/model/writer~Writer} writer
+	 * @returns {Array.<module:engine/model/element~Element>}
+	 */
+	_replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selectedTable, selection, writer ) {
+		const { width: pastedWidth, height: pastedHeight } = pastedDimensions;
+
+		// Holds two-dimensional array that is addressed by [ row ][ column ] that stores cells anchored at given location.
+		const pastedTableLocationMap = createLocationMap( pastedTable, pastedWidth, pastedHeight );
+
+		const selectedTableMap = [ ...new TableWalker( selectedTable, {
+			startRow: selection.firstRow,
+			endRow: selection.lastRow,
+			startColumn: selection.firstColumn,
+			endColumn: selection.lastColumn,
+			includeAllSlots: true
+		} ) ];
+
+		// Selection must be set to pasted cells (some might be removed or new created).
+		const cellsToSelect = [];
+
+		// Store next cell insert position.
+		let insertPosition;
+
+		// Content table replace cells algorithm iterates over a selected table fragment and:
+		//
+		// - Removes existing table cells at current slot (location).
+		// - Inserts cell from a pasted table for a matched slots.
+		//
+		// This ensures proper table geometry after the paste
+		for ( const tableSlot of selectedTableMap ) {
+			const { row, column } = tableSlot;
+
+			// Save the insert position for current row start.
+			if ( column === selection.firstColumn ) {
+				insertPosition = tableSlot.getPositionBefore();
+			}
+
+			// Map current table slot location to an pasted table slot location.
+			const pastedRow = row - selection.firstRow;
+			const pastedColumn = column - selection.firstColumn;
+			const pastedCell = pastedTableLocationMap[ pastedRow % pastedHeight ][ pastedColumn % pastedWidth ];
+
+			// Clone cell to insert (to duplicate its attributes and children).
+			// Cloning is required to support repeating pasted table content when inserting to a bigger selection.
+			const cellToInsert = pastedCell ? writer.cloneElement( pastedCell ) : null;
+
+			// Replace the cell from the current slot with new table cell.
+			const newTableCell = this._replaceTableSlotCell( tableSlot, cellToInsert, insertPosition, writer );
+
+			// The cell was only removed.
+			if ( !newTableCell ) {
+				continue;
+			}
+
+			// Trim the cell if it's row/col-spans would exceed selection area.
+			trimTableCellIfNeeded( newTableCell, row, column, selection.lastRow, selection.lastColumn, writer );
+
+			cellsToSelect.push( newTableCell );
+
+			insertPosition = writer.createPositionAfter( newTableCell );
+		}
+
+		// If there are any headings, all the cells that overlap from heading must be splitted.
+		const headingRows = parseInt( selectedTable.getAttribute( 'headingRows' ) || 0 );
+		const headingColumns = parseInt( selectedTable.getAttribute( 'headingColumns' ) || 0 );
+
+		const areHeadingRowsIntersectingSelection = selection.firstRow < headingRows && headingRows <= selection.lastRow;
+		const areHeadingColumnsIntersectingSelection = selection.firstColumn < headingColumns && headingColumns <= selection.lastColumn;
+
+		if ( areHeadingRowsIntersectingSelection ) {
+			const columnsLimit = { first: selection.firstColumn, last: selection.lastColumn };
+			const newCells = doHorizontalSplit( selectedTable, headingRows, columnsLimit, writer, selection.firstRow );
+
+			cellsToSelect.push( ...newCells );
+		}
+
+		if ( areHeadingColumnsIntersectingSelection ) {
+			const rowsLimit = { first: selection.firstRow, last: selection.lastRow };
+			const newCells = doVerticalSplit( selectedTable, headingColumns, rowsLimit, writer );
+
+			cellsToSelect.push( ...newCells );
+		}
+
+		return cellsToSelect;
+	}
+
+	/**
+	 * Replaces a single table slot.
+	 *
+	 * @private
+	 * @param {module:table/tablewalker~TableSlot} tableSlot
+	 * @param {module:engine/model/element~Element} cellToInsert
+	 * @param {module:engine/model/position~Position} insertPosition
+	 * @param {module:engine/model/writer~Writer} writer
+	 * @returns {module:engine/model/element~Element|null} Inserted table cell or null if slot should remain empty.
+	 */
+	_replaceTableSlotCell( tableSlot, cellToInsert, insertPosition, writer ) {
+		const { cell, isAnchor } = tableSlot;
+
+		// If the slot is occupied by a cell in a selected table - remove it.
+		// The slot of this cell will be either:
+		// - Replaced by a pasted table cell.
+		// - Spanned by a previously pasted table cell.
+		if ( isAnchor ) {
+			writer.remove( cell );
+		}
+
+		// There is no cell to insert (might be spanned by other cell in a pasted table) - advance to the next content table slot.
+		if ( !cellToInsert ) {
+			return null;
+		}
+
+		writer.insert( cellToInsert, insertPosition );
+
+		return cellToInsert;
+	}
+}
+
+/**
+ * Extract table for pasting into table.
+ *
+ * @private
+ * @param {module:engine/model/documentfragment~DocumentFragment|module:engine/model/item~Item} content The content to insert.
+ * @param {module:engine/model/model~Model} model The editor model.
+ * @returns {module:engine/model/element~Element|null}
+ */
+export function getTableIfOnlyTableInContent( content, model ) {
+	if ( !content.is( 'documentFragment' ) && !content.is( 'element' ) ) {
+		return null;
+	}
+
+	// Table passed directly.
+	if ( content.is( 'element', 'table' ) ) {
+		return content;
+	}
+
+	// We do not support mixed content when pasting table into table.
+	// See: https://github.com/ckeditor/ckeditor5/issues/6817.
+	if ( content.childCount == 1 && content.getChild( 0 ).is( 'element', 'table' ) ) {
+		return content.getChild( 0 );
+	}
+
+	// If there are only whitespaces around a table then use that table for pasting.
+
+	const contentRange = model.createRangeIn( content );
+
+	for ( const element of contentRange.getItems() ) {
+		if ( element.is( 'element', 'table' ) ) {
+			// Stop checking if there is some content before table.
+			const rangeBefore = model.createRange( contentRange.start, model.createPositionBefore( element ) );
+
+			if ( model.hasContent( rangeBefore, { ignoreWhitespaces: true } ) ) {
+				return null;
+			}
+
+			// Stop checking if there is some content after table.
+			const rangeAfter = model.createRange( model.createPositionAfter( element ), contentRange.end );
+
+			if ( model.hasContent( rangeAfter, { ignoreWhitespaces: true } ) ) {
+				return null;
+			}
+
+			// There wasn't any content neither before nor after.
+			return element;
+		}
+	}
+
+	return null;
 }
 
 // Prepares a table for pasting and returns adjusted selection dimensions.
@@ -246,109 +431,6 @@ function prepareTableForPasting( selectedTableCells, pastedDimensions, writer, t
 	return selection;
 }
 
-// Replaces the part of selectedTable with pastedTable.
-//
-// @param {module:engine/model/element~Element} pastedTable
-// @param {Object} pastedDimensions
-// @param {Number} pastedDimensions.height
-// @param {Number} pastedDimensions.width
-// @param {module:engine/model/element~Element} selectedTable
-// @param {Object} selection
-// @param {Number} selection.firstColumn
-// @param {Number} selection.firstRow
-// @param {Number} selection.lastColumn
-// @param {Number} selection.lastRow
-// @param {module:engine/model/writer~Writer} writer
-// @returns {Array.<module:engine/model/element~Element>}
-function replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selectedTable, selection, writer ) {
-	const { width: pastedWidth, height: pastedHeight } = pastedDimensions;
-
-	// Holds two-dimensional array that is addressed by [ row ][ column ] that stores cells anchored at given location.
-	const pastedTableLocationMap = createLocationMap( pastedTable, pastedWidth, pastedHeight );
-
-	const selectedTableMap = [ ...new TableWalker( selectedTable, {
-		startRow: selection.firstRow,
-		endRow: selection.lastRow,
-		startColumn: selection.firstColumn,
-		endColumn: selection.lastColumn,
-		includeAllSlots: true
-	} ) ];
-
-	// Selection must be set to pasted cells (some might be removed or new created).
-	const cellsToSelect = [];
-
-	// Store next cell insert position.
-	let insertPosition;
-
-	// Content table replace cells algorithm iterates over a selected table fragment and:
-	//
-	// - Removes existing table cells at current slot (location).
-	// - Inserts cell from a pasted table for a matched slots.
-	//
-	// This ensures proper table geometry after the paste
-	for ( const tableSlot of selectedTableMap ) {
-		const { row, column, cell, isAnchor } = tableSlot;
-
-		// Save the insert position for current row start.
-		if ( column === selection.firstColumn ) {
-			insertPosition = tableSlot.getPositionBefore();
-		}
-
-		// If the slot is occupied by a cell in a selected table - remove it.
-		// The slot of this cell will be either:
-		// - Replaced by a pasted table cell.
-		// - Spanned by a previously pasted table cell.
-		if ( isAnchor ) {
-			writer.remove( cell );
-		}
-
-		// Map current table slot location to an pasted table slot location.
-		const pastedRow = row - selection.firstRow;
-		const pastedColumn = column - selection.firstColumn;
-		const pastedCell = pastedTableLocationMap[ pastedRow % pastedHeight ][ pastedColumn % pastedWidth ];
-
-		// There is no cell to insert (might be spanned by other cell in a pasted table) - advance to the next content table slot.
-		if ( !pastedCell ) {
-			continue;
-		}
-
-		// Clone cell to insert (to duplicate its attributes and children).
-		// Cloning is required to support repeating pasted table content when inserting to a bigger selection.
-		const cellToInsert = writer.cloneElement( pastedCell );
-
-		// Trim the cell if it's row/col-spans would exceed selection area.
-		trimTableCellIfNeeded( cellToInsert, row, column, selection.lastRow, selection.lastColumn, writer );
-
-		writer.insert( cellToInsert, insertPosition );
-		cellsToSelect.push( cellToInsert );
-
-		insertPosition = writer.createPositionAfter( cellToInsert );
-	}
-
-	// If there are any headings, all the cells that overlap from heading must be splitted.
-	const headingRows = parseInt( selectedTable.getAttribute( 'headingRows' ) || 0 );
-	const headingColumns = parseInt( selectedTable.getAttribute( 'headingColumns' ) || 0 );
-
-	const areHeadingRowsIntersectingSelection = selection.firstRow < headingRows && headingRows <= selection.lastRow;
-	const areHeadingColumnsIntersectingSelection = selection.firstColumn < headingColumns && headingColumns <= selection.lastColumn;
-
-	if ( areHeadingRowsIntersectingSelection ) {
-		const columnsLimit = { first: selection.firstColumn, last: selection.lastColumn };
-		const newCells = doHorizontalSplit( selectedTable, headingRows, columnsLimit, writer, selection.firstRow );
-
-		cellsToSelect.push( ...newCells );
-	}
-
-	if ( areHeadingColumnsIntersectingSelection ) {
-		const rowsLimit = { first: selection.firstRow, last: selection.lastRow };
-		const newCells = doVerticalSplit( selectedTable, headingColumns, rowsLimit, writer );
-
-		cellsToSelect.push( ...newCells );
-	}
-
-	return cellsToSelect;
-}
-
 // Expand table (in place) to expected size.
 function expandTableSize( table, expectedHeight, expectedWidth, tableUtils ) {
 	const tableWidth = tableUtils.getColumns( table );
@@ -369,50 +451,6 @@ function expandTableSize( table, expectedHeight, expectedWidth, tableUtils ) {
 	}
 }
 
-function getTableIfOnlyTableInContent( content, model ) {
-	if ( !content.is( 'documentFragment' ) && !content.is( 'element' ) ) {
-		return null;
-	}
-
-	// Table passed directly.
-	if ( content.is( 'element', 'table' ) ) {
-		return content;
-	}
-
-	// We do not support mixed content when pasting table into table.
-	// See: https://github.com/ckeditor/ckeditor5/issues/6817.
-	if ( content.childCount == 1 && content.getChild( 0 ).is( 'element', 'table' ) ) {
-		return content.getChild( 0 );
-	}
-
-	// If there are only whitespaces around a table then use that table for pasting.
-
-	const contentRange = model.createRangeIn( content );
-
-	for ( const element of contentRange.getItems() ) {
-		if ( element.is( 'element', 'table' ) ) {
-			// Stop checking if there is some content before table.
-			const rangeBefore = model.createRange( contentRange.start, model.createPositionBefore( element ) );
-
-			if ( model.hasContent( rangeBefore, { ignoreWhitespaces: true } ) ) {
-				return null;
-			}
-
-			// Stop checking if there is some content after table.
-			const rangeAfter = model.createRange( model.createPositionAfter( element ), contentRange.end );
-
-			if ( model.hasContent( rangeAfter, { ignoreWhitespaces: true } ) ) {
-				return null;
-			}
-
-			// There wasn't any content neither before nor after.
-			return element;
-		}
-	}
-
-	return null;
-}
-
 // Returns two-dimensional array that is addressed by [ row ][ column ] that stores cells anchored at given location.
 //
 // At given row & column location it might be one of:

+ 8 - 0
packages/ckeditor5-table/src/tableutils.js

@@ -26,6 +26,14 @@ export default class TableUtils extends Plugin {
 		return 'TableUtils';
 	}
 
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		this.decorate( 'insertColumns' );
+		this.decorate( 'insertRows' );
+	}
+
 	/**
 	 * Returns the table cell location as an object with table row and table column indexes.
 	 *

+ 146 - 1
packages/ckeditor5-table/tests/tableclipboard-paste.js

@@ -23,7 +23,7 @@ import TableEditing from '../src/tableediting';
 import TableCellPropertiesEditing from '../src/tablecellproperties/tablecellpropertiesediting';
 import TableWalker from '../src/tablewalker';
 
-import TableClipboard from '../src/tableclipboard';
+import TableClipboard, { getTableIfOnlyTableInContent } from '../src/tableclipboard';
 
 describe( 'table clipboard', () => {
 	let editor, model, modelRoot, tableSelection, viewDocument, element;
@@ -478,6 +478,30 @@ describe( 'table clipboard', () => {
 			] ) );
 		} );
 
+		it( '#_replaceTableSlotCell() should be overridable', () => {
+			const tableClipboard = editor.plugins.get( 'TableClipboard' );
+
+			tableClipboard.on( '_replaceTableSlotCell', ( evt, args ) => {
+				const [ /* tableSlot */, cellToInsert, /* insertPosition */, writer ] = args;
+
+				if ( cellToInsert ) {
+					writer.setAttribute( 'foo', 'bar', cellToInsert );
+				}
+			}, { priority: 'high' } );
+
+			pasteTable( [
+				[ 'aa', 'ab' ],
+				[ 'ba', 'bb' ]
+			] );
+
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+				[ { contents: 'aa', foo: 'bar' }, { contents: 'ab', foo: 'bar' }, '02', '03' ],
+				[ { contents: 'ba', foo: 'bar' }, { contents: 'bb', foo: 'bar' }, '12', '13' ],
+				[ '20', '21', '22', '23' ],
+				[ '30', '31', '32', '33' ]
+			] ) );
+		} );
+
 		describe( 'single cell selected', () => {
 			beforeEach( () => {
 				setModelData( model, modelTable( [
@@ -3949,6 +3973,127 @@ describe( 'table clipboard', () => {
 		} );
 	} );
 
+	describe( 'getTableIfOnlyTableInContent helper', () => {
+		beforeEach( async () => {
+			await createEditor();
+		} );
+
+		it( 'should return null for no table provided', () => {
+			setModelData( model, '<paragraph>foo</paragraph>' );
+
+			const content = modelRoot.getChild( 0 );
+
+			expect( getTableIfOnlyTableInContent( content, model ) ).to.be.null;
+		} );
+
+		it( 'should return null for a text node provided', async () => {
+			setModelData( model, '<paragraph>foo</paragraph>' );
+
+			const content = modelRoot.getNodeByPath( [ 0, 0 ] );
+
+			expect( getTableIfOnlyTableInContent( content, model ) ).to.be.null;
+		} );
+
+		it( 'should return null for mixed content provided (table + paragraph)', () => {
+			setModelData( model,
+				'<table><tableRow><tableCell><paragraph>bar</paragraph></tableCell></tableRow></table>' +
+				'<paragraph>foo</paragraph>'
+			);
+
+			const content = documentFragmentFromChildren( modelRoot );
+
+			expect( getTableIfOnlyTableInContent( content, model ) ).to.be.null;
+		} );
+
+		it( 'should return null for mixed content provided (paragraph + table)', () => {
+			setModelData( model,
+				'<paragraph>foo</paragraph>' +
+				'<table><tableRow><tableCell><paragraph>bar</paragraph></tableCell></tableRow></table>'
+			);
+
+			const content = documentFragmentFromChildren( modelRoot );
+
+			expect( getTableIfOnlyTableInContent( content, model ) ).to.be.null;
+		} );
+
+		it( 'should return table element for mixed content provided (table + empty paragraph)', () => {
+			setModelData( model,
+				'<table><tableRow><tableCell><paragraph>bar</paragraph></tableCell></tableRow></table>' +
+				'<paragraph></paragraph>'
+			);
+
+			const content = documentFragmentFromChildren( modelRoot );
+			const result = getTableIfOnlyTableInContent( content, model );
+
+			expect( result ).to.be.not.null;
+			expect( result.is( 'element', 'table' ) ).to.be.true;
+		} );
+
+		it( 'should return table element for mixed content provided (table + empty paragraph)', () => {
+			setModelData( model,
+				'<paragraph></paragraph>' +
+				'<table><tableRow><tableCell><paragraph>bar</paragraph></tableCell></tableRow></table>'
+			);
+
+			const content = documentFragmentFromChildren( modelRoot );
+			const result = getTableIfOnlyTableInContent( content, model );
+
+			expect( result ).to.be.not.null;
+			expect( result.is( 'element', 'table' ) ).to.be.true;
+		} );
+
+		it( 'should return table element for mixed content provided (p + p + table + p)', () => {
+			setModelData( model,
+				'<paragraph></paragraph>' +
+				'<paragraph></paragraph>' +
+				'<table><tableRow><tableCell><paragraph>bar</paragraph></tableCell></tableRow></table>' +
+				'<paragraph></paragraph>'
+			);
+
+			const content = documentFragmentFromChildren( modelRoot );
+			const result = getTableIfOnlyTableInContent( content, model );
+
+			expect( result ).to.be.not.null;
+			expect( result.is( 'element', 'table' ) ).to.be.true;
+		} );
+
+		it( 'should return table element for if table is the only element provided in document fragment', () => {
+			setModelData( model,
+				'<table><tableRow><tableCell><paragraph>bar</paragraph></tableCell></tableRow></table>'
+			);
+
+			const content = documentFragmentFromChildren( modelRoot );
+			const result = getTableIfOnlyTableInContent( content, model );
+
+			expect( result ).to.be.not.null;
+			expect( result.is( 'element', 'table' ) ).to.be.true;
+		} );
+
+		it( 'should return table element for if table is the only element provided directly', () => {
+			setModelData( model,
+				'<table><tableRow><tableCell><paragraph>bar</paragraph></tableCell></tableRow></table>'
+			);
+
+			const content = modelRoot.getChild( 0 );
+			const result = getTableIfOnlyTableInContent( content, model );
+
+			expect( result ).to.be.not.null;
+			expect( result.is( 'element', 'table' ) ).to.be.true;
+		} );
+
+		function documentFragmentFromChildren( element ) {
+			return model.change( writer => {
+				const documentFragment = writer.createDocumentFragment();
+
+				for ( const child of element.getChildren() ) {
+					writer.insert( writer.cloneElement( child ), documentFragment, 'end' );
+				}
+
+				return documentFragment;
+			} );
+		}
+	} );
+
 	async function createEditor( extraPlugins = [] ) {
 		editor = await ClassicTestEditor.create( element, {
 			plugins: [ TableEditing, TableClipboard, Paragraph, Clipboard, ...extraPlugins ]

+ 44 - 0
packages/ckeditor5-table/tests/tableutils.js

@@ -5,6 +5,7 @@
 
 import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
@@ -16,6 +17,8 @@ import TableUtils from '../src/tableutils';
 describe( 'TableUtils', () => {
 	let editor, model, root, tableUtils;
 
+	testUtils.createSinonSandbox();
+
 	beforeEach( () => {
 		return ModelTestEditor.create( {
 			plugins: [ Paragraph, TableEditing, TableUtils ]
@@ -51,6 +54,27 @@ describe( 'TableUtils', () => {
 	} );
 
 	describe( 'insertRows()', () => {
+		it( 'should be decorated', () => {
+			const spy = sinon.spy();
+
+			setData( model, modelTable( [
+				[ '11[]', '12' ],
+				[ '21', '22' ]
+			] ) );
+
+			tableUtils.on( 'insertRows', spy );
+
+			tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 1 } );
+
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ '11[]', '12' ],
+				[ '', '' ],
+				[ '21', '22' ]
+			] ) );
+
+			expect( spy.calledOnce ).to.be.true;
+		} );
+
 		it( 'should insert row in given table at given index', () => {
 			setData( model, modelTable( [
 				[ '11[]', '12' ],
@@ -335,6 +359,26 @@ describe( 'TableUtils', () => {
 	} );
 
 	describe( 'insertColumns()', () => {
+		it( 'should be decorated', () => {
+			const spy = sinon.spy();
+
+			setData( model, modelTable( [
+				[ '11[]', '12' ],
+				[ '21', '22' ]
+			] ) );
+
+			tableUtils.on( 'insertColumns', spy );
+
+			tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ), { at: 1 } );
+
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ '11[]', '', '12' ],
+				[ '21', '', '22' ]
+			] ) );
+
+			expect( spy.calledOnce ).to.be.true;
+		} );
+
 		it( 'should insert column in given table at given index', () => {
 			setData( model, modelTable( [
 				[ '11[]', '12' ],

+ 0 - 25
packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkactions.css

@@ -11,8 +11,6 @@
 @import "@ckeditor/ckeditor5-ui/theme/mixins/_rwd.css";
 
 .ck.ck-link-actions {
-	padding: var(--ck-spacing-standard);
-
 	& .ck-button.ck-link-actions__preview {
 		padding-left: 0;
 		padding-right: 0;
@@ -52,11 +50,6 @@
 		}
 	}
 
-	&:focus {
-		/* https://github.com/ckeditor/ckeditor5-link/issues/90 */
-		outline: none;
-	}
-
 	@mixin ck-dir ltr {
 		& .ck-button:not(:first-child) {
 			margin-left: var(--ck-spacing-standard);
@@ -70,9 +63,6 @@
 	}
 
 	@mixin ck-media-phone {
-		padding: 0;
-		width: calc(.8 * var(--ck-input-text-width));
-
 		& .ck-button.ck-link-actions__preview {
 			margin: var(--ck-spacing-standard) var(--ck-spacing-standard) 0;
 
@@ -83,27 +73,12 @@
 		}
 
 		& .ck-button:not(.ck-link-actions__preview) {
-			padding: var(--ck-spacing-standard);
-			margin-top: var(--ck-spacing-standard);
-
-			border-radius: 0;
-			border: 0;
-			border-top: 1px solid var(--ck-color-base-border);
-
 			@mixin ck-dir ltr {
 				margin-left: 0;
-
-				&:first-of-type {
-					border-right: 1px solid var(--ck-color-base-border);
-				}
 			}
 
 			@mixin ck-dir rtl {
 				margin-left: 0;
-
-				&:last-of-type {
-					border-right: 1px solid var(--ck-color-base-border);
-				}
 			}
 		}
 	}

+ 0 - 65
packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkform.css

@@ -3,69 +3,8 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
-@import "@ckeditor/ckeditor5-ui/theme/mixins/_rwd.css";
 @import "@ckeditor/ckeditor5-ui/theme/mixins/_dir.css";
 
-.ck.ck-link-form {
-	padding: var(--ck-spacing-large);
-
-	&:focus {
-		/* https://github.com/ckeditor/ckeditor5-link/issues/90 */
-		outline: none;
-	}
-
-	@mixin ck-dir ltr {
-		& > :not(:first-child) {
-			margin-left: var(--ck-spacing-standard);
-		}
-	}
-
-	@mixin ck-dir rtl {
-		& > :not(:last-child) {
-			margin-left: var(--ck-spacing-standard);
-		}
-	}
-
-	@mixin ck-media-phone {
-		padding: 0;
-		width: calc(.8 * var(--ck-input-text-width));
-
-		& .ck-labeled-field-view {
-			margin: var(--ck-spacing-large) var(--ck-spacing-large) 0;
-
-			& .ck-input-text {
-				min-width: 0;
-				width: 100%;
-			}
-		}
-
-		& .ck-button {
-			padding: var(--ck-spacing-standard);
-			margin-top: var(--ck-spacing-large);
-
-			border-radius: 0;
-			border: 0;
-			border-top: 1px solid var(--ck-color-base-border);
-
-			@mixin ck-dir ltr {
-				margin-left: 0;
-
-				&:first-of-type {
-					border-right: 1px solid var(--ck-color-base-border);
-				}
-			}
-
-			@mixin ck-dir rtl {
-				margin-left: 0;
-
-				&:last-of-type {
-					border-right: 1px solid var(--ck-color-base-border);
-				}
-			}
-		}
-	}
-}
-
 /*
  * Style link form differently when manual decorators are available.
  * See: https://github.com/ckeditor/ckeditor5-link/issues/186.
@@ -93,10 +32,6 @@
 
 		@mixin ck-dir ltr {
 			margin-left: 0;
-
-			&:first-of-type {
-				border-right: 1px solid var(--ck-color-base-border);
-			}
 		}
 
 		@mixin ck-dir rtl {

+ 12 - 6
packages/ckeditor5-theme-lark/theme/ckeditor5-media-embed/mediaform.css → packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/responsive-form/responsiveform.css

@@ -6,10 +6,15 @@
 @import "@ckeditor/ckeditor5-ui/theme/mixins/_rwd.css";
 @import "@ckeditor/ckeditor5-ui/theme/mixins/_dir.css";
 
-.ck.ck-media-form {
+.ck-vertical-form .ck-button::after {
+	border-right: 1px solid var(--ck-color-base-border);
+}
+
+.ck.ck-responsive-form {
 	padding: var(--ck-spacing-large);
 
 	&:focus {
+		/* https://github.com/ckeditor/ckeditor5-link/issues/90 */
 		outline: none;
 	}
 
@@ -43,7 +48,8 @@
 			}
 		}
 
-		& .ck-button {
+		/* 'button' selector must be used because those styles shouldn't be added to other elements, like 'a'. Example in LinkActionsView. */
+		& button.ck-button {
 			padding: var(--ck-spacing-standard);
 			margin-top: var(--ck-spacing-standard);
 
@@ -53,10 +59,6 @@
 
 			@mixin ck-dir ltr {
 				margin-left: 0;
-
-				&:first-of-type {
-					border-right: 1px solid var(--ck-color-base-border);
-				}
 			}
 
 			@mixin ck-dir rtl {
@@ -66,6 +68,10 @@
 					border-right: 1px solid var(--ck-color-base-border);
 				}
 			}
+
+			&::after {
+				border-right: 1px solid var(--ck-color-base-border);
+			}
 		}
 	}
 }

+ 30 - 0
packages/ckeditor5-ui/theme/components/responsive-form/responsiveform.css

@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+@import "@ckeditor/ckeditor5-ui/theme/mixins/_rwd.css";
+
+.ck-vertical-form .ck-button::after {
+	content: "";
+	width: 0;
+	position: absolute;
+	right: -1px;
+	top: var(--ck-spacing-small);
+	bottom: var(--ck-spacing-small);
+	z-index: 1;
+}
+
+.ck.ck-responsive-form {
+	@mixin ck-media-phone {
+		& .ck-button::after {
+			content: "";
+			width: 0;
+			position: absolute;
+			right: -1px;
+			top: var(--ck-spacing-small);
+			bottom: var(--ck-spacing-small);
+			z-index: 1;
+		}
+	}
+}

+ 13 - 0
packages/ckeditor5-widget/docs/framework/guides/deep-dive/widget-internals.md

@@ -65,3 +65,16 @@ widgetTypeAroundPlugin.clearForceDisabled( 'MyApplication' );
 ```
 
 Refer to the {@link module:core/plugin~Plugin#clearForceDisabled API documentation} to learn more.
+
+## Exclude DOM events from default handlers
+
+Sometimes it can be useful to prevent processing of events by default handlers, for example using React component inside an `UIElement` in the widget where, by default, widget itself wants to control everything. To make it possible the only thing to do is to add a `data-cke-ignore-events` attribute to an element or to its ancestor and then all events triggered by any of children from that element will be ignored in default handlers.
+
+Let's see it in an short example:
+
+```html
+<div data-cke-ignore-events="true">
+	<button>Click!</button>
+</div>
+```
+In the above template events dispatched from the button, which is placed inside `<div>` containing `data-cke-ignore-events` attribute, will be ignored by default event handlers.

+ 132 - 0
packages/ckeditor5-widget/tests/widget-events.js

@@ -0,0 +1,132 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals document, Event */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import KeyObserver from '@ckeditor/ckeditor5-engine/src/view/observer/keyobserver';
+
+import { toWidget } from '../src/utils';
+import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+
+describe( 'Widget - Events', () => {
+	const EVENT_NAME = 'keyup';
+	let editor, editorElement, eventCallback, buttonIgnored, buttonRegular;
+
+	testUtils.createSinonSandbox();
+
+	beforeEach( async () => {
+		editorElement = createEditorElement();
+		editor = await createEditor( editorElement );
+	} );
+
+	afterEach( () => {
+		editorElement.remove();
+
+		if ( editor ) {
+			return editor.destroy();
+		}
+	} );
+
+	it( 'should not ignore events from child inside parent without the `data-cke-ignore-events` attribute', () => {
+		buttonRegular.dispatchEvent( new Event( EVENT_NAME, { bubbles: true } ) );
+
+		expect( eventCallback.callCount ).to.equal( 1 );
+	} );
+
+	it( 'should ignore events from child inside parent with the `data-cke-ignore-events` attribute', () => {
+		buttonIgnored.dispatchEvent( new Event( EVENT_NAME, { bubbles: true } ) );
+
+		expect( eventCallback.callCount ).to.equal( 0 );
+	} );
+
+	function createEditorElement() {
+		return document.body.appendChild( document.createElement( 'div' ) );
+	}
+
+	async function createEditor( element ) {
+		const editor = await ClassicEditor.create( element, { plugins: [ simpleWidgetPlugin ] } );
+
+		setModelData( editor.model, '[<simpleWidgetElement></simpleWidgetElement>]' );
+
+		const container = Array
+			.from( editor.editing.view.document.getRoot().getChildren() )
+			.find( element => element.hasClass( 'simple-widget-container' ) );
+
+		const domFragment = editor.editing.view.domConverter.viewToDom( container );
+
+		buttonIgnored = domFragment.querySelector( '#ignored-button' );
+		buttonRegular = domFragment.querySelector( '#regular-button' );
+
+		return editor;
+	}
+
+	function simpleWidgetPlugin( editor ) {
+		defineSchema( editor );
+		defineConverters( editor );
+		addObserver( editor );
+
+		function defineSchema( editor ) {
+			editor.model.schema.register( 'simpleWidgetElement', {
+				inheritAllFrom: '$block',
+				isObject: true
+			} );
+		}
+
+		function defineConverters( editor ) {
+			editor.conversion.for( 'editingDowncast' )
+				.elementToElement( {
+					model: 'simpleWidgetElement',
+					view: ( modelElement, { writer } ) => {
+						const widgetElement = createWidgetView( modelElement, { writer } );
+
+						return toWidget( widgetElement, writer );
+					}
+				} );
+
+			editor.conversion.for( 'dataDowncast' )
+				.elementToElement( {
+					model: 'simpleWidgetElement',
+					view: createWidgetView
+				} );
+
+			editor.conversion.for( 'upcast' )
+				.elementToElement( {
+					model: 'simpleWidgetElement',
+					view: {
+						name: 'section',
+						classes: 'simple-widget-container'
+					}
+				} );
+
+			function createWidgetView( modelElement, { writer } ) {
+				const simpleWidgetContainer = writer.createContainerElement( 'section', { class: 'simple-widget-container' } );
+				const simpleWidgetElement = writer.createRawElement( 'div', { class: 'simple-widget-element' }, domElement => {
+					domElement.innerHTML = `
+						<div data-cke-ignore-events="true">
+							<button id="ignored-button">Click!</button>
+						</div>
+						<div>
+							<button id="regular-button">Click!</button>
+						</div>
+					`;
+				} );
+
+				writer.insert( writer.createPositionAt( simpleWidgetContainer, 0 ), simpleWidgetElement );
+
+				return simpleWidgetContainer;
+			}
+		}
+
+		function addObserver( editor ) {
+			eventCallback = sinon.fake();
+
+			editor.editing.view.addObserver( KeyObserver );
+			editor.editing.view.document.on( EVENT_NAME, eventCallback );
+		}
+	}
+} );