/**
* @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/
/**
* @module table/converters/upcasttable
*/
import { createEmptyTableCell } from '../utils/common';
/**
* View table element to model table element conversion helper.
*
* This conversion helper converts the table element as well as table rows.
*
* @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 );
// When there is no split result it means that we can't insert element to model tree, so let's skip it.
if ( !splitResult ) {
return;
}
conversionApi.writer.insert( table, splitResult.position );
conversionApi.consumable.consume( viewTable, { name: true } );
// Upcast table rows in proper order (heading rows first).
rows.forEach( row => conversionApi.convertItem( row, conversionApi.writer.createPositionAt( table, 'end' ) ) );
// Create one row and one table cell for empty table.
if ( table.isEmpty ) {
const row = conversionApi.writer.createElement( 'tableRow' );
conversionApi.writer.insert( row, conversionApi.writer.createPositionAt( table, 'end' ) );
createEmptyTableCell( conversionApi.writer, conversionApi.writer.createPositionAt( row, 'end' ) );
}
// Set conversion result range.
data.modelRange = conversionApi.writer.createRange(
// Range should start before inserted element
conversionApi.writer.createPositionBefore( 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: []
conversionApi.writer.createPositionAfter( 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 = conversionApi.writer.createPositionAt( splitResult.cursorParent, 0 );
// Otherwise just continue after inserted element.
} else {
data.modelCursor = data.modelRange.end;
}
} );
};
}
/**
* Conversion helper that skips empty
from upcasting at the beginning of the table.
*
* Empty row is considered a table model error if there are no cells spanned over that row.
*
* @returns {Function} Conversion helper.
*/
export function skipEmptyTableRow() {
return dispatcher => {
dispatcher.on( 'element:tr', ( evt, data ) => {
if ( data.viewItem.isEmpty && data.modelCursor.index == 0 ) {
evt.stop();
}
}, { priority: 'high' } );
};
}
export function upcastTableCell( elementName ) {
return dispatcher => {
dispatcher.on( `element:${ elementName }`, ( evt, data, conversionApi ) => {
const viewTableCell = data.viewItem;
// When element was already consumed then skip it.
if ( !conversionApi.consumable.test( viewTableCell, { name: true } ) ) {
return;
}
const tableCell = conversionApi.writer.createElement( 'tableCell' );
// Insert element on allowed position.
const splitResult = conversionApi.splitToAllowedParent( tableCell, data.modelCursor );
// When there is no split result it means that we can't insert element to model tree, so let's skip it.
if ( !splitResult ) {
return;
}
conversionApi.writer.insert( tableCell, splitResult.position );
conversionApi.consumable.consume( viewTableCell, { name: true } );
const modelCursor = conversionApi.writer.createPositionAt( tableCell, 0 );
conversionApi.convertChildren( viewTableCell, modelCursor );
// Ensure a paragraph in the model for empty table cells.
if ( !tableCell.childCount ) {
conversionApi.writer.insertElement( 'paragraph', modelCursor );
}
// Set conversion result range.
data.modelRange = conversionApi.writer.createRange(
// Range should start before inserted element
conversionApi.writer.createPositionBefore( tableCell ),
// 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: []
conversionApi.writer.createPositionAfter( tableCell )
);
// Continue after inserted element.
data.modelCursor = data.modelRange.end;
} );
};
}
// Scans table rows and extracts required metadata from the table:
//
// headingRows - The number of rows that go as table headers.
// headingColumns - The maximum number of row headings.
// rows - Sorted `
` elements 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 do not have to be in order `` -> `` and there might be more than one
// of them.
// As the model does not have these sections, rows from different sections must be sorted.
// For example, below is a valid HTML table:
//
//
//
// But browsers will render rows in order as: 1 as heading and 2 and 3 as the 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;
}
// There might be some extra empty text nodes between the `tr`s.
// Make sure further code operates on `tr`s only. (#145)
const trs = Array.from( tableChild.getChildren() ).filter( el => el.is( 'element', 'tr' ) );
for ( const tr of trs ) {
// 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 a `` element and its children for metadata:
// - For heading row:
// - Adds this row to either the heading or the body rows.
// - Updates the number of heading rows.
// - For body rows:
// - Calculates the 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;
}