Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#7454: Down-casting whole table on headingRows attribute change #7572

Merged
merged 7 commits into from
Jul 10, 2020
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
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
28 changes: 24 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 Down Expand Up @@ -113,9 +112,11 @@ 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() );

// Table heading rows change requires reconversion of the whole table.
this.listenTo( model, 'applyOperation', headingRowsAttributeChangeHandler( model ) );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@scofalik & @Reinmar - I need to double check this as this changes how table is converted.

After numerous bugs with changing headings + removing rows we concluded that enough is enough. The table models sucks. Downcasting headingRows change requires moving <tr> in the view from <thead> to <tbody> or vice-versa. It can fail in multiple scenarios (#6502, #6437, #6544, #6502, #6391, #6406 + #7454) and would potentially never be OK.

Here (#7454) there was a problem with table layout post-fixer which was called after each enqueueChange() - and thus we hit limit of what API is allowing us to do. The solution is to wait with conversion and post-fixing after all table model changes are done and that table attribute converter would know that also table children are changing.

Possible considered solutions were:

  1. A "hack": set heading rows to 0, remove/add rows, set heading rows to proper value - but it lacks an API for not converting stuff at once.
  2. Some API to mark that some changes need to be either converted at once or should not trigger model post-fixers.
  3. Mark table to be re-rendered at once.

We choose option 3 using differ.refreshItem() - which is called on every headingRows attribute operation as It requires to also take external operations into account when dealing with this bug. Also the API for many TableUtils get simplified but not passing a batch instance (:tada:).


In other words the solution is to re-render whole table in the view if headingRows attribute was changed by any means.


What is needed - some kind of 👍 / 👎 - it might impact collaboration features in some way. Do we need better API for requiring re-converting whole chunk of content?

ps.: @Reinmar this is also some sort of ADR proposal (without sections - but have intro, considered steps and proposed solution).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this is done on applyOperation not in a post-fixer?

As a rule of thumb, we try to avoid doing stuff on applyOperation - this is the last resort solution. Getting between multiple operations is a risky business. OTOH, I see that you only mark the table to refresh, so no changes in the model tree are done. Might be okay.

The one difference that quickly comes to mind is that you might have an attribute operation for heading rows and then an operation that reverses it, so in the end, there's no change. Post-fixer would not refresh the table, AFAIU. 

So, once again, if it can be done through post-fixer and there are no counterarguments, I think I'd go with post-fixer. But I don't see real big disadvantages in doing this with applyOperation either, other than we simply avoid it because it is a more vulnerable "place", if you know what I mean.


// Define all the commands.
editor.commands.add( 'insertTable', new InsertTableCommand( editor ) );
Expand Down Expand Up @@ -155,3 +156,22 @@ export default class TableEditing extends Plugin {
return [ TableUtils ];
}
}

// Model#applyOperation handler for headingRows attribute changes.
function headingRowsAttributeChangeHandler( model ) {
return ( event, [ operation ] ) => {
if ( !operation.isDocumentOperation ) {
return;
}

if ( operation.type != 'addAttribute' && operation.type != 'removeAttribute' && operation.type != 'changeAttribute' ) {
return;
}

const element = operation.range.getContainedElement();

if ( element && element.is( 'table' ) && operation.key == 'headingRows' ) {
model.document.differ.refreshItem( element );
}
};
}
32 changes: 17 additions & 15 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, model );

// 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,13 +783,8 @@ 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 => {
function updateHeadingRows( table, first, last, model ) {
model.change( writer => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that this is not needed any more - it is already called in a model.change() block.

const headingRows = table.getAttribute( 'headingRows' ) || 0;

if ( first < headingRows ) {
Expand Down
36 changes: 21 additions & 15 deletions packages/ckeditor5-table/src/utils/structure.js
Original file line number Diff line number Diff line change
Expand Up @@ -342,13 +342,17 @@ export function removeEmptyColumns( table, tableUtils ) {
return cellsCount ? result : [ ...result, column ];
}, [] );

// @if CK_DEBUG_TABLE // emptyColumns.length > 0 && console.log( `Removing empty columns: ${ emptyColumns.join( ', ' ) }.` );
if ( emptyColumns.length > 0 ) {
// Remove only last empty column because it will recurrently trigger removing empty rows.
const emptyColumn = emptyColumns[ emptyColumns.length - 1 ];

emptyColumns.reverse().forEach( column => {
tableUtils.removeColumns( table, { at: column } );
} );
// @if CK_DEBUG_TABLE // console.log( `Removing empty column: ${ emptyColumn }.` );
tableUtils.removeColumns( table, { at: emptyColumn } );

return emptyColumns.length > 0;
return true;
}

return false;
}

/**
Expand Down Expand Up @@ -380,10 +384,9 @@ export function removeEmptyColumns( table, tableUtils ) {
* @protected
* @param {module:engine/model/element~Element} table
* @param {module:table/tableutils~TableUtils} tableUtils
* @param {module:engine/model/batch~Batch|null} [batch] Batch that should be used for removing empty rows.
* @returns {Boolean} True if removed some rows.
*/
export function removeEmptyRows( table, tableUtils, batch ) {
export function removeEmptyRows( table, tableUtils ) {
const emptyRows = [];

for ( let rowIndex = 0; rowIndex < table.childCount; rowIndex++ ) {
Expand All @@ -394,13 +397,17 @@ export function removeEmptyRows( table, tableUtils, batch ) {
}
}

// @if CK_DEBUG_TABLE // emptyRows.length > 0 && console.log( `Removing empty rows: ${ emptyRows.join( ', ' ) }.` );
if ( emptyRows.length > 0 ) {
// Remove only last empty row because it will recurrently trigger removing empty columns.
const emptyRow = emptyRows[ emptyRows.length - 1 ];

emptyRows.reverse().forEach( row => {
tableUtils.removeRows( table, { at: row, batch } );
} );
// @if CK_DEBUG_TABLE // console.log( `Removing empty row: ${ emptyRow }.` );
tableUtils.removeRows( table, { at: emptyRow } );

return true;
}

return emptyRows.length > 0;
return false;
}

/**
Expand Down Expand Up @@ -428,14 +435,13 @@ export function removeEmptyRows( table, tableUtils, batch ) {
* @protected
* @param {module:engine/model/element~Element} table
* @param {module:table/tableutils~TableUtils} tableUtils
* @param {module:engine/model/batch~Batch|null} [batch] Batch that should be used for removing empty rows.
*/
export function removeEmptyRowsColumns( table, tableUtils, batch ) {
export function removeEmptyRowsColumns( table, tableUtils ) {
const removedColumns = removeEmptyColumns( table, tableUtils );

// If there was some columns removed then cleaning empty rows was already triggered.
if ( !removedColumns ) {
removeEmptyRows( table, tableUtils, batch );
removeEmptyRows( table, tableUtils );
}
}

Expand Down
Loading