/** * @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: // // // // // //
2
1
3
// // 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; }