浏览代码

Merge branch 'master' into i/6775

Aleksander Nowodzinski 5 年之前
父节点
当前提交
fbde7c42fa
共有 25 个文件被更改,包括 2366 次插入413 次删除
  1. 31 0
      .github/workflows/merge-stable.yml
  2. 6 4
      docs/framework/guides/contributing/git-commit-message-convention.md
  3. 11 0
      packages/ckeditor5-cloud-services/src/cloudservices.js
  4. 31 0
      packages/ckeditor5-cloud-services/tests/cloudservices.js
  5. 33 27
      packages/ckeditor5-engine/tests/view/view/view.js
  6. 2 1
      packages/ckeditor5-media-embed/package.json
  7. 7 0
      packages/ckeditor5-media-embed/src/automediaembed.js
  8. 34 0
      packages/ckeditor5-media-embed/tests/automediaembed.js
  9. 12 3
      packages/ckeditor5-table/src/commands/setheadercolumncommand.js
  10. 5 79
      packages/ckeditor5-table/src/commands/setheaderrowcommand.js
  11. 270 51
      packages/ckeditor5-table/src/tableclipboard.js
  12. 14 5
      packages/ckeditor5-table/src/tableselection/croptable.js
  13. 167 2
      packages/ckeditor5-table/src/utils.js
  14. 104 2
      packages/ckeditor5-table/tests/commands/setheadercolumncommand.js
  15. 3 3
      packages/ckeditor5-table/tests/commands/setheaderrowcommand.js
  16. 57 11
      packages/ckeditor5-table/tests/manual/tablemocking.js
  17. 1362 180
      packages/ckeditor5-table/tests/tableclipboard-paste.js
  18. 89 1
      packages/ckeditor5-table/tests/utils.js
  19. 15 2
      packages/ckeditor5-theme-lark/theme/ckeditor5-widget/widgettypearound.css
  20. 18 4
      packages/ckeditor5-utils/src/dom/position.js
  21. 26 1
      packages/ckeditor5-utils/tests/dom/position.js
  22. 8 1
      packages/ckeditor5-widget/src/utils.js
  23. 38 6
      packages/ckeditor5-widget/tests/utils.js
  24. 1 11
      packages/ckeditor5-widget/theme/widgettypearound.css
  25. 22 19
      scripts/release/changelog.js

+ 31 - 0
.github/workflows/merge-stable.yml

@@ -0,0 +1,31 @@
+name: Stable branch auto merge
+
+on:
+  push:
+    branches: [ stable ]
+
+jobs:
+  merge:
+    runs-on: ubuntu-latest
+    steps:
+      # First: merge
+      - uses: octokit/request-action@v2.x
+        id: merge_action
+        with:
+          route: POST /repos/:repository/merges
+          repository: ${{ github.repository }}
+          base: master
+          head: stable
+        env:
+          GITHUB_TOKEN: ${{ secrets.STABLE_MERGE_GITHUB_TOKEN }}
+      # Report errors if any
+      - uses: rtCamp/action-slack-notify@v2.0.2
+        id: error_message_slack
+        name: Slack notification
+        if: (steps.merge_action.outputs.status != 201) && (steps.merge_action.outputs.status != 204)
+        env:
+          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
+          SLACK_CHANNEL: "cke5-ci"
+          SLACK_ICON: https://github.com/ckeditor.png?size=48
+          SLACK_USERNAME: "stable auto merge action"
+          SLACK_MESSAGE: ${{ format('💥 Error, merge call returned code {0}, see {1} for a list of codes with explanation. 💥', steps.merge_action.outputs.status, 'https://developer.github.com/v3/repos/merging/#perform-a-merge' ) }}

+ 6 - 4
docs/framework/guides/contributing/git-commit-message-convention.md

@@ -24,8 +24,8 @@ Type (another-package-name): If the change affects more than one package, it's p
 
 
 Optional description.
 Optional description.
 
 
-BREAKING CHANGE: If any breaking changes were done, they need to be listed here.
-BREAKING CHANGE: Another breaking change if needed. Closes #ZZZ.
+BREAKING CHANGE (scope): If any breaking changes were done, they need to be listed here.
+BREAKING CHANGE (scope): Another breaking change if needed. Closes #ZZZ.
 ```
 ```
 
 
 ### Commit types
 ### Commit types
@@ -50,6 +50,8 @@ If any change contains the `MAJOR BREAKING CHANGE` note, the next release will b
 
 
 For reference on how to identify minor or major breaking change see the {@link framework/guides/support/versioning-policy versioning policy guide}.
 For reference on how to identify minor or major breaking change see the {@link framework/guides/support/versioning-policy versioning policy guide}.
 
 
+Each `BREAKING CHANGE` note must be followed by the scope of changes.
+
 ### Package name
 ### Package name
 
 
 Most commits are related to one or more packages. Each affected package should be listed in parenthesis following the commit type. A package that was the most impacted by the change should be listed first.
 Most commits are related to one or more packages. Each affected package should be listed in parenthesis following the commit type. A package that was the most impacted by the change should be listed first.
@@ -95,7 +97,7 @@ Other (utils): Extracted the `utils.foo()` to a separate package. Closes #9.
 
 
 Feature (engine): Introduced the `engine.foo()` method. Closes #9.
 Feature (engine): Introduced the `engine.foo()` method. Closes #9.
 
 
-MAJOR BREAKING CHANGE: The `utils.foo()` method was moved to the `engine` package. See #9.
+MAJOR BREAKING CHANGE (utils): The `utils.foo()` method was moved to the `engine` package. See #9.
 ```
 ```
 
 
 For the commits shown above the changelog will look like this:
 For the commits shown above the changelog will look like this:
@@ -108,7 +110,7 @@ Changelog
 
 
 ### MAJOR BREAKING CHANGES [ℹ️](https://ckeditor.com/docs/ckeditor5/latest/framework/guides/support/versioning-policy.html#major-and-minor-breaking-changes)
 ### MAJOR BREAKING CHANGES [ℹ️](https://ckeditor.com/docs/ckeditor5/latest/framework/guides/support/versioning-policy.html#major-and-minor-breaking-changes)
 
 
-* The `utils.foo()` method was moved to the `engine` package. See #9. See [#9](https://github.com/ckeditor/ckeditor5/issue/9).
+* **[utils](http://npmjs.com/package/@ckeditor/ckeditor5-utils)**: The `utils.foo()` method was moved to the `engine` package. See [#9](https://github.com/ckeditor/ckeditor5/issue/9).
 
 
 ### Features
 ### Features
 
 

+ 11 - 0
packages/ckeditor5-cloud-services/src/cloudservices.js

@@ -71,6 +71,17 @@ export default class CloudServices extends ContextPlugin {
 
 
 		return this.token.init();
 		return this.token.init();
 	}
 	}
+
+	/**
+	 * @inheritDoc
+	 */
+	destroy() {
+		super.destroy();
+
+		if ( this.token ) {
+			this.token.destroy();
+		}
+	}
 }
 }
 
 
 CloudServices.Token = Token;
 CloudServices.Token = Token;

+ 31 - 0
packages/ckeditor5-cloud-services/tests/cloudservices.js

@@ -136,4 +136,35 @@ describe( 'CloudServices', () => {
 			} );
 			} );
 		} );
 		} );
 	} );
 	} );
+
+	describe( 'destroy()', () => {
+		it( 'should destroy created token when tokenUrl was provided', async () => {
+			CloudServices.Token.initialToken = 'initial-token';
+
+			const context = await Context.create( {
+				plugins: [ CloudServices ],
+				cloudServices: {
+					tokenUrl: 'http://token-endpoint'
+				}
+			} );
+
+			const cloudServicesPlugin = context.plugins.get( CloudServices );
+
+			const destroySpy = sinon.spy( cloudServicesPlugin.token, 'destroy' );
+
+			await context.destroy();
+
+			sinon.assert.calledOnce( destroySpy );
+		} );
+
+		it( 'should not crash when tokenUrl was not provided', async () => {
+			const context = await Context.create( { plugins: [ CloudServices ] } );
+
+			try {
+				await context.destroy();
+			} catch ( error ) {
+				expect.fail( 'Error should not be thrown.' );
+			}
+		} );
+	} );
 } );
 } );

+ 33 - 27
packages/ckeditor5-engine/tests/view/view/view.js

@@ -17,6 +17,7 @@ import CompositionObserver from '../../../src/view/observer/compositionobserver'
 import ViewRange from '../../../src/view/range';
 import ViewRange from '../../../src/view/range';
 import ViewElement from '../../../src/view/element';
 import ViewElement from '../../../src/view/element';
 import ViewContainerElement from '../../../src/view/containerelement';
 import ViewContainerElement from '../../../src/view/containerelement';
+import ViewText from '../../../src/view/text';
 import ViewPosition from '../../../src/view/position';
 import ViewPosition from '../../../src/view/position';
 import ViewSelection from '../../../src/view/selection';
 import ViewSelection from '../../../src/view/selection';
 import { StylesProcessor } from '../../../src/view/stylesmap';
 import { StylesProcessor } from '../../../src/view/stylesmap';
@@ -516,7 +517,7 @@ describe( 'view', () => {
 	} );
 	} );
 
 
 	describe( 'hasDomSelection', () => {
 	describe( 'hasDomSelection', () => {
-		let domElement, domP, domSelection;
+		let domElement, domP, domText, viewP, viewText, domSelection;
 
 
 		// Focus tests are too unstable on Firefox to run them.
 		// Focus tests are too unstable on Firefox to run them.
 		if ( env.isGecko ) {
 		if ( env.isGecko ) {
@@ -528,10 +529,12 @@ describe( 'view', () => {
 
 
 			view.attachDomRoot( domRoot );
 			view.attachDomRoot( domRoot );
 
 
-			// It must be a container element to be rendered with the bogus <br> inside which ensures
-			// that the browser sees a selection position inside (empty <p> may not be selectable).
-			// May help resolving https://github.com/ckeditor/ckeditor5/issues/6655.
-			viewRoot._appendChild( new ViewContainerElement( viewDocument, 'p' ) );
+			viewP = new ViewContainerElement( viewDocument, 'p' );
+			viewText = new ViewText( viewDocument, 'ab' );
+
+			viewRoot._appendChild( viewP );
+			viewP._appendChild( viewText );
+
 			view.forceRender();
 			view.forceRender();
 
 
 			domElement = createElement( document, 'div', { contenteditable: 'true' } );
 			domElement = createElement( document, 'div', { contenteditable: 'true' } );
@@ -539,6 +542,7 @@ describe( 'view', () => {
 
 
 			domSelection = document.getSelection();
 			domSelection = document.getSelection();
 			domP = domRoot.childNodes[ 0 ];
 			domP = domRoot.childNodes[ 0 ];
+			domText = domP.childNodes[ 0 ];
 		} );
 		} );
 
 
 		afterEach( () => {
 		afterEach( () => {
@@ -548,38 +552,40 @@ describe( 'view', () => {
 		it( 'should be true if selection is inside a DOM root element', done => {
 		it( 'should be true if selection is inside a DOM root element', done => {
 			domRoot.focus();
 			domRoot.focus();
 
 
-			setTimeout( () => {
-				domSelection.collapse( domP, 0 );
+			// Both selection need to stay in sync to avoid inf selection loops
+			// as there's no editing pipeline that would ensure that the view selection
+			// gets changed based on the selectionChange event. See https://github.com/ckeditor/ckeditor5/issues/6655.
+			viewDocument.selection._setTo( viewText, 1 );
+			domSelection.collapse( domText, 1 );
 
 
-				// Wait for async selectionchange event on DOM document.
-				setTimeout( () => {
-					expect( view.hasDomSelection ).to.be.true;
+			// Wait for async selectionchange event on DOM document.
+			setTimeout( () => {
+				expect( view.hasDomSelection ).to.be.true;
 
 
-					done();
-				}, 10 );
-			}, 10 );
+				done();
+			}, 1000 );
 		} );
 		} );
 
 
 		it( 'should be true if selection is inside a DOM root element - no focus', done => {
 		it( 'should be true if selection is inside a DOM root element - no focus', done => {
 			domRoot.focus();
 			domRoot.focus();
 
 
+			// See the previous test.
+			viewDocument.selection._setTo( viewText, 1 );
+			domSelection.collapse( domText, 1 );
+
 			setTimeout( () => {
 			setTimeout( () => {
-				domSelection.collapse( domP, 0 );
+				// We could also do domRoot.blur() here but it's always better to know where the focus went.
+				// E.g. if it went to some <input>, the selection would disappear from the editor and the test would fail.
+				domRoot.blur();
 
 
+				// Wait for async selectionchange event on DOM document.
 				setTimeout( () => {
 				setTimeout( () => {
-					// We could also do domRoot.blur() here but it's always better to know where the focus went.
-					// E.g. if it went to some <input>, the selection would disappear from the editor and the test would fail.
-					domRoot.blur();
-
-					// Wait for async selectionchange event on DOM document.
-					setTimeout( () => {
-						expect( view.hasDomSelection ).to.be.true;
-						expect( view.document.isFocused ).to.be.false;
-
-						done();
-					}, 10 );
-				}, 10 );
-			}, 10 );
+					expect( view.hasDomSelection ).to.be.true;
+					expect( view.document.isFocused ).to.be.false;
+
+					done();
+				}, 100 );
+			}, 100 );
 		} );
 		} );
 
 
 		it( 'should be false if selection is outside DOM root element', done => {
 		it( 'should be false if selection is outside DOM root element', done => {

+ 2 - 1
packages/ckeditor5-media-embed/package.json

@@ -26,7 +26,8 @@
     "@ckeditor/ckeditor5-link": "^19.0.0",
     "@ckeditor/ckeditor5-link": "^19.0.0",
     "@ckeditor/ckeditor5-list": "^19.0.0",
     "@ckeditor/ckeditor5-list": "^19.0.0",
     "@ckeditor/ckeditor5-paragraph": "^19.0.0",
     "@ckeditor/ckeditor5-paragraph": "^19.0.0",
-    "@ckeditor/ckeditor5-typing": "^19.0.0"
+    "@ckeditor/ckeditor5-typing": "^19.0.0",
+    "@ckeditor/ckeditor5-table": "^19.0.0"
   },
   },
   "engines": {
   "engines": {
     "node": ">=8.0.0",
     "node": ">=8.0.0",

+ 7 - 0
packages/ckeditor5-media-embed/src/automediaembed.js

@@ -129,11 +129,15 @@ export default class AutoMediaEmbed extends Plugin {
 
 
 		// If the URL does not match to universal URL regexp, let's skip that.
 		// If the URL does not match to universal URL regexp, let's skip that.
 		if ( !url.match( URL_REGEXP ) ) {
 		if ( !url.match( URL_REGEXP ) ) {
+			urlRange.detach();
+
 			return;
 			return;
 		}
 		}
 
 
 		// If the URL represents a media, let's use it.
 		// If the URL represents a media, let's use it.
 		if ( !mediaRegistry.hasMedia( url ) ) {
 		if ( !mediaRegistry.hasMedia( url ) ) {
+			urlRange.detach();
+
 			return;
 			return;
 		}
 		}
 
 
@@ -141,6 +145,8 @@ export default class AutoMediaEmbed extends Plugin {
 
 
 		// Do not anything if media element cannot be inserted at the current position (#47).
 		// Do not anything if media element cannot be inserted at the current position (#47).
 		if ( !mediaEmbedCommand.isEnabled ) {
 		if ( !mediaEmbedCommand.isEnabled ) {
+			urlRange.detach();
+
 			return;
 			return;
 		}
 		}
 
 
@@ -153,6 +159,7 @@ export default class AutoMediaEmbed extends Plugin {
 				this._timeoutId = null;
 				this._timeoutId = null;
 
 
 				writer.remove( urlRange );
 				writer.remove( urlRange );
+				urlRange.detach();
 
 
 				let insertionPosition;
 				let insertionPosition;
 
 

+ 34 - 0
packages/ckeditor5-media-embed/tests/automediaembed.js

@@ -17,6 +17,7 @@ import Undo from '@ckeditor/ckeditor5-undo/src/undo';
 import Typing from '@ckeditor/ckeditor5-typing/src/typing';
 import Typing from '@ckeditor/ckeditor5-typing/src/typing';
 import Image from '@ckeditor/ckeditor5-image/src/image';
 import Image from '@ckeditor/ckeditor5-image/src/image';
 import ImageCaption from '@ckeditor/ckeditor5-image/src/imagecaption';
 import ImageCaption from '@ckeditor/ckeditor5-image/src/imagecaption';
+import Table from '@ckeditor/ckeditor5-table/src/table';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
 
@@ -573,6 +574,39 @@ describe( 'AutoMediaEmbed - integration', () => {
 		} );
 		} );
 	} );
 	} );
 
 
+	it( 'should detach LiveRange', async () => {
+		const editor = await ClassicTestEditor.create( editorElement, {
+			plugins: [ MediaEmbed, AutoMediaEmbed, Link, List, Bold, Typing, Image, ImageCaption, Table ]
+		} );
+
+		setData(
+			editor.model,
+			'<table>' +
+				'<tableRow>' +
+					'[<tableCell><paragraph>foo</paragraph></tableCell>]' +
+					'[<tableCell><paragraph>bar</paragraph></tableCell>]' +
+				'</tableRow>' +
+			'</table>'
+		);
+
+		pasteHtml( editor, '<table><tr><td>one</td><td>two</td></tr></table>' );
+
+		expect( getData( editor.model, { withoutSelection: true } ) ).to.equal(
+			'<table>' +
+				'<tableRow>' +
+					'<tableCell><paragraph>one</paragraph></tableCell>' +
+					'<tableCell><paragraph>two</paragraph></tableCell>' +
+				'</tableRow>' +
+			'</table>'
+		);
+
+		expect( () => {
+			editor.setData( '' );
+		} ).not.to.throw();
+
+		await editor.destroy();
+	} );
+
 	function simulateTyping( text ) {
 	function simulateTyping( text ) {
 		// While typing, every character is an atomic change.
 		// While typing, every character is an atomic change.
 		text.split( '' ).forEach( character => {
 		text.split( '' ).forEach( character => {

+ 12 - 3
packages/ckeditor5-table/src/commands/setheadercolumncommand.js

@@ -10,7 +10,7 @@
 import Command from '@ckeditor/ckeditor5-core/src/command';
 import Command from '@ckeditor/ckeditor5-core/src/command';
 
 
 import { findAncestor, isHeadingColumnCell, updateNumericAttribute } from './utils';
 import { findAncestor, isHeadingColumnCell, updateNumericAttribute } from './utils';
-import { getColumnIndexes, getSelectionAffectedTableCells } from '../utils';
+import { getColumnIndexes, getSelectionAffectedTableCells, getHorizontallyOverlappingCells, splitVertically } from '../utils';
 
 
 /**
 /**
  * The header column command.
  * The header column command.
@@ -69,12 +69,21 @@ export default class SetHeaderColumnCommand extends Command {
 
 
 		const model = this.editor.model;
 		const model = this.editor.model;
 		const selectedCells = getSelectionAffectedTableCells( model.document.selection );
 		const selectedCells = getSelectionAffectedTableCells( model.document.selection );
-		const { first, last } = getColumnIndexes( selectedCells );
+		const table = findAncestor( 'table', selectedCells[ 0 ] );
 
 
+		const { first, last } = getColumnIndexes( selectedCells );
 		const headingColumnsToSet = this.value ? first : last + 1;
 		const headingColumnsToSet = this.value ? first : last + 1;
 
 
 		model.change( writer => {
 		model.change( writer => {
-			const table = findAncestor( 'table', selectedCells[ 0 ] );
+			if ( headingColumnsToSet ) {
+				// Changing heading columns requires to check if any of a heading cell is overlapping horizontally the table head.
+				// Any table cell that has a colspan attribute > 1 will not exceed the table head so we need to fix it in columns before.
+				const overlappingCells = getHorizontallyOverlappingCells( table, headingColumnsToSet );
+
+				for ( const { cell, column } of overlappingCells ) {
+					splitVertically( cell, column, headingColumnsToSet, writer );
+				}
+			}
 
 
 			updateNumericAttribute( 'headingColumns', headingColumnsToSet, table, writer, 0 );
 			updateNumericAttribute( 'headingColumns', headingColumnsToSet, table, writer, 0 );
 		} );
 		} );

+ 5 - 79
packages/ckeditor5-table/src/commands/setheaderrowcommand.js

@@ -9,9 +9,8 @@
 
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
 import Command from '@ckeditor/ckeditor5-core/src/command';
 
 
-import { createEmptyTableCell, findAncestor, updateNumericAttribute } from './utils';
-import { getRowIndexes, getSelectionAffectedTableCells } from '../utils';
-import TableWalker from '../tablewalker';
+import { findAncestor, updateNumericAttribute } from './utils';
+import { getVerticallyOverlappingCells, getRowIndexes, getSelectionAffectedTableCells, splitHorizontally } from '../utils';
 
 
 /**
 /**
  * The header row command.
  * The header row command.
@@ -77,9 +76,10 @@ export default class SetHeaderRowCommand extends Command {
 			if ( headingRowsToSet ) {
 			if ( headingRowsToSet ) {
 				// Changing heading rows requires to check if any of a heading cell is overlapping vertically the table head.
 				// Changing heading rows requires to check if any of a heading cell is overlapping vertically the table head.
 				// Any table cell that has a rowspan attribute > 1 will not exceed the table head so we need to fix it in rows below.
 				// Any table cell that has a rowspan attribute > 1 will not exceed the table head so we need to fix it in rows below.
-				const cellsToSplit = getOverlappingCells( table, headingRowsToSet, currentHeadingRows );
+				const startRow = headingRowsToSet > currentHeadingRows ? currentHeadingRows : 0;
+				const overlappingCells = getVerticallyOverlappingCells( table, headingRowsToSet, startRow );
 
 
-				for ( const cell of cellsToSplit ) {
+				for ( const { cell } of overlappingCells ) {
 					splitHorizontally( cell, headingRowsToSet, writer );
 					splitHorizontally( cell, headingRowsToSet, writer );
 				}
 				}
 			}
 			}
@@ -102,77 +102,3 @@ export default class SetHeaderRowCommand extends Command {
 		return !!headingRows && tableCell.parent.index < headingRows;
 		return !!headingRows && tableCell.parent.index < headingRows;
 	}
 	}
 }
 }
-
-// Returns cells that span beyond the new heading section.
-//
-// @param {module:engine/model/element~Element} table The table to check.
-// @param {Number} headingRowsToSet New heading rows attribute.
-// @param {Number} currentHeadingRows Current heading rows attribute.
-// @returns {Array.<module:engine/model/element~Element>}
-function getOverlappingCells( table, headingRowsToSet, currentHeadingRows ) {
-	const cellsToSplit = [];
-
-	const startAnalysisRow = headingRowsToSet > currentHeadingRows ? currentHeadingRows : 0;
-	// We're analyzing only when headingRowsToSet > 0.
-	const endAnalysisRow = headingRowsToSet - 1;
-
-	const tableWalker = new TableWalker( table, { startRow: startAnalysisRow, endRow: endAnalysisRow } );
-
-	for ( const { row, rowspan, cell } of tableWalker ) {
-		if ( rowspan > 1 && row + rowspan > headingRowsToSet ) {
-			cellsToSplit.push( cell );
-		}
-	}
-
-	return cellsToSplit;
-}
-
-// Splits the table cell horizontally.
-//
-// @param {module:engine/model/element~Element} tableCell
-// @param {Number} headingRows
-// @param {module:engine/model/writer~Writer} writer
-function splitHorizontally( tableCell, headingRows, writer ) {
-	const tableRow = tableCell.parent;
-	const table = tableRow.parent;
-	const rowIndex = tableRow.index;
-
-	const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) );
-	const newRowspan = headingRows - rowIndex;
-
-	const attributes = {};
-
-	const spanToSet = rowspan - newRowspan;
-
-	if ( spanToSet > 1 ) {
-		attributes.rowspan = spanToSet;
-	}
-
-	const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
-
-	if ( colspan > 1 ) {
-		attributes.colspan = colspan;
-	}
-
-	const startRow = table.getChildIndex( tableRow );
-	const endRow = startRow + newRowspan;
-	const tableMap = [ ...new TableWalker( table, { startRow, endRow, includeSpanned: true } ) ];
-
-	let columnIndex;
-
-	for ( const { row, column, cell, cellIndex } of tableMap ) {
-		if ( cell === tableCell && columnIndex === undefined ) {
-			columnIndex = column;
-		}
-
-		if ( columnIndex !== undefined && columnIndex === column && row === endRow ) {
-			const tableRow = table.getChild( row );
-			const tableCellPosition = writer.createPositionAt( tableRow, cellIndex );
-
-			createEmptyTableCell( writer, tableCellPosition, attributes );
-		}
-	}
-
-	// Update the rowspan attribute after updating table.
-	updateNumericAttribute( 'rowspan', newRowspan, tableCell, writer );
-}

+ 270 - 51
packages/ckeditor5-table/src/tableclipboard.js

@@ -11,9 +11,18 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 
 
 import TableSelection from './tableselection';
 import TableSelection from './tableselection';
 import TableWalker from './tablewalker';
 import TableWalker from './tablewalker';
-import { getColumnIndexes, getRowIndexes, getSelectionAffectedTableCells, isSelectionRectangular } from './utils';
+import {
+	getColumnIndexes,
+	getVerticallyOverlappingCells,
+	getRowIndexes,
+	getSelectionAffectedTableCells,
+	getHorizontallyOverlappingCells,
+	isSelectionRectangular,
+	splitHorizontally,
+	splitVertically
+} from './utils';
 import { findAncestor } from './commands/utils';
 import { findAncestor } from './commands/utils';
-import { cropTableToDimensions } from './tableselection/croptable';
+import { cropTableToDimensions, trimTableCellIfNeeded } from './tableselection/croptable';
 import TableUtils from './tableutils';
 import TableUtils from './tableutils';
 
 
 /**
 /**
@@ -110,9 +119,6 @@ export default class TableClipboard extends Plugin {
 			return;
 			return;
 		}
 		}
 
 
-		// Content table to which we insert a pasted table.
-		const selectedTable = findAncestor( 'table', selectedTableCells[ 0 ] );
-
 		// We might need to crop table before inserting so reference might change.
 		// We might need to crop table before inserting so reference might change.
 		let pastedTable = getTableIfOnlyTableInContent( content );
 		let pastedTable = getTableIfOnlyTableInContent( content );
 
 
@@ -123,61 +129,96 @@ export default class TableClipboard extends Plugin {
 		// Override default model.insertContent() handling at this point.
 		// Override default model.insertContent() handling at this point.
 		evt.stop();
 		evt.stop();
 
 
-		const pasteWidth = tableUtils.getColumns( pastedTable );
-		const pasteHeight = tableUtils.getRows( pastedTable );
-
 		model.change( writer => {
 		model.change( writer => {
-			let { first: firstColumnOfSelection, last: lastColumnOfSelection } = getColumnIndexes( selectedTableCells );
-			let { first: firstRowOfSelection, last: lastRowOfSelection } = getRowIndexes( selectedTableCells );
+			const columnIndexes = getColumnIndexes( selectedTableCells );
+			const rowIndexes = getRowIndexes( selectedTableCells );
+
+			let { first: firstColumnOfSelection, last: lastColumnOfSelection } = columnIndexes;
+			let { first: firstRowOfSelection, last: lastRowOfSelection } = rowIndexes;
+
+			const pasteHeight = tableUtils.getRows( pastedTable );
+			const pasteWidth = tableUtils.getColumns( pastedTable );
 
 
-			if ( selectedTableCells.length == 1 ) {
+			// Content table to which we insert a pasted table.
+			const selectedTable = findAncestor( 'table', selectedTableCells[ 0 ] );
+
+			// Single cell selected - expand selection to pasted table dimensions.
+			const shouldExpandSelection = selectedTableCells.length === 1;
+
+			if ( shouldExpandSelection ) {
 				lastRowOfSelection += pasteHeight - 1;
 				lastRowOfSelection += pasteHeight - 1;
 				lastColumnOfSelection += pasteWidth - 1;
 				lastColumnOfSelection += pasteWidth - 1;
 
 
 				expandTableSize( selectedTable, lastRowOfSelection + 1, lastColumnOfSelection + 1, writer, tableUtils );
 				expandTableSize( selectedTable, lastRowOfSelection + 1, lastColumnOfSelection + 1, writer, tableUtils );
 			}
 			}
 
 
-			// Currently not handled. The selected table content should be trimmed to a rectangular selection.
-			// See: https://github.com/ckeditor/ckeditor5/issues/6122.
-			else if ( !isSelectionRectangular( selectedTableCells, tableUtils ) ) {
-				// @if CK_DEBUG // console.log( 'NOT IMPLEMENTED YET: Selection is not rectangular (non-mergeable).' );
+			// In case of expanding selection we do not reset the selection so in this case we will always try to fix selection
+			// like in the case of a non-rectangular area. This might be fixed by re-setting selected cells array but this shortcut is safe.
+			if ( shouldExpandSelection || !isSelectionRectangular( selectedTableCells, tableUtils ) ) {
+				const splitDimensions = {
+					firstRow: firstRowOfSelection,
+					lastRow: lastRowOfSelection,
+					firstColumn: firstColumnOfSelection,
+					lastColumn: lastColumnOfSelection
+				};
 
 
-				return;
+				// For a non-rectangular selection (ie in which some cells sticks out from a virtual selection rectangle) we need to create
+				// a table layout that has a rectangular selection. This will split cells so the selection become rectangular.
+				// Beyond this point we will operate on fixed content table.
+				splitCellsToRectangularSelection( selectedTable, splitDimensions, writer );
+			}
+			// However a selected table fragment might be invalid if examined alone. Ie such table fragment:
+			//
+			//    +---+---+---+---+
+			//  0 | a | b | c | d |
+			//    +   +   +---+---+
+			//  1 |   | e | f | g |
+			//    +   +---+   +---+
+			//  2 |   | h |   | i | <- last row, each cell has rowspan = 2,
+			//    +   +   +   +   +    so we need to return 3, not 2
+			//  3 |   |   |   |   |
+			//    +---+---+---+---+
+			//
+			// is invalid as the cells "h" and "i" have rowspans.
+			// This case needs only adjusting the selection dimension as the rest of the algorithm operates on empty slots also.
+			else {
+				lastRowOfSelection = adjustLastRowIndex( selectedTable, rowIndexes, columnIndexes );
+				lastColumnOfSelection = adjustLastColumnIndex( selectedTable, rowIndexes, columnIndexes );
 			}
 			}
 
 
+			// Beyond this point we operate on a fixed content table with rectangular selection and proper last row/column values.
+
 			const selectionHeight = lastRowOfSelection - firstRowOfSelection + 1;
 			const selectionHeight = lastRowOfSelection - firstRowOfSelection + 1;
 			const selectionWidth = lastColumnOfSelection - firstColumnOfSelection + 1;
 			const selectionWidth = lastColumnOfSelection - firstColumnOfSelection + 1;
 
 
-			// The if below is temporal and will be removed when handling this case.
-			// See: https://github.com/ckeditor/ckeditor5/issues/6769.
-			if ( selectionHeight > pasteHeight || selectionWidth > pasteWidth ) {
-				// @if CK_DEBUG // console.log( 'NOT IMPLEMENTED YET: Pasted table is smaller than selection area.' );
-
-				return;
-			}
-
-			// Crop pasted table if it extends selection area.
-			if ( selectionHeight < pasteHeight || selectionWidth < pasteWidth ) {
-				const cropDimensions = {
-					startRow: 0,
-					startColumn: 0,
-					endRow: selectionHeight - 1,
-					endColumn: selectionWidth - 1
-				};
+			// Crop pasted table if:
+			// - Pasted table dimensions exceeds selection area.
+			// - Pasted table has broken layout (ie some cells sticks out by the table dimensions established by the first and last row).
+			//
+			// Note: The table dimensions are established by the width of the first row and the total number of rows.
+			// It is possible to programmatically create a table that has rows which would have cells anchored beyond first row width but
+			// such table will not be created by other editing solutions.
+			const cropDimensions = {
+				startRow: 0,
+				startColumn: 0,
+				endRow: Math.min( selectionHeight - 1, pasteHeight - 1 ),
+				endColumn: Math.min( selectionWidth - 1, pasteWidth - 1 )
+			};
 
 
-				pastedTable = cropTableToDimensions( pastedTable, cropDimensions, writer, tableUtils );
-			}
+			pastedTable = cropTableToDimensions( pastedTable, cropDimensions, writer, tableUtils );
 
 
+			const pastedDimensions = {
+				width: pasteWidth,
+				height: pasteHeight
+			};
 			const selectionDimensions = {
 			const selectionDimensions = {
 				firstColumnOfSelection,
 				firstColumnOfSelection,
 				firstRowOfSelection,
 				firstRowOfSelection,
 				lastColumnOfSelection,
 				lastColumnOfSelection,
-				lastRowOfSelection,
-				selectionHeight,
-				selectionWidth
+				lastRowOfSelection
 			};
 			};
 
 
-			replaceSelectedCellsWithPasted( pastedTable, selectedTable, selectionDimensions, writer );
+			replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selectedTable, selectionDimensions, writer );
 		} );
 		} );
 	}
 	}
 }
 }
@@ -185,23 +226,26 @@ export default class TableClipboard extends Plugin {
 // Replaces the part of selectedTable with pastedTable.
 // Replaces the part of selectedTable with pastedTable.
 //
 //
 // @param {module:engine/model/element~Element} 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 {module:engine/model/element~Element} selectedTable
 // @param {Object} selectionDimensions
 // @param {Object} selectionDimensions
 // @param {Number} selectionDimensions.firstColumnOfSelection
 // @param {Number} selectionDimensions.firstColumnOfSelection
 // @param {Number} selectionDimensions.firstRowOfSelection
 // @param {Number} selectionDimensions.firstRowOfSelection
 // @param {Number} selectionDimensions.lastColumnOfSelection
 // @param {Number} selectionDimensions.lastColumnOfSelection
 // @param {Number} selectionDimensions.lastRowOfSelection
 // @param {Number} selectionDimensions.lastRowOfSelection
-// @param {Number} selectionDimensions.selectionHeight
-// @param {Number} selectionDimensions.selectionWidth
 // @param {module:engine/model/writer~Writer} writer
 // @param {module:engine/model/writer~Writer} writer
-function replaceSelectedCellsWithPasted( pastedTable, selectedTable, selectionDimensions, writer ) {
+function replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selectedTable, selectionDimensions, writer ) {
 	const {
 	const {
-		firstColumnOfSelection, lastColumnOfSelection, selectionWidth,
-		firstRowOfSelection, lastRowOfSelection, selectionHeight
+		firstColumnOfSelection, lastColumnOfSelection,
+		firstRowOfSelection, lastRowOfSelection
 	} = selectionDimensions;
 	} = selectionDimensions;
 
 
+	const { width: pastedWidth, height: pastedHeight } = pastedDimensions;
+
 	// Holds two-dimensional array that is addressed by [ row ][ column ] that stores cells anchored at given location.
 	// Holds two-dimensional array that is addressed by [ row ][ column ] that stores cells anchored at given location.
-	const pastedTableLocationMap = createLocationMap( pastedTable, selectionWidth, selectionHeight );
+	const pastedTableLocationMap = createLocationMap( pastedTable, pastedWidth, pastedHeight );
 
 
 	const selectedTableMap = [ ...new TableWalker( selectedTable, {
 	const selectedTableMap = [ ...new TableWalker( selectedTable, {
 		startRow: firstRowOfSelection,
 		startRow: firstRowOfSelection,
@@ -245,7 +289,9 @@ function replaceSelectedCellsWithPasted( pastedTable, selectedTable, selectionDi
 		}
 		}
 
 
 		// Map current table slot location to an pasted table slot location.
 		// Map current table slot location to an pasted table slot location.
-		const pastedCell = pastedTableLocationMap[ row - firstRowOfSelection ][ column - firstColumnOfSelection ];
+		const pastedRow = row - firstRowOfSelection;
+		const pastedColumn = column - firstColumnOfSelection;
+		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.
 		// 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 ) {
 		if ( !pastedCell ) {
@@ -256,6 +302,9 @@ function replaceSelectedCellsWithPasted( pastedTable, selectedTable, selectionDi
 		// Cloning is required to support repeating pasted table content when inserting to a bigger selection.
 		// Cloning is required to support repeating pasted table content when inserting to a bigger selection.
 		const cellToInsert = pastedCell._clone( true );
 		const cellToInsert = pastedCell._clone( true );
 
 
+		// Trim the cell if it's row/col-spans would exceed selection area.
+		trimTableCellIfNeeded( cellToInsert, row, column, lastRowOfSelection, lastColumnOfSelection, writer );
+
 		let insertPosition;
 		let insertPosition;
 
 
 		if ( !previousCellInRow ) {
 		if ( !previousCellInRow ) {
@@ -272,24 +321,24 @@ function replaceSelectedCellsWithPasted( pastedTable, selectedTable, selectionDi
 	writer.setSelection( cellsToSelect.map( cell => writer.createRangeOn( cell ) ) );
 	writer.setSelection( cellsToSelect.map( cell => writer.createRangeOn( cell ) ) );
 }
 }
 
 
-// Expand table (in place) to expected size (rows and columns).
-function expandTableSize( table, rows, columns, writer, tableUtils ) {
+// Expand table (in place) to expected size.
+function expandTableSize( table, expectedHeight, expectedWidth, writer, tableUtils ) {
 	const tableWidth = tableUtils.getColumns( table );
 	const tableWidth = tableUtils.getColumns( table );
 	const tableHeight = tableUtils.getRows( table );
 	const tableHeight = tableUtils.getRows( table );
 
 
-	if ( columns > tableWidth ) {
+	if ( expectedWidth > tableWidth ) {
 		tableUtils.insertColumns( table, {
 		tableUtils.insertColumns( table, {
 			batch: writer.batch,
 			batch: writer.batch,
 			at: tableWidth,
 			at: tableWidth,
-			columns: columns - tableWidth
+			columns: expectedWidth - tableWidth
 		} );
 		} );
 	}
 	}
 
 
-	if ( rows > tableHeight ) {
+	if ( expectedHeight > tableHeight ) {
 		tableUtils.insertRows( table, {
 		tableUtils.insertRows( table, {
 			batch: writer.batch,
 			batch: writer.batch,
 			at: tableHeight,
 			at: tableHeight,
-			rows: rows - tableHeight
+			rows: expectedHeight - tableHeight
 		} );
 		} );
 	}
 	}
 }
 }
@@ -349,3 +398,173 @@ function createLocationMap( table, width, height ) {
 
 
 	return map;
 	return map;
 }
 }
+
+// Make selected cells rectangular by splitting the cells that stand out from a rectangular selection.
+//
+// In the table below a selection is shown with "::" and slots with anchor cells are named.
+//
+// +----+----+----+----+----+                    +----+----+----+----+----+
+// | 00 | 01 | 02 | 03      |                    | 00 | 01 | 02 | 03      |
+// +    +----+    +----+----+                    |    ::::::::::::::::----+
+// |    | 11 |    | 13 | 14 |                    |    ::11 |    | 13:: 14 |    <- first row
+// +----+----+    +    +----+                    +----::---|    |   ::----+
+// | 20 | 21 |    |    | 24 |   select cells:    | 20 ::21 |    |   :: 24 |
+// +----+----+    +----+----+     11 -> 33       +----::---|    |---::----+
+// | 30      |    | 33 | 34 |                    | 30 ::   |    | 33:: 34 |    <- last row
+// +         +    +----+    +                    |    ::::::::::::::::    +
+// |         |    | 43 |    |                    |         |    | 43 |    |
+// +----+----+----+----+----+                    +----+----+----+----+----+
+//                                                      ^          ^
+//                                                     first & last columns
+//
+// Will update table to:
+//
+//                       +----+----+----+----+----+
+//                       | 00 | 01 | 02 | 03      |
+//                       +    +----+----+----+----+
+//                       |    | 11 |    | 13 | 14 |
+//                       +----+----+    +    +----+
+//                       | 20 | 21 |    |    | 24 |
+//                       +----+----+    +----+----+
+//                       | 30 |    |    | 33 | 34 |
+//                       +    +----+----+----+    +
+//                       |    |    |    | 43 |    |
+//                       +----+----+----+----+----+
+//
+// In th example above:
+// - Cell "02" which have `rowspan = 4` must be trimmed at first and at after last row.
+// - Cell "03" which have `rowspan = 2` and `colspan = 2` must be trimmed at first column and after last row.
+// - Cells "00", "03" & "30" which cannot be cut by this algorithm as they are outside the trimmed area.
+// - Cell "13" cannot be cut as it is inside the trimmed area.
+function splitCellsToRectangularSelection( table, dimensions, writer ) {
+	const { firstRow, lastRow, firstColumn, lastColumn } = dimensions;
+
+	const rowIndexes = { first: firstRow, last: lastRow };
+	const columnIndexes = { first: firstColumn, last: lastColumn };
+
+	// 1. Split cells vertically in two steps as first step might create cells that needs to split again.
+	doVerticalSplit( table, firstColumn, rowIndexes, writer );
+	doVerticalSplit( table, lastColumn + 1, rowIndexes, writer );
+
+	// 2. Split cells horizontally in two steps as first step might create cells that needs to split again.
+	doHorizontalSplit( table, firstRow, columnIndexes, writer );
+	doHorizontalSplit( table, lastRow + 1, columnIndexes, writer, firstRow );
+}
+
+function doHorizontalSplit( table, splitRow, limitColumns, writer, startRow = 0 ) {
+	// If selection starts at first row then no split is needed.
+	if ( splitRow < 1 ) {
+		return;
+	}
+
+	const overlappingCells = getVerticallyOverlappingCells( table, splitRow, startRow );
+
+	// Filter out cells that are not touching insides of the rectangular selection.
+	const cellsToSplit = overlappingCells.filter( ( { column, colspan } ) => isAffectedBySelection( column, colspan, limitColumns ) );
+
+	for ( const { cell } of cellsToSplit ) {
+		splitHorizontally( cell, splitRow, writer );
+	}
+}
+
+function doVerticalSplit( table, splitColumn, limitRows, writer ) {
+	// If selection starts at first column then no split is needed.
+	if ( splitColumn < 1 ) {
+		return;
+	}
+
+	const overlappingCells = getHorizontallyOverlappingCells( table, splitColumn );
+
+	// Filter out cells that are not touching insides of the rectangular selection.
+	const cellsToSplit = overlappingCells.filter( ( { row, rowspan } ) => isAffectedBySelection( row, rowspan, limitRows ) );
+
+	for ( const { cell, column } of cellsToSplit ) {
+		splitVertically( cell, column, splitColumn, writer );
+	}
+}
+
+// Checks if cell at given row (column) is affected by a rectangular selection defined by first/last column (row).
+//
+// The same check is used for row as for column.
+function isAffectedBySelection( index, span, limit ) {
+	const endIndex = index + span - 1;
+	const { first, last } = limit;
+
+	const isInsideSelection = index >= first && index <= last;
+	const overlapsSelectionFromOutside = index < first && endIndex >= first;
+
+	return isInsideSelection || overlapsSelectionFromOutside;
+}
+
+// Returns adjusted last row index if selection covers part of a row with empty slots (spanned by other cells).
+// The rowIndexes.last is equal to last row index but selection might be bigger.
+//
+// This happens *only* on rectangular selection so we analyze a case like this:
+//
+//    +---+---+---+---+
+//  0 | a | b | c | d |
+//    +   +   +---+---+
+//  1 |   | e | f | g |
+//    +   +---+   +---+
+//  2 |   | h |   | i | <- last row, each cell has rowspan = 2,
+//    +   +   +   +   +    so we need to return 3, not 2
+//  3 |   |   |   |   |
+//    +---+---+---+---+
+function adjustLastRowIndex( table, rowIndexes, columnIndexes ) {
+	const tableIterator = new TableWalker( table, {
+		startRow: rowIndexes.last,
+		endRow: rowIndexes.last
+	} );
+
+	const lastRowMap = Array.from( tableIterator ).filter( ( { column } ) => {
+		// Could use startColumn, endColumn. See: https://github.com/ckeditor/ckeditor5/issues/6785.
+		return columnIndexes.first <= column && column <= columnIndexes.last;
+	} );
+
+	const everyCellHasSingleRowspan = lastRowMap.every( ( { rowspan } ) => rowspan === 1 );
+
+	// It is a "flat" row, so the last row index is OK.
+	if ( everyCellHasSingleRowspan ) {
+		return rowIndexes.last;
+	}
+
+	// Otherwise get any cell's rowspan and adjust the last row index.
+	const rowspanAdjustment = lastRowMap[ 0 ].rowspan - 1;
+	return rowIndexes.last + rowspanAdjustment;
+}
+
+// Returns adjusted last column index if selection covers part of a column with empty slots (spanned by other cells).
+// The columnIndexes.last is equal to last column index but selection might be bigger.
+//
+// This happens *only* on rectangular selection so we analyze a case like this:
+//
+//   0   1   2   3
+// +---+---+---+---+
+// | a             |
+// +---+---+---+---+
+// | b | c | d     |
+// +---+---+---+---+
+// | e     | f     |
+// +---+---+---+---+
+// | g | h         |
+// +---+---+---+---+
+//           ^
+//          last column, each cell has colspan = 2, so we need to return 3, not 2
+function adjustLastColumnIndex( table, rowIndexes, columnIndexes ) {
+	const lastColumnMap = Array.from( new TableWalker( table, {
+		startRow: rowIndexes.first,
+		endRow: rowIndexes.last,
+		column: columnIndexes.last
+	} ) );
+
+	const everyCellHasSingleColspan = lastColumnMap.every( ( { colspan } ) => colspan === 1 );
+
+	// It is a "flat" column, so the last column index is OK.
+	if ( everyCellHasSingleColspan ) {
+		return columnIndexes.last;
+	}
+
+	// Otherwise get any cell's colspan and adjust the last column index.
+	const colspanAdjustment = lastColumnMap[ 0 ].colspan - 1;
+	return columnIndexes.last + colspanAdjustment;
+}

+ 14 - 5
packages/ckeditor5-table/src/tableselection/croptable.js

@@ -101,11 +101,20 @@ export function cropTableToDimensions( sourceTable, cropDimensions, writer, tabl
 	return croppedTable;
 	return croppedTable;
 }
 }
 
 
-// Adjusts table cell dimensions to not exceed limit row and column.
-//
-// If table cell span to a column (or row) that is after a limit column (or row) trim colspan (or rowspan)
-// so the table cell will fit in a cropped area.
-function trimTableCellIfNeeded( tableCell, cellRow, cellColumn, limitRow, limitColumn, writer ) {
+/**
+ * Adjusts table cell dimensions to not exceed limit row and column.
+ *
+ * If table cell width (or height) covers a column (or row) that is after a limit column (or row)
+ * this method will trim "colspan" (or "rowspan") attribute so the table cell will fit in a defined limits.
+ *
+ * @param {module:engine/model/element~Element} tableCell
+ * @param {Number} cellRow
+ * @param {Number} cellColumn
+ * @param {Number} limitRow
+ * @param {Number} limitColumn
+ * @param {module:engine/model/writer~Writer} writer
+ */
+export function trimTableCellIfNeeded( tableCell, cellRow, cellColumn, limitRow, limitColumn, writer ) {
 	const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
 	const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
 	const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
 	const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
 
 

+ 167 - 2
packages/ckeditor5-table/src/utils.js

@@ -8,7 +8,7 @@
  */
  */
 
 
 import { isWidget, toWidget } from '@ckeditor/ckeditor5-widget/src/utils';
 import { isWidget, toWidget } from '@ckeditor/ckeditor5-widget/src/utils';
-import { findAncestor } from './commands/utils';
+import { createEmptyTableCell, findAncestor, updateNumericAttribute } from './commands/utils';
 import TableWalker from './tablewalker';
 import TableWalker from './tablewalker';
 
 
 /**
 /**
@@ -188,7 +188,7 @@ export function getColumnIndexes( tableCells ) {
  *   │ a │ b │ c │ d │
  *   │ a │ b │ c │ d │
  *   ├───┴───┼───┤   │
  *   ├───┴───┼───┤   │
  *   │ e     │ f │   │
  *   │ e     │ f │   │
- *          ├───┼───┤
+ *          ├───┼───┤
  *   │       │ g │ h │
  *   │       │ g │ h │
  *   └───────┴───┴───┘
  *   └───────┴───┴───┘
  *
  *
@@ -247,6 +247,171 @@ export function isSelectionRectangular( selectedTableCells, tableUtils ) {
 	return areaOfValidSelection == areaOfSelectedCells;
 	return areaOfValidSelection == areaOfSelectedCells;
 }
 }
 
 
+/**
+ * Returns slot info of cells that starts above and overlaps a given row.
+ *
+ * In a table below, passing `overlapRow = 3`
+ *
+ *       ┌───┬───┬───┬───┬───┐
+ *    0  │ a │ b │ c │ d │ e │
+ *       │   ├───┼───┼───┼───┤
+ *    1  │   │ f │ g │ h │ i │
+ *       ├───┤   ├───┼───┤   │
+ *    2  │ j │   │ k │ l │   │
+ *       │   │   │   ├───┼───┤
+ *    3  │   │   │   │ m │ n │  <- overlap row to check
+ *       ├───┼───┤   │   ├───│
+ *    4  │ o │ p │   │   │ q │
+ *       └───┴───┴───┴───┴───┘
+ *
+ * will return slot info for cells: "j", "f", "k".
+ *
+ * @param {module:engine/model/element~Element} table The table to check.
+ * @param {Number} overlapRow The index of the row to check.
+ * @param {Number} [startRow=0] A row to start analysis. Use it when it is known that the cells above that row will not overlap.
+ * @returns {Array.<module:table/tablewalker~TableWalkerValue>}
+ */
+export function getVerticallyOverlappingCells( table, overlapRow, startRow = 0 ) {
+	const cells = [];
+
+	const tableWalker = new TableWalker( table, { startRow, endRow: overlapRow - 1 } );
+
+	for ( const slotInfo of tableWalker ) {
+		const { row, rowspan } = slotInfo;
+		const cellEndRow = row + rowspan - 1;
+
+		if ( row < overlapRow && overlapRow <= cellEndRow ) {
+			cells.push( slotInfo );
+		}
+	}
+
+	return cells;
+}
+
+/**
+ * Splits the table cell horizontally.
+ *
+ * @param {module:engine/model/element~Element} tableCell
+ * @param {Number} splitRow
+ * @param {module:engine/model/writer~Writer} writer
+ */
+export function splitHorizontally( tableCell, splitRow, writer ) {
+	const tableRow = tableCell.parent;
+	const table = tableRow.parent;
+	const rowIndex = tableRow.index;
+
+	const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) );
+	const newRowspan = splitRow - rowIndex;
+
+	const newCellAttributes = {};
+	const newCellRowSpan = rowspan - newRowspan;
+
+	if ( newCellRowSpan > 1 ) {
+		newCellAttributes.rowspan = newCellRowSpan;
+	}
+
+	const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
+
+	if ( colspan > 1 ) {
+		newCellAttributes.colspan = colspan;
+	}
+
+	const startRow = rowIndex;
+	const endRow = startRow + newRowspan;
+	const tableMap = [ ...new TableWalker( table, { startRow, endRow, includeSpanned: true } ) ];
+
+	let columnIndex;
+
+	for ( const { row, column, cell, cellIndex } of tableMap ) {
+		if ( cell === tableCell && columnIndex === undefined ) {
+			columnIndex = column;
+		}
+
+		if ( columnIndex !== undefined && columnIndex === column && row === endRow ) {
+			const tableRow = table.getChild( row );
+			const tableCellPosition = writer.createPositionAt( tableRow, cellIndex );
+
+			createEmptyTableCell( writer, tableCellPosition, newCellAttributes );
+		}
+	}
+
+	// Update the rowspan attribute after updating table.
+	updateNumericAttribute( 'rowspan', newRowspan, tableCell, writer );
+}
+
+/**
+ * Returns slot info of cells that starts before and overlaps a given column.
+ *
+ * In a table below, passing `overlapColumn = 3`
+ *
+ *      0   1   2   3   4
+ *    ┌───────┬───────┬───┐
+ *    │ a     │ b     │ c │
+ *    │───┬───┴───────┼───┤
+ *    │ d │ e         │ f │
+ *    ├───┼───┬───────┴───┤
+ *    │ g │ h │ i         │
+ *    ├───┼───┼───┬───────┤
+ *    │ j │ k │ l │ m     │
+ *    ├───┼───┴───┼───┬───┤
+ *    │ n │ o     │ p │ q │
+ *    └───┴───────┴───┴───┘
+ *                  ^
+ *                  Overlap column to check
+ *
+ * will return slot info for cells: "b", "e", "i".
+ *
+ * @param {module:engine/model/element~Element} table The table to check.
+ * @param {Number} overlapColumn The index of the column to check.
+ * @returns {Array.<module:table/tablewalker~TableWalkerValue>}
+ */
+export function getHorizontallyOverlappingCells( table, overlapColumn ) {
+	const cellsToSplit = [];
+
+	const tableWalker = new TableWalker( table );
+
+	for ( const slotInfo of tableWalker ) {
+		const { column, colspan } = slotInfo;
+		const cellEndColumn = column + colspan - 1;
+
+		if ( column < overlapColumn && overlapColumn <= cellEndColumn ) {
+			cellsToSplit.push( slotInfo );
+		}
+	}
+
+	return cellsToSplit;
+}
+
+/**
+ * Splits the table cell vertically.
+ *
+ * @param {module:engine/model/element~Element} tableCell
+ * @param {Number} columnIndex The table cell column index.
+ * @param {Number} splitColumn The index of column to split cell on.
+ * @param {module:engine/model/writer~Writer} writer
+ */
+export function splitVertically( tableCell, columnIndex, splitColumn, writer ) {
+	const colspan = parseInt( tableCell.getAttribute( 'colspan' ) );
+	const newColspan = splitColumn - columnIndex;
+
+	const newCellAttributes = {};
+	const newCellColSpan = colspan - newColspan;
+
+	if ( newCellColSpan > 1 ) {
+		newCellAttributes.colspan = newCellColSpan;
+	}
+
+	const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
+
+	if ( rowspan > 1 ) {
+		newCellAttributes.rowspan = rowspan;
+	}
+
+	createEmptyTableCell( writer, writer.createPositionAfter( tableCell ), newCellAttributes );
+	// Update the colspan attribute after updating table.
+	updateNumericAttribute( 'colspan', newColspan, tableCell, writer );
+}
+
 // Helper method to get an object with `first` and `last` indexes from an unsorted array of indexes.
 // Helper method to get an object with `first` and `last` indexes from an unsorted array of indexes.
 function getFirstLastIndexesObject( indexes ) {
 function getFirstLastIndexesObject( indexes ) {
 	const allIndexesSorted = indexes.sort( ( indexA, indexB ) => indexA - indexB );
 	const allIndexesSorted = indexes.sort( ( indexA, indexB ) => indexA - indexB );

+ 104 - 2
packages/ckeditor5-table/tests/commands/setheadercolumncommand.js

@@ -198,6 +198,18 @@ describe( 'SetHeaderColumnCommand', () => {
 			], { headingColumns: 1 } ) );
 			], { headingColumns: 1 } ) );
 		} );
 		} );
 
 
+		it( 'should remove "headingColumns" attribute from table if no value was given', () => {
+			setData( model, modelTable( [
+				[ '[]00', '01', '02', '03' ]
+			], { headingColumns: 3 } ) );
+
+			command.execute();
+
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ '[]00', '01', '02', '03' ]
+			] ) );
+		} );
+
 		describe( 'multi-cell selection', () => {
 		describe( 'multi-cell selection', () => {
 			describe( 'setting header', () => {
 			describe( 'setting header', () => {
 				it( 'should set it correctly in a middle of single-row, multiple cell selection', () => {
 				it( 'should set it correctly in a middle of single-row, multiple cell selection', () => {
@@ -404,7 +416,7 @@ describe( 'SetHeaderColumnCommand', () => {
 			], { headingColumns: 2 } ) );
 			], { headingColumns: 2 } ) );
 		} );
 		} );
 
 
-		it( 'should respect forceValue parameter #1', () => {
+		it( 'should respect forceValue parameter (forceValue=true)', () => {
 			setData( model, modelTable( [
 			setData( model, modelTable( [
 				[ '00', '01[]', '02', '03' ]
 				[ '00', '01[]', '02', '03' ]
 			], { headingColumns: 3 } ) );
 			], { headingColumns: 3 } ) );
@@ -416,7 +428,7 @@ describe( 'SetHeaderColumnCommand', () => {
 			], { headingColumns: 3 } ) );
 			], { headingColumns: 3 } ) );
 		} );
 		} );
 
 
-		it( 'should respect forceValue parameter #2', () => {
+		it( 'should respect forceValue parameter (forceValue=false)', () => {
 			setData( model, modelTable( [
 			setData( model, modelTable( [
 				[ '00', '01[]', '02', '03' ]
 				[ '00', '01[]', '02', '03' ]
 			], { headingColumns: 1 } ) );
 			], { headingColumns: 1 } ) );
@@ -427,5 +439,95 @@ describe( 'SetHeaderColumnCommand', () => {
 				[ '00', '01[]', '02', '03' ]
 				[ '00', '01[]', '02', '03' ]
 			], { headingColumns: 1 } ) );
 			], { headingColumns: 1 } ) );
 		} );
 		} );
+
+		it( 'should fix col-spanned cells on the edge of an table heading columns section', () => {
+			// +----+----+----+
+			// | 00 | 01      |
+			// +----+         +
+			// | 10 |         |
+			// +----+----+----+
+			// | 20 | 21 | 22 |
+			// +----+----+----+
+			//      ^-- heading columns
+			setData( model, modelTable( [
+				[ '00', { contents: '[]01', colspan: 2, rowspan: 2 } ],
+				[ '10' ],
+				[ '20', '21', '22' ]
+			], { headingColumns: 1 } ) );
+
+			command.execute();
+
+			// +----+----+----+
+			// | 00 | 01 |    |
+			// +----+    +    +
+			// | 10 |    |    |
+			// +----+----+----+
+			// | 20 | 21 | 22 |
+			// +----+----+----+
+			//           ^-- heading columns
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ '00', { contents: '[]01', rowspan: 2 }, { contents: '', rowspan: 2 } ],
+				[ '10' ],
+				[ '20', '21', '22' ]
+			], { headingColumns: 2 } ) );
+		} );
+
+		it( 'should split to at most 2 table cells when fixing col-spanned cells on the edge of an table heading columns section', () => {
+			// +----+----+----+----+----+----+
+			// | 00 | 01                     |
+			// +----+                        +
+			// | 10 |                        |
+			// +----+----+----+----+----+----+
+			// | 20 | 21 | 22 | 23 | 24 | 25 |
+			// +----+----+----+----+----+----+
+			//      ^-- heading columns
+			setData( model, modelTable( [
+				[ '00', { contents: '01', colspan: 5, rowspan: 2 } ],
+				[ '10' ],
+				[ '20', '21', '22[]', '23', '24', '25' ]
+			], { headingColumns: 1 } ) );
+
+			command.execute();
+
+			// +----+----+----+----+----+----+
+			// | 00 | 01      |              |
+			// +----+         +              +
+			// | 10 |         |              |
+			// +----+----+----+----+----+----+
+			// | 20 | 21 | 22 | 23 | 24 | 25 |
+			// +----+----+----+----+----+----+
+			//                ^-- heading columns
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ '00', { contents: '01', colspan: 2, rowspan: 2 }, { contents: '', colspan: 3, rowspan: 2 } ],
+				[ '10' ],
+				[ '20', '21', '22[]', '23', '24', '25' ]
+			], { headingColumns: 3 } ) );
+		} );
+
+		it( 'should fix col-spanned cells on the edge of an table heading columns section when creating section', () => {
+			// +----+----+
+			// | 00      |
+			// +----+----+
+			// | 10 | 11 |
+			// +----+----+
+			//           ^-- heading columns
+			setData( model, modelTable( [
+				[ { contents: '00', colspan: 2 } ],
+				[ '10', '[]11' ]
+			], { headingColumns: 2 } ) );
+
+			command.execute();
+
+			// +----+----+
+			// | 00 |    |
+			// +----+----+
+			// | 10 | 11 |
+			// +----+----+
+			//      ^-- heading columns
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ '00', '' ],
+				[ '10', '[]11' ]
+			], { headingColumns: 1 } ) );
+		} );
 	} );
 	} );
 } );
 } );

+ 3 - 3
packages/ckeditor5-table/tests/commands/setheaderrowcommand.js

@@ -225,7 +225,7 @@ describe( 'SetHeaderRowCommand', () => {
 			], { headingRows: 1 } ) );
 			], { headingRows: 1 } ) );
 		} );
 		} );
 
 
-		it( 'should unsetset heading rows attribute', () => {
+		it( 'should remove "headingRows" attribute from table if no value was given', () => {
 			setData( model, modelTable( [
 			setData( model, modelTable( [
 				[ '[]00' ],
 				[ '[]00' ],
 				[ '10' ],
 				[ '10' ],
@@ -551,7 +551,7 @@ describe( 'SetHeaderRowCommand', () => {
 			} );
 			} );
 		} );
 		} );
 
 
-		it( 'should respect forceValue parameter #1', () => {
+		it( 'should respect forceValue parameter (forceValue=true)', () => {
 			setData( model, modelTable( [
 			setData( model, modelTable( [
 				[ '00' ],
 				[ '00' ],
 				[ '[]10' ],
 				[ '[]10' ],
@@ -569,7 +569,7 @@ describe( 'SetHeaderRowCommand', () => {
 			], { headingRows: 3 } ) );
 			], { headingRows: 3 } ) );
 		} );
 		} );
 
 
-		it( 'should respect forceValue parameter #2', () => {
+		it( 'should respect forceValue parameter (forceValue=false)', () => {
 			setData( model, modelTable( [
 			setData( model, modelTable( [
 				[ '00' ],
 				[ '00' ],
 				[ '[]10' ],
 				[ '[]10' ],

+ 57 - 11
packages/ckeditor5-table/tests/manual/tablemocking.js

@@ -14,6 +14,8 @@ import { diffString } from 'json-diff';
 import { debounce } from 'lodash-es';
 import { debounce } from 'lodash-es';
 import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
 import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
 import TableWalker from '../../src/tablewalker';
 import TableWalker from '../../src/tablewalker';
+import { getSelectionAffectedTableCells } from '../../src/utils';
+import { findAncestor } from '../../src/commands/utils';
 
 
 ClassicEditor
 ClassicEditor
 	.create( document.querySelector( '#editor' ), {
 	.create( document.querySelector( '#editor' ), {
@@ -38,21 +40,32 @@ ClassicEditor
 		document.getElementById( 'set-model-data' ).addEventListener( 'click', () => {
 		document.getElementById( 'set-model-data' ).addEventListener( 'click', () => {
 			updateInputStatus();
 			updateInputStatus();
 
 
+			const table = findTable( editor );
 			const inputModelData = parseModelData( modelData.value );
 			const inputModelData = parseModelData( modelData.value );
-			setModelData( editor.model, inputModelData ? modelTable( inputModelData ) : '' );
+
+			if ( inputModelData ) {
+				const element = setModelData._parse( modelTable( inputModelData ), editor.model.schema );
+
+				editor.model.change( writer => {
+					editor.model.insertContent( element, table ? editor.model.createRangeOn( table ) : null );
+					writer.setSelection( element, 'on' );
+				} );
+
+				editor.editing.view.focus();
+			}
 		} );
 		} );
 
 
 		document.getElementById( 'get-model-data' ).addEventListener( 'click', () => {
 		document.getElementById( 'get-model-data' ).addEventListener( 'click', () => {
 			updateInputStatus();
 			updateInputStatus();
 
 
-			const table = findTable( editor );
+			const table = findTable( editor, true );
 			modelData.value = table ? prettyFormatModelTableInput( prepareModelTableInput( editor.model, table ) ) : '';
 			modelData.value = table ? prettyFormatModelTableInput( prepareModelTableInput( editor.model, table ) ) : '';
 
 
 			updateAsciiAndDiff();
 			updateAsciiAndDiff();
 		} );
 		} );
 
 
 		document.getElementById( 'renumber-cells' ).addEventListener( 'click', () => {
 		document.getElementById( 'renumber-cells' ).addEventListener( 'click', () => {
-			const table = findTable( editor );
+			const table = findTable( editor, true );
 
 
 			if ( !table ) {
 			if ( !table ) {
 				return;
 				return;
@@ -75,15 +88,15 @@ ClassicEditor
 		updateAsciiAndDiff();
 		updateAsciiAndDiff();
 
 
 		function updateAsciiAndDiff() {
 		function updateAsciiAndDiff() {
-			const table = findTable( editor );
+			const tables = getAllTables( editor );
 
 
-			if ( !table ) {
+			if ( !tables.length ) {
 				asciiOut.innerText = '-- table not found --';
 				asciiOut.innerText = '-- table not found --';
 				return;
 				return;
 			}
 			}
 
 
 			const inputModelData = parseModelData( modelData.value );
 			const inputModelData = parseModelData( modelData.value );
-			const currentModelData = prepareModelTableInput( editor.model, table );
+			const currentModelData = prepareModelTableInput( editor.model, tables[ 0 ] );
 
 
 			const diffOutput = inputModelData ? diffString( inputModelData, currentModelData, {
 			const diffOutput = inputModelData ? diffString( inputModelData, currentModelData, {
 				theme: {
 				theme: {
@@ -93,20 +106,53 @@ ClassicEditor
 				}
 				}
 			} ) : '-- no input --';
 			} ) : '-- no input --';
 
 
-			asciiOut.innerHTML = createTableAsciiArt( editor.model, table ) + '\n\n' +
-				'Diff: input vs post-fixed model:\n' + ( diffOutput ? diffOutput : '-- no differences --' );
+			const asciiArt = tables
+				.map( table => createTableAsciiArt( editor.model, table ) )
+				.join( '\n\n' );
+
+			asciiOut.innerHTML = asciiArt + '\n\n' +
+				'Diff: input vs post-fixed model (only first table):\n' + ( diffOutput ? diffOutput : '-- no differences --' );
 		}
 		}
 
 
-		function findTable( editor ) {
+		function findTable( editor, useAnyTable = false ) {
+			const selection = editor.model.document.selection;
+
+			const tableCells = getSelectionAffectedTableCells( selection );
+
+			if ( tableCells.length ) {
+				return findAncestor( 'table', tableCells[ 0 ] );
+			}
+
+			const element = selection.getSelectedElement();
+
+			if ( element && element.is( 'table' ) ) {
+				return element;
+			}
+
+			if ( useAnyTable ) {
+				const range = editor.model.createRangeIn( editor.model.document.getRoot() );
+
+				for ( const element of range.getItems() ) {
+					if ( element.is( 'table' ) ) {
+						return element;
+					}
+				}
+			}
+
+			return null;
+		}
+
+		function getAllTables( editor ) {
 			const range = editor.model.createRangeIn( editor.model.document.getRoot() );
 			const range = editor.model.createRangeIn( editor.model.document.getRoot() );
+			const tables = [];
 
 
 			for ( const element of range.getItems() ) {
 			for ( const element of range.getItems() ) {
 				if ( element.is( 'table' ) ) {
 				if ( element.is( 'table' ) ) {
-					return element;
+					tables.push( element );
 				}
 				}
 			}
 			}
 
 
-			return null;
+			return tables;
 		}
 		}
 
 
 		function parseModelData( string ) {
 		function parseModelData( string ) {

文件差异内容过多而无法显示
+ 1362 - 180
packages/ckeditor5-table/tests/tableclipboard-paste.js


+ 89 - 1
packages/ckeditor5-table/tests/utils.js

@@ -13,7 +13,7 @@ import { modelTable } from './_utils/utils';
 import {
 import {
 	getSelectedTableCells,
 	getSelectedTableCells,
 	getTableCellsContainingSelection,
 	getTableCellsContainingSelection,
-	getSelectionAffectedTableCells
+	getSelectionAffectedTableCells, getVerticallyOverlappingCells, getHorizontallyOverlappingCells
 } from '../src/utils';
 } from '../src/utils';
 
 
 describe( 'table utils', () => {
 describe( 'table utils', () => {
@@ -359,4 +359,92 @@ describe( 'table utils', () => {
 			expect( getSelectionAffectedTableCells( selection ) ).to.be.empty;
 			expect( getSelectionAffectedTableCells( selection ) ).to.be.empty;
 		} );
 		} );
 	} );
 	} );
+
+	describe( 'getVerticallyOverlappingCells()', () => {
+		let table;
+
+		beforeEach( () => {
+			// +----+----+----+----+----+
+			// | 00 | 01 | 02 | 03 | 04 |
+			// +    +    +----+    +----+
+			// |    |    | 12 |    | 14 |
+			// +    +    +    +----+----+
+			// |    |    |    | 23 | 24 |
+			// +    +----+    +    +----+
+			// |    | 31 |    |    | 34 |
+			// +    +    +----+----+----+
+			// |    |    | 42 | 43 | 44 |
+			// +----+----+----+----+----+
+			setModelData( model, modelTable( [
+				[ { contents: '00', rowspan: 5 }, { contents: '01', rowspan: 3 }, '02', { contents: '03', rowspan: 2 }, '04' ],
+				[ { contents: '12', rowspan: 3 }, '14' ],
+				[ { contents: '23', rowspan: 2 }, '24' ],
+				[ { contents: '31', rowspan: 2 }, '34' ],
+				[ '42', '43', '44' ]
+			] ) );
+
+			table = modelRoot.getChild( 0 );
+		} );
+
+		it( 'should return empty array for no overlapping cells', () => {
+			const cellsInfo = getVerticallyOverlappingCells( table, 0 );
+
+			expect( cellsInfo ).to.be.empty;
+		} );
+
+		it( 'should return overlapping cells info for given overlapRow', () => {
+			const cellsInfo = getVerticallyOverlappingCells( table, 2 );
+
+			expect( cellsInfo[ 0 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) ); // Cell 00
+			expect( cellsInfo[ 1 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) ); // Cell 01
+			expect( cellsInfo[ 2 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 1, 0 ] ) ); // Cell 12
+		} );
+
+		it( 'should ignore rows below startRow', () => {
+			const cellsInfo = getVerticallyOverlappingCells( table, 2, 1 );
+
+			expect( cellsInfo[ 0 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 1, 0 ] ) ); // Cell 12
+		} );
+	} );
+
+	describe( 'getHorizontallyOverlappingCells()', () => {
+		let table;
+
+		beforeEach( () => {
+			// +----+----+----+----+----+
+			// | 00                     |
+			// +----+----+----+----+----+
+			// | 10           | 13      |
+			// +----+----+----+----+----+
+			// | 20 | 21           | 24 |
+			// +----+----+----+----+----+
+			// | 30      | 32      | 34 |
+			// +----+----+----+----+----+
+			// | 40 | 41 | 42 | 43 | 44 |
+			// +----+----+----+----+----+
+			setModelData( model, modelTable( [
+				[ { contents: '00', colspan: 5 } ],
+				[ { contents: '10', colspan: 3 }, { contents: '13', colspan: 2 } ],
+				[ '20', { contents: '21', colspan: 3 }, '24' ],
+				[ { contents: '30', colspan: 2 }, { contents: '32', colspan: 2 }, '34' ],
+				[ '40', '41', '42', '43', '44' ]
+			] ) );
+
+			table = modelRoot.getChild( 0 );
+		} );
+
+		it( 'should return empty array for no overlapping cells', () => {
+			const cellsInfo = getHorizontallyOverlappingCells( table, 0 );
+
+			expect( cellsInfo ).to.be.empty;
+		} );
+
+		it( 'should return overlapping cells info for given overlapColumn', () => {
+			const cellsInfo = getHorizontallyOverlappingCells( table, 2 );
+
+			expect( cellsInfo[ 0 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) ); // Cell 00
+			expect( cellsInfo[ 1 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 1, 0 ] ) ); // Cell 10
+			expect( cellsInfo[ 2 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 2, 1 ] ) ); // Cell 21
+		} );
+	} );
 } );
 } );

+ 15 - 2
packages/ckeditor5-theme-lark/theme/ckeditor5-widget/widgettypearound.css

@@ -22,8 +22,10 @@
 		height: var(--ck-widget-type-around-button-size);
 		height: var(--ck-widget-type-around-button-size);
 		background: var(--ck-color-widget-type-around-button);
 		background: var(--ck-color-widget-type-around-button);
 		border-radius: 100px;
 		border-radius: 100px;
-		animation: fadein linear 300ms 1 normal forwards;
-		transition: opacity 100ms linear;
+
+		pointer-events: none;
+		opacity: 0;
+		transition: opacity var(--ck-widget-handler-animation-duration) var(--ck-widget-handler-animation-curve), background var(--ck-widget-handler-animation-duration) var(--ck-widget-handler-animation-curve);
 
 
 		& svg {
 		& svg {
 			width: 10px;
 			width: 10px;
@@ -70,6 +72,17 @@
 	}
 	}
 
 
 	/*
 	/*
+	 * Show type around buttons when the widget gets selected or being hovered.
+	 */
+	&.ck-widget_selected,
+	&:hover {
+		& > .ck-widget__type-around > .ck-widget__type-around__button {
+			pointer-events: auto;
+			opacity: 1;
+		}
+	}
+
+	/*
 	 * Styles for the buttons when the widget is NOT selected (but the buttons are visible
 	 * Styles for the buttons when the widget is NOT selected (but the buttons are visible
 	 * and still can be hovered).
 	 * and still can be hovered).
 	 */
 	 */

+ 18 - 4
packages/ckeditor5-utils/src/dom/position.js

@@ -129,9 +129,15 @@ export function getOptimalPosition( { element, target, positions, limiter, fitIn
 // @param {Function} position A function returning {@link module:utils/dom/position~Position}.
 // @param {Function} position A function returning {@link module:utils/dom/position~Position}.
 // @param {utils/dom/rect~Rect} targetRect A rect of the target.
 // @param {utils/dom/rect~Rect} targetRect A rect of the target.
 // @param {utils/dom/rect~Rect} elementRect A rect of positioned element.
 // @param {utils/dom/rect~Rect} elementRect A rect of positioned element.
-// @returns {Array} An array containing position name and its Rect.
+// @returns {Array|null} An array containing position name and its Rect (or null if position should be ignored).
 function getPositionNameAndRect( position, targetRect, elementRect ) {
 function getPositionNameAndRect( position, targetRect, elementRect ) {
-	const { left, top, name } = position( targetRect, elementRect );
+	const positionData = position( targetRect, elementRect );
+
+	if ( !positionData ) {
+		return null;
+	}
+
+	const { left, top, name } = positionData;
 
 
 	return [ name, elementRect.clone().moveTo( left, top ) ];
 	return [ name, elementRect.clone().moveTo( left, top ) ];
 }
 }
@@ -205,7 +211,13 @@ function processPositionsToAreas( positions, { targetRect, elementRect, limiterR
 	const elementRectArea = elementRect.getArea();
 	const elementRectArea = elementRect.getArea();
 
 
 	for ( const position of positions ) {
 	for ( const position of positions ) {
-		const [ positionName, positionRect ] = getPositionNameAndRect( position, targetRect, elementRect );
+		const positionData = getPositionNameAndRect( position, targetRect, elementRect );
+
+		if ( !positionData ) {
+			continue;
+		}
+
+		const [ positionName, positionRect ] = positionData;
 		let limiterIntersectArea = 0;
 		let limiterIntersectArea = 0;
 		let viewportIntersectArea = 0;
 		let viewportIntersectArea = 0;
 
 
@@ -351,7 +363,7 @@ function getAbsoluteRectCoordinates( { left, top } ) {
 }
 }
 
 
 /**
 /**
- * The `getOptimalPosition` helper options.
+ * The `getOptimalPosition()` helper options.
  *
  *
  * @interface module:utils/dom/position~Options
  * @interface module:utils/dom/position~Options
  */
  */
@@ -372,6 +384,8 @@ function getAbsoluteRectCoordinates( { left, top } ) {
  * An array of functions which return {@link module:utils/dom/position~Position} relative
  * An array of functions which return {@link module:utils/dom/position~Position} relative
  * to the `target`, in the order of preference.
  * to the `target`, in the order of preference.
  *
  *
+ * **Note**: If a function returns `null`, it is ignored by the `getOptimalPosition()`.
+ *
  * @member {Array.<Function>} #positions
  * @member {Array.<Function>} #positions
  */
  */
 
 

+ 26 - 1
packages/ckeditor5-utils/tests/dom/position.js

@@ -95,6 +95,8 @@ const attachTopRight = ( targetRect, elementRect ) => ( {
 	name: 'top-right'
 	name: 'top-right'
 } );
 } );
 
 
+const attachNone = () => null;
+
 const allPositions = [
 const allPositions = [
 	attachLeftBottom,
 	attachLeftBottom,
 	attachLeftTop,
 	attachLeftTop,
@@ -103,7 +105,8 @@ const allPositions = [
 	attachBottomRight,
 	attachBottomRight,
 	attachBottomLeft,
 	attachBottomLeft,
 	attachTopLeft,
 	attachTopLeft,
-	attachTopRight
+	attachTopRight,
+	attachNone
 ];
 ];
 
 
 describe( 'getOptimalPosition()', () => {
 describe( 'getOptimalPosition()', () => {
@@ -291,6 +294,17 @@ describe( 'getOptimalPosition()', () => {
 				name: 'right-bottom'
 				name: 'right-bottom'
 			} );
 			} );
 		} );
 		} );
+
+		it( 'should allow position function to return null to be ignored', () => {
+			assertPosition( {
+				element, target,
+				positions: [ attachRightBottom, attachNone ]
+			}, {
+				top: 100,
+				left: 110,
+				name: 'right-bottom'
+			} );
+		} );
 	} );
 	} );
 
 
 	describe( 'with a limiter', () => {
 	describe( 'with a limiter', () => {
@@ -392,6 +406,17 @@ describe( 'getOptimalPosition()', () => {
 
 
 			element.remove();
 			element.remove();
 		} );
 		} );
+
+		it( 'should allow position function to return null to be ignored', () => {
+			assertPosition( {
+				element, target, limiter,
+				positions: [ attachLeftBottom, attachNone ]
+			}, {
+				top: 100,
+				left: -20,
+				name: 'left-bottom'
+			} );
+		} );
 	} );
 	} );
 
 
 	describe( 'with fitInViewport on', () => {
 	describe( 'with fitInViewport on', () => {

+ 8 - 1
packages/ckeditor5-widget/src/utils.js

@@ -374,12 +374,19 @@ export function viewToModelPositionOutsideModelElement( model, viewElementMatche
  *
  *
  * @param {module:utils/dom/rect~Rect} widgetRect A rect of the widget.
  * @param {module:utils/dom/rect~Rect} widgetRect A rect of the widget.
  * @param {module:utils/dom/rect~Rect} balloonRect A rect of the balloon.
  * @param {module:utils/dom/rect~Rect} balloonRect A rect of the balloon.
- * @returns {module:utils/dom/position~Position}
+ * @returns {module:utils/dom/position~Position|null}
  */
  */
 export function centeredBalloonPositionForLongWidgets( widgetRect, balloonRect ) {
 export function centeredBalloonPositionForLongWidgets( widgetRect, balloonRect ) {
 	const viewportRect = new Rect( global.window );
 	const viewportRect = new Rect( global.window );
 	const viewportWidgetInsersectionRect = viewportRect.getIntersection( widgetRect );
 	const viewportWidgetInsersectionRect = viewportRect.getIntersection( widgetRect );
 
 
+	const balloonTotalHeight = balloonRect.height + BalloonPanelView.arrowVerticalOffset;
+
+	// If there is enough space above or below the widget then this position should not be used.
+	if ( widgetRect.top - balloonTotalHeight > viewportRect.top || widgetRect.bottom + balloonTotalHeight < viewportRect.bottom ) {
+		return null;
+	}
+
 	// Because this is a last resort positioning, to keep things simple we're not playing with positions of the arrow
 	// Because this is a last resort positioning, to keep things simple we're not playing with positions of the arrow
 	// like, for instance, "south west" or whatever. Just try to keep the balloon in the middle of the visible area of
 	// like, for instance, "south west" or whatever. Just try to keep the balloon in the middle of the visible area of
 	// the widget for as long as it is possible. If the widgets becomes invisible (because cropped by the viewport),
 	// the widget for as long as it is possible. If the widgets becomes invisible (because cropped by the viewport),

+ 38 - 6
packages/ckeditor5-widget/tests/utils.js

@@ -512,7 +512,7 @@ describe( 'widget utils', () => {
 			testUtils.sinon.stub( global.window, 'innerHeight' ).value( 100 );
 			testUtils.sinon.stub( global.window, 'innerHeight' ).value( 100 );
 		} );
 		} );
 
 
-		it( 'should position the balloon inside a widget – at the top + in the middle', () => {
+		it( 'should return null if there is enough space above the widget', () => {
 			// Widget is a 50x150 rect, translated (25,25) from viewport's beginning (0,0).
 			// Widget is a 50x150 rect, translated (25,25) from viewport's beginning (0,0).
 			const widgetRect = new Rect( {
 			const widgetRect = new Rect( {
 				top: 25,
 				top: 25,
@@ -525,8 +525,40 @@ describe( 'widget utils', () => {
 
 
 			const position = centeredBalloonPositionForLongWidgets( widgetRect, balloonRect );
 			const position = centeredBalloonPositionForLongWidgets( widgetRect, balloonRect );
 
 
+			expect( position ).to.equal( null );
+		} );
+
+		it( 'should return null if there is enough space below the widget', () => {
+			// Widget is a 50x150 rect, translated (25,-125) from viewport's beginning (0,0).
+			const widgetRect = new Rect( {
+				top: -125,
+				left: 25,
+				right: 75,
+				bottom: 25,
+				width: 50,
+				height: 150
+			} );
+
+			const position = centeredBalloonPositionForLongWidgets( widgetRect, balloonRect );
+
+			expect( position ).to.equal( null );
+		} );
+
+		it( 'should position the balloon inside a widget – at the top + in the middle', () => {
+			// Widget is a 50x150 rect, translated (25,5) from viewport's beginning (0,0).
+			const widgetRect = new Rect( {
+				top: 5,
+				left: 25,
+				right: 75,
+				bottom: 155,
+				width: 50,
+				height: 150
+			} );
+
+			const position = centeredBalloonPositionForLongWidgets( widgetRect, balloonRect );
+
 			expect( position ).to.deep.equal( {
 			expect( position ).to.deep.equal( {
-				top: 25 + arrowVerticalOffset,
+				top: 5 + arrowVerticalOffset,
 				left: 45,
 				left: 45,
 				name: 'arrow_n'
 				name: 'arrow_n'
 			} );
 			} );
@@ -553,12 +585,12 @@ describe( 'widget utils', () => {
 		} );
 		} );
 
 
 		it( 'should horizontally center the balloon in the visible area when the widget is cropped by the viewport', () => {
 		it( 'should horizontally center the balloon in the visible area when the widget is cropped by the viewport', () => {
-			// Widget is a 50x150 rect, translated (25,-25) from viewport's beginning (0,0).
+			// Widget is a 50x150 rect, translated (-25,5) from viewport's beginning (0,0).
 			const widgetRect = new Rect( {
 			const widgetRect = new Rect( {
-				top: 25,
+				top: 5,
 				left: -25,
 				left: -25,
 				right: 25,
 				right: 25,
-				bottom: 175,
+				bottom: 155,
 				width: 50,
 				width: 50,
 				height: 150
 				height: 150
 			} );
 			} );
@@ -566,7 +598,7 @@ describe( 'widget utils', () => {
 			const position = centeredBalloonPositionForLongWidgets( widgetRect, balloonRect );
 			const position = centeredBalloonPositionForLongWidgets( widgetRect, balloonRect );
 
 
 			expect( position ).to.deep.equal( {
 			expect( position ).to.deep.equal( {
-				top: 25 + arrowVerticalOffset,
+				top: 5 + arrowVerticalOffset,
 				left: 7.5,
 				left: 7.5,
 				name: 'arrow_n'
 				name: 'arrow_n'
 			} );
 			} );

+ 1 - 11
packages/ckeditor5-widget/theme/widgettypearound.css

@@ -8,7 +8,7 @@
 	 * Styles of the type around buttons
 	 * Styles of the type around buttons
 	 */
 	 */
 	& .ck-widget__type-around__button {
 	& .ck-widget__type-around__button {
-		display: none;
+		display: block;
 		position: absolute;
 		position: absolute;
 		overflow: hidden;
 		overflow: hidden;
 		z-index: var(--ck-z-default);
 		z-index: var(--ck-z-default);
@@ -38,16 +38,6 @@
 	}
 	}
 
 
 	/*
 	/*
-	 * Show type around buttons when the widget gets selected or being hovered.
-	 */
-	&.ck-widget_selected,
-	&:hover {
-		& > .ck-widget__type-around > .ck-widget__type-around__button {
-			display: block;
-		}
-	}
-
-	/*
 	 * Hide the type around buttons depending on which directions the widget supports.
 	 * Hide the type around buttons depending on which directions the widget supports.
 	 */
 	 */
 	&:not(.ck-widget_can-type-around_before) > .ck-widget__type-around > .ck-widget__type-around__button_before {
 	&:not(.ck-widget_can-type-around_before) > .ck-widget__type-around > .ck-widget__type-around__button_before {

+ 22 - 19
scripts/release/changelog.js

@@ -9,28 +9,31 @@
 
 
 'use strict';
 'use strict';
 
 
-// In order to use the same version for all packages (including builds and ckeditor5 itself), you can call:
-// yarn run changelog [newVersion]
-
 const devEnv = require( '@ckeditor/ckeditor5-dev-env' );
 const devEnv = require( '@ckeditor/ckeditor5-dev-env' );
-const commonOptions = {
-	cwd: process.cwd(),
-	packages: 'packages'
-};
-const editorBuildsGlob = '@ckeditor/ckeditor5-build-*';
-
-const optionsForDependencies = Object.assign( {}, commonOptions, {
-	skipPackages: editorBuildsGlob,
-	skipMainRepository: true
-} );
-
-const optionsForBuilds = Object.assign( {}, commonOptions, {
-	scope: editorBuildsGlob
-} );
 
 
 Promise.resolve()
 Promise.resolve()
-	.then( () => devEnv.generateChangelogForSubRepositories( optionsForDependencies ) )
-	.then( response => devEnv.generateSummaryChangelog( Object.assign( optionsForBuilds, response ) ) )
+	.then( () => devEnv.generateChangelogForMonoRepository( {
+		cwd: process.cwd(),
+		packages: 'packages',
+		highlightsPlaceholder: true,
+		collaborationFeatures: true,
+		from: '87c56114028c00b1e45b6ecba3bead575c6c1afe', // TODO: Remove the line after the nearest release.
+		transformScope: name => {
+			if ( name === 'ckeditor5' ) {
+				return 'https://www.npmjs.com/package/ckeditor5';
+			}
+
+			if ( name === 'build-*' ) {
+				return 'https://www.npmjs.com/search?q=keywords%3Ackeditor5-build%20maintainer%3Ackeditor';
+			}
+
+			if ( name === 'cloud-services-core' ) {
+				return 'https://www.npmjs.com/package/@ckeditor/ckeditor-cloud-services-core';
+			}
+
+			return 'https://www.npmjs.com/package/@ckeditor/ckeditor5-' + name;
+		}
+	} ) )
 	.then( () => {
 	.then( () => {
 		console.log( 'Done!' );
 		console.log( 'Done!' );
 	} )
 	} )