Browse Source

Merge branch 'master' into i/ckeditor5-dev/481

Maciej Gołaszewski 5 years ago
parent
commit
5d58ada0cd

+ 97 - 0
packages/ckeditor5-table/src/tableclipboard.js

@@ -0,0 +1,97 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tableclipboard
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import TableSelection from './tableselection';
+
+/**
+ * The table clipboard integration plugin.
+ *
+ * It introduces the ability to copy selected table cells.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class TableClipboard extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'TableClipboard';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ TableSelection ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const viewDocument = editor.editing.view.document;
+
+		/**
+		 * A table selection plugin instance.
+		 *
+		 * @private
+		 * @readonly
+		 * @member {module:table/tableselection~TableSelection} module:tableclipboard~TableClipboard#_tableSelection
+		 */
+		this._tableSelection = editor.plugins.get( 'TableSelection' );
+
+		this.listenTo( viewDocument, 'copy', ( evt, data ) => this._onCopy( evt, data ), { priority: 'normal' } );
+		this.listenTo( viewDocument, 'cut', ( evt, data ) => this._onCut( evt, data ), { priority: 'high' } );
+	}
+
+	/**
+	 * A clipboard "copy" event handler.
+	 *
+	 * @param {module:utils/eventinfo~EventInfo} evt An object containing information about the handled event.
+	 * @param {Object} data Clipboard event data.
+	 * @private
+	 */
+	_onCopy( evt, data ) {
+		const tableSelection = this._tableSelection;
+
+		if ( !tableSelection.hasMultiCellSelection ) {
+			return;
+		}
+
+		data.preventDefault();
+		evt.stop();
+
+		const dataController = this.editor.data;
+		const viewDocument = this.editor.editing.view.document;
+
+		const content = dataController.toView( tableSelection.getSelectionAsFragment() );
+
+		viewDocument.fire( 'clipboardOutput', {
+			dataTransfer: data.dataTransfer,
+			content,
+			method: evt.name
+		} );
+	}
+
+	/**
+	 * A clipboard "cut" event handler.
+	 *
+	 * @param {module:utils/eventinfo~EventInfo} evt An object containing information about the handled event.
+	 * @param {Object} data Clipboard event data.
+	 * @private
+	 */
+	_onCut( evt, data ) {
+		if ( this._tableSelection.hasMultiCellSelection ) {
+			data.preventDefault();
+			evt.stop();
+		}
+	}
+}

+ 23 - 1
packages/ckeditor5-table/src/tableselection.js

@@ -13,6 +13,8 @@ import TableWalker from './tablewalker';
 import TableUtils from './tableutils';
 import { setupTableSelectionHighlighting } from './tableselection/converters';
 import MouseSelectionHandler from './tableselection/mouseselectionhandler';
+import { findAncestor } from './commands/utils';
+import cropTable from './tableselection/croptable';
 
 import '../theme/tableselection.css';
 
@@ -214,7 +216,7 @@ export default class TableSelection extends Plugin {
 		const startColumn = Math.min( startLocation.column, endLocation.column );
 		const endColumn = Math.max( startLocation.column, endLocation.column );
 
-		for ( const cellInfo of new TableWalker( this._startElement.parent.parent, { startRow, endRow } ) ) {
+		for ( const cellInfo of new TableWalker( findAncestor( 'table', this._startElement ), { startRow, endRow } ) ) {
 			if ( cellInfo.column >= startColumn && cellInfo.column <= endColumn ) {
 				yield cellInfo.cell;
 			}
@@ -222,6 +224,26 @@ export default class TableSelection extends Plugin {
 	}
 
 	/**
+	 * Returns selected table fragment as a document fragment.
+	 *
+	 * @returns {module:engine/model/documentfragment~DocumentFragment|undefined}
+	 */
+	getSelectionAsFragment() {
+		if ( !this.hasMultiCellSelection ) {
+			return;
+		}
+
+		return this.editor.model.change( writer => {
+			const documentFragment = writer.createDocumentFragment();
+
+			const table = cropTable( this.getSelectedTableCells(), this._tableUtils, writer );
+			writer.insert( table, documentFragment, 0 );
+
+			return documentFragment;
+		} );
+	}
+
+	/**
 	 * Synchronizes the model selection with currently selected table cells.
 	 *
 	 * @private

+ 152 - 0
packages/ckeditor5-table/src/tableselection/croptable.js

@@ -0,0 +1,152 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tableselection/croptable
+ */
+
+import { findAncestor } from '../commands/utils';
+
+/**
+ * Returns cropped table from selected table cells.
+ *
+ * This is to be used with table selection
+ *
+ *		tableSelection.startSelectingFrom( startCell )
+ *		tableSelection.setSelectingFrom( endCell )
+ *
+ *		const croppedTable = cropTable( tableSelection.getSelectedTableCells );
+ *
+ * **Note**: This function is used also by {@link module:table/tableselection~TableSelection#getSelectionAsFragment}
+ *
+ * @param {Iterable.<module:engine/model/element~Element>} selectedTableCellsIterator
+ * @param {module:table/tableutils~TableUtils} tableUtils
+ * @param {module:engine/model/writer~Writer} writer
+ * @returns {module:engine/model/element~Element}
+ */
+export default function cropTable( selectedTableCellsIterator, tableUtils, writer ) {
+	const selectedTableCells = Array.from( selectedTableCellsIterator );
+	const startElement = selectedTableCells[ 0 ];
+	const endElement = selectedTableCells[ selectedTableCells.length - 1 ];
+
+	const { row: startRow, column: startColumn } = tableUtils.getCellLocation( startElement );
+
+	const tableCopy = makeTableCopy( selectedTableCells, startColumn, writer, tableUtils );
+
+	const { row: endRow, column: endColumn } = tableUtils.getCellLocation( endElement );
+	const selectionWidth = endColumn - startColumn + 1;
+	const selectionHeight = endRow - startRow + 1;
+
+	trimTable( tableCopy, selectionWidth, selectionHeight, writer, tableUtils );
+
+	const sourceTable = findAncestor( 'table', startElement );
+	addHeadingsToTableCopy( tableCopy, sourceTable, startRow, startColumn, writer );
+
+	return tableCopy;
+}
+
+// Creates a table copy from a selected table cells.
+//
+// It fills "gaps" in copied table - ie when cell outside copied range was spanning over selection.
+function makeTableCopy( selectedTableCells, startColumn, writer, tableUtils ) {
+	const tableCopy = writer.createElement( 'table' );
+
+	const rowToCopyMap = new Map();
+	const copyToOriginalColumnMap = new Map();
+
+	for ( const tableCell of selectedTableCells ) {
+		const row = findAncestor( 'tableRow', tableCell );
+
+		if ( !rowToCopyMap.has( row ) ) {
+			const rowCopy = row._clone();
+			writer.append( rowCopy, tableCopy );
+			rowToCopyMap.set( row, rowCopy );
+		}
+
+		const tableCellCopy = tableCell._clone( true );
+		const { column } = tableUtils.getCellLocation( tableCell );
+
+		copyToOriginalColumnMap.set( tableCellCopy, column );
+
+		writer.append( tableCellCopy, rowToCopyMap.get( row ) );
+	}
+
+	addMissingTableCells( tableCopy, startColumn, copyToOriginalColumnMap, writer, tableUtils );
+
+	return tableCopy;
+}
+
+// Fills gaps for spanned cell from outside the selection range.
+function addMissingTableCells( tableCopy, startColumn, copyToOriginalColumnMap, writer, tableUtils ) {
+	for ( const row of tableCopy.getChildren() ) {
+		for ( const tableCell of Array.from( row.getChildren() ) ) {
+			const { column } = tableUtils.getCellLocation( tableCell );
+
+			const originalColumn = copyToOriginalColumnMap.get( tableCell );
+			const shiftedColumn = originalColumn - startColumn;
+
+			if ( column !== shiftedColumn ) {
+				for ( let i = 0; i < shiftedColumn - column; i++ ) {
+					const prepCell = writer.createElement( 'tableCell' );
+					writer.insert( prepCell, writer.createPositionBefore( tableCell ) );
+
+					const paragraph = writer.createElement( 'paragraph' );
+
+					writer.insert( paragraph, prepCell, 0 );
+					writer.insertText( '', paragraph, 0 );
+				}
+			}
+		}
+	}
+}
+
+// Trims table to a given dimensions.
+function trimTable( table, width, height, writer, tableUtils ) {
+	for ( const row of table.getChildren() ) {
+		for ( const tableCell of row.getChildren() ) {
+			const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
+			const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
+
+			const { row, column } = tableUtils.getCellLocation( tableCell );
+
+			if ( column + colspan > width ) {
+				const newSpan = width - column;
+
+				if ( newSpan > 1 ) {
+					writer.setAttribute( 'colspan', newSpan, tableCell );
+				} else {
+					writer.removeAttribute( 'colspan', tableCell );
+				}
+			}
+
+			if ( row + rowspan > height ) {
+				const newSpan = height - row;
+
+				if ( newSpan > 1 ) {
+					writer.setAttribute( 'rowspan', newSpan, tableCell );
+				} else {
+					writer.removeAttribute( 'rowspan', tableCell );
+				}
+			}
+		}
+	}
+}
+
+// Sets proper heading attributes to copied table.
+function addHeadingsToTableCopy( tableCopy, sourceTable, startRow, startColumn, writer ) {
+	const headingRows = parseInt( sourceTable.getAttribute( 'headingRows' ) || 0 );
+
+	if ( headingRows > 0 ) {
+		const copiedRows = headingRows - startRow;
+		writer.setAttribute( 'headingRows', copiedRows, tableCopy );
+	}
+
+	const headingColumns = parseInt( sourceTable.getAttribute( 'headingColumns' ) || 0 );
+
+	if ( headingColumns > 0 ) {
+		const copiedColumns = headingColumns - startColumn;
+		writer.setAttribute( 'headingColumns', copiedColumns, tableCopy );
+	}
+}

+ 174 - 0
packages/ckeditor5-table/tests/manual/tableclipboard.html

@@ -0,0 +1,174 @@
+<style>
+	body {
+		font-family: Helvetica, Arial, sans-serif;
+		font-size: 14px;
+	}
+
+	.print-selected {
+		background: #b8e3ff;
+	}
+</style>
+
+<div style="display:flex">
+	<div style="flex:1;">
+
+		<h3>A "content" test editor</h3>
+
+		<div id="editor-content">
+			<p>Jelly-o topping chocolate danish. Powder donut dragée cupcake sesame snaps cotton candy. Cotton candy pudding apple pie liquorice
+				sugar plum cookie. Powder gummi bears macaroon. Gingerbread soufflé liquorice jelly-o marzipan pudding. Toffee sweet candy canes danish
+				macaroon cotton candy fruitcake. Sesame snaps cake cookie chocolate cupcake. Ice cream pie apple pie sweet. Wafer ice cream gingerbread
+				fruitcake donut jelly sweet. Sugar plum chocolate gummi bears. Jelly-o oat cake wafer brownie gingerbread pie dragée. Marshmallow
+				marzipan candy canes. Cotton candy liquorice cake soufflé pie candy.</p>
+
+			<figure class="table" style="width:60%;">
+				<table
+					style="background-color:hsl(0,0%,90%);border-bottom:2px solid hsl(30, 75%, 60%);border-left:2px solid hsl(30, 75%, 60%);border-right:2px solid hsl(30, 75%, 60%);border-top:2px solid hsl(30, 75%, 60%);">
+					<thead>
+						<tr>
+							<th>0</th>
+							<th>1</th>
+							<th>2</th>
+							<th>3</th>
+							<th>4</th>
+						</tr>
+					</thead>
+					<tbody>
+						<tr>
+							<td>a</td>
+							<td>b</td>
+							<td style="border-bottom:4px dashed hsl(0, 75%, 60%);border-left:4px dashed hsl(0, 75%, 60%);border-right:4px dashed hsl(0, 75%, 60%);border-top:4px dashed hsl(0, 75%, 60%);height:30px;text-align:right;vertical-align:top;width:30px;">
+								c
+							</td>
+							<td>d</td>
+							<td><i><strong>e</strong></i></td>
+						</tr>
+						<tr>
+							<td style="background-color:hsl(210,75%,60%);">f</td>
+							<td style="background-color:hsl(210,75%,60%);">g</td>
+							<td style="background-color:hsl(210,75%,60%);">h</td>
+							<td style="background-color:hsl(210,75%,60%);">i</td>
+							<td style="background-color:hsl(210,75%,60%);"><i><strong>j</strong></i></td>
+						</tr>
+						<tr>
+							<td>k</td>
+							<td>l</td>
+							<td>m</td>
+							<td>n</td>
+							<td><i><strong>o</strong></i></td>
+						</tr>
+						<tr>
+							<td>p</td>
+							<td>q</td>
+							<td>r</td>
+							<td>s</td>
+							<td>t</td>
+						</tr>
+					</tbody>
+				</table>
+			</figure>
+		</div>
+
+		<h3>A "geometry" test editor</h3>
+
+		<div id="editor-geometry">
+			<figure class="table">
+				<table>
+					<thead>
+						<tr>
+							<th>a</th>
+							<th>b</th>
+							<th>c</th>
+							<th>d</th>
+							<th>e</th>
+							<th>f</th>
+							<th>g</th>
+							<th>h</th>
+							<th>i</th>
+						</tr>
+					</thead>
+					<tbody>
+						<tr>
+							<td>00</td>
+							<td>01</td>
+							<td rowspan="4">02</td>
+							<td>03</td>
+							<td colspan="2" rowspan="7">04</td>
+							<td>07</td>
+							<td>07</td>
+							<td>08</td>
+						</tr>
+						<tr>
+							<td>10</td>
+							<td>11</td>
+							<td>13</td>
+							<td>17</td>
+							<td>17</td>
+							<td>18</td>
+						</tr>
+						<tr>
+							<td>20</td>
+							<td>21</td>
+							<td>23</td>
+							<td colspan="3">27</td>
+						</tr>
+						<tr>
+							<td>30</td>
+							<td>31</td>
+							<td>33</td>
+							<td>37</td>
+							<td colspan="2">37</td>
+						</tr>
+						<tr>
+							<td colspan="4">40</td>
+							<td>47</td>
+							<td>47</td>
+							<td>48</td>
+						</tr>
+						<tr>
+							<td>50</td>
+							<td>51</td>
+							<td>52</td>
+							<td>53</td>
+							<td rowspan="4">57</td>
+							<td>57</td>
+							<td>58</td>
+						</tr>
+						<tr>
+							<td>60</td>
+							<td colspan="3">61</td>
+							<td>67</td>
+							<td>68</td>
+						</tr>
+						<tr>
+							<td>70</td>
+							<td rowspan="2">71</td>
+							<td>72</td>
+							<td>73</td>
+							<td>74</td>
+							<td>75</td>
+							<td>77</td>
+							<td>78</td>
+						</tr>
+						<tr>
+							<td>80</td>
+							<td>82</td>
+							<td>83</td>
+							<td>84</td>
+							<td>85</td>
+							<td>87</td>
+							<td>88</td>
+						</tr>
+					</tbody>
+				</table>
+			</figure>
+		</div>
+	</div>
+
+	<div style="padding-left: 2em;flex: 1;">
+		<h3>Content editable to test paste</h3>
+		<div contenteditable="true" class="ck-content" style="padding: 1em;border: 1px dotted #333;">
+			paste here
+		</div>
+	</div>
+</div>

+ 44 - 0
packages/ckeditor5-table/tests/manual/tableclipboard.js

@@ -0,0 +1,44 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals console, document, window, CKEditorInspector */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import Table from '../../src/table';
+import TableToolbar from '../../src/tabletoolbar';
+import TableSelection from '../../src/tableselection';
+import TableClipboard from '../../src/tableclipboard';
+import TableProperties from '../../src/tableproperties';
+import TableCellProperties from '../../src/tablecellproperties';
+
+window.editors = {};
+
+createEditor( '#editor-content', 'content' );
+createEditor( '#editor-geometry', 'geometry' );
+
+function createEditor( target, inspectorName ) {
+	ClassicEditor
+		.create( document.querySelector( target ), {
+			plugins: [ ArticlePluginSet, Table, TableToolbar, TableSelection, TableClipboard, TableProperties, TableCellProperties ],
+			toolbar: [
+				'heading', '|',
+				'insertTable', '|',
+				'bold', 'italic', 'link', '|',
+				'bulletedList', 'numberedList', 'blockQuote', '|',
+				'undo', 'redo'
+			],
+			table: {
+				contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells', 'tableProperties', 'tableCellProperties' ]
+			}
+		} )
+		.then( editor => {
+			window.editors[ inspectorName ] = editor;
+			CKEditorInspector.attach( inspectorName, editor );
+		} )
+		.catch( err => {
+			console.error( err.stack );
+		} );
+}

+ 21 - 0
packages/ckeditor5-table/tests/manual/tableclipboard.md

@@ -0,0 +1,21 @@
+### Testing
+
+Copying selected table cells:
+
+1. Select a fragment of table cell.
+2. Use copy shortcut <kbd>ctrl</kbd>+<kbd>C</kbd>.
+3. Paste selected content:
+    - somewhere in the document.
+    - in the editable field on the right.
+4. The pasted table should:
+    - be rectangular (no missing nor exceeding table cells)
+    - have proper headings
+5. The editors are exposed as:
+    - `window.editor.content` and "content" editor in CKEditor inspector
+    - `window.editor.geometry` and "geometry" editor in CKEditor inspector
+
+Note that table copy:
+
+- have cut disabled
+- paste in table is not possible
+- pasted table can be fixed by a post-fixer (use content editable to verify what's being copied)

+ 85 - 52
packages/ckeditor5-table/tests/manual/tableselection.html

@@ -7,67 +7,100 @@
 	.print-selected {
 		background: #b8e3ff;
 	}
-</style>
 
-<div id="editor">
-	<h3>A simple table to test selection:</h3>
-	<table>
-		<thead>
-			<tr>
-				<td>0</td>
-				<td>1</td>
-				<td>2</td>
-				<td>3</td>
-				<td>4</td>
-			</tr>
-		</thead>
-		<tbody>
-			<tr>
-				<td>a</td>
-				<td>b</td>
-				<td>c</td>
-				<td>d</td>
-				<td>e</td>
-			</tr>
-			<tr>
-				<td>f</td>
-				<td>g</td>
-				<td>h</td>
-				<td>i</td>
-				<td>j</td>
-			</tr>
-			<tr>
-				<td>k</td>
-				<td>l</td>
-				<td>m</td>
-				<td>n</td>
-				<td>o</td>
-			</tr>
-			<tr>
-				<td>p</td>
-				<td>q</td>
-				<td>r</td>
-				<td>s</td>
-				<td>t</td>
-			</tr>
-		</tbody>
-	</table>
+	/* This shouldn't be needed. See https://github.com/ckeditor/ckeditor5/issues/6314 */
+	.external-source td, .external-source th {
+		border: solid 1px hsl(0, 0%, 85%);
+	}
+</style>
 
-	<h3>A complex table</h3>
+<h3>A "content" test editor</h3>
 
+<div id="editor-content">
+	<p>Jelly-o topping chocolate danish. Powder donut dragée cupcake sesame snaps cotton candy. Cotton candy pudding apple pie liquorice
+	sugar plum cookie. Powder gummi bears macaroon. Gingerbread soufflé liquorice jelly-o marzipan pudding. Toffee sweet candy canes danish
+	macaroon cotton candy fruitcake. Sesame snaps cake cookie chocolate cupcake. Ice cream pie apple pie sweet. Wafer ice cream gingerbread
+	fruitcake donut jelly sweet. Sugar plum chocolate gummi bears. Jelly-o oat cake wafer brownie gingerbread pie dragée. Marshmallow
+	marzipan candy canes. Cotton candy liquorice cake soufflé pie candy.</p>
 	<figure class="table">
 		<table>
 			<thead>
 				<tr>
+					<th>0</th>
+					<th>1</th>
+					<th>2</th>
+					<th>3</th>
+					<th>4</th>
+				</tr>
+			</thead>
+			<tbody>
+				<tr>
 					<td>a</td>
 					<td>b</td>
-					<td>c</td>
-					<td>d</td>
+					<td><strong>c</strong></td>
+					<td><a href="https://example.com">d</a></td>
 					<td>e</td>
-					<td>f</td>
-					<td>g</td>
-					<td>h</td>
-					<td>i</td>
+				</tr>
+				<tr>
+					<td><i>f</i></td>
+					<td><i>g</i></td>
+					<td><i><strong>h</strong></i></td>
+					<td><i>i</i></td>
+					<td><i>j</i></td>
+				</tr>
+				<tr>
+					<td>k</td>
+					<td>l</td>
+					<td><strong>m</strong></td>
+					<td>n</td>
+					<td>o</td>
+				</tr>
+				<tr>
+					<td>p</td>
+					<td>
+						<ul>
+							<li>q</li>
+							<li>q</li>
+						</ul>
+					</td>
+					<td>
+						<ol>
+							<li>r</li>
+							<li>r</li>
+						</ol>
+					</td>
+					<td>s</td>
+					<td>
+						<blockquote><p>t</p></blockquote>
+						<p>t</p></td>
+				</tr>
+			</tbody>
+		</table>
+	</figure>
+	<p>Halvah oat cake lemon drops. Cake tart caramels. Topping soufflé cheesecake. Chocolate bar sugar plum pastry sesame snaps bear claw
+		gummies cotton candy topping. Tootsie roll topping cake chocolate bar marshmallow lemon drops. Cheesecake cookie croissant chupa
+		chups. Biscuit jujubes lollipop fruitcake sesame snaps halvah. Marzipan croissant dessert chocolate cheesecake halvah jelly beans.
+		Caramels pastry bear claw oat cake sugar plum muffin sweet roll cake. Chocolate bar sweet roll sweet roll bonbon apple pie pastry
+		lemon drops icing. Apple pie wafer marzipan. Cake donut macaroon pudding. Gummi bears wafer toffee chocolate bar bear claw.
+		Fruitcake jelly chocolate bar.</p>
+</div>
+
+<h3>A "geometry" test editor</h3>
+
+<div id="editor-geometry">
+	<figure class="table">
+		<table>
+			<thead>
+				<tr>
+					<th>a</th>
+					<th>b</th>
+					<th>c</th>
+					<th>d</th>
+					<th>e</th>
+					<th>f</th>
+					<th>g</th>
+					<th>h</th>
+					<th>i</th>
 				</tr>
 			</thead>
 			<tbody>
@@ -76,7 +109,7 @@
 					<td>01</td>
 					<td rowspan="4">02</td>
 					<td>03</td>
-					<td rowspan="7" colspan="2">04</td>
+					<td colspan="2" rowspan="7">04</td>
 					<td>07</td>
 					<td>07</td>
 					<td>08</td>

+ 35 - 21
packages/ckeditor5-table/tests/manual/tableselection.js

@@ -3,7 +3,7 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
-/* globals console, window, document, global */
+/* globals console, window, document, global, CKEditorInspector */
 
 import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
 import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
@@ -11,28 +11,42 @@ import Table from '../../src/table';
 import TableToolbar from '../../src/tabletoolbar';
 import { getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 import TableSelection from '../../src/tableselection';
+import TableClipboard from '../../src/tableclipboard';
+import TableProperties from '../../src/tableproperties';
+import TableCellProperties from '../../src/tablecellproperties';
 
-ClassicEditor
-	.create( document.querySelector( '#editor' ), {
-		plugins: [ ArticlePluginSet, Table, TableToolbar, TableSelection ],
-		toolbar: [
-			'heading', '|', 'insertTable', '|', 'bold', 'italic', 'bulletedList', 'numberedList', 'blockQuote', 'undo', 'redo'
-		],
-		table: {
-			contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells' ]
-		}
-	} )
-	.then( editor => {
-		window.editor = editor;
-		editor.model.document.on( 'change', () => {
-			printModelContents( editor );
-		} );
+window.editors = {};
+
+createEditor( '#editor-content', 'content' );
+createEditor( '#editor-geometry', 'geometry' );
+
+function createEditor( target, inspectorName ) {
+	ClassicEditor
+		.create( document.querySelector( target ), {
+			plugins: [ ArticlePluginSet, Table, TableToolbar, TableSelection, TableClipboard, TableProperties, TableCellProperties ],
+			toolbar: [
+				'heading', '|',
+				'insertTable', '|',
+				'bold', 'italic', 'link', '|',
+				'bulletedList', 'numberedList', 'blockQuote', '|',
+				'undo', 'redo'
+			],
+			table: {
+				contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells', 'tableProperties', 'tableCellProperties' ]
+			}
+		} )
+		.then( editor => {
+			window.editors[ inspectorName ] = editor;
+			CKEditorInspector.attach( inspectorName, editor );
 
-		printModelContents( editor );
-	} )
-	.catch( err => {
-		console.error( err.stack );
-	} );
+			editor.model.document.on( 'change', () => {
+				printModelContents( editor );
+			} );
+		} )
+		.catch( err => {
+			console.error( err.stack );
+		} );
+}
 
 const modelDiv = global.document.querySelector( '#model' );
 

+ 3 - 0
packages/ckeditor5-table/tests/manual/tableselection.md

@@ -4,3 +4,6 @@ Selecting table cells:
 
 1. It should be possible to select multiple table cells.
 2. Observe selection inn the below model representation - for a block selection the table cells should be selected.
+3. The editors are exposed as:
+    - `window.editor.content` and "content" editor in CKEditor inspector
+    - `window.editor.geometry` and "geometry" editor in CKEditor inspector

+ 335 - 0
packages/ckeditor5-table/tests/tableclipboard.js

@@ -0,0 +1,335 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import TableEditing from '../src/tableediting';
+import { modelTable, viewTable } from './_utils/utils';
+import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
+import ViewDocumentFragment from '@ckeditor/ckeditor5-engine/src/view/documentfragment';
+import { stringify as stringifyView } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+import TableClipboard from '../src/tableclipboard';
+
+describe( 'table clipboard', () => {
+	let editor, model, modelRoot, tableSelection, viewDocument;
+
+	beforeEach( async () => {
+		editor = await VirtualTestEditor.create( {
+			plugins: [ TableEditing, TableClipboard, Paragraph, Clipboard ]
+		} );
+
+		model = editor.model;
+		modelRoot = model.document.getRoot();
+		viewDocument = editor.editing.view.document;
+		tableSelection = editor.plugins.get( 'TableSelection' );
+
+		setModelData( model, modelTable( [
+			[ '00[]', '01', '02' ],
+			[ '10', '11', '12' ],
+			[ '20', '21', '22' ]
+		] ) );
+	} );
+
+	afterEach( async () => {
+		await editor.destroy();
+	} );
+
+	describe( 'Clipboard integration', () => {
+		describe( 'copy', () => {
+			it( 'should to nothing for normal selection in table', () => {
+				const dataTransferMock = createDataTransfer();
+				const spy = sinon.spy();
+
+				viewDocument.on( 'clipboardOutput', spy );
+
+				viewDocument.fire( 'copy', {
+					dataTransfer: dataTransferMock,
+					preventDefault: sinon.spy()
+				} );
+
+				sinon.assert.calledOnce( spy );
+			} );
+
+			it( 'should copy selected table cells as standalone table', done => {
+				const dataTransferMock = createDataTransfer();
+				const preventDefaultSpy = sinon.spy();
+
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) );
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 1, 2 ] ) );
+
+				viewDocument.on( 'clipboardOutput', ( evt, data ) => {
+					expect( preventDefaultSpy.calledOnce ).to.be.true;
+					expect( data.method ).to.equal( 'copy' );
+
+					expect( data.dataTransfer ).to.equal( dataTransferMock );
+
+					expect( data.content ).is.instanceOf( ViewDocumentFragment );
+					expect( stringifyView( data.content ) ).to.equal( viewTable( [
+						[ '01', '02' ],
+						[ '11', '12' ]
+					] ) );
+
+					done();
+				} );
+
+				viewDocument.fire( 'copy', {
+					dataTransfer: dataTransferMock,
+					preventDefault: preventDefaultSpy
+				} );
+			} );
+
+			it( 'should trim selected table to a selection rectangle (inner cell with colspan, no colspan after trim)', done => {
+				setModelData( model, modelTable( [
+					[ '00[]', '01', '02' ],
+					[ '10', { contents: '11', colspan: 2 } ],
+					[ '20', '21', '22' ]
+				] ) );
+
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) );
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 2, 1 ] ) );
+
+				assertClipboardCopy( viewTable( [
+					[ '00', '01' ],
+					[ '10', '11' ],
+					[ '20', '21' ]
+				] ), done );
+			} );
+
+			it( 'should trim selected table to a selection rectangle (inner cell with colspan, has colspan after trim)', done => {
+				setModelData( model, modelTable( [
+					[ '00[]', '01', '02' ],
+					[ { contents: '10', colspan: 3 } ],
+					[ '20', '21', '22' ]
+				] ) );
+
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) );
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 2, 1 ] ) );
+
+				assertClipboardCopy( viewTable( [
+					[ '00', '01' ],
+					[ { contents: '10', colspan: 2 } ],
+					[ '20', '21' ]
+				] ), done );
+			} );
+
+			it( 'should trim selected table to a selection rectangle (inner cell with rowspan, no colspan after trim)', done => {
+				setModelData( model, modelTable( [
+					[ '00[]', '01', '02' ],
+					[ '10', { contents: '11', rowspan: 2 }, '12' ],
+					[ '20', '21', '22' ]
+				] ) );
+
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) );
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 1, 2 ] ) );
+
+				assertClipboardCopy( viewTable( [
+					[ '00', '01', '02' ],
+					[ '10', '11', '12' ]
+				] ), done );
+			} );
+
+			it( 'should trim selected table to a selection rectangle (inner cell with rowspan, has rowspan after trim)', done => {
+				setModelData( model, modelTable( [
+					[ '00[]', { contents: '01', rowspan: 3 }, '02' ],
+					[ '10', '12' ],
+					[ '20', '22' ]
+				] ) );
+
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) );
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 1, 1 ] ) );
+
+				assertClipboardCopy( viewTable( [
+					[ '00', { contents: '01', rowspan: 2 }, '02' ],
+					[ '10', '12' ]
+				] ), done );
+			} );
+
+			it( 'should prepend spanned columns with empty cells (outside cell with colspan)', done => {
+				setModelData( model, modelTable( [
+					[ '00[]', '01', '02' ],
+					[ { contents: '10', colspan: 2 }, '12' ],
+					[ '20', '21', '22' ]
+				] ) );
+
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) );
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 2, 2 ] ) );
+
+				assertClipboardCopy( viewTable( [
+					[ '01', '02' ],
+					[ '', '12' ],
+					[ '21', '22' ]
+				] ), done );
+			} );
+
+			it( 'should prepend spanned columns with empty cells (outside cell with rowspan)', done => {
+				setModelData( model, modelTable( [
+					[ '00[]', { contents: '01', rowspan: 2 }, '02' ],
+					[ '10', '12' ],
+					[ '20', '21', '22' ]
+				] ) );
+
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 1, 0 ] ) );
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 2, 2 ] ) );
+
+				assertClipboardCopy( viewTable( [
+					[ '10', '', '12' ],
+					[ '20', '21', '22' ]
+				] ), done );
+			} );
+
+			it( 'should fix selected table to a selection rectangle (hardcore case)', done => {
+				// This test check how previous simple rules run together (mixed prepending and trimming).
+				// In the example below a selection is set from cell "32" to "88"
+				//
+				//                    Input table:                                         Copied table:
+				//
+				//   +----+----+----+----+----+----+----+----+----+
+				//   | 00 | 01 | 02 | 03 | 04      | 06 | 07 | 08 |
+				//   +----+----+    +----+         +----+----+----+
+				//   | 10 | 11 |    | 13 |         | 16 | 17 | 18 |
+				//   +----+----+    +----+         +----+----+----+             +----+----+----+---------+----+----+
+				//   | 20 | 21 |    | 23 |         | 26           |             | 21 |    | 23 |    |    | 26 |    |
+				//   +----+----+    +----+         +----+----+----+             +----+----+----+----+----+----+----+
+				//   | 30 | 31 |    | 33 |         | 36 | 37      |             | 31 |    | 33 |    |    | 36 | 37 |
+				//   +----+----+----+----+         +----+----+----+             +----+----+----+----+----+----+----+
+				//   | 40                |         | 46 | 47 | 48 |             |    |    |    |    |    | 46 | 47 |
+				//   +----+----+----+----+         +----+----+----+     ==>     +----+----+----+----+----+----+----+
+				//   | 50 | 51 | 52 | 53 |         | 56 | 57 | 58 |             | 51 | 52 | 53 |    |    | 56 | 57 |
+				//   +----+----+----+----+----+----+    +----+----+             +----+----+----+----+----+----+----+
+				//   | 60 | 61           | 64 | 65 |    | 67 | 68 |             | 61 |    |    | 64 | 65 |    | 67 |
+				//   +----+----+----+----+----+----+    +----+----+             +----+----+----+----+----+----+----+
+				//   | 70 | 71 | 72 | 73 | 74 | 75 |    | 77 | 78 |             | 71 | 72 | 73 | 74 | 75 |    | 77 |
+				//   +----+    +----+----+----+----+    +----+----+             +----+----+----+----+----+----+----+
+				//   | 80 |    | 82 | 83 | 84 | 85 |    | 87 | 88 |
+				//   +----+----+----+----+----+----+----+----+----+
+				//
+				setModelData( model, modelTable( [
+					[ '00', '01', { contents: '02', rowspan: 4 }, '03', { contents: '04', colspan: 2, rowspan: 7 }, '07', '07', '08' ],
+					[ '10', '11', '13', '17', '17', '18' ],
+					[ '20', '21', '23', { contents: '27', colspan: 3 } ],
+					[ '30', '31', '33', '37', { contents: '37', colspan: 2 } ],
+					[ { contents: '40', colspan: 4 }, '47', '47', '48' ],
+					[ '50', '51', '52', '53', { contents: '57', rowspan: 4 }, '57', '58' ],
+					[ '60', { contents: '61', colspan: 3 }, '67', '68' ],
+					[ '70', { contents: '71', rowspan: 2 }, '72', '73', '74', '75', '77', '78' ],
+					[ '80', '82', '83', '84', '85', '87', '88' ]
+				] ) );
+
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 2, 1 ] ) );
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 7, 6 ] ) );
+
+				assertClipboardCopy( viewTable( [
+					[ '21', '', '23', '', '', { contents: '27', colspan: 2 } ],
+					[ '31', '', '33', '', '', '37', '37' ],
+					[ '', '', '', '', '', '47', '47' ],
+					[ '51', '52', '53', '', '', { contents: '57', rowspan: 3 }, '57' ],
+					[ { contents: '61', colspan: 3 }, '', '', '', '67' ],
+					[ '71', '72', '73', '74', '75', '77' ]
+				] ), done );
+			} );
+
+			it( 'should update table heading attributes (selection with headings)', done => {
+				setModelData( model, modelTable( [
+					[ '00', '01', '02', '03', '04' ],
+					[ '10', '11', '12', '13', '14' ],
+					[ '20', '21', '22', '23', '24' ],
+					[ '30', '31', '32', '33', '34' ],
+					[ '40', '41', '42', '43', '44' ]
+				], { headingRows: 3, headingColumns: 2 } ) );
+
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 1, 1 ] ) );
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 3, 3 ] ) );
+
+				assertClipboardCopy( viewTable( [
+					[ '11', '12', '13' ],
+					[ '21', '22', '23' ],
+					[ { contents: '31', isHeading: true }, '32', '33' ] // TODO: bug in viewTable
+				], { headingRows: 2, headingColumns: 1 } ), done );
+			} );
+
+			it( 'should update table heading attributes (selection without headings)', done => {
+				setModelData( model, modelTable( [
+					[ '00', '01', '02', '03', '04' ],
+					[ '10', '11', '12', '13', '14' ],
+					[ '20', '21', '22', '23', '24' ],
+					[ '30', '31', '32', '33', '34' ],
+					[ '40', '41', '42', '43', '44' ]
+				], { headingRows: 3, headingColumns: 2 } ) );
+
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 3, 2 ] ) );
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 4, 4 ] ) );
+
+				assertClipboardCopy( viewTable( [
+					[ '32', '33', '34' ],
+					[ '42', '43', '44' ]
+				] ), done );
+			} );
+		} );
+
+		describe( 'cut', () => {
+			it( 'is disabled for multi-range selection over a table', () => {
+				const dataTransferMock = createDataTransfer();
+				const preventDefaultSpy = sinon.spy();
+				const spy = sinon.spy();
+
+				viewDocument.on( 'clipboardOutput', spy );
+
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) );
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 1, 2 ] ) );
+
+				viewDocument.fire( 'cut', {
+					dataTransfer: dataTransferMock,
+					preventDefault: preventDefaultSpy
+				} );
+
+				sinon.assert.notCalled( spy );
+				sinon.assert.calledOnce( preventDefaultSpy );
+			} );
+
+			it( 'is not disabled normal selection over a table', () => {
+				const dataTransferMock = createDataTransfer();
+				const spy = sinon.spy();
+
+				viewDocument.on( 'clipboardOutput', spy );
+
+				viewDocument.fire( 'cut', {
+					dataTransfer: dataTransferMock,
+					preventDefault: sinon.spy()
+				} );
+
+				sinon.assert.calledOnce( spy );
+			} );
+		} );
+	} );
+
+	function assertClipboardCopy( expectedViewTable, callback ) {
+		viewDocument.on( 'clipboardOutput', ( evt, data ) => {
+			expect( stringifyView( data.content ) ).to.equal( expectedViewTable );
+
+			callback();
+		} );
+
+		viewDocument.fire( 'copy', {
+			dataTransfer: createDataTransfer(),
+			preventDefault: sinon.spy()
+		} );
+	}
+
+	function createDataTransfer() {
+		const store = new Map();
+
+		return {
+			setData( type, data ) {
+				store.set( type, data );
+			},
+
+			getData( type ) {
+				return store.get( type );
+			}
+		};
+	}
+} );

+ 14 - 0
packages/ckeditor5-table/tests/tableselection.js

@@ -12,6 +12,7 @@ import TableSelection from '../src/tableselection';
 import { assertSelectedCells, modelTable, viewTable } from './_utils/utils';
 import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+import DocumentFragment from '@ckeditor/ckeditor5-engine/src/model/documentfragment';
 
 describe( 'table selection', () => {
 	let editor, model, tableSelection, modelRoot;
@@ -298,6 +299,19 @@ describe( 'table selection', () => {
 			} );
 		} );
 
+		describe( 'getSelectionAsFragment()', () => {
+			it( 'should return undefined if no table cells are selected', () => {
+				expect( tableSelection.getSelectionAsFragment() ).to.be.undefined;
+			} );
+
+			it( 'should return document fragment for selected table cells', () => {
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) );
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 1, 1 ] ) );
+
+				expect( tableSelection.getSelectionAsFragment() ).to.be.instanceOf( DocumentFragment );
+			} );
+		} );
+
 		describe( 'behavior', () => {
 			it( 'should clear selection on external changes', () => {
 				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) );