Skip to content
This repository was archived by the owner on Jun 26, 2020. It is now read-only.

Commit b29a042

Browse files
authored
Merge pull request #212 from ckeditor/t/209
Fix: Table cell post-fixer will refresh a cell only when it is needed. Closes #209.
2 parents 9f64452 + 37c524b commit b29a042

File tree

3 files changed

+131
-21
lines changed

3 files changed

+131
-21
lines changed

src/converters/table-cell-paragraph-post-fixer.js

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -42,19 +42,20 @@ function tableCellContentsPostFixer( writer, model ) {
4242
let wasFixed = false;
4343

4444
for ( const entry of changes ) {
45-
// Analyze table cells on insertion.
46-
if ( entry.type == 'insert' ) {
47-
if ( entry.name == 'table' ) {
48-
wasFixed = fixTable( entry.position.nodeAfter, writer ) || wasFixed;
49-
}
50-
51-
if ( entry.name == 'tableRow' ) {
52-
wasFixed = fixTableRow( entry.position.nodeAfter, writer ) || wasFixed;
53-
}
54-
55-
if ( entry.name == 'tableCell' ) {
56-
wasFixed = fixTableCellContent( entry.position.nodeAfter, writer ) || wasFixed;
57-
}
45+
if ( entry.type == 'insert' && entry.name == 'table' ) {
46+
wasFixed = fixTable( entry.position.nodeAfter, writer ) || wasFixed;
47+
}
48+
49+
if ( entry.type == 'insert' && entry.name == 'tableRow' ) {
50+
wasFixed = fixTableRow( entry.position.nodeAfter, writer ) || wasFixed;
51+
}
52+
53+
if ( entry.type == 'insert' && entry.name == 'tableCell' ) {
54+
wasFixed = fixTableCellContent( entry.position.nodeAfter, writer ) || wasFixed;
55+
}
56+
57+
if ( checkTableCellChange( entry ) ) {
58+
wasFixed = fixTableCellContent( entry.position.parent, writer ) || wasFixed;
5859
}
5960
}
6061

@@ -115,3 +116,17 @@ function fixTableCellContent( tableCell, writer ) {
115116
// Return true when there were text nodes to fix.
116117
return !!textNodes.length;
117118
}
119+
120+
// Check if differ change should fix table cell. This happens on:
121+
// - removing content from table cell (ie tableCell can be left empty).
122+
// - adding text node directly into a table cell.
123+
//
124+
// @param {Object} differ change entry
125+
// @returns {Boolean}
126+
function checkTableCellChange( entry ) {
127+
if ( !entry.position || !entry.position.parent.is( 'tableCell' ) ) {
128+
return false;
129+
}
130+
131+
return entry.type == 'insert' && entry.name == '$text' || entry.type == 'remove';
132+
}

src/converters/table-cell-refresh-post-fixer.js

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,63 @@ export default function injectTableCellRefreshPostFixer( model ) {
2525
function tableCellRefreshPostFixer( model ) {
2626
const differ = model.document.differ;
2727

28-
let fixed = false;
28+
// Stores cells to be refreshed so the table cell will be refreshed once for multiple changes.
29+
const cellsToRefresh = new Set();
2930

3031
for ( const change of differ.getChanges() ) {
3132
const parent = change.type == 'insert' || change.type == 'remove' ? change.position.parent : change.range.start.parent;
3233

33-
if ( parent.is( 'tableCell' ) ) {
34-
differ.refreshItem( parent );
34+
if ( parent.is( 'tableCell' ) && checkRefresh( parent, change.type ) ) {
35+
cellsToRefresh.add( parent );
36+
}
37+
}
3538

36-
fixed = true;
39+
if ( cellsToRefresh.size ) {
40+
for ( const tableCell of cellsToRefresh.values() ) {
41+
differ.refreshItem( tableCell );
3742
}
43+
44+
return true;
45+
}
46+
47+
return false;
48+
}
49+
50+
// Checks if the model table cell requires refreshing to be re-rendered to a proper state in the view.
51+
//
52+
// This methods detects changes that will require renaming <span> to <p> (or vice versa) in the view.
53+
//
54+
// This method is a simple heuristic that checks only a single change and will sometimes give a false positive result when multiple changes
55+
// will result in a state that does not require renaming in the view (but will be seen as requiring a refresh).
56+
//
57+
// For instance: a `<span>` should be renamed to `<p>` when adding an attribute to a `<paragraph>`.
58+
// But adding one attribute and removing another one will result in a false positive: the check for added attribute will see one attribute
59+
// on a paragraph and will falsy qualify such change as adding an attribute to a paragraph without any attribute.
60+
//
61+
// @param {module:engine/model/element~Element} tableCell Table cell to check.
62+
// @param {String} type Type of change.
63+
function checkRefresh( tableCell, type ) {
64+
const hasInnerParagraph = Array.from( tableCell.getChildren() ).some( child => child.is( 'paragraph' ) );
65+
66+
// If there is no paragraph in table cell then the view doesn't require refreshing.
67+
//
68+
// Why? What we really want to achieve is to make all the old paragraphs (which weren't added in this batch) to be
69+
// converted once again, so that the paragraph-in-table-cell converter can correctly create a `<p>` or a `<span>` element.
70+
// If there are no paragraphs in the table cell, we don't care.
71+
if ( !hasInnerParagraph ) {
72+
return false;
73+
}
74+
75+
// For attribute change we only refresh if there is a single paragraph as in this case we may want to change existing `<span>` to `<p>`.
76+
if ( type == 'attribute' ) {
77+
const attributesCount = Array.from( tableCell.getChild( 0 ).getAttributeKeys() ).length;
78+
79+
return tableCell.childCount === 1 && attributesCount < 2;
3880
}
3981

40-
return fixed;
82+
// For other changes (insert, remove) the `<span>` to `<p>` change is needed when:
83+
//
84+
// - another element is added to a single paragraph (childCount becomes >= 2)
85+
// - another element is removed and a single paragraph is left (childCount == 1)
86+
return tableCell.childCount <= ( type == 'insert' ? 2 : 1 );
4187
}

tests/converters/table-cell-refresh-post-fixer.js

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictest
1717
import Delete from '@ckeditor/ckeditor5-typing/src/delete';
1818

1919
describe( 'Table cell refresh post-fixer', () => {
20-
let editor, model, doc, root, view;
20+
let editor, model, doc, root, view, refreshItemSpy;
2121

2222
testUtils.createSinonSandbox();
2323

@@ -44,10 +44,12 @@ describe( 'Table cell refresh post-fixer', () => {
4444
} );
4545
editor.conversion.elementToElement( { model: 'block', view: 'div' } );
4646

47-
model.schema.extend( '$block', { allowAttributes: 'foo' } );
47+
model.schema.extend( '$block', { allowAttributes: [ 'foo', 'bar' ] } );
4848
editor.conversion.attributeToAttribute( { model: 'foo', view: 'foo' } );
49+
editor.conversion.attributeToAttribute( { model: 'bar', view: 'bar' } );
4950

5051
injectTableCellRefreshPostFixer( model );
52+
refreshItemSpy = sinon.spy( model.document.differ, 'refreshItem' );
5153
} );
5254
} );
5355

@@ -69,6 +71,7 @@ describe( 'Table cell refresh post-fixer', () => {
6971
expect( formatTable( getViewData( view, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
7072
[ '<p>00</p><p></p>' ]
7173
], { asWidget: true } ) );
74+
sinon.assert.calledOnce( refreshItemSpy );
7275
} );
7376

7477
it( 'should rename <span> to <p> on adding other block element to the same table cell', () => {
@@ -89,6 +92,7 @@ describe( 'Table cell refresh post-fixer', () => {
8992
expect( formatTable( getViewData( view, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
9093
[ '<p>00</p><div></div>' ]
9194
], { asWidget: true } ) );
95+
sinon.assert.calledOnce( refreshItemSpy );
9296
} );
9397

9498
it( 'should properly rename the same element on consecutive changes', () => {
@@ -107,6 +111,7 @@ describe( 'Table cell refresh post-fixer', () => {
107111
expect( formatTable( getViewData( view, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
108112
[ '<p>00</p><p></p>' ]
109113
], { asWidget: true } ) );
114+
sinon.assert.calledOnce( refreshItemSpy );
110115

111116
model.change( writer => {
112117
writer.remove( table.getNodeByPath( [ 0, 0, 1 ] ) );
@@ -115,6 +120,7 @@ describe( 'Table cell refresh post-fixer', () => {
115120
expect( formatTable( getViewData( view, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
116121
[ '00' ]
117122
], { asWidget: true } ) );
123+
sinon.assert.calledTwice( refreshItemSpy );
118124
} );
119125

120126
it( 'should rename <span> to <p> when setting attribute on <paragraph>', () => {
@@ -129,6 +135,7 @@ describe( 'Table cell refresh post-fixer', () => {
129135
expect( formatTable( getViewData( view, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
130136
[ '<p foo="bar">00</p>' ]
131137
], { asWidget: true } ) );
138+
sinon.assert.calledOnce( refreshItemSpy );
132139
} );
133140

134141
it( 'should rename <p> to <span> when removing all but one paragraph inside table cell', () => {
@@ -143,6 +150,7 @@ describe( 'Table cell refresh post-fixer', () => {
143150
expect( formatTable( getViewData( view, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
144151
[ '00' ]
145152
], { asWidget: true } ) );
153+
sinon.assert.calledOnce( refreshItemSpy );
146154
} );
147155

148156
it( 'should rename <p> to <span> when removing attribute from <paragraph>', () => {
@@ -157,6 +165,7 @@ describe( 'Table cell refresh post-fixer', () => {
157165
expect( formatTable( getViewData( view, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
158166
[ '<span>00</span>' ]
159167
], { asWidget: true } ) );
168+
sinon.assert.calledOnce( refreshItemSpy );
160169
} );
161170

162171
it( 'should keep <p> in the view when <paragraph> attribute value is changed', () => {
@@ -171,6 +180,42 @@ describe( 'Table cell refresh post-fixer', () => {
171180
expect( formatTable( getViewData( view, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
172181
[ '<p foo="baz">00</p>' ]
173182
], { asWidget: true } ) );
183+
// False positive: should not be called.
184+
sinon.assert.calledOnce( refreshItemSpy );
185+
} );
186+
187+
it( 'should keep <p> in the view when adding another attribute to a <paragraph> with other attributes', () => {
188+
editor.setData( viewTable( [ [ '<p foo="bar">00</p>' ] ] ) );
189+
190+
const table = root.getChild( 0 );
191+
192+
model.change( writer => {
193+
writer.setAttribute( 'bar', 'bar', table.getNodeByPath( [ 0, 0, 0 ] ) );
194+
} );
195+
196+
expect( formatTable( getViewData( view, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
197+
[ '<p bar="bar" foo="bar">00</p>' ]
198+
], { asWidget: true } ) );
199+
200+
// False positive
201+
sinon.assert.notCalled( refreshItemSpy );
202+
} );
203+
204+
it( 'should keep <p> in the view when adding another attribute to a <paragraph> and removing attribute that is already set', () => {
205+
editor.setData( viewTable( [ [ '<p foo="bar">00</p>' ] ] ) );
206+
207+
const table = root.getChild( 0 );
208+
209+
model.change( writer => {
210+
writer.setAttribute( 'bar', 'bar', table.getNodeByPath( [ 0, 0, 0 ] ) );
211+
writer.removeAttribute( 'foo', table.getNodeByPath( [ 0, 0, 0 ] ) );
212+
} );
213+
214+
expect( formatTable( getViewData( view, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
215+
[ '<p bar="bar">00</p>' ]
216+
], { asWidget: true } ) );
217+
// False positive: should not be called.
218+
sinon.assert.calledOnce( refreshItemSpy );
174219
} );
175220

176221
it( 'should keep <p> in the view when <paragraph> attribute value is changed (table cell with multiple blocks)', () => {
@@ -185,6 +230,7 @@ describe( 'Table cell refresh post-fixer', () => {
185230
expect( formatTable( getViewData( view, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
186231
[ '<p foo="baz">00</p><p>00</p>' ]
187232
], { asWidget: true } ) );
233+
sinon.assert.notCalled( refreshItemSpy );
188234
} );
189235

190236
it( 'should do nothing on rename <paragraph> to other block', () => {
@@ -199,6 +245,7 @@ describe( 'Table cell refresh post-fixer', () => {
199245
expect( formatTable( getViewData( view, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
200246
[ '<div>00</div>' ]
201247
], { asWidget: true } ) );
248+
sinon.assert.notCalled( refreshItemSpy );
202249
} );
203250

204251
it( 'should do nothing when setting attribute on block item other then <paragraph>', () => {
@@ -213,9 +260,10 @@ describe( 'Table cell refresh post-fixer', () => {
213260
expect( formatTable( getViewData( view, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
214261
[ '<div foo="bar">foo</div>' ]
215262
], { asWidget: true } ) );
263+
sinon.assert.notCalled( refreshItemSpy );
216264
} );
217265

218-
it( 'should keep <p> in the view when <paragraph> attribute value is changed (table cell with multiple blocks)', () => {
266+
it( 'should rename <p> in to <span> when removing <paragraph> (table cell with 2 paragraphs)', () => {
219267
editor.setData( viewTable( [ [ '<p>00</p><p>00</p>' ] ] ) );
220268

221269
const table = root.getChild( 0 );
@@ -227,6 +275,7 @@ describe( 'Table cell refresh post-fixer', () => {
227275
expect( formatTable( getViewData( view, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
228276
[ '<span>00</span>' ]
229277
], { asWidget: true } ) );
278+
sinon.assert.calledOnce( refreshItemSpy );
230279
} );
231280

232281
it( 'should update view selection after deleting content', () => {

0 commit comments

Comments
 (0)