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

Commit b91d967

Browse files
authored
Merge pull request #1343 from ckeditor/t/1267
Fix: `model.DocumentSelection` should update it's attributes after each change, including external changes. Closes #1267.
2 parents 3ea70f3 + d90354f commit b91d967

File tree

3 files changed

+127
-45
lines changed

3 files changed

+127
-45
lines changed

src/model/documentselection.js

Lines changed: 34 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,6 @@ import toMap from '@ckeditor/ckeditor5-utils/src/tomap';
1919
import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
2020
import log from '@ckeditor/ckeditor5-utils/src/log';
2121

22-
const attrOpTypes = new Set(
23-
[ 'addAttribute', 'removeAttribute', 'changeAttribute', 'addRootAttribute', 'removeRootAttribute', 'changeRootAttribute' ]
24-
);
25-
2622
const storePrefix = 'selection:';
2723

2824
/**
@@ -533,29 +529,13 @@ class LiveSelection extends Selection {
533529
}
534530
} );
535531

536-
this.listenTo( this._model, 'applyOperation', ( evt, args ) => {
537-
const operation = args[ 0 ];
538-
539-
if ( !operation.isDocumentOperation ) {
540-
return;
541-
}
542-
543-
// Whenever attribute operation is performed on document, update selection attributes.
544-
// This is not the most efficient way to update selection attributes, but should be okay for now.
545-
if ( attrOpTypes.has( operation.type ) ) {
546-
this._updateAttributes( false );
547-
}
548-
549-
const batch = operation.delta.batch;
532+
this.listenTo( this._document, 'change', ( evt, batch ) => {
533+
// Update selection's attributes.
534+
this._updateAttributes( false );
550535

551-
// Batch may not be passed to the document#change event in some tests.
552-
// See https://github.com/ckeditor/ckeditor5-engine/issues/1001#issuecomment-314202352
553-
if ( batch ) {
554-
// Whenever element which had selection's attributes stored in it stops being empty,
555-
// the attributes need to be removed.
556-
clearAttributesStoredInElement( operation, this._model, batch );
557-
}
558-
}, { priority: 'low' } );
536+
// Clear selection attributes from element if no longer empty.
537+
clearAttributesStoredInElement( this._model, batch );
538+
} );
559539

560540
this.listenTo( this._model, 'applyOperation', () => {
561541
while ( this._fixGraveyardRangesData.length ) {
@@ -823,6 +803,9 @@ class LiveSelection extends Selection {
823803
// Internal method for removing `LiveSelection` attribute. Supports attribute priorities (through `directChange`
824804
// parameter).
825805
//
806+
// NOTE: Even if attribute is not present in the selection but is provided to this method, it's priority will
807+
// be changed according to `directChange` parameter.
808+
//
826809
// @private
827810
// @param {String} key Attribute key.
828811
// @param {Boolean} [directChange=true] `true` if the change is caused by `Selection` API, `false` if change
@@ -837,16 +820,16 @@ class LiveSelection extends Selection {
837820
return false;
838821
}
839822

823+
// Update priorities map.
824+
this._attributePriority.set( key, priority );
825+
840826
// Don't do anything if value has not changed.
841827
if ( !super.hasAttribute( key ) ) {
842828
return false;
843829
}
844830

845831
this._attrs.delete( key );
846832

847-
// Update priorities map.
848-
this._attributePriority.set( key, priority );
849-
850833
return true;
851834
}
852835

@@ -1021,24 +1004,30 @@ function getAttrsIfCharacter( node ) {
10211004
}
10221005

10231006
// Removes selection attributes from element which is not empty anymore.
1024-
function clearAttributesStoredInElement( operation, model, batch ) {
1025-
let changeParent = null;
1026-
1027-
if ( operation.type == 'insert' ) {
1028-
changeParent = operation.position.parent;
1029-
} else if ( operation.type == 'move' || operation.type == 'reinsert' || operation.type == 'remove' ) {
1030-
changeParent = operation.getMovedRangeStart().parent;
1031-
}
1007+
//
1008+
// @private
1009+
// @param {module:engine/model/model~Model} model
1010+
// @param {module:engine/model/batch~Batch} batch
1011+
function clearAttributesStoredInElement( model, batch ) {
1012+
const differ = model.document.differ;
1013+
1014+
for ( const entry of differ.getChanges() ) {
1015+
if ( entry.type != 'insert' ) {
1016+
continue;
1017+
}
10321018

1033-
if ( !changeParent || changeParent.isEmpty ) {
1034-
return;
1035-
}
1019+
const changeParent = entry.position.parent;
1020+
const isNoLongerEmpty = entry.length === changeParent.maxOffset;
10361021

1037-
model.enqueueChange( batch, writer => {
1038-
const storedAttributes = Array.from( changeParent.getAttributeKeys() ).filter( key => key.startsWith( storePrefix ) );
1022+
if ( isNoLongerEmpty ) {
1023+
model.enqueueChange( batch, writer => {
1024+
const storedAttributes = Array.from( changeParent.getAttributeKeys() )
1025+
.filter( key => key.startsWith( storePrefix ) );
10391026

1040-
for ( const key of storedAttributes ) {
1041-
writer.removeAttribute( key, changeParent );
1027+
for ( const key of storedAttributes ) {
1028+
writer.removeAttribute( key, changeParent );
1029+
}
1030+
} );
10421031
}
1043-
} );
1032+
}
10441033
}

tests/model/documentselection.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,21 @@ describe( 'DocumentSelection', () => {
439439

440440
expect( selection.getAttribute( 'foo' ) ).to.be.undefined;
441441
} );
442+
443+
it( 'should prevent auto update of the attribute even if attribute is not preset yet', () => {
444+
selection._setTo( new Position( root, [ 0, 1 ] ) );
445+
446+
// Remove "foo" attribute that is not present in selection yet.
447+
expect( selection.hasAttribute( 'foo' ) ).to.be.false;
448+
selection._removeAttribute( 'foo' );
449+
450+
// Trigger selecton auto update on document change. It should not get attribute from surrounding text;
451+
model.change( writer => {
452+
writer.setAttribute( 'foo', 'bar', Range.createIn( fullP ) );
453+
} );
454+
455+
expect( selection.getAttribute( 'foo' ) ).to.be.undefined;
456+
} );
442457
} );
443458

444459
describe( '_getStoredAttributes()', () => {
@@ -1066,6 +1081,9 @@ describe( 'DocumentSelection', () => {
10661081
)
10671082
) );
10681083

1084+
// Attributes are auto updated on document change.
1085+
model.change( () => {} );
1086+
10691087
expect( selection.getAttribute( 'foo' ) ).to.equal( 'bar' );
10701088
expect( spyAttribute.calledOnce ).to.be.true;
10711089
} );

tests/tickets/1267.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
3+
* For licensing, see LICENSE.md.
4+
*/
5+
6+
/* globals document */
7+
8+
import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
9+
import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
10+
import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
11+
import Position from '../../src/model/position';
12+
import Range from '../../src/model/range';
13+
import { setData as setModelData, getData as getModelData } from '../../src/dev-utils/model';
14+
15+
describe( 'Bug ckeditor5-engine#1267', () => {
16+
let element, editor, model;
17+
18+
beforeEach( () => {
19+
element = document.createElement( 'div' );
20+
document.body.appendChild( element );
21+
22+
return ClassicTestEditor
23+
.create( element, { plugins: [ Paragraph, Bold ] } )
24+
.then( newEditor => {
25+
editor = newEditor;
26+
model = editor.model;
27+
} );
28+
} );
29+
30+
afterEach( () => {
31+
element.remove();
32+
33+
return editor.destroy();
34+
} );
35+
36+
it( 'selection should not retain attributes after external change removal', () => {
37+
setModelData( model,
38+
'<paragraph>foo bar baz</paragraph>' +
39+
'<paragraph>foo <$text bold="true">bar{}</$text> baz</paragraph>'
40+
);
41+
42+
// Remove second paragraph where selection is placed.
43+
model.enqueueChange( 'transparent', writer => {
44+
writer.remove( Range.createFromPositionAndShift( new Position( model.document.getRoot(), [ 1 ] ), 1 ) );
45+
} );
46+
47+
expect( getModelData( model ) ).to.equal( '<paragraph>foo bar baz[]</paragraph>' );
48+
} );
49+
50+
it( 'selection should retain attributes set manually', () => {
51+
setModelData( model,
52+
'<paragraph>foo bar baz</paragraph>' +
53+
'<paragraph>foo bar baz</paragraph>' +
54+
'<paragraph>[]</paragraph>'
55+
);
56+
57+
// Execute bold command when selection is inside empty paragraph.
58+
editor.execute( 'bold' );
59+
expect( getModelData( model ) ).to.equal(
60+
'<paragraph>foo bar baz</paragraph>' +
61+
'<paragraph>foo bar baz</paragraph>' +
62+
'<paragraph selection:bold="true"><$text bold="true">[]</$text></paragraph>'
63+
);
64+
65+
// Remove second paragraph.
66+
model.enqueueChange( 'transparent', writer => {
67+
writer.remove( Range.createFromPositionAndShift( new Position( model.document.getRoot(), [ 1 ] ), 1 ) );
68+
} );
69+
70+
// Selection attributes set by command should stay as they were.
71+
expect( getModelData( model ) ).to.equal(
72+
'<paragraph>foo bar baz</paragraph>' +
73+
'<paragraph selection:bold="true"><$text bold="true">[]</$text></paragraph>' );
74+
} );
75+
} );

0 commit comments

Comments
 (0)