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

T/643 Markers in engine #700

Merged
merged 27 commits into from
Dec 23, 2016
Merged

T/643 Markers in engine #700

merged 27 commits into from
Dec 23, 2016

Conversation

scofalik
Copy link
Contributor

@scofalik scofalik commented Nov 29, 2016

This PR introduces markers implementation in CKEditor5 engine. Fixes ckeditor/ckeditor5#3856.

Other than that some changes and fixes has been done:

  • Mapper position mapping events have changed, now callbacks receive position mapped by default algorithm (previously default mapping was a lowest priority callback). This way default mapping can be used in custom callbacks.
  • LiveRange transformation has been changed. LiveRange is not sticky anymore (which was weird from UX perspective).
  • Range.createFromRanges was introduced, it glues multiple ranges into one. Useful after a range is transformed.
  • view.Writer.move and ModelConversionDispatcher#convertMove were fixed.
  • other slight fixes.

Related PRs and follow-up issues will be linked in comments below.

@scofalik
Copy link
Contributor Author

scofalik commented Nov 29, 2016

Related PRs:
ckeditor/ckeditor5-list#28
ckeditor/ckeditor5-undo#53
ckeditor/ckeditor5-autoformat#16

Link to CI build with branch configured to have all related branches https://travis-ci.org/ckeditor/ckeditor5/builds/179751995

@scofalik
Copy link
Contributor Author

Follow-ups:
#701 - Selection is incorrect after creating intersecting marker.
#702 - Selection break view elements created by markers. This issue should be solved first as it may solve other #701 and #703
#703 - Non-collapsed selection sometimes is not created when it starts inside marker
#704 - Refactoring/enhancement in EditingController. Not a bug, not critical.
#705 - Refactoring/enhancement in conversion eco-system. Not a bug, not critical.

Also in undo:
https://github.com/ckeditor/ckeditor5-undo/issues/54
https://github.com/ckeditor/ckeditor5-undo/issues/55

if ( this._from.type == 'element' ) {
// Converting from model element to view attribute is unsupported.
if ( this._from.type != 'attribute' ) {
// Only converting from attribute to attribute is supported.
Copy link

Choose a reason for hiding this comment

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

You should throw or call console.error.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I will throw CKEditorError.

* a view attribute element, which will be used for wrapping.
* @returns {Function} Add marker converter.
*/
export function wrapMarker( elementCreator ) {
Copy link

Choose a reason for hiding this comment

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

What is the difference between wrap and wrapMarker? From the view point of view, there is nothing like marker to be wrapped.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

As far as name is concerned, I agree that the name could be different, i.e. wrapRange (while wrap could be changed to wrapItem, same for unwraps).

The functions (wrap and wrapMarker) could be merged. But there are couple of differences between them:

  1. Consumable type. It's taken from eventNameToConsumableType( evt.name ) and would be invalid for wrapMarker. This would have to change.
  2. data object passed to the converter is different. We can try to unify them a bit though. wrap gets: item, range, attributeKey, attributeOldValue and attributeNewValue. wrapMarker gets range and name. We could add data.item to wrapMarker data and make it equal to data.range, but that's it.
  3. There's difference if view element is created using "element creator function":
    wrap: elementCreator( data.attributeNewValue, data, consumable, conversionApi );
    wrapMarker: elementCreator( data, consumable, conversionApi );
    It would have to be something like:
    elementCreator( data.attributeNewValue || data.name, data, consumable, conversionApi ); and still, elementCreator functions would have to be different for converting items and ranges which means that we need to write about it in docs and it might be confusing.
  4. Both converters have special behavior that are individual for them. wrap has to "unwrap" part of view if attribute changed. wrapMarker has to support collapsed ranges. Those would be just two ifs standing next to each other, operating on different values and "active" depending on data content. We can put them together in one function, but on the same basis we could put everything into one class.

All in all the merging would not be as seamless as it looks on first sight so I am against it. Let's change wrapMarker to wrapRange and wrap to wrapItem, same for unwrap and keep them separated.

Copy link

Choose a reason for hiding this comment

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

You're right. Maybe we should call them explicit: wrapInAttribute and wrapInMarker?

Copy link

Choose a reason for hiding this comment

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

And the documentation should stress the difference.

// @param {engine.view.Position} viewPosition Position to start from when looking for furthest zero length position.
// @param {engine.conversion.Mapper} mapper Mapper to use when looking for furthest zero length position.
// @returns {engine.view.Position} Furthest zero length position.
function findFurthestZeroLengthPoint( viewPosition, mapper ) {
Copy link

Choose a reason for hiding this comment

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

Enlarge?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I will rename it to enlargeViewPosition and it will return a view.Range.

// that passed ranges are contained within one container element).
// @param {engine.view.Range} viewRange Range to break.
// @returns {Array.<engine.view.Range>} Flat ranges that combines into passed `viewRange`.
function breakViewRangeOnFlat( viewRange ) {
Copy link

Choose a reason for hiding this comment

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

To view.writer?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

For now, I will just move it. I thought about making this a default behavior of methods like viewWriter.remove or viewWriter.wrap but those methods return values and I think it would be too many changes in this PR.

@Reinmar
Copy link
Member

Reinmar commented Dec 16, 2016

Remember to fix API docs. This PR has been made before we reformatted them.

}
}
}
}, { priority: 'lowest' } );
Copy link

Choose a reason for hiding this comment

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

These two functions (for insert and move) should be moved to the separate functions like all other.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll move them to model-to-view-converters.js.

}, { priority: 'lowest' } );

this.modelToView.on( 'move', ( evt, data ) => {
const pos = data.sourcePosition._getTransformedByInsertion( data.targetPosition, data.item.offsetSize );
Copy link

Choose a reason for hiding this comment

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

Why do you need to transform this position here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

To make conversion easier, data.sourcePosition for move change is in state "before" operation happened. However, both data.targetPosition and markers state are already "after" operation. To check whether moved part of document was inside a range or was moved to the range, we need to transform sourcePosition.

It's only transforming by insertion, because move sourcePosition (in other words, start of moved range) could not change due to removing that range:
ab[cde]f <- if cde is moved, source position is after b and will stay there. Only change can happen if range is moved before letter b.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Buuuuut, when I look at the conditions I am not sure why exactly I check if ( marker.range.start.isEqual( pos ) || marker.range.end.isEqual( pos ) ) {. I remember this condition went through some iterations.

Copy link

Choose a reason for hiding this comment

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

Can you leave a comment there?

Copy link
Contributor Author

@scofalik scofalik Dec 19, 2016

Choose a reason for hiding this comment

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

Checking with the start is because when you have a (marker) range and then you "cut" something out of it, the transformed range always starts or ends on sourcePosition. Examples:

ab[cd{e]f}g -> move {} -> ab[cd]^g -> sourcePosition will be after d
a{b[c}de]fg -> move {} -> a^[de]fg -> sourcePosition will be after a

That's why it makes sense to check marker.range.start and marker.range.end. But I will take a look if that loop cannot be simplified.

EDIT: OTOH a[bc{d}ef]g here it won't work this way. I will check this part again.

* Stores range to marker name bindings for added ranges.
*
* @private
* @member {Map} #_markerNames
Copy link

Choose a reason for hiding this comment

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

Why this is called "names" if it contains ranges?

Copy link
Contributor Author

@scofalik scofalik Dec 16, 2016

Choose a reason for hiding this comment

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

Because it keeps range => name mapping. It makes sense when you write:
const name = this._markerNames.get( range );. It does not contain ranges, ranges are keys.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Since we'll implement getting, removing and updating markers by name, it makes sense to store name => range mapping now. I'll rename this property to _nameToRange.

this.modelToView.on( 'insert', ( evt, data ) => {
const pos = data.range.start;

for ( let [ range, name ] of this.model.markers._markerNames ) {
Copy link

Choose a reason for hiding this comment

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

You iterate here over the private property. I believe this.model.markers should be iterable and return [ name, range ].

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll make MarkersCollection iterable and it will return an object with name and range properties.

Copy link

Choose a reason for hiding this comment

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Okay...

this.modelToView.on( 'move', ( evt, data ) => {
const pos = data.sourcePosition._getTransformedByInsertion( data.targetPosition, data.item.offsetSize );

for ( let [ range, name ] of this.model.markers._markerNames ) {
Copy link

Choose a reason for hiding this comment

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

As above.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

As above.

…ters, now transformation is always "spreading" and "not sticky".
…e range if it was collapsed and insertion was at that position.
…ion was in an element which part was in moved range.
* @param {module:engine/model/liverange~LiveRange} newLiveRange Live range to be added.
* @returns {Boolean} `true` if `oldLiveRange` was found and changed, `false` otherwise.
*/
updateRange( oldLiveRange, newLiveRange ) {
Copy link

@pjasiun pjasiun Dec 16, 2016

Choose a reason for hiding this comment

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

Maybe it should be update( name, newLiveRange )?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Will be update( nameOrRange, newLiveRange )

* @param {module:engine/model/liverange~LiveRange} liveRange Live range to be added as a marker to markers collection.
* @param {String} markerName Name to be associated with given `liveRange`.
*/
addRange( liveRange, markerName ) {
Copy link

Choose a reason for hiding this comment

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

It's markers collection why I'm adding range? I'm adding range with a name so marker (even addMarker event is fired). So addMarker would be better. But since there is nothing else I can add maybe simple add?

Also, it's more natural to have a name as the first parameter, so:

add( name, range )?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Okay.

}

this._markerNames.set( liveRange, markerName );
this.fire( 'addMarker', markerName, Range.createFromRange( liveRange ) );
Copy link

Choose a reason for hiding this comment

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

As above, maybe simple add event like in the Collection class?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Will simplify.

* @param {module:engine/model/liverange~LiveRange} liveRange Live range to be removed from markers collection.
* @returns {Boolean} `true` is passed `liveRange` was found and removed from the markers collection, `false` otherwise.
*/
removeRange( liveRange ) {
Copy link

Choose a reason for hiding this comment

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

As above. It would be also nice to remove the marker by name.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Will be remove( nameOrRange )


return false;
}

Copy link

Choose a reason for hiding this comment

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

The get method is missing.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Will add get( name )

@scofalik
Copy link
Contributor Author

I have corrected all raised issues and updated docs.

const range = LiveRange.createFromRange( model.selection.getFirstRange() );

ranges.push( range );
model.markers.addRange( range, 'highlight:' + color );
Copy link

Choose a reason for hiding this comment

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

It looks like the manual tests is not updated.

* {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher}).
*
* modelDispatcher.on( 'addMarker:searchResult', wrapRange( new ViewAttributeElement( 'span', { class: 'searchResult' } ) ) );
*
Copy link

Choose a reason for hiding this comment

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

I would like to see here the difference between wrapItem and this, so no one in the future asks again can we merge these two converters. I would be nice to have it written directly: "In contrast to wrapItem...".

* Markers are similar to adding and converting attributes on nodes. The difference is that attribute is connected to
* a given node (i.e. a character is bold no matter if it gets moved or content around it changes). Markers on the
* other hand are continuous ranges (i.e. if a character from inside of marker range is moved somewhere else, marker
* range is shrunk and the character does not have any attribute or information that it was in the marked range).
Copy link

Choose a reason for hiding this comment

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

Also, what is most important, markers let you find quickly the marked range without searching through the whole document.

}

/**
* Returns range for given name.
Copy link

Choose a reason for hiding this comment

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

Returns name for given range.

/**
* Returns range for given name.
*
* @private
Copy link

Choose a reason for hiding this comment

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

Maybe this method could be public, it might be useful. Then update and remove could simply get a name.

Copy link

Choose a reason for hiding this comment

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

Or we could keep it simple and drop the possibility of updating or removing marker by the range and remove this method. We copy range very ofter and it is a potential problem that someone uses a copy of a range to remove or update a marker. KISS, let's use names.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't agree. I understand that you requested additional possibility to pass name instead of range. That was backed up by arguments and made sense. But why remove existing feature that works, is tested and it's not like this is 100LOC.

Copy link

@pjasiun pjasiun Dec 22, 2016

Choose a reason for hiding this comment

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

Still, range instance is a terrible ID. Also, it is not a point to introduce more features that we need at this stage. Then we need to maintain them, what is usually more costly than introducing them. If we are unsure we will need this feature we should not bring it.

@@ -271,6 +273,36 @@ export function mergeContainers( position ) {
}

/**
* Breaks given `range` on a set of {@link module:engine/view/range~Range ranges}, that each are contained within a
* {@link module:engine/view/containerelement~ContainerElement container element}. After `range` is broken, it's "pieces" can
* be used by {@link module:engine/view/writer~writer} (which expects that passed ranges are contained within on container element).
Copy link

Choose a reason for hiding this comment

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

be used by {@link module:engine/view/writer~writer}

*writer.wrap?

(which expects that passed ranges are contained within on container element).

*one?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Well, it applies to all methods in writer, so first one is correct, but the other is not.

Copy link

Choose a reason for hiding this comment

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

all methods in writer

Others then this one.

@scofalik
Copy link
Contributor Author

scofalik commented Dec 23, 2016

I fixed one more thing and then merged master into this. I also merged master to related branches in List, Undo and Autoformat features. I will prepare just one more followup and this PR, hopefuly, will be ready to merge.

@scofalik
Copy link
Contributor Author

scofalik commented Dec 23, 2016

When testing this with --files=**/* please make sure that you have all the repos updated! Since I merged master into this, this includes changes that could brake some other repos, if they are not updated. This includes ckeditor5-core and maybe others. If you have an error in code from non-engine repo, please update that repo first.

@scofalik
Copy link
Contributor Author

Two followups: #740 and #741

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Markers
3 participants