/**
* @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md.
*/
/**
* @module table/converters/downcasttable
*/
import ViewPosition from '@ckeditor/ckeditor5-engine/src/view/position';
/**
* Model table element to view table element conversion helper.
*
* This conversion helper creates whole table element with child elements.
*
* @returns {Function} Conversion helper.
*/
export default function downcastTable() {
return dispatcher => dispatcher.on( 'insert:table', ( evt, data, conversionApi ) => {
const table = data.item;
if ( !conversionApi.consumable.consume( table, 'insert' ) ) {
return;
}
// The and
elements are created on the fly when needed by inner `getTableSection()` function.
let tHead, tBody;
const tableElement = conversionApi.writer.createContainerElement( 'table' );
const headingRows = parseInt( table.getAttribute( 'headingRows' ) ) || 0;
const tableRows = Array.from( table.getChildren() );
const cellSpans = new CellSpans();
for ( const tableRow of tableRows ) {
const rowIndex = tableRows.indexOf( tableRow );
const tableSectionElement = getTableSection( rowIndex, headingRows, tableElement, conversionApi );
downcastTableRow( tableRow, rowIndex, cellSpans, tableSectionElement, conversionApi );
// Drop table cell spans information for downcasted row.
cellSpans.drop( rowIndex );
}
const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
conversionApi.mapper.bindElements( table, tableElement );
conversionApi.writer.insert( viewPosition, tableElement );
// Creates if not existing and returns or element for given rowIndex.
function getTableSection( rowIndex, headingRows, tableElement, conversionApi ) {
if ( headingRows && rowIndex < headingRows ) {
if ( !tHead ) {
tHead = createTableSection( 'thead', tableElement, conversionApi );
}
return tHead;
}
if ( !tBody ) {
tBody = createTableSection( 'tbody', tableElement, conversionApi );
}
return tBody;
}
}, { priority: 'normal' } );
}
// Downcast converter for tableRow model element. Converts tableCells as well.
//
// @param {module:engine/model/element~Element} tableRow
// @param {Number} rowIndex
// @param {CellSpans} cellSpans
// @param {module:engine/view/containerelement~ContainerElement} tableSection
// @param {Object} conversionApi
function downcastTableRow( tableRow, rowIndex, cellSpans, tableSection, conversionApi ) {
// Will always consume since we're converting element from a parent .
conversionApi.consumable.consume( tableRow, 'insert' );
const trElement = conversionApi.writer.createContainerElement( 'tr' );
conversionApi.mapper.bindElements( tableRow, trElement );
conversionApi.writer.insert( ViewPosition.createAt( tableSection, 'end' ), trElement );
// Defines tableCell horizontal position in table.
// Might be different then position of tableCell in parent tableRow
// as tableCells from previous rows might overlaps current row's cells.
let columnIndex = 0;
const headingRows = tableRow.parent.getAttribute( 'headingRows' ) || 0;
const headingColumns = tableRow.parent.getAttribute( 'headingColumns' ) || 0;
for ( const tableCell of Array.from( tableRow.getChildren() ) ) {
// Check whether current columnIndex is overlapped by table cells from previous rows.
columnIndex = cellSpans.getNextFreeColumnIndex( rowIndex, columnIndex );
const colspan = tableCell.hasAttribute( 'colspan' ) ? parseInt( tableCell.getAttribute( 'colspan' ) ) : 1;
const rowspan = tableCell.hasAttribute( 'rowspan' ) ? parseInt( tableCell.getAttribute( 'rowspan' ) ) : 1;
cellSpans.recordSpans( rowIndex, columnIndex, rowspan, colspan );
// Will always consume since we're converting element from a parent .
conversionApi.consumable.consume( tableCell, 'insert' );
const cellElementName = getCellElementName( rowIndex, columnIndex, headingRows, headingColumns );
const cellElement = conversionApi.writer.createContainerElement( cellElementName );
conversionApi.mapper.bindElements( tableCell, cellElement );
conversionApi.writer.insert( ViewPosition.createAt( trElement, 'end' ), cellElement );
// Skip to next "free" column index.
columnIndex += colspan;
}
}
// Creates table section at the end of a table.
//
// @param {String} elementName
// @param {module:engine/view/element~Element} tableElement
// @param conversionApi
// @return {module:engine/view/containerelement~ContainerElement}
function createTableSection( elementName, tableElement, conversionApi ) {
const tableChildElement = conversionApi.writer.createContainerElement( elementName );
conversionApi.writer.insert( ViewPosition.createAt( tableElement, 'end' ), tableChildElement );
return tableChildElement;
}
// Returns `th` for heading cells and `td` for other cells.
// It is based on tableCell location (rowIndex x columnIndex) and the sizes of column & row headings sizes.
//
// @param {Number} rowIndex
// @param {Number} columnIndex
// @param {Number} headingRows
// @param {Number} headingColumns
// @returns {String}
function getCellElementName( rowIndex, columnIndex, headingRows, headingColumns ) {
// Column heading are all tableCells in the first `columnHeading` rows.
const isHeadingForAColumn = headingRows && headingRows > rowIndex;
// So a whole row gets | element.
if ( isHeadingForAColumn ) {
return 'th';
}
// Row heading are tableCells which columnIndex is lower then headingColumns.
const isHeadingForARow = headingColumns && headingColumns > columnIndex;
return isHeadingForARow ? 'th' : 'td';
}
/**
* Holds information about spanned table cells.
*
* @private
*/
class CellSpans {
/**
* Creates CellSpans instance.
*/
constructor() {
/**
* Holds table cell spans mapping.
*
* @type {Map}
* @private
*/
this._spans = new Map();
}
/**
* Returns proper column index if a current cell index is overlapped by other (has a span defined).
*
* @param {Number} row
* @param {Number} column
* @return {Number} Returns current column or updated column index.
*/
getNextFreeColumnIndex( row, column ) {
let span = this._check( row, column ) || 0;
// Offset current table cell columnIndex by spanning cells from rows above.
while ( span ) {
column += span;
span = this._check( row, column );
}
return column;
}
/**
* Updates spans based on current table cell height & width. Spans with height <= 1 will not be recorded.
*
* For instance if a table cell at row 0 and column 0 has height of 3 and width of 2 we're setting spans:
*
* 0 1 2 3 4 5
* 0:
* 1: 2
* 2: 2
* 3:
*
* Adding another spans for a table cell at row 2 and column 1 that has height of 2 and width of 4 will update above to:
*
* 0 1 2 3 4 5
* 0:
* 1: 2
* 2: 2
* 3: 4
*
* The above span mapping was calculated from a table below (cells 03 & 12 were not added as their height is 1):
*
* +----+----+----+----+----+----+
* | 00 | 02 | 03 | 05 |
* | +--- +----+----+----+
* | | 12 | 24 | 25 |
* | +----+----+----+----+
* | | 22 |
* |----+----+ +
* | 31 | 32 | |
* +----+----+----+----+----+----+
*
* @param {Number} rowIndex
* @param {Number} columnIndex
* @param {Number} height
* @param {Number} width
*/
recordSpans( rowIndex, columnIndex, height, width ) {
// This will update all rows below up to row height with value of span width.
for ( let rowToUpdate = rowIndex + 1; rowToUpdate < rowIndex + height; rowToUpdate++ ) {
if ( !this._spans.has( rowToUpdate ) ) {
this._spans.set( rowToUpdate, new Map() );
}
const rowSpans = this._spans.get( rowToUpdate );
rowSpans.set( columnIndex, width );
}
}
/**
* Removes row from mapping.
*
* @param {Number} rowIndex
*/
drop( rowIndex ) {
if ( this._spans.has( rowIndex ) ) {
this._spans.delete( rowIndex );
}
}
/**
* Checks if given table cell is spanned by other.
*
* @param {Number} rowIndex
* @param {Number} columnIndex
* @return {Boolean|Number} Returns false or width of a span.
* @private
*/
_check( rowIndex, columnIndex ) {
if ( !this._spans.has( rowIndex ) ) {
return false;
}
const rowSpans = this._spans.get( rowIndex );
return rowSpans.has( columnIndex ) ? rowSpans.get( columnIndex ) : false;
}
}
|