8
0
Quellcode durchsuchen

Merge branch 'master' into i/6775

Aleksander Nowodzinski vor 5 Jahren
Ursprung
Commit
fbde7c42fa
25 geänderte Dateien mit 2366 neuen und 413 gelöschten Zeilen
  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.
 
-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
@@ -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}.
 
+Each `BREAKING CHANGE` note must be followed by the scope of changes.
+
 ### 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.
@@ -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.
 
-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:
@@ -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)
 
-* 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
 

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

@@ -71,6 +71,17 @@ export default class CloudServices extends ContextPlugin {
 
 		return this.token.init();
 	}
+
+	/**
+	 * @inheritDoc
+	 */
+	destroy() {
+		super.destroy();
+
+		if ( this.token ) {
+			this.token.destroy();
+		}
+	}
 }
 
 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 ViewElement from '../../../src/view/element';
 import ViewContainerElement from '../../../src/view/containerelement';
+import ViewText from '../../../src/view/text';
 import ViewPosition from '../../../src/view/position';
 import ViewSelection from '../../../src/view/selection';
 import { StylesProcessor } from '../../../src/view/stylesmap';
@@ -516,7 +517,7 @@ describe( 'view', () => {
 	} );
 
 	describe( 'hasDomSelection', () => {
-		let domElement, domP, domSelection;
+		let domElement, domP, domText, viewP, viewText, domSelection;
 
 		// Focus tests are too unstable on Firefox to run them.
 		if ( env.isGecko ) {
@@ -528,10 +529,12 @@ describe( 'view', () => {
 
 			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();
 
 			domElement = createElement( document, 'div', { contenteditable: 'true' } );
@@ -539,6 +542,7 @@ describe( 'view', () => {
 
 			domSelection = document.getSelection();
 			domP = domRoot.childNodes[ 0 ];
+			domText = domP.childNodes[ 0 ];
 		} );
 
 		afterEach( () => {
@@ -548,38 +552,40 @@ describe( 'view', () => {
 		it( 'should be true if selection is inside a DOM root element', done => {
 			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 => {
 			domRoot.focus();
 
+			// See the previous test.
+			viewDocument.selection._setTo( viewText, 1 );
+			domSelection.collapse( domText, 1 );
+
 			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( () => {
-					// 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 => {

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

@@ -26,7 +26,8 @@
     "@ckeditor/ckeditor5-link": "^19.0.0",
     "@ckeditor/ckeditor5-list": "^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": {
     "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 ( !url.match( URL_REGEXP ) ) {
+			urlRange.detach();
+
 			return;
 		}
 
 		// If the URL represents a media, let's use it.
 		if ( !mediaRegistry.hasMedia( url ) ) {
+			urlRange.detach();
+
 			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).
 		if ( !mediaEmbedCommand.isEnabled ) {
+			urlRange.detach();
+
 			return;
 		}
 
@@ -153,6 +159,7 @@ export default class AutoMediaEmbed extends Plugin {
 				this._timeoutId = null;
 
 				writer.remove( urlRange );
+				urlRange.detach();
 
 				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 Image from '@ckeditor/ckeditor5-image/src/image';
 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 { 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 ) {
 		// While typing, every character is an atomic change.
 		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 { findAncestor, isHeadingColumnCell, updateNumericAttribute } from './utils';
-import { getColumnIndexes, getSelectionAffectedTableCells } from '../utils';
+import { getColumnIndexes, getSelectionAffectedTableCells, getHorizontallyOverlappingCells, splitVertically } from '../utils';
 
 /**
  * The header column command.
@@ -69,12 +69,21 @@ export default class SetHeaderColumnCommand extends Command {
 
 		const model = this.editor.model;
 		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;
 
 		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 );
 		} );

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

@@ -9,9 +9,8 @@
 
 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.
@@ -77,9 +76,10 @@ export default class SetHeaderRowCommand extends Command {
 			if ( headingRowsToSet ) {
 				// 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.
-				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 );
 				}
 			}
@@ -102,77 +102,3 @@ export default class SetHeaderRowCommand extends Command {
 		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 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 { cropTableToDimensions } from './tableselection/croptable';
+import { cropTableToDimensions, trimTableCellIfNeeded } from './tableselection/croptable';
 import TableUtils from './tableutils';
 
 /**
@@ -110,9 +119,6 @@ export default class TableClipboard extends Plugin {
 			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.
 		let pastedTable = getTableIfOnlyTableInContent( content );
 
@@ -123,61 +129,96 @@ export default class TableClipboard extends Plugin {
 		// Override default model.insertContent() handling at this point.
 		evt.stop();
 
-		const pasteWidth = tableUtils.getColumns( pastedTable );
-		const pasteHeight = tableUtils.getRows( pastedTable );
-
 		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;
 				lastColumnOfSelection += pasteWidth - 1;
 
 				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 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 = {
 				firstColumnOfSelection,
 				firstRowOfSelection,
 				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.
 //
 // @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} selectionDimensions
 // @param {Number} selectionDimensions.firstColumnOfSelection
 // @param {Number} selectionDimensions.firstRowOfSelection
 // @param {Number} selectionDimensions.lastColumnOfSelection
 // @param {Number} selectionDimensions.lastRowOfSelection
-// @param {Number} selectionDimensions.selectionHeight
-// @param {Number} selectionDimensions.selectionWidth
 // @param {module:engine/model/writer~Writer} writer
-function replaceSelectedCellsWithPasted( pastedTable, selectedTable, selectionDimensions, writer ) {
+function replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selectedTable, selectionDimensions, writer ) {
 	const {
-		firstColumnOfSelection, lastColumnOfSelection, selectionWidth,
-		firstRowOfSelection, lastRowOfSelection, selectionHeight
+		firstColumnOfSelection, lastColumnOfSelection,
+		firstRowOfSelection, lastRowOfSelection
 	} = selectionDimensions;
 
+	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, selectionWidth, selectionHeight );
+	const pastedTableLocationMap = createLocationMap( pastedTable, pastedWidth, pastedHeight );
 
 	const selectedTableMap = [ ...new TableWalker( selectedTable, {
 		startRow: firstRowOfSelection,
@@ -245,7 +289,9 @@ function replaceSelectedCellsWithPasted( pastedTable, selectedTable, selectionDi
 		}
 
 		// 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.
 		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.
 		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;
 
 		if ( !previousCellInRow ) {
@@ -272,24 +321,24 @@ function replaceSelectedCellsWithPasted( pastedTable, selectedTable, selectionDi
 	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 tableHeight = tableUtils.getRows( table );
 
-	if ( columns > tableWidth ) {
+	if ( expectedWidth > tableWidth ) {
 		tableUtils.insertColumns( table, {
 			batch: writer.batch,
 			at: tableWidth,
-			columns: columns - tableWidth
+			columns: expectedWidth - tableWidth
 		} );
 	}
 
-	if ( rows > tableHeight ) {
+	if ( expectedHeight > tableHeight ) {
 		tableUtils.insertRows( table, {
 			batch: writer.batch,
 			at: tableHeight,
-			rows: rows - tableHeight
+			rows: expectedHeight - tableHeight
 		} );
 	}
 }
@@ -349,3 +398,173 @@ function createLocationMap( table, width, height ) {
 
 	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;
 }
 
-// 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 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 { findAncestor } from './commands/utils';
+import { createEmptyTableCell, findAncestor, updateNumericAttribute } from './commands/utils';
 import TableWalker from './tablewalker';
 
 /**
@@ -188,7 +188,7 @@ export function getColumnIndexes( tableCells ) {
  *   │ a │ b │ c │ d │
  *   ├───┴───┼───┤   │
  *   │ e     │ f │   │
- *          ├───┼───┤
+ *          ├───┼───┤
  *   │       │ g │ h │
  *   └───────┴───┴───┘
  *
@@ -247,6 +247,171 @@ export function isSelectionRectangular( selectedTableCells, tableUtils ) {
 	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.
 function getFirstLastIndexesObject( indexes ) {
 	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 } ) );
 		} );
 
+		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( 'setting header', () => {
 				it( 'should set it correctly in a middle of single-row, multiple cell selection', () => {
@@ -404,7 +416,7 @@ describe( 'SetHeaderColumnCommand', () => {
 			], { headingColumns: 2 } ) );
 		} );
 
-		it( 'should respect forceValue parameter #1', () => {
+		it( 'should respect forceValue parameter (forceValue=true)', () => {
 			setData( model, modelTable( [
 				[ '00', '01[]', '02', '03' ]
 			], { headingColumns: 3 } ) );
@@ -416,7 +428,7 @@ describe( 'SetHeaderColumnCommand', () => {
 			], { headingColumns: 3 } ) );
 		} );
 
-		it( 'should respect forceValue parameter #2', () => {
+		it( 'should respect forceValue parameter (forceValue=false)', () => {
 			setData( model, modelTable( [
 				[ '00', '01[]', '02', '03' ]
 			], { headingColumns: 1 } ) );
@@ -427,5 +439,95 @@ describe( 'SetHeaderColumnCommand', () => {
 				[ '00', '01[]', '02', '03' ]
 			], { 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 } ) );
 		} );
 
-		it( 'should unsetset heading rows attribute', () => {
+		it( 'should remove "headingRows" attribute from table if no value was given', () => {
 			setData( model, modelTable( [
 				[ '[]00' ],
 				[ '10' ],
@@ -551,7 +551,7 @@ describe( 'SetHeaderRowCommand', () => {
 			} );
 		} );
 
-		it( 'should respect forceValue parameter #1', () => {
+		it( 'should respect forceValue parameter (forceValue=true)', () => {
 			setData( model, modelTable( [
 				[ '00' ],
 				[ '[]10' ],
@@ -569,7 +569,7 @@ describe( 'SetHeaderRowCommand', () => {
 			], { headingRows: 3 } ) );
 		} );
 
-		it( 'should respect forceValue parameter #2', () => {
+		it( 'should respect forceValue parameter (forceValue=false)', () => {
 			setData( model, modelTable( [
 				[ '00' ],
 				[ '[]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 ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
 import TableWalker from '../../src/tablewalker';
+import { getSelectionAffectedTableCells } from '../../src/utils';
+import { findAncestor } from '../../src/commands/utils';
 
 ClassicEditor
 	.create( document.querySelector( '#editor' ), {
@@ -38,21 +40,32 @@ ClassicEditor
 		document.getElementById( 'set-model-data' ).addEventListener( 'click', () => {
 			updateInputStatus();
 
+			const table = findTable( editor );
 			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', () => {
 			updateInputStatus();
 
-			const table = findTable( editor );
+			const table = findTable( editor, true );
 			modelData.value = table ? prettyFormatModelTableInput( prepareModelTableInput( editor.model, table ) ) : '';
 
 			updateAsciiAndDiff();
 		} );
 
 		document.getElementById( 'renumber-cells' ).addEventListener( 'click', () => {
-			const table = findTable( editor );
+			const table = findTable( editor, true );
 
 			if ( !table ) {
 				return;
@@ -75,15 +88,15 @@ ClassicEditor
 		updateAsciiAndDiff();
 
 		function updateAsciiAndDiff() {
-			const table = findTable( editor );
+			const tables = getAllTables( editor );
 
-			if ( !table ) {
+			if ( !tables.length ) {
 				asciiOut.innerText = '-- table not found --';
 				return;
 			}
 
 			const inputModelData = parseModelData( modelData.value );
-			const currentModelData = prepareModelTableInput( editor.model, table );
+			const currentModelData = prepareModelTableInput( editor.model, tables[ 0 ] );
 
 			const diffOutput = inputModelData ? diffString( inputModelData, currentModelData, {
 				theme: {
@@ -93,20 +106,53 @@ ClassicEditor
 				}
 			} ) : '-- 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 tables = [];
 
 			for ( const element of range.getItems() ) {
 				if ( element.is( 'table' ) ) {
-					return element;
+					tables.push( element );
 				}
 			}
 
-			return null;
+			return tables;
 		}
 
 		function parseModelData( string ) {

Datei-Diff unterdrückt, da er zu groß ist
+ 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 {
 	getSelectedTableCells,
 	getTableCellsContainingSelection,
-	getSelectionAffectedTableCells
+	getSelectionAffectedTableCells, getVerticallyOverlappingCells, getHorizontallyOverlappingCells
 } from '../src/utils';
 
 describe( 'table utils', () => {
@@ -359,4 +359,92 @@ describe( 'table utils', () => {
 			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);
 		background: var(--ck-color-widget-type-around-button);
 		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 {
 			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
 	 * 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 {utils/dom/rect~Rect} targetRect A rect of the target.
 // @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 ) {
-	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 ) ];
 }
@@ -205,7 +211,13 @@ function processPositionsToAreas( positions, { targetRect, elementRect, limiterR
 	const elementRectArea = elementRect.getArea();
 
 	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 viewportIntersectArea = 0;
 
@@ -351,7 +363,7 @@ function getAbsoluteRectCoordinates( { left, top } ) {
 }
 
 /**
- * The `getOptimalPosition` helper options.
+ * The `getOptimalPosition()` helper 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
  * to the `target`, in the order of preference.
  *
+ * **Note**: If a function returns `null`, it is ignored by the `getOptimalPosition()`.
+ *
  * @member {Array.<Function>} #positions
  */
 

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

@@ -95,6 +95,8 @@ const attachTopRight = ( targetRect, elementRect ) => ( {
 	name: 'top-right'
 } );
 
+const attachNone = () => null;
+
 const allPositions = [
 	attachLeftBottom,
 	attachLeftTop,
@@ -103,7 +105,8 @@ const allPositions = [
 	attachBottomRight,
 	attachBottomLeft,
 	attachTopLeft,
-	attachTopRight
+	attachTopRight,
+	attachNone
 ];
 
 describe( 'getOptimalPosition()', () => {
@@ -291,6 +294,17 @@ describe( 'getOptimalPosition()', () => {
 				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', () => {
@@ -392,6 +406,17 @@ describe( 'getOptimalPosition()', () => {
 
 			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', () => {

+ 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} balloonRect A rect of the balloon.
- * @returns {module:utils/dom/position~Position}
+ * @returns {module:utils/dom/position~Position|null}
  */
 export function centeredBalloonPositionForLongWidgets( widgetRect, balloonRect ) {
 	const viewportRect = new Rect( global.window );
 	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
 	// 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),

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

@@ -512,7 +512,7 @@ describe( 'widget utils', () => {
 			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).
 			const widgetRect = new Rect( {
 				top: 25,
@@ -525,8 +525,40 @@ describe( 'widget utils', () => {
 
 			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( {
-				top: 25 + arrowVerticalOffset,
+				top: 5 + arrowVerticalOffset,
 				left: 45,
 				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', () => {
-			// 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( {
-				top: 25,
+				top: 5,
 				left: -25,
 				right: 25,
-				bottom: 175,
+				bottom: 155,
 				width: 50,
 				height: 150
 			} );
@@ -566,7 +598,7 @@ describe( 'widget utils', () => {
 			const position = centeredBalloonPositionForLongWidgets( widgetRect, balloonRect );
 
 			expect( position ).to.deep.equal( {
-				top: 25 + arrowVerticalOffset,
+				top: 5 + arrowVerticalOffset,
 				left: 7.5,
 				name: 'arrow_n'
 			} );

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

@@ -8,7 +8,7 @@
 	 * Styles of the type around buttons
 	 */
 	& .ck-widget__type-around__button {
-		display: none;
+		display: block;
 		position: absolute;
 		overflow: hidden;
 		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.
 	 */
 	&: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';
 
-// 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 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()
-	.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( () => {
 		console.log( 'Done!' );
 	} )