8
0
Pārlūkot izejas kodu

Add MergeCellsCommand.

Maciej Gołaszewski 7 gadi atpakaļ
vecāks
revīzija
f0a2c29c5e

+ 161 - 0
packages/ckeditor5-table/src/commands/mergecellscommand.js

@@ -0,0 +1,161 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module table/commands/mergecellscommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+import Position from '@ckeditor/ckeditor5-engine/src/model/position';
+import Range from '@ckeditor/ckeditor5-engine/src/model/range';
+import TableWalker from '../tablewalker';
+import { updateNumericAttribute } from './utils';
+import TableUtils from '../tableutils';
+import TableSelection from '../tableselection';
+
+/**
+ * The merge cells command.
+ *
+ * The command is registered by {@link module:table/tableediting~TableEditing} as `'mergeTableCellRight'`, `'mergeTableCellLeft'`,
+ * `'mergeTableCellUp'` and `'mergeTableCellDown'` editor commands.
+ *
+ * To merge a table cell at the current selection with another cell, execute the command corresponding with the preferred direction.
+ *
+ * For example, to merge with a cell to the right:
+ *
+ *        editor.execute( 'mergeTableCellRight' );
+ *
+ * **Note**: If a table cell has a different [`rowspan`](https://www.w3.org/TR/html50/tabular-data.html#attr-tdth-rowspan)
+ * (for `'mergeTableCellRight'` and `'mergeTableCellLeft'`) or [`colspan`](https://www.w3.org/TR/html50/tabular-data.html#attr-tdth-colspan)
+ * (for `'mergeTableCellUp'` and `'mergeTableCellDown'`), the command will be disabled.
+ *
+ * @extends module:core/command~Command
+ */
+export default class MergeCellsCommand extends Command {
+	/**
+	 * @inheritDoc
+	 */
+	refresh() {
+		const tableSelection = this.editor.plugins.get( TableSelection );
+
+		this.isEnabled = !!tableSelection.size && canMerge( Array.from( tableSelection.getSelection() ) );
+	}
+
+	/**
+	 * Executes the command.
+	 *
+	 * Depending on the command's {@link #direction} value, it will merge the cell that is to the `'left'`, `'right'`, `'up'` or `'down'`.
+	 *
+	 * @fires execute
+	 */
+	execute() {
+		const model = this.editor.model;
+
+		const tableSelection = this.editor.plugins.get( TableSelection );
+		const tableUtils = this.editor.plugins.get( TableUtils );
+
+		model.change( writer => {
+			const selectedTableCells = [ ... tableSelection.getSelection() ];
+
+			tableSelection.clearSelection();
+
+			const firstTableCell = selectedTableCells.shift();
+			const { row, column } = tableUtils.getCellLocation( firstTableCell );
+
+			const colspan = parseInt( firstTableCell.getAttribute( 'colspan' ) || 1 );
+			const rowspan = parseInt( firstTableCell.getAttribute( 'rowspan' ) || 1 );
+
+			let rightMax = column + colspan;
+			let bottomMax = row + rowspan;
+
+			const rowsToCheck = new Set();
+
+			for ( const tableCell of selectedTableCells ) {
+				const { row, column } = tableUtils.getCellLocation( tableCell );
+
+				const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
+				const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
+
+				if ( column + colspan > rightMax ) {
+					rightMax = column + colspan;
+				}
+
+				if ( row + rowspan > bottomMax ) {
+					bottomMax = row + rowspan;
+				}
+			}
+
+			for ( const tableCell of selectedTableCells ) {
+				rowsToCheck.add( tableCell.parent );
+				mergeTableCells( tableCell, firstTableCell, writer );
+			}
+
+			// Update table cell span attribute and merge set selection on merged contents.
+			updateNumericAttribute( 'colspan', rightMax - column, firstTableCell, writer );
+			updateNumericAttribute( 'rowspan', bottomMax - row, firstTableCell, writer );
+
+			writer.setSelection( Range.createIn( firstTableCell ) );
+
+			// Remove empty rows after merging table cells.
+			for ( const row of rowsToCheck ) {
+				if ( !row.childCount ) {
+					removeEmptyRow( row, writer );
+				}
+			}
+		} );
+	}
+}
+
+// Properly removes empty row from a table. Will update `rowspan` attribute of cells that overlaps 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 - will ensure that after merging cells with empty paragraph the result table cell will only have one paragraph.
+// If one of the merged table cell is empty the merged table cell will have contents of the non-empty table cell.
+// If both are empty the merged table cell will have only one empty paragraph.
+//
+// @param {module:engine/model/element~Element} cellToRemove
+// @param {module:engine/model/element~Element} cellToExpand
+// @param {module:engine/model/writer~Writer} writer
+function mergeTableCells( cellToRemove, cellToExpand, writer ) {
+	if ( !isEmpty( cellToRemove ) ) {
+		if ( isEmpty( cellToExpand ) ) {
+			writer.remove( Range.createIn( cellToExpand ) );
+		}
+
+		writer.move( Range.createIn( cellToRemove ), Position.createAt( cellToExpand, 'end' ) );
+	}
+
+	// Remove merged table cell.
+	writer.remove( cellToRemove );
+}
+
+// Checks if passed table cell contains empty paragraph.
+//
+// @param {module:engine/model/element~Element} tableCell
+// @returns {Boolean}
+function isEmpty( tableCell ) {
+	return tableCell.childCount == 1 && tableCell.getChild( 0 ).is( 'paragraph' ) && tableCell.getChild( 0 ).isEmpty;
+}
+
+function canMerge() {
+	return true;
+}

+ 4 - 86
packages/ckeditor5-table/src/tableediting.js

@@ -34,11 +34,10 @@ import { findAncestor } from './commands/utils';
 import TableUtils from '../src/tableutils';
 
 import injectTablePostFixer from './converters/table-post-fixer';
-import Position from '@ckeditor/ckeditor5-engine/src/model/position';
 import injectTableCellPostFixer from './converters/tablecell-post-fixer';
-import TableSelection from './tableselection';
 
 import '../theme/tableediting.css';
+import MergeCellsCommand from './commands/mergecellscommand';
 
 /**
  * The table editing feature.
@@ -54,7 +53,6 @@ export default class TableEditing extends Plugin {
 		const model = editor.model;
 		const schema = model.schema;
 		const conversion = editor.conversion;
-		const viewDocument = editor.editing.view.document;
 
 		schema.register( 'table', {
 			allowWhere: '$block',
@@ -141,6 +139,8 @@ export default class TableEditing extends Plugin {
 		editor.commands.add( 'mergeTableCellDown', new MergeCellCommand( editor, { direction: 'down' } ) );
 		editor.commands.add( 'mergeTableCellUp', new MergeCellCommand( editor, { direction: 'up' } ) );
 
+		editor.commands.add( 'mergeTableCells', new MergeCellsCommand( editor ) );
+
 		editor.commands.add( 'setTableColumnHeader', new SetHeaderColumnCommand( editor ) );
 		editor.commands.add( 'setTableRowHeader', new SetHeaderRowCommand( editor ) );
 
@@ -150,69 +150,13 @@ export default class TableEditing extends Plugin {
 		this.editor.keystrokes.set( 'Tab', ( ...args ) => this._handleTabOnSelectedTable( ...args ), { priority: 'low' } );
 		this.editor.keystrokes.set( 'Tab', this._getTabHandler( true ), { priority: 'low' } );
 		this.editor.keystrokes.set( 'Shift+Tab', this._getTabHandler( false ), { priority: 'low' } );
-
-		const tableSelection = editor.plugins.get( TableSelection );
-
-		this.listenTo( viewDocument, 'mousedown', ( eventInfo, domEventData ) => {
-			const tableCell = getTableCell( domEventData, this.editor );
-
-			if ( !tableCell ) {
-				return;
-			}
-
-			const { column, row } = editor.plugins.get( TableUtils ).getCellLocation( tableCell );
-
-			const mode = getSelectionMode( domEventData, column, row );
-
-			tableSelection.startSelection( tableCell, mode );
-
-			domEventData.preventDefault();
-		} );
-
-		this.listenTo( viewDocument, 'mousemove', ( eventInfo, domEventData ) => {
-			if ( !tableSelection.isSelecting ) {
-				return;
-			}
-
-			const tableCell = getTableCell( domEventData, this.editor );
-
-			if ( !tableCell ) {
-				return;
-			}
-
-			tableSelection.updateSelection( tableCell );
-		} );
-
-		this.listenTo( viewDocument, 'mouseup', ( eventInfo, domEventData ) => {
-			if ( !tableSelection.isSelecting ) {
-				return;
-			}
-
-			const tableCell = getTableCell( domEventData, this.editor );
-
-			tableSelection.stopSelection( tableCell );
-		} );
-
-		this.listenTo( viewDocument, 'blur', () => {
-			tableSelection.clearSelection();
-		} );
-
-		viewDocument.selection.on( 'change', () => {
-			for ( const range of viewDocument.selection.getRanges() ) {
-				const node = range.start.nodeAfter;
-
-				if ( node && ( node.is( 'td' ) || node.is( 'th' ) ) ) {
-					editor.editing.view.change( writer => writer.addClass( 'selected', node ) );
-				}
-			}
-		} );
 	}
 
 	/**
 	 * @inheritDoc
 	 */
 	static get requires() {
-		return [ TableUtils, TableSelection ];
+		return [ TableUtils ];
 	}
 
 	/**
@@ -310,29 +254,3 @@ export default class TableEditing extends Plugin {
 		};
 	}
 }
-
-function getTableCell( domEventData, editor ) {
-	const element = domEventData.target;
-	const modelElement = editor.editing.mapper.toModelElement( element );
-
-	if ( !modelElement ) {
-		return;
-	}
-
-	return findAncestor( 'tableCell', Position.createAt( modelElement ) );
-}
-
-function getSelectionMode( domEventData, column, row ) {
-	let mode = 'block';
-
-	const domEvent = domEventData.domEvent;
-	const target = domEvent.target;
-
-	if ( column == 0 && domEvent.offsetX < target.clientWidth / 2 ) {
-		mode = 'row';
-	} else if ( row == 0 && ( domEvent.offsetY < target.clientHeight / 2 ) ) {
-		mode = 'column';
-	}
-
-	return mode;
-}

+ 111 - 8
packages/ckeditor5-table/src/tableselection.js

@@ -9,9 +9,11 @@
 
 import ViewRange from '@ckeditor/ckeditor5-engine/src/view/range';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import Position from '@ckeditor/ckeditor5-engine/src/model/position';
 
 import TableWalker from './tablewalker';
 import TableUtils from './tableutils';
+import { findAncestor } from './commands/utils';
 
 export default class TableSelection extends Plugin {
 	/**
@@ -38,24 +40,101 @@ export default class TableSelection extends Plugin {
 		this.tableUtils = editor.plugins.get( TableUtils );
 	}
 
+	init() {
+		const editor = this.editor;
+		const viewDocument = editor.editing.view.document;
+
+		this.listenTo( viewDocument, 'mousedown', ( eventInfo, domEventData ) => {
+			const tableCell = getTableCell( domEventData, this.editor );
+
+			if ( !tableCell ) {
+				return;
+			}
+
+			this.startSelection( tableCell );
+		} );
+
+		this.listenTo( viewDocument, 'mousemove', ( eventInfo, domEventData ) => {
+			if ( !this.isSelecting ) {
+				return;
+			}
+
+			const tableCell = getTableCell( domEventData, this.editor );
+
+			if ( !tableCell ) {
+				return;
+			}
+
+			const wasOne = this.size === 1;
+
+			this.updateSelection( tableCell );
+
+			if ( this.size > 1 ) {
+				domEventData.preventDefault();
+
+				if ( wasOne ) {
+					editor.editing.view.change( writer => {
+						const viewElement = editor.editing.mapper.toViewElement( this._startElement );
+
+						writer.setSelection( ViewRange.createIn( viewElement ), {
+							fake: true,
+							label: 'fake selection over table cell'
+						} );
+					} );
+				}
+
+				this.redrawSelection();
+			}
+		} );
+
+		this.listenTo( viewDocument, 'mouseup', ( eventInfo, domEventData ) => {
+			if ( !this.isSelecting ) {
+				return;
+			}
+
+			const tableCell = getTableCell( domEventData, this.editor );
+
+			this.stopSelection( tableCell );
+		} );
+	}
+
 	get isSelecting() {
 		return this._isSelecting;
 	}
 
+	get size() {
+		return [ ...this.getSelection() ].length;
+	}
+
 	startSelection( tableCell ) {
 		this.clearSelection();
 		this._isSelecting = true;
 		this._startElement = tableCell;
 		this._endElement = tableCell;
-		this._redrawSelection();
 	}
 
 	updateSelection( tableCell ) {
-		if ( this.isSelecting && tableCell && tableCell.parent.parent === this._startElement.parent.parent ) {
-			this._endElement = tableCell;
+		// Do not update if not in selection mode or no table cell passed.
+		if ( !this.isSelecting || !tableCell ) {
+			return;
 		}
 
-		this._redrawSelection();
+		const table = this._startElement.parent.parent;
+
+		// Do not add tableCell to selection if it is from other table or is already set as end element.
+		if ( table !== tableCell.parent.parent || this._endElement === tableCell ) {
+			return;
+		}
+
+		const headingRows = parseInt( table.getAttribute( 'headingRows' ) || 0 );
+		const startInHeading = this._startElement.parent.index < headingRows;
+		const updateCellInHeading = tableCell.parent.index < headingRows;
+
+		// Only add cell to selection if they are in the same table section.
+		if ( startInHeading === updateCellInHeading ) {
+			this._endElement = tableCell;
+			this.redrawSelection();
+		}
 	}
 
 	stopSelection( tableCell ) {
@@ -64,14 +143,13 @@ export default class TableSelection extends Plugin {
 		}
 
 		this._isSelecting = false;
-		this._redrawSelection();
 	}
 
 	clearSelection() {
 		this._startElement = undefined;
 		this._endElement = undefined;
 		this._isSelecting = false;
-		this.updateSelection();
+		this.clearPreviousSelection();
 		this._highlighted.clear();
 	}
 
@@ -100,7 +178,7 @@ export default class TableSelection extends Plugin {
 		}
 	}
 
-	_redrawSelection() {
+	redrawSelection() {
 		const viewRanges = [];
 
 		const selected = [ ...this.getSelection() ];
@@ -118,12 +196,37 @@ export default class TableSelection extends Plugin {
 		this.editor.editing.view.change( writer => {
 			for ( const previouslyHighlighted of previous ) {
 				if ( !selected.includes( previouslyHighlighted ) ) {
-					// TODO: unify somewhere...
 					writer.removeClass( 'selected', previouslyHighlighted );
 				}
 			}
 
+			for ( const currently of this._highlighted ) {
+				writer.addClass( 'selected', currently );
+			}
+
+			// TODO works on FF ony... :|
 			writer.setSelection( viewRanges, { fake: true, label: 'fake selection over table cell' } );
 		} );
 	}
+
+	clearPreviousSelection() {
+		const previous = [ ...this._highlighted.values() ];
+
+		this.editor.editing.view.change( writer => {
+			for ( const previouslyHighlighted of previous ) {
+				writer.removeClass( 'selected', previouslyHighlighted );
+			}
+		} );
+	}
+}
+
+function getTableCell( domEventData, editor ) {
+	const element = domEventData.target;
+	const modelElement = editor.editing.mapper.toModelElement( element );
+
+	if ( !modelElement ) {
+		return;
+	}
+
+	return findAncestor( 'tableCell', Position.createAt( modelElement ) );
 }

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

@@ -151,6 +151,14 @@ export default class TableUI extends Plugin {
 				{
 					type: 'button',
 					model: {
+						commandName: 'mergeTableCells',
+						label: t( 'Merge cells' )
+					}
+				},
+				{ type: 'separator' },
+				{
+					type: 'button',
+					model: {
 						commandName: 'mergeTableCellUp',
 						label: t( 'Merge cell up' )
 					}

+ 2 - 1
packages/ckeditor5-table/tests/converters/table-post-fixer.js

@@ -11,6 +11,7 @@ import { getData as getModelData, parse, setData as setModelData } from '@ckedit
 import TableEditing from '../../src/tableediting';
 import { formatTable, formattedModelTable, modelTable } from './../_utils/utils';
 import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting';
+import TableSelection from '../../src/tableselection';
 
 describe( 'Table post-fixer', () => {
 	let editor, model, root;
@@ -18,7 +19,7 @@ describe( 'Table post-fixer', () => {
 	beforeEach( () => {
 		return VirtualTestEditor
 			.create( {
-				plugins: [ TableEditing, Paragraph, UndoEditing ]
+				plugins: [ TableEditing, TableSelection, Paragraph, UndoEditing ]
 			} )
 			.then( newEditor => {
 				editor = newEditor;

+ 2 - 1
packages/ckeditor5-table/tests/integration.js

@@ -11,6 +11,7 @@ import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 import Table from '../src/table';
 import TableToolbar from '../src/tabletoolbar';
 import View from '@ckeditor/ckeditor5-ui/src/view';
+import TableSelection from '../src/tableselection';
 
 describe( 'TableToolbar integration', () => {
 	describe( 'with the BalloonToolbar', () => {
@@ -22,7 +23,7 @@ describe( 'TableToolbar integration', () => {
 
 			return ClassicTestEditor
 				.create( editorElement, {
-					plugins: [ Table, TableToolbar, BalloonToolbar, Paragraph ]
+					plugins: [ Table, TableSelection, TableToolbar, BalloonToolbar, Paragraph ]
 				} )
 				.then( editor => {
 					newEditor = editor;

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

@@ -10,10 +10,11 @@ import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articleplugi
 import Table from '../../src/table';
 import TableToolbar from '../../src/tabletoolbar';
 import Alignment from '@ckeditor/ckeditor5-alignment/src/alignment';
+import TableSelection from '../../src/tableselection';
 
 ClassicEditor
 	.create( document.querySelector( '#editor' ), {
-		plugins: [ ArticlePluginSet, Table, TableToolbar, Alignment ],
+		plugins: [ ArticlePluginSet, Table, TableToolbar, TableSelection, Alignment ],
 		toolbar: [
 			'heading', '|', 'insertTable', '|', 'bold', 'italic', 'bulletedList', 'numberedList', 'blockQuote',
 			'alignment', '|', 'undo', 'redo'

+ 3 - 2
packages/ckeditor5-table/tests/table-integration.js

@@ -19,6 +19,7 @@ import { parse as parseView } from '@ckeditor/ckeditor5-engine/src/dev-utils/vie
 
 import TableEditing from '../src/tableediting';
 import { formatTable, formattedModelTable, modelTable, viewTable } from './_utils/utils';
+import TableSelection from '../src/tableselection';
 
 describe( 'Table feature – integration', () => {
 	describe( 'with clipboard', () => {
@@ -26,7 +27,7 @@ describe( 'Table feature – integration', () => {
 
 		beforeEach( () => {
 			return VirtualTestEditor
-				.create( { plugins: [ Paragraph, TableEditing, ListEditing, BlockQuoteEditing, Widget, Clipboard ] } )
+				.create( { plugins: [ Paragraph, TableEditing, TableSelection, ListEditing, BlockQuoteEditing, Widget, Clipboard ] } )
 				.then( newEditor => {
 					editor = newEditor;
 					clipboard = editor.plugins.get( 'Clipboard' );
@@ -85,7 +86,7 @@ describe( 'Table feature – integration', () => {
 
 		beforeEach( () => {
 			return VirtualTestEditor
-				.create( { plugins: [ Paragraph, TableEditing, Widget, UndoEditing ] } )
+				.create( { plugins: [ Paragraph, TableEditing, TableSelection, Widget, UndoEditing ] } )
 				.then( newEditor => {
 					editor = newEditor;
 					doc = editor.model.document;

+ 3 - 2
packages/ckeditor5-table/tests/tableediting.js

@@ -20,6 +20,7 @@ import SplitCellCommand from '../src/commands/splitcellcommand';
 import MergeCellCommand from '../src/commands/mergecellcommand';
 import SetHeaderRowCommand from '../src/commands/setheaderrowcommand';
 import SetHeaderColumnCommand from '../src/commands/setheadercolumncommand';
+import TableSelection from '../src/tableselection';
 
 describe( 'TableEditing', () => {
 	let editor, model;
@@ -27,7 +28,7 @@ describe( 'TableEditing', () => {
 	beforeEach( () => {
 		return VirtualTestEditor
 			.create( {
-				plugins: [ TableEditing, Paragraph, ImageEditing ]
+				plugins: [ TableEditing, TableSelection, Paragraph, ImageEditing ]
 			} )
 			.then( newEditor => {
 				editor = newEditor;
@@ -466,7 +467,7 @@ describe( 'TableEditing', () => {
 
 			return VirtualTestEditor
 				.create( {
-					plugins: [ TableEditing, Paragraph ]
+					plugins: [ TableEditing, TableSelection, Paragraph ]
 				} )
 				.then( newEditor => {
 					editor = newEditor;

+ 2 - 1
packages/ckeditor5-table/tests/tabletoolbar.js

@@ -15,6 +15,7 @@ import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import Range from '@ckeditor/ckeditor5-engine/src/model/range';
 import View from '@ckeditor/ckeditor5-ui/src/view';
 import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import TableSelection from '../src/tableselection';
 
 describe( 'TableToolbar', () => {
 	let editor, model, doc, plugin, toolbar, balloon, editorElement;
@@ -25,7 +26,7 @@ describe( 'TableToolbar', () => {
 
 		return ClassicEditor
 			.create( editorElement, {
-				plugins: [ Paragraph, Table, TableToolbar, FakeButton ],
+				plugins: [ Paragraph, Table, TableSelection, TableToolbar, FakeButton ],
 				table: {
 					toolbar: [ 'fake_button' ]
 				}

+ 5 - 2
packages/ckeditor5-table/tests/tableui.js

@@ -14,8 +14,9 @@ import TableUI from '../src/tableui';
 import SwitchButtonView from '@ckeditor/ckeditor5-ui/src/button/switchbuttonview';
 import DropdownView from '@ckeditor/ckeditor5-ui/src/dropdown/dropdownview';
 import ListSeparatorView from '@ckeditor/ckeditor5-ui/src/list/listseparatorview';
+import TableSelection from '../src/tableselection';
 
-describe( 'TableUI', () => {
+describe.only( 'TableUI', () => {
 	let editor, element;
 
 	testUtils.createSinonSandbox();
@@ -35,7 +36,7 @@ describe( 'TableUI', () => {
 
 		return ClassicTestEditor
 			.create( element, {
-				plugins: [ TableEditing, TableUI ]
+				plugins: [ TableEditing, TableSelection, TableUI ]
 			} )
 			.then( newEditor => {
 				editor = newEditor;
@@ -326,6 +327,8 @@ describe( 'TableUI', () => {
 			const labels = listView.items.map( item => item instanceof ListSeparatorView ? '|' : item.children.first.label );
 
 			expect( labels ).to.deep.equal( [
+				'Merge cells',
+				'|',
 				'Merge cell up',
 				'Merge cell right',
 				'Merge cell down',