-
Notifications
You must be signed in to change notification settings - Fork 40
T/897: Make Position/Range immutable. #1175
Conversation
I just started to review this, but I wonder if it will be a positive change at all. AFAICS now positions will be created in some scenarios when they weren't, pretty much a lot in OT. Edit: Maybe when we will refactor OT we will be able to introduce methods that work on |
src/model/operation/transform.js
Outdated
@@ -179,25 +179,29 @@ const ot = { | |||
// Take the start and the end of the range and transform them by deletion of moved nodes. | |||
// Note that if rangeB was inside AttributeOperation range, only difference.end will be transformed. | |||
// This nicely covers the joining simplification we did in the previous step. | |||
difference.start = difference.start._getTransformedByDeletion( b.sourcePosition, b.howMany ); | |||
difference.end = difference.end._getTransformedByDeletion( b.sourcePosition, b.howMany ); | |||
const range = new Range( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please change this const name to differenceTransformed
.
src/model/operation/transform.js
Outdated
} | ||
|
||
if ( common !== null ) { | ||
// Here we do not need to worry that newTargetPosition is inside moved range, because that | ||
// would mean that the MoveOperation targets into itself, and that is incorrect operation. | ||
// Instead, we calculate the new position of that part of original range. | ||
common.start = common.start._getCombined( b.sourcePosition, b.getMovedRangeStart() ); | ||
common.end = common.end._getCombined( b.sourcePosition, b.getMovedRangeStart() ); | ||
const range = new Range( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
commonTransformed
.
src/model/position.js
Outdated
// This position can't be affected if deletion was in a different root. | ||
if ( this.root != deletePosition.root ) { | ||
return transformed; | ||
return this; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are we sure this is not harmful in any way? I know that positions are now immutable but this method, by name, suggests that a new instance is returned. I think we don't have (and maybe won't have) such case in code, but one thing that comes to my mind is storing values in Map
with Position
s as keys.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah.. maybe I've gone too deep into removing new instances. Here might be better to always create a copy.
src/model/position.js
Outdated
// This position can't be affected if insertion was in a different root. | ||
if ( this.root != insertPosition.root ) { | ||
return transformed; | ||
return this; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same as above.
src/model/position.js
Outdated
}; | ||
} | ||
|
||
/** | ||
* Returns a copy of this position that is updated by removing `howMany` nodes starting from `deletePosition`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Docs might need an update if we won't be returning a copy in every scenario (see comment in code below).
src/model/position.js
Outdated
// @private | ||
// @param {Array.<Number>} path Position path. See {@link module:engine/model/position~Position#path}. | ||
function getOffset( path ) { | ||
return last( path ) || 0; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why || 0
? Wouldn't undefined
be better?
Anyway, it is an incorrect situation if path
has no items. So maybe just return last( path );
. I am not even sure we need this helper if it is that easy.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't want to change logic behind this AFAIR. It was:
get offset() {
return last( path ) || 0;
}
I don't know if it would have any side effects or not.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Weird. Position always has to have an offset :S. Try if anything breaks without it ;).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@scofalik: I was able to fail a test after removing || 0
:
expect( new Position( root, [ false ] ) ).to.have.property( 'offset' ).that.equals( 0 );
expect( new Position( root, [ undefined ] ) ).to.have.property( 'offset' ).that.equals( 0 );
But this looks like a bad API use - so we might as well drop || 0
check as position
accepts only non-empty array as path.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I'd remove || 0
and the test.
src/model/range.js
Outdated
@@ -804,13 +829,13 @@ export default class Range { | |||
// 4. At this moment we don't need the original range. | |||
// We are going to modify the result and we need to return a new instance of Range. | |||
// We have to create a copy of the reference range. | |||
const result = new this( ref.start, ref.end ); | |||
let result = new this( ref.start, ref.end ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AFAICS, range result
is not used directly, just its start
and end
properties. Maybe we could just go with storing Position
s and it would be better?
@@ -204,33 +204,37 @@ export default class TreeWalker { | |||
*/ | |||
_next() { | |||
const previousPosition = this.position; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe we can drop position
variable in this function? It is confusing and I am not really sure it is needed.
@@ -278,27 +285,31 @@ export default class TreeWalker { | |||
*/ | |||
_previous() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same with position
here.
src/view/treewalker.js
Outdated
@@ -187,7 +187,7 @@ export default class TreeWalker { | |||
* @returns {module:engine/view/treewalker~TreeWalkerValue} return.value Information about taken step. | |||
*/ | |||
_next() { | |||
let position = Position.createFromPosition( this.position ); | |||
let position = this.position; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same with position
.
src/view/treewalker.js
Outdated
@@ -292,7 +293,7 @@ export default class TreeWalker { | |||
* @returns {module:engine/view/treewalker~TreeWalkerValue} return.value Information about taken step. | |||
*/ | |||
_previous() { | |||
let position = Position.createFromPosition( this.position ); | |||
let position = this.position; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same with position
.
All in all, good job! @Reinmar can you check what impact does it have on the original issue? |
Could you make PRs out of commits in other repos? |
@Reinmar @jodator Original issue described problem with copying selection. Unfortunately, this PR only partially fixes it. Edit: sorry, I got confused -- above concerns Edit2: So I guess, that this should change too: * getRanges() {
for ( const range of this._ranges ) {
yield Range.createFromRange( range );
}
} Both in Same with |
src/model/position.js
Outdated
* @param {module:engine/model/position~Position} position Position of which path should be modified. | ||
* @param {Array.<Number>} path Position path. See {@link module:engine/model/position~Position#path}. | ||
*/ | ||
export function setPath( position, path ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would make this a protected class method.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have mixed feelings about this.
From one point we don't want to mess around with position path. On the other hand, though, we need this or LivePosition
won't work (there is an exactly same problem with LiveRange#start
and LiveRange#end
).
If these are protected methods then we don't have to really refactor some parts of the code. Just use the new protected methods! But is this really the way to go?
So IDK, I am torn. I liked this as a utility function because it is more discouraging to use it. But IDK. Maybe LiveRange
should be in the same file as Range
. Then we would just use the symbol defined in range.js
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For me, it the matter of convention. We have classes with protected methods exposed for other related classes in modules. For instance:
_execute() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know what is the purpose of @protected
tag and how we use it. The question is whether we want to have such @protected
method at all. Because if we do, we can revert (or rather re-do) many of the changes proposed in this PR (at least in OT and tree walkers as these are used multiple time consecutively).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Now I see that also there is a symbol used for the property. I would go with the @protected
property (without a symbol) and public getters. We have such pair already in some places in the code. We can even write that the property is exposed only for LiveRange
and should not be used anywhere.
src/model/position.js
Outdated
@@ -13,6 +13,9 @@ import compareArrays from '@ckeditor/ckeditor5-utils/src/comparearrays'; | |||
import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; | |||
import Text from './text'; | |||
|
|||
const _path = Symbol( 'path' ); | |||
const _root = Symbol( 'root' ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We don't use symbols for private properties. @private
is enough.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is not really @private
, it is read-only. Also, we do use Symbol
s in a couple of places. I think these are fine here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But symbols make them private too.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But symbols make them private too.
A bit better hidden but also accessible (more work required though...) also not enumerable.
But I get that in CKEditor 5 there is already established convention and using straightforward this._path
in child classes would be simpler.
I only don like "duplication" on object as the getter this.path
would just return this._path
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Feel free to create a separate ticket to change it, but since we already do it this way, we should follow the convention:
ckeditor5-engine/src/model/rootelement.js
Lines 53 to 55 in 79da6fb
get document() { | |
return this._doc; | |
} |
ckeditor5-engine/src/view/selection.js
Lines 107 to 119 in 79da6fb
get isFake() { | |
return this._isFake; | |
} | |
/** | |
* Returns fake selection label. | |
* | |
* @see #setFake | |
* @returns {String} | |
*/ | |
get fakeSelectionLabel() { | |
return this._fakeSelectionLabel; | |
} |
ckeditor5-engine/src/view/text.js
Lines 58 to 60 in 79da6fb
get data() { | |
return this._data; | |
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I never liked this._data
and was wondering why it is this way. I guess I will like to change it after more important subjects are dealt with.
Please don't merge this PR before we'll release alpha.2. It's a bit risky change. |
Since alpha.2 is out, please conclude this PR and let us know when we could have a final check on it. |
So probably directe getters are to be created as it would make no sense for |
No matter what get ranges() {
return this._ranges;
} Is an incorrect solution if you ask me. As for |
return [ ...this._ranges ]; ? |
Technically it is fine. Although it was a generator function before, and some people on this project love their generators... |
I am for returning array, because in this case you always want to handle all of them. If not, there are already |
I would go with: get ranges() {
return this. _ranges[ Symbol.iterator ]();
} This way you will not create any new object and it will be still not mutable. |
…() and getLastRange() methods.
@scofalik I've done with all the suggested changes. I've also removed |
src/model/range.js
Outdated
@@ -291,8 +311,11 @@ export default class Range { | |||
ranges.push( new Range( pos, pos.getShiftedBy( howMany ) ) ); | |||
} | |||
|
|||
pos.path = pos.path.slice( 0, -1 ); | |||
pos.offset++; | |||
const path = pos.getParentPath(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is same as Position.createAfter( pos.parent )
?
src/model/range.js
Outdated
@@ -804,13 +839,14 @@ export default class Range { | |||
// 4. At this moment we don't need the original range. | |||
// We are going to modify the result and we need to return a new instance of Range. | |||
// We have to create a copy of the reference range. | |||
const result = new this( ref.start, ref.end ); | |||
let start = ref.start; | |||
let end = ref.end; | |||
|
|||
// 5. Ranges should be checked and glued starting from the range that is closest to the reference range. | |||
// Since ranges are sorted, start with the range with index that is closest to reference range index. | |||
for ( let i = refIndex - 1; i >= 0; i++ ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder how the hell this works if the condition is i >= 0
and i
is increment in each loop. Seems wrong.
src/view/position.js
Outdated
* @member {Number} module:engine/view/position~Position#offset | ||
*/ | ||
this.offset = offset; | ||
this[ _parent ] = parent; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Didn't we agree that this should be this._parent
and this._offset
?
src/view/range.js
Outdated
* @member {module:engine/view/position~Position} | ||
*/ | ||
this.end = end ? Position.createFromPosition( end ) : Position.createFromPosition( start ); | ||
this[ _start ] = start; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this._parent
and this._offset
const mergePosition = mergeAttributes( breakStart ); | ||
range.start = mergePosition; | ||
range.end = Position.createFromPosition( mergePosition ); | ||
mergeAttributes( breakStart ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note to self: check how mergeAttributes
works now.
I've pushed some minor changes. |
Hm, for some reason it broke: https://travis-ci.org/ckeditor/ckeditor5-engine/builds/306753449?utm_source=email&utm_medium=notification although tests pass for engine for me locally. |
I had to revert this change because it completely blows up editor tests. Also, see https://github.com/ckeditor/ckeditor5-engine/issues/897#issuecomment-346862873. |
What do you mean exactly by "blows up editor tests"? Performance issues or something else? |
Many failing tests in lists, images and undo. |
Suggested merge commit message (convention)
Other: Make
Position
andRange
immutable in model and view. Closes ckeditor/ckeditor5#4032.BREAKING CHANGE:
model.Position#offset
andview.Position#offset
now have only a getter. To modify offset usePostion#getShiftedBy()
orPosition#getShiftedTo()
.BREAKING CHANGE:
model.Position#path
andview.Position#path
now have only a getter and are immutable.BREAKING CHANGE:
model.Range#start
andmodel.Range#end
properties now have only a getter.BREAKING CHANGE:
view.Range#start
andview.Range#end
properties now have only a getter.Additional information
This PR required minor changes in other repos (centralized in: https://github.com/ckeditor/ckeditor5/tree/t/ckeditor5-engine/897)
Merge commit message for related branches:
Other:
Position
/Range
objects when using it in other methods. Now those object can be safely passed around without side effects.remove()
method inview/writer
still does something with passed