Skip to content

Commit

Permalink
Merge pull request #7572 from ckeditor/i/7454
Browse files Browse the repository at this point in the history
Fix (table): The table structure should not be changed while removing the heading row. Closes #7454. Closes #7601.

Fix (table): Merging cells of multiple whole rows or columns should not crash the editor.

MINOR BREAKING CHANGE (table): The `removeEmptyRows()` and `removeEmptyRowsColumns()` ulitiy functions does not require `batch` parameter any more.

MINOR BREAKING CHANGE (table): The `downcastTableHeadingRowsChange()` downcast converter has been removed. It is no longer possible to override `headingRows` attribute change in single converter. This behavior can be customized using a table downcast converter. See #7601.
  • Loading branch information
jodator authored Jul 10, 2020
2 parents 8cecf39 + 13c9fd8 commit 8b83c9b
Show file tree
Hide file tree
Showing 12 changed files with 203 additions and 188 deletions.
2 changes: 1 addition & 1 deletion packages/ckeditor5-table/src/commands/mergecellcommand.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export default class MergeCellCommand extends Command {
const table = findAncestor( 'table', removedTableCellRow );

// Remove empty rows and columns after merging.
removeEmptyRowsColumns( table, tableUtils, writer.batch );
removeEmptyRowsColumns( table, tableUtils );
} );
}

Expand Down
2 changes: 1 addition & 1 deletion packages/ckeditor5-table/src/commands/mergecellscommand.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export default class MergeCellsCommand extends Command {
const table = findAncestor( 'table', firstTableCell );

// Remove rows and columns that become empty (have no anchored cells).
removeEmptyRowsColumns( table, tableUtils, writer.batch );
removeEmptyRowsColumns( table, tableUtils );

writer.setSelection( firstTableCell, 'in' );
} );
Expand Down
10 changes: 2 additions & 8 deletions packages/ckeditor5-table/src/commands/removerowcommand.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,20 +60,14 @@ export default class RemoveRowCommand extends Command {

const columnIndexToFocus = this.editor.plugins.get( 'TableUtils' ).getCellLocation( firstCell ).column;

// Use single batch to modify table in steps but in one undo step.
const batch = model.createBatch();

model.enqueueChange( batch, () => {
model.change( writer => {
const rowsToRemove = removedRowIndexes.last - removedRowIndexes.first + 1;

this.editor.plugins.get( 'TableUtils' ).removeRows( table, {
at: removedRowIndexes.first,
rows: rowsToRemove,
batch
rows: rowsToRemove
} );
} );

model.enqueueChange( batch, writer => {
const cellToFocus = getCellToFocus( table, removedRowIndexes.first, columnIndexToFocus );

writer.setSelection( writer.createPositionAt( cellToFocus, 0 ) );
Expand Down
100 changes: 0 additions & 100 deletions packages/ckeditor5-table/src/converters/downcast.js
Original file line number Diff line number Diff line change
Expand Up @@ -173,79 +173,6 @@ export function downcastInsertCell() {
} );
}

/**
* Conversion helper that acts on heading row table attribute change.
*
* This converter will:
*
* * Rename `<td>` to `<th>` elements or vice versa depending on headings.
* * Create `<thead>` or `<tbody>` elements if needed.
* * Remove empty `<thead>` or `<tbody>` if needed.
*
* @returns {Function} Conversion helper.
*/
export function downcastTableHeadingRowsChange() {
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 = getViewTable( figureElement );

const oldRows = data.attributeOldValue;
const newRows = data.attributeNewValue;

// The head section has grown so move rows from <tbody> to <thead>.
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 <thead>.
for ( const tableRow of rowsToMove ) {
for ( const tableCell of tableRow.getChildren() ) {
renameViewTableCell( tableCell, 'th', conversionApi );
}
}
}
// The head section has shrunk so move rows from <thead> to <tbody>.
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 <thead> to <tbody> in reverse order at the beginning of a <tbody>.

const viewTableBody = getOrCreateTableSection( 'tbody', viewTable, conversionApi );
moveViewRowsToTableSection( rowsToMove, viewTableBody, conversionApi, 0 );

// Check if cells moved from <thead> to <tbody> requires renaming to <td> 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 tableSlot of tableWalker ) {
renameViewTableCellIfRequired( tableSlot, tableAttributes, conversionApi );
}
}

// Cleanup: Ensure that thead & tbody sections are removed if left empty after moving rows. See #6437, #6391.
removeTableSectionIfEmpty( 'thead', viewTable, conversionApi );
removeTableSectionIfEmpty( 'tbody', viewTable, conversionApi );

function isBetween( index, lower, upper ) {
return index > lower && index < upper;
}
} );
}

/**
* Conversion helper that acts on heading column table attribute change.
*
Expand Down Expand Up @@ -333,11 +260,6 @@ function renameViewTableCell( tableCell, desiredCellElementName, conversionApi )
const viewWriter = conversionApi.writer;
const viewCell = conversionApi.mapper.toViewElement( tableCell );

// View cell might be not yet converted - skip it as it will be properly created by cell converter later on.
if ( !viewCell ) {
return;
}

const editable = viewWriter.createEditableElement( desiredCellElementName, viewCell.getAttributes() );
const renamedCell = toWidgetEditable( editable, viewWriter );

Expand Down Expand Up @@ -545,28 +467,6 @@ function removeTableSectionIfEmpty( sectionName, tableElement, conversionApi ) {
}
}

// Moves view table rows associated with passed model rows to the provided table section element.
//
// **Note**: This method will skip not converted table rows.
//
// @param {Array.<module:engine/model/element~Element>} rowsToMove
// @param {module:engine/view/element~Element} viewTableSection
// @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
// @param {Number|'end'|'before'|'after'} offset Offset or one of the flags.
function moveViewRowsToTableSection( rowsToMove, viewTableSection, conversionApi, offset ) {
for ( const tableRow of rowsToMove ) {
const viewTableRow = conversionApi.mapper.toViewElement( tableRow );

// View table row might be not yet converted - skip it as it will be properly created by cell converter later on.
if ( viewTableRow ) {
conversionApi.writer.move(
conversionApi.writer.createRangeOn( viewTableRow ),
conversionApi.writer.createPositionAt( viewTableSection, offset )
);
}
}
}

// Finds a '<table>' element inside the `<figure>` widget.
//
// @param {module:engine/view/element~Element} viewFigure
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* @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/table-heading-rows-refresh-post-fixer
*/

/**
* Injects a table post-fixer into the model which marks the table in the differ to have it re-rendered.
*
* Table heading rows are represented in the model by a `headingRows` attribute. However, in the view, it's represented as separate
* sections of the table (`<thead>` or `<tbody>`) and changing `headingRows` attribute requires moving table rows between two sections.
* This causes problems with structural changes in a table (like adding and removing rows) thus atomic converters cannot be used.
*
* When table `headingRows` attribute changes, the entire table is re-rendered.
*
* @param {module:engine/model/model~Model} model
*/
export default function injectTableHeadingRowsRefreshPostFixer( model ) {
model.document.registerPostFixer( () => tableHeadingRowsRefreshPostFixer( model ) );
}

function tableHeadingRowsRefreshPostFixer( model ) {
const differ = model.document.differ;

// Stores tables to be refreshed so the table will be refreshed once for multiple changes.
const tablesToRefresh = new Set();

for ( const change of differ.getChanges() ) {
if ( change.type != 'attribute' ) {
continue;
}

const element = change.range.start.nodeAfter;

if ( element && element.is( 'table' ) && change.attributeKey == 'headingRows' ) {
tablesToRefresh.add( element );
}
}

if ( tablesToRefresh.size ) {
// @if CK_DEBUG_TABLE // console.log( `Post-fixing table: refreshing heading rows (${ tablesToRefresh.size }).` );

for ( const table of tablesToRefresh.values() ) {
differ.refreshItem( table );
}

return true;
}

return false;
}
6 changes: 2 additions & 4 deletions packages/ckeditor5-table/src/tableclipboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ function prepareTableForPasting( selectedTableCells, pastedDimensions, writer, t
selection.lastRow += pastedDimensions.height - 1;
selection.lastColumn += pastedDimensions.width - 1;

expandTableSize( selectedTable, selection.lastRow + 1, selection.lastColumn + 1, writer, tableUtils );
expandTableSize( selectedTable, selection.lastRow + 1, selection.lastColumn + 1, tableUtils );
}

// In case of expanding selection we do not reset the selection so in this case we will always try to fix selection
Expand Down Expand Up @@ -320,21 +320,19 @@ function replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selected
}

// Expand table (in place) to expected size.
function expandTableSize( table, expectedHeight, expectedWidth, writer, tableUtils ) {
function expandTableSize( table, expectedHeight, expectedWidth, tableUtils ) {
const tableWidth = tableUtils.getColumns( table );
const tableHeight = tableUtils.getRows( table );

if ( expectedWidth > tableWidth ) {
tableUtils.insertColumns( table, {
batch: writer.batch,
at: tableWidth,
columns: expectedWidth - tableWidth
} );
}

if ( expectedHeight > tableHeight ) {
tableUtils.insertRows( table, {
batch: writer.batch,
at: tableHeight,
rows: expectedHeight - tableHeight
} );
Expand Down
8 changes: 4 additions & 4 deletions packages/ckeditor5-table/src/tableediting.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ import {
downcastInsertRow,
downcastInsertTable,
downcastRemoveRow,
downcastTableHeadingColumnsChange,
downcastTableHeadingRowsChange
downcastTableHeadingColumnsChange
} from './converters/downcast';

import InsertTableCommand from './commands/inserttablecommand';
Expand All @@ -36,6 +35,7 @@ import TableUtils from '../src/tableutils';
import injectTableLayoutPostFixer from './converters/table-layout-post-fixer';
import injectTableCellParagraphPostFixer from './converters/table-cell-paragraph-post-fixer';
import injectTableCellRefreshPostFixer from './converters/table-cell-refresh-post-fixer';
import injectTableHeadingRowsRefreshPostFixer from './converters/table-heading-rows-refresh-post-fixer';

import '../theme/tableediting.css';

Expand Down Expand Up @@ -113,9 +113,8 @@ export default class TableEditing extends Plugin {
conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } );
conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } );

// Table heading rows and columns conversion.
// Table heading columns conversion (change of heading rows requires reconversion of the whole table).
conversion.for( 'editingDowncast' ).add( downcastTableHeadingColumnsChange() );
conversion.for( 'editingDowncast' ).add( downcastTableHeadingRowsChange() );

// Define all the commands.
editor.commands.add( 'insertTable', new InsertTableCommand( editor ) );
Expand Down Expand Up @@ -143,6 +142,7 @@ export default class TableEditing extends Plugin {
editor.commands.add( 'selectTableRow', new SelectRowCommand( editor ) );
editor.commands.add( 'selectTableColumn', new SelectColumnCommand( editor ) );

injectTableHeadingRowsRefreshPostFixer( model );
injectTableLayoutPostFixer( model );
injectTableCellRefreshPostFixer( model );
injectTableCellParagraphPostFixer( model );
Expand Down
46 changes: 23 additions & 23 deletions packages/ckeditor5-table/src/tableutils.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export default class TableUtils extends Plugin {

// 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 );
updateNumericAttribute( 'headingRows', headingRows + rowsToInsert, table, writer, 0 );
}

// Inserting at the end or at the beginning of a table doesn't require to calculate anything special.
Expand Down Expand Up @@ -309,9 +309,8 @@ export default class TableUtils extends Plugin {
const rowsToRemove = options.rows || 1;
const first = options.at;
const last = first + rowsToRemove - 1;
const batch = options.batch || 'default';

model.enqueueChange( batch, writer => {
model.change( writer => {
// Removing rows from the table require that most calculations to be done prior to changing table structure.
// Preparations must be done in the same enqueueChange callback to use the current table structure.

Expand All @@ -337,11 +336,15 @@ export default class TableUtils extends Plugin {
updateNumericAttribute( 'rowspan', rowspan, cell, writer );
}

// 2d. Remove empty columns (without anchored cells) if there are any.
removeEmptyColumns( table, this );
// 2d. Adjust heading rows if removed rows were in a heading section.
updateHeadingRows( table, first, last, writer );

// 2e. Adjust heading rows if removed rows were in a heading section.
updateHeadingRows( table, first, last, model, batch );
// 2e. Remove empty columns (without anchored cells) if there are any.
if ( !removeEmptyColumns( table, this ) ) {
// If there wasn't any empty columns then we still need to check if this wasn't called
// because of cleaning empty rows and we only removed one of them.
removeEmptyRows( table, this );
}
} );
}

Expand Down Expand Up @@ -396,7 +399,11 @@ export default class TableUtils extends Plugin {
}

// Remove empty rows that could appear after removing columns.
removeEmptyRows( table, this, writer.batch );
if ( !removeEmptyRows( table, this ) ) {
// If there wasn't any empty rows then we still need to check if this wasn't called
// because of cleaning empty columns and we only removed one of them.
removeEmptyColumns( table, this );
}
} );
}

Expand Down Expand Up @@ -776,21 +783,14 @@ function adjustHeadingColumns( table, removedColumnIndexes, writer ) {
}

// Calculates a new heading rows value for removing rows from heading section.
function updateHeadingRows( table, first, last, model, batch ) {
// Must be done after the changes in table structure (removing rows).
// Otherwise the downcast converter for headingRows attribute will fail.
// See https://github.com/ckeditor/ckeditor5/issues/6391.
//
// Must be completely wrapped in enqueueChange to get the current table state (after applying other enqueued changes).
model.enqueueChange( batch, writer => {
const headingRows = table.getAttribute( 'headingRows' ) || 0;

if ( first < headingRows ) {
const newRows = last < headingRows ? headingRows - ( last - first + 1 ) : first;

updateNumericAttribute( 'headingRows', newRows, table, writer, 0 );
}
} );
function updateHeadingRows( table, first, last, writer ) {
const headingRows = table.getAttribute( 'headingRows' ) || 0;

if ( first < headingRows ) {
const newRows = last < headingRows ? headingRows - ( last - first + 1 ) : first;

updateNumericAttribute( 'headingRows', newRows, table, writer, 0 );
}
}

// Finds cells that will be:
Expand Down
Loading

0 comments on commit 8b83c9b

Please sign in to comment.