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

Merge branch 'master' into i/6120

# Conflicts:
#	packages/ckeditor5-table/src/commands/mergecellscommand.js
Maciej Gołaszewski 5 лет назад
Родитель
Сommit
eaeddf8d5f
26 измененных файлов с 984 добавлено и 433 удалено
  1. 1 0
      docs/framework/guides/contributing/testing-environment.md
  2. 2 2
      package.json
  3. 2 1
      packages/ckeditor5-table/package.json
  4. 6 25
      packages/ckeditor5-table/src/commands/mergecellcommand.js
  5. 14 26
      packages/ckeditor5-table/src/commands/mergecellscommand.js
  6. 4 0
      packages/ckeditor5-table/src/converters/table-cell-paragraph-post-fixer.js
  7. 19 3
      packages/ckeditor5-table/src/converters/table-cell-refresh-post-fixer.js
  8. 35 12
      packages/ckeditor5-table/src/converters/table-layout-post-fixer.js
  9. 22 5
      packages/ckeditor5-table/src/converters/upcasttable.js
  10. 23 11
      packages/ckeditor5-table/src/tablecellproperties/tablecellpropertiesui.js
  11. 2 1
      packages/ckeditor5-table/src/tableediting.js
  12. 21 9
      packages/ckeditor5-table/src/tableproperties/tablepropertiesui.js
  13. 22 15
      packages/ckeditor5-table/src/tableutils.js
  14. 64 11
      packages/ckeditor5-table/src/ui/colorinputview.js
  15. 19 0
      packages/ckeditor5-table/tests/_utils/utils.js
  16. 94 0
      packages/ckeditor5-table/tests/commands/mergecellcommand.js
  17. 97 0
      packages/ckeditor5-table/tests/commands/mergecellscommand.js
  18. 76 7
      packages/ckeditor5-table/tests/converters/table-cell-refresh-post-fixer.js
  19. 19 0
      packages/ckeditor5-table/tests/converters/table-layout-post-fixer.js
  20. 26 0
      packages/ckeditor5-table/tests/converters/upcasttable.js
  21. 2 1
      packages/ckeditor5-table/tests/manual/tablemocking.js
  22. 22 0
      packages/ckeditor5-table/tests/tablecellproperties/tablecellpropertiesui.js
  23. 20 0
      packages/ckeditor5-table/tests/tableproperties/tablepropertiesui.js
  24. 87 3
      packages/ckeditor5-table/tests/ui/colorinputview.js
  25. 21 8
      packages/ckeditor5-ui/src/colorgrid/colorgridview.js
  26. 264 293
      yarn.lock

+ 1 - 0
docs/framework/guides/contributing/testing-environment.md

@@ -57,6 +57,7 @@ The task accepts the following options:
 * `--files` – Specifies test files to run. Accepts a package name or a glob. For example `--files=ckeditor5` will only run tests from the CKEditor 5 main package. Read more about the [rules for converting the `--files` option to a glob pattern](https://github.com/ckeditor/ckeditor5-dev/tree/master/packages/ckeditor5-dev-tests#rules-for-converting---files-option-to-glob-pattern).
 * `--additionalLanguages="ar,pl,..."` – Specifies extra languages to the [CKEditor 5 webpack plugin](https://www.npmjs.com/package/@ckeditor/ckeditor5-dev-webpack-plugin). Check out the {@link features/ui-language UI language guide} to learn more.
 * `--debug` (alias `-d`) – Allows specifying custom debug flags. For example, the `--debug engine` option uncomments the `// @if CK_DEBUG_ENGINE //` lines in the code. Note that by default `--debug` is set to `true` even if you did not specify it. This enables the base set of debug logs (`// @if CK_DEBUG //`) which should always be enabled in the testing environment. You can completely turn off the debug mode by setting the `--debug false` option.
+* `--port` – Specifies the port for the server to use. Defaults to `8125`.
 
 It starts the server available at http://localhost:8125.
 

+ 2 - 2
package.json

@@ -79,7 +79,7 @@
     "@ckeditor/ckeditor5-comments": "^19.0.1",
     "@ckeditor/ckeditor5-dev-docs": "^11.1.0",
     "@ckeditor/ckeditor5-dev-env": "^18.0.0",
-    "@ckeditor/ckeditor5-dev-tests": "^19.0.0",
+    "@ckeditor/ckeditor5-dev-tests": "^19.1.0",
     "@ckeditor/ckeditor5-dev-utils": "^13.0.0",
     "@ckeditor/ckeditor5-dev-webpack-plugin": "^9.0.0",
     "@ckeditor/ckeditor5-inspector": "^2.0.0",
@@ -130,7 +130,7 @@
     "manual": "node --max_old_space_size=4096 node_modules/@ckeditor/ckeditor5-dev-tests/bin/test-manual.js",
     "bootstrap": "yarn install",
     "clean": "rm -rf node_modules",
-    "reset": "rm -rf ./packages ./node_modules && yarn run bootstrap",
+    "reset": "rm -rf ./node_modules && yarn run bootstrap",
     "reinstall": "yarn run clean && yarn run bootstrap",
     "docs": "node --max-old-space-size=4096 ./scripts/docs/build-docs.js",
     "docs:api": "node ./scripts/docs/build-api-docs.js",

+ 2 - 1
packages/ckeditor5-table/package.json

@@ -30,7 +30,8 @@
     "@ckeditor/ckeditor5-typing": "^19.0.0",
     "@ckeditor/ckeditor5-undo": "^19.0.0",
     "@ckeditor/ckeditor5-utils": "^19.0.0",
-    "json-diff": "^0.5.4"
+    "json-diff": "^0.5.4",
+    "lodash-es": "^4.17.10"
   },
   "engines": {
     "node": ">=8.0.0",

+ 6 - 25
packages/ckeditor5-table/src/commands/mergecellcommand.js

@@ -9,10 +9,7 @@
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
 import TableWalker from '../tablewalker';
-import {
-	updateNumericAttribute,
-	isHeadingColumnCell
-} from './utils';
+import { isHeadingColumnCell, findAncestor } from './utils';
 import { getTableCellsContainingSelection } from '../utils';
 
 /**
@@ -83,6 +80,7 @@ export default class MergeCellCommand extends Command {
 		const model = this.editor.model;
 		const doc = model.document;
 		const tableCell = getTableCellsContainingSelection( doc.selection )[ 0 ];
+
 		const cellToMerge = this.value;
 		const direction = this.direction;
 
@@ -108,7 +106,10 @@ export default class MergeCellCommand extends Command {
 
 			// Remove empty row after merging.
 			if ( !removedTableCellRow.childCount ) {
-				removeEmptyRow( removedTableCellRow, writer );
+				const tableUtils = this.editor.plugins.get( 'TableUtils' );
+				const table = findAncestor( 'table', removedTableCellRow );
+
+				tableUtils.removeRows( table, { at: removedTableCellRow.index, batch: writer.batch } );
 			}
 		} );
 	}
@@ -243,26 +244,6 @@ function getVerticalCell( tableCell, direction ) {
 	return cellToMergeData && cellToMergeData.cell;
 }
 
-// Properly removes an empty row from a table. It will update the `rowspan` attribute of cells that overlap the removed row.
-//
-// @param {module:engine/model/element~Element} removedTableCellRow
-// @param {module:engine/model/writer~Writer} writer
-function removeEmptyRow( removedTableCellRow, writer ) {
-	const table = removedTableCellRow.parent;
-
-	const removedRowIndex = table.getChildIndex( removedTableCellRow );
-
-	for ( const { cell, row, rowspan } of new TableWalker( table, { endRow: removedRowIndex } ) ) {
-		const overlapsRemovedRow = row + rowspan - 1 >= removedRowIndex;
-
-		if ( overlapsRemovedRow ) {
-			updateNumericAttribute( 'rowspan', rowspan - 1, cell, writer );
-		}
-	}
-
-	writer.remove( removedTableCellRow );
-}
-
 // Merges two table cells. It will ensure that after merging cells with an empty paragraph, the resulting table cell will only have one
 // paragraph. If one of the merged table cells is empty, the merged table cell will have the contents of the non-empty table cell.
 // If both are empty, the merged table cell will have only one empty paragraph.

+ 14 - 26
packages/ckeditor5-table/src/commands/mergecellscommand.js

@@ -8,9 +8,8 @@
  */
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
-import TableWalker from '../tablewalker';
-import { updateNumericAttribute } from './utils';
 import TableUtils from '../tableutils';
+import { findAncestor, updateNumericAttribute } from './utils';
 import { isSelectionRectangular, getSelectedTableCells } from '../utils';
 
 /**
@@ -58,38 +57,27 @@ export default class MergeCellsCommand extends Command {
 			updateNumericAttribute( 'colspan', mergeWidth, firstTableCell, writer );
 			updateNumericAttribute( 'rowspan', mergeHeight, firstTableCell, writer );
 
+			const emptyRowsIndexes = [];
+
 			for ( const tableCell of selectedTableCells ) {
 				const tableRow = tableCell.parent;
-				mergeTableCells( tableCell, firstTableCell, writer );
-				removeRowIfEmpty( tableRow, writer );
-			}
 
-			writer.setSelection( firstTableCell, 'in' );
-		} );
-	}
-}
+				mergeTableCells( tableCell, firstTableCell, writer );
 
-// Properly removes an empty row from a table. Updates the `rowspan` attribute of cells that overlap the removed row.
-//
-// @param {module:engine/model/element~Element} row
-// @param {module:engine/model/writer~Writer} writer
-function removeRowIfEmpty( row, writer ) {
-	if ( row.childCount ) {
-		return;
-	}
+				if ( !tableRow.childCount ) {
+					emptyRowsIndexes.push( tableRow.index );
+				}
+			}
 
-	const table = row.parent;
-	const removedRowIndex = table.getChildIndex( row );
+			if ( emptyRowsIndexes.length ) {
+				const table = findAncestor( 'table', firstTableCell );
 
-	for ( const { cell, row, rowspan } of new TableWalker( table, { endRow: removedRowIndex } ) ) {
-		const overlapsRemovedRow = row + rowspan - 1 >= removedRowIndex;
+				emptyRowsIndexes.reverse().forEach( row => tableUtils.removeRows( table, { at: row, batch: writer.batch } ) );
+			}
 
-		if ( overlapsRemovedRow ) {
-			updateNumericAttribute( 'rowspan', rowspan - 1, cell, writer );
-		}
+			writer.setSelection( firstTableCell, 'in' );
+		} );
 	}
-
-	writer.remove( row );
 }
 
 // Merges two table cells. It will ensure that after merging cells with empty paragraphs the resulting table cell will only have one

+ 4 - 0
packages/ckeditor5-table/src/converters/table-cell-paragraph-post-fixer.js

@@ -100,6 +100,8 @@ function fixTableRow( tableRow, writer ) {
 function fixTableCellContent( tableCell, writer ) {
 	// Insert paragraph to an empty table cell.
 	if ( tableCell.childCount == 0 ) {
+		// @if CK_DEBUG_TABLE // console.log( 'Post-fixing table: insert paragraph in empty cell.' );
+
 		writer.insertElement( 'paragraph', tableCell );
 
 		return true;
@@ -109,6 +111,8 @@ function fixTableCellContent( tableCell, writer ) {
 	// Temporary solution. See https://github.com/ckeditor/ckeditor5/issues/1464.
 	const textNodes = Array.from( tableCell.getChildren() ).filter( child => child.is( 'text' ) );
 
+	// @if CK_DEBUG_TABLE // textNodes.length && console.log( 'Post-fixing table: wrap cell content with paragraph.' );
+
 	for ( const child of textNodes ) {
 		writer.wrap( writer.createRangeOn( child ), 'paragraph' );
 	}

+ 19 - 3
packages/ckeditor5-table/src/converters/table-cell-refresh-post-fixer.js

@@ -28,15 +28,28 @@ function tableCellRefreshPostFixer( model ) {
 	// Stores cells to be refreshed so the table cell will be refreshed once for multiple changes.
 	const cellsToRefresh = new Set();
 
+	// Counting the paragraph inserts to verify if it increased to more than one paragraph in the current differ.
+	let insertCount = 0;
+
 	for ( const change of differ.getChanges() ) {
 		const parent = change.type == 'insert' || change.type == 'remove' ? change.position.parent : change.range.start.parent;
 
-		if ( parent.is( 'tableCell' ) && checkRefresh( parent, change.type ) ) {
+		if ( !parent.is( 'tableCell' ) ) {
+			continue;
+		}
+
+		if ( change.type == 'insert' ) {
+			insertCount++;
+		}
+
+		if ( checkRefresh( parent, change.type, insertCount ) ) {
 			cellsToRefresh.add( parent );
 		}
 	}
 
 	if ( cellsToRefresh.size ) {
+		// @if CK_DEBUG_TABLE // console.log( `Post-fixing table: refreshing cells (${ cellsToRefresh.size }).` );
+
 		for ( const tableCell of cellsToRefresh.values() ) {
 			differ.refreshItem( tableCell );
 		}
@@ -60,7 +73,8 @@ function tableCellRefreshPostFixer( model ) {
 //
 // @param {module:engine/model/element~Element} tableCell The table cell to check.
 // @param {String} type Type of change.
-function checkRefresh( tableCell, type ) {
+// @param {Number} insertCount The number of inserts in differ.
+function checkRefresh( tableCell, type, insertCount ) {
 	const hasInnerParagraph = Array.from( tableCell.getChildren() ).some( child => child.is( 'paragraph' ) );
 
 	// If there is no paragraph in table cell then the view doesn't require refreshing.
@@ -83,5 +97,7 @@ function checkRefresh( tableCell, type ) {
 	//
 	// - another element is added to a single paragraph (childCount becomes >= 2)
 	// - another element is removed and a single paragraph is left (childCount == 1)
-	return tableCell.childCount <= ( type == 'insert' ? 2 : 1 );
+	//
+	// Change is not needed if there were multiple blocks before change.
+	return tableCell.childCount <= ( type == 'insert' ? insertCount + 1 : 1 );
 }

+ 35 - 12
packages/ckeditor5-table/src/converters/table-layout-post-fixer.js

@@ -271,6 +271,8 @@ function fixTableCellsRowspan( table, writer ) {
 	const cellsToTrim = findCellsToTrim( table );
 
 	if ( cellsToTrim.length ) {
+		// @if CK_DEBUG_TABLE // console.log( `Post-fixing table: trimming cells row-spans (${ cellsToTrim.length }).` );
+
 		wasFixed = true;
 
 		for ( const data of cellsToTrim ) {
@@ -290,14 +292,38 @@ function fixTableRowsSizes( table, writer ) {
 	let wasFixed = false;
 
 	const rowsLengths = getRowsLengths( table );
-	const tableSize = rowsLengths[ 0 ];
+	const rowsToRemove = [];
+
+	// Find empty rows.
+	for ( const [ rowIndex, size ] of rowsLengths.entries() ) {
+		if ( !size ) {
+			rowsToRemove.push( rowIndex );
+		}
+	}
+
+	// Remove empty rows.
+	if ( rowsToRemove.length ) {
+		// @if CK_DEBUG_TABLE // console.log( `Post-fixing table: remove empty rows (${ rowsToRemove.length }).` );
 
-	const isValid = Object.values( rowsLengths ).every( length => length === tableSize );
+		wasFixed = true;
+
+		for ( const rowIndex of rowsToRemove.reverse() ) {
+			writer.remove( table.getChild( rowIndex ) );
+			rowsLengths.splice( rowIndex, 1 );
+		}
+	}
+
+	// Verify if all the rows have the same number of columns.
+	const tableSize = rowsLengths[ 0 ];
+	const isValid = rowsLengths.every( length => length === tableSize );
 
 	if ( !isValid ) {
-		const maxColumns = Object.values( rowsLengths ).reduce( ( prev, current ) => current > prev ? current : prev, 0 );
+		// @if CK_DEBUG_TABLE // console.log( 'Post-fixing table: adding missing cells.' );
 
-		for ( const [ rowIndex, size ] of Object.entries( rowsLengths ) ) {
+		// Find the maximum number of columns.
+		const maxColumns = rowsLengths.reduce( ( prev, current ) => current > prev ? current : prev, 0 );
+
+		for ( const [ rowIndex, size ] of rowsLengths.entries() ) {
 			const columnsToInsert = maxColumns - size;
 
 			if ( columnsToInsert ) {
@@ -346,19 +372,16 @@ function findCellsToTrim( table ) {
 	return cellsToTrim;
 }
 
-// Returns an object with lengths of rows assigned to the corresponding row index.
+// Returns an array with lengths of rows assigned to the corresponding row index.
 //
 // @param {module:engine/model/element~Element} table
-// @returns {Object}
+// @returns {Array.<Number>}
 function getRowsLengths( table ) {
-	const lengths = {};
+	// TableWalker will not provide items for the empty rows, we need to pre-fill this array.
+	const lengths = new Array( table.childCount ).fill( 0 );
 
 	for ( const { row } of new TableWalker( table, { includeSpanned: true } ) ) {
-		if ( !lengths[ row ] ) {
-			lengths[ row ] = 0;
-		}
-
-		lengths[ row ] += 1;
+		lengths[ row ]++;
 	}
 
 	return lengths;

+ 22 - 5
packages/ckeditor5-table/src/converters/upcasttable.js

@@ -52,11 +52,11 @@ export default function upcastTable() {
 			conversionApi.writer.insert( table, splitResult.position );
 			conversionApi.consumable.consume( viewTable, { name: true } );
 
-			if ( rows.length ) {
-				// Upcast table rows in proper order (heading rows first).
-				rows.forEach( row => conversionApi.convertItem( row, conversionApi.writer.createPositionAt( table, 'end' ) ) );
-			} else {
-				// Create one row and one table cell for empty table.
+			// Upcast table rows in proper order (heading rows first).
+			rows.forEach( row => conversionApi.convertItem( row, conversionApi.writer.createPositionAt( table, 'end' ) ) );
+
+			// Create one row and one table cell for empty table.
+			if ( table.isEmpty ) {
 				const row = conversionApi.writer.createElement( 'tableRow' );
 				conversionApi.writer.insert( row, conversionApi.writer.createPositionAt( table, 'end' ) );
 
@@ -90,6 +90,23 @@ export default function upcastTable() {
 	};
 }
 
+/**
+ * Conversion helper that skips empty <tr> from upcasting.
+ *
+ * Empty row is considered a table model error.
+ *
+ * @returns {Function} Conversion helper.
+ */
+export function skipEmptyTableRow() {
+	return dispatcher => {
+		dispatcher.on( 'element:tr', ( evt, data ) => {
+			if ( data.viewItem.isEmpty ) {
+				evt.stop();
+			}
+		}, { priority: 'high' } );
+	};
+}
+
 export function upcastTableCell( elementName ) {
 	return dispatcher => {
 		dispatcher.on( `element:${ elementName }`, ( evt, data, conversionApi ) => {

+ 23 - 11
packages/ckeditor5-table/src/tablecellproperties/tablecellpropertiesui.js

@@ -32,6 +32,19 @@ import { debounce } from 'lodash-es';
 
 const ERROR_TEXT_TIMEOUT = 500;
 
+// Map of view properties and related commands.
+const propertyToCommandMap = {
+	borderStyle: 'tableCellBorderStyle',
+	borderColor: 'tableCellBorderColor',
+	borderWidth: 'tableCellBorderWidth',
+	width: 'tableCellWidth',
+	height: 'tableCellHeight',
+	padding: 'tableCellPadding',
+	backgroundColor: 'tableCellBackgroundColor',
+	horizontalAlignment: 'tableCellHorizontalAlignment',
+	verticalAlignment: 'tableCellVerticalAlignment'
+};
+
 /**
  * The table cell properties UI plugin. It introduces the `'tableCellProperties'` button
  * that opens a form allowing to specify the visual styling of a table cell.
@@ -110,6 +123,13 @@ export default class TableCellPropertiesUI extends Plugin {
 
 			this.listenTo( view, 'execute', () => this._showView() );
 
+			const commands = Object.values( propertyToCommandMap )
+				.map( commandName => editor.commands.get( commandName ) );
+
+			view.bind( 'isEnabled' ).toMany( commands, 'isEnabled', ( ...areEnabled ) => (
+				areEnabled.some( isCommandEnabled => isCommandEnabled )
+			) );
+
 			return view;
 		} );
 	}
@@ -256,17 +276,9 @@ export default class TableCellPropertiesUI extends Plugin {
 	_fillViewFormFromCommandValues() {
 		const commands = this.editor.commands;
 
-		this.view.set( {
-			borderStyle: commands.get( 'tableCellBorderStyle' ).value || '',
-			borderColor: commands.get( 'tableCellBorderColor' ).value || '',
-			borderWidth: commands.get( 'tableCellBorderWidth' ).value || '',
-			width: commands.get( 'tableCellWidth' ).value || '',
-			height: commands.get( 'tableCellHeight' ).value || '',
-			padding: commands.get( 'tableCellPadding' ).value || '',
-			backgroundColor: commands.get( 'tableCellBackgroundColor' ).value || '',
-			horizontalAlignment: commands.get( 'tableCellHorizontalAlignment' ).value || '',
-			verticalAlignment: commands.get( 'tableCellVerticalAlignment' ).value || ''
-		} );
+		Object.entries( propertyToCommandMap )
+			.map( ( [ property, commandName ] ) => [ property, commands.get( commandName ).value || '' ] )
+			.forEach( ( [ property, value ] ) => this.view.set( property, value ) );
 	}
 
 	/**

+ 2 - 1
packages/ckeditor5-table/src/tableediting.js

@@ -9,7 +9,7 @@
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 
-import upcastTable, { upcastTableCell } from './converters/upcasttable';
+import upcastTable, { upcastTableCell, skipEmptyTableRow } from './converters/upcasttable';
 import {
 	downcastInsertCell,
 	downcastInsertRow,
@@ -98,6 +98,7 @@ export default class TableEditing extends Plugin {
 
 		// Table row conversion.
 		conversion.for( 'upcast' ).elementToElement( { model: 'tableRow', view: 'tr' } );
+		conversion.for( 'upcast' ).add( skipEmptyTableRow() );
 
 		conversion.for( 'editingDowncast' ).add( downcastInsertRow( { asWidget: true } ) );
 		conversion.for( 'dataDowncast' ).add( downcastInsertRow() );

+ 21 - 9
packages/ckeditor5-table/src/tableproperties/tablepropertiesui.js

@@ -32,6 +32,17 @@ import { debounce } from 'lodash-es';
 
 const ERROR_TEXT_TIMEOUT = 500;
 
+// Map of view properties and related commands.
+const propertyToCommandMap = {
+	borderStyle: 'tableBorderStyle',
+	borderColor: 'tableBorderColor',
+	borderWidth: 'tableBorderWidth',
+	backgroundColor: 'tableBackgroundColor',
+	width: 'tableWidth',
+	height: 'tableHeight',
+	alignment: 'tableAlignment'
+};
+
 /**
  * The table properties UI plugin. It introduces the `'tableProperties'` button
  * that opens a form allowing to specify visual styling of an entire table.
@@ -110,6 +121,13 @@ export default class TablePropertiesUI extends Plugin {
 
 			this.listenTo( view, 'execute', () => this._showView() );
 
+			const commands = Object.values( propertyToCommandMap )
+				.map( commandName => editor.commands.get( commandName ) );
+
+			view.bind( 'isEnabled' ).toMany( commands, 'isEnabled', ( ...areEnabled ) => (
+				areEnabled.some( isCommandEnabled => isCommandEnabled )
+			) );
+
 			return view;
 		} );
 	}
@@ -248,15 +266,9 @@ export default class TablePropertiesUI extends Plugin {
 	_fillViewFormFromCommandValues() {
 		const commands = this.editor.commands;
 
-		this.view.set( {
-			borderStyle: commands.get( 'tableBorderStyle' ).value || '',
-			borderColor: commands.get( 'tableBorderColor' ).value || '',
-			borderWidth: commands.get( 'tableBorderWidth' ).value || '',
-			backgroundColor: commands.get( 'tableBackgroundColor' ).value || '',
-			width: commands.get( 'tableWidth' ).value || '',
-			height: commands.get( 'tableHeight' ).value || '',
-			alignment: commands.get( 'tableAlignment' ).value || ''
-		} );
+		Object.entries( propertyToCommandMap )
+			.map( ( [ property, commandName ] ) => [ property, commands.get( commandName ).value || '' ] )
+			.forEach( ( [ property, value ] ) => this.view.set( property, value ) );
 	}
 
 	/**

+ 22 - 15
packages/ckeditor5-table/src/tableutils.js

@@ -289,17 +289,21 @@ export default class TableUtils extends Plugin {
 		const last = first + rowsToRemove - 1;
 		const batch = options.batch || 'default';
 
-		// Removing rows from table requires most calculations to be done prior to changing table structure.
+		model.enqueueChange( batch, writer => {
+			// Removing rows from the table require that most calculations to be done prior to changing table structure.
+			// Preparations must be done in the same enqueueChange callback to use the current table structure.
 
-		// 1. Preparation - get row-spanned cells that have to be modified after removing rows.
-		const { cellsToMove, cellsToTrim } = getCellsToMoveAndTrimOnRemoveRow( table, first, last );
+			// 1. Preparation - get row-spanned cells that have to be modified after removing rows.
+			const { cellsToMove, cellsToTrim } = getCellsToMoveAndTrimOnRemoveRow( table, first, last );
+
+			// 2. Execution
 
-		// 2. Execution
-		model.enqueueChange( batch, writer => {
 			// 2a. Move cells from removed rows that extends over a removed section - must be done before removing rows.
 			// This will fill any gaps in a rows below that previously were empty because of row-spanned cells.
-			const rowAfterRemovedSection = last + 1;
-			moveCellsToRow( table, rowAfterRemovedSection, cellsToMove, writer );
+			if ( cellsToMove.size ) {
+				const rowAfterRemovedSection = last + 1;
+				moveCellsToRow( table, rowAfterRemovedSection, cellsToMove, writer );
+			}
 
 			// 2b. Remove all required rows.
 			for ( let i = last; i >= first; i-- ) {
@@ -753,17 +757,20 @@ function adjustHeadingColumns( table, removedColumnIndexes, writer ) {
 
 // Calculates a new heading rows value for removing rows from heading section.
 function updateHeadingRows( table, first, last, model, batch ) {
-	const headingRows = table.getAttribute( 'headingRows' ) || 0;
+	// Must be done after the changes in table structure (removing rows).
+	// Otherwise the downcast converter for headingRows attribute will fail.
+	// See https://github.com/ckeditor/ckeditor5/issues/6391.
+	//
+	// Must be completely wrapped in enqueueChange to get the current table state (after applying other enqueued changes).
+	model.enqueueChange( batch, writer => {
+		const headingRows = table.getAttribute( 'headingRows' ) || 0;
 
-	if ( first < headingRows ) {
-		const newRows = last < headingRows ? headingRows - ( last - first + 1 ) : first;
+		if ( first < headingRows ) {
+			const newRows = last < headingRows ? headingRows - ( last - first + 1 ) : first;
 
-		// Must be done after the changes in table structure (removing rows).
-		// Otherwise the downcast converter for headingRows attribute will fail. ckeditor/ckeditor5#6391.
-		model.enqueueChange( batch, writer => {
 			updateNumericAttribute( 'headingRows', newRows, table, writer, 0 );
-		} );
-	}
+		}
+	} );
 }
 
 // Finds cells that will be:

+ 64 - 11
packages/ckeditor5-table/src/ui/colorinputview.js

@@ -101,10 +101,20 @@ export default class ColorInputView extends View {
 		 * An instance of the input allowing the user to type a color value.
 		 *
 		 * @protected
-		 * @member {module:ui/dropdown/dropdown~DropdownView}
+		 * @member {module:ui/inputtext/inputtextview~InputTextView}
 		 */
 		this._inputView = this._createInputTextView( locale );
 
+		/**
+		 * The flag that indicates whether the user is still typing.
+		 * If set to true, it means that the text input field ({@link #_inputView}) still has the focus.
+		 * So, we should interrupt the user by replacing the input's value.
+		 *
+		 * @protected
+		 * @member {Boolean}
+		 */
+		this._stillTyping = false;
+
 		this.setTemplate( {
 			tag: 'div',
 			attributes: {
@@ -122,6 +132,8 @@ export default class ColorInputView extends View {
 				this._dropdownView
 			]
 		} );
+
+		this.on( 'change:value', ( evt, name, inputValue ) => this._setInputValue( inputValue ) );
 	}
 
 	/**
@@ -186,25 +198,42 @@ export default class ColorInputView extends View {
 	}
 
 	/**
-	 * Creates and configures the {@link #_inputView}.
+	 * Creates and configures an instance of {@link module:ui/inputtext/inputtextview~InputTextView}.
 	 *
 	 * @private
+	 * @returns {module:ui/inputtext/inputtextview~InputTextView} A configured instance to be set as {@link #_inputView}.
 	 */
 	_createInputTextView() {
 		const locale = this.locale;
-		const input = new InputTextView( locale );
+		const inputView = new InputTextView( locale );
+
+		inputView.extendTemplate( {
+			on: {
+				blur: inputView.bindTemplate.to( 'blur' )
+			}
+		} );
+
+		inputView.value = this.value;
+		inputView.bind( 'isReadOnly' ).to( this );
+		inputView.bind( 'hasError' ).to( this );
 
-		input.bind( 'value' ).to( this );
-		input.bind( 'isReadOnly' ).to( this );
-		input.bind( 'hasError' ).to( this );
+		inputView.on( 'input', () => {
+			const inputValue = inputView.element.value;
+			// Check if the value matches one of our defined colors' label.
+			const mappedColor = this.options.colorDefinitions.find( def => inputValue === def.label );
 
-		input.on( 'input', () => {
-			this.value = input.element.value;
+			this._stillTyping = true;
+			this.value = mappedColor && mappedColor.color || inputValue;
 		} );
 
-		input.delegate( 'input' ).to( this );
+		inputView.on( 'blur', () => {
+			this._stillTyping = false;
+			this._setInputValue( inputView.element.value );
+		} );
 
-		return input;
+		inputView.delegate( 'input' ).to( this );
+
+		return inputView;
 	}
 
 	/**
@@ -246,9 +275,33 @@ export default class ColorInputView extends View {
 			this._dropdownView.isOpen = false;
 			this.fire( 'input' );
 		} );
-
 		colorGrid.bind( 'selectedColor' ).to( this, 'value' );
 
 		return colorGrid;
 	}
+
+	/**
+	 * Sets {@link #_inputView}'s value property to the color value or color label,
+	 * if there is one and the user is not typing.
+	 *
+	 * Handles cases like:
+	 *
+	 * * Someone picks the color in the grid.
+	 * * The color is set from the plugin level.
+	 *
+	 * @private
+	 * @param {String} inputValue Color value to be set.
+	 */
+	_setInputValue( inputValue ) {
+		if ( !this._stillTyping ) {
+			// Check if the value matches one of our defined colors.
+			const mappedColor = this.options.colorDefinitions.find( def => inputValue === def.color );
+
+			if ( mappedColor ) {
+				this._inputView.value = mappedColor.label;
+			} else {
+				this._inputView.value = inputValue || '';
+			}
+		}
+	}
 }

+ 19 - 0
packages/ckeditor5-table/tests/_utils/utils.js

@@ -500,9 +500,16 @@ function getClassToSet( attributes ) {
 export function createTableAsciiArt( model, table ) {
 	const tableMap = [ ...new TableWalker( table, { includeSpanned: true } ) ];
 
+	if ( !tableMap.length ) {
+		return '';
+	}
+
 	const { row: lastRow, column: lastColumn } = tableMap[ tableMap.length - 1 ];
 	const columns = lastColumn + 1;
 
+	const headingRows = parseInt( table.getAttribute( 'headingRows' ) ) || 0;
+	const headingColumns = parseInt( table.getAttribute( 'headingColumns' ) ) || 0;
+
 	let result = '';
 
 	for ( let row = 0; row <= lastRow; row++ ) {
@@ -535,6 +542,10 @@ export function createTableAsciiArt( model, table ) {
 			if ( column == lastColumn ) {
 				gridLine += '+';
 				contentLine += '|';
+
+				if ( headingRows && row == headingRows ) {
+					gridLine += ' <-- heading rows';
+				}
 			}
 		}
 		result += gridLine + '\n';
@@ -542,6 +553,14 @@ export function createTableAsciiArt( model, table ) {
 
 		if ( row == lastRow ) {
 			result += `+${ '----+'.repeat( columns ) }`;
+
+			if ( headingRows && row == headingRows - 1 ) {
+				result += ' <-- heading rows';
+			}
+
+			if ( headingColumns > 0 ) {
+				result += `\n${ '     '.repeat( headingColumns ) }^-- heading columns`;
+			}
 		}
 	}
 

+ 94 - 0
packages/ckeditor5-table/tests/commands/mergecellcommand.js

@@ -701,6 +701,53 @@ describe( 'MergeCellCommand', () => {
 					[ '40', '41' ]
 				] ) );
 			} );
+
+			it( 'should adjust heading rows if empty row was removed ', () => {
+				// +----+----+
+				// | 00 | 01 |
+				// +    +----+
+				// |    | 11 |
+				// +----+----+ <-- heading rows
+				// | 20 | 21 |
+				// +----+----+
+				setData( model, modelTable( [
+					[ { contents: '00', rowspan: 2 }, '[]01' ],
+					[ '11' ],
+					[ '20', '21' ]
+				], { headingRows: 2 } ) );
+
+				command.execute();
+
+				assertEqualMarkup( getData( model ), modelTable( [
+					[ '00', '<paragraph>[01</paragraph><paragraph>11]</paragraph>' ],
+					[ '20', '21' ]
+				], { headingRows: 1 } ) );
+			} );
+
+			it( 'should create one undo step (1 batch)', () => {
+				// +----+----+
+				// | 00 | 01 |
+				// +    +----+
+				// |    | 11 |
+				// +----+----+ <-- heading rows
+				// | 20 | 21 |
+				// +----+----+
+				setData( model, modelTable( [
+					[ { contents: '00', rowspan: 2 }, '[]01' ],
+					[ '11' ],
+					[ '20', '21' ]
+				], { headingRows: 2 } ) );
+
+				const createdBatches = new Set();
+
+				model.on( 'applyOperation', ( evt, [ operation ] ) => {
+					createdBatches.add( operation.batch );
+				} );
+
+				command.execute();
+
+				expect( createdBatches.size ).to.equal( 1 );
+			} );
 		} );
 	} );
 
@@ -959,6 +1006,53 @@ describe( 'MergeCellCommand', () => {
 					[ '40', '41' ]
 				] ) );
 			} );
+
+			it( 'should adjust heading rows if empty row was removed ', () => {
+				// +----+----+
+				// | 00 | 01 |
+				// +    +----+
+				// |    | 11 |
+				// +----+----+ <-- heading rows
+				// | 20 | 21 |
+				// +----+----+
+				setData( model, modelTable( [
+					[ { contents: '00', rowspan: 2 }, '01' ],
+					[ '[]11' ],
+					[ '20', '21' ]
+				], { headingRows: 2 } ) );
+
+				command.execute();
+
+				assertEqualMarkup( getData( model ), modelTable( [
+					[ '00', '<paragraph>[01</paragraph><paragraph>11]</paragraph>' ],
+					[ '20', '21' ]
+				], { headingRows: 1 } ) );
+			} );
+
+			it( 'should create one undo step (1 batch)', () => {
+				// +----+----+
+				// | 00 | 01 |
+				// +    +----+
+				// |    | 11 |
+				// +----+----+ <-- heading rows
+				// | 20 | 21 |
+				// +----+----+
+				setData( model, modelTable( [
+					[ { contents: '00', rowspan: 2 }, '01' ],
+					[ '[]11' ],
+					[ '20', '21' ]
+				], { headingRows: 2 } ) );
+
+				const createdBatches = new Set();
+
+				model.on( 'applyOperation', ( evt, [ operation ] ) => {
+					createdBatches.add( operation.batch );
+				} );
+
+				command.execute();
+
+				expect( createdBatches.size ).to.equal( 1 );
+			} );
 		} );
 	} );
 } );

+ 97 - 0
packages/ckeditor5-table/tests/commands/mergecellscommand.js

@@ -514,6 +514,103 @@ describe( 'MergeCellsCommand', () => {
 				] ) );
 			} );
 
+			it( 'should decrease heading rows if some heading rows were removed', () => {
+				setData( model, modelTable( [
+					[ '00' ],
+					[ '10' ],
+					[ '20' ]
+				], { headingRows: 2 } ) );
+
+				selectNodes( [
+					[ 0, 0, 0 ],
+					[ 0, 1, 0 ]
+				] );
+
+				command.execute();
+
+				assertEqualMarkup( getData( model ), modelTable( [
+					[
+						'<paragraph>[00</paragraph><paragraph>10]</paragraph>'
+					],
+					[ '20' ]
+				], { headingRows: 1 } ) );
+			} );
+
+			it( 'should decrease heading rows if multiple heading rows were removed', () => {
+				// +----+----+
+				// | 00 | 01 |
+				// +    +----+
+				// |    | 11 |
+				// +----+----+
+				// | 20 | 21 |
+				// +----+----+
+				// | 30 | 31 |
+				// +    +----+
+				// |    | 41 |
+				// +----+----+ <-- heading rows
+				// | 50 | 51 |
+				// +----+----+
+				setData( model, modelTable( [
+					[ { contents: '00', rowspan: 2 }, '01' ],
+					[ '11' ],
+					[ '20', '21' ],
+					[ { contents: '30', rowspan: 2 }, '31' ],
+					[ '41' ],
+					[ '50', '51' ]
+				], { headingRows: 5 } ) );
+
+				selectNodes( [
+					[ 0, 0, 1 ],
+					[ 0, 1, 0 ],
+					[ 0, 2, 1 ],
+					[ 0, 3, 1 ],
+					[ 0, 4, 0 ]
+				] );
+
+				command.execute();
+
+				const contents = [ '[01', '11', '21', '31', '41]' ].map( content => `<paragraph>${ content }</paragraph>` ).join( '' );
+
+				// +----+----+
+				// | 00 | 01 |
+				// +----+    +
+				// | 20 |    |
+				// +----+    +
+				// | 30 |    |
+				// +----+----+ <-- heading rows
+				// | 50 | 51 |
+				// +----+----+
+				assertEqualMarkup( getData( model ), modelTable( [
+					[ '00', { contents, rowspan: 3 } ],
+					[ '20' ],
+					[ '30' ],
+					[ '50', '51' ]
+				], { headingRows: 3 } ) );
+			} );
+
+			it( 'should create one undo step (1 batch)', () => {
+				setData( model, modelTable( [
+					[ '00' ],
+					[ '10' ],
+					[ '20' ]
+				], { headingRows: 2 } ) );
+
+				selectNodes( [
+					[ 0, 0, 0 ],
+					[ 0, 1, 0 ]
+				] );
+
+				const createdBatches = new Set();
+
+				model.on( 'applyOperation', ( evt, [ operation ] ) => {
+					createdBatches.add( operation.batch );
+				} );
+
+				command.execute();
+
+				expect( createdBatches.size ).to.equal( 1 );
+			} );
+
 			it( 'should decrease rowspan if cell overlaps removed row', () => {
 				setData( model, modelTable( [
 					[ '00', { rowspan: 2, contents: '01' }, { rowspan: 3, contents: '02' } ],

+ 76 - 7
packages/ckeditor5-table/tests/converters/table-cell-refresh-post-fixer.js

@@ -55,18 +55,16 @@ describe( 'Table cell refresh post-fixer', () => {
 		return editor.destroy();
 	} );
 
-	it( 'should rename <span> to <p> when adding more <paragraph> elements to the same table cell', () => {
+	it( 'should rename <span> to <p> when adding <paragraph> element to the same table cell', () => {
 		editor.setData( viewTable( [ [ '<p>00</p>' ] ] ) );
 
 		const table = root.getChild( 0 );
 
 		model.change( writer => {
 			const nodeByPath = table.getNodeByPath( [ 0, 0, 0 ] );
-
 			const paragraph = writer.createElement( 'paragraph' );
 
 			writer.insert( paragraph, nodeByPath, 'after' );
-
 			writer.setSelection( nodeByPath.nextSibling, 0 );
 		} );
 
@@ -76,18 +74,37 @@ describe( 'Table cell refresh post-fixer', () => {
 		sinon.assert.calledOnce( refreshItemSpy );
 	} );
 
-	it( 'should rename <span> to <p> on adding other block element to the same table cell', () => {
+	it( 'should rename <span> to <p> when adding more <paragraph> elements to the same table cell', () => {
 		editor.setData( viewTable( [ [ '<p>00</p>' ] ] ) );
 
 		const table = root.getChild( 0 );
 
 		model.change( writer => {
 			const nodeByPath = table.getNodeByPath( [ 0, 0, 0 ] );
+			const paragraph1 = writer.createElement( 'paragraph' );
+			const paragraph2 = writer.createElement( 'paragraph' );
 
-			const paragraph = writer.createElement( 'block' );
+			writer.insert( paragraph1, nodeByPath, 'after' );
+			writer.insert( paragraph2, nodeByPath, 'after' );
+			writer.setSelection( nodeByPath.nextSibling, 0 );
+		} );
 
-			writer.insert( paragraph, nodeByPath, 'after' );
+		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
+			[ '<p>00</p><p></p><p></p>' ]
+		], { asWidget: true } ) );
+		sinon.assert.calledOnce( refreshItemSpy );
+	} );
 
+	it( 'should rename <span> to <p> on adding other block element to the same table cell', () => {
+		editor.setData( viewTable( [ [ '<p>00</p>' ] ] ) );
+
+		const table = root.getChild( 0 );
+
+		model.change( writer => {
+			const nodeByPath = table.getNodeByPath( [ 0, 0, 0 ] );
+			const block = writer.createElement( 'block' );
+
+			writer.insert( block, nodeByPath, 'after' );
 			writer.setSelection( nodeByPath.nextSibling, 0 );
 		} );
 
@@ -97,6 +114,27 @@ describe( 'Table cell refresh post-fixer', () => {
 		sinon.assert.calledOnce( refreshItemSpy );
 	} );
 
+	it( 'should rename <span> to <p> on adding multiple other block elements to the same table cell', () => {
+		editor.setData( viewTable( [ [ '<p>00</p>' ] ] ) );
+
+		const table = root.getChild( 0 );
+
+		model.change( writer => {
+			const nodeByPath = table.getNodeByPath( [ 0, 0, 0 ] );
+			const block1 = writer.createElement( 'block' );
+			const block2 = writer.createElement( 'block' );
+
+			writer.insert( block1, nodeByPath, 'after' );
+			writer.insert( block2, nodeByPath, 'after' );
+			writer.setSelection( nodeByPath.nextSibling, 0 );
+		} );
+
+		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
+			[ '<p>00</p><div></div><div></div>' ]
+		], { asWidget: true } ) );
+		sinon.assert.calledOnce( refreshItemSpy );
+	} );
+
 	it( 'should properly rename the same element on consecutive changes', () => {
 		editor.setData( viewTable( [ [ '<p>00</p>' ] ] ) );
 
@@ -140,7 +178,7 @@ describe( 'Table cell refresh post-fixer', () => {
 		sinon.assert.calledOnce( refreshItemSpy );
 	} );
 
-	it( 'should rename <p> to <span> when removing all but one paragraph inside table cell', () => {
+	it( 'should rename <p> to <span> when removing one of two paragraphs inside table cell', () => {
 		editor.setData( viewTable( [ [ '<p>00</p><p>foo</p>' ] ] ) );
 
 		const table = root.getChild( 0 );
@@ -155,6 +193,22 @@ describe( 'Table cell refresh post-fixer', () => {
 		sinon.assert.calledOnce( refreshItemSpy );
 	} );
 
+	it( 'should rename <p> to <span> when removing all but one paragraph inside table cell', () => {
+		editor.setData( viewTable( [ [ '<p>00</p><p>foo</p><p>bar</p>' ] ] ) );
+
+		const table = root.getChild( 0 );
+
+		model.change( writer => {
+			writer.remove( table.getNodeByPath( [ 0, 0, 1 ] ) );
+			writer.remove( table.getNodeByPath( [ 0, 0, 1 ] ) );
+		} );
+
+		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
+			[ '00' ]
+		], { asWidget: true } ) );
+		sinon.assert.calledOnce( refreshItemSpy );
+	} );
+
 	it( 'should rename <p> to <span> when removing attribute from <paragraph>', () => {
 		editor.setData( '<table><tr><td><p foo="bar">00</p></td></tr></table>' );
 
@@ -250,6 +304,21 @@ describe( 'Table cell refresh post-fixer', () => {
 		sinon.assert.notCalled( refreshItemSpy );
 	} );
 
+	it( 'should do nothing on adding <paragraph> to existing paragraphs', () => {
+		editor.setData( viewTable( [ [ '<p>a</p><p>b</p>' ] ] ) );
+
+		const table = root.getChild( 0 );
+
+		model.change( writer => {
+			writer.insertElement( 'paragraph', table.getNodeByPath( [ 0, 0, 1 ] ), 'after' );
+		} );
+
+		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
+			[ '<p>a</p><p>b</p><p></p>' ]
+		], { asWidget: true } ) );
+		sinon.assert.notCalled( refreshItemSpy );
+	} );
+
 	it( 'should do nothing when setting attribute on block item other then <paragraph>', () => {
 		editor.setData( viewTable( [ [ '<div>foo</div>' ] ] ) );
 

+ 19 - 0
packages/ckeditor5-table/tests/converters/table-layout-post-fixer.js

@@ -89,6 +89,25 @@ describe( 'Table layout post-fixer', () => {
 			] ) );
 		} );
 
+		it( 'should remove empty rows', () => {
+			const parsed = parse( modelTable( [
+				[ '00', '01' ],
+				[ ],
+				[ '20', '21', '22' ],
+				[ ]
+			] ), model.schema );
+
+			model.change( writer => {
+				writer.remove( writer.createRangeIn( root ) );
+				writer.insert( parsed, root );
+			} );
+
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+				[ '00', '01', '' ],
+				[ '20', '21', '22' ]
+			] ) );
+		} );
+
 		it( 'should fix the wrong rowspan attribute of a table cell inside the header', () => {
 			const parsed = parse( modelTable( [
 				[ { rowspan: 2, contents: '00' }, { rowspan: 3, contents: '01' }, '02' ],

+ 26 - 0
packages/ckeditor5-table/tests/converters/upcasttable.js

@@ -180,6 +180,32 @@ describe( 'upcastTable()', () => {
 		);
 	} );
 
+	it( 'should create valid table model from all empty rows', () => {
+		editor.setData(
+			'<table>' +
+				'<tr></tr>' +
+				'<tr></tr>' +
+			'</table>'
+		);
+
+		expectModel(
+			'<table><tableRow><tableCell><paragraph></paragraph></tableCell></tableRow></table>'
+		);
+	} );
+
+	it( 'should skip empty table rows', () => {
+		editor.setData(
+			'<table>' +
+				'<tr></tr>' +
+				'<tr><td>bar</td></tr>' +
+			'</table>'
+		);
+
+		expectModel(
+			'<table><tableRow><tableCell><paragraph>bar</paragraph></tableCell></tableRow></table>'
+		);
+	} );
+
 	it( 'should skip unknown table children', () => {
 		editor.setData(
 			'<table>' +

+ 2 - 1
packages/ckeditor5-table/tests/manual/tablemocking.js

@@ -11,6 +11,7 @@ import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'
 import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
 import { diffString } from 'json-diff';
+import { debounce } from 'lodash-es';
 import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
 import TableWalker from '../../src/tablewalker';
 
@@ -70,7 +71,7 @@ ClassicEditor
 			updateAsciiAndDiff();
 		} );
 
-		editor.model.document.on( 'change:data', updateAsciiAndDiff );
+		editor.model.document.on( 'change:data', debounce( () => updateAsciiAndDiff(), 100 ) );
 		updateAsciiAndDiff();
 
 		function updateAsciiAndDiff() {

+ 22 - 0
packages/ckeditor5-table/tests/tablecellproperties/tablecellpropertiesui.js

@@ -121,6 +121,28 @@ describe( 'table cell properties', () => {
 					tableCellPropertiesButton.fire( 'execute' );
 					sinon.assert.calledOnce( spy );
 				} );
+
+				it( 'should be disabled if all of the table cell properties commands are disabled', () => {
+					[
+						'tableCellBorderStyle',
+						'tableCellBorderColor',
+						'tableCellBorderWidth',
+						'tableCellWidth',
+						'tableCellHeight',
+						'tableCellPadding',
+						'tableCellBackgroundColor',
+						'tableCellHorizontalAlignment',
+						'tableCellVerticalAlignment'
+					].forEach( command => {
+						editor.commands.get( command ).isEnabled = false;
+					} );
+
+					expect( tableCellPropertiesButton.isEnabled ).to.be.false;
+
+					editor.commands.get( 'tableCellBackgroundColor' ).isEnabled = true;
+
+					expect( tableCellPropertiesButton.isEnabled ).to.be.true;
+				} );
 			} );
 		} );
 

+ 20 - 0
packages/ckeditor5-table/tests/tableproperties/tablepropertiesui.js

@@ -120,6 +120,26 @@ describe( 'table properties', () => {
 					tablePropertiesButton.fire( 'execute' );
 					sinon.assert.calledOnce( spy );
 				} );
+
+				it( 'should be disabled if all of the table properties commands are disabled', () => {
+					[
+						'tableBorderStyle',
+						'tableBorderColor',
+						'tableBorderWidth',
+						'tableBackgroundColor',
+						'tableWidth',
+						'tableHeight',
+						'tableAlignment'
+					].forEach( command => {
+						editor.commands.get( command ).isEnabled = false;
+					} );
+
+					expect( tablePropertiesButton.isEnabled ).to.be.false;
+
+					editor.commands.get( 'tableBackgroundColor' ).isEnabled = true;
+
+					expect( tablePropertiesButton.isEnabled ).to.be.true;
+				} );
 			} );
 		} );
 

+ 87 - 3
packages/ckeditor5-table/tests/ui/colorinputview.js

@@ -3,6 +3,8 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
+/* global Event */
+
 import ColorInputView from '../../src/ui/colorinputview';
 import InputTextView from '@ckeditor/ckeditor5-ui/src/inputtext/inputtextview';
 import ColorGridView from '@ckeditor/ckeditor5-ui/src/colorgrid/colorgridview';
@@ -138,7 +140,7 @@ describe( 'ColorInputView', () => {
 				expect( colorGridView ).to.be.instanceOf( ColorGridView );
 			} );
 
-			it( 'should set #value upon #execute', () => {
+			it( 'should set ColorInputView#value upon ColorTileView#execute', () => {
 				expect( view.value ).to.equal( '' );
 
 				colorGridView.items.last.fire( 'execute' );
@@ -146,7 +148,15 @@ describe( 'ColorInputView', () => {
 				expect( view.value ).to.equal( 'rgb(0,0,255)' );
 			} );
 
-			it( 'should close the dropdown upon #execute', () => {
+			it( 'should set InputTextView#value to the selected color\'s label upon ColorTileView#execute', () => {
+				expect( inputView.value ).to.equal( '' );
+
+				colorGridView.items.last.fire( 'execute' );
+
+				expect( inputView.value ).to.equal( 'Blue' );
+			} );
+
+			it( 'should close the dropdown upon ColorTileView#execute', () => {
 				view._dropdownView.isOpen = true;
 
 				colorGridView.items.last.fire( 'execute' );
@@ -154,7 +164,7 @@ describe( 'ColorInputView', () => {
 				expect( view._dropdownView.isOpen ).to.be.false;
 			} );
 
-			it( 'should fire #input upon #execute', () => {
+			it( 'should fire the ColorInputView#input event upon ColorTileView#execute', () => {
 				const spy = sinon.spy( view, 'fire' );
 
 				colorGridView.items.last.fire( 'execute' );
@@ -208,6 +218,15 @@ describe( 'ColorInputView', () => {
 				expect( inputView.value ).to.equal( 'bar' );
 			} );
 
+			it( `when the color input value is set to one of defined colors,
+			should use its label as the text input value`, () => {
+				view.value = 'rgb(0,255,0)';
+				expect( inputView.value ).to.equal( 'Green' );
+
+				view.value = 'rgb(255,0,0)';
+				expect( inputView.value ).to.equal( 'Red' );
+			} );
+
 			it( 'should have #isReadOnly bound to the color input', () => {
 				view.isReadOnly = true;
 				expect( inputView.isReadOnly ).to.equal( true );
@@ -236,6 +255,71 @@ describe( 'ColorInputView', () => {
 				expect( view.value ).to.equal( 'bar' );
 			} );
 
+			it(
+				`when any defined color label is given as the text input #value (case-sensitive),
+				should set the color as #value on #input event`,
+				() => {
+					inputView.element.value = 'Red';
+					inputView.fire( 'input' );
+
+					expect( view.value ).to.equal( 'rgb(255,0,0)' );
+
+					inputView.element.value = 'Green';
+					inputView.fire( 'input' );
+
+					expect( view.value ).to.equal( 'rgb(0,255,0)' );
+
+					inputView.element.value = 'blue';
+					inputView.fire( 'input' );
+
+					expect( view.value ).to.equal( 'blue' );
+				}
+			);
+
+			it(
+				`when any defined color label is given as the text input #value (case-sensitive),
+				then a non-defined value is set to the color input,
+				the latter value should be set to text input`,
+				() => {
+					inputView.element.value = 'Red';
+					inputView.fire( 'input' );
+
+					expect( view.value ).to.equal( 'rgb(255,0,0)' );
+
+					view.value = 'rgb(0,0,255)';
+
+					expect( view.value ).to.equal( 'rgb(0,0,255)' );
+				}
+			);
+
+			it(
+				`when any defined color value is given as the text input #value (case-sensitive),
+				its value should be set to color and text inputs after input event`,
+				() => {
+					inputView.element.value = 'rgb(255,0,0)';
+					inputView.fire( 'input' );
+
+					expect( view.value ).to.equal( 'rgb(255,0,0)' );
+					expect( inputView.element.value ).to.equal( 'rgb(255,0,0)' );
+				}
+			);
+
+			it(
+				`when any defined color value is given as the text input #value (case-sensitive),
+				its label should be set to text inputs after blur event on input view input element`,
+				() => {
+					inputView.element.value = 'rgb(255,0,0)';
+
+					inputView.fire( 'input' );
+
+					expect( inputView.element.value ).to.equal( 'rgb(255,0,0)' );
+
+					inputView.element.dispatchEvent( new Event( 'blur' ) );
+
+					expect( inputView.element.value ).to.equal( 'Red' );
+				}
+			);
+
 			it( 'should have #input event delegated to the color input', () => {
 				const spy = sinon.spy();
 				view.on( 'input', spy );

+ 21 - 8
packages/ckeditor5-ui/src/colorgrid/colorgridview.js

@@ -95,21 +95,21 @@ export default class ColorGridView extends View {
 			colorTile.isOn = colorTile.color === this.selectedColor;
 		} );
 
-		colorDefinitions.forEach( item => {
+		colorDefinitions.forEach( color => {
 			const colorTile = new ColorTileView();
 
 			colorTile.set( {
-				color: item.color,
-				label: item.label,
+				color: color.color,
+				label: color.label,
 				tooltip: true,
-				hasBorder: item.options.hasBorder
+				hasBorder: color.options.hasBorder
 			} );
 
 			colorTile.on( 'execute', () => {
 				this.fire( 'execute', {
-					value: item.color,
-					hasBorder: item.options.hasBorder,
-					label: item.label
+					value: color.color,
+					hasBorder: color.options.hasBorder,
+					label: color.label
 				} );
 			} );
 
@@ -175,13 +175,26 @@ export default class ColorGridView extends View {
 		// Start listening for the keystrokes coming from #element.
 		this.keystrokes.listenTo( this.element );
 	}
+
+	/**
+	 * Fired when the `ColorTileView` for the picked item is executed.
+	 *
+	 * @event execute
+	 * @param {Object} data Additional information about the event.
+	 * @param {String} data.value The value of the selected color
+	 * ({@link module:ui/colorgrid/colorgrid~ColorDefinition#color `color.color`}).
+	 * @param {Boolean} data.hasBorder The `hasBorder` property of the selected color
+	 * ({@link module:ui/colorgrid/colorgrid~ColorDefinition#options `color.options.hasBorder`}).
+	 * @param {String} data.Label The label of the selected color
+	 * ({@link module:ui/colorgrid/colorgrid~ColorDefinition#label `color.label`})
+	 */
 }
 
 /**
  * A color definition used to create a {@link module:ui/colorgrid/colortile~ColorTileView}.
  *
  *		{
- *			color: hsl(0, 0%, 75%),
+ *			color: 'hsl(0, 0%, 75%)',
  *			label: 'Light Grey',
  *			options: {
  *				hasBorder: true

Разница между файлами не показана из-за своего большого размера
+ 264 - 293
yarn.lock


Некоторые файлы не были показаны из-за большого количества измененных файлов