diff --git a/package.json b/package.json index dd707950..f60e4683 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,19 @@ "ckeditor5-feature" ], "dependencies": { + "@ckeditor/ckeditor5-core": "^10.0.0", + "@ckeditor/ckeditor5-engine": "^10.0.0", + "@ckeditor/ckeditor5-ui": "^10.0.0", + "@ckeditor/ckeditor5-widget": "^10.0.0" }, "devDependencies": { + "@ckeditor/ckeditor5-editor-classic": "^10.0.0", + "@ckeditor/ckeditor5-paragraph": "^10.0.0", + "@ckeditor/ckeditor5-utils": "^10.0.0", + "eslint": "^4.15.0", + "eslint-config-ckeditor5": "^1.0.7", + "husky": "^0.14.3", + "lint-staged": "^7.0.0" }, "engines": { "node": ">=6.0.0", diff --git a/src/commands/insertcolumncommand.js b/src/commands/insertcolumncommand.js new file mode 100644 index 00000000..c80a1e6b --- /dev/null +++ b/src/commands/insertcolumncommand.js @@ -0,0 +1,67 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module table/commands/insertcolumncommand + */ + +import Command from '@ckeditor/ckeditor5-core/src/command'; +import { getParentTable } from './utils'; +import TableUtils from '../tableutils'; + +/** + * The insert column command. + * + * @extends module:core/command~Command + */ +export default class InsertColumnCommand extends Command { + /** + * Creates a new `InsertRowCommand` instance. + * + * @param {module:core/editor/editor~Editor} editor Editor on which this command will be used. + * @param {Object} options + * @param {String} [options.order="after"] The order of insertion relative to a column in which caret is located. + * Possible values: "after" and "before". + */ + constructor( editor, options = {} ) { + super( editor ); + + /** + * The order of insertion relative to a column in which caret is located. + * + * @readonly + * @member {String} module:table/commands/insertcolumncommand~InsertColumnCommand#order + */ + this.order = options.order || 'after'; + } + + /** + * @inheritDoc + */ + refresh() { + const selection = this.editor.model.document.selection; + + const tableParent = getParentTable( selection.getFirstPosition() ); + + this.isEnabled = !!tableParent; + } + + /** + * @inheritDoc + */ + execute() { + const editor = this.editor; + const selection = editor.model.document.selection; + const tableUtils = editor.plugins.get( TableUtils ); + + const table = getParentTable( selection.getFirstPosition() ); + const tableCell = selection.getFirstPosition().parent; + + const { column } = tableUtils.getCellLocation( tableCell ); + const insertAt = this.order === 'after' ? column + 1 : column; + + tableUtils.insertColumns( table, { columns: 1, at: insertAt } ); + } +} diff --git a/src/commands/insertrowcommand.js b/src/commands/insertrowcommand.js new file mode 100644 index 00000000..e939dc5b --- /dev/null +++ b/src/commands/insertrowcommand.js @@ -0,0 +1,67 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module table/commands/insertrowcommand + */ + +import Command from '@ckeditor/ckeditor5-core/src/command'; +import { getParentTable } from './utils'; +import TableUtils from '../tableutils'; + +/** + * The insert row command. + * + * @extends module:core/command~Command + */ +export default class InsertRowCommand extends Command { + /** + * Creates a new `InsertRowCommand` instance. + * + * @param {module:core/editor/editor~Editor} editor Editor on which this command will be used. + * @param {Object} options + * @param {String} [options.order="below"] The order of insertion relative to a row in which caret is located. + * Possible values: "above" and "below". + */ + constructor( editor, options = {} ) { + super( editor ); + + /** + * The order of insertion relative to a row in which caret is located. + * + * @readonly + * @member {String} module:table/commands/insertrowcommand~InsertRowCommand#order + */ + this.order = options.order || 'below'; + } + + /** + * @inheritDoc + */ + refresh() { + const selection = this.editor.model.document.selection; + + const tableParent = getParentTable( selection.getFirstPosition() ); + + this.isEnabled = !!tableParent; + } + + /** + * @inheritDoc + */ + execute() { + const editor = this.editor; + const selection = editor.model.document.selection; + const tableUtils = editor.plugins.get( TableUtils ); + + const tableCell = selection.getFirstPosition().parent; + const table = getParentTable( selection.getFirstPosition() ); + + const row = table.getChildIndex( tableCell.parent ); + const insertAt = this.order === 'below' ? row + 1 : row; + + tableUtils.insertRows( table, { rows: 1, at: insertAt } ); + } +} diff --git a/src/commands/inserttablecommand.js b/src/commands/inserttablecommand.js new file mode 100644 index 00000000..c69d8ec4 --- /dev/null +++ b/src/commands/inserttablecommand.js @@ -0,0 +1,60 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module table/commands/inserttablecommand + */ + +import Command from '@ckeditor/ckeditor5-core/src/command'; +import Position from '@ckeditor/ckeditor5-engine/src/model/position'; +import TableUtils from '../tableutils'; + +/** + * The insert table command. + * + * @extends module:core/command~Command + */ +export default class InsertTableCommand extends Command { + /** + * @inheritDoc + */ + refresh() { + const model = this.editor.model; + const selection = model.document.selection; + const schema = model.schema; + + const validParent = getInsertTableParent( selection.getFirstPosition() ); + + this.isEnabled = schema.checkChild( validParent, 'table' ); + } + + /** + * @inheritDoc + */ + execute( options = {} ) { + const model = this.editor.model; + const selection = model.document.selection; + const tableUtils = this.editor.plugins.get( TableUtils ); + + const rows = parseInt( options.rows ) || 2; + const columns = parseInt( options.columns ) || 2; + + const firstPosition = selection.getFirstPosition(); + + const isRoot = firstPosition.parent === firstPosition.root; + const insertPosition = isRoot ? Position.createAt( firstPosition ) : Position.createAfter( firstPosition.parent ); + + tableUtils.createTable( insertPosition, rows, columns ); + } +} + +// Returns valid parent to insert table +// +// @param {module:engine/model/position} position +function getInsertTableParent( position ) { + const parent = position.parent; + + return parent === parent.root ? parent : parent.parent; +} diff --git a/src/commands/mergecellcommand.js b/src/commands/mergecellcommand.js new file mode 100644 index 00000000..032428e3 --- /dev/null +++ b/src/commands/mergecellcommand.js @@ -0,0 +1,170 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module table/commands/mergecellcommand + */ + +import Command from '@ckeditor/ckeditor5-core/src/command'; +import Position from '@ckeditor/ckeditor5-engine/src/model/position'; +import Range from '@ckeditor/ckeditor5-engine/src/model/range'; +import TableWalker from '../tablewalker'; + +/** + * The merge cell command. + * + * @extends module:core/command~Command + */ +export default class MergeCellCommand extends Command { + /** + * Creates a new `MergeCellCommand` instance. + * + * @param {module:core/editor/editor~Editor} editor Editor on which this command will be used. + * @param {Object} options + * @param {String} options.direction Indicates which cell merge to currently selected one. + * Possible values are: "left", "right", "up" and "down". + */ + constructor( editor, options ) { + super( editor ); + + /** + * The direction indicates which cell will be merged to currently selected one. + * + * @readonly + * @member {String} #direction + */ + this.direction = options.direction; + + /** + * Whether the merge is horizontal (left/right) or vertical (up/down). + * + * @readonly + * @member {Boolean} #isHorizontal + */ + this.isHorizontal = this.direction == 'right' || this.direction == 'left'; + } + + /** + * @inheritDoc + */ + refresh() { + const cellToMerge = this._getMergeableCell(); + + this.isEnabled = !!cellToMerge; + // In order to check if currently selected cell can be merged with one defined by #direction some computation are done beforehand. + // As such we can cache it as a command's value. + this.value = cellToMerge; + } + + /** + * @inheritDoc + */ + execute() { + const model = this.editor.model; + const doc = model.document; + const tableCell = doc.selection.getFirstPosition().parent; + const cellToMerge = this.value; + const direction = this.direction; + + model.change( writer => { + const isMergeNext = direction == 'right' || direction == 'down'; + + // The merge mechanism is always the same so sort cells to be merged. + const cellToExpand = isMergeNext ? tableCell : cellToMerge; + const cellToRemove = isMergeNext ? cellToMerge : tableCell; + + writer.move( Range.createIn( cellToRemove ), Position.createAt( cellToExpand, 'end' ) ); + writer.remove( cellToRemove ); + + const spanAttribute = this.isHorizontal ? 'colspan' : 'rowspan'; + const cellSpan = parseInt( tableCell.getAttribute( spanAttribute ) || 1 ); + const cellToMergeSpan = parseInt( cellToMerge.getAttribute( spanAttribute ) || 1 ); + + writer.setAttribute( spanAttribute, cellSpan + cellToMergeSpan, cellToExpand ); + + writer.setSelection( Range.createIn( cellToExpand ) ); + } ); + } + + /** + * Returns a cell that is mergeable with current cell depending on command's direction. + * + * @returns {module:engine/model/element|undefined} + * @private + */ + _getMergeableCell() { + const model = this.editor.model; + const doc = model.document; + const element = doc.selection.getFirstPosition().parent; + + if ( !element.is( 'tableCell' ) ) { + return; + } + + // First get the cell on proper direction. + const cellToMerge = this.isHorizontal ? getHorizontalCell( element, this.direction ) : getVerticalCell( element, this.direction ); + + if ( !cellToMerge ) { + return; + } + + // If found check if the span perpendicular to merge direction is equal on both cells. + const spanAttribute = this.isHorizontal ? 'rowspan' : 'colspan'; + const span = parseInt( element.getAttribute( spanAttribute ) || 1 ); + + const cellToMergeSpan = parseInt( cellToMerge.getAttribute( spanAttribute ) || 1 ); + + if ( cellToMergeSpan === span ) { + return cellToMerge; + } + } +} + +// Returns horizontally mergeable cell. +// +// @param {module:engine/model/element~Element} tableCell +// @param {String} direction +// @returns {module:engine/model/node~Node|null} +function getHorizontalCell( tableCell, direction ) { + return direction == 'right' ? tableCell.nextSibling : tableCell.previousSibling; +} + +// Returns vertically mergeable cell. +// +// @param {module:engine/model/element~Element} tableCell +// @param {String} direction +// @returns {module:engine/model/node~Node|null} +function getVerticalCell( tableCell, direction ) { + const tableRow = tableCell.parent; + const table = tableRow.parent; + + const rowIndex = table.getChildIndex( tableRow ); + + // Don't search for mergeable cell if direction points out of the table. + if ( ( direction == 'down' && rowIndex === table.childCount - 1 ) || ( direction == 'up' && rowIndex === 0 ) ) { + return; + } + + const headingRows = table.getAttribute( 'headingRows' ) || 0; + + // Don't search for mergeable cell if direction points out of the current table section. + if ( headingRows && ( ( direction == 'down' && rowIndex === headingRows - 1 ) || ( direction == 'up' && rowIndex === headingRows ) ) ) { + return; + } + + const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 ); + const mergeRow = direction == 'down' ? rowIndex + rowspan : rowIndex; + + const tableMap = [ ...new TableWalker( table, { endRow: mergeRow } ) ]; + + const currentCellData = tableMap.find( value => value.cell === tableCell ); + const mergeColumn = currentCellData.column; + + const cellToMergeData = tableMap.find( ( { row, column } ) => { + return column === mergeColumn && ( direction == 'down' ? mergeRow === row : mergeRow === rowspan + row ); + } ); + + return cellToMergeData && cellToMergeData.cell; +} diff --git a/src/commands/removecolumncommand.js b/src/commands/removecolumncommand.js new file mode 100644 index 00000000..46f30012 --- /dev/null +++ b/src/commands/removecolumncommand.js @@ -0,0 +1,75 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module table/commands/removecolumncommand + */ + +import Command from '@ckeditor/ckeditor5-core/src/command'; + +import TableWalker from '../tablewalker'; +import TableUtils from '../tableutils'; +import { updateNumericAttribute } from './utils'; + +/** + * The split cell command. + * + * @extends module:core/command~Command + */ +export default class RemoveColumnCommand extends Command { + /** + * @inheritDoc + */ + refresh() { + const editor = this.editor; + const selection = editor.model.document.selection; + const tableUtils = editor.plugins.get( TableUtils ); + + const selectedElement = selection.getFirstPosition().parent; + + this.isEnabled = selectedElement.is( 'tableCell' ) && tableUtils.getColumns( selectedElement.parent.parent ) > 1; + } + + /** + * @inheritDoc + */ + execute() { + const model = this.editor.model; + const selection = model.document.selection; + + const firstPosition = selection.getFirstPosition(); + + const tableCell = firstPosition.parent; + const tableRow = tableCell.parent; + 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 ) ]; + + // Get column index of removed column. + const cellData = tableMap.find( value => value.cell === tableCell ); + const removedColumn = cellData.column; + + model.change( writer => { + // Update heading columns attribute if removing a row from head section. + if ( headingColumns && row <= headingColumns ) { + writer.setAttribute( 'headingColumns', headingColumns - 1, table ); + } + + for ( const { cell, column, colspan } of tableMap ) { + // If colspaned cell overlaps removed column decrease it's span. + if ( column <= removedColumn && colspan > 1 && column + colspan > removedColumn ) { + updateNumericAttribute( 'colspan', colspan - 1, cell, writer ); + } else if ( column === removedColumn ) { + // The cell in removed column has colspan of 1. + writer.remove( cell ); + } + } + } ); + } +} diff --git a/src/commands/removerowcommand.js b/src/commands/removerowcommand.js new file mode 100644 index 00000000..612a0868 --- /dev/null +++ b/src/commands/removerowcommand.js @@ -0,0 +1,92 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module table/commands/removerowcommand + */ + +import Command from '@ckeditor/ckeditor5-core/src/command'; +import Position from '@ckeditor/ckeditor5-engine/src/model/position'; +import Range from '@ckeditor/ckeditor5-engine/src/model/range'; + +import TableWalker from '../tablewalker'; +import { updateNumericAttribute } from './utils'; + +/** + * The remove row command. + * + * @extends module:core/command~Command + */ +export default class RemoveRowCommand extends Command { + /** + * @inheritDoc + */ + refresh() { + const model = this.editor.model; + const doc = model.document; + + const element = doc.selection.getFirstPosition().parent; + + this.isEnabled = element.is( 'tableCell' ) && element.parent.parent.childCount > 1; + } + + /** + * @inheritDoc + */ + execute() { + const model = this.editor.model; + const selection = model.document.selection; + + const firstPosition = selection.getFirstPosition(); + const tableCell = firstPosition.parent; + const tableRow = tableCell.parent; + const table = tableRow.parent; + + const currentRow = table.getChildIndex( tableRow ); + const headingRows = table.getAttribute( 'headingRows' ) || 0; + + model.change( writer => { + if ( headingRows && currentRow <= 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 ) + .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 ) + .forEach( ( { cell, rowspan } ) => updateNumericAttribute( 'rowspan', rowspan - 1, cell, writer ) ); + + // Move cells to another row. + const targetRow = currentRow + 1; + const tableWalker = new TableWalker( table, { includeSpanned: true, startRow: targetRow, endRow: targetRow } ); + + let previousCell; + + for ( const { row, column, cell } of [ ...tableWalker ] ) { + if ( cellsToMove.has( column ) ) { + const { cell: cellToMove, rowspanToSet } = cellsToMove.get( column ); + const targetPosition = previousCell ? Position.createAfter( previousCell ) : Position.createAt( table.getChild( row ) ); + + writer.move( Range.createOn( cellToMove ), targetPosition ); + updateNumericAttribute( 'rowspan', rowspanToSet, cellToMove, writer ); + + previousCell = cellToMove; + } else { + previousCell = cell; + } + } + + writer.remove( tableRow ); + } ); + } +} diff --git a/src/commands/settableheaderscommand.js b/src/commands/settableheaderscommand.js new file mode 100644 index 00000000..176a14e9 --- /dev/null +++ b/src/commands/settableheaderscommand.js @@ -0,0 +1,143 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module table/commands/settableheaderscommand + */ + +import Command from '@ckeditor/ckeditor5-core/src/command'; +import Position from '@ckeditor/ckeditor5-engine/src/model/position'; + +import { getParentTable, updateNumericAttribute } from './utils'; +import TableWalker from '../tablewalker'; + +/** + * The set table headers command. + * + * @extends module:core/command~Command + */ +export default class SetTableHeadersCommand extends Command { + /** + * @inheritDoc + */ + refresh() { + const model = this.editor.model; + const doc = model.document; + const selection = doc.selection; + + const tableParent = getParentTable( selection.getFirstPosition() ); + + this.isEnabled = !!tableParent; + } + + /** + * @inheritDoc + */ + execute( options = {} ) { + const model = this.editor.model; + const doc = model.document; + const selection = doc.selection; + + const rowsToSet = parseInt( options.rows ) || 0; + + const table = getParentTable( selection.getFirstPosition() ); + + model.change( writer => { + const currentHeadingRows = table.getAttribute( 'headingRows' ) || 0; + + if ( currentHeadingRows !== rowsToSet && rowsToSet > 0 ) { + // Changing heading rows requires to check if any of a heading cell is overlaping vertically the table head. + // Any table cell that has a rowspan attribute > 1 will not exceed the table head so we need to fix it in rows below. + const cellsToSplit = getOverlappingCells( table, rowsToSet, currentHeadingRows ); + + for ( const cell of cellsToSplit ) { + splitHorizontally( cell, rowsToSet, writer ); + } + } + + const columnsToSet = parseInt( options.columns ) || 0; + updateTableAttribute( table, 'headingColumns', columnsToSet, writer ); + updateTableAttribute( table, 'headingRows', rowsToSet, writer ); + } ); + } +} + +// Returns cells that span beyond new heading section. +// +// @param {module:engine/model/element~Element} table Table to check +// @param {Number} headingRowsToSet New heading rows attribute. +// @param {Number} currentHeadingRows Current heading rows attribute. +// @returns {Array.} +function getOverlappingCells( table, headingRowsToSet, currentHeadingRows ) { + const cellsToSplit = []; + + const startAnalysisRow = headingRowsToSet > currentHeadingRows ? currentHeadingRows : 0; + + const tableWalker = new TableWalker( table, { startRow: startAnalysisRow, endRow: headingRowsToSet } ); + + for ( const { row, rowspan, cell } of tableWalker ) { + if ( rowspan > 1 && row + rowspan > headingRowsToSet ) { + cellsToSplit.push( cell ); + } + } + + return cellsToSplit; +} + +// @private +function updateTableAttribute( table, attributeName, newValue, writer ) { + const currentValue = table.getAttribute( attributeName ) || 0; + + if ( newValue !== currentValue ) { + updateNumericAttribute( attributeName, newValue, table, writer, 0 ); + } +} + +// Splits table cell horizontally. +// +// @param {module:engine/model/element~Element} tableCell +// @param {Number} headingRows +// @param {module:engine/model/writer~Writer} writer +function splitHorizontally( tableCell, headingRows, writer ) { + const tableRow = tableCell.parent; + const table = tableRow.parent; + const rowIndex = tableRow.index; + + const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) ); + const newRowspan = headingRows - rowIndex; + + const attributes = {}; + + const spanToSet = rowspan - newRowspan; + + if ( spanToSet > 1 ) { + attributes.rowspan = spanToSet; + } + + const startRow = table.getChildIndex( tableRow ); + const endRow = startRow + newRowspan; + const tableMap = [ ...new TableWalker( table, { startRow, endRow, includeSpanned: true } ) ]; + + let columnIndex; + + for ( const { row, column, cell, colspan, cellIndex } of tableMap ) { + if ( cell === tableCell ) { + columnIndex = column; + + if ( colspan > 1 ) { + attributes.colspan = colspan; + } + } + + if ( columnIndex !== undefined && columnIndex === column && row === endRow ) { + const tableRow = table.getChild( row ); + + writer.insertElement( 'tableCell', attributes, Position.createFromParentAndOffset( tableRow, cellIndex ) ); + } + } + + // Update the rowspan attribute after updating table. + updateNumericAttribute( 'rowspan', newRowspan, tableCell, writer ); +} diff --git a/src/commands/splitcellcommand.js b/src/commands/splitcellcommand.js new file mode 100644 index 00000000..00b2396d --- /dev/null +++ b/src/commands/splitcellcommand.js @@ -0,0 +1,71 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module table/commands/splitcellcommand + */ + +import Command from '@ckeditor/ckeditor5-core/src/command'; +import TableUtils from '../tableutils'; + +/** + * The split cell command. + * + * @extends module:core/command~Command + */ +export default class SplitCellCommand extends Command { + /** + * Creates a new `SplitCellCommand` instance. + * + * @param {module:core/editor/editor~Editor} editor Editor on which this command will be used. + * @param {Object} options + * @param {String} options.direction Indicates whether the command should split cells `'horizontally'` or `'vertically'`. + */ + constructor( editor, options = {} ) { + super( editor ); + + /** + * The direction indicates which cell will be split. + * + * @readonly + * @member {String} #direction + */ + this.direction = options.direction || 'horizontally'; + } + + /** + * @inheritDoc + */ + refresh() { + const model = this.editor.model; + const doc = model.document; + + const element = doc.selection.getFirstPosition().parent; + + this.isEnabled = element.is( 'tableCell' ); + } + + /** + * @inheritDoc + */ + execute() { + const model = this.editor.model; + const document = model.document; + const selection = document.selection; + + const firstPosition = selection.getFirstPosition(); + const tableCell = firstPosition.parent; + + const isHorizontally = this.direction === 'horizontally'; + + const tableUtils = this.editor.plugins.get( TableUtils ); + + if ( isHorizontally ) { + tableUtils.splitCellHorizontally( tableCell, 2 ); + } else { + tableUtils.splitCellVertically( tableCell, 2 ); + } + } +} diff --git a/src/commands/utils.js b/src/commands/utils.js new file mode 100644 index 00000000..cb2890f7 --- /dev/null +++ b/src/commands/utils.js @@ -0,0 +1,43 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module table/commands/utils + */ + +/** + * Returns parent table. + * + * @param {module:engine/model/position~Position} position + * @returns {module:engine/model/element~Element|module:engine/model/documentfragment~DocumentFragment} + */ +export function getParentTable( position ) { + let parent = position.parent; + + while ( parent ) { + if ( parent.name === 'table' ) { + return parent; + } + + parent = parent.parent; + } +} + +/** + * Common method to update numeric value. If value is default one it will be unset. + * + * @param {String} key Attribute key. + * @param {*} value Attribute new value. + * @param {module:engine/model/item~Item} item Model item on which the attribute will be set. + * @param {module:engine/model/writer~Writer} writer + * @param {*} defaultValue Default attribute value if value is lower or equal then it will be unset. + */ +export function updateNumericAttribute( key, value, item, writer, defaultValue = 1 ) { + if ( value > defaultValue ) { + writer.setAttribute( key, value, item ); + } else { + writer.removeAttribute( key, item ); + } +} diff --git a/src/converters/downcast.js b/src/converters/downcast.js new file mode 100644 index 00000000..5dd87760 --- /dev/null +++ b/src/converters/downcast.js @@ -0,0 +1,494 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module table/converters/downcast + */ + +import ViewPosition from '@ckeditor/ckeditor5-engine/src/view/position'; +import ViewRange from '@ckeditor/ckeditor5-engine/src/view/range'; +import TableWalker from './../tablewalker'; +import { toWidget, toWidgetEditable } from '@ckeditor/ckeditor5-widget/src/utils'; + +/** + * Model table element to view table element conversion helper. + * + * This conversion helper creates whole table element with child elements. + * + * @param {Object} options + * @param {Boolean} options.asWidget If set to true the downcast conversion will produce widget. + * @returns {Function} Conversion helper. + */ +export function downcastInsertTable( options = {} ) { + return dispatcher => dispatcher.on( 'insert:table', ( evt, data, conversionApi ) => { + const table = data.item; + + if ( !conversionApi.consumable.consume( table, 'insert' ) ) { + return; + } + + // Consume attributes if present to not fire attribute change downcast + conversionApi.consumable.consume( table, 'attribute:headingRows:table' ); + conversionApi.consumable.consume( table, 'attribute:headingColumns:table' ); + + const asWidget = options && options.asWidget; + + const tableElement = conversionApi.writer.createContainerElement( 'table' ); + + let tableWidget; + + if ( asWidget ) { + tableWidget = toWidget( tableElement, conversionApi.writer ); + } + + const tableWalker = new TableWalker( table ); + + const tableAttributes = { + headingRows: table.getAttribute( 'headingRows' ) || 0, + headingColumns: table.getAttribute( 'headingColumns' ) || 0 + }; + + for ( const tableWalkerValue of tableWalker ) { + const { row, cell } = tableWalkerValue; + + const tableSection = getOrCreateTableSection( getSectionName( row, tableAttributes ), tableElement, conversionApi ); + const tableRow = table.getChild( row ); + + // Check if row was converted + const trElement = getOrCreateTr( tableRow, row, tableSection, conversionApi ); + + // Consume table cell - it will be always consumed as we convert whole table at once. + conversionApi.consumable.consume( cell, 'insert' ); + + const insertPosition = ViewPosition.createAt( trElement, 'end' ); + + createViewTableCellElement( tableWalkerValue, tableAttributes, insertPosition, conversionApi, options ); + } + + const viewPosition = conversionApi.mapper.toViewPosition( data.range.start ); + + conversionApi.mapper.bindElements( table, asWidget ? tableWidget : tableElement ); + conversionApi.writer.insert( viewPosition, asWidget ? tableWidget : tableElement ); + }, { priority: 'normal' } ); +} + +/** + * Model row element to view element conversion helper. + * + * This conversion helper creates whole element with child elements. + * + * @returns {Function} Conversion helper. + */ +export function downcastInsertRow( options = {} ) { + return dispatcher => dispatcher.on( 'insert:tableRow', ( evt, data, conversionApi ) => { + const tableRow = data.item; + + if ( !conversionApi.consumable.consume( tableRow, 'insert' ) ) { + return; + } + + const table = tableRow.parent; + + const tableElement = conversionApi.mapper.toViewElement( table ); + + const row = table.getChildIndex( tableRow ); + + const tableWalker = new TableWalker( table, { startRow: row, endRow: row } ); + + const tableAttributes = { + headingRows: table.getAttribute( 'headingRows' ) || 0, + headingColumns: table.getAttribute( 'headingColumns' ) || 0 + }; + + for ( const tableWalkerValue of tableWalker ) { + const tableSection = getOrCreateTableSection( getSectionName( row, tableAttributes ), tableElement, conversionApi ); + const trElement = getOrCreateTr( tableRow, row, tableSection, conversionApi ); + + // Consume table cell - it will be always consumed as we convert whole row at once. + conversionApi.consumable.consume( tableWalkerValue.cell, 'insert' ); + + const insertPosition = ViewPosition.createAt( trElement, 'end' ); + + createViewTableCellElement( tableWalkerValue, tableAttributes, insertPosition, conversionApi, options ); + } + }, { priority: 'normal' } ); +} + +/** + * Model tableCEll element to view or element conversion helper. + * + * This conversion helper will create proper elements for tableCells that are in heading section (heading row or column) + * and otherwise. + * + * @returns {Function} Conversion helper. + */ +export function downcastInsertCell( options = {} ) { + return dispatcher => dispatcher.on( 'insert:tableCell', ( evt, data, conversionApi ) => { + const tableCell = data.item; + + if ( !conversionApi.consumable.consume( tableCell, 'insert' ) ) { + return; + } + + const tableRow = tableCell.parent; + const table = tableRow.parent; + const rowIndex = table.getChildIndex( tableRow ); + + const tableWalker = new TableWalker( table, { startRow: rowIndex, endRow: rowIndex } ); + + const tableAttributes = { + headingRows: table.getAttribute( 'headingRows' ) || 0, + headingColumns: table.getAttribute( 'headingColumns' ) || 0 + }; + + // We need to iterate over a table in order to get proper row & column values from a walker + for ( const tableWalkerValue of tableWalker ) { + if ( tableWalkerValue.cell === tableCell ) { + const trElement = conversionApi.mapper.toViewElement( tableRow ); + const insertPosition = ViewPosition.createAt( trElement, tableRow.getChildIndex( tableCell ) ); + + createViewTableCellElement( tableWalkerValue, tableAttributes, insertPosition, conversionApi, options ); + + // No need to iterate further. + return; + } + } + }, { priority: 'normal' } ); +} + +/** + * Conversion helper that acts on headingRows table attribute change. + * + * This converter will: + * + * * rename to elements or vice versa depending on headings, + * * create or elements if needed, + * * remove empty or if needed. + * + * @returns {Function} Conversion helper. + */ +export function downcastTableHeadingRowsChange( options = {} ) { + const asWidget = !!options.asWidget; + + return dispatcher => dispatcher.on( 'attribute:headingRows:table', ( evt, data, conversionApi ) => { + const table = data.item; + + if ( !conversionApi.consumable.consume( data.item, evt.name ) ) { + return; + } + + const viewTable = conversionApi.mapper.toViewElement( table ); + + const oldRows = data.attributeOldValue; + const newRows = data.attributeNewValue; + + // The head section has grown so move rows from to . + if ( newRows > oldRows ) { + // Filter out only those rows that are in wrong section. + const rowsToMove = Array.from( table.getChildren() ).filter( ( { index } ) => isBetween( index, oldRows - 1, newRows ) ); + + const viewTableHead = getOrCreateTableSection( 'thead', viewTable, conversionApi ); + moveViewRowsToTableSection( rowsToMove, viewTableHead, conversionApi, 'end' ); + + // Rename all table cells from moved rows to 'th' as they lands in . + for ( const tableRow of rowsToMove ) { + for ( const tableCell of tableRow.getChildren() ) { + renameViewTableCell( tableCell, 'th', conversionApi, asWidget ); + } + } + + // Cleanup: this will remove any empty section from the view which may happen when moving all rows from a table section. + removeTableSectionIfEmpty( 'tbody', viewTable, conversionApi ); + } + // The head section has shrunk so move rows from to . + else { + // Filter out only those rows that are in wrong section. + const rowsToMove = Array.from( table.getChildren() ) + .filter( ( { index } ) => isBetween( index, newRows - 1, oldRows ) ) + .reverse(); // The rows will be moved from to in reverse order at the beginning of a . + + const viewTableBody = getOrCreateTableSection( 'tbody', viewTable, conversionApi ); + moveViewRowsToTableSection( rowsToMove, viewTableBody, conversionApi ); + + // Check if cells moved from to requires renaming to as this depends on current heading columns attribute. + const tableWalker = new TableWalker( table, { startRow: newRows ? newRows - 1 : newRows, endRow: oldRows - 1 } ); + + const tableAttributes = { + headingRows: table.getAttribute( 'headingRows' ) || 0, + headingColumns: table.getAttribute( 'headingColumns' ) || 0 + }; + + for ( const tableWalkerValue of tableWalker ) { + renameViewTableCellIfRequired( tableWalkerValue, tableAttributes, conversionApi, asWidget ); + } + + // Cleanup: this will remove any empty section from the view which may happen when moving all rows from a table section. + removeTableSectionIfEmpty( 'thead', viewTable, conversionApi ); + } + + function isBetween( index, lower, upper ) { + return index > lower && index < upper; + } + }, { priority: 'normal' } ); +} + +/** + * Conversion helper that acts on headingColumns table attribute change. + * + * Depending on changed attributes this converter will rename to elements or vice versa depending of cell column index. + * + * @returns {Function} Conversion helper. + */ +export function downcastTableHeadingColumnsChange( options = {} ) { + const asWidget = !!options.asWidget; + + return dispatcher => dispatcher.on( 'attribute:headingColumns:table', ( evt, data, conversionApi ) => { + const table = data.item; + + if ( !conversionApi.consumable.consume( data.item, evt.name ) ) { + return; + } + + const tableAttributes = { + headingRows: table.getAttribute( 'headingRows' ) || 0, + headingColumns: table.getAttribute( 'headingColumns' ) || 0 + }; + + const oldColumns = data.attributeOldValue; + const newColumns = data.attributeNewValue; + + const lastColumnToCheck = ( oldColumns > newColumns ? oldColumns : newColumns ) - 1; + + for ( const tableWalkerValue of new TableWalker( table ) ) { + // Skip cells that were not in heading section before and after the change. + if ( tableWalkerValue.column > lastColumnToCheck ) { + continue; + } + + renameViewTableCellIfRequired( tableWalkerValue, tableAttributes, conversionApi, asWidget ); + } + }, { priority: 'normal' } ); +} + +/** + * Conversion helper that acts on removed row. + * + * @returns {Function} Conversion helper. + */ +export function downcastRemoveRow() { + return dispatcher => dispatcher.on( 'remove:tableRow', ( evt, data, conversionApi ) => { + // Prevent default remove converter. + evt.stop(); + + const viewStart = conversionApi.mapper.toViewPosition( data.position ).getLastMatchingPosition( value => !value.item.is( 'tr' ) ); + const viewItem = viewStart.nodeAfter; + const tableSection = viewItem.parent; + + // Remove associated from the view. + const removeRange = ViewRange.createOn( viewItem ); + const removed = conversionApi.writer.remove( removeRange ); + + for ( const child of ViewRange.createIn( removed ).getItems() ) { + conversionApi.mapper.unbindViewElement( child ); + } + + // Check if table section has any children left - if not remove it from the view. + if ( !tableSection.childCount ) { + // No need to unbind anything as table section is not represented in the model. + conversionApi.writer.remove( ViewRange.createOn( tableSection ) ); + } + }, { priority: 'higher' } ); +} + +// Renames table cell in the view to given element name. +// +// @param {module:engine/model/element~Element} tableCell +// @param {String} desiredCellElementName +// @param {Object} conversionApi +// @param {Boolean} asWidget +function renameViewTableCell( tableCell, desiredCellElementName, conversionApi, asWidget ) { + const viewCell = conversionApi.mapper.toViewElement( tableCell ); + + let renamedCell; + + if ( asWidget ) { + const editable = conversionApi.writer.createEditableElement( desiredCellElementName, viewCell.getAttributes() ); + renamedCell = toWidgetEditable( editable, conversionApi.writer ); + + conversionApi.writer.insert( ViewPosition.createAfter( viewCell ), renamedCell ); + conversionApi.writer.move( ViewRange.createIn( viewCell ), ViewPosition.createAt( renamedCell ) ); + conversionApi.writer.remove( ViewRange.createOn( viewCell ) ); + } else { + renamedCell = conversionApi.writer.rename( viewCell, desiredCellElementName ); + } + + conversionApi.mapper.bindElements( tableCell, renamedCell ); +} + +// Renames a table cell element in a view according to it's location in table. +// +// @param {module:table/tablewalker~TableWalkerValue} tableWalkerValue +// @param {{headingColumns, headingRows}} tableAttributes +// @param {Object} conversionApi +// @param {Boolean} asWidget +function renameViewTableCellIfRequired( tableWalkerValue, tableAttributes, conversionApi, asWidget ) { + const { cell } = tableWalkerValue; + + // Check whether current columnIndex is overlapped by table cells from previous rows. + const desiredCellElementName = getCellElementName( tableWalkerValue, tableAttributes ); + + const viewCell = conversionApi.mapper.toViewElement( cell ); + + // If in single change we're converting attribute changes and inserting cell the table cell might not be inserted into view + // because of child conversion is done after parent. + if ( viewCell && viewCell.name !== desiredCellElementName ) { + renameViewTableCell( cell, desiredCellElementName, conversionApi, asWidget ); + } +} + +// Creates a table cell element in a view. +// +// @param {module:table/tablewalker~TableWalkerValue} tableWalkerValue +// @param {module:engine/view/position~Position} insertPosition +// @param {Object} conversionApi +function createViewTableCellElement( tableWalkerValue, tableAttributes, insertPosition, conversionApi, options ) { + const asWidget = options && options.asWidget; + const cellElementName = getCellElementName( tableWalkerValue, tableAttributes ); + + const cellElement = asWidget ? + toWidgetEditable( conversionApi.writer.createEditableElement( cellElementName ), conversionApi.writer ) : + conversionApi.writer.createContainerElement( cellElementName ); + + const tableCell = tableWalkerValue.cell; + + conversionApi.mapper.bindElements( tableCell, cellElement ); + conversionApi.writer.insert( insertPosition, cellElement ); +} + +// Creates or returns an existing tr element from a view. +// +// @param {module:engine/view/element~Element} tableRow +// @param {Number} rowIndex +// @param {module:engine/view/element~Element} tableSection +// @param {Object} conversionApi +// @returns {module:engine/view/element~Element} +function getOrCreateTr( tableRow, rowIndex, tableSection, conversionApi ) { + let trElement = conversionApi.mapper.toViewElement( tableRow ); + + if ( !trElement ) { + // Will always consume since we're converting element from a parent . + conversionApi.consumable.consume( tableRow, 'insert' ); + + trElement = conversionApi.writer.createContainerElement( 'tr' ); + conversionApi.mapper.bindElements( tableRow, trElement ); + + const headingRows = tableRow.parent.getAttribute( 'headingRows' ) || 0; + const offset = headingRows > 0 && rowIndex >= headingRows ? rowIndex - headingRows : rowIndex; + + const position = ViewPosition.createAt( tableSection, offset ); + conversionApi.writer.insert( position, trElement ); + } + + return trElement; +} + +// Returns `th` for heading cells and `td` for other cells for current table walker value. +// +// @param {module:table/tablewalker~TableWalkerValue} tableWalkerValue +// @param {{headingColumns, headingRows}} tableAttributes +// @returns {String} +function getCellElementName( tableWalkerValue, tableAttributes ) { + const { row, column } = tableWalkerValue; + const { headingColumns, headingRows } = tableAttributes; + + // Column heading are all tableCells in the first `columnHeading` rows. + const isColumnHeading = headingRows && headingRows > row; + + // So a whole row gets or element witch caching. +// +// @param {String} sectionName +// @param {module:engine/view/element~Element} viewTable +// @param {Object} conversionApi +// @param {Object} cachedTableSection An object on which store cached elements. +// @returns {module:engine/view/containerelement~ContainerElement} +function getOrCreateTableSection( sectionName, viewTable, conversionApi ) { + const viewTableSection = getExistingTableSectionElement( sectionName, viewTable ); + + return viewTableSection ? viewTableSection : createTableSection( sectionName, viewTable, conversionApi ); +} + +// Finds an existing or element or returns undefined. +// +// @param {String} sectionName +// @param {module:engine/view/element~Element} tableElement +// @param {Object} conversionApi +function getExistingTableSectionElement( sectionName, tableElement ) { + for ( const tableSection of tableElement.getChildren() ) { + if ( tableSection.name == sectionName ) { + return tableSection; + } + } +} + +// Creates table section at the end of a table. +// +// @param {String} sectionName +// @param {module:engine/view/element~Element} tableElement +// @param {Object} conversionApi +// @returns {module:engine/view/containerelement~ContainerElement} +function createTableSection( sectionName, tableElement, conversionApi ) { + const tableChildElement = conversionApi.writer.createContainerElement( sectionName ); + + conversionApi.writer.insert( ViewPosition.createAt( tableElement, sectionName == 'tbody' ? 'end' : 'start' ), tableChildElement ); + + return tableChildElement; +} + +// Removes an existing or element if it is empty. +// +// @param {String} sectionName +// @param {module:engine/view/element~Element} tableElement +// @param {Object} conversionApi +function removeTableSectionIfEmpty( sectionName, tableElement, conversionApi ) { + const tableSection = getExistingTableSectionElement( sectionName, tableElement ); + + if ( tableSection && tableSection.childCount === 0 ) { + conversionApi.writer.remove( ViewRange.createOn( tableSection ) ); + } +} + +// Moves view table rows associated with passed model rows to provided table section element. +// +// @param {Array.} rowsToMove +// @param {module:engine/view/element~Element} viewTableSection +// @param {Object} conversionApi +// @param {Number|'end'|'before'|'after'} [offset=0] Offset or one of the flags. +function moveViewRowsToTableSection( rowsToMove, viewTableSection, conversionApi, offset ) { + for ( const tableRow of rowsToMove ) { + const viewTableRow = conversionApi.mapper.toViewElement( tableRow ); + + conversionApi.writer.move( ViewRange.createOn( viewTableRow ), ViewPosition.createAt( viewTableSection, offset ) ); + } +} diff --git a/src/converters/upcasttable.js b/src/converters/upcasttable.js new file mode 100644 index 00000000..f3b4eb26 --- /dev/null +++ b/src/converters/upcasttable.js @@ -0,0 +1,182 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module table/converters/upcasttable + */ + +import ModelRange from '@ckeditor/ckeditor5-engine/src/model/range'; +import ModelPosition from '@ckeditor/ckeditor5-engine/src/model/position'; + +/** + * View table element to model table element conversion helper. + * + * This conversion helper convert table element as well as tableRows. + * + * @returns {Function} Conversion helper. + */ +export default function upcastTable() { + return dispatcher => { + dispatcher.on( 'element:table', ( evt, data, conversionApi ) => { + const viewTable = data.viewItem; + + // When element was already consumed then skip it. + if ( !conversionApi.consumable.test( viewTable, { name: true } ) ) { + return; + } + + const { rows, headingRows, headingColumns } = scanTable( viewTable ); + + // Only set attributes if values is greater then 0. + const attributes = {}; + + if ( headingColumns ) { + attributes.headingColumns = headingColumns; + } + + if ( headingRows ) { + attributes.headingRows = headingRows; + } + + const table = conversionApi.writer.createElement( 'table', attributes ); + + // Insert element on allowed position. + const splitResult = conversionApi.splitToAllowedParent( table, data.modelCursor ); + conversionApi.writer.insert( table, splitResult.position ); + conversionApi.consumable.consume( viewTable, { name: true } ); + + if ( rows.length ) { + // Upcast table rows in proper order (heading rows first). + rows.forEach( row => conversionApi.convertItem( row, ModelPosition.createAt( table, 'end' ) ) ); + } else { + // Create one row and one table cell for empty table. + const row = conversionApi.writer.createElement( 'tableRow' ); + + conversionApi.writer.insert( row, ModelPosition.createAt( table, 'end' ) ); + conversionApi.writer.insertElement( 'tableCell', ModelPosition.createAt( row, 'end' ) ); + } + + // Set conversion result range. + data.modelRange = new ModelRange( + // Range should start before inserted element + ModelPosition.createBefore( table ), + // Should end after but we need to take into consideration that children could split our + // element, so we need to move range after parent of the last converted child. + // before: [] + // after: [] + ModelPosition.createAfter( table ) + ); + + // Now we need to check where the modelCursor should be. + // If we had to split parent to insert our element then we want to continue conversion inside split parent. + // + // before: [] + // after: [] + if ( splitResult.cursorParent ) { + data.modelCursor = ModelPosition.createAt( splitResult.cursorParent ); + + // Otherwise just continue after inserted element. + } else { + data.modelCursor = data.modelRange.end; + } + }, { priority: 'normal' } ); + }; +} + +// Scans table rows & extracts required metadata from table: +// +// headingRows - number of rows that goes as table header. +// headingColumns - max number of row headings. +// rows - sorted trs as they should go into the model - ie if is inserted after in the view. +// +// @param {module:engine/view/element~Element} viewTable +// @returns {{headingRows, headingColumns, rows}} +function scanTable( viewTable ) { + const tableMeta = { + headingRows: 0, + headingColumns: 0 + }; + + // The and sections in the DOM doesn't have to be in order -> and there might be more then one of them. + // As the model doesn't have those sections rows from different sections must be sorted. + // Ie below is a valid HTML table: + // + //
element. + if ( isColumnHeading ) { + return 'th'; + } + + // Row heading are tableCells which columnIndex is lower then headingColumns. + const isRowHeading = headingColumns && headingColumns > column; + + return isRowHeading ? 'th' : 'td'; +} + +// Returns table section name for current table walker value. +// +// @param {Number} row +// @param {{headingColumns, headingRows}} tableAttributes +// @returns {String} +function getSectionName( row, tableAttributes ) { + return row < tableAttributes.headingRows ? 'thead' : 'tbody'; +} + +// Creates or returns an existing
+ // + // + // + //
2
1
3
+ // + // But browsers will render rows in order as : 1 as heading and 2 & 3 as (body). + const headRows = []; + const bodyRows = []; + + // Currently the editor does not support more then one section. + // Only the first from the view will be used as heading rows and others will be converted to body rows. + let firstTheadElement; + + for ( const tableChild of Array.from( viewTable.getChildren() ) ) { + // Only , & from allowed table children can have s. + // The else is for future purposes (mainly ). + if ( tableChild.name === 'tbody' || tableChild.name === 'thead' || tableChild.name === 'tfoot' ) { + // Save the first in the table as table header - all other ones will be converted to table body rows. + if ( tableChild.name === 'thead' && !firstTheadElement ) { + firstTheadElement = tableChild; + } + + for ( const tr of Array.from( tableChild.getChildren() ) ) { + // This is a child of a first element. + if ( tr.parent.name === 'thead' && tr.parent === firstTheadElement ) { + tableMeta.headingRows++; + headRows.push( tr ); + } else { + bodyRows.push( tr ); + // For other rows check how many column headings this row has. + + const headingCols = scanRowForHeadingColumns( tr, tableMeta, firstTheadElement ); + + if ( headingCols > tableMeta.headingColumns ) { + tableMeta.headingColumns = headingCols; + } + } + } + } + } + + tableMeta.rows = [ ...headRows, ...bodyRows ]; + + return tableMeta; +} + +// Scans and it's children for metadata: +// - For heading row: +// - either add this row to heading or body rows. +// - updates number of heading rows. +// - For body rows: +// - calculates number of column headings. +// +// @param {module:engine/view/element~Element} tr +// @returns {Number} +function scanRowForHeadingColumns( tr ) { + let headingColumns = 0; + let index = 0; + + // Filter out empty text nodes from tr children. + const children = Array.from( tr.getChildren() ) + .filter( child => child.name === 'th' || child.name === 'td' ); + + // Count starting adjacent elements of a . + while ( index < children.length && children[ index ].name === 'th' ) { + const th = children[ index ]; + + // Adjust columns calculation by the number of spanned columns. + const colspan = parseInt( th.getAttribute( 'colspan' ) || 1 ); + + headingColumns = headingColumns + colspan; + index++; + } + + return headingColumns; +} diff --git a/src/table.js b/src/table.js new file mode 100644 index 00000000..83f11331 --- /dev/null +++ b/src/table.js @@ -0,0 +1,35 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module table/table + */ + +import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; + +import TableEditing from './tableediting'; +import TableUI from './tableui'; +import Widget from '@ckeditor/ckeditor5-widget/src/widget'; + +/** + * The table plugin. + * + * @extends module:core/plugin~Plugin + */ +export default class Table extends Plugin { + /** + * @inheritDoc + */ + static get requires() { + return [ TableEditing, TableUI, Widget ]; + } + + /** + * @inheritDoc + */ + static get pluginName() { + return 'Table'; + } +} diff --git a/src/tableediting.js b/src/tableediting.js new file mode 100644 index 00000000..35d6e63e --- /dev/null +++ b/src/tableediting.js @@ -0,0 +1,235 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module table/tableediting + */ + +import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; +import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters'; +import Range from '@ckeditor/ckeditor5-engine/src/model/range'; +import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; + +import upcastTable from './converters/upcasttable'; +import { + downcastInsertCell, + downcastInsertRow, + downcastInsertTable, + downcastRemoveRow, + downcastTableHeadingColumnsChange, + downcastTableHeadingRowsChange +} from './converters/downcast'; +import InsertTableCommand from './commands/inserttablecommand'; +import InsertRowCommand from './commands/insertrowcommand'; +import InsertColumnCommand from './commands/insertcolumncommand'; +import SplitCellCommand from './commands/splitcellcommand'; +import MergeCellCommand from './commands/mergecellcommand'; +import RemoveRowCommand from './commands/removerowcommand'; +import RemoveColumnCommand from './commands/removecolumncommand'; +import SetTableHeadersCommand from './commands/settableheaderscommand'; +import { getParentTable } from './commands/utils'; + +import './../theme/table.css'; +import TableUtils from './tableutils'; + +/** + * The table editing feature. + * + * @extends module:core/plugin~Plugin + */ +export default class TableEditing extends Plugin { + /** + * @inheritDoc + */ + init() { + const editor = this.editor; + const schema = editor.model.schema; + const conversion = editor.conversion; + + schema.register( 'table', { + allowWhere: '$block', + allowAttributes: [ 'headingRows', 'headingColumns' ], + isObject: true + } ); + + schema.register( 'tableRow', { allowIn: 'table' } ); + + schema.register( 'tableCell', { + allowIn: 'tableRow', + allowContentOf: '$block', + allowAttributes: [ 'colspan', 'rowspan' ], + isLimit: true + } ); + + // Table conversion. + conversion.for( 'upcast' ).add( upcastTable() ); + + conversion.for( 'editingDowncast' ).add( downcastInsertTable( { asWidget: true } ) ); + conversion.for( 'dataDowncast' ).add( downcastInsertTable() ); + + // Table row conversion. + conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableRow', view: 'tr' } ) ); + + conversion.for( 'editingDowncast' ).add( downcastInsertRow( { asWidget: true } ) ); + conversion.for( 'dataDowncast' ).add( downcastInsertRow() ); + conversion.for( 'downcast' ).add( downcastRemoveRow() ); + + // Table cell conversion. + conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'td' } ) ); + conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'th' } ) ); + + conversion.for( 'editingDowncast' ).add( downcastInsertCell( { asWidget: true } ) ); + conversion.for( 'dataDowncast' ).add( downcastInsertCell() ); + + // Table attributes conversion. + conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } ); + conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } ); + + // Table heading rows and cols conversion. + conversion.for( 'editingDowncast' ).add( downcastTableHeadingColumnsChange( { asWidget: true } ) ); + conversion.for( 'dataDowncast' ).add( downcastTableHeadingColumnsChange() ); + conversion.for( 'editingDowncast' ).add( downcastTableHeadingRowsChange( { asWidget: true } ) ); + conversion.for( 'dataDowncast' ).add( downcastTableHeadingRowsChange() ); + + // Define all the commands. + editor.commands.add( 'insertTable', new InsertTableCommand( editor ) ); + editor.commands.add( 'insertRowAbove', new InsertRowCommand( editor, { order: 'above' } ) ); + editor.commands.add( 'insertRowBelow', new InsertRowCommand( editor, { order: 'below' } ) ); + editor.commands.add( 'insertColumnBefore', new InsertColumnCommand( editor, { order: 'before' } ) ); + editor.commands.add( 'insertColumnAfter', new InsertColumnCommand( editor, { order: 'after' } ) ); + + editor.commands.add( 'removeRow', new RemoveRowCommand( editor ) ); + editor.commands.add( 'removeColumn', new RemoveColumnCommand( editor ) ); + + editor.commands.add( 'splitCellVertically', new SplitCellCommand( editor, { direction: 'vertically' } ) ); + editor.commands.add( 'splitCellHorizontally', new SplitCellCommand( editor, { direction: 'horizontally' } ) ); + + editor.commands.add( 'mergeCellRight', new MergeCellCommand( editor, { direction: 'right' } ) ); + editor.commands.add( 'mergeCellLeft', new MergeCellCommand( editor, { direction: 'left' } ) ); + editor.commands.add( 'mergeCellDown', new MergeCellCommand( editor, { direction: 'down' } ) ); + editor.commands.add( 'mergeCellUp', new MergeCellCommand( editor, { direction: 'up' } ) ); + + editor.commands.add( 'setTableHeaders', new SetTableHeadersCommand( editor ) ); + + // Handle tab key navigation. + this.listenTo( editor.editing.view.document, 'keydown', ( ...args ) => this._handleTabOnSelectedTable( ...args ) ); + this.listenTo( editor.editing.view.document, 'keydown', ( ...args ) => this._handleTabInsideTable( ...args ) ); + } + + /** + * @inheritDoc + */ + static get requires() { + return [ TableUtils ]; + } + + /** + * Handles {@link module:engine/view/document~Document#event:keydown keydown} events for 'Tab' key executed + * when table widget is selected. + * + * @private + * @param {module:utils/eventinfo~EventInfo} eventInfo + * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData + */ + _handleTabOnSelectedTable( eventInfo, domEventData ) { + const tabPressed = domEventData.keyCode == keyCodes.tab; + + // Act only on TAB & SHIFT-TAB - Do not override native CTRL+TAB handler. + if ( !tabPressed || domEventData.ctrlKey ) { + return; + } + + const editor = this.editor; + const selection = editor.model.document.selection; + + if ( !selection.isCollapsed && selection.rangeCount === 1 && selection.getFirstRange().isFlat ) { + const selectedElement = selection.getSelectedElement(); + + if ( !selectedElement || selectedElement.name != 'table' ) { + return; + } + + eventInfo.stop(); + domEventData.preventDefault(); + domEventData.stopPropagation(); + + editor.model.change( writer => { + writer.setSelection( Range.createIn( selectedElement.getChild( 0 ).getChild( 0 ) ) ); + } ); + } + } + + /** + * Handles {@link module:engine/view/document~Document#event:keydown keydown} events for 'Tab' key executed inside table cell. + * + * @private + * @param {module:utils/eventinfo~EventInfo} eventInfo + * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData + */ + _handleTabInsideTable( eventInfo, domEventData ) { + const tabPressed = domEventData.keyCode == keyCodes.tab; + + // Act only on TAB & SHIFT-TAB - Do not override native CTRL+TAB handler. + if ( !tabPressed || domEventData.ctrlKey ) { + return; + } + + const editor = this.editor; + const selection = editor.model.document.selection; + + const table = getParentTable( selection.getFirstPosition() ); + + if ( !table ) { + return; + } + + domEventData.preventDefault(); + domEventData.stopPropagation(); + + const tableCell = selection.focus.parent; + const tableRow = tableCell.parent; + + const currentRowIndex = table.getChildIndex( tableRow ); + const currentCellIndex = tableRow.getChildIndex( tableCell ); + + const isForward = !domEventData.shiftKey; + const isFirstCellInRow = currentCellIndex === 0; + + if ( !isForward && isFirstCellInRow && currentRowIndex === 0 ) { + // It's the first cell of a table - don't do anything (stay in current position). + return; + } + + const isLastCellInRow = currentCellIndex === tableRow.childCount - 1; + const isLastRow = currentRowIndex === table.childCount - 1; + + if ( isForward && isLastRow && isLastCellInRow ) { + editor.plugins.get( TableUtils ).insertRows( table, { at: table.childCount } ); + } + + let cellToFocus; + + // Move to first cell in next row. + if ( isForward && isLastCellInRow ) { + const nextRow = table.getChild( currentRowIndex + 1 ); + + cellToFocus = nextRow.getChild( 0 ); + } + // Move to last cell in a previous row. + else if ( !isForward && isFirstCellInRow ) { + const previousRow = table.getChild( currentRowIndex - 1 ); + + cellToFocus = previousRow.getChild( previousRow.childCount - 1 ); + } + // Move to next/previous cell. + else { + cellToFocus = tableRow.getChild( currentCellIndex + ( isForward ? 1 : -1 ) ); + } + + editor.model.change( writer => { + writer.setSelection( Range.createIn( cellToFocus ) ); + } ); + } +} diff --git a/src/tableui.js b/src/tableui.js new file mode 100644 index 00000000..6ce4f570 --- /dev/null +++ b/src/tableui.js @@ -0,0 +1,89 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module table/tableui + */ + +import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; +import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; + +import icon from '@ckeditor/ckeditor5-core/theme/icons/object-center.svg'; +import insertRowIcon from '@ckeditor/ckeditor5-core/theme/icons/object-left.svg'; +import insertColumnIcon from '@ckeditor/ckeditor5-core/theme/icons/object-right.svg'; + +/** + * The table UI plugin. + * + * @extends module:core/plugin~Plugin + */ +export default class TableUI extends Plugin { + /** + * @inheritDoc + */ + init() { + const editor = this.editor; + + editor.ui.componentFactory.add( 'insertTable', locale => { + const command = editor.commands.get( 'insertTable' ); + const buttonView = new ButtonView( locale ); + + buttonView.bind( 'isEnabled' ).to( command ); + + buttonView.set( { + icon, + label: 'Insert table', + tooltip: true + } ); + + buttonView.on( 'execute', () => { + editor.execute( 'insertTable' ); + editor.editing.view.focus(); + } ); + + return buttonView; + } ); + + editor.ui.componentFactory.add( 'insertRowBelow', locale => { + const command = editor.commands.get( 'insertRowBelow' ); + const buttonView = new ButtonView( locale ); + + buttonView.bind( 'isEnabled' ).to( command ); + + buttonView.set( { + icon: insertRowIcon, + label: 'Insert row', + tooltip: true + } ); + + buttonView.on( 'execute', () => { + editor.execute( 'insertRowBelow' ); + editor.editing.view.focus(); + } ); + + return buttonView; + } ); + + editor.ui.componentFactory.add( 'insertColumnAfter', locale => { + const command = editor.commands.get( 'insertColumnAfter' ); + const buttonView = new ButtonView( locale ); + + buttonView.bind( 'isEnabled' ).to( command ); + + buttonView.set( { + icon: insertColumnIcon, + label: 'Insert column', + tooltip: true + } ); + + buttonView.on( 'execute', () => { + editor.execute( 'insertColumnAfter' ); + editor.editing.view.focus(); + } ); + + return buttonView; + } ); + } +} diff --git a/src/tableutils.js b/src/tableutils.js new file mode 100644 index 00000000..2b3737f9 --- /dev/null +++ b/src/tableutils.js @@ -0,0 +1,593 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module table/tableutils + */ + +import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; +import Position from '@ckeditor/ckeditor5-engine/src/model/position'; + +import TableWalker from './tablewalker'; +import { getParentTable, updateNumericAttribute } from './commands/utils'; + +/** + * The table utils plugin. + * + * @extends module:core/plugin~Plugin + */ +export default class TableUtils extends Plugin { + /** + * @inheritDoc + */ + static get pluginName() { + return 'TableUtils'; + } + + /** + * Returns table cell location as an object with table row and table column indexes. + * + * For instance in a table below: + * + * 0 1 2 3 + * +---+---+---+---+ + * 0 | a | b | c | + * + + +---+ + * 1 | | | d | + * +---+---+ +---+ + * 2 | e | | f | + * +---+---+---+---+ + * + * the method will return: + * + * const cellA = table.getNodeByPath( [ 0, 0 ] ); + * editor.plugins.get( 'TableUtils' ).getCellLocation( cellA ); + * // will return { row: 0, column: 0 } + * + * const cellD = table.getNodeByPath( [ 1, 0 ] ); + * editor.plugins.get( 'TableUtils' ).getCellLocation( cellD ); + * // will return { row: 1, column: 3 } + * + * @param {module:engine/model/element~Element} tableCell + * @returns {Object} Returns a `{row, column}` object. + */ + getCellLocation( tableCell ) { + const tableRow = tableCell.parent; + const table = tableRow.parent; + + const rowIndex = table.getChildIndex( tableRow ); + + const tableWalker = new TableWalker( table, { startRow: rowIndex, endRow: rowIndex } ); + + for ( const { cell, row, column } of tableWalker ) { + if ( cell === tableCell ) { + return { row, column }; + } + } + } + + /** + * Creates an empty table at given position. + * + * @param {module:engine/model/position~Position} position Position at which insert a table. + * @param {Number} rows Number of rows to create. + * @param {Number} columns Number of columns to create. + */ + createTable( position, rows, columns ) { + const model = this.editor.model; + + model.change( writer => { + const table = writer.createElement( 'table' ); + + writer.insert( table, position ); + + createEmptyRows( writer, table, 0, rows, columns ); + } ); + } + + /** + * Insert rows into a table. + * + * editor.plugins.get( 'TableUtils' ).insertRows( table, { at: 1, rows: 2 } ); + * + * Assuming the table on the left, the above code will transform it to the table on the right: + * + * row index + * 0 +---+---+---+ `at` = 1, +---+---+---+ 0 + * | a | b | c | `rows` = 2, | a | b | c | + * 1 + +---+---+ <-- insert here + +---+---+ 1 + * | | d | e | | | | | + * 2 + +---+---+ will give: + +---+---+ 2 + * | | f | g | | | | | + * 3 +---+---+---+ + +---+---+ 3 + * | | d | e | + * +---+---+---+ 4 + * + + f | g | + * +---+---+---+ 5 + * + * @param {module:engine/model/element~Element} table Table model element to which insert rows. + * @param {Object} options + * @param {Number} [options.at=0] Row index at which insert rows. + * @param {Number} [options.rows=1] Number of rows to insert. + */ + insertRows( table, options = {} ) { + const model = this.editor.model; + + const insertAt = options.at || 0; + const rowsToInsert = options.rows || 1; + + model.change( writer => { + const headingRows = table.getAttribute( 'headingRows' ) || 0; + + // Inserting rows inside heading section requires to update `headingRows` attribute as the heading section will grow. + if ( headingRows > insertAt ) { + writer.setAttribute( 'headingRows', headingRows + rowsToInsert, table ); + } + + // Inserting at the end and at the beginning of a table doesn't require to calculate anything special. + if ( insertAt === 0 || insertAt === table.childCount ) { + createEmptyRows( writer, table, insertAt, rowsToInsert, this.getColumns( table ) ); + + return; + } + + // Iterate over all rows above inserted rows in order to check for rowspanned cells. + const tableIterator = new TableWalker( table, { endRow: insertAt } ); + + // Will hold number of cells needed to insert in created rows. + // The number might be different then table cell width when there are rowspanned cells. + let cellsToInsert = 0; + + for ( const { row, rowspan, colspan, cell } of tableIterator ) { + const isBeforeInsertedRow = row < insertAt; + const overlapsInsertedRow = row + rowspan > insertAt; + + if ( isBeforeInsertedRow && overlapsInsertedRow ) { + // This cell overlaps inserted rows so we need to expand it further. + writer.setAttribute( 'rowspan', rowspan + rowsToInsert, cell ); + } + + // Calculate how many cells to insert based on the width of cells in a row at insert position. + // It might be lower then table width as some cells might overlaps inserted row. + // In the table above the cell 'a' overlaps inserted row so only two empty cells are need to be created. + if ( row === insertAt ) { + cellsToInsert += colspan; + } + } + + createEmptyRows( writer, table, insertAt, rowsToInsert, cellsToInsert ); + } ); + } + + /** + * Inserts columns into a table. + * + * editor.plugins.get( 'TableUtils' ).insertColumns( table, { at: 1, columns: 2 } ); + * + * Assuming the table on the left, the above code will transform it to the table on the right: + * + * 0 1 2 3 0 1 2 3 4 5 + * +---+---+---+ +---+---+---+---+---+ + * | a | b | | a | b | + * + +---+ + +---+ + * | | c | | | c | + * +---+---+---+ will give: +---+---+---+---+---+ + * | d | e | f | | d | | | e | f | + * +---+ +---+ +---+---+---+ +---+ + * | g | | h | | g | | | | h | + * +---+---+---+ +---+---+---+---+---+ + * | i | | i | + * +---+---+---+ +---+---+---+---+---+ + * ^---- insert here, `at` = 1, `columns` = 2 + * + * @param {module:engine/model/element~Element} table Table model element to which insert columns. + * @param {Object} options + * @param {Number} [options.at=0] Column index at which insert columns. + * @param {Number} [options.columns=1] Number of columns to insert. + */ + insertColumns( table, options = {} ) { + const model = this.editor.model; + + const insertAt = options.at || 0; + const columnsToInsert = options.columns || 1; + + model.change( writer => { + const headingColumns = table.getAttribute( 'headingColumns' ); + + // Inserting columns inside heading section requires to update `headingColumns` attribute as the heading section will grow. + if ( insertAt < headingColumns ) { + writer.setAttribute( 'headingColumns', headingColumns + columnsToInsert, table ); + } + + const tableColumns = this.getColumns( table ); + + // Inserting at the end and at the beginning of a table doesn't require to calculate anything special. + if ( insertAt === 0 || tableColumns === insertAt ) { + for ( const tableRow of table.getChildren() ) { + createCells( columnsToInsert, writer, Position.createAt( tableRow, insertAt ? 'end' : 0 ) ); + } + + return; + } + + const tableWalker = new TableWalker( table, { column: insertAt, includeSpanned: true } ); + + for ( const { row, column, cell, colspan, rowspan, cellIndex } of tableWalker ) { + // When iterating over column the table walker outputs either: + // - cells at given column index (cell "e" from method docs), + // - spanned columns (spanned cell from row between cells "g" and "h" - spanned by "e", only if `includeSpanned: true`), + // - or a cell from the same row which spans over this column (cell "a"). + + if ( column !== insertAt ) { + // If column is different than `insertAt`, it is a cell that spans over an inserted column (cell "a" & "i"). + // For such cells expand them by a number of columns inserted. + writer.setAttribute( 'colspan', colspan + columnsToInsert, cell ); + + // The `includeSpanned` option will output the "empty"/spanned column so skip this row already. + tableWalker.skipRow( row ); + + // This cell will overlap cells in rows below so skip them also (because of `includeSpanned` option) - (cell "a") + if ( rowspan > 1 ) { + for ( let i = row + 1; i < row + rowspan; i++ ) { + tableWalker.skipRow( i ); + } + } + } else { + // It's either cell at this column index or spanned cell by a rowspanned cell from row above. + // In table above it's cell "e" and a spanned position from row below (empty cell between cells "g" and "h") + const insertPosition = Position.createFromParentAndOffset( table.getChild( row ), cellIndex ); + + createCells( columnsToInsert, writer, insertPosition ); + } + } + } ); + } + + /** + * Divides table cell vertically into several ones. + * + * The cell will visually split to more cells by updating colspans of other cells in a column + * and inserting cells (columns) after that cell. + * + * In the table below, if cell "a" is split to 3 cells: + * + * +---+---+---+ + * | a | b | c | + * +---+---+---+ + * | d | e | f | + * +---+---+---+ + * + * it will result in the table below: + * + * +---+---+---+---+---+ + * | a | | | b | c | + * +---+---+---+---+---+ + * | d | e | f | + * +---+---+---+---+---+ + * + * So cell d will get updated `colspan` to 3 and 2 cells will be added (2 columns created). + * + * Splitting cell that already has a colspan attribute set will distribute cell's colspan evenly and a reminder + * will be left to original cell: + * + * +---+---+---+ + * | a | + * +---+---+---+ + * | b | c | d | + * +---+---+---+ + * + * Splitting cell a with colspan=3 to a 2 cells will create 1 cell with colspan=2 and cell a will have colspan=1: + * + * +---+---+---+ + * | a | | + * +---+---+---+ + * | b | c | d | + * +---+---+---+ + * + * @param {module:engine/model/element~Element} tableCell + * @param {Number} numberOfCells + */ + splitCellVertically( tableCell, numberOfCells = 2 ) { + const model = this.editor.model; + const table = getParentTable( tableCell ); + + const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 ); + const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 ); + + model.change( writer => { + // First check - the cell spans over multiple rows so before doing anything else just split this cell. + if ( colspan > 1 ) { + // Get spans of new (inserted) cells and span to update of split cell. + const { newCellsSpan, updatedSpan } = breakSpanEvenly( colspan, numberOfCells ); + + updateNumericAttribute( 'colspan', updatedSpan, tableCell, writer ); + + // Each inserted cell will have the same attributes: + const newCellsAttributes = {}; + + // Do not store default value in the model. + if ( newCellsSpan > 1 ) { + newCellsAttributes.colspan = newCellsSpan; + } + + // Copy rowspan of split cell. + if ( rowspan > 1 ) { + newCellsAttributes.rowspan = rowspan; + } + + const cellsToInsert = colspan > numberOfCells ? numberOfCells - 1 : colspan - 1; + createCells( cellsToInsert, writer, Position.createAfter( tableCell ), newCellsAttributes ); + } + + // Second check - the cell has colspan of 1 or we need to create more cells then the currently one spans over. + if ( colspan < numberOfCells ) { + const cellsToInsert = numberOfCells - colspan; + + // First step: expand cells on the same column as split cell. + const tableMap = [ ...new TableWalker( table ) ]; + + // Get the column index of split cell. + const { column: splitCellColumn } = tableMap.find( ( { cell } ) => cell === tableCell ); + + // Find cells which needs to be expanded vertically - those on the same column or those that spans over split cell's column. + const cellsToUpdate = tableMap.filter( ( { cell, colspan, column } ) => { + const isOnSameColumn = cell !== tableCell && column === splitCellColumn; + const spansOverColumn = ( column < splitCellColumn && column + colspan > splitCellColumn ); + + return isOnSameColumn || spansOverColumn; + } ); + + // Expand cells vertically. + for ( const { cell, colspan } of cellsToUpdate ) { + writer.setAttribute( 'colspan', colspan + cellsToInsert, cell ); + } + + // Second step: create columns after split cell. + + // Each inserted cell will have the same attributes: + const newCellsAttributes = {}; + + // Do not store default value in the model. + + // Copy rowspan of split cell. + if ( rowspan > 1 ) { + newCellsAttributes.rowspan = rowspan; + } + + createCells( cellsToInsert, writer, Position.createAfter( tableCell ), newCellsAttributes ); + + const headingColumns = table.getAttribute( 'headingColumns' ) || 0; + + // Update heading section if split cell is in heading section. + if ( headingColumns > splitCellColumn ) { + updateNumericAttribute( 'headingColumns', headingColumns + cellsToInsert, table, writer ); + } + } + } ); + } + + /** + * Divides table cell horizontally into several ones. + * + * The cell will visually split to more cells by updating rowspans of other cells in a row and inserting rows with single cell below. + * + * If in a table below cell b will be split to a 3 cells: + * + * +---+---+---+ + * | a | b | c | + * +---+---+---+ + * | d | e | f | + * +---+---+---+ + * + * will result in a table below: + * + * +---+---+---+ + * | a | b | c | + * + +---+ + + * | | | | + * + +---+ + + * | | | | + * +---+---+---+ + * | d | e | f | + * +---+---+---+ + * + * So cells a & b will get updated `rowspan` to 3 and 2 rows with single cell will be added. + * + * Splitting cell that has already a rowspan attribute set will distribute cell's rowspan evenly and a reminder + * will be left to original cell: + * + * +---+---+---+ + * | a | b | c | + * + +---+---+ + * | | d | e | + * + +---+---+ + * | | f | g | + * + +---+---+ + * | | h | i | + * +---+---+---+ + * + * Splitting cell a with rowspan=4 to a 3 cells will create 2 cells with rowspan=1 and cell a will have rowspan=2: + * + * +---+---+---+ + * | a | b | c | + * + +---+---+ + * | | d | e | + * +---+---+---+ + * | | f | g | + * +---+---+---+ + * | | h | i | + * +---+---+---+ + * + * @param {module:engine/model/element~Element} tableCell + * @param {Number} numberOfCells + */ + splitCellHorizontally( tableCell, numberOfCells = 2 ) { + const model = this.editor.model; + + const table = getParentTable( tableCell ); + const splitCellRow = table.getChildIndex( tableCell.parent ); + + const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 ); + const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 ); + + model.change( writer => { + // First check - the cell spans over multiple rows so before doing anything else just split this cell. + if ( rowspan > 1 ) { + // Cache table map before updating table. + const tableMap = [ ...new TableWalker( table, { + startRow: splitCellRow, + endRow: splitCellRow + rowspan - 1, + includeSpanned: true + } ) ]; + + // Get spans of new (inserted) cells and span to update of split cell. + const { newCellsSpan, updatedSpan } = breakSpanEvenly( rowspan, numberOfCells ); + + updateNumericAttribute( 'rowspan', updatedSpan, tableCell, writer ); + + const { column: cellColumn } = tableMap.find( ( { cell } ) => cell === tableCell ); + + // Each inserted cell will have the same attributes: + const newCellsAttributes = {}; + + // Do not store default value in the model. + if ( newCellsSpan > 1 ) { + newCellsAttributes.rowspan = newCellsSpan; + } + + // Copy colspan of split cell. + if ( colspan > 1 ) { + newCellsAttributes.colspan = colspan; + } + + for ( const { column, row, cellIndex } of tableMap ) { + // As both newly created cells and the split cell might have rowspan, + // the insertion of new cells must go to appropriate rows: + // + // 1. It's a row after split cell + it's height. + const isAfterSplitCell = row >= splitCellRow + updatedSpan; + // 2. Is on the same column. + const isOnSameColumn = column === cellColumn; + // 3. And it's row index is after previous cell height. + const isInEvenlySplitRow = ( row + splitCellRow + updatedSpan ) % newCellsSpan === 0; + + if ( isAfterSplitCell && isOnSameColumn && isInEvenlySplitRow ) { + const position = Position.createFromParentAndOffset( table.getChild( row ), cellIndex ); + + writer.insertElement( 'tableCell', newCellsAttributes, position ); + } + } + } + + // Second check - the cell has rowspan of 1 or we need to create more cells than the current cell spans over. + if ( rowspan < numberOfCells ) { + // We already split the cell in check one so here we split to the remaining number of cells only. + const cellsToInsert = numberOfCells - rowspan; + + // This check is needed since we need to check if there are any cells from previous rows than spans over this cell's row. + const tableMap = [ ...new TableWalker( table, { startRow: 0, endRow: splitCellRow } ) ]; + + // First step: expand cells. + for ( const { cell, rowspan, row } of tableMap ) { + // Expand rowspan of cells that are either: + // - on the same row as current cell, + // - or are below split cell row and overlaps that row. + if ( cell !== tableCell && row + rowspan > splitCellRow ) { + const rowspanToSet = rowspan + cellsToInsert; + + writer.setAttribute( 'rowspan', rowspanToSet, cell ); + } + } + + // Second step: create rows with single cell below split cell. + const newCellsAttributes = {}; + + // Copy colspan of split cell. + if ( colspan > 1 ) { + newCellsAttributes.colspan = colspan; + } + + createEmptyRows( writer, table, splitCellRow + 1, cellsToInsert, 1, newCellsAttributes ); + + // Update heading section if split cell is in heading section. + const headingRows = table.getAttribute( 'headingRows' ) || 0; + + if ( headingRows > splitCellRow ) { + updateNumericAttribute( 'headingRows', headingRows + cellsToInsert, table, writer ); + } + } + } ); + } + + /** + * Returns number of columns for given table. + * + * editor.plugins.get( 'TableUtils' ).getColumns( table ); + * + * @param {module:engine/model/element~Element} table Table to analyze. + * @returns {Number} + */ + getColumns( table ) { + // Analyze first row only as all the rows should have the same width. + const row = table.getChild( 0 ); + + return [ ...row.getChildren() ].reduce( ( columns, row ) => { + const columnWidth = parseInt( row.getAttribute( 'colspan' ) || 1 ); + + return columns + columnWidth; + }, 0 ); + } +} + +// Creates empty rows at given index in an existing table. +// +// @param {module:engine/model/writer~Writer} writer +// @param {module:engine/model/element~Element} table +// @param {Number} insertAt Row index of row insertion. +// @param {Number} rows Number of rows to create. +// @param {Number} tableCellToInsert Number of cells to insert in each row. +function createEmptyRows( writer, table, insertAt, rows, tableCellToInsert, attributes = {} ) { + for ( let i = 0; i < rows; i++ ) { + const tableRow = writer.createElement( 'tableRow' ); + + writer.insert( tableRow, table, insertAt ); + + createCells( tableCellToInsert, writer, Position.createAt( tableRow, 'end' ), attributes ); + } +} + +// Creates cells at given position. +// +// @param {Number} columns Number of columns to create +// @param {module:engine/model/writer~Writer} writer +// @param {module:engine/model/position~Position} insertPosition +function createCells( cells, writer, insertPosition, attributes = {} ) { + for ( let i = 0; i < cells; i++ ) { + writer.insertElement( 'tableCell', attributes, insertPosition ); + } +} + +// Evenly distributes span of a cell to a number of provided cells. +// The resulting spans will always be integer values. +// +// For instance breaking a span of 7 into 3 cells will return: +// +// { newCellsSpan: 2, updatedSpan: 3 } +// +// as two cells will have span of 2 and the reminder will go the first cell so it's span will change to 3. +// +// @param {Number} span Span value do break. +// @param {Number} numberOfCells Number of resulting spans. +// @returns {{newCellsSpan: Number, updatedSpan: Number}} +function breakSpanEvenly( span, numberOfCells ) { + if ( span < numberOfCells ) { + return { newCellsSpan: 1, updatedSpan: 1 }; + } + + const newCellsSpan = Math.floor( span / numberOfCells ); + const updatedSpan = ( span - newCellsSpan * numberOfCells ) + newCellsSpan; + + return { newCellsSpan, updatedSpan }; +} diff --git a/src/tablewalker.js b/src/tablewalker.js new file mode 100644 index 00000000..e0c35a70 --- /dev/null +++ b/src/tablewalker.js @@ -0,0 +1,397 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module table/tablewalker + */ + +/** + * Table iterator class. It allows to iterate over a table cells. For each cell the iterator yields + * {@link module:table/tablewalker~TableWalkerValue} with proper table cell attributes. + */ +export default class TableWalker { + /** + * Creates an instance of table walker. + * + * + * The TableWalker iterates internally by traversing table from row index = 0 and column index = 0. + * It walks row by row and column by column in order to output values defined in constructor. + * By default it will output only those locations that are occupied by a cell to include also a spanned rows & columns + * pass `includeSpanned` option to a constructor. + * + * The most important values of iterator values are column & row indexes of a cell. + * + * To iterate over given row: + * + * const tableWalker = new TableWalker( table, { startRow: 1, endRow: 2 } ); + * + * for ( const cellInfo of tableWalker ) { + * console.log( 'A cell at row ' + cellInfo.row + ' and column ' + cellInfo.column ); + * } + * + * For instance the above code for a table: + * + * +----+----+----+----+----+----+ + * | 00 | 02 | 03 | 05 | + * | +----+----+----+----+ + * | | 12 | 14 | 15 | + * | +----+----+----+----+ + * | | 22 | + * |----+----+ + + * | 31 | 32 | | + * +----+----+----+----+----+----+ + * + * will log in the console: + * + * 'A cell at row 1 and column 2' + * 'A cell at row 1 and column 4' + * 'A cell at row 1 and column 5' + * 'A cell at row 2 and column 2' + * + * To iterate over spanned cells also: + * + * const tableWalker = new TableWalker( table, { startRow: 1, endRow: 1, includeSpanned: true } ); + * + * for ( const cellInfo of tableWalker ) { + * console.log( 'Cell at ' + cellInfo.row + ' x ' + cellInfo.column + ' : ' + ( cellInfo.cell ? 'has data' : 'is spanned' ) ); + * } + * + * will log in the console for the table from previous example: + * + * 'Cell at 1 x 0 : is spanned' + * 'Cell at 1 x 1 : is spanned' + * 'Cell at 1 x 2 : has data' + * 'Cell at 1 x 3 : is spanned' + * 'Cell at 1 x 4 : has data' + * 'Cell at 1 x 5 : has data' + * + * @constructor + * @param {module:engine/model/element~Element} table A table over which iterate. + * @param {Object} [options={}] Object with configuration. + * @param {Number} [options.column] A column index for which this iterator will output cells. + * @param {Number} [options.startRow=0] A row index for which this iterator should start. + * @param {Number} [options.endRow] A row index for which this iterator should end. + * @param {Boolean} [options.includeSpanned] Also return values for spanned cells. + */ + constructor( table, options = {} ) { + /** + * The walker's table element. + * + * @readonly + * @member {module:engine/model/element~Element} + */ + this.table = table; + + /** + * A row index on which this iterator will start. + * + * @readonly + * @member {Number} + */ + this.startRow = options.startRow || 0; + + /** + * A row index on which this iterator will end. + * + * @readonly + * @member {Number} + */ + this.endRow = typeof options.endRow == 'number' ? options.endRow : undefined; + + /** + * Enables output of spanned cells that are normally not yielded. + * + * @readonly + * @member {Boolean} + */ + this.includeSpanned = !!options.includeSpanned; + + /** + * If set table walker will only output cells of given column or cells that overlaps it. + * + * @readonly + * @member {Number} + */ + this.column = typeof options.column == 'number' ? options.column : undefined; + + /** + * Row indexes to skip from iteration. + * + * @readonly + * @member {Set} + * @private + */ + this._skipRows = new Set(); + + /** + * A current row index. + * + * @readonly + * @member {Number} + * @private + */ + this._row = 0; + + /** + * A current column index. + * + * @readonly + * @member {Number} + * @private + */ + this._column = 0; + + /** + * A cell index in a parent row. For spanned cells when {@link #includeSpanned} is set to true + * this represents the index of next table cell. + * + * @readonly + * @member {Number} + * @private + */ + this._cell = 0; + + /** + * Holds map of spanned cells in a table. + * + * @readonly + * @member {Map>} + * @private + */ + this._spannedCells = new Map(); + } + + /** + * Iterable interface. + * + * @returns {Iterable.} + */ + [ Symbol.iterator ]() { + return this; + } + + /** + * Gets the next table walker's value. + * + * @returns {module:table/tablewalker~TableWalkerValue} Next table walker's value. + */ + next() { + const row = this.table.getChild( this._row ); + + // Iterator is done when no row (table end) or the row is after #endRow. + if ( !row || this._isOverEndRow() ) { + return { done: true }; + } + + // Spanned cell location handling. + if ( this._isSpanned( this._row, this._column ) ) { + // Current column must be kept as it will be updated before returning current value. + const currentColumn = this._column; + const outValue = this._formatOutValue( undefined, currentColumn ); + + // Advance to next column - always. + this._column++; + + const skipCurrentValue = !this.includeSpanned || this._shouldSkipRow() || this._shouldSkipColumn( currentColumn, 1 ); + + // The current value will be returned only if #includedSpanned=true and also current row and column are not skipped. + return skipCurrentValue ? this.next() : outValue; + } + + // The cell location is not spanned by other cells. + const cell = row.getChild( this._cell ); + + if ( !cell ) { + // If there are no more cells left in row advance to next row. + this._row++; + // And reset column & cell indexes. + this._column = 0; + this._cell = 0; + + // Return next value. + return this.next(); + } + + // Read table cell attributes. + const colspan = parseInt( cell.getAttribute( 'colspan' ) || 1 ); + const rowspan = parseInt( cell.getAttribute( 'rowspan' ) || 1 ); + + // Record this cell spans if it's not 1x1 cell. + if ( colspan > 1 || rowspan > 1 ) { + this._recordSpans( this._row, this._column, rowspan, colspan ); + } + + // Current column must be kept as it will be updated before returning current value. + const currentColumn = this._column; + const outValue = this._formatOutValue( cell, currentColumn, rowspan, colspan ); + + // Advance to next column before returning value. + this._column++; + + // Advance to next cell in a parent row before returning value. + this._cell++; + + const skipCurrentValue = this._shouldSkipRow() || this._shouldSkipColumn( currentColumn, colspan ); + + // The current value will be returned only if current row & column are not skipped. + return skipCurrentValue ? this.next() : outValue; + } + + /** + * Mark a row to skip on next iteration - will skip also cells from current row if any. + * + * @param {Number} row Row index to skip. + */ + skipRow( row ) { + this._skipRows.add( row ); + } + + /** + * Check if current row is over {@link #endRow}. + * + * @returns {Boolean} + * @private + */ + _isOverEndRow() { + // If {@link #endRow) is defined skipp all rows above it. + return this.endRow !== undefined && this._row > this.endRow; + } + + /** + * Common method for formatting iterator's out value. + * + * @param {module:engine/model/element~Element|undefined} cell Table cell to output. Might be undefined for spanned cell locations. + * @param {Number} column Column index (use cached value) + * @param {Number} rowspan Rowspan of current cell. + * @param {Number} colspan Colspan of current cell. + * @returns {{done: boolean, value: {cell: *, row: Number, column: *, rowspan: *, colspan: *, cellIndex: Number}}} + * @private + */ + _formatOutValue( cell, column, rowspan = 1, colspan = 1 ) { + return { + done: false, + value: { + cell, + row: this._row, + column, + rowspan, + colspan, + cellIndex: this._cell + } + }; + } + + /** + * Checks if current row should be skipped. + * + * @returns {Boolean} + * @private + */ + _shouldSkipRow() { + const rowIsBelowStartRow = this._row < this.startRow; + const rowIsMarkedAsSkipped = this._skipRows.has( this._row ); + + return rowIsBelowStartRow || rowIsMarkedAsSkipped; + } + + /** + * Checks if current column should be skipped. + * + * @param {Number} column + * @param {Number} colspan + * @returns {Boolean} + * @private + */ + _shouldSkipColumn( column, colspan ) { + if ( this.column === undefined ) { + // The {@link #column} is not defined so output all columns. + return false; + } + + // When outputting cells from given column we skip: + // - Cells that are not on that column. + const isCurrentColumn = column === this.column; + // - CSells that are before given column and they overlaps given column. + const isPreviousThatOverlapsColumn = column < this.column && column + colspan > this.column; + + return !isCurrentColumn && !isPreviousThatOverlapsColumn; + } + + /** + * Checks if current cell location - row x column - is spanned by other cell. + * + * @param {Number} row Row index of a cell location to check. + * @param {Number} column Column index of a cell location to check. + * @returns {Boolean} + * @private + */ + _isSpanned( row, column ) { + if ( !this._spannedCells.has( row ) ) { + // No spans for given row. + return false; + } + + const rowSpans = this._spannedCells.get( row ); + + // If spans for given rows has entry for column it means that this location if spanned by other cell. + return rowSpans.has( column ); + } + + /** + * Updates spanned cells map relative to current cell location and it's span dimensions. + * + * @param {Number} row Row index of a cell. + * @param {Number} column Column index of a cell. + * @param {Number} rowspan Cell's height. + * @param {Number} colspan Cell's width. + * @private + */ + _recordSpans( row, column, rowspan, colspan ) { + // This will update all cell locations after current column - ie a cell has colspan set. + for ( let columnToUpdate = column + 1; columnToUpdate <= column + colspan - 1; columnToUpdate++ ) { + this._markSpannedCell( row, columnToUpdate ); + } + + // This will update all rows below current up to row's height. + for ( let rowToUpdate = row + 1; rowToUpdate < row + rowspan; rowToUpdate++ ) { + for ( let columnToUpdate = column; columnToUpdate <= column + colspan - 1; columnToUpdate++ ) { + this._markSpannedCell( rowToUpdate, columnToUpdate ); + } + } + } + + /** + * Marks cell location as spanned by other cell. + * + * @param {Number} row Row index of cell location. + * @param {Number} column Column index of cell location. + * @private + */ + _markSpannedCell( row, column ) { + if ( !this._spannedCells.has( row ) ) { + this._spannedCells.set( row, new Map() ); + } + + const rowSpans = this._spannedCells.get( row ); + + rowSpans.set( column, true ); + } +} + +/** + * Object returned by {@link module:table/tablewalker~TableWalker} when traversing table cells. + * + * @typedef {Object} module:table/tablewalker~TableWalkerValue + * @property {module:engine/model/element~Element} [cell] Current table cell. Might be empty if + * {@link module:table/tablewalker~TableWalker#includeSpanned} is set to true. + * @property {Number} row The row index of a cell. + * @property {Number} column The column index of a cell. Column index is adjusted to widths & heights of previous cells. + * @property {Number} [colspan] The colspan attribute of a cell - always defined even if model attribute is not present. Not set if + * {@link module:table/tablewalker~TableWalker#includeSpanned} is set to true. + * @property {Number} [rowspan] The rowspan attribute of a cell - always defined even if model attribute is not present. Not set if + * {@link module:table/tablewalker~TableWalker#includeSpanned} is set to true. + * @property {Number} cellIndex The index of a current cell in a parent row. When using `includeSpanned` option it will indicate next child + * index if #cell is empty (which indicates that cell is spanned by other cell). + */ diff --git a/tests/_utils/utils.js b/tests/_utils/utils.js new file mode 100644 index 00000000..5d57deab --- /dev/null +++ b/tests/_utils/utils.js @@ -0,0 +1,121 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +function formatAttributes( attributes ) { + let attributesString = ''; + + if ( attributes ) { + const entries = Object.entries( attributes ); + + if ( entries.length ) { + attributesString = ' ' + entries.map( entry => `${ entry[ 0 ] }="${ entry[ 1 ] }"` ).join( ' ' ); + } + } + return attributesString; +} + +function makeRows( tableData, cellElement, rowElement, headingElement = 'th' ) { + const tableRows = tableData + .reduce( ( previousRowsString, tableRow ) => { + const tableRowString = tableRow.reduce( ( tableRowString, tableCellData ) => { + let tableCell = tableCellData; + + const isObject = typeof tableCellData === 'object'; + + let resultingCellElement = cellElement; + + if ( isObject ) { + tableCell = tableCellData.contents; + + if ( tableCellData.isHeading ) { + resultingCellElement = headingElement; + } + + delete tableCellData.contents; + delete tableCellData.isHeading; + } + + const formattedAttributes = formatAttributes( isObject ? tableCellData : '' ); + tableRowString += `<${ resultingCellElement }${ formattedAttributes }>${ tableCell }`; + + return tableRowString; + }, '' ); + + return `${ previousRowsString }<${ rowElement }>${ tableRowString }`; + }, '' ); + return tableRows; +} + +/** + * @param {Number} columns + * @param {Array.} tableData + * @param {Object} [attributes] + * + * @returns {String} + */ +export function modelTable( tableData, attributes ) { + const tableRows = makeRows( tableData, 'tableCell', 'tableRow', 'tableCell' ); + + return `${ tableRows }`; +} + +/** + * @param {Number} columns + * @param {Array.} tableData + * @param {Object} [attributes] + * + * @returns {String} + */ +export function viewTable( tableData, attributes = {} ) { + const headingRows = attributes.headingRows || 0; + + const thead = headingRows > 0 ? `${ makeRows( tableData.slice( 0, headingRows ), 'th', 'tr' ) }` : ''; + const tbody = tableData.length > headingRows ? `${ makeRows( tableData.slice( headingRows ), 'td', 'tr' ) }` : ''; + + return `${ thead }${ tbody }
`; +} + +/** + * Formats model or view table - useful for chai assertions debuging. + * + * @param {String} tableString + * @returns {String} + */ +export function formatTable( tableString ) { + return tableString + .replace( //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' ); +} + +/** + * Returns formatted model table string. + * + * @param {Array.} tableData + * @param {Object} [attributes] + * @returns {String} + */ +export function formattedModelTable( tableData, attributes ) { + const tableString = modelTable( tableData, attributes ); + + return formatTable( tableString ); +} + +/** + * Returns formatted view table string. + * + * @param {Array.} tableData + * @param {Object} [attributes] + * @returns {String} + */ +export function formattedViewTable( tableData, attributes ) { + return formatTable( viewTable( tableData, attributes ) ); +} diff --git a/tests/commands/insertcolumncommand.js b/tests/commands/insertcolumncommand.js new file mode 100644 index 00000000..bb052b17 --- /dev/null +++ b/tests/commands/insertcolumncommand.js @@ -0,0 +1,305 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor'; +import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters'; + +import InsertColumnCommand from '../../src/commands/insertcolumncommand'; +import { + downcastInsertCell, + downcastInsertRow, + downcastInsertTable, + downcastRemoveRow, + downcastTableHeadingColumnsChange, + downcastTableHeadingRowsChange +} from '../../src/converters/downcast'; +import upcastTable from '../../src/converters/upcasttable'; +import { formatTable, formattedModelTable, modelTable } from '../_utils/utils'; +import TableUtils from '../../src/tableutils'; + +describe( 'InsertColumnCommand', () => { + let editor, model, command; + + beforeEach( () => { + return ModelTestEditor.create( { + plugins: [ TableUtils ] + } ).then( newEditor => { + editor = newEditor; + model = editor.model; + + const conversion = editor.conversion; + const schema = model.schema; + + schema.register( 'table', { + allowWhere: '$block', + allowAttributes: [ 'headingRows' ], + isObject: true + } ); + + schema.register( 'tableRow', { allowIn: 'table' } ); + + schema.register( 'tableCell', { + allowIn: 'tableRow', + allowContentOf: '$block', + allowAttributes: [ 'colspan', 'rowspan' ], + isLimit: true + } ); + + model.schema.register( 'p', { inheritAllFrom: '$block' } ); + + // Table conversion. + conversion.for( 'upcast' ).add( upcastTable() ); + conversion.for( 'downcast' ).add( downcastInsertTable() ); + + // Insert row conversion. + conversion.for( 'downcast' ).add( downcastInsertRow() ); + + // Remove row conversion. + conversion.for( 'downcast' ).add( downcastRemoveRow() ); + + // Table cell conversion. + conversion.for( 'downcast' ).add( downcastInsertCell() ); + + conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'td' } ) ); + conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'th' } ) ); + + // Table attributes conversion. + conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } ); + conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } ); + + conversion.for( 'downcast' ).add( downcastTableHeadingColumnsChange() ); + conversion.for( 'downcast' ).add( downcastTableHeadingRowsChange() ); + } ); + } ); + + afterEach( () => { + return editor.destroy(); + } ); + + describe( 'order=after', () => { + beforeEach( () => { + command = new InsertColumnCommand( editor ); + } ); + + describe( 'isEnabled', () => { + it( 'should be false if wrong node', () => { + setData( model, '

foo[]

' ); + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be true if in table', () => { + setData( model, modelTable( [ [ '[]' ] ] ) ); + expect( command.isEnabled ).to.be.true; + } ); + } ); + + describe( 'execute()', () => { + it( 'should insert column in given table at given index', () => { + setData( model, modelTable( [ + [ '11[]', '12' ], + [ '21', '22' ] + ] ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '11[]', '', '12' ], + [ '21', '', '22' ] + ] ) ); + } ); + + it( 'should insert columns at table end', () => { + setData( model, modelTable( [ + [ '11', '12' ], + [ '21', '22[]' ] + ] ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '11', '12', '' ], + [ '21', '22[]', '' ] + ] ) ); + } ); + + it( 'should update table heading columns attribute when inserting column in headings section', () => { + setData( model, modelTable( [ + [ '11[]', '12' ], + [ '21', '22' ], + [ '31', '32' ] + ], { headingColumns: 2 } ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '11[]', '', '12' ], + [ '21', '', '22' ], + [ '31', '', '32' ] + ], { headingColumns: 3 } ) ); + } ); + + it( 'should not update table heading columns attribute when inserting column after headings section', () => { + setData( model, modelTable( [ + [ '11', '12[]', '13' ], + [ '21', '22', '23' ], + [ '31', '32', '33' ] + ], { headingColumns: 2 } ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '11', '12[]', '', '13' ], + [ '21', '22', '', '23' ], + [ '31', '32', '', '33' ] + ], { headingColumns: 2 } ) ); + } ); + + it( 'should skip spanned columns', () => { + setData( model, modelTable( [ + [ '11[]', '12' ], + [ { colspan: 2, contents: '21' } ], + [ '31', '32' ] + ], { headingColumns: 2 } ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '11[]', '', '12' ], + [ { colspan: 3, contents: '21' } ], + [ '31', '', '32' ] + ], { headingColumns: 3 } ) ); + } ); + + it( 'should skip wide spanned columns', () => { + setData( model, modelTable( [ + [ '11', '12[]', '13', '14', '15' ], + [ '21', '22', { colspan: 2, contents: '23' }, '25' ], + [ { colspan: 4, contents: '31' }, { colspan: 2, contents: '34' } ] + ], { headingColumns: 4 } ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '11', '12[]', '', '13', '14', '15' ], + [ '21', '22', '', { colspan: 2, contents: '23' }, '25' ], + [ { colspan: 5, contents: '31' }, { colspan: 2, contents: '34' } ] + ], { headingColumns: 5 } ) ); + } ); + } ); + } ); + + describe( 'order=before', () => { + beforeEach( () => { + command = new InsertColumnCommand( editor, { order: 'before' } ); + } ); + + describe( 'isEnabled', () => { + it( 'should be false if wrong node', () => { + setData( model, '

foo[]

' ); + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be true if in table', () => { + setData( model, modelTable( [ [ '[]' ] ] ) ); + expect( command.isEnabled ).to.be.true; + } ); + } ); + + describe( 'execute()', () => { + it( 'should insert column in given table at given index', () => { + setData( model, modelTable( [ + [ '11', '12[]' ], + [ '21', '22' ] + ] ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '11', '', '12[]' ], + [ '21', '', '22' ] + ] ) ); + } ); + + it( 'should insert columns at the table start', () => { + setData( model, modelTable( [ + [ '11', '12' ], + [ '[]21', '22' ] + ] ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '', '11', '12' ], + [ '', '[]21', '22' ] + ] ) ); + } ); + + it( 'should update table heading columns attribute when inserting column in headings section', () => { + setData( model, modelTable( [ + [ '11', '12[]' ], + [ '21', '22' ], + [ '31', '32' ] + ], { headingColumns: 2 } ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '11', '', '12[]' ], + [ '21', '', '22' ], + [ '31', '', '32' ] + ], { headingColumns: 3 } ) ); + } ); + + it( 'should not update table heading columns attribute when inserting column after headings section', () => { + setData( model, modelTable( [ + [ '11', '12', '13[]' ], + [ '21', '22', '23' ], + [ '31', '32', '33' ] + ], { headingColumns: 2 } ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '11', '12', '', '13[]' ], + [ '21', '22', '', '23' ], + [ '31', '32', '', '33' ] + ], { headingColumns: 2 } ) ); + } ); + + it( 'should skip spanned columns', () => { + setData( model, modelTable( [ + [ '11', '12[]' ], + [ { colspan: 2, contents: '21' } ], + [ '31', '32' ] + ], { headingColumns: 2 } ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '11', '', '12[]' ], + [ { colspan: 3, contents: '21' } ], + [ '31', '', '32' ] + ], { headingColumns: 3 } ) ); + } ); + + it( 'should skip wide spanned columns', () => { + setData( model, modelTable( [ + [ '11', '12', '13[]', '14', '15' ], + [ '21', '22', { colspan: 2, contents: '23' }, '25' ], + [ { colspan: 4, contents: '31' }, { colspan: 2, contents: '34' } ] + ], { headingColumns: 4 } ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '11', '12', '', '13[]', '14', '15' ], + [ '21', '22', '', { colspan: 2, contents: '23' }, '25' ], + [ { colspan: 5, contents: '31' }, { colspan: 2, contents: '34' } ] + ], { headingColumns: 5 } ) ); + } ); + } ); + } ); +} ); diff --git a/tests/commands/insertrowcommand.js b/tests/commands/insertrowcommand.js new file mode 100644 index 00000000..c15558e6 --- /dev/null +++ b/tests/commands/insertrowcommand.js @@ -0,0 +1,302 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor'; +import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters'; + +import InsertRowCommand from '../../src/commands/insertrowcommand'; +import { + downcastInsertCell, + downcastInsertRow, + downcastInsertTable, + downcastRemoveRow, + downcastTableHeadingColumnsChange, + downcastTableHeadingRowsChange +} from '../../src/converters/downcast'; +import upcastTable from '../../src/converters/upcasttable'; +import { formatTable, formattedModelTable, modelTable } from '../_utils/utils'; +import TableUtils from '../../src/tableutils'; + +describe( 'InsertRowCommand', () => { + let editor, model, command; + + beforeEach( () => { + return ModelTestEditor.create( { + plugins: [ TableUtils ] + } ).then( newEditor => { + editor = newEditor; + model = editor.model; + + const conversion = editor.conversion; + const schema = model.schema; + + schema.register( 'table', { + allowWhere: '$block', + allowAttributes: [ 'headingRows' ], + isObject: true + } ); + + schema.register( 'tableRow', { allowIn: 'table' } ); + + schema.register( 'tableCell', { + allowIn: 'tableRow', + allowContentOf: '$block', + allowAttributes: [ 'colspan', 'rowspan' ], + isLimit: true + } ); + + model.schema.register( 'p', { inheritAllFrom: '$block' } ); + + // Table conversion. + conversion.for( 'upcast' ).add( upcastTable() ); + conversion.for( 'downcast' ).add( downcastInsertTable() ); + + // Insert row conversion. + conversion.for( 'downcast' ).add( downcastInsertRow() ); + + // Remove row conversion. + conversion.for( 'downcast' ).add( downcastRemoveRow() ); + + // Table cell conversion. + conversion.for( 'downcast' ).add( downcastInsertCell() ); + + conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'td' } ) ); + conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'th' } ) ); + + // Table attributes conversion. + conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } ); + conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } ); + + conversion.for( 'downcast' ).add( downcastTableHeadingColumnsChange() ); + conversion.for( 'downcast' ).add( downcastTableHeadingRowsChange() ); + } ); + } ); + + afterEach( () => { + return editor.destroy(); + } ); + + describe( 'order=below', () => { + beforeEach( () => { + command = new InsertRowCommand( editor ); + } ); + + describe( 'isEnabled', () => { + it( 'should be false if wrong node', () => { + setData( model, '

foo[]

' ); + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be true if in table', () => { + setData( model, modelTable( [ [ '[]' ] ] ) ); + expect( command.isEnabled ).to.be.true; + } ); + } ); + + describe( 'execute()', () => { + it( 'should insert row after current position', () => { + setData( model, modelTable( [ + [ '00[]', '01' ], + [ '10', '11' ] + ] ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00[]', '01' ], + [ '', '' ], + [ '10', '11' ] + ] ) ); + } ); + + it( 'should update table heading rows attribute when inserting row in headings section', () => { + setData( model, modelTable( [ + [ '00[]', '01' ], + [ '10', '11' ], + [ '20', '21' ] + ], { headingRows: 2 } ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00[]', '01' ], + [ '', '' ], + [ '10', '11' ], + [ '20', '21' ] + ], { headingRows: 3 } ) ); + } ); + + it( 'should not update table heading rows attribute when inserting row after headings section', () => { + setData( model, modelTable( [ + [ '00', '01' ], + [ '10[]', '11' ], + [ '20', '21' ] + ], { headingRows: 2 } ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', '01' ], + [ '10[]', '11' ], + [ '', '' ], + [ '20', '21' ] + ], { headingRows: 2 } ) ); + } ); + + it( 'should expand rowspan of a cell that overlaps inserted rows', () => { + setData( model, modelTable( [ + [ { colspan: 2, contents: '00' }, '02', '03' ], + [ { colspan: 2, rowspan: 4, contents: '10[]' }, '12', '13' ], + [ '22', '23' ] + ], { headingColumns: 3, headingRows: 1 } ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ { colspan: 2, contents: '00' }, '02', '03' ], + [ { colspan: 2, rowspan: 5, contents: '10[]' }, '12', '13' ], + [ '', '' ], + [ '22', '23' ] + ], { headingColumns: 3, headingRows: 1 } ) ); + } ); + + it( 'should not expand rowspan of a cell that does not overlaps inserted rows', () => { + setData( model, modelTable( [ + [ { rowspan: 2, contents: '00' }, '01', '02' ], + [ '11[]', '12' ], + [ '20', '21', '22' ] + ], { headingColumns: 3, headingRows: 1 } ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ { rowspan: 2, contents: '00' }, '01', '02' ], + [ '11[]', '12' ], + [ '', '', '' ], + [ '20', '21', '22' ] + ], { headingColumns: 3, headingRows: 1 } ) ); + } ); + + it( 'should properly calculate columns if next row has colspans', () => { + setData( model, modelTable( [ + [ { rowspan: 2, contents: '00' }, '01', '02' ], + [ '11[]', '12' ], + [ { colspan: 3, contents: '20' } ] + ], { headingColumns: 3, headingRows: 1 } ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ { rowspan: 2, contents: '00' }, '01', '02' ], + [ '11[]', '12' ], + [ '', '', '' ], + [ { colspan: 3, contents: '20' } ] + ], { headingColumns: 3, headingRows: 1 } ) ); + } ); + + it( 'should insert rows at the end of a table', () => { + setData( model, modelTable( [ + [ '00', '01' ], + [ '10[]', '11' ] + ] ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', '01' ], + [ '10[]', '11' ], + [ '', '' ] + ] ) ); + } ); + } ); + } ); + + describe( 'order=above', () => { + beforeEach( () => { + command = new InsertRowCommand( editor, { order: 'above' } ); + } ); + + describe( 'isEnabled', () => { + it( 'should be false if wrong node', () => { + setData( model, '

foo[]

' ); + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be true if in table', () => { + setData( model, modelTable( [ [ '[]' ] ] ) ); + expect( command.isEnabled ).to.be.true; + } ); + } ); + + describe( 'execute()', () => { + it( 'should insert row at the beginning of a table', () => { + setData( model, modelTable( [ + [ '00[]', '01' ], + [ '10', '11' ] + ] ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '', '' ], + [ '00[]', '01' ], + [ '10', '11' ] + ] ) ); + } ); + + it( 'should insert row at the end of a table', () => { + setData( model, modelTable( [ + [ '00', '01' ], + [ '10', '11' ], + [ '20[]', '21' ] + ] ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', '01' ], + [ '10', '11' ], + [ '', '' ], + [ '20[]', '21' ] + ] ) ); + } ); + + it( 'should update table heading rows attribute when inserting row in headings section', () => { + setData( model, modelTable( [ + [ '00[]', '01' ], + [ '10', '11' ], + [ '20', '21' ] + ], { headingRows: 2 } ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '', '' ], + [ '00[]', '01' ], + [ '10', '11' ], + [ '20', '21' ] + ], { headingRows: 3 } ) ); + } ); + + it( 'should not update table heading rows attribute when inserting row after headings section', () => { + setData( model, modelTable( [ + [ '00', '01' ], + [ '10', '11' ], + [ '20[]', '21' ] + ], { headingRows: 2 } ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', '01' ], + [ '10', '11' ], + [ '', '' ], + [ '20[]', '21' ] + ], { headingRows: 2 } ) ); + } ); + } ); + } ); +} ); diff --git a/tests/commands/inserttablecommand.js b/tests/commands/inserttablecommand.js new file mode 100644 index 00000000..faf3c69a --- /dev/null +++ b/tests/commands/inserttablecommand.js @@ -0,0 +1,139 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor'; +import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters'; + +import InsertTableCommand from '../../src/commands/inserttablecommand'; +import { + downcastInsertCell, + downcastInsertRow, + downcastInsertTable, + downcastRemoveRow, + downcastTableHeadingColumnsChange, + downcastTableHeadingRowsChange +} from '../../src/converters/downcast'; +import upcastTable from '../../src/converters/upcasttable'; +import TableUtils from '../../src/tableutils'; + +describe( 'InsertTableCommand', () => { + let editor, model, command; + + beforeEach( () => { + return ModelTestEditor.create( { + plugins: [ TableUtils ] + } ).then( newEditor => { + editor = newEditor; + model = editor.model; + command = new InsertTableCommand( editor ); + + const conversion = editor.conversion; + const schema = model.schema; + + schema.register( 'table', { + allowWhere: '$block', + allowAttributes: [ 'headingRows' ], + isObject: true + } ); + + schema.register( 'tableRow', { allowIn: 'table' } ); + + schema.register( 'tableCell', { + allowIn: 'tableRow', + allowContentOf: '$block', + allowAttributes: [ 'colspan', 'rowspan' ], + isLimit: true + } ); + + model.schema.register( 'p', { inheritAllFrom: '$block' } ); + + // Table conversion. + conversion.for( 'upcast' ).add( upcastTable() ); + conversion.for( 'downcast' ).add( downcastInsertTable() ); + + // Insert row conversion. + conversion.for( 'downcast' ).add( downcastInsertRow() ); + + // Remove row conversion. + conversion.for( 'downcast' ).add( downcastRemoveRow() ); + + // Table cell conversion. + conversion.for( 'downcast' ).add( downcastInsertCell() ); + + conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'td' } ) ); + conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'th' } ) ); + + // Table attributes conversion. + conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } ); + conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } ); + + conversion.for( 'downcast' ).add( downcastTableHeadingColumnsChange() ); + conversion.for( 'downcast' ).add( downcastTableHeadingRowsChange() ); + } ); + } ); + + afterEach( () => { + return editor.destroy(); + } ); + + describe( 'isEnabled', () => { + describe( 'when selection is collapsed', () => { + it( 'should be true if in paragraph', () => { + setData( model, '

foo[]

' ); + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false if in table', () => { + setData( model, 'foo[]
' ); + expect( command.isEnabled ).to.be.false; + } ); + } ); + } ); + + describe( 'execute()', () => { + describe( 'collapsed selection', () => { + it( 'should insert table in empty root', () => { + setData( model, '[]' ); + + command.execute(); + + expect( getData( model ) ).to.equal( + '' + + '' + + '' + + '
[]' + ); + } ); + + it( 'should insert table with two rows and two columns after non-empty paragraph', () => { + setData( model, '

foo[]

' ); + + command.execute(); + + expect( getData( model ) ).to.equal( '

foo[]

' + + '' + + '' + + '' + + '
' + ); + } ); + + it( 'should insert table with given rows and columns after non-empty paragraph', () => { + setData( model, '

foo[]

' ); + + command.execute( { rows: 3, columns: 4 } ); + + expect( getData( model ) ).to.equal( '

foo[]

' + + '' + + '' + + '' + + '' + + '
' + ); + } ); + } ); + } ); +} ); diff --git a/tests/commands/mergecellcommand.js b/tests/commands/mergecellcommand.js new file mode 100644 index 00000000..428e1f00 --- /dev/null +++ b/tests/commands/mergecellcommand.js @@ -0,0 +1,518 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor'; +import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters'; + +import MergeCellCommand from '../../src/commands/mergecellcommand'; +import { + downcastInsertCell, + downcastInsertRow, + downcastInsertTable, + downcastRemoveRow, + downcastTableHeadingColumnsChange, + downcastTableHeadingRowsChange +} from '../../src/converters/downcast'; +import upcastTable from '../../src/converters/upcasttable'; +import { formatTable, formattedModelTable, modelTable } from '../_utils/utils'; + +describe( 'MergeCellCommand', () => { + let editor, model, command, root; + + beforeEach( () => { + return ModelTestEditor.create() + .then( newEditor => { + editor = newEditor; + model = editor.model; + root = model.document.getRoot( 'main' ); + + const conversion = editor.conversion; + const schema = model.schema; + + schema.register( 'table', { + allowWhere: '$block', + allowAttributes: [ 'headingRows' ], + isObject: true + } ); + + schema.register( 'tableRow', { allowIn: 'table' } ); + + schema.register( 'tableCell', { + allowIn: 'tableRow', + allowContentOf: '$block', + allowAttributes: [ 'colspan', 'rowspan' ], + isLimit: true + } ); + + model.schema.register( 'p', { inheritAllFrom: '$block' } ); + + // Table conversion. + conversion.for( 'upcast' ).add( upcastTable() ); + conversion.for( 'downcast' ).add( downcastInsertTable() ); + + // Insert row conversion. + conversion.for( 'downcast' ).add( downcastInsertRow() ); + + // Remove row conversion. + conversion.for( 'downcast' ).add( downcastRemoveRow() ); + + // Table cell conversion. + conversion.for( 'downcast' ).add( downcastInsertCell() ); + + conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'td' } ) ); + conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'th' } ) ); + + // Table attributes conversion. + conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } ); + conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } ); + + conversion.for( 'downcast' ).add( downcastTableHeadingColumnsChange() ); + conversion.for( 'downcast' ).add( downcastTableHeadingRowsChange() ); + } ); + } ); + + afterEach( () => { + return editor.destroy(); + } ); + + describe( 'direction=right', () => { + beforeEach( () => { + command = new MergeCellCommand( editor, { direction: 'right' } ); + } ); + + describe( 'isEnabled', () => { + it( 'should be true if in cell that has sibling on the right', () => { + setData( model, modelTable( [ + [ '00[]', '01' ] + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false if last cell of a row', () => { + setData( model, modelTable( [ + [ '00', '01[]' ] + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be true if in a cell that has sibling on the right with the same rowspan', () => { + setData( model, modelTable( [ + [ { rowspan: 2, contents: '00[]' }, { rowspan: 2, contents: '01' } ] + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false if in a cell that has sibling but with different rowspan', () => { + setData( model, modelTable( [ + [ { rowspan: 2, contents: '00[]' }, { rowspan: 3, contents: '01' } ] + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false if not in a cell', () => { + setData( model, '

11[]

' ); + + expect( command.isEnabled ).to.be.false; + } ); + } ); + + describe( 'value', () => { + it( 'should be set to mergeable sibling if in cell that has sibling on the right', () => { + setData( model, modelTable( [ + [ '00[]', '01' ] + ] ) ); + + expect( command.value ).to.equal( root.getNodeByPath( [ 0, 0, 1 ] ) ); + } ); + + it( 'should be undefined if last cell of a row', () => { + setData( model, modelTable( [ + [ '00', '01[]' ] + ] ) ); + + expect( command.value ).to.be.undefined; + } ); + + it( 'should be set to mergeable sibling if in a cell that has sibling on the right with the same rowspan', () => { + setData( model, modelTable( [ + [ { rowspan: 2, contents: '00[]' }, { rowspan: 2, contents: '01' } ] + ] ) ); + + expect( command.value ).to.equal( root.getNodeByPath( [ 0, 0, 1 ] ) ); + } ); + + it( 'should be undefined if in a cell that has sibling but with different rowspan', () => { + setData( model, modelTable( [ + [ { rowspan: 2, contents: '00[]' }, { rowspan: 3, contents: '01' } ] + ] ) ); + + expect( command.value ).to.be.undefined; + } ); + + it( 'should be undefined if not in a cell', () => { + setData( model, '

11[]

' ); + + expect( command.value ).to.be.undefined; + } ); + } ); + + describe( 'execute()', () => { + it( 'should merge table cells ', () => { + setData( model, modelTable( [ + [ '[]00', '01' ] + ] ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ { colspan: 2, contents: '[0001]' } ] + ] ) ); + } ); + } ); + } ); + + describe( 'direction=left', () => { + beforeEach( () => { + command = new MergeCellCommand( editor, { direction: 'left' } ); + } ); + + describe( 'isEnabled', () => { + it( 'should be true if in cell that has sibling on the left', () => { + setData( model, modelTable( [ + [ '00', '01[]' ] + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false if first cell of a row', () => { + setData( model, modelTable( [ + [ '00[]', '01' ] + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be true if in a cell that has sibling on the left with the same rowspan', () => { + setData( model, modelTable( [ + [ { rowspan: 2, contents: '00' }, { rowspan: 2, contents: '01[]' } ] + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false if in a cell that has sibling but with different rowspan', () => { + setData( model, modelTable( [ + [ { rowspan: 2, contents: '00' }, { rowspan: 3, contents: '01[]' } ] + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false if not in a cell', () => { + setData( model, '

11[]

' ); + + expect( command.isEnabled ).to.be.false; + } ); + } ); + + describe( 'value', () => { + it( 'should be set to mergeable sibling if in cell that has sibling on the left', () => { + setData( model, modelTable( [ + [ '00', '01[]' ] + ] ) ); + + expect( command.value ).to.equal( root.getNodeByPath( [ 0, 0, 0 ] ) ); + } ); + + it( 'should be undefined if first cell of a row', () => { + setData( model, modelTable( [ + [ '00[]', '01' ] + ] ) ); + + expect( command.value ).to.be.undefined; + } ); + + it( 'should be set to mergeable sibling if in a cell that has sibling on the left with the same rowspan', () => { + setData( model, modelTable( [ + [ { rowspan: 2, contents: '00' }, { rowspan: 2, contents: '01[]' } ] + ] ) ); + + expect( command.value ).to.equal( root.getNodeByPath( [ 0, 0, 0 ] ) ); + } ); + + it( 'should be undefined if in a cell that has sibling but with different rowspan', () => { + setData( model, modelTable( [ + [ { rowspan: 2, contents: '00' }, { rowspan: 3, contents: '01[]' } ] + ] ) ); + + expect( command.value ).to.be.undefined; + } ); + + it( 'should be undefined if not in a cell', () => { + setData( model, '

11[]

' ); + + expect( command.value ).to.be.undefined; + } ); + } ); + + describe( 'execute()', () => { + it( 'should merge table cells ', () => { + setData( model, modelTable( [ + [ '00', '[]01' ] + ] ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ { colspan: 2, contents: '[0001]' } ] + ] ) ); + } ); + } ); + } ); + + describe( 'direction=down', () => { + beforeEach( () => { + command = new MergeCellCommand( editor, { direction: 'down' } ); + } ); + + describe( 'isEnabled', () => { + it( 'should be true if in cell that has mergeable cell in next row', () => { + setData( model, modelTable( [ + [ '00', '01[]' ], + [ '10', '11' ] + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false if in last row', () => { + setData( model, modelTable( [ + [ '00', '01' ], + [ '10[]', '11' ] + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be true if in a cell that has mergeable cell with the same colspan', () => { + setData( model, modelTable( [ + [ { colspan: 2, contents: '00[]' }, '02' ], + [ { colspan: 2, contents: '01' }, '12' ] + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false if in a cell that potential mergeable cell has different colspan', () => { + setData( model, modelTable( [ + [ { colspan: 2, contents: '00[]' }, '02' ], + [ { colspan: 3, contents: '01' } ] + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false if not in a cell', () => { + setData( model, '

11[]

' ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false if mergeable cell is in other table section then current cell', () => { + setData( model, modelTable( [ + [ '00[]', '01' ], + [ '10', '11' ] + ], { headingRows: 1 } ) ); + + expect( command.isEnabled ).to.be.false; + } ); + } ); + + describe( 'value', () => { + it( 'should be set to mergeable cell', () => { + setData( model, modelTable( [ + [ '00', '01[]' ], + [ '10', '11' ] + ] ) ); + + expect( command.value ).to.equal( root.getNodeByPath( [ 0, 1, 1 ] ) ); + } ); + + it( 'should be undefined if in last row', () => { + setData( model, modelTable( [ + [ '00', '01' ], + [ '10[]', '11' ] + ] ) ); + + expect( command.value ).to.be.undefined; + } ); + + it( 'should be set to mergeable cell with the same rowspan', () => { + setData( model, modelTable( [ + [ { colspan: 2, contents: '00[]' }, '02' ], + [ { colspan: 2, contents: '01' }, '12' ] + ] ) ); + + expect( command.value ).to.equal( root.getNodeByPath( [ 0, 1, 0 ] ) ); + } ); + + it( 'should be undefined if in a cell that potential mergeable cell has different rowspan', () => { + setData( model, modelTable( [ + [ { colspan: 2, contents: '00[]' }, '02' ], + [ { colspan: 3, contents: '01' } ] + ] ) ); + + expect( command.value ).to.be.undefined; + } ); + + it( 'should be undefined if not in a cell', () => { + setData( model, '

11[]

' ); + + expect( command.value ).to.be.undefined; + } ); + } ); + + describe( 'execute()', () => { + it( 'should merge table cells ', () => { + setData( model, modelTable( [ + [ '00', '01[]' ], + [ '10', '11' ] + ] ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', { rowspan: 2, contents: '[0111]' } ], + [ '10' ] + ] ) ); + } ); + } ); + } ); + + describe( 'direction=up', () => { + beforeEach( () => { + command = new MergeCellCommand( editor, { direction: 'up' } ); + } ); + + describe( 'isEnabled', () => { + it( 'should be true if in cell that has mergeable cell in previous row', () => { + setData( model, modelTable( [ + [ '00', '01' ], + [ '10', '11[]' ] + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false if in first row', () => { + setData( model, modelTable( [ + [ '00[]', '01' ], + [ '10', '11' ] + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be true if in a cell that has mergeable cell with the same colspan', () => { + setData( model, modelTable( [ + [ { colspan: 2, contents: '00' }, '02' ], + [ { colspan: 2, contents: '01[]' }, '12' ] + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false if in a cell that potential mergeable cell has different colspan', () => { + setData( model, modelTable( [ + [ { colspan: 2, contents: '00' }, '02' ], + [ { colspan: 3, contents: '01[]' } ] + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false if not in a cell', () => { + setData( model, '

11[]

' ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false if mergeable cell is in other table section then current cell', () => { + setData( model, modelTable( [ + [ '00', '01' ], + [ '10[]', '11' ] + ], { headingRows: 1 } ) ); + + expect( command.isEnabled ).to.be.false; + } ); + } ); + + describe( 'value', () => { + it( 'should be set to mergeable cell', () => { + setData( model, modelTable( [ + [ '00', '01' ], + [ '10', '11[]' ] + ] ) ); + + expect( command.value ).to.equal( root.getNodeByPath( [ 0, 0, 1 ] ) ); + } ); + + it( 'should be undefined if in first row', () => { + setData( model, modelTable( [ + [ '00[]', '01' ], + [ '10', '11' ] + ] ) ); + + expect( command.value ).to.be.undefined; + } ); + + it( 'should be set to mergeable cell with the same rowspan', () => { + setData( model, modelTable( [ + [ { colspan: 2, contents: '00' }, '02' ], + [ { colspan: 2, contents: '01[]' }, '12' ] + ] ) ); + + expect( command.value ).to.equal( root.getNodeByPath( [ 0, 0, 0 ] ) ); + } ); + + it( 'should be undefined if in a cell that potential mergeable cell has different rowspan', () => { + setData( model, modelTable( [ + [ { colspan: 2, contents: '00' }, '02' ], + [ { colspan: 3, contents: '01[]' } ] + ] ) ); + + expect( command.value ).to.be.undefined; + } ); + + it( 'should be undefined if not in a cell', () => { + setData( model, '

11[]

' ); + + expect( command.value ).to.be.undefined; + } ); + } ); + + describe( 'execute()', () => { + it( 'should merge table cells ', () => { + setData( model, modelTable( [ + [ '00', '01' ], + [ '10', '11[]' ] + ] ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', { rowspan: 2, contents: '[0111]' } ], + [ '10' ] + ] ) ); + } ); + } ); + } ); +} ); diff --git a/tests/commands/removecolumncommand.js b/tests/commands/removecolumncommand.js new file mode 100644 index 00000000..af422200 --- /dev/null +++ b/tests/commands/removecolumncommand.js @@ -0,0 +1,196 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor'; +import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters'; + +import RemoveColumnCommand from '../../src/commands/removecolumncommand'; +import { + downcastInsertCell, + downcastInsertRow, + downcastInsertTable, + downcastRemoveRow, + downcastTableHeadingColumnsChange, + downcastTableHeadingRowsChange +} from '../../src/converters/downcast'; +import upcastTable from '../../src/converters/upcasttable'; +import { formatTable, formattedModelTable, modelTable } from '../_utils/utils'; +import TableUtils from '../../src/tableutils'; + +describe( 'RemoveColumnCommand', () => { + let editor, model, command; + + beforeEach( () => { + return ModelTestEditor.create( { + plugins: [ TableUtils ] + } ).then( newEditor => { + editor = newEditor; + model = editor.model; + command = new RemoveColumnCommand( editor ); + + const conversion = editor.conversion; + const schema = model.schema; + + schema.register( 'table', { + allowWhere: '$block', + allowAttributes: [ 'headingRows', 'headingColumns' ], + isObject: true + } ); + + schema.register( 'tableRow', { allowIn: 'table' } ); + + schema.register( 'tableCell', { + allowIn: 'tableRow', + allowContentOf: '$block', + allowAttributes: [ 'colspan', 'rowspan' ], + isLimit: true + } ); + + model.schema.register( 'p', { inheritAllFrom: '$block' } ); + + // Table conversion. + conversion.for( 'upcast' ).add( upcastTable() ); + conversion.for( 'downcast' ).add( downcastInsertTable() ); + + // Insert row conversion. + conversion.for( 'downcast' ).add( downcastInsertRow() ); + + // Remove row conversion. + conversion.for( 'downcast' ).add( downcastRemoveRow() ); + + // Table cell conversion. + conversion.for( 'downcast' ).add( downcastInsertCell() ); + + conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'td' } ) ); + conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'th' } ) ); + + // Table attributes conversion. + conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } ); + conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } ); + + conversion.for( 'downcast' ).add( downcastTableHeadingColumnsChange() ); + conversion.for( 'downcast' ).add( downcastTableHeadingRowsChange() ); + } ); + } ); + + afterEach( () => { + return editor.destroy(); + } ); + + describe( 'isEnabled', () => { + it( 'should be true if selection is inside table cell', () => { + setData( model, modelTable( [ + [ '00[]', '01' ], + [ '10', '11' ] + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false if selection is inside table with one column only', () => { + setData( model, modelTable( [ + [ '00' ], + [ '10[]' ], + [ '20[]' ] + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false if selection is outside a table', () => { + setData( model, '

11[]

' ); + + expect( command.isEnabled ).to.be.false; + } ); + } ); + + describe( 'execute()', () => { + it( 'should remove a given column', () => { + setData( model, modelTable( [ + [ '00', '01', '02' ], + [ '10', '[]11', '12' ], + [ '20', '21', '22' ] + ] ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', '02' ], + [ '10[]', '12' ], + [ '20', '22' ] + ] ) ); + } ); + + it( 'should remove a given column from a table start', () => { + setData( model, modelTable( [ + [ '[]00', '01' ], + [ '10', '11' ], + [ '20', '21' ] + ] ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '[]01' ], + [ '11' ], + [ '21' ] + ] ) ); + } ); + + it( 'should change heading columns if removing a heading column', () => { + setData( model, modelTable( [ + [ '00', '01' ], + [ '[]10', '11' ], + [ '20', '21' ] + ], { headingColumns: 2 } ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '01' ], + [ '[]11' ], + [ '21' ] + ], { headingColumns: 1 } ) ); + } ); + + it( 'should decrease colspan of table cells from previous column', () => { + setData( model, modelTable( [ + [ { colspan: 4, contents: '00' }, '03' ], + [ { colspan: 3, contents: '10' }, '13' ], + [ { colspan: 2, contents: '20' }, '22[]', '23' ], + [ '30', { colspan: 2, contents: '31' }, '33' ], + [ '40', '41', '42', '43' ] + ] ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ { colspan: 3, contents: '00' }, '03' ], + [ { colspan: 2, contents: '10' }, '13' ], + [ { colspan: 2, contents: '20[]' }, '23' ], + [ '30', '31', '33' ], + [ '40', '41', '43' ] + + ] ) ); + } ); + + it( 'should decrease colspan of cells that are on removed column', () => { + setData( model, modelTable( [ + [ { colspan: 3, contents: '[]00' }, '03' ], + [ { colspan: 2, contents: '10' }, '13' ], + [ '20', '21', '22', '23' ] + ] ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ { colspan: 2, contents: '[]00' }, '03' ], + [ '10', '13' ], + [ '21', '22', '23' ] + ] ) ); + } ); + } ); +} ); diff --git a/tests/commands/removerowcommand.js b/tests/commands/removerowcommand.js new file mode 100644 index 00000000..937f2cac --- /dev/null +++ b/tests/commands/removerowcommand.js @@ -0,0 +1,186 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor'; +import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters'; + +import RemoveRowCommand from '../../src/commands/removerowcommand'; +import { + downcastInsertCell, + downcastInsertRow, + downcastInsertTable, + downcastRemoveRow, + downcastTableHeadingColumnsChange, + downcastTableHeadingRowsChange +} from '../../src/converters/downcast'; +import upcastTable from '../../src/converters/upcasttable'; +import { formatTable, formattedModelTable, modelTable } from '../_utils/utils'; + +describe( 'RemoveRowCommand', () => { + let editor, model, command; + + beforeEach( () => { + return ModelTestEditor.create() + .then( newEditor => { + editor = newEditor; + model = editor.model; + command = new RemoveRowCommand( editor ); + + const conversion = editor.conversion; + const schema = model.schema; + + schema.register( 'table', { + allowWhere: '$block', + allowAttributes: [ 'headingRows' ], + isObject: true + } ); + + schema.register( 'tableRow', { allowIn: 'table' } ); + + schema.register( 'tableCell', { + allowIn: 'tableRow', + allowContentOf: '$block', + allowAttributes: [ 'colspan', 'rowspan' ], + isLimit: true + } ); + + model.schema.register( 'p', { inheritAllFrom: '$block' } ); + + // Table conversion. + conversion.for( 'upcast' ).add( upcastTable() ); + conversion.for( 'downcast' ).add( downcastInsertTable() ); + + // Insert row conversion. + conversion.for( 'downcast' ).add( downcastInsertRow() ); + + // Remove row conversion. + conversion.for( 'downcast' ).add( downcastRemoveRow() ); + + // Table cell conversion. + conversion.for( 'downcast' ).add( downcastInsertCell() ); + + conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'td' } ) ); + conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'th' } ) ); + + // Table attributes conversion. + conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } ); + conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } ); + + conversion.for( 'downcast' ).add( downcastTableHeadingColumnsChange() ); + conversion.for( 'downcast' ).add( downcastTableHeadingRowsChange() ); + } ); + } ); + + afterEach( () => { + return editor.destroy(); + } ); + + describe( 'isEnabled', () => { + it( 'should be true if selection is inside table cell', () => { + setData( model, modelTable( [ + [ '00[]', '01' ], + [ '10', '11' ] + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false if selection is inside table with one row only', () => { + setData( model, modelTable( [ + [ '00[]', '01' ] + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false if selection is outside a table', () => { + setData( model, '

11[]

' ); + + expect( command.isEnabled ).to.be.false; + } ); + } ); + + describe( 'execute()', () => { + it( 'should remove a given row', () => { + setData( model, modelTable( [ + [ '00', '01' ], + [ '[]10', '11' ], + [ '20', '21' ] + ] ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', '01[]' ], + [ '20', '21' ] + ] ) ); + } ); + + it( 'should remove a given row from a table start', () => { + setData( model, modelTable( [ + [ '[]00', '01' ], + [ '10', '11' ], + [ '20', '21' ] + ] ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '[]10', '11' ], + [ '20', '21' ] + ] ) ); + } ); + + it( 'should change heading rows if removing a heading row', () => { + setData( model, modelTable( [ + [ '00', '01' ], + [ '[]10', '11' ], + [ '20', '21' ] + ], { headingRows: 2 } ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', '01[]' ], + [ '20', '21' ] + ], { headingRows: 1 } ) ); + } ); + + it( 'should decrease rowspan of table cells from previous rows', () => { + setData( model, modelTable( [ + [ { rowspan: 4, contents: '00' }, { rowspan: 3, contents: '01' }, { rowspan: 2, contents: '02' }, '03', '04' ], + [ { rowspan: 2, contents: '13' }, '14' ], + [ '22[]', '23', '24' ], + [ '30', '31', '32', '33', '34' ] + ] ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ { rowspan: 3, contents: '00' }, { rowspan: 2, contents: '01' }, { rowspan: 2, contents: '02' }, '03', '04' ], + [ '13', '14[]' ], + [ '30', '31', '32', '33', '34' ] + ] ) ); + } ); + + it( 'should move rowspaned cells to row below removing it\'s row', () => { + setData( model, modelTable( [ + [ { rowspan: 3, contents: '[]00' }, { rowspan: 2, contents: '01' }, '02' ], + [ '12' ], + [ '22' ], + [ '30', '31', '32' ] + ] ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ { rowspan: 2, contents: '[]00' }, '01', '12' ], + [ '22' ], + [ '30', '31', '32' ] + ] ) ); + } ); + } ); +} ); diff --git a/tests/commands/settableheaderscommand.js b/tests/commands/settableheaderscommand.js new file mode 100644 index 00000000..8b717437 --- /dev/null +++ b/tests/commands/settableheaderscommand.js @@ -0,0 +1,240 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor'; +import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters'; + +import SetTableHeadersCommand from '../../src/commands/settableheaderscommand'; +import { + downcastInsertCell, + downcastInsertRow, + downcastInsertTable, + downcastRemoveRow, + downcastTableHeadingColumnsChange, + downcastTableHeadingRowsChange +} from '../../src/converters/downcast'; +import upcastTable from '../../src/converters/upcasttable'; +import { formatTable, formattedModelTable, modelTable } from '../_utils/utils'; + +describe( 'SetTableHeadersCommand', () => { + let editor, model, command; + + beforeEach( () => { + return ModelTestEditor.create() + .then( newEditor => { + editor = newEditor; + model = editor.model; + command = new SetTableHeadersCommand( editor ); + + const conversion = editor.conversion; + const schema = model.schema; + + schema.register( 'table', { + allowWhere: '$block', + allowAttributes: [ 'headingRows' ], + isObject: true + } ); + + schema.register( 'tableRow', { allowIn: 'table' } ); + + schema.register( 'tableCell', { + allowIn: 'tableRow', + allowContentOf: '$block', + allowAttributes: [ 'colspan', 'rowspan' ], + isLimit: true + } ); + + model.schema.register( 'p', { inheritAllFrom: '$block' } ); + + // Table conversion. + conversion.for( 'upcast' ).add( upcastTable() ); + conversion.for( 'downcast' ).add( downcastInsertTable() ); + + // Insert row conversion. + conversion.for( 'downcast' ).add( downcastInsertRow() ); + + // Remove row conversion. + conversion.for( 'downcast' ).add( downcastRemoveRow() ); + + // Table cell conversion. + conversion.for( 'downcast' ).add( downcastInsertCell() ); + + conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'td' } ) ); + conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'th' } ) ); + + // Table attributes conversion. + conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } ); + conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } ); + + conversion.for( 'downcast' ).add( downcastTableHeadingColumnsChange() ); + conversion.for( 'downcast' ).add( downcastTableHeadingRowsChange() ); + } ); + } ); + + afterEach( () => { + return editor.destroy(); + } ); + + describe( 'isEnabled', () => { + it( 'should be false if not in a table', () => { + setData( model, '

foo[]

' ); + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be true if in table', () => { + setData( model, 'foo[]
' ); + expect( command.isEnabled ).to.be.true; + } ); + } ); + + describe( 'execute()', () => { + it( 'should set heading rows attribute', () => { + setData( model, modelTable( [ + [ '00', '01' ], + [ '[]10', '11' ], + [ '20', '21' ] + ] ) ); + + command.execute( { rows: 2 } ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', '01' ], + [ '[]10', '11' ], + [ '20', '21' ] + ], { headingRows: 2 } ) ); + } ); + + it( 'should remove heading rows attribute', () => { + setData( model, modelTable( [ + [ '00', '01' ], + [ '[]10', '11' ], + [ '20', '21' ] + ], { headingRows: 2 } ) ); + + command.execute( { rows: 0 } ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', '01' ], + [ '[]10', '11' ], + [ '20', '21' ] + ] ) ); + } ); + + it( 'should set heading columns attribute', () => { + setData( model, modelTable( [ + [ '00', '01' ], + [ '[]10', '11' ], + [ '20', '21' ] + ] ) ); + + command.execute( { columns: 2 } ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', '01' ], + [ '[]10', '11' ], + [ '20', '21' ] + ], { headingColumns: 2 } ) ); + } ); + + it( 'should remove heading columns attribute', () => { + setData( model, modelTable( [ + [ '00', '01' ], + [ '[]10', '11' ], + [ '20', '21' ] + ], { headingColumns: 2 } ) ); + + command.execute( { columns: 0 } ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', '01' ], + [ '[]10', '11' ], + [ '20', '21' ] + ] ) ); + } ); + + it( 'should remove heading columns & heading rows attributes', () => { + setData( model, modelTable( [ + [ '00', '01' ], + [ '[]10', '11' ], + [ '20', '21' ] + ], { headingColumns: 2, headingRows: 2 } ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', '01' ], + [ '[]10', '11' ], + [ '20', '21' ] + ] ) ); + } ); + + it( 'should fix rowspaned cells on the edge of an table head section', () => { + setData( model, modelTable( [ + [ '00', '01', '02' ], + [ { colspan: 2, rowspan: 2, contents: '10[]' }, '12' ], + [ '22' ] + ], { headingColumns: 2, headingRows: 1 } ) ); + + command.execute( { rows: 2, columns: 2 } ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', '01', '02' ], + [ { colspan: 2, contents: '10[]' }, '12' ], + [ { colspan: 2, contents: '' }, '22' ] + ], { headingColumns: 2, headingRows: 2 } ) ); + } ); + + it( 'should split to at most 2 table cells when fixing rowspaned cells on the edge of an table head section', () => { + setData( model, modelTable( [ + [ '00', '01', '02' ], + [ { colspan: 2, rowspan: 5, contents: '10[]' }, '12' ], + [ '22' ], + [ '32' ], + [ '42' ], + [ '52' ] + ], { headingColumns: 2, headingRows: 1 } ) ); + + command.execute( { rows: 3, columns: 2 } ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', '01', '02' ], + [ { colspan: 2, rowspan: 2, contents: '10[]' }, '12' ], + [ '22' ], + [ { colspan: 2, rowspan: 3, contents: '' }, '32' ], + [ '42' ], + [ '52' ] + ], { headingColumns: 2, headingRows: 3 } ) ); + } ); + + it( 'should fix rowspaned cells on the edge of an table head section when creating section', () => { + setData( model, modelTable( [ + [ { rowspan: 2, contents: '[]00' }, '01' ], + [ '11' ] + ], { headingRows: 2 } ) ); + + command.execute( { rows: 1 } ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '[]00', '01' ], + [ '', '11' ], + ], { headingRows: 1 } ) ); + } ); + + it( 'should fix rowspaned cells inside a row', () => { + setData( model, modelTable( [ + [ '00', { rowspan: 2, contents: '[]01' } ], + [ '10' ] + ], { headingRows: 2 } ) ); + + command.execute( { rows: 1 } ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', '[]01' ], + [ '10', '' ] + ], { headingRows: 1 } ) ); + } ); + } ); +} ); diff --git a/tests/commands/splitcellcommand.js b/tests/commands/splitcellcommand.js new file mode 100644 index 00000000..22fa9928 --- /dev/null +++ b/tests/commands/splitcellcommand.js @@ -0,0 +1,227 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor'; +import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters'; + +import SplitCellCommand from '../../src/commands/splitcellcommand'; +import { + downcastInsertCell, + downcastInsertRow, + downcastInsertTable, + downcastRemoveRow, + downcastTableHeadingColumnsChange, + downcastTableHeadingRowsChange +} from '../../src/converters/downcast'; +import upcastTable from '../../src/converters/upcasttable'; +import { formatTable, formattedModelTable, modelTable } from '../_utils/utils'; +import TableUtils from '../../src/tableutils'; + +describe( 'SplitCellCommand', () => { + let editor, model, command; + + beforeEach( () => { + return ModelTestEditor.create( { + plugins: [ TableUtils ] + } ).then( newEditor => { + editor = newEditor; + model = editor.model; + command = new SplitCellCommand( editor ); + + const conversion = editor.conversion; + const schema = model.schema; + + schema.register( 'table', { + allowWhere: '$block', + allowAttributes: [ 'headingRows' ], + isObject: true + } ); + + schema.register( 'tableRow', { allowIn: 'table' } ); + + schema.register( 'tableCell', { + allowIn: 'tableRow', + allowContentOf: '$block', + allowAttributes: [ 'colspan', 'rowspan' ], + isLimit: true + } ); + + model.schema.register( 'p', { inheritAllFrom: '$block' } ); + + // Table conversion. + conversion.for( 'upcast' ).add( upcastTable() ); + conversion.for( 'downcast' ).add( downcastInsertTable() ); + + // Insert row conversion. + conversion.for( 'downcast' ).add( downcastInsertRow() ); + + // Remove row conversion. + conversion.for( 'downcast' ).add( downcastRemoveRow() ); + + // Table cell conversion. + conversion.for( 'downcast' ).add( downcastInsertCell() ); + + conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'td' } ) ); + conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'th' } ) ); + + // Table attributes conversion. + conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } ); + conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } ); + + conversion.for( 'downcast' ).add( downcastTableHeadingColumnsChange() ); + conversion.for( 'downcast' ).add( downcastTableHeadingRowsChange() ); + } ); + } ); + + afterEach( () => { + return editor.destroy(); + } ); + + describe( 'direction=vertically', () => { + beforeEach( () => { + command = new SplitCellCommand( editor, { direction: 'vertically' } ); + } ); + + describe( 'isEnabled', () => { + it( 'should be true if in a table cell', () => { + setData( model, modelTable( [ + [ '00[]' ] + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false if not in cell', () => { + setData( model, '

11[]

' ); + + expect( command.isEnabled ).to.be.false; + } ); + } ); + + describe( 'execute()', () => { + it( 'should split table cell for two table cells', () => { + setData( model, modelTable( [ + [ '00', '01', '02' ], + [ '10', '[]11', '12' ], + [ '20', { colspan: 2, contents: '21' } ], + [ { colspan: 2, contents: '30' }, '32' ] + ] ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', { colspan: 2, contents: '01' }, '02' ], + [ '10', '[]11', '', '12' ], + [ '20', { colspan: 3, contents: '21' } ], + [ { colspan: 3, contents: '30' }, '32' ] + ] ) ); + } ); + + it( 'should unsplit table cell if split is equal to colspan', () => { + setData( model, modelTable( [ + [ '00', '01', '02' ], + [ '10', '11', '12' ], + [ '20', { colspan: 2, contents: '21[]' } ], + [ { colspan: 2, contents: '30' }, '32' ] + ] ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', '01', '02' ], + [ '10', '11', '12' ], + [ '20', '21[]', '' ], + [ { colspan: 2, contents: '30' }, '32' ] + ] ) ); + } ); + + it( 'should properly unsplit table cell if split is uneven', () => { + setData( model, modelTable( [ + [ '00', '01', '02' ], + [ { colspan: 3, contents: '10[]' } ] + ] ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', '01', '02' ], + [ { colspan: 2, contents: '10[]' }, '' ] + ] ) ); + } ); + + it( 'should properly set colspan of inserted cells', () => { + setData( model, modelTable( [ + [ '00', '01', '02', '03' ], + [ { colspan: 4, contents: '10[]' } ] + ] ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', '01', '02', '03' ], + [ { colspan: 2, contents: '10[]' }, { colspan: 2, contents: '' } ] + ] ) ); + } ); + + it( 'should keep rowspan attribute for newly inserted cells', () => { + setData( model, modelTable( [ + [ '00', '01', '02', '03', '04', '05' ], + [ { colspan: 5, rowspan: 2, contents: '10[]' }, '15' ], + [ '25' ] + ] ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', '01', '02', '03', '04', '05' ], + [ { colspan: 3, rowspan: 2, contents: '10[]' }, { colspan: 2, rowspan: 2, contents: '' }, '15' ], + [ '25' ] + ] ) ); + } ); + } ); + } ); + + describe( 'direction=horizontally', () => { + beforeEach( () => { + command = new SplitCellCommand( editor, { direction: 'horizontally' } ); + } ); + + describe( 'isEnabled', () => { + it( 'should be true if in a table cell', () => { + setData( model, modelTable( [ + [ '00[]' ] + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false if not in cell', () => { + setData( model, '

11[]

' ); + + expect( command.isEnabled ).to.be.false; + } ); + } ); + + describe( 'execute()', () => { + it( 'should split table cell for two table cells', () => { + setData( model, modelTable( [ + [ '00', '01', '02' ], + [ '10', '[]11', '12' ], + [ '20', '21', '22' ] + ] ) ); + + command.execute(); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', '01', '02' ], + [ { rowspan: 2, contents: '10' }, '[]11', { rowspan: 2, contents: '12' } ], + [ '' ], + [ '20', '21', '22' ] + ] ) ); + } ); + } ); + } ); +} ); diff --git a/tests/commands/utils.js b/tests/commands/utils.js new file mode 100644 index 00000000..4fa16536 --- /dev/null +++ b/tests/commands/utils.js @@ -0,0 +1,80 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor'; +import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters'; + +import { downcastInsertTable } from '../../src/converters/downcast'; +import upcastTable from '../../src/converters/upcasttable'; +import { modelTable } from '../_utils/utils'; +import { getParentTable } from '../../src/commands/utils'; + +describe( 'commands utils', () => { + let editor, model; + + beforeEach( () => { + return ModelTestEditor.create() + .then( newEditor => { + editor = newEditor; + model = editor.model; + + const conversion = editor.conversion; + const schema = model.schema; + + schema.register( 'table', { + allowWhere: '$block', + allowAttributes: [ 'headingRows' ], + isObject: true + } ); + + schema.register( 'tableRow', { allowIn: 'table' } ); + + schema.register( 'tableCell', { + allowIn: 'tableRow', + allowContentOf: '$block', + allowAttributes: [ 'colspan', 'rowspan' ], + isLimit: true + } ); + + model.schema.register( 'p', { inheritAllFrom: '$block' } ); + + // Table conversion. + conversion.for( 'upcast' ).add( upcastTable() ); + conversion.for( 'downcast' ).add( downcastInsertTable() ); + + // Table row upcast only since downcast conversion is done in `downcastTable()`. + conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableRow', view: 'tr' } ) ); + + // Table cell conversion. + conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'td' } ) ); + conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'th' } ) ); + + conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } ); + conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } ); + } ); + } ); + + afterEach( () => { + return editor.destroy(); + } ); + + describe( 'getParentTable()', () => { + it( 'should return undefined if not in table', () => { + setData( model, '

foo[]

' ); + + expect( getParentTable( model.document.selection.focus ) ).to.be.undefined; + } ); + + it( 'should return table if position is in tableCell', () => { + setData( model, modelTable( [ [ '[]' ] ] ) ); + + const parentTable = getParentTable( model.document.selection.focus ); + + expect( parentTable ).to.not.be.undefined; + expect( parentTable.is( 'table' ) ).to.be.true; + } ); + } ); +} ); diff --git a/tests/converters/downcast.js b/tests/converters/downcast.js new file mode 100644 index 00000000..c4f2626c --- /dev/null +++ b/tests/converters/downcast.js @@ -0,0 +1,1226 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; +import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; + +import { + downcastInsertCell, + downcastInsertRow, + downcastInsertTable, + downcastRemoveRow, + downcastTableHeadingColumnsChange, + downcastTableHeadingRowsChange +} from '../../src/converters/downcast'; +import { formatTable, formattedViewTable, modelTable, viewTable } from '../_utils/utils'; + +describe( 'downcast converters', () => { + let editor, model, doc, root, viewDocument; + + beforeEach( () => { + return VirtualTestEditor.create() + .then( newEditor => { + editor = newEditor; + model = editor.model; + doc = model.document; + root = doc.getRoot( 'main' ); + viewDocument = editor.editing.view; + + const conversion = editor.conversion; + const schema = model.schema; + + schema.register( 'table', { + allowWhere: '$block', + allowAttributes: [ 'headingRows', 'headingColumns' ], + isObject: true + } ); + + schema.register( 'tableRow', { allowIn: 'table' } ); + + schema.register( 'tableCell', { + allowIn: 'tableRow', + allowContentOf: '$block', + allowAttributes: [ 'colspan', 'rowspan' ], + isLimit: true + } ); + + conversion.for( 'downcast' ).add( downcastInsertTable() ); + conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } ); + conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } ); + + // Insert conversion + conversion.for( 'downcast' ).add( downcastInsertRow() ); + conversion.for( 'downcast' ).add( downcastInsertCell() ); + + conversion.for( 'downcast' ).add( downcastRemoveRow() ); + + conversion.for( 'downcast' ).add( downcastTableHeadingRowsChange() ); + conversion.for( 'downcast' ).add( downcastTableHeadingColumnsChange() ); + } ); + } ); + + describe( 'downcastInsertTable()', () => { + it( 'should create table with tbody', () => { + setModelData( model, + '' + + '' + + '
' + ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( + '' + + '' + + '' + + '' + + '
' + ); + } ); + + it( 'should create table with tbody and thead', () => { + setModelData( model, + '' + + '1' + + '2' + + '
' + ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
1
2
' + ); + } ); + + it( 'should create table with thead', () => { + setModelData( model, + '' + + '1' + + '2' + + '
' + ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( + '' + + '' + + '' + + '' + + '' + + '
1
2
' + ); + } ); + + it( 'should create table with heading columns and rows', () => { + setModelData( model, + '' + + '' + + '11121314' + + '' + + '' + + '21222324' + + '' + + '
' + ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
11121314
21222324
' + ); + } ); + + it( 'should be possible to overwrite', () => { + editor.conversion.elementToElement( { model: 'tableRow', view: 'tr', converterPriority: 'high' } ); + editor.conversion.elementToElement( { model: 'tableCell', view: 'td', converterPriority: 'high' } ); + editor.conversion.for( 'downcast' ).add( dispatcher => { + dispatcher.on( 'insert:table', ( evt, data, conversionApi ) => { + conversionApi.consumable.consume( data.item, 'insert' ); + + const tableElement = conversionApi.writer.createContainerElement( 'table', { foo: 'bar' } ); + const viewPosition = conversionApi.mapper.toViewPosition( data.range.start ); + + conversionApi.mapper.bindElements( data.item, tableElement ); + conversionApi.writer.insert( viewPosition, tableElement ); + }, { priority: 'high' } ); + } ); + + setModelData( model, + '' + + '' + + '
' + ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( + '' + + '' + + '
' + ); + } ); + + describe( 'headingColumns attribute', () => { + it( 'should mark heading columns table cells', () => { + setModelData( model, + '' + + '111213' + + '212223' + + '
' + ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( + '' + + '' + + '' + + '' + + '' + + '
111213
212223
' + ); + } ); + + it( 'should mark heading columns table cells when one has colspan attribute', () => { + setModelData( model, + '' + + '' + + '11121314' + + '' + + '212324' + + '
' + ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( + '' + + '' + + '' + + '' + + '' + + '
11121314
212324
' + ); + } ); + + it( 'should work with colspan and rowspan attributes on table cells', () => { + // The table in this test looks like a table below: + // + // Row headings | Normal cells + // | + // +----+----+----+----+ + // | 11 | 12 | 13 | 14 | + // | +----+ +----+ + // | | 22 | | 24 | + // |----+----+ +----+ + // | 31 | | 34 | + // | +----+----+ + // | | 43 | 44 | + // +----+----+----+----+ + + setModelData( model, + '' + + '' + + '11' + + '12' + + '13' + + '14' + + '' + + '2224' + + '3134' + + '4344' + + '
' + ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
11121314
2224
3134
4344
' + ); + } ); + } ); + + describe( 'asWidget', () => { + beforeEach( () => { + return VirtualTestEditor.create() + .then( newEditor => { + editor = newEditor; + model = editor.model; + doc = model.document; + root = doc.getRoot( 'main' ); + viewDocument = editor.editing.view; + + const conversion = editor.conversion; + const schema = model.schema; + + schema.register( 'table', { + allowWhere: '$block', + allowAttributes: [ 'headingRows', 'headingColumns' ], + isObject: true + } ); + + schema.register( 'tableRow', { allowIn: 'table' } ); + + schema.register( 'tableCell', { + allowIn: 'tableRow', + allowContentOf: '$block', + allowAttributes: [ 'colspan', 'rowspan' ], + isLimit: true + } ); + + conversion.for( 'downcast' ).add( downcastInsertTable( { asWidget: true } ) ); + conversion.for( 'downcast' ).add( downcastInsertRow( { asWidget: true } ) ); + conversion.for( 'downcast' ).add( downcastInsertCell( { asWidget: true } ) ); + + conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } ); + conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } ); + } ); + } ); + + it( 'should create table as a widget', () => { + setModelData( model, + '' + + '' + + '
' + ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( + '' + + '' + + '' + + '' + + '
' + ); + } ); + } ); + } ); + + describe( 'downcastInsertRow()', () => { + it( 'should react to changed rows', () => { + setModelData( model, modelTable( [ + [ '11', '12' ] + ] ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + const row = writer.createElement( 'tableRow' ); + + writer.insert( row, table, 1 ); + + writer.insertElement( 'tableCell', row, 'end' ); + writer.insertElement( 'tableCell', row, 'end' ); + } ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [ + [ '11', '12' ], + [ '', '' ] + ] ) ); + } ); + + it( 'should properly consume already added rows', () => { + setModelData( model, modelTable( [ + [ '11', '12' ] + ] ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + const row = writer.createElement( 'tableRow' ); + + writer.insert( row, table, 1 ); + + writer.insertElement( 'tableCell', row, 'end' ); + writer.insertElement( 'tableCell', row, 'end' ); + } ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [ + [ '11', '12' ], + [ '', '' ] + ] ) ); + + model.change( writer => { + const row = writer.createElement( 'tableRow' ); + + writer.insert( row, table, 2 ); + + writer.insertElement( 'tableCell', row, 'end' ); + writer.insertElement( 'tableCell', row, 'end' ); + } ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [ + [ '11', '12' ], + [ '', '' ], + [ '', '' ] + ] ) ); + } ); + + it( 'should insert row on proper index', () => { + setModelData( model, modelTable( [ + [ '11', '12' ], + [ '21', '22' ], + [ '31', '32' ] + ] ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + const row = writer.createElement( 'tableRow' ); + + writer.insert( row, table, 1 ); + + writer.insertElement( 'tableCell', row, 'end' ); + writer.insertElement( 'tableCell', row, 'end' ); + } ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [ + [ '11', '12' ], + [ '', '' ], + [ '21', '22' ], + [ '31', '32' ] + ] ) ); + } ); + + it( 'should insert row on proper index when table has heading rows defined - insert in body', () => { + setModelData( model, modelTable( [ + [ '11', '12' ], + [ '21', '22' ], + [ '31', '32' ] + ], { headingRows: 1 } ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + const row = writer.createElement( 'tableRow' ); + + writer.insert( row, table, 1 ); + + writer.insertElement( 'tableCell', row, 'end' ); + writer.insertElement( 'tableCell', row, 'end' ); + } ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [ + [ '11', '12' ], + [ '', '' ], + [ '21', '22' ], + [ '31', '32' ] + ], { headingRows: 1 } ) ); + } ); + + it( 'should insert row on proper index when table has heading rows defined - insert in heading', () => { + setModelData( model, modelTable( [ + [ '11', '12' ], + [ '21', '22' ], + [ '31', '32' ] + ], { headingRows: 2 } ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + const row = writer.createElement( 'tableRow' ); + + writer.insert( row, table, 1 ); + + writer.insertElement( 'tableCell', row, 'end' ); + writer.insertElement( 'tableCell', row, 'end' ); + } ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [ + [ '11', '12' ], + [ '', '' ], + [ '21', '22' ], + [ '31', '32' ] + ], { headingRows: 3 } ) ); + } ); + + it( 'should react to changed rows when previous rows\' cells has rowspans', () => { + setModelData( model, modelTable( [ + [ { rowspan: 3, contents: '11' }, '12' ], + [ '22' ] + ] ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + const row = writer.createElement( 'tableRow' ); + + writer.insert( row, table, 2 ); + writer.insertElement( 'tableCell', row, 'end' ); + } ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [ + [ { rowspan: 3, contents: '11' }, '12' ], + [ '22' ], + [ '' ] + ] ) ); + } ); + + it( 'should properly create row headings', () => { + setModelData( model, modelTable( [ + [ { rowspan: 3, contents: '11' }, '12' ], + [ '22' ] + ], { headingColumns: 1 } ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + const firstRow = writer.createElement( 'tableRow' ); + + writer.insert( firstRow, table, 2 ); + writer.insert( writer.createElement( 'tableCell' ), firstRow, 'end' ); + + const secondRow = writer.createElement( 'tableRow' ); + + writer.insert( secondRow, table, 3 ); + writer.insert( writer.createElement( 'tableCell' ), secondRow, 'end' ); + writer.insert( writer.createElement( 'tableCell' ), secondRow, 'end' ); + } ); + + expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [ + [ { rowspan: 3, contents: '11', isHeading: true }, '12' ], + [ '22' ], + [ '' ], + [ { contents: '', isHeading: true }, '' ] + ] ) ); + } ); + + describe( 'asWidget', () => { + beforeEach( () => { + return VirtualTestEditor.create() + .then( newEditor => { + editor = newEditor; + model = editor.model; + doc = model.document; + root = doc.getRoot( 'main' ); + viewDocument = editor.editing.view; + + const conversion = editor.conversion; + const schema = model.schema; + + schema.register( 'table', { + allowWhere: '$block', + allowAttributes: [ 'headingRows', 'headingColumns' ], + isObject: true + } ); + + schema.register( 'tableRow', { allowIn: 'table' } ); + + schema.register( 'tableCell', { + allowIn: 'tableRow', + allowContentOf: '$block', + allowAttributes: [ 'colspan', 'rowspan' ], + isLimit: true + } ); + + conversion.for( 'downcast' ).add( downcastInsertTable( { asWidget: true } ) ); + conversion.for( 'downcast' ).add( downcastInsertRow( { asWidget: true } ) ); + conversion.for( 'downcast' ).add( downcastInsertCell( { asWidget: true } ) ); + + conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } ); + conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } ); + } ); + } ); + + it( 'should create table cell inside inserted row as a widget', () => { + setModelData( model, + '' + + 'foo' + + '
' + ); + + const table = root.getChild( 0 ); + + model.change( writer => { + const firstRow = writer.createElement( 'tableRow' ); + + writer.insert( firstRow, table, 1 ); + writer.insert( writer.createElement( 'tableCell' ), firstRow, 'end' ); + } ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( + '' + + '' + + '' + + '' + + '' + + '
foo
' + ); + } ); + } ); + } ); + + describe( 'downcastInsertCell()', () => { + it( 'should add tableCell on proper index in tr', () => { + setModelData( model, modelTable( [ + [ '11', '12' ] + ] ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + const row = table.getChild( 0 ); + + writer.insertElement( 'tableCell', row, 1 ); + } ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [ + [ '11', '', '12' ] + ] ) ); + } ); + + it( 'should add tableCell on proper index in tr when previous have colspans', () => { + setModelData( model, modelTable( [ + [ { colspan: 2, contents: '11' }, '13' ] + ] ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + const row = table.getChild( 0 ); + + writer.insertElement( 'tableCell', row, 1 ); + } ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [ + [ { colspan: 2, contents: '11' }, '', '13' ] + ] ) ); + } ); + + it( 'should add tableCell on proper index in tr when previous row have rowspans', () => { + setModelData( model, modelTable( [ + [ { rowspan: 2, contents: '11' }, '13' ], + [ '22', '23' ] + ] ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + writer.insertElement( 'tableCell', table.getChild( 0 ), 1 ); + writer.insertElement( 'tableCell', table.getChild( 1 ), 0 ); + } ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [ + [ { rowspan: 2, contents: '11' }, '', '13' ], + [ '', '22', '23' ] + ] ) ); + } ); + + it( 'split cell simulation - simple', () => { + setModelData( model, modelTable( [ + [ '11', '12' ], + [ '21', '22' ] + ] ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + const firstRow = table.getChild( 0 ); + const secondRow = table.getChild( 1 ); + + writer.insertElement( 'tableCell', firstRow, 1 ); + writer.setAttribute( 'colspan', 2, secondRow.getChild( 0 ) ); + } ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [ + [ '11', '', '12' ], + [ { colspan: 2, contents: '21' }, '22' ] + ] ) ); + } ); + + it( 'merge simulation - simple', () => { + setModelData( model, modelTable( [ + [ '11', '12' ], + [ '21', '22' ] + ] ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + const firstRow = table.getChild( 0 ); + + writer.setAttribute( 'colspan', 2, firstRow.getChild( 0 ) ); + writer.remove( firstRow.getChild( 1 ) ); + } ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [ + [ { colspan: 2, contents: '11' } ], + [ '21', '22' ] + ] ) ); + } ); + + describe( 'asWidget', () => { + beforeEach( () => { + return VirtualTestEditor.create() + .then( newEditor => { + editor = newEditor; + model = editor.model; + doc = model.document; + root = doc.getRoot( 'main' ); + viewDocument = editor.editing.view; + + const conversion = editor.conversion; + const schema = model.schema; + + schema.register( 'table', { + allowWhere: '$block', + allowAttributes: [ 'headingRows', 'headingColumns' ], + isObject: true + } ); + + schema.register( 'tableRow', { allowIn: 'table' } ); + + schema.register( 'tableCell', { + allowIn: 'tableRow', + allowContentOf: '$block', + allowAttributes: [ 'colspan', 'rowspan' ], + isLimit: true + } ); + + conversion.for( 'downcast' ).add( downcastInsertTable( { asWidget: true } ) ); + conversion.for( 'downcast' ).add( downcastInsertRow( { asWidget: true } ) ); + conversion.for( 'downcast' ).add( downcastInsertCell( { asWidget: true } ) ); + + conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } ); + conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } ); + } ); + } ); + + it( 'should create inserted table cell as a widget', () => { + setModelData( model, + '' + + 'foo' + + '
' + ); + + const table = root.getChild( 0 ); + + model.change( writer => { + const row = table.getChild( 0 ); + + writer.insert( writer.createElement( 'tableCell' ), row, 'end' ); + } ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
foo
' + ); + } ); + } ); + } ); + + describe( 'downcastTableHeadingColumnsChange()', () => { + it( 'should work for adding heading columns', () => { + setModelData( model, modelTable( [ + [ '11', '12' ], + [ '21', '22' ] + ] ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + writer.setAttribute( 'headingColumns', 1, table ); + } ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [ + [ { isHeading: true, contents: '11' }, '12' ], + [ { isHeading: true, contents: '21' }, '22' ] + ] ) ); + } ); + + it( 'should work for changing heading columns to a bigger number', () => { + setModelData( model, modelTable( [ + [ '11', '12', '13', '14' ], + [ '21', '22', '23', '24' ] + ], { headingColumns: 1 } ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + writer.setAttribute( 'headingColumns', 3, table ); + } ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [ + [ { isHeading: true, contents: '11' }, { isHeading: true, contents: '12' }, { isHeading: true, contents: '13' }, '14' ], + [ { isHeading: true, contents: '21' }, { isHeading: true, contents: '22' }, { isHeading: true, contents: '23' }, '24' ] + ] ) ); + } ); + + it( 'should work for changing heading columns to a smaller number', () => { + setModelData( model, modelTable( [ + [ { isHeading: true, contents: '11' }, { isHeading: true, contents: '12' }, { isHeading: true, contents: '13' }, '14' ], + [ { isHeading: true, contents: '21' }, { isHeading: true, contents: '22' }, { isHeading: true, contents: '23' }, '24' ] + ], { headingColumns: 3 } ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + writer.setAttribute( 'headingColumns', 1, table ); + } ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [ + [ { isHeading: true, contents: '11' }, '12', '13', '14' ], + [ { isHeading: true, contents: '21' }, '22', '23', '24' ] + ], { headingColumns: 3 } ) ); + } ); + + it( 'should work for removing heading columns', () => { + setModelData( model, modelTable( [ + [ '11', '12' ], + [ '21', '22' ] + ], { headingColumns: 1 } ) ); + const table = root.getChild( 0 ); + + model.change( writer => { + writer.removeAttribute( 'headingColumns', table ); + } ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [ + [ '11', '12' ], + [ '21', '22' ] + ] ) ); + } ); + + it( 'should be possible to overwrite', () => { + editor.conversion.attributeToAttribute( { model: 'headingColumns', view: 'headingColumns', converterPriority: 'high' } ); + setModelData( model, modelTable( [ [ '11' ] ] ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + writer.setAttribute( 'headingColumns', 1, table ); + } ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( + '
11
' + ); + } ); + + it( 'should work with adding table cells', () => { + setModelData( model, modelTable( [ + [ { rowspan: 2, contents: '11' }, '12', '13', '14' ], + [ '22', '23', '24' ], + [ { colspan: 2, contents: '31' }, '33', '34' ] + ], { headingColumns: 2 } ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + // Inserting column in heading columns so update table's attribute also + writer.setAttribute( 'headingColumns', 3, table ); + + writer.insertElement( 'tableCell', table.getChild( 0 ), 2 ); + writer.insertElement( 'tableCell', table.getChild( 1 ), 1 ); + writer.insertElement( 'tableCell', table.getChild( 2 ), 1 ); + } ); + + expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [ + [ + { isHeading: true, rowspan: 2, contents: '11' }, + { isHeading: true, contents: '12' }, + { isHeading: true, contents: '' }, + '13', + '14' + ], + [ + { isHeading: true, contents: '22' }, + { isHeading: true, contents: '' }, + '23', + '24' + ], + [ + { isHeading: true, colspan: 2, contents: '31' }, + { isHeading: true, contents: '' }, + '33', + '34' + ] + ] ) ); + } ); + + describe( 'asWidget', () => { + beforeEach( () => { + return VirtualTestEditor.create() + .then( newEditor => { + editor = newEditor; + model = editor.model; + doc = model.document; + root = doc.getRoot( 'main' ); + viewDocument = editor.editing.view; + + const conversion = editor.conversion; + const schema = model.schema; + + schema.register( 'table', { + allowWhere: '$block', + allowAttributes: [ 'headingRows', 'headingColumns' ], + isObject: true + } ); + + schema.register( 'tableRow', { allowIn: 'table' } ); + + schema.register( 'tableCell', { + allowIn: 'tableRow', + allowContentOf: '$block', + allowAttributes: [ 'colspan', 'rowspan' ], + isLimit: true + } ); + + conversion.for( 'downcast' ).add( downcastInsertTable( { asWidget: true } ) ); + conversion.for( 'downcast' ).add( downcastInsertRow( { asWidget: true } ) ); + conversion.for( 'downcast' ).add( downcastInsertCell( { asWidget: true } ) ); + + conversion.for( 'downcast' ).add( downcastTableHeadingColumnsChange( { asWidget: true } ) ); + conversion.for( 'downcast' ).add( downcastTableHeadingRowsChange( { asWidget: true } ) ); + + conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } ); + conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } ); + } ); + } ); + + it( 'should create renamed cell as a widget', () => { + setModelData( model, + '' + + 'foo' + + '
' + ); + + const table = root.getChild( 0 ); + + model.change( writer => { + writer.setAttribute( 'headingRows', 1, table ); + } ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( + '' + + '' + + '' + + '' + + '
foo
' + ); + } ); + } ); + } ); + + describe( 'downcastTableHeadingRowsChange()', () => { + it( 'should work for adding heading rows', () => { + setModelData( model, modelTable( [ + [ '11', '12' ], + [ '21', '22' ], + [ '31', '32' ] + ] ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + writer.setAttribute( 'headingRows', 2, table ); + } ); + + expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [ + [ '11', '12' ], + [ '21', '22' ], + [ '31', '32' ] + ], { headingRows: 2 } ) ); + } ); + + it( 'should work for changing number of heading rows to a bigger number', () => { + setModelData( model, modelTable( [ + [ '11', '12' ], + [ '21', '22' ], + [ '31', '32' ] + ], { headingRows: 1 } ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + writer.setAttribute( 'headingRows', 2, table ); + } ); + + expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [ + [ '11', '12' ], + [ '21', '22' ], + [ '31', '32' ] + ], { headingRows: 2 } ) ); + } ); + + it( 'should work for changing number of heading rows to a smaller number', () => { + setModelData( model, modelTable( [ + [ '11', '12' ], + [ '21', '22' ], + [ '31', '32' ], + [ '41', '42' ] + ], { headingRows: 3 } ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + writer.setAttribute( 'headingRows', 2, table ); + } ); + + expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [ + [ '11', '12' ], + [ '21', '22' ], + [ '31', '32' ], + [ '41', '42' ] + ], { headingRows: 2 } ) ); + } ); + + it( 'should work for removing heading rows', () => { + setModelData( model, modelTable( [ + [ '11', '12' ], + [ '21', '22' ] + ], { headingRows: 2 } ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + writer.removeAttribute( 'headingRows', table ); + } ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [ + [ '11', '12' ], + [ '21', '22' ] + ] ) ); + } ); + + it( 'should work for making heading rows without tbody', () => { + setModelData( model, modelTable( [ + [ '11', '12' ], + [ '21', '22' ] + ] ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + writer.setAttribute( 'headingRows', 2, table ); + } ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [ + [ '11', '12' ], + [ '21', '22' ] + ], { headingRows: 2 } ) ); + } ); + + it( 'should be possible to overwrite', () => { + editor.conversion.attributeToAttribute( { model: 'headingRows', view: 'headingRows', converterPriority: 'high' } ); + setModelData( model, modelTable( [ [ '11' ] ] ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + writer.setAttribute( 'headingRows', 1, table ); + } ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( + '
11
' + ); + } ); + + it( 'should work with adding table rows at the beginning of a table', () => { + setModelData( model, modelTable( [ + [ '00', '01' ], + [ '10', '11' ] + ], { headingRows: 1 } ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + writer.setAttribute( 'headingRows', 2, table ); + + const tableRow = writer.createElement( 'tableRow' ); + + writer.insert( tableRow, table, 0 ); + writer.insertElement( 'tableCell', tableRow, 'end' ); + writer.insertElement( 'tableCell', tableRow, 'end' ); + } ); + + expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [ + [ '', '' ], + [ '00', '01' ], + [ '10', '11' ] + ], { headingRows: 2 } ) ); + } ); + + describe( 'asWidget', () => { + beforeEach( () => { + return VirtualTestEditor.create() + .then( newEditor => { + editor = newEditor; + model = editor.model; + doc = model.document; + root = doc.getRoot( 'main' ); + viewDocument = editor.editing.view; + + const conversion = editor.conversion; + const schema = model.schema; + + schema.register( 'table', { + allowWhere: '$block', + allowAttributes: [ 'headingRows', 'headingColumns' ], + isObject: true + } ); + + schema.register( 'tableRow', { allowIn: 'table' } ); + + schema.register( 'tableCell', { + allowIn: 'tableRow', + allowContentOf: '$block', + allowAttributes: [ 'colspan', 'rowspan' ], + isLimit: true + } ); + + conversion.for( 'downcast' ).add( downcastInsertTable( { asWidget: true } ) ); + conversion.for( 'downcast' ).add( downcastInsertRow( { asWidget: true } ) ); + conversion.for( 'downcast' ).add( downcastInsertCell( { asWidget: true } ) ); + + conversion.for( 'downcast' ).add( downcastTableHeadingColumnsChange( { asWidget: true } ) ); + conversion.for( 'downcast' ).add( downcastTableHeadingRowsChange( { asWidget: true } ) ); + + conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } ); + conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } ); + } ); + } ); + + it( 'should create renamed cell as a widget', () => { + setModelData( model, + '' + + 'foo' + + '
' + ); + + const table = root.getChild( 0 ); + + model.change( writer => { + writer.setAttribute( 'headingColumns', 1, table ); + } ); + + expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( + '' + + '' + + '' + + '' + + '
foo
' + ); + } ); + } ); + } ); + + describe( 'downcastRemoveRow()', () => { + it( 'should react to removed row from the beginning of a tbody', () => { + setModelData( model, modelTable( [ + [ '00[]', '01' ], + [ '10', '11' ] + ] ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + writer.remove( table.getChild( 1 ) ); + } ); + + expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [ + [ '00', '01' ] + ] ) ); + } ); + + it( 'should react to removed row form the end of a tbody', () => { + setModelData( model, modelTable( [ + [ '00[]', '01' ], + [ '10', '11' ] + ] ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + writer.remove( table.getChild( 0 ) ); + } ); + + expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [ + [ '10', '11' ] + ] ) ); + } ); + + it( 'should react to removed row from the beginning of a thead', () => { + setModelData( model, modelTable( [ + [ '00[]', '01' ], + [ '10', '11' ] + ], { headingRows: 2 } ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + writer.remove( table.getChild( 1 ) ); + } ); + + expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [ + [ '00', '01' ] + ], { headingRows: 2 } ) ); + } ); + + it( 'should react to removed row form the end of a thead', () => { + setModelData( model, modelTable( [ + [ '00[]', '01' ], + [ '10', '11' ] + ], { headingRows: 2 } ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + writer.remove( table.getChild( 0 ) ); + } ); + + expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [ + [ '10', '11' ] + ], { headingRows: 2 } ) ); + } ); + + it( 'should remove empty thead section if a last row was removed from thead', () => { + setModelData( model, modelTable( [ + [ '00[]', '01' ], + [ '10', '11' ] + ], { headingRows: 1 } ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + writer.setAttribute( 'headingRows', 0, table ); + writer.remove( table.getChild( 0 ) ); + } ); + + expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [ + [ '10', '11' ] + ] ) ); + } ); + + it( 'should remove empty tbody section if a last row was removed from tbody', () => { + setModelData( model, modelTable( [ + [ '00[]', '01' ], + [ '10', '11' ] + ], { headingRows: 1 } ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + writer.remove( table.getChild( 1 ) ); + } ); + + expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [ + [ '00', '01' ] + ], { headingRows: 1 } ) ); + } ); + } ); +} ); diff --git a/tests/converters/upcasttable.js b/tests/converters/upcasttable.js new file mode 100644 index 00000000..39b5c5ed --- /dev/null +++ b/tests/converters/upcasttable.js @@ -0,0 +1,309 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters'; +import { getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; + +import upcastTable from '../../src/converters/upcasttable'; + +describe( 'upcastTable()', () => { + let editor, model; + + beforeEach( () => { + return VirtualTestEditor.create() + .then( newEditor => { + editor = newEditor; + model = editor.model; + + const conversion = editor.conversion; + const schema = model.schema; + + schema.register( 'table', { + allowWhere: '$block', + allowAttributes: [ 'headingRows', 'headingColumns' ], + isObject: true + } ); + + schema.register( 'tableRow', { allowIn: 'table' } ); + + schema.register( 'tableCell', { + allowIn: 'tableRow', + allowContentOf: '$block', + allowAttributes: [ 'colspan', 'rowspan' ], + isLimit: true + } ); + + conversion.for( 'upcast' ).add( upcastTable() ); + + // Table row upcast only since downcast conversion is done in `downcastTable()`. + conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableRow', view: 'tr' } ) ); + + // Table cell conversion. + conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'td' } ) ); + conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'th' } ) ); + + conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } ); + conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } ); + + // Since this part of test tests only view->model conversion editing pipeline is not necessary + // so defining model->view converters won't be necessary. + editor.editing.destroy(); + } ); + } ); + + function expectModel( data ) { + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( data ); + } + + it( 'should create table model from table without thead', () => { + editor.setData( + '' + + '' + + '
1
' + ); + + expectModel( + '' + + '1' + + '
' + ); + } ); + + it( 'should create table model from table with one thead with one row', () => { + editor.setData( + '' + + '' + + '
1
' + ); + + expectModel( + '' + + '1' + + '
' + ); + } ); + + it( 'should create table model from table with one thead with more then on row', () => { + editor.setData( + '' + + '' + + '' + + '' + + '' + + '' + + '
1
2
3
' + ); + + expectModel( + '' + + '1' + + '2' + + '3' + + '
' + ); + } ); + + it( 'should create table model from table with two theads with one row', () => { + editor.setData( + '' + + '' + + '' + + '' + + '
1
2
3
' + ); + + expectModel( + '' + + '1' + + '2' + + '3' + + '
' + ); + } ); + + it( 'should create table model from table with thead after the tbody', () => { + editor.setData( + '' + + '' + + '' + + '
2
1
' + ); + + expectModel( + '' + + '1' + + '2' + + '
' + ); + } ); + + it( 'should create table model from table with one tfoot with one row', () => { + editor.setData( + '' + + '' + + '
1
' + ); + + expectModel( + '' + + '1' + + '
' + ); + } ); + + it( 'should create valid table model from empty table', () => { + editor.setData( + '' + + '
' + ); + + expectModel( + '
' + ); + } ); + + it( 'should skip unknown table children', () => { + editor.setData( + '' + + '' + + '' + + '
foo
bar
' + ); + + expectModel( + 'bar
' + ); + } ); + + it( 'should create table model from some broken table', () => { + editor.setData( + '

foo

' + ); + + expectModel( + 'foo
' + ); + } ); + + it( 'should fix if inside other blocks', () => { + // Using
instead of

as it breaks on Edge. + editor.model.schema.register( 'div', { + inheritAllFrom: '$block' + } ); + editor.conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'div', view: 'div' } ) ); + + editor.setData( + '

foo' + + '' + + '' + + '' + + '
2
1
' + + 'bar
' + ); + + expectModel( + '
foo
' + + '' + + '1' + + '2' + + '
' + + '
bar
' + ); + } ); + + it( 'should be possible to overwrite table conversion', () => { + editor.model.schema.register( 'fooTable', { + allowWhere: '$block', + allowAttributes: [ 'headingRows' ], + isObject: true + } ); + + editor.conversion.elementToElement( { model: 'fooTable', view: 'table', converterPriority: 'high' } ); + + editor.setData( + '' + + '' + + '
foo
' + ); + + expectModel( + '' + ); + } ); + + describe( 'headingColumns', () => { + it( 'should properly calculate heading columns', () => { + editor.setData( + '' + + '' + + // This row starts with 1 th (3 total). + '' + + // This row starts with 2 th (2 total). This one has max number of heading columns: 2. + '' + + // This row starts with 1 th (1 total). + '' + + // This row starts with 0 th (3 total). + '' + + '' + + '' + + // This row has 4 ths but it is a thead. + '' + + '' + + '
21222324
31323334
41424344
51525354
11121314
' + ); + + expectModel( + '' + + '' + + '11121314' + + '' + + '' + + '21222324' + + '' + + '' + + '31323334' + + '' + + '' + + '41424344' + + '' + + '' + + '51525354' + + '' + + '
' + ); + } ); + + it( 'should calculate heading columns of cells with colspan', () => { + editor.setData( + '' + + '' + + // This row has colspan of 3 so it should be the whole table should have 3 heading columns. + '' + + '' + + '' + + '' + + // This row has 4 ths but it is a thead. + '' + + '' + + '
21222324
313334
11121314
' + ); + + expectModel( + '' + + '' + + '11121314' + + '' + + '' + + '21222324' + + '' + + '' + + '313334' + + '' + + '
' + ); + } ); + } ); +} ); diff --git a/tests/manual/table.html b/tests/manual/table.html new file mode 100644 index 00000000..ef126a9d --- /dev/null +++ b/tests/manual/table.html @@ -0,0 +1,217 @@ + + +
+

Table with everything:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Data about the planets of our solar system (Planetary facts taken from Nasa's Planetary Fact Sheet - Metric.
 NameMass (1024kg)Diameter (km)Density (kg/m3)Gravity (m/s2)Length of day (hours)Distance from Sun (106km)Mean temperature (°C)Number of moonsNotes
Terrestial planetsMercury0.3304,87954273.74222.657.91670Closest to the Sun
Venus4.8712,10452438.92802.0108.24640 
Earth5.9712,75655149.824.0149.6151Our world
Mars0.6426,79239333.724.7227.9-652The red planet
Jovian planetsGas giantsJupiter1898142,984132623.19.9778.6-11067The largest planet
Saturn568120,5366879.010.71433.5-14062 
Ice giantsUranus86.851,11812718.717.22872.5-19527 
Neptune10249,528163811.016.14495.1-20014 
Dwarf planetsPluto0.01462,37020950.7153.35906.4-2255Declassified as a planet in 2006, but this remains controversial.
+ + +

Table with 2 tbody:

+ + + + + + + + + + + + + + + +
abc
abc
+ +

Table with no tbody:

+ + + + + + + + + + + +
abc
abc
+ +

Table with thead section between two tbody sections

+ + + + + + + + + + + + + + + + +
2
1
3
+
diff --git a/tests/manual/table.js b/tests/manual/table.js new file mode 100644 index 00000000..d2d72728 --- /dev/null +++ b/tests/manual/table.js @@ -0,0 +1,25 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* globals console, window, document */ + +import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'; +import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset'; +import Table from '../../src/table'; + +ClassicEditor + .create( document.querySelector( '#editor' ), { + plugins: [ ArticlePluginSet, Table ], + toolbar: [ + 'heading', '|', 'insertTable', 'insertRowBelow', 'insertColumnAfter', + '|', 'bold', 'italic', 'bulletedList', 'numberedList', 'blockQuote', 'undo', 'redo' + ] + } ) + .then( editor => { + window.editor = editor; + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/tests/manual/table.md b/tests/manual/table.md new file mode 100644 index 00000000..84a3f322 --- /dev/null +++ b/tests/manual/table.md @@ -0,0 +1,3 @@ +### Loading + +### Testing diff --git a/tests/table.js b/tests/table.js new file mode 100644 index 00000000..8054007f --- /dev/null +++ b/tests/table.js @@ -0,0 +1,19 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import Table from '../src/table'; +import TableEditing from '../src/tableediting'; +import TableUI from '../src/tableui'; +import Widget from '@ckeditor/ckeditor5-widget/src/widget'; + +describe( 'Table', () => { + it( 'requires TableEditing, TableUI and Widget', () => { + expect( Table.requires ).to.deep.equal( [ TableEditing, TableUI, Widget ] ); + } ); + + it( 'has proper name', () => { + expect( Table.pluginName ).to.equal( 'Table' ); + } ); +} ); diff --git a/tests/tableediting.js b/tests/tableediting.js new file mode 100644 index 00000000..75204d22 --- /dev/null +++ b/tests/tableediting.js @@ -0,0 +1,342 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +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 { getCode } from '@ckeditor/ckeditor5-utils/src/keyboard'; + +import TableEditing from '../src/tableediting'; +import { formatTable, formattedModelTable, modelTable } from './_utils/utils'; +import InsertRowCommand from '../src/commands/insertrowcommand'; +import InsertTableCommand from '../src/commands/inserttablecommand'; +import InsertColumnCommand from '../src/commands/insertcolumncommand'; +import RemoveRowCommand from '../src/commands/removerowcommand'; +import RemoveColumnCommand from '../src/commands/removecolumncommand'; +import SplitCellCommand from '../src/commands/splitcellcommand'; +import MergeCellCommand from '../src/commands/mergecellcommand'; +import SetTableHeadersCommand from '../src/commands/settableheaderscommand'; + +describe( 'TableEditing', () => { + let editor, model; + + beforeEach( () => { + return VirtualTestEditor + .create( { + plugins: [ TableEditing, Paragraph ] + } ) + .then( newEditor => { + editor = newEditor; + + model = editor.model; + } ); + } ); + + afterEach( () => { + editor.destroy(); + } ); + + it( 'should set proper schema rules', () => { + } ); + + it( 'adds insertTable command', () => { + expect( editor.commands.get( 'insertTable' ) ).to.be.instanceOf( InsertTableCommand ); + } ); + + it( 'adds insertRowAbove command', () => { + expect( editor.commands.get( 'insertRowAbove' ) ).to.be.instanceOf( InsertRowCommand ); + } ); + + it( 'adds insertRowBelow command', () => { + expect( editor.commands.get( 'insertRowBelow' ) ).to.be.instanceOf( InsertRowCommand ); + } ); + + it( 'adds insertColumnBefore command', () => { + expect( editor.commands.get( 'insertColumnBefore' ) ).to.be.instanceOf( InsertColumnCommand ); + } ); + + it( 'adds insertColumnAfter command', () => { + expect( editor.commands.get( 'insertColumnAfter' ) ).to.be.instanceOf( InsertColumnCommand ); + } ); + + it( 'adds removeRow command', () => { + expect( editor.commands.get( 'removeRow' ) ).to.be.instanceOf( RemoveRowCommand ); + } ); + + it( 'adds removeColumn command', () => { + expect( editor.commands.get( 'removeColumn' ) ).to.be.instanceOf( RemoveColumnCommand ); + } ); + + it( 'adds splitCellVertically command', () => { + expect( editor.commands.get( 'splitCellVertically' ) ).to.be.instanceOf( SplitCellCommand ); + } ); + + it( 'adds splitCellHorizontally command', () => { + expect( editor.commands.get( 'splitCellHorizontally' ) ).to.be.instanceOf( SplitCellCommand ); + } ); + + it( 'adds mergeCellRight command', () => { + expect( editor.commands.get( 'mergeCellRight' ) ).to.be.instanceOf( MergeCellCommand ); + } ); + + it( 'adds mergeCellLeft command', () => { + expect( editor.commands.get( 'mergeCellLeft' ) ).to.be.instanceOf( MergeCellCommand ); + } ); + + it( 'adds mergeCellDown command', () => { + expect( editor.commands.get( 'mergeCellDown' ) ).to.be.instanceOf( MergeCellCommand ); + } ); + + it( 'adds mergeCellUp command', () => { + expect( editor.commands.get( 'mergeCellUp' ) ).to.be.instanceOf( MergeCellCommand ); + } ); + + it( 'adds setTableHeaders command', () => { + expect( editor.commands.get( 'setTableHeaders' ) ).to.be.instanceOf( SetTableHeadersCommand ); + } ); + + describe( 'conversion in data pipeline', () => { + describe( 'model to view', () => { + it( 'should create tbody section', () => { + setModelData( model, 'foo[]
' ); + + expect( editor.getData() ).to.equal( + '' + + '' + + '' + + '' + + '
foo
' + ); + } ); + + it( 'should create thead section', () => { + setModelData( model, 'foo[]
' ); + + expect( editor.getData() ).to.equal( + '' + + '' + + '' + + '' + + '
foo
' + ); + } ); + } ); + + describe( 'view to model', () => { + it( 'should convert table', () => { + editor.setData( '
foo
' ); + + expect( getModelData( model, { withoutSelection: true } ) ) + .to.equal( 'foo
' ); + } ); + } ); + } ); + + describe( 'caret movement', () => { + let domEvtDataStub; + + beforeEach( () => { + domEvtDataStub = { + keyCode: getCode( 'Tab' ), + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + } ); + + it( 'should do nothing if not tab pressed', () => { + setModelData( model, modelTable( [ + [ '11', '12[]' ] + ] ) ); + + domEvtDataStub.keyCode = getCode( 'a' ); + + editor.editing.view.document.fire( 'keydown', domEvtDataStub ); + + sinon.assert.notCalled( domEvtDataStub.preventDefault ); + sinon.assert.notCalled( domEvtDataStub.stopPropagation ); + expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [ + [ '11', '12[]' ] + ] ) ); + } ); + + it( 'should do nothing if Ctrl+Tab is pressed', () => { + setModelData( model, modelTable( [ + [ '11', '12[]' ] + ] ) ); + + domEvtDataStub.ctrlKey = true; + + editor.editing.view.document.fire( 'keydown', domEvtDataStub ); + + sinon.assert.notCalled( domEvtDataStub.preventDefault ); + sinon.assert.notCalled( domEvtDataStub.stopPropagation ); + expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [ + [ '11', '12[]' ] + ] ) ); + } ); + + describe( 'on TAB', () => { + it( 'should do nothing if selection is not in a table', () => { + setModelData( model, '[]' + modelTable( [ + [ '11', '12' ] + ] ) ); + + editor.editing.view.document.fire( 'keydown', domEvtDataStub ); + + sinon.assert.notCalled( domEvtDataStub.preventDefault ); + sinon.assert.notCalled( domEvtDataStub.stopPropagation ); + expect( formatTable( getModelData( model ) ) ).to.equal( '[]' + formattedModelTable( [ + [ '11', '12' ] + ] ) ); + } ); + + it( 'should move to next cell', () => { + setModelData( model, modelTable( [ + [ '11[]', '12' ] + ] ) ); + + editor.editing.view.document.fire( 'keydown', domEvtDataStub ); + + sinon.assert.calledOnce( domEvtDataStub.preventDefault ); + sinon.assert.calledOnce( domEvtDataStub.stopPropagation ); + expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [ + [ '11', '[12]' ] + ] ) ); + } ); + + it( 'should create another row and move to first cell in new row', () => { + setModelData( model, modelTable( [ + [ '11', '[12]' ] + ] ) ); + + editor.editing.view.document.fire( 'keydown', domEvtDataStub ); + + expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [ + [ '11', '12' ], + [ '[]', '' ] + ] ) ); + } ); + + it( 'should move to the first cell of next row if on end of a row', () => { + setModelData( model, modelTable( [ + [ '11', '12[]' ], + [ '21', '22' ] + ] ) ); + + editor.editing.view.document.fire( 'keydown', domEvtDataStub ); + + expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [ + [ '11', '12' ], + [ '[21]', '22' ] + ] ) ); + } ); + + describe( 'on table widget selected', () => { + it( 'should move caret to the first table cell on TAB', () => { + const spy = sinon.spy(); + + editor.editing.view.document.on( 'keydown', spy ); + + setModelData( model, '[' + modelTable( [ + [ '11', '12' ] + ] ) + ']' ); + + editor.editing.view.document.fire( 'keydown', domEvtDataStub ); + + sinon.assert.calledOnce( domEvtDataStub.preventDefault ); + sinon.assert.calledOnce( domEvtDataStub.stopPropagation ); + + expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [ + [ '[11]', '12' ] + ] ) ); + + // Should cancel event - so no other tab handler is called. + sinon.assert.notCalled( spy ); + } ); + + it( 'shouldn\' do anything on other blocks', () => { + const spy = sinon.spy(); + + editor.editing.view.document.on( 'keydown', spy ); + + setModelData( model, '[foo]' ); + + editor.editing.view.document.fire( 'keydown', domEvtDataStub ); + + sinon.assert.notCalled( domEvtDataStub.preventDefault ); + sinon.assert.notCalled( domEvtDataStub.stopPropagation ); + + expect( formatTable( getModelData( model ) ) ).to.equal( '[foo]' ); + + // Should not cancel event. + sinon.assert.calledOnce( spy ); + } ); + } ); + } ); + + describe( 'on SHIFT+TAB', () => { + beforeEach( () => { + domEvtDataStub.shiftKey = true; + } ); + + it( 'should do nothing if selection is not in a table', () => { + setModelData( model, '[]' + modelTable( [ + [ '11', '12' ] + ] ) ); + + domEvtDataStub.keyCode = getCode( 'Tab' ); + domEvtDataStub.shiftKey = true; + + editor.editing.view.document.fire( 'keydown', domEvtDataStub ); + + sinon.assert.notCalled( domEvtDataStub.preventDefault ); + sinon.assert.notCalled( domEvtDataStub.stopPropagation ); + expect( formatTable( getModelData( model ) ) ).to.equal( '[]' + formattedModelTable( [ + [ '11', '12' ] + ] ) ); + } ); + + it( 'should move to previous cell', () => { + setModelData( model, modelTable( [ + [ '11', '12[]' ] + ] ) ); + + editor.editing.view.document.fire( 'keydown', domEvtDataStub ); + + sinon.assert.calledOnce( domEvtDataStub.preventDefault ); + sinon.assert.calledOnce( domEvtDataStub.stopPropagation ); + expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [ + [ '[11]', '12' ] + ] ) ); + } ); + + it( 'should not move if caret is in first table cell', () => { + setModelData( model, 'foo' + modelTable( [ + [ '[]11', '12' ] + ] ) ); + + editor.editing.view.document.fire( 'keydown', domEvtDataStub ); + + expect( formatTable( getModelData( model ) ) ).to.equal( + 'foo' + formattedModelTable( [ [ '[]11', '12' ] ] ) + ); + } ); + + it( 'should move to the last cell of previous row if on beginning of a row', () => { + setModelData( model, modelTable( [ + [ '11', '12' ], + [ '[]21', '22' ] + ] ) ); + + editor.editing.view.document.fire( 'keydown', domEvtDataStub ); + + expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [ + [ '11', '[12]' ], + [ '21', '22' ] + ] ) ); + } ); + } ); + } ); +} ); diff --git a/tests/tableui.js b/tests/tableui.js new file mode 100644 index 00000000..c2de62fb --- /dev/null +++ b/tests/tableui.js @@ -0,0 +1,153 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* global document */ + +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import { _clear as clearTranslations, add as addTranslations } from '@ckeditor/ckeditor5-utils/src/translation-service'; +import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; + +import TableEditing from '../src/tableediting'; +import TableUI from '../src/tableui'; + +testUtils.createSinonSandbox(); + +describe( 'TableUI', () => { + let editor, element; + + before( () => { + addTranslations( 'en', {} ); + addTranslations( 'pl', {} ); + } ); + + after( () => { + clearTranslations(); + } ); + + beforeEach( () => { + element = document.createElement( 'div' ); + document.body.appendChild( element ); + + return ClassicTestEditor + .create( element, { + plugins: [ TableEditing, TableUI ] + } ) + .then( newEditor => { + editor = newEditor; + } ); + } ); + + afterEach( () => { + element.remove(); + + return editor.destroy(); + } ); + + describe( 'insertTable button', () => { + let insertTable; + + beforeEach( () => { + insertTable = editor.ui.componentFactory.create( 'insertTable' ); + } ); + + it( 'should register insertTable buton', () => { + expect( insertTable ).to.be.instanceOf( ButtonView ); + expect( insertTable.isOn ).to.be.false; + expect( insertTable.label ).to.equal( 'Insert table' ); + expect( insertTable.icon ).to.match( / { + const command = editor.commands.get( 'insertTable' ); + + command.isEnabled = true; + expect( insertTable.isOn ).to.be.false; + expect( insertTable.isEnabled ).to.be.true; + + command.isEnabled = false; + expect( insertTable.isEnabled ).to.be.false; + } ); + + it( 'should execute insertTable command on button execute event', () => { + const executeSpy = testUtils.sinon.spy( editor, 'execute' ); + + insertTable.fire( 'execute' ); + + sinon.assert.calledOnce( executeSpy ); + sinon.assert.calledWithExactly( executeSpy, 'insertTable' ); + } ); + } ); + + describe( 'insertRowBelow button', () => { + let insertRow; + + beforeEach( () => { + insertRow = editor.ui.componentFactory.create( 'insertRowBelow' ); + } ); + + it( 'should register insertRowBelow button', () => { + expect( insertRow ).to.be.instanceOf( ButtonView ); + expect( insertRow.isOn ).to.be.false; + expect( insertRow.label ).to.equal( 'Insert row' ); + expect( insertRow.icon ).to.match( / { + const command = editor.commands.get( 'insertRowBelow' ); + + command.isEnabled = true; + expect( insertRow.isOn ).to.be.false; + expect( insertRow.isEnabled ).to.be.true; + + command.isEnabled = false; + expect( insertRow.isEnabled ).to.be.false; + } ); + + it( 'should execute insertRow command on button execute event', () => { + const executeSpy = testUtils.sinon.spy( editor, 'execute' ); + + insertRow.fire( 'execute' ); + + sinon.assert.calledOnce( executeSpy ); + sinon.assert.calledWithExactly( executeSpy, 'insertRowBelow' ); + } ); + } ); + + describe( 'insertColumnAfter button', () => { + let insertColumn; + + beforeEach( () => { + insertColumn = editor.ui.componentFactory.create( 'insertColumnAfter' ); + } ); + + it( 'should register insertColumn buton', () => { + expect( insertColumn ).to.be.instanceOf( ButtonView ); + expect( insertColumn.isOn ).to.be.false; + expect( insertColumn.label ).to.equal( 'Insert column' ); + expect( insertColumn.icon ).to.match( / { + const command = editor.commands.get( 'insertColumnAfter' ); + + command.isEnabled = true; + expect( insertColumn.isOn ).to.be.false; + expect( insertColumn.isEnabled ).to.be.true; + + command.isEnabled = false; + expect( insertColumn.isEnabled ).to.be.false; + } ); + + it( 'should execute insertColumn command on button execute event', () => { + const executeSpy = testUtils.sinon.spy( editor, 'execute' ); + + insertColumn.fire( 'execute' ); + + sinon.assert.calledOnce( executeSpy ); + sinon.assert.calledWithExactly( executeSpy, 'insertColumnAfter' ); + } ); + } ); +} ); diff --git a/tests/tableutils.js b/tests/tableutils.js new file mode 100644 index 00000000..22e5fc1b --- /dev/null +++ b/tests/tableutils.js @@ -0,0 +1,750 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor'; +import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters'; + +import { + downcastInsertCell, + downcastInsertRow, + downcastInsertTable, + downcastRemoveRow, + downcastTableHeadingColumnsChange, + downcastTableHeadingRowsChange +} from '../src/converters/downcast'; +import upcastTable from '../src/converters/upcasttable'; +import { formatTable, formattedModelTable, modelTable } from './_utils/utils'; +import TableUtils from '../src/tableutils'; + +describe( 'TableUtils', () => { + let editor, model, root, tableUtils; + + beforeEach( () => { + return ModelTestEditor.create( { + plugins: [ TableUtils ] + } ).then( newEditor => { + editor = newEditor; + model = editor.model; + root = model.document.getRoot( 'main' ); + tableUtils = editor.plugins.get( TableUtils ); + + const conversion = editor.conversion; + const schema = model.schema; + + schema.register( 'table', { + allowWhere: '$block', + allowAttributes: [ 'headingRows' ], + isObject: true + } ); + + schema.register( 'tableRow', { allowIn: 'table' } ); + + schema.register( 'tableCell', { + allowIn: 'tableRow', + allowContentOf: '$block', + allowAttributes: [ 'colspan', 'rowspan' ], + isLimit: true + } ); + + model.schema.register( 'p', { inheritAllFrom: '$block' } ); + + // Table conversion. + conversion.for( 'upcast' ).add( upcastTable() ); + conversion.for( 'downcast' ).add( downcastInsertTable() ); + + // Insert row conversion. + conversion.for( 'downcast' ).add( downcastInsertRow() ); + + // Remove row conversion. + conversion.for( 'downcast' ).add( downcastRemoveRow() ); + + // Table cell conversion. + conversion.for( 'downcast' ).add( downcastInsertCell() ); + + conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'td' } ) ); + conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'th' } ) ); + + // Table attributes conversion. + conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } ); + conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } ); + + conversion.for( 'downcast' ).add( downcastTableHeadingColumnsChange() ); + conversion.for( 'downcast' ).add( downcastTableHeadingRowsChange() ); + } ); + } ); + + afterEach( () => { + return editor.destroy(); + } ); + + describe( '#pluginName', () => { + it( 'should provide plugin name', () => { + expect( TableUtils.pluginName ).to.equal( 'TableUtils' ); + } ); + } ); + + describe( 'getCellLocation()', () => { + it( 'should return proper table cell location', () => { + setData( model, modelTable( [ + [ { rowspan: 2, colspan: 2, contents: '00[]' }, '02' ], + [ '12' ] + ] ) ); + + expect( tableUtils.getCellLocation( root.getNodeByPath( [ 0, 0, 0 ] ) ) ).to.deep.equal( { row: 0, column: 0 } ); + expect( tableUtils.getCellLocation( root.getNodeByPath( [ 0, 0, 1 ] ) ) ).to.deep.equal( { row: 0, column: 2 } ); + expect( tableUtils.getCellLocation( root.getNodeByPath( [ 0, 1, 0 ] ) ) ).to.deep.equal( { row: 1, column: 2 } ); + } ); + } ); + + describe( 'insertRows()', () => { + it( 'should insert row in given table at given index', () => { + setData( model, modelTable( [ + [ '11[]', '12' ], + [ '21', '22' ] + ] ) ); + + tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 1 } ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '11[]', '12' ], + [ '', '' ], + [ '21', '22' ] + ] ) ); + } ); + + it( 'should insert row in given table at default index', () => { + setData( model, modelTable( [ + [ '11[]', '12' ], + [ '21', '22' ] + ] ) ); + + tableUtils.insertRows( root.getNodeByPath( [ 0 ] ) ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '', '' ], + [ '11[]', '12' ], + [ '21', '22' ] + ] ) ); + } ); + + it( 'should update table heading rows attribute when inserting row in headings section', () => { + setData( model, modelTable( [ + [ '11[]', '12' ], + [ '21', '22' ], + [ '31', '32' ] + ], { headingRows: 2 } ) ); + + tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 1 } ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '11[]', '12' ], + [ '', '' ], + [ '21', '22' ], + [ '31', '32' ] + ], { headingRows: 3 } ) ); + } ); + + it( 'should not update table heading rows attribute when inserting row after headings section', () => { + setData( model, modelTable( [ + [ '11[]', '12' ], + [ '21', '22' ], + [ '31', '32' ] + ], { headingRows: 2 } ) ); + + tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 2 } ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '11[]', '12' ], + [ '21', '22' ], + [ '', '' ], + [ '31', '32' ] + ], { headingRows: 2 } ) ); + } ); + + it( 'should expand rowspan of a cell that overlaps inserted rows', () => { + setData( model, modelTable( [ + [ { colspan: 2, contents: '11[]' }, '13', '14' ], + [ { colspan: 2, rowspan: 4, contents: '21' }, '23', '24' ], + [ '33', '34' ] + ], { headingColumns: 3, headingRows: 1 } ) ); + + tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 2, rows: 3 } ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ { colspan: 2, contents: '11[]' }, '13', '14' ], + [ { colspan: 2, rowspan: 7, contents: '21' }, '23', '24' ], + [ '', '' ], + [ '', '' ], + [ '', '' ], + [ '33', '34' ] + ], { headingColumns: 3, headingRows: 1 } ) ); + } ); + + it( 'should not expand rowspan of a cell that does not overlaps inserted rows', () => { + setData( model, modelTable( [ + [ { rowspan: 2, contents: '11[]' }, '12', '13' ], + [ '22', '23' ], + [ '31', '32', '33' ] + ], { headingColumns: 3, headingRows: 1 } ) ); + + tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 2, rows: 3 } ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ { rowspan: 2, contents: '11[]' }, '12', '13' ], + [ '22', '23' ], + [ '', '', '' ], + [ '', '', '' ], + [ '', '', '' ], + [ '31', '32', '33' ] + ], { headingColumns: 3, headingRows: 1 } ) ); + } ); + + it( 'should properly calculate columns if next row has colspans', () => { + setData( model, modelTable( [ + [ { rowspan: 2, contents: '11[]' }, '12', '13' ], + [ '22', '23' ], + [ { colspan: 3, contents: '31' } ] + ], { headingColumns: 3, headingRows: 1 } ) ); + + tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 2, rows: 3 } ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ { rowspan: 2, contents: '11[]' }, '12', '13' ], + [ '22', '23' ], + [ '', '', '' ], + [ '', '', '' ], + [ '', '', '' ], + [ { colspan: 3, contents: '31' } ] + ], { headingColumns: 3, headingRows: 1 } ) ); + } ); + + it( 'should insert rows at the end of a table', () => { + setData( model, modelTable( [ + [ '11[]', '12' ], + [ '21', '22' ] + ] ) ); + + tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 2, rows: 3 } ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '11[]', '12' ], + [ '21', '22' ], + [ '', '' ], + [ '', '' ], + [ '', '' ] + ] ) ); + } ); + } ); + + describe( 'insertColumns()', () => { + it( 'should insert column in given table at given index', () => { + setData( model, modelTable( [ + [ '11[]', '12' ], + [ '21', '22' ] + ] ) ); + + tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ), { at: 1 } ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '11[]', '', '12' ], + [ '21', '', '22' ] + ] ) ); + } ); + + it( 'should insert column in given table with default values', () => { + setData( model, modelTable( [ + [ '11[]', '12' ], + [ '21', '22' ] + ] ) ); + + tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ) ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '', '11[]', '12' ], + [ '', '21', '22' ] + ] ) ); + } ); + + it( 'should insert column in given table at default index', () => { + setData( model, modelTable( [ + [ '11[]', '12' ], + [ '21', '22' ] + ] ) ); + + tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ) ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '', '11[]', '12' ], + [ '', '21', '22' ] + ] ) ); + } ); + + it( 'should insert columns at the end of a row', () => { + setData( model, modelTable( [ + [ '00[]', '01' ], + [ { colspan: 2, contents: '10' } ], + [ '20', { rowspan: 2, contents: '21' } ], + [ '30' ] + ] ) ); + + tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ), { at: 2, columns: 2 } ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00[]', '01', '', '' ], + [ { colspan: 2, contents: '10' }, '', '' ], + [ '20', { rowspan: 2, contents: '21' }, '', '' ], + [ '30', '', '' ] + ] ) ); + } ); + + it( 'should insert columns at the beginning of a row', () => { + setData( model, modelTable( [ + [ '00[]', '01' ], + [ { colspan: 2, contents: '10' } ], + [ '20', { rowspan: 2, contents: '21' } ], + [ { rowspan: 2, contents: '30' } ], + [ '41' ] + ] ) ); + + tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ), { at: 0, columns: 2 } ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '', '', '00[]', '01' ], + [ '', '', { colspan: 2, contents: '10' } ], + [ '', '', '20', { rowspan: 2, contents: '21' } ], + [ '', '', { rowspan: 2, contents: '30' } ], + [ '', '', '41' ] + ] ) ); + } ); + + it( 'should update table heading columns attribute when inserting column in headings section', () => { + setData( model, modelTable( [ + [ '11[]', '12' ], + [ '21', '22' ], + [ '31', '32' ] + ], { headingColumns: 2 } ) ); + + tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ), { at: 1 } ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '11[]', '', '12' ], + [ '21', '', '22' ], + [ '31', '', '32' ] + ], { headingColumns: 3 } ) ); + } ); + + it( 'should not update table heading columns attribute when inserting column after headings section', () => { + setData( model, modelTable( [ + [ '11[]', '12', '13' ], + [ '21', '22', '23' ], + [ '31', '32', '33' ] + ], { headingColumns: 2 } ) ); + + tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ), { at: 2 } ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '11[]', '12', '', '13' ], + [ '21', '22', '', '23' ], + [ '31', '32', '', '33' ] + ], { headingColumns: 2 } ) ); + } ); + + it( 'should expand spanned columns', () => { + setData( model, modelTable( [ + [ '00[]', '01' ], + [ { colspan: 2, contents: '10' } ], + [ '20', '21' ] + ], { headingColumns: 2 } ) ); + + tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ), { at: 1 } ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00[]', '', '01' ], + [ { colspan: 3, contents: '10' } ], + [ '20', '', '21' ] + ], { headingColumns: 3 } ) ); + } ); + + it( 'should skip wide spanned columns', () => { + setData( model, modelTable( [ + [ '11[]', '12', '13', '14', '15' ], + [ '21', '22', { colspan: 2, contents: '23' }, '25' ], + [ { colspan: 4, contents: '31' }, { colspan: 2, contents: '34' } ] + ], { headingColumns: 4 } ) ); + + tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ), { at: 2, columns: 2 } ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '11[]', '12', '', '', '13', '14', '15' ], + [ '21', '22', '', '', { colspan: 2, contents: '23' }, '25' ], + [ { colspan: 6, contents: '31' }, { colspan: 2, contents: '34' } ] + ], { headingColumns: 6 } ) ); + } ); + + it( 'should skip row & column spanned cells', () => { + setData( model, modelTable( [ + [ { colspan: 2, rowspan: 2, contents: '00[]' }, '02' ], + [ '12' ], + [ '20', '21', '22' ] + ], { headingColumns: 2 } ) ); + + tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ), { at: 1, columns: 2 } ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ { colspan: 4, rowspan: 2, contents: '00[]' }, '02' ], + [ '12' ], + [ '20', '', '', '21', '22' ] + ], { headingColumns: 4 } ) ); + } ); + + it( 'should properly insert column while table has rowspanned cells', () => { + setData( model, modelTable( [ + [ { rowspan: 4, contents: '00[]' }, { rowspan: 2, contents: '01' }, '02' ], + [ '12' ], + [ { rowspan: 2, contents: '21' }, '22' ], + [ '32' ] + ], { headingColumns: 2 } ) ); + + tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ), { at: 1, columns: 1 } ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ { rowspan: 4, contents: '00[]' }, '', { rowspan: 2, contents: '01' }, '02' ], + [ '', '12' ], + [ '', { rowspan: 2, contents: '21' }, '22' ], + [ '', '32' ] + ], { headingColumns: 3 } ) ); + } ); + } ); + + describe( 'splitCellVertically()', () => { + it( 'should split table cell to given table cells number', () => { + setData( model, modelTable( [ + [ '00', '01', '02' ], + [ '10', '[]11', '12' ], + [ '20', { colspan: 2, contents: '21' } ], + [ { colspan: 2, contents: '30' }, '32' ] + ] ) ); + + tableUtils.splitCellVertically( root.getNodeByPath( [ 0, 1, 1 ] ), 3 ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', { colspan: 3, contents: '01' }, '02' ], + [ '10', '[]11', '', '', '12' ], + [ '20', { colspan: 4, contents: '21' } ], + [ { colspan: 4, contents: '30' }, '32' ] + ] ) ); + } ); + + it( 'should split table cell for two table cells as a default', () => { + setData( model, modelTable( [ + [ '00', '01', '02' ], + [ '10', '[]11', '12' ], + [ '20', { colspan: 2, contents: '21' } ], + [ { colspan: 2, contents: '30' }, '32' ] + ] ) ); + + tableUtils.splitCellVertically( root.getNodeByPath( [ 0, 1, 1 ] ) ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', { colspan: 2, contents: '01' }, '02' ], + [ '10', '[]11', '', '12' ], + [ '20', { colspan: 3, contents: '21' } ], + [ { colspan: 3, contents: '30' }, '32' ] + ] ) ); + } ); + + it( 'should split table cell if split is equal to colspan', () => { + setData( model, modelTable( [ + [ '00', '01', '02' ], + [ '10', '11', '12' ], + [ '20', { colspan: 2, contents: '21[]' } ], + [ { colspan: 2, contents: '30' }, '32' ] + ] ) ); + + tableUtils.splitCellVertically( root.getNodeByPath( [ 0, 2, 1 ] ), 2 ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', '01', '02' ], + [ '10', '11', '12' ], + [ '20', '21[]', '' ], + [ { colspan: 2, contents: '30' }, '32' ] + ] ) ); + } ); + + it( 'should properly split table cell if split is uneven', () => { + setData( model, modelTable( [ + [ '00', '01', '02' ], + [ { colspan: 3, contents: '10[]' } ] + ] ) ); + + tableUtils.splitCellVertically( root.getNodeByPath( [ 0, 1, 0 ] ), 2 ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', '01', '02' ], + [ { colspan: 2, contents: '10[]' }, '' ] + ] ) ); + } ); + + it( 'should properly set colspan of inserted cells', () => { + setData( model, modelTable( [ + [ '00', '01', '02', '03' ], + [ { colspan: 4, contents: '10[]' } ] + ] ) ); + + tableUtils.splitCellVertically( root.getNodeByPath( [ 0, 1, 0 ] ), 2 ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', '01', '02', '03' ], + [ { colspan: 2, contents: '10[]' }, { colspan: 2, contents: '' } ] + ] ) ); + } ); + + it( 'should keep rowspan attribute for newly inserted cells', () => { + setData( model, modelTable( [ + [ '00', '01', '02', '03', '04', '05' ], + [ { colspan: 5, rowspan: 2, contents: '10[]' }, '15' ], + [ '25' ] + ] ) ); + + tableUtils.splitCellVertically( root.getNodeByPath( [ 0, 1, 0 ] ), 2 ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', '01', '02', '03', '04', '05' ], + [ { colspan: 3, rowspan: 2, contents: '10[]' }, { colspan: 2, rowspan: 2, contents: '' }, '15' ], + [ '25' ] + ] ) ); + } ); + + it( 'should keep rowspan attribute of for newly inserted cells if number of cells is bigger then curren colspan', () => { + setData( model, modelTable( [ + [ '00', '01', '02' ], + [ { colspan: 2, rowspan: 2, contents: '10[]' }, '12' ], + [ '22' ] + ] ) ); + + tableUtils.splitCellVertically( root.getNodeByPath( [ 0, 1, 0 ] ), 3 ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ { colspan: 2, contents: '00' }, '01', '02' ], + [ { rowspan: 2, contents: '10[]' }, { rowspan: 2, contents: '' }, { rowspan: 2, contents: '' }, '12' ], + [ '22' ] + ] ) ); + } ); + + it( 'should properly break a cell if it has colspan and number of created cells is bigger then colspan', () => { + setData( model, modelTable( [ + [ '00', '01', '02', '03' ], + [ { colspan: 4, contents: '10[]' } ] + ] ) ); + + tableUtils.splitCellVertically( root.getNodeByPath( [ 0, 1, 0 ] ), 6 ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ { colspan: 3, contents: '00' }, '01', '02', '03' ], + [ '10[]', '', '', '', '', '' ] + ] ) ); + } ); + + it( 'should update heading columns is split cell is in heading section', () => { + setData( model, modelTable( [ + [ '00', '01' ], + [ '10[]', '11' ] + ], { headingColumns: 1 } ) ); + + tableUtils.splitCellVertically( root.getNodeByPath( [ 0, 1, 0 ] ), 3 ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ { colspan: 3, contents: '00' }, '01' ], + [ '10[]', '', '', '11' ] + ], { headingColumns: 3 } ) ); + } ); + } ); + + describe( 'splitCellHorizontally()', () => { + it( 'should split table cell to default table cells number', () => { + setData( model, modelTable( [ + [ '00', '01', '02' ], + [ '10', '[]11', '12' ], + [ '20', '21', '22' ] + ] ) ); + + tableUtils.splitCellHorizontally( root.getNodeByPath( [ 0, 1, 1 ] ) ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', '01', '02' ], + [ { rowspan: 2, contents: '10' }, '[]11', { rowspan: 2, contents: '12' } ], + [ '' ], + [ '20', '21', '22' ] + ] ) ); + } ); + + it( 'should split table cell to given table cells number', () => { + setData( model, modelTable( [ + [ '00', '01', '02' ], + [ '10', '[]11', '12' ], + [ '20', '21', '22' ] + ] ) ); + + tableUtils.splitCellHorizontally( root.getNodeByPath( [ 0, 1, 1 ] ), 4 ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', '01', '02' ], + [ { rowspan: 4, contents: '10' }, '[]11', { rowspan: 4, contents: '12' } ], + [ '' ], + [ '' ], + [ '' ], + [ '20', '21', '22' ] + ] ) ); + } ); + + it( 'should properly update rowspanned cells overlapping selected cell', () => { + setData( model, modelTable( [ + [ { rowspan: 2, contents: '00' }, '01', { rowspan: 3, contents: '02' } ], + [ '[]11' ], + [ '20', '21' ] + ] ) ); + + tableUtils.splitCellHorizontally( root.getNodeByPath( [ 0, 1, 0 ] ), 3 ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ { rowspan: 4, contents: '00' }, '01', { rowspan: 5, contents: '02' } ], + [ '[]11' ], + [ '' ], + [ '' ], + [ '20', '21' ] + ] ) ); + } ); + + it( 'should split rowspanned cell', () => { + setData( model, modelTable( [ + [ '00', { rowspan: 2, contents: '01[]' } ], + [ '10' ], + [ '20', '21' ] + ] ) ); + + const tableCell = root.getNodeByPath( [ 0, 0, 1 ] ); + + tableUtils.splitCellHorizontally( tableCell, 2 ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', '01[]' ], + [ '10', '' ], + [ '20', '21' ] + ] ) ); + } ); + + it( 'should copy colspan while splitting rowspanned cell', () => { + setData( model, modelTable( [ + [ '00', { rowspan: 2, colspan: 2, contents: '01[]' } ], + [ '10' ], + [ '20', '21', '22' ] + ] ) ); + + const tableCell = root.getNodeByPath( [ 0, 0, 1 ] ); + + tableUtils.splitCellHorizontally( tableCell, 2 ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', { colspan: 2, contents: '01[]' } ], + [ '10', { colspan: 2, contents: '' } ], + [ '20', '21', '22' ] + ] ) ); + } ); + + it( 'should evenly distribute rowspan attribute', () => { + setData( model, modelTable( [ + [ '00', { rowspan: 7, contents: '01[]' } ], + [ '10' ], + [ '20' ], + [ '30' ], + [ '40' ], + [ '50' ], + [ '60' ], + [ '70', '71' ] + ] ) ); + + const tableCell = root.getNodeByPath( [ 0, 0, 1 ] ); + + tableUtils.splitCellHorizontally( tableCell, 3 ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00', { rowspan: 3, contents: '01[]' } ], + [ '10' ], + [ '20' ], + [ '30', { rowspan: 2, contents: '' } ], + [ '40' ], + [ '50', { rowspan: 2, contents: '' } ], + [ '60' ], + [ '70', '71' ] + ] ) ); + } ); + + it( 'should split rowspanned cell and updated other cells rowspan when splitting to bigger number of cells', () => { + setData( model, modelTable( [ + [ '00', { rowspan: 2, contents: '01[]' } ], + [ '10' ], + [ '20', '21' ] + ] ) ); + + const tableCell = root.getNodeByPath( [ 0, 0, 1 ] ); + + tableUtils.splitCellHorizontally( tableCell, 3 ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ { rowspan: 2, contents: '00' }, '01[]' ], + [ '' ], + [ '10', '' ], + [ '20', '21' ] + ] ) ); + } ); + + it( 'should split rowspanned & colspaned cell', () => { + setData( model, modelTable( [ + [ '00', { colspan: 2, contents: '01[]' } ], + [ '10', '11' ] + ] ) ); + + const tableCell = root.getNodeByPath( [ 0, 0, 1 ] ); + + tableUtils.splitCellHorizontally( tableCell, 3 ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ { rowspan: 3, contents: '00' }, { colspan: 2, contents: '01[]' } ], + [ { colspan: 2, contents: '' } ], + [ { colspan: 2, contents: '' } ], + [ '10', '11' ] + ] ) ); + } ); + + it( 'should split table cell from a heading section', () => { + setData( model, modelTable( [ + [ '00[]', '01', '02' ], + [ '10', '11', '12' ], + [ '20', '21', '22' ] + ], { headingRows: 1 } ) ); + + tableUtils.splitCellHorizontally( root.getNodeByPath( [ 0, 0, 0 ] ), 3 ); + + expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [ + [ '00[]', { rowspan: 3, contents: '01' }, { rowspan: 3, contents: '02' } ], + [ '' ], + [ '' ], + [ '10', '11', '12' ], + [ '20', '21', '22' ] + ], { headingRows: 3 } ) ); + } ); + } ); + + describe( 'getColumns()', () => { + it( 'should return proper number of columns', () => { + setData( model, modelTable( [ + [ '00', { colspan: 3, contents: '01' }, '04' ] + ] ) ); + + expect( tableUtils.getColumns( root.getNodeByPath( [ 0 ] ) ) ).to.equal( 5 ); + } ); + } ); +} ); diff --git a/tests/tablewalker.js b/tests/tablewalker.js new file mode 100644 index 00000000..2d5f8222 --- /dev/null +++ b/tests/tablewalker.js @@ -0,0 +1,254 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor'; +import { modelTable } from './_utils/utils'; + +import TableWalker from '../src/tablewalker'; + +describe( 'TableWalker', () => { + let editor, model, doc, root; + + beforeEach( () => { + return ModelTestEditor.create() + .then( newEditor => { + editor = newEditor; + model = editor.model; + doc = model.document; + root = doc.getRoot( 'main' ); + + const schema = model.schema; + + schema.register( 'table', { + allowWhere: '$block', + allowAttributes: [ 'headingRows', 'headingColumns' ], + isObject: true + } ); + + schema.register( 'tableRow', { allowIn: 'table' } ); + + schema.register( 'tableCell', { + allowIn: 'tableRow', + allowContentOf: '$block', + allowAttributes: [ 'colspan', 'rowspan' ], + isLimit: true + } ); + } ); + } ); + + function testWalker( tableData, expected, options ) { + setData( model, modelTable( tableData ) ); + + const iterator = new TableWalker( root.getChild( 0 ), options ); + + const result = []; + + for ( const tableInfo of iterator ) { + result.push( tableInfo ); + } + + const formattedResult = result.map( ( { row, column, cell, cellIndex } ) => ( { + row, + column, + data: cell && cell.getChild( 0 ).data, + index: cellIndex + } ) ); + + expect( formattedResult ).to.deep.equal( expected ); + } + + it( 'should iterate over a table', () => { + testWalker( [ + [ '00', '01' ], + [ '10', '11' ] + ], [ + { row: 0, column: 0, index: 0, data: '00' }, + { row: 0, column: 1, index: 1, data: '01' }, + { row: 1, column: 0, index: 0, data: '10' }, + { row: 1, column: 1, index: 1, data: '11' } + ] ); + } ); + + it( 'should properly output column indexes of a table that has colspans', () => { + testWalker( [ + [ { colspan: 2, contents: '00' }, '13' ] + ], [ + { row: 0, column: 0, index: 0, data: '00' }, + { row: 0, column: 2, index: 1, data: '13' } + ] ); + } ); + + it( 'should properly output column indexes of a table that has rowspans', () => { + testWalker( [ + [ { colspan: 2, rowspan: 3, contents: '00' }, '02' ], + [ '12' ], + [ '22' ], + [ '30', '31', '32' ] + ], [ + { row: 0, column: 0, index: 0, data: '00' }, + { row: 0, column: 2, index: 1, data: '02' }, + { row: 1, column: 2, index: 0, data: '12' }, + { row: 2, column: 2, index: 0, data: '22' }, + { row: 3, column: 0, index: 0, data: '30' }, + { row: 3, column: 1, index: 1, data: '31' }, + { row: 3, column: 2, index: 2, data: '32' } + ] ); + } ); + + it( 'should properly output column indexes of a table that has multiple rowspans', () => { + testWalker( [ + [ { rowspan: 3, contents: '11' }, '12', '13' ], + [ { rowspan: 2, contents: '22' }, '23' ], + [ '33' ], + [ '41', '42', '43' ] + ], [ + { row: 0, column: 0, index: 0, data: '11' }, + { row: 0, column: 1, index: 1, data: '12' }, + { row: 0, column: 2, index: 2, data: '13' }, + { row: 1, column: 1, index: 0, data: '22' }, + { row: 1, column: 2, index: 1, data: '23' }, + { row: 2, column: 2, index: 0, data: '33' }, + { row: 3, column: 0, index: 0, data: '41' }, + { row: 3, column: 1, index: 1, data: '42' }, + { row: 3, column: 2, index: 2, data: '43' } + ] ); + } ); + + describe( 'option.startRow', () => { + it( 'should start iterating from given row but with cell spans properly calculated', () => { + testWalker( [ + [ { colspan: 2, rowspan: 3, contents: '11' }, '13' ], + [ '23' ], + [ '33' ], + [ '41', '42', '43' ] + ], [ + { row: 2, column: 2, index: 0, data: '33' }, + { row: 3, column: 0, index: 0, data: '41' }, + { row: 3, column: 1, index: 1, data: '42' }, + { row: 3, column: 2, index: 2, data: '43' } + ], { startRow: 2 } ); + } ); + } ); + + describe( 'option.endRow', () => { + it( 'should stopp iterating after given row but with cell spans properly calculated', () => { + testWalker( [ + [ { colspan: 2, rowspan: 3, contents: '11' }, '13' ], + [ '23' ], + [ '33' ], + [ '41', '42', '43' ] + ], [ + { row: 0, column: 0, index: 0, data: '11' }, + { row: 0, column: 2, index: 1, data: '13' }, + { row: 1, column: 2, index: 0, data: '23' }, + { row: 2, column: 2, index: 0, data: '33' } + ], { endRow: 2 } ); + } ); + + it( 'should iterate over given row 0 only', () => { + testWalker( [ + [ { colspan: 2, rowspan: 3, contents: '11' }, '13' ], + [ '23' ], + [ '33' ], + [ '41', '42', '43' ] + ], [ + { row: 0, column: 0, index: 0, data: '11' }, + { row: 0, column: 2, index: 1, data: '13' } + ], { endRow: 0 } ); + } ); + } ); + + describe( 'option.includeSpanned', () => { + it( 'should output spanned cells at the end of a table', () => { + testWalker( [ + [ '00', { rowspan: 2, contents: '01' } ], + [ '10' ] + ], [ + { row: 0, column: 0, index: 0, data: '00' }, + { row: 0, column: 1, index: 1, data: '01' }, + { row: 1, column: 0, index: 0, data: '10' }, + { row: 1, column: 1, index: 1, data: undefined } + ], { includeSpanned: true } ); + } ); + + it( 'should output spanned cells as empty cell', () => { + testWalker( [ + [ { colspan: 2, rowspan: 3, contents: '00' }, '02' ], + [ '12' ], + [ '22' ], + [ '30', { colspan: 2, contents: '31' } ] + ], [ + { row: 0, column: 0, index: 0, data: '00' }, + { row: 0, column: 1, index: 1, data: undefined }, + { row: 0, column: 2, index: 1, data: '02' }, + { row: 1, column: 0, index: 0, data: undefined }, + { row: 1, column: 1, index: 0, data: undefined }, + { row: 1, column: 2, index: 0, data: '12' }, + { row: 2, column: 0, index: 0, data: undefined }, + { row: 2, column: 1, index: 0, data: undefined }, + { row: 2, column: 2, index: 0, data: '22' }, + { row: 3, column: 0, index: 0, data: '30' }, + { row: 3, column: 1, index: 1, data: '31' }, + { row: 3, column: 2, index: 2, data: undefined } + ], { includeSpanned: true } ); + } ); + + it( 'should output rowspanned cells at the end of a table row', () => { + testWalker( [ + [ '00', { rowspan: 2, contents: '01' } ], + [ '10' ] + ], [ + { row: 0, column: 0, index: 0, data: '00' }, + { row: 0, column: 1, index: 1, data: '01' }, + { row: 1, column: 0, index: 0, data: '10' }, + { row: 1, column: 1, index: 1, data: undefined } + ], { includeSpanned: true } ); + } ); + + it( 'should work with startRow & endRow options', () => { + testWalker( [ + [ { colspan: 2, rowspan: 3, contents: '00' }, '02' ], + [ '12' ], + [ '22' ], + [ '30', '31', '32' ] + ], [ + { row: 1, column: 0, index: 0, data: undefined }, + { row: 1, column: 1, index: 0, data: undefined }, + { row: 1, column: 2, index: 0, data: '12' }, + { row: 2, column: 0, index: 0, data: undefined }, + { row: 2, column: 1, index: 0, data: undefined }, + { row: 2, column: 2, index: 0, data: '22' } + ], { includeSpanned: true, startRow: 1, endRow: 2 } ); + } ); + + it( 'should output rowspanned cells at the end of a table row', () => { + testWalker( [ + [ '00', { rowspan: 2, contents: '01' } ], + [ '10' ], + [ '20', '21' ] + ], [ + { row: 0, column: 0, index: 0, data: '00' }, + { row: 0, column: 1, index: 1, data: '01' }, + { row: 1, column: 0, index: 0, data: '10' }, + { row: 1, column: 1, index: 1, data: undefined } + ], { startRow: 0, endRow: 1, includeSpanned: true } ); + } ); + } ); + + describe( 'options.startColumn', () => { + it( 'should output only cells on given column', () => { + testWalker( [ + [ { colspan: 2, rowspan: 3, contents: '00' }, '02' ], + [ '12' ], + [ '22' ], + [ '30', '31', '32' ] + ], [ + { row: 0, column: 0, index: 0, data: '00' }, + { row: 3, column: 1, index: 1, data: '31' } + ], { column: 1 } ); + } ); + } ); +} ); diff --git a/theme/table.css b/theme/table.css new file mode 100644 index 00000000..419ba7f5 --- /dev/null +++ b/theme/table.css @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +.ck table.ck-widget th.ck-editor__nested-editable { + border-color: inherit; + border-style: inset; +} + +.ck table.ck-widget td.ck-editor__nested-editable { + border-color: inherit; + border-style: inset; +} + +.ck table.ck-widget td.ck-editor__nested-editable.ck-editor__nested-editable_focused, +.ck table.ck-widget td.ck-editor__nested-editable:focus { + background-color: inherit; + color: inherit; +} + +.ck table.ck-widget th.ck-editor__nested-editable.ck-editor__nested-editable_focused, +.ck table.ck-widget th.ck-editor__nested-editable:focus { + background-color: inherit; + color: inherit; +}