diff --git a/src/commands/removecolumncommand.js b/src/commands/removecolumncommand.js index de93b32d..eadecca9 100644 --- a/src/commands/removecolumncommand.js +++ b/src/commands/removecolumncommand.js @@ -51,7 +51,6 @@ export default class RemoveColumnCommand extends Command { const table = tableRow.parent; const headingColumns = table.getAttribute( 'headingColumns' ) || 0; - const row = table.getChildIndex( tableRow ); // Cache the table before removing or updating colspans. const tableMap = [ ...new TableWalker( table ) ]; @@ -59,10 +58,12 @@ export default class RemoveColumnCommand extends Command { // Get column index of removed column. const cellData = tableMap.find( value => value.cell === tableCell ); const removedColumn = cellData.column; + const selectionRow = cellData.row; + const cellToFocus = getCellToFocus( tableCell ); model.change( writer => { // Update heading columns attribute if removing a row from head section. - if ( headingColumns && row <= headingColumns ) { + if ( headingColumns && selectionRow <= headingColumns ) { writer.setAttribute( 'headingColumns', headingColumns - 1, table ); } @@ -75,6 +76,21 @@ export default class RemoveColumnCommand extends Command { writer.remove( cell ); } } + + writer.setSelection( writer.createPositionAt( cellToFocus, 0 ) ); } ); } } + +// Returns a proper table cell to focus after removing a column. It should be a next sibling to selection visually stay in place but: +// - selection is on last table cell it will return previous cell. +// - table cell is spanned over 2+ columns - it will be truncated so the selection should stay in that cell. +function getCellToFocus( tableCell ) { + const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 ); + + if ( colspan > 1 ) { + return tableCell; + } + + return tableCell.nextSibling ? tableCell.nextSibling : tableCell.previousSibling; +} diff --git a/src/commands/removerowcommand.js b/src/commands/removerowcommand.js index b63f9077..41d16b78 100644 --- a/src/commands/removerowcommand.js +++ b/src/commands/removerowcommand.js @@ -48,30 +48,35 @@ export default class RemoveRowCommand extends Command { const tableRow = tableCell.parent; const table = tableRow.parent; - const currentRow = table.getChildIndex( tableRow ); + const removedRow = table.getChildIndex( tableRow ); + + const tableMap = [ ...new TableWalker( table, { endRow: removedRow } ) ]; + + const cellData = tableMap.find( value => value.cell === tableCell ); + const headingRows = table.getAttribute( 'headingRows' ) || 0; + const columnToFocus = cellData.column; + model.change( writer => { - if ( headingRows && currentRow <= headingRows ) { + if ( headingRows && removedRow <= headingRows ) { updateNumericAttribute( 'headingRows', headingRows - 1, table, writer, 0 ); } - const tableMap = [ ...new TableWalker( table, { endRow: currentRow } ) ]; - const cellsToMove = new Map(); // Get cells from removed row that are spanned over multiple rows. tableMap - .filter( ( { row, rowspan } ) => row === currentRow && rowspan > 1 ) + .filter( ( { row, rowspan } ) => row === removedRow && rowspan > 1 ) .forEach( ( { column, cell, rowspan } ) => cellsToMove.set( column, { cell, rowspanToSet: rowspan - 1 } ) ); // Reduce rowspan on cells that are above removed row and overlaps removed row. tableMap - .filter( ( { row, rowspan } ) => row <= currentRow - 1 && row + rowspan > currentRow ) + .filter( ( { row, rowspan } ) => row <= removedRow - 1 && row + rowspan > removedRow ) .forEach( ( { cell, rowspan } ) => updateNumericAttribute( 'rowspan', rowspan - 1, cell, writer ) ); // Move cells to another row. - const targetRow = currentRow + 1; + const targetRow = removedRow + 1; const tableWalker = new TableWalker( table, { includeSpanned: true, startRow: targetRow, endRow: targetRow } ); let previousCell; @@ -93,6 +98,27 @@ export default class RemoveRowCommand extends Command { } writer.remove( tableRow ); + + const cellToFocus = getCellToFocus( table, removedRow, columnToFocus ); + writer.setSelection( writer.createPositionAt( cellToFocus, 0 ) ); } ); } } + +// Returns a cell that should be focused before removing the row, belonging to the same column as the currently focused cell. +function getCellToFocus( table, removedRow, columnToFocus ) { + const row = table.getChild( removedRow ); + + // Default to first table cell. + let cellToFocus = row.getChild( 0 ); + let column = 0; + + for ( const tableCell of row.getChildren() ) { + if ( column > columnToFocus ) { + return cellToFocus; + } + + cellToFocus = tableCell; + column += parseInt( tableCell.getAttribute( 'colspan' ) || 1 ); + } +} diff --git a/src/commands/utils.js b/src/commands/utils.js index 5190c6e6..55a3a0fe 100644 --- a/src/commands/utils.js +++ b/src/commands/utils.js @@ -10,14 +10,15 @@ import { isObject } from 'lodash-es'; /** - * Returns the parent element of the given name. Returns undefined if the position is not inside the desired parent. + * Returns the parent element of the given name. Returns undefined if the position or the element is not inside the desired parent. * * @param {String} parentName The name of the parent element to find. - * @param {module:engine/model/position~Position|module:engine/model/position~Position} position The position to start searching. + * @param {module:engine/model/position~Position|module:engine/model/position~Position} positionOrElement The position or + * the parentElement to start searching. * @returns {module:engine/model/element~Element|module:engine/model/documentfragment~DocumentFragment} */ -export function findAncestor( parentName, position ) { - let parent = position.parent; +export function findAncestor( parentName, positionOrElement ) { + let parent = positionOrElement.parent; while ( parent ) { if ( parent.name === parentName ) { diff --git a/src/tableediting.js b/src/tableediting.js index 0ea02afc..9cf1480f 100644 --- a/src/tableediting.js +++ b/src/tableediting.js @@ -75,7 +75,7 @@ export default class TableEditing extends Plugin { schema.register( 'tableCell', { allowIn: 'tableRow', allowAttributes: [ 'colspan', 'rowspan' ], - isLimit: true + isObject: true } ); // Allow all $block content inside table cell. diff --git a/src/tableselection.js b/src/tableselection.js new file mode 100644 index 00000000..11b5d414 --- /dev/null +++ b/src/tableselection.js @@ -0,0 +1,260 @@ +/** + * @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 + */ + +import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; + +import TableWalker from './tablewalker'; +import TableUtils from './tableutils'; +import { setupTableSelectionHighlighting } from './tableselection/converters'; +import MouseSelectionHandler from './tableselection/mouseselectionhandler'; + +import '../theme/tableselection.css'; + +/** + * The table selection plugin. + * + * It introduces the ability to select table cells. The table selection is described by two nodes: start and end. + * Both are the oposite corners of an rectangle that spans over them. + * + * Consider a table: + * + * 0 1 2 3 + * +---+---+---+---+ + * 0 | a | b | c | d | + * +-------+ +---+ + * 1 | e | f | | g | + * +---+---+---+---+ + * 2 | h | i | j | + * +---+---+---+---+ + * + * Setting the table selection start in table cell "b" and the end in table cell "g" will select table cells: "b", "c", "d", "f", and "g". + * The cells that span over multiple rows or columns can extend over the selection rectangle. For instance, setting a selection from + * the table cell "a" to the table cell "i" will create a selection in which the table cell "i" will be (partially) outside + * the rectangle of selected cells: "a", "b", "e", "f", "h", and "i". + * + * @extends module:core/plugin~Plugin + */ +export default class TableSelection extends Plugin { + /** + * @inheritDoc + */ + static get pluginName() { + return 'TableSelection'; + } + + /** + * @inheritDoc + */ + static get requires() { + return [ TableUtils ]; + } + + /** + * @inheritDoc + */ + constructor( editor ) { + super( editor ); + + /** + * A mouse selection handler. + * + * @private + * @readonly + * @member {module:table/tableselection/mouseselectionhandler~MouseSelectionHandler} + */ + this._mouseHandler = new MouseSelectionHandler( this, this.editor.editing ); + + /** + * A reference to the table utilities used across the class. + * + * @private + * @readonly + * @member {module:table/tableutils~TableUtils} #_tableUtils + */ + } + + /** + * A flag indicating that there are selected table cells and the selection includes more than one table cell. + * + * @type {Boolean} + */ + get hasMultiCellSelection() { + return !!this._startElement && !!this._endElement && this._startElement !== this._endElement; + } + + /** + * @inheritDoc + */ + init() { + const editor = this.editor; + const selection = editor.model.document.selection; + + this._tableUtils = editor.plugins.get( 'TableUtils' ); + + setupTableSelectionHighlighting( editor, this ); + + selection.on( 'change:range', () => this._clearSelectionOnExternalChange( selection ) ); + } + + /** + * @inheritDoc + */ + destroy() { + super.destroy(); + this._mouseHandler.stopListening(); + } + + /** + * Marks the table cell as a start of a table selection. + * + * editor.plugins.get( 'TableSelection' ).startSelectingFrom( tableCell ); + * + * This method will clear the previous selection. The model selection will not be updated until + * the {@link #setSelectingTo} method is used. + * + * @param {module:engine/model/element~Element} tableCell + */ + startSelectingFrom( tableCell ) { + this.clearSelection(); + + this._startElement = tableCell; + this._endElement = tableCell; + } + + /** + * Updates the current table selection end element. Table selection is defined by a start and an end element. + * This method updates the end element. Must be preceded by {@link #startSelectingFrom}. + * + * editor.plugins.get( 'TableSelection' ).startSelectingFrom( startTableCell ); + * + * editor.plugins.get( 'TableSelection' ).setSelectingTo( endTableCell ); + * + * This method will update model selection if start and end cells are different and belongs to the same table. + * + * @param {module:engine/model/element~Element} tableCell + */ + setSelectingTo( tableCell ) { + if ( !this._startElement ) { + this._startElement = tableCell; + } + + 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; + } + + this._endElement = tableCell; + this._updateModelSelection(); + } + + /** + * Stops the selection process (but do not clear the current selection). + * The selection process is finished but the selection in the model remains. + * + * editor.plugins.get( 'TableSelection' ).startSelectingFrom( startTableCell ); + * editor.plugins.get( 'TableSelection' ).setSelectingTo( endTableCell ); + * editor.plugins.get( 'TableSelection' ).stopSelection(); + * + * To clear the selection use {@link #clearSelection}. + * + * @param {module:engine/model/element~Element} [tableCell] + */ + stopSelection( tableCell ) { + if ( tableCell && tableCell.parent.parent === this._startElement.parent.parent ) { + this._endElement = tableCell; + } + + this._updateModelSelection(); + } + + /** + * Stops the current selection process and clears the table selection in the model. + * + * editor.plugins.get( 'TableSelection' ).startSelectingFrom( startTableCell ); + * editor.plugins.get( 'TableSelection' ).setSelectingTo( endTableCell ); + * editor.plugins.get( 'TableSelection' ).stopSelection(); + * + * editor.plugins.get( 'TableSelection' ).clearSelection(); + */ + clearSelection() { + this._startElement = undefined; + this._endElement = undefined; + } + + /** + * Returns an iterator for selected table cells. + * + * tableSelection.startSelectingFrom( startTableCell ); + * tableSelection.stopSelection( endTableCell ); + * + * const selectedTableCells = Array.from( tableSelection.getSelectedTableCells() ); + * // The above array will represent a rectangular table selection. + * + * @returns {Iterable.} + */ + * getSelectedTableCells() { + if ( !this.hasMultiCellSelection ) { + return; + } + + const startLocation = this._tableUtils.getCellLocation( this._startElement ); + const endLocation = this._tableUtils.getCellLocation( this._endElement ); + + const startRow = Math.min( startLocation.row, endLocation.row ); + const endRow = Math.max( startLocation.row, endLocation.row ); + + 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 } ) ) { + if ( cellInfo.column >= startColumn && cellInfo.column <= endColumn ) { + yield cellInfo.cell; + } + } + } + + /** + * Synchronizes the model selection with currently selected table cells. + * + * @private + */ + _updateModelSelection() { + if ( !this.hasMultiCellSelection ) { + return; + } + + const editor = this.editor; + const model = editor.model; + + const modelRanges = []; + + for ( const tableCell of this.getSelectedTableCells() ) { + modelRanges.push( model.createRangeOn( tableCell ) ); + } + + // Update model's selection + model.change( writer => { + writer.setSelection( modelRanges ); + } ); + } + + /** + * Checks if the selection has changed via an external change and if it is required to clear the internal state of the plugin. + * + * @param {module:engine/model/documentselection~DocumentSelection} selection + * @private + */ + _clearSelectionOnExternalChange( selection ) { + if ( selection.rangeCount <= 1 && this.hasMultiCellSelection ) { + this.clearSelection(); + } + } +} diff --git a/src/tableselection/converters.js b/src/tableselection/converters.js new file mode 100644 index 00000000..1d6750c1 --- /dev/null +++ b/src/tableselection/converters.js @@ -0,0 +1,49 @@ +/** + * @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/converters + */ + +/** + * Adds a visual highlight style to selected table cells. + * + * @param {module:core/editor/editor~Editor} editor + * @param {module:table/tableselection~TableSelection} tableSelection + */ +export function setupTableSelectionHighlighting( editor, tableSelection ) { + const highlighted = new Set(); + + editor.conversion.for( 'editingDowncast' ).add( dispatcher => dispatcher.on( 'selection', ( evt, data, conversionApi ) => { + const view = editor.editing.view; + const viewWriter = conversionApi.writer; + const viewSelection = viewWriter.document.selection; + + if ( tableSelection.hasMultiCellSelection ) { + clearHighlightedTableCells( highlighted, view ); + + for ( const tableCell of tableSelection.getSelectedTableCells() ) { + const viewElement = conversionApi.mapper.toViewElement( tableCell ); + + viewWriter.addClass( 'ck-editor__editable_selected', viewElement ); + highlighted.add( viewElement ); + } + + viewWriter.setSelection( viewSelection.getRanges(), { fake: true, label: 'TABLE' } ); + } else { + clearHighlightedTableCells( highlighted, view ); + } + }, { priority: 'lowest' } ) ); +} + +function clearHighlightedTableCells( highlighted, view ) { + const previous = [ ...highlighted.values() ]; + + view.change( writer => { + for ( const previouslyHighlighted of previous ) { + writer.removeClass( 'ck-editor__editable_selected', previouslyHighlighted ); + } + } ); +} diff --git a/src/tableselection/mouseeventsobserver.js b/src/tableselection/mouseeventsobserver.js new file mode 100644 index 00000000..fed474bc --- /dev/null +++ b/src/tableselection/mouseeventsobserver.js @@ -0,0 +1,86 @@ +/** + * @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/mouseeventsobserver + */ + +import DomEventObserver from '@ckeditor/ckeditor5-engine/src/view/observer/domeventobserver'; + +/** + * The mouse selection events observer. + * + * It registers listeners for DOM events: + * + * - `'mousemove'` + * - `'mouseup'` + * - `'mouseleave'` + * + * Note that this observer is disabled by default. To enable this observer it needs to be added to + * {@link module:engine/view/view~View} using the {@link module:engine/view/view~View#addObserver} method. + * + * It is registered by {@link module:table/tableselection/mouseselectionhandler~MouseSelectionHandler}. + * + * @extends module:engine/view/observer/domeventobserver~DomEventObserver + */ +export default class MouseEventsObserver extends DomEventObserver { + /** + * @inheritDoc + */ + constructor( view ) { + super( view ); + + this.domEventType = [ 'mousemove', 'mouseup', 'mouseleave' ]; + } + + /** + * @inheritDoc + */ + onDomEvent( domEvent ) { + this.fire( domEvent.type, domEvent ); + } +} + +/** + * Fired when the mouse button is released over one of the editables. + * + * Introduced by {@link module:table/tableselection/mouseeventsobserver~MouseEventsObserver}. + * + * Note that this event is not available by default. To make it available + * {@link module:table/tableselection/mouseeventsobserver~MouseEventsObserver} needs to be added + * to {@link module:engine/view/view~View} using the {@link module:engine/view/view~View#addObserver} method. + * + * @see module:table/tableselection/mouseeventsobserver~MouseEventsObserver + * @event module:engine/view/document~Document#event:mouseup + * @param {module:engine/view/observer/domeventdata~DomEventData} data Event data. + */ + +/** + * Fired when the mouse is moved over one of the editables. + * + * Introduced by {@link module:table/tableselection/mouseeventsobserver~MouseEventsObserver}. + * + * Note that this event is not available by default. To make it available + * {@link module:table/tableselection/mouseeventsobserver~MouseEventsObserver} needs to be added + * to {@link module:engine/view/view~View} using the {@link module:engine/view/view~View#addObserver} method. + * + * @see module:table/tableselection/mouseeventsobserver~MouseEventsObserver + * @event module:engine/view/document~Document#event:mousemove + * @param {module:engine/view/observer/domeventdata~DomEventData} data Event data. + */ + +/** + * Fired when the mouse is moved out of one of the editables. + * + * Introduced by {@link module:table/tableselection/mouseeventsobserver~MouseEventsObserver}. + * + * Note that this event is not available by default. To make it available + * {@link module:table/tableselection/mouseeventsobserver~MouseEventsObserver} needs to be added + * to {@link module:engine/view/view~View} using the {@link module:engine/view/view~View#addObserver} method. + * + * @see module:table/tableselection/mouseeventsobserver~MouseEventsObserver + * @event module:engine/view/document~Document#event:mouseleave + * @param {module:engine/view/observer/domeventdata~DomEventData} data Event data. + */ diff --git a/src/tableselection/mouseselectionhandler.js b/src/tableselection/mouseselectionhandler.js new file mode 100644 index 00000000..fb50b03e --- /dev/null +++ b/src/tableselection/mouseselectionhandler.js @@ -0,0 +1,160 @@ +/** + * @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/mouseselectionhandler + */ + +import mix from '@ckeditor/ckeditor5-utils/src/mix'; +import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin'; + +import { findAncestor } from '../commands/utils'; +import MouseEventsObserver from './mouseeventsobserver'; + +/** + * A mouse selection handler class for the table selection. + * + * It registers the {@link module:table/tableselection/mouseeventsobserver~MouseEventsObserver} to observe view document mouse events + * and invoke proper {@link module:table/tableselection~TableSelection} actions. + */ +export default class MouseSelectionHandler { + /** + * Creates an instance of the `MouseSelectionHandler`. + * + * @param {module:table/tableselection~TableSelection} tableSelection + * @param {module:engine/controller/editingcontroller~EditingController} editing + */ + constructor( tableSelection, editing ) { + /** + * The table selection plugin instance. + * + * @private + * @readonly + * @member {module:table/tableselection~TableSelection} + */ + this._tableSelection = tableSelection; + + /** + * A flag indicating that the mouse selection is "active". A selection is "active" if it was started and not yet finished. + * A selection can be "active", for instance, if a user moves a mouse over a table while holding a mouse button down. + * + * @readonly + * @member {Boolean} + */ + this.isSelecting = false; + + /** + * Editing mapper. + * + * @private + * @readonly + * @member {module:engine/conversion/mapper~Mapper} + */ + this._mapper = editing.mapper; + + const view = editing.view; + + // Currently the MouseObserver only handles `mouseup` events. + view.addObserver( MouseEventsObserver ); + + this.listenTo( view.document, 'mousedown', ( event, domEventData ) => this._handleMouseDown( domEventData ) ); + this.listenTo( view.document, 'mousemove', ( event, domEventData ) => this._handleMouseMove( domEventData ) ); + this.listenTo( view.document, 'mouseup', ( event, domEventData ) => this._handleMouseUp( domEventData ) ); + } + + /** + * Handles starting a selection when "mousedown" event has table cell as a DOM target. + * + * If there is no table cell in the event target, it will clear the previous selection. + * + * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData + * @private + */ + _handleMouseDown( domEventData ) { + const tableCell = this._getModelTableCellFromDomEvent( domEventData ); + + if ( !tableCell ) { + this._tableSelection.clearSelection(); + this._tableSelection.stopSelection(); + + return; + } + + this.isSelecting = true; + this._tableSelection.startSelectingFrom( tableCell ); + } + + /** + * Handles updating the table selection when the "mousemove" event has a table cell as a DOM target. + * + * Does nothing if there is no table cell in event target or the selection has not been started yet. + * + * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData + * @private + */ + _handleMouseMove( domEventData ) { + if ( !isButtonPressed( domEventData ) ) { + this._tableSelection.stopSelection(); + + return; + } + + const tableCell = this._getModelTableCellFromDomEvent( domEventData ); + + if ( !tableCell ) { + return; + } + + this._tableSelection.setSelectingTo( tableCell ); + } + + /** + * Handles ending (not clearing) the table selection on the "mouseup" event. + * + * Does nothing if the selection has not been started yet. + * + * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData + * @private + */ + _handleMouseUp( domEventData ) { + if ( !this.isSelecting ) { + return; + } + + const tableCell = this._getModelTableCellFromDomEvent( domEventData ); + + // Selection can be stopped if table cell is undefined. + this._tableSelection.stopSelection( tableCell ); + } + + /** + * Finds a model table cell for a given DOM event. + * + * @private + * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData + * @returns {module:engine/model/element~Element|undefined} Returns model table cell or undefined event target is not + * a mapped table cell. + */ + _getModelTableCellFromDomEvent( domEventData ) { + const viewTargetElement = domEventData.target; + const modelElement = this._mapper.toModelElement( viewTargetElement ); + + if ( !modelElement ) { + return; + } + + if ( modelElement.is( 'tableCell' ) ) { + return modelElement; + } + + return findAncestor( 'tableCell', modelElement ); + } +} + +mix( MouseSelectionHandler, ObservableMixin ); + +function isButtonPressed( domEventData ) { + return !!domEventData.domEvent.buttons; +} diff --git a/tests/_utils/utils.js b/tests/_utils/utils.js index 59909520..48cf187f 100644 --- a/tests/_utils/utils.js +++ b/tests/_utils/utils.js @@ -196,7 +196,7 @@ export function defaultSchema( schema, registerParagraph = true ) { schema.register( 'tableCell', { allowIn: 'tableRow', allowAttributes: [ 'colspan', 'rowspan' ], - isLimit: true + isObject: true } ); // Allow all $block content inside table cell. @@ -322,11 +322,59 @@ export function assertTableStyle( editor, tableStyle, figureStyle ) { export function assertTableCellStyle( editor, tableCellStyle ) { assertEqualMarkup( editor.getData(), '
' + - `foo` + + `foo` + '
' ); } +/** + * A helper method for asserting selected table cells. + * + * To check if a table has expected cells selected pass two dimensional array of truthy and falsy values: + * + * assertSelectedCells( model, [ + * [ 0, 1 ], + * [ 0, 1 ] + * ] ); + * + * The above call will check if table has second column selected (assuming no spans). + * + * **Note**: This function operates on child indexes - not rows/columns. + */ +export function assertSelectedCells( model, tableMap ) { + const tableIndex = 0; + + for ( let rowIndex = 0; rowIndex < tableMap.length; rowIndex++ ) { + const row = tableMap[ rowIndex ]; + + for ( let cellIndex = 0; cellIndex < row.length; cellIndex++ ) { + const expectSelected = row[ cellIndex ]; + + if ( expectSelected ) { + assertNodeIsSelected( model, [ tableIndex, rowIndex, cellIndex ] ); + } else { + assertNodeIsNotSelected( model, [ tableIndex, rowIndex, cellIndex ] ); + } + } + } +} + +function assertNodeIsSelected( model, path ) { + const modelRoot = model.document.getRoot(); + const node = modelRoot.getNodeByPath( path ); + const selectionRanges = Array.from( model.document.selection.getRanges() ); + + expect( selectionRanges.some( range => range.containsItem( node ) ), `Expected node [${ path }] to be selected` ).to.be.true; +} + +function assertNodeIsNotSelected( model, path ) { + const modelRoot = model.document.getRoot(); + const node = modelRoot.getNodeByPath( path ); + const selectionRanges = Array.from( model.document.selection.getRanges() ); + + expect( selectionRanges.every( range => !range.containsItem( node ) ), `Expected node [${ path }] to be not selected` ).to.be.true; +} + // Formats table cell attributes // // @param {Object} attributes Attributes of a cell. @@ -356,20 +404,24 @@ function makeRows( tableData, options ) { let contents = isObject ? tableCellData.contents : tableCellData; let resultingCellElement = cellElement; + let isSelected = false; if ( isObject ) { if ( tableCellData.isHeading ) { resultingCellElement = headingElement; } + isSelected = !!tableCellData.isSelected; + delete tableCellData.contents; delete tableCellData.isHeading; + delete tableCellData.isSelected; } const attributes = isObject ? tableCellData : {}; if ( asWidget ) { - attributes.class = WIDGET_TABLE_CELL_CLASS + ( attributes.class ? ` ${ attributes.class }` : '' ); + attributes.class = getClassToSet( attributes ); attributes.contenteditable = 'true'; } @@ -381,7 +433,9 @@ function makeRows( tableData, options ) { } const formattedAttributes = formatAttributes( attributes ); - tableRowString += `<${ resultingCellElement }${ formattedAttributes }>${ contents }`; + const tableCell = `<${ resultingCellElement }${ formattedAttributes }>${ contents }`; + + tableRowString += isSelected ? `[${ tableCell }]` : tableCell; return tableRowString; }, '' ); @@ -389,3 +443,11 @@ function makeRows( tableData, options ) { return `${ previousRowsString }<${ rowElement }>${ tableRowString }`; }, '' ); } + +// Properly handles passed CSS class - editor do sort them. +function getClassToSet( attributes ) { + return ( WIDGET_TABLE_CELL_CLASS + ( attributes.class ? ` ${ attributes.class }` : '' ) ) + .split( ' ' ) + .sort() + .join( ' ' ); +} diff --git a/tests/commands/removecolumncommand.js b/tests/commands/removecolumncommand.js index 828815f1..07cdd14a 100644 --- a/tests/commands/removecolumncommand.js +++ b/tests/commands/removecolumncommand.js @@ -72,7 +72,7 @@ describe( 'RemoveColumnCommand', () => { assertEqualMarkup( getData( model ), modelTable( [ [ '00', '02' ], - [ '10[]', '12' ], + [ '10', '[]12' ], [ '20', '22' ] ] ) ); } ); @@ -87,7 +87,7 @@ describe( 'RemoveColumnCommand', () => { command.execute(); assertEqualMarkup( getData( model ), modelTable( [ - [ '[]01' ], + [ '[]01' ], [ '11' ], [ '21' ] ] ) ); @@ -104,7 +104,7 @@ describe( 'RemoveColumnCommand', () => { assertEqualMarkup( getData( model ), modelTable( [ [ '01' ], - [ '[]11' ], + [ '[]11' ], [ '21' ] ], { headingColumns: 1 } ) ); } ); @@ -123,7 +123,7 @@ describe( 'RemoveColumnCommand', () => { assertEqualMarkup( getData( model ), modelTable( [ [ { colspan: 3, contents: '00' }, '03' ], [ { colspan: 2, contents: '10' }, '13' ], - [ { colspan: 2, contents: '20[]' }, '23' ], + [ { colspan: 2, contents: '20' }, '[]23' ], [ '30', '31', '33' ], [ '40', '41', '43' ] @@ -145,5 +145,21 @@ describe( 'RemoveColumnCommand', () => { [ '21', '22', '23' ] ] ) ); } ); + + it( 'should move focus to previous column of removed cell if in last column', () => { + setData( model, modelTable( [ + [ '00', '01', '02' ], + [ '10', '11', '12[]' ], + [ '20', '21', '22' ] + ] ) ); + + command.execute(); + + assertEqualMarkup( getData( model ), modelTable( [ + [ '00', '01' ], + [ '10', '[]11' ], + [ '20', '21' ] + ] ) ); + } ); } ); } ); diff --git a/tests/commands/removerowcommand.js b/tests/commands/removerowcommand.js index c4d11991..c2bb6686 100644 --- a/tests/commands/removerowcommand.js +++ b/tests/commands/removerowcommand.js @@ -65,8 +65,8 @@ describe( 'RemoveRowCommand', () => { command.execute(); assertEqualMarkup( getData( model ), modelTable( [ - [ '00', '01[]' ], - [ '20', '21' ] + [ '00', '01' ], + [ '[]20', '21' ] ] ) ); } ); @@ -80,7 +80,7 @@ describe( 'RemoveRowCommand', () => { command.execute(); assertEqualMarkup( getData( model ), modelTable( [ - [ '[]10', '11' ], + [ '[]10', '11' ], [ '20', '21' ] ] ) ); } ); @@ -95,8 +95,8 @@ describe( 'RemoveRowCommand', () => { command.execute(); assertEqualMarkup( getData( model ), modelTable( [ - [ '00', '01[]' ], - [ '20', '21' ] + [ '00', '01' ], + [ '[]20', '21' ] ], { headingRows: 1 } ) ); } ); @@ -112,8 +112,8 @@ describe( 'RemoveRowCommand', () => { assertEqualMarkup( getData( model ), modelTable( [ [ { rowspan: 3, contents: '00' }, { rowspan: 2, contents: '01' }, { rowspan: 2, contents: '02' }, '03', '04' ], - [ '13', '14[]' ], - [ '30', '31', '32', '33', '34' ] + [ '13', '14' ], + [ '30', '31', '[]32', '33', '34' ] ] ) ); } ); diff --git a/tests/manual/tableselection.html b/tests/manual/tableselection.html new file mode 100644 index 00000000..796b3413 --- /dev/null +++ b/tests/manual/tableselection.html @@ -0,0 +1,151 @@ + + +
+

A simple table to test selection:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
01234
abcde
fghij
klmno
pqrst
+ +

A complex table

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
abcdefghi
0001020304070708
101113171718
20212327
3031333737
40474748
50515253575758
60616768
7071727374757778
80828384858788
+
+
+ +

Model contents:

+
diff --git a/tests/manual/tableselection.js b/tests/manual/tableselection.js new file mode 100644 index 00000000..f719277a --- /dev/null +++ b/tests/manual/tableselection.js @@ -0,0 +1,64 @@ +/** + * @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, window, document, global */ + +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 { getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import TableSelection from '../../src/tableselection'; + +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 ); + } ); + + printModelContents( editor ); + } ) + .catch( err => { + console.error( err.stack ); + } ); + +const modelDiv = global.document.querySelector( '#model' ); + +function printModelContents( editor ) { + modelDiv.innerHTML = formatTable( getData( editor.model ) ) + .replace( //g, '>' ) + .replace( /\n/g, '
' ) + .replace( /\[/g, '[' ) + .replace( /]/g, ']' ); +} + +function formatTable( tableString ) { + return tableString + .replace( //g, '\n/g, '\n\n ' ) + .replace( //g, '\n\n ' ) + .replace( //g, '\n\n ' ) + .replace( //g, '\n\n ' ) + .replace( /<\/tableRow>/g, '\n' ) + .replace( /<\/thead>/g, '\n' ) + .replace( /<\/tbody>/g, '\n' ) + .replace( /<\/tr>/g, '\n' ) + .replace( /<\/table>/g, '\n
' ) + .replace( /tableCell/g, 'cell' ) + .replace( /tableRow/g, 'row' ) + .replace( /paragraph/g, 'p' ); +} diff --git a/tests/manual/tableselection.md b/tests/manual/tableselection.md new file mode 100644 index 00000000..e52654fa --- /dev/null +++ b/tests/manual/tableselection.md @@ -0,0 +1,6 @@ +### Testing + +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. diff --git a/tests/tableediting.js b/tests/tableediting.js index d8dbee31..b466e0eb 100644 --- a/tests/tableediting.js +++ b/tests/tableediting.js @@ -259,9 +259,7 @@ describe( 'TableEditing', () => { describe( 'on TAB', () => { it( 'should do nothing if selection is not in a table', () => { - setModelData( model, '[]' + modelTable( [ - [ '11', '12' ] - ] ) ); + setModelData( model, '[]' + modelTable( [ [ '11', '12' ] ] ) ); editor.editing.view.document.fire( 'keydown', domEvtDataStub ); diff --git a/tests/tableselection.js b/tests/tableselection.js new file mode 100644 index 00000000..fa2d1ea7 --- /dev/null +++ b/tests/tableselection.js @@ -0,0 +1,325 @@ +/** + * @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 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'; + +describe( 'table selection', () => { + let editor, model, tableSelection, modelRoot; + + beforeEach( async () => { + editor = await VirtualTestEditor.create( { + plugins: [ TableEditing, TableSelection, Paragraph ] + } ); + + model = editor.model; + modelRoot = model.document.getRoot(); + tableSelection = editor.plugins.get( TableSelection ); + + setModelData( model, modelTable( [ + [ '11[]', '12', '13' ], + [ '21', '22', '23' ], + [ '31', '32', '33' ] + ] ) ); + } ); + + afterEach( async () => { + await editor.destroy(); + } ); + + describe( 'TableSelection', () => { + describe( 'startSelectingFrom()', () => { + it( 'should not change model selection', () => { + const spy = sinon.spy(); + + model.document.selection.on( 'change', spy ); + + tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) ); + + sinon.assert.notCalled( spy ); + } ); + } ); + + describe( 'setSelectingTo()', () => { + it( 'should set model selection on passed cell if startSelectingFrom() was not used', () => { + const spy = sinon.spy(); + + model.document.selection.on( 'change', spy ); + + tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) ); + tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) ); + + sinon.assert.calledOnce( spy ); + + assertSelectedCells( model, [ + [ 1, 1, 0 ], + [ 0, 0, 0 ], + [ 0, 0, 0 ] + ] ); + } ); + + it( 'should change model selection if valid selection will be set', () => { + const spy = sinon.spy(); + + model.document.selection.on( 'change', spy ); + + tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) ); + tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) ); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'should not change model selection if passed table cell is from other table then start cell', () => { + setModelData( model, + modelTable( [ + [ '11[]', '12', '13' ], + [ '21', '22', '23' ], + [ '31', '32', '33' ] + ] ) + + modelTable( [ + [ 'a', 'b' ], + [ 'c', 'd' ] + ] ) + ); + + const spy = sinon.spy(); + + model.document.selection.on( 'change', spy ); + + tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) ); + tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 1, 0, 1 ] ) ); + + sinon.assert.notCalled( spy ); + } ); + + it( 'should select two table cells', () => { + tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) ); + + tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) ); + + assertSelectedCells( model, [ + [ 1, 1, 0 ], + [ 0, 0, 0 ], + [ 0, 0, 0 ] + ] ); + } ); + + it( 'should select four table cells for diagonal selection', () => { + tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) ); + + tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 1, 1 ] ) ); + + assertSelectedCells( model, [ + [ 1, 1, 0 ], + [ 1, 1, 0 ], + [ 0, 0, 0 ] + ] ); + } ); + + it( 'should select row table cells', () => { + tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) ); + + tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 0, 2 ] ) ); + + assertSelectedCells( model, [ + [ 1, 1, 1 ], + [ 0, 0, 0 ], + [ 0, 0, 0 ] + ] ); + } ); + + it( 'should select column table cells', () => { + tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) ); + + tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 2, 1 ] ) ); + + assertSelectedCells( model, [ + [ 0, 1, 0 ], + [ 0, 1, 0 ], + [ 0, 1, 0 ] + ] ); + } ); + + it( 'should create proper selection on consecutive changes', () => { + tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 1, 1 ] ) ); + + tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 2, 1 ] ) ); + + assertSelectedCells( model, [ + [ 0, 0, 0 ], + [ 0, 1, 0 ], + [ 0, 1, 0 ] + ] ); + + tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) ); + + assertSelectedCells( model, [ + [ 0, 1, 0 ], + [ 0, 1, 0 ], + [ 0, 0, 0 ] + ] ); + + tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 2, 2 ] ) ); + + assertSelectedCells( model, [ + [ 0, 0, 0 ], + [ 0, 1, 1 ], + [ 0, 1, 1 ] + ] ); + } ); + } ); + + describe( 'stopSelection()', () => { + it( 'should not clear currently selected cells if not cell was passed', () => { + tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) ); + tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) ); + + tableSelection.stopSelection(); + + assertSelectedCells( model, [ + [ 1, 1, 0 ], + [ 0, 0, 0 ], + [ 0, 0, 0 ] + ] ); + } ); + + it( 'should change model selection if cell was passed', () => { + const spy = sinon.spy(); + + tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) ); + + model.document.selection.on( 'change', spy ); + tableSelection.stopSelection( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) ); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'should extend selection to passed table cell', () => { + tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) ); + + tableSelection.stopSelection( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) ); + + assertSelectedCells( model, [ + [ 1, 1, 0 ], + [ 0, 0, 0 ], + [ 0, 0, 0 ] + ] ); + } ); + } ); + + describe( 'clearSelection()', () => { + it( 'should not change model selection', () => { + const spy = sinon.spy(); + + tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) ); + tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) ); + + model.document.selection.on( 'change', spy ); + + tableSelection.clearSelection(); + + sinon.assert.notCalled( spy ); + } ); + + it( 'should not reset model selections', () => { + tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) ); + tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) ); + + tableSelection.clearSelection(); + + assertSelectedCells( model, [ + [ 1, 1, 0 ], + [ 0, 0, 0 ], + [ 0, 0, 0 ] + ] ); + } ); + } ); + + describe( '* getSelectedTableCells()', () => { + it( 'should return nothing if selection is not started', () => { + expect( Array.from( tableSelection.getSelectedTableCells() ) ).to.deep.equal( [] ); + } ); + + it( 'should return two table cells', () => { + const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] ); + const lastCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] ); + + tableSelection.startSelectingFrom( firstCell ); + tableSelection.setSelectingTo( lastCell ); + + expect( Array.from( tableSelection.getSelectedTableCells() ) ).to.deep.equal( [ + firstCell, lastCell + ] ); + } ); + + it( 'should return four table cells for diagonal selection', () => { + const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] ); + const lastCell = modelRoot.getNodeByPath( [ 0, 1, 1 ] ); + + tableSelection.startSelectingFrom( firstCell ); + tableSelection.setSelectingTo( lastCell ); + + expect( Array.from( tableSelection.getSelectedTableCells() ) ).to.deep.equal( [ + firstCell, modelRoot.getNodeByPath( [ 0, 0, 1 ] ), modelRoot.getNodeByPath( [ 0, 1, 0 ] ), lastCell + ] ); + } ); + + it( 'should return row table cells', () => { + const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] ); + const lastCell = modelRoot.getNodeByPath( [ 0, 0, 2 ] ); + + tableSelection.startSelectingFrom( firstCell ); + tableSelection.setSelectingTo( lastCell ); + + expect( Array.from( tableSelection.getSelectedTableCells() ) ).to.deep.equal( [ + firstCell, modelRoot.getNodeByPath( [ 0, 0, 1 ] ), lastCell + ] ); + } ); + + it( 'should return column table cells', () => { + const firstCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] ); + const lastCell = modelRoot.getNodeByPath( [ 0, 2, 1 ] ); + + tableSelection.startSelectingFrom( firstCell ); + tableSelection.setSelectingTo( lastCell ); + + expect( Array.from( tableSelection.getSelectedTableCells() ) ).to.deep.equal( [ + firstCell, modelRoot.getNodeByPath( [ 0, 1, 1 ] ), lastCell + ] ); + } ); + } ); + + describe( 'behavior', () => { + it( 'should clear selection on external changes', () => { + tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) ); + tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) ); + + editor.model.change( writer => { + writer.setSelection( modelRoot.getNodeByPath( [ 0, 0, 0, 0 ] ), 0 ); + } ); + + assertSelectedCells( model, [ + [ 0, 0, 0 ], + [ 0, 0, 0 ], + [ 0, 0, 0 ] + ] ); + + expect( editor.editing.view.document.selection.isFake ).to.be.false; + assertEqualMarkup( getViewData( editor.editing.view ), viewTable( [ + [ '{}11', '12', '13' ], + [ '21', '22', '23' ], + [ '31', '32', '33' ] + ], { asWidget: true } ) ); + } ); + } ); + } ); +} ); diff --git a/tests/tableselection/mouseeventsobserver.js b/tests/tableselection/mouseeventsobserver.js new file mode 100644 index 00000000..48d57443 --- /dev/null +++ b/tests/tableselection/mouseeventsobserver.js @@ -0,0 +1,50 @@ +/** + * @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 document */ + +import View from '@ckeditor/ckeditor5-engine/src/view/view'; +import MouseEventsObserver from '../../src/tableselection/mouseeventsobserver'; + +describe( 'table selection', () => { + describe( 'MouseEventsObserver', () => { + let view, viewDocument, observer; + + beforeEach( () => { + view = new View(); + viewDocument = view.document; + observer = view.addObserver( MouseEventsObserver ); + } ); + + afterEach( () => { + view.destroy(); + } ); + + it( 'should define domEventTypes', () => { + expect( observer.domEventType ).to.deep.equal( [ + 'mousemove', + 'mouseup', + 'mouseleave' + ] ); + } ); + + describe( 'onDomEvent', () => { + for ( const eventName of [ 'mousemove', 'mouseup', 'mouseleave' ] ) { + it( `should fire ${ eventName } with the right event data`, () => { + const spy = sinon.spy(); + + viewDocument.on( eventName, spy ); + + observer.onDomEvent( { type: eventName, target: document.body } ); + + expect( spy.calledOnce ).to.be.true; + + const data = spy.args[ 0 ][ 1 ]; + expect( data.domTarget ).to.equal( document.body ); + } ); + } + } ); + } ); +} ); diff --git a/tests/tableselection/mouseselectionhandler.js b/tests/tableselection/mouseselectionhandler.js new file mode 100644 index 00000000..ec8fab5d --- /dev/null +++ b/tests/tableselection/mouseselectionhandler.js @@ -0,0 +1,485 @@ +/** + * @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 { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; +import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils'; + +import TableEditing from '../../src/tableediting'; +import TableSelection from '../../src/tableselection'; +import { assertSelectedCells, modelTable, viewTable } from '../_utils/utils'; + +describe( 'table selection', () => { + let editor, model, view, viewDoc; + + beforeEach( async () => { + editor = await VirtualTestEditor.create( { + plugins: [ TableEditing, TableSelection, Paragraph ] + } ); + + model = editor.model; + view = editor.editing.view; + viewDoc = view.document; + + setModelData( model, modelTable( [ + [ '11[]', '12', '13' ], + [ '21', '22', '23' ], + [ '31', '32', '33' ] + ] ) ); + } ); + + afterEach( async () => { + await editor.destroy(); + } ); + + describe( 'MouseSelectionHandler', () => { + it( 'should not start table selection when mouse move is inside one table cell', () => { + setModelData( model, modelTable( [ + [ '[]00', '01' ], + [ '10', '11' ] + ] ) ); + + pressMouseButtonOver( getTableCell( '00' ) ); + + assertEqualMarkup( getModelData( model ), modelTable( [ + [ '[]00', '01' ], + [ '10', '11' ] + ] ) ); + + movePressedMouseOver( getTableCell( '00' ) ); + + assertEqualMarkup( getModelData( model ), modelTable( [ + [ '[]00', '01' ], + [ '10', '11' ] + ] ) ); + } ); + + it( 'should start table selection when mouse move expands over two cells', () => { + setModelData( model, modelTable( [ + [ '[]00', '01' ], + [ '10', '11' ] + ] ) ); + + pressMouseButtonOver( getTableCell( '00' ) ); + + assertEqualMarkup( getModelData( model ), modelTable( [ + [ '[]00', '01' ], + [ '10', '11' ] + ] ) ); + + movePressedMouseOver( getTableCell( '01' ) ); + + assertSelectedCells( model, [ + [ 1, 1 ], + [ 0, 0 ] + ] ); + + assertEqualMarkup( getViewData( view ), viewTable( [ + [ + { contents: '00', class: 'ck-editor__editable_selected', isSelected: true }, + { contents: '01', class: 'ck-editor__editable_selected', isSelected: true } + ], + [ '10', '11' ] + ], { asWidget: true } ) ); + } ); + + it( 'should select rectangular table cells when mouse moved to diagonal cell (up -> down)', () => { + setModelData( model, modelTable( [ + [ '[]00', '01' ], + [ '10', '11' ] + ] ) ); + + pressMouseButtonOver( getTableCell( '00' ) ); + + assertEqualMarkup( getModelData( model ), modelTable( [ + [ '[]00', '01' ], + [ '10', '11' ] + ] ) ); + + movePressedMouseOver( getTableCell( '11' ) ); + + assertSelectedCells( model, [ + [ 1, 1 ], + [ 1, 1 ] + ] ); + + assertEqualMarkup( getViewData( view ), viewTable( [ + [ + { contents: '00', class: 'ck-editor__editable_selected', isSelected: true }, + { contents: '01', class: 'ck-editor__editable_selected', isSelected: true } + ], + [ + { contents: '10', class: 'ck-editor__editable_selected', isSelected: true }, + { contents: '11', class: 'ck-editor__editable_selected', isSelected: true } + ] + ], { asWidget: true } ) ); + } ); + + it( 'should select rectangular table cells when mouse moved to diagonal cell (down -> up)', () => { + setModelData( model, modelTable( [ + [ '00', '01' ], + [ '10', '[]11' ] + ] ) ); + + pressMouseButtonOver( getTableCell( '11' ) ); + + assertEqualMarkup( getModelData( model ), modelTable( [ + [ '00', '01' ], + [ '10', '[]11' ] + ] ) ); + + movePressedMouseOver( getTableCell( '00' ) ); + + assertSelectedCells( model, [ + [ 1, 1 ], + [ 1, 1 ] + ] ); + + assertEqualMarkup( getViewData( view ), viewTable( [ + [ + { contents: '00', class: 'ck-editor__editable_selected', isSelected: true }, + { contents: '01', class: 'ck-editor__editable_selected', isSelected: true } + ], + [ + { contents: '10', class: 'ck-editor__editable_selected', isSelected: true }, + { contents: '11', class: 'ck-editor__editable_selected', isSelected: true } + ] + ], { asWidget: true } ) ); + } ); + + it( 'should update view selection after changing selection rect', () => { + setModelData( model, modelTable( [ + [ '[]00', '01', '02' ], + [ '10', '11', '12' ], + [ '20', '21', '22' ] + ] ) ); + + pressMouseButtonOver( getTableCell( '00' ) ); + movePressedMouseOver( getTableCell( '22' ) ); + + assertSelectedCells( model, [ + [ 1, 1, 1 ], + [ 1, 1, 1 ], + [ 1, 1, 1 ] + ] ); + + assertEqualMarkup( getViewData( view ), viewTable( [ + [ + { contents: '00', class: 'ck-editor__editable_selected', isSelected: true }, + { contents: '01', class: 'ck-editor__editable_selected', isSelected: true }, + { contents: '02', class: 'ck-editor__editable_selected', isSelected: true } + ], + [ + { contents: '10', class: 'ck-editor__editable_selected', isSelected: true }, + { contents: '11', class: 'ck-editor__editable_selected', isSelected: true }, + { contents: '12', class: 'ck-editor__editable_selected', isSelected: true } + ], + [ + { contents: '20', class: 'ck-editor__editable_selected', isSelected: true }, + { contents: '21', class: 'ck-editor__editable_selected', isSelected: true }, + { contents: '22', class: 'ck-editor__editable_selected', isSelected: true } + ] + ], { asWidget: true } ) ); + + movePressedMouseOver( getTableCell( '11' ) ); + + assertSelectedCells( model, [ + [ 1, 1, 0 ], + [ 1, 1, 0 ], + [ 0, 0, 0 ] + ] ); + + assertEqualMarkup( getViewData( view ), viewTable( [ + [ + { contents: '00', class: 'ck-editor__editable_selected', isSelected: true }, + { contents: '01', class: 'ck-editor__editable_selected', isSelected: true }, + '02' + ], + [ + { contents: '10', class: 'ck-editor__editable_selected', isSelected: true }, + { contents: '11', class: 'ck-editor__editable_selected', isSelected: true }, + '12' + ], + [ + '20', + '21', + '22' + ] + ], { asWidget: true } ) ); + } ); + + it( 'should stop selecting after "mouseup" event', () => { + setModelData( model, modelTable( [ + [ '[]00', '01' ], + [ '10', '11' ] + ] ) ); + + pressMouseButtonOver( getTableCell( '00' ) ); + + assertEqualMarkup( getModelData( model ), modelTable( [ + [ '[]00', '01' ], + [ '10', '11' ] + ] ) ); + + movePressedMouseOver( getTableCell( '01' ) ); + releaseMouseButtonOver( getTableCell( '01' ) ); + + assertSelectedCells( model, [ + [ 1, 1 ], + [ 0, 0 ] + ] ); + + assertEqualMarkup( getViewData( view ), viewTable( [ + [ + { contents: '00', class: 'ck-editor__editable_selected', isSelected: true }, + { contents: '01', class: 'ck-editor__editable_selected', isSelected: true } + ], + [ '10', '11' ] + ], { asWidget: true } ) ); + + moveReleasedMouseOver( getTableCell( '11' ) ); + + assertSelectedCells( model, [ + [ 1, 1 ], + [ 0, 0 ] + ] ); + } ); + + it( 'should do nothing on "mouseup" event', () => { + setModelData( model, modelTable( [ + [ '[]00', '01' ], + [ '10', '11' ] + ] ) ); + + releaseMouseButtonOver( getTableCell( '01' ) ); + + assertSelectedCells( model, [ + [ 0, 0 ], + [ 0, 0 ] + ] ); + } ); + + it( 'should stop selection mode on "mouseleve" event if next "mousemove" has no button pressed', () => { + setModelData( model, modelTable( [ + [ '[]00', '01' ], + [ '10', '11' ] + ] ) ); + + pressMouseButtonOver( getTableCell( '00' ) ); + + assertEqualMarkup( getModelData( model ), modelTable( [ + [ '[]00', '01' ], + [ '10', '11' ] + ] ) ); + + movePressedMouseOver( getTableCell( '01' ) ); + makeMouseLeave(); + + assertSelectedCells( model, [ + [ 1, 1 ], + [ 0, 0 ] + ] ); + + assertEqualMarkup( getViewData( view ), viewTable( [ + [ + { contents: '00', class: 'ck-editor__editable_selected', isSelected: true }, + { contents: '01', class: 'ck-editor__editable_selected', isSelected: true } + ], + [ '10', '11' ] + ], { asWidget: true } ) ); + + moveReleasedMouseOver( getTableCell( '11' ) ); + + assertSelectedCells( model, [ + [ 1, 1 ], + [ 0, 0 ] + ] ); + } ); + + it( 'should continue selection mode on "mouseleve" and "mousemove" if mouse button is pressed', () => { + setModelData( model, modelTable( [ + [ '[]00', '01' ], + [ '10', '11' ] + ] ) ); + + pressMouseButtonOver( getTableCell( '00' ) ); + + assertEqualMarkup( getModelData( model ), modelTable( [ + [ '[]00', '01' ], + [ '10', '11' ] + ] ) ); + + movePressedMouseOver( getTableCell( '01' ) ); + makeMouseLeave(); + + assertSelectedCells( model, [ + [ 1, 1 ], + [ 0, 0 ] + ] ); + + assertEqualMarkup( getViewData( view ), viewTable( [ + [ + { contents: '00', class: 'ck-editor__editable_selected', isSelected: true }, + { contents: '01', class: 'ck-editor__editable_selected', isSelected: true } + ], + [ '10', '11' ] + ], { asWidget: true } ) ); + + movePressedMouseOver( getTableCell( '11' ) ); + + assertSelectedCells( model, [ + [ 1, 1 ], + [ 1, 1 ] + ] ); + } ); + + it( 'should do nothing on "mouseleve" event', () => { + setModelData( model, modelTable( [ + [ '[]00', '01' ], + [ '10', '11' ] + ] ) ); + + makeMouseLeave(); + + assertSelectedCells( model, [ + [ 0, 0 ], + [ 0, 0 ] + ] ); + } ); + + it( 'should do nothing on "mousedown" event over ui element (click on selection handle)', () => { + setModelData( model, modelTable( [ + [ '[]00', '01' ], + [ '10', '11' ] + ] ) ); + + const uiElement = viewDoc.getRoot() + .getChild( 0 ) + .getChild( 0 ); // selection handler; + + fireEvent( view, 'mousedown', addTarget( uiElement ), mouseButtonPressed ); + + assertEqualMarkup( getModelData( model ), modelTable( [ + [ '[]00', '01' ], + [ '10', '11' ] + ] ) ); + } ); + + it( 'should do nothing on "mousemove" event over ui element (click on selection handle)', () => { + setModelData( model, modelTable( [ + [ '[]00', '01' ], + [ '10', '11' ] + ] ) ); + + const uiElement = viewDoc.getRoot() + .getChild( 0 ) + .getChild( 0 ); // selection handler; + + fireEvent( view, 'mousemove', addTarget( uiElement ), mouseButtonPressed ); + + assertEqualMarkup( getModelData( model ), modelTable( [ + [ '[]00', '01' ], + [ '10', '11' ] + ] ) ); + } ); + + it( 'should clear view table selection after mouse click outside table', () => { + setModelData( model, modelTable( [ + [ '[]00', '01' ], + [ '10', '11' ] + ] ) + 'foo' ); + + pressMouseButtonOver( getTableCell( '00' ) ); + + assertEqualMarkup( getModelData( model ), modelTable( [ + [ '[]00', '01' ], + [ '10', '11' ] + ] ) + 'foo' ); + + movePressedMouseOver( getTableCell( '01' ) ); + + const paragraph = viewDoc.getRoot().getChild( 1 ); + + fireEvent( view, 'mousemove', addTarget( paragraph ) ); + fireEvent( view, 'mousedown', addTarget( paragraph ) ); + fireEvent( view, 'mouseup', addTarget( paragraph ) ); + + // The click in the DOM would trigger selection change and it will set the selection: + model.change( writer => { + writer.setSelection( writer.createRange( writer.createPositionAt( model.document.getRoot().getChild( 1 ), 0 ) ) ); + } ); + + assertEqualMarkup( getViewData( view ), viewTable( [ + [ '00', '01' ], + [ '10', '11' ] + ], { asWidget: true } ) + '

{}foo

' ); + } ); + } ); + + function getTableCell( data ) { + for ( const value of view.createRangeIn( viewDoc.getRoot() ) ) { + if ( value.type === 'text' && value.item.data === data ) { + return value.item.parent.parent; + } + } + } + + function makeMouseLeave() { + fireEvent( view, 'mouseleave' ); + } + + function pressMouseButtonOver( target ) { + fireEvent( view, 'mousedown', addTarget( target ), mouseButtonPressed ); + } + + function movePressedMouseOver( target ) { + moveMouseOver( target, mouseButtonPressed ); + } + + function moveReleasedMouseOver( target ) { + moveMouseOver( target, mouseButtonReleased ); + } + + function moveMouseOver( target, ...decorators ) { + fireEvent( view, 'mousemove', addTarget( target ), ...decorators ); + } + + function releaseMouseButtonOver( target ) { + fireEvent( view, 'mouseup', addTarget( target ), mouseButtonReleased ); + } + + function addTarget( target ) { + return domEventData => { + domEventData.target = target; + }; + } + + function mouseButtonPressed( domEventData ) { + domEventData.domEvent.buttons = 1; + } + + function mouseButtonReleased( domEventData ) { + domEventData.domEvent.buttons = 0; + } + + function fireEvent( view, eventName, ...decorators ) { + const domEvtDataStub = { + domEvent: { + buttons: 0 + }, + target: undefined, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + for ( const decorator of decorators ) { + decorator( domEvtDataStub ); + } + + viewDoc.fire( eventName, domEvtDataStub ); + } +} ); diff --git a/theme/tableselection.css b/theme/tableselection.css new file mode 100644 index 00000000..b3d84551 --- /dev/null +++ b/theme/tableselection.css @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* + * Note: This file should contain the wireframe styles only. But since there are no such styles, + * it acts as a message to the builder telling that it should look for the corresponding styles + * **in the theme** when compiling the editor. + */ + +.ck.ck-editor__editable .table table { + & td.ck-editor__editable_selected, + & th.ck-editor__editable_selected { + box-shadow: inset 0 0 0 1px var(--ck-color-focus-border); + } +}