/**
* @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 { toWidgetEditable } from '@ckeditor/ckeditor5-widget/src/utils';
import { toTableWidget } from '../utils';
/**
* Model table element to view table element conversion helper.
*
* This conversion helper creates the whole table element with child elements.
*
* @param {Object} options
* @param {Boolean} options.asWidget If set to `true`, the downcast conversion will produce a 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 figureElement = conversionApi.writer.createContainerElement( 'figure', { class: 'table' } );
const tableElement = conversionApi.writer.createContainerElement( 'table' );
conversionApi.writer.insert( ViewPosition.createAt( figureElement ), tableElement );
let tableWidget;
if ( asWidget ) {
tableWidget = toTableWidget( figureElement, 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 : figureElement );
conversionApi.writer.insert( viewPosition, asWidget ? tableWidget : figureElement );
}, { priority: 'normal' } );
}
/**
* Model row element to view `
` element conversion helper.
*
* This conversion helper creates the 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 figureElement = conversionApi.mapper.toViewElement( table );
const tableElement = figureElement.getChild( 0 );
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 table cell element to view `| ` or ` | ` element conversion helper.
*
* This conversion helper will create proper ` | ` elements for table cells that are in the 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 heading rows 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 figureElement = conversionApi.mapper.toViewElement( table );
const viewTable = figureElement.getChild( 0 );
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 heading columns table attribute change.
*
* Depending on changed attributes this converter will rename ` | ` elements or vice versa depending of the 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 a 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 a table cell in the view to a 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 the view according to its location in the 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 the 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 `
` element from the 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 the 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 | 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 the table section name for the 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 ` | ` or `` element witch caching.
//
// @param {String} sectionName
// @param {module:engine/view/element~Element} viewTable
// @param {Object} conversionApi
// @param {Object} cachedTableSection An object that stores 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 a table section at the end of the 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 the 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 ) );
}
}