8
0
Просмотр исходного кода

Merge pull request #7546 from ckeditor/i/7444-2SCM-refactor-highlight

Other: Link's attribute element highlight is now `inlineHighlight()` - a public utility.
Maciej 5 лет назад
Родитель
Сommit
fc59dc4f97

+ 1 - 1
packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion-preserving-custom-content.md

@@ -429,7 +429,7 @@ function downcastCustomClassesToChild( viewElementName, modelElementName ) {
 function findViewChild( viewElement, viewElementName, conversionApi ) {
 	const viewChildren = Array.from( conversionApi.writer.createRangeIn( viewElement ).getItems() );
 
-	return viewChildren.find( item => item.is( viewElementName ) );
+	return viewChildren.find( item => item.is( 'element', viewElementName ) );
 }
 
 /**

+ 2 - 2
packages/ckeditor5-link/src/linkcommand.js

@@ -8,7 +8,7 @@
  */
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
-import findLinkRange from './findlinkrange';
+import findAttributeRange from '@ckeditor/ckeditor5-typing/src/utils/findattributerange';
 import toMap from '@ckeditor/ckeditor5-utils/src/tomap';
 import Collection from '@ckeditor/ckeditor5-utils/src/collection';
 import first from '@ckeditor/ckeditor5-utils/src/first';
@@ -171,7 +171,7 @@ export default class LinkCommand extends Command {
 				// When selection is inside text with `linkHref` attribute.
 				if ( selection.hasAttribute( 'linkHref' ) ) {
 					// Then update `linkHref` value.
-					const linkRange = findLinkRange( position, selection.getAttribute( 'linkHref' ), model );
+					const linkRange = findAttributeRange( position, 'linkHref', selection.getAttribute( 'linkHref' ), model );
 
 					writer.setAttribute( 'linkHref', href, linkRange );
 

+ 5 - 65
packages/ckeditor5-link/src/linkediting.js

@@ -10,12 +10,13 @@
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import MouseObserver from '@ckeditor/ckeditor5-engine/src/view/observer/mouseobserver';
 import TwoStepCaretMovement from '@ckeditor/ckeditor5-typing/src/twostepcaretmovement';
+import inlineHighlight from '@ckeditor/ckeditor5-typing/src/utils/inlinehighlight';
 import Input from '@ckeditor/ckeditor5-typing/src/input';
 import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
 import LinkCommand from './linkcommand';
 import UnlinkCommand from './unlinkcommand';
 import ManualDecorator from './utils/manualdecorator';
-import findLinkRange from './findlinkrange';
+import findAttributeRange from '@ckeditor/ckeditor5-typing/src/utils/findattributerange';
 import { createLinkElement, ensureSafeUrl, getLocalizedDecorators, normalizeDecorators } from './utils';
 
 import '../theme/link.css';
@@ -105,7 +106,7 @@ export default class LinkEditing extends Plugin {
 		twoStepCaretMovementPlugin.registerAttribute( 'linkHref' );
 
 		// Setup highlight over selected link.
-		this._setupLinkHighlight();
+		inlineHighlight( editor, 'linkHref', 'a', HIGHLIGHT_CLASS );
 
 		// Change the attributes of the selection in certain situations after the link was inserted into the document.
 		this._enableInsertContentSelectionAttributesFixer();
@@ -207,67 +208,6 @@ export default class LinkEditing extends Plugin {
 		} );
 	}
 
-	/**
-	 * Adds a visual highlight style to a link in which the selection is anchored.
-	 * Together with two-step caret movement, they indicate that the user is typing inside the link.
-	 *
-	 * Highlight is turned on by adding the `.ck-link_selected` class to the link in the view:
-	 *
-	 * * The class is removed before the conversion has started, as callbacks added with the `'highest'` priority
-	 * to {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher} events.
-	 * * The class is added in the view post fixer, after other changes in the model tree were converted to the view.
-	 *
-	 * This way, adding and removing the highlight does not interfere with conversion.
-	 *
-	 * @private
-	 */
-	_setupLinkHighlight() {
-		const editor = this.editor;
-		const view = editor.editing.view;
-		const highlightedLinks = new Set();
-
-		// Adding the class.
-		view.document.registerPostFixer( writer => {
-			const selection = editor.model.document.selection;
-			let changed = false;
-
-			if ( selection.hasAttribute( 'linkHref' ) ) {
-				const modelRange = findLinkRange( selection.getFirstPosition(), selection.getAttribute( 'linkHref' ), editor.model );
-				const viewRange = editor.editing.mapper.toViewRange( modelRange );
-
-				// There might be multiple `a` elements in the `viewRange`, for example, when the `a` element is
-				// broken by a UIElement.
-				for ( const item of viewRange.getItems() ) {
-					if ( item.is( 'element', 'a' ) && !item.hasClass( HIGHLIGHT_CLASS ) ) {
-						writer.addClass( HIGHLIGHT_CLASS, item );
-						highlightedLinks.add( item );
-						changed = true;
-					}
-				}
-			}
-
-			return changed;
-		} );
-
-		// Removing the class.
-		editor.conversion.for( 'editingDowncast' ).add( dispatcher => {
-			// Make sure the highlight is removed on every possible event, before conversion is started.
-			dispatcher.on( 'insert', removeHighlight, { priority: 'highest' } );
-			dispatcher.on( 'remove', removeHighlight, { priority: 'highest' } );
-			dispatcher.on( 'attribute', removeHighlight, { priority: 'highest' } );
-			dispatcher.on( 'selection', removeHighlight, { priority: 'highest' } );
-
-			function removeHighlight() {
-				view.change( writer => {
-					for ( const item of highlightedLinks.values() ) {
-						writer.removeClass( HIGHLIGHT_CLASS, item );
-						highlightedLinks.delete( item );
-					}
-				} );
-			}
-		} );
-	}
-
 	/**
 	 * Starts listening to {@link module:engine/model/model~Model#event:insertContent} and corrects the model
 	 * selection attributes if the selection is at the end of a link after inserting the content.
@@ -407,7 +347,7 @@ export default class LinkEditing extends Plugin {
 			}
 
 			const position = selection.getFirstPosition();
-			const linkRange = findLinkRange( position, selection.getAttribute( 'linkHref' ), editor.model );
+			const linkRange = findAttributeRange( position, 'linkHref', selection.getAttribute( 'linkHref' ), editor.model );
 
 			// ...check whether clicked start/end boundary of the link.
 			// If so, remove the `linkHref` attribute.
@@ -536,7 +476,7 @@ function shouldCopyAttributes( model ) {
 
 	// If nodes are not equal, maybe the link nodes has defined additional attributes inside.
 	// First, we need to find the entire link range.
-	const linkRange = findLinkRange( firstPosition, nodeAtFirstPosition.getAttribute( 'linkHref' ), model );
+	const linkRange = findAttributeRange( firstPosition, 'linkHref', nodeAtFirstPosition.getAttribute( 'linkHref' ), model );
 
 	// Then we can check whether selected range is inside the found link range. If so, attributes should be preserved.
 	return linkRange.containsRange( model.createRange( firstPosition, lastPosition ), true );

+ 8 - 2
packages/ckeditor5-link/src/unlinkcommand.js

@@ -8,8 +8,8 @@
  */
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
+import findAttributeRange from '@ckeditor/ckeditor5-typing/src/utils/findattributerange';
 import first from '@ckeditor/ckeditor5-utils/src/first';
-import findLinkRange from './findlinkrange';
 import { isImageAllowed } from './utils';
 
 /**
@@ -58,7 +58,13 @@ export default class UnlinkCommand extends Command {
 		model.change( writer => {
 			// Get ranges to unlink.
 			const rangesToUnlink = selection.isCollapsed ?
-				[ findLinkRange( selection.getFirstPosition(), selection.getAttribute( 'linkHref' ), model ) ] : selection.getRanges();
+				[ findAttributeRange(
+					selection.getFirstPosition(),
+					'linkHref',
+					selection.getAttribute( 'linkHref' ),
+					model
+				) ] :
+				selection.getRanges();
 
 			// Remove `linkHref` attribute from specified ranges.
 			for ( const range of rangesToUnlink ) {

+ 26 - 20
packages/ckeditor5-link/tests/linkediting.js

@@ -22,6 +22,8 @@ import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import { isLinkElement } from '../src/utils';
 
+import '@ckeditor/ckeditor5-core/tests/_utils/assertions/attribute';
+
 /* global document */
 
 describe( 'LinkEditing', () => {
@@ -89,7 +91,7 @@ describe( 'LinkEditing', () => {
 			setModelData( editor.model, '<paragraph>foo[]<$text linkHref="url">b</$text>ar</paragraph>' );
 
 			// The selection's gravity should read attributes from the left.
-			expect( selection.hasAttribute( 'linkHref' ), 'hasAttribute( \'linkHref\' )' ).to.be.false;
+			expect( selection ).not.to.have.attribute( 'linkHref' );
 
 			// So let's simulate the `keydown` event.
 			editor.editing.view.document.fire( 'keydown', {
@@ -100,8 +102,8 @@ describe( 'LinkEditing', () => {
 
 			expect( getModelData( model ) ).to.equal( '<paragraph>foo<$text linkHref="url">[]b</$text>ar</paragraph>' );
 			// Selection should get the attributes from the right.
-			expect( selection.hasAttribute( 'linkHref' ), 'hasAttribute( \'linkHref\' )' ).to.be.true;
-			expect( selection.getAttribute( 'linkHref' ), 'linkHref attribute' ).to.equal( 'url' );
+			expect( selection ).to.have.attribute( 'linkHref' );
+			expect( selection ).to.have.attribute( 'linkHref', 'url' );
 		} );
 
 		it( 'should be bound to the `linkHref` attribute (RTL)', async () => {
@@ -120,7 +122,7 @@ describe( 'LinkEditing', () => {
 			setModelData( editor.model, '<paragraph>foo[]<$text linkHref="url">b</$text>ar</paragraph>' );
 
 			// The selection's gravity should read attributes from the left.
-			expect( selection.hasAttribute( 'linkHref' ), 'hasAttribute( \'linkHref\' )' ).to.be.false;
+			expect( selection ).not.to.have.attribute( 'linkHref' );
 
 			// So let's simulate the `keydown` event.
 			editor.editing.view.document.fire( 'keydown', {
@@ -131,8 +133,8 @@ describe( 'LinkEditing', () => {
 
 			expect( getModelData( model ) ).to.equal( '<paragraph>foo<$text linkHref="url">[]b</$text>ar</paragraph>' );
 			// Selection should get the attributes from the right.
-			expect( selection.hasAttribute( 'linkHref' ), 'hasAttribute( \'linkHref\' )' ).to.be.true;
-			expect( selection.getAttribute( 'linkHref' ), 'linkHref attribute' ).to.equal( 'url' );
+			expect( selection ).to.have.attribute( 'linkHref' );
+			expect( selection ).to.have.attribute( 'linkHref', 'url' );
 
 			await editor.destroy();
 		} );
@@ -182,7 +184,7 @@ describe( 'LinkEditing', () => {
 				'</paragraph>'
 			);
 
-			expect( [ ...model.document.selection.getAttributeKeys() ] ).to.have.members( [ 'bold' ] );
+			expect( model.document.selection ).to.have.attribute( 'bold' );
 		} );
 
 		it( 'should not remove link atttributes when pasting in the middle of a link with the same URL', () => {
@@ -193,7 +195,7 @@ describe( 'LinkEditing', () => {
 			} );
 
 			expect( getModelData( model ) ).to.equal( '<paragraph><$text linkHref="ckeditor.com">foINSERTED[]o</$text></paragraph>' );
-			expect( [ ...model.document.selection.getAttributeKeys() ] ).to.have.members( [ 'linkHref' ] );
+			expect( model.document.selection ).to.have.attribute( 'linkHref' );
 		} );
 
 		it( 'should not remove link atttributes from the selection when pasting before a link when the gravity is overridden', () => {
@@ -205,7 +207,7 @@ describe( 'LinkEditing', () => {
 				domTarget: document.body
 			} );
 
-			expect( model.document.selection.isGravityOverridden ).to.be.true;
+			expect( model.document.selection ).to.have.property( 'isGravityOverridden', true );
 
 			model.change( writer => {
 				model.insertContent( writer.createText( 'INSERTED', { bold: true } ) );
@@ -219,8 +221,8 @@ describe( 'LinkEditing', () => {
 				'</paragraph>'
 			);
 
-			expect( model.document.selection.isGravityOverridden ).to.be.true;
-			expect( [ ...model.document.selection.getAttributeKeys() ] ).to.have.members( [ 'linkHref' ] );
+			expect( model.document.selection ).to.have.property( 'isGravityOverridden', true );
+			expect( model.document.selection ).to.have.attribute( 'linkHref' );
 		} );
 
 		it( 'should not remove link atttributes when pasting a link into another link (different URLs, no merge)', () => {
@@ -238,13 +240,13 @@ describe( 'LinkEditing', () => {
 				'</paragraph>'
 			);
 
-			expect( [ ...model.document.selection.getAttributeKeys() ] ).to.have.members( [ 'linkHref' ] );
+			expect( model.document.selection ).to.have.attribute( 'linkHref' );
 		} );
 
 		it( 'should not remove link atttributes when pasting before another link (different URLs, no merge)', () => {
 			setModelData( model, '<paragraph>[]<$text linkHref="ckeditor.com">foo</$text></paragraph>' );
 
-			expect( model.document.selection.isGravityOverridden ).to.be.false;
+			expect( model.document.selection ).to.have.property( 'isGravityOverridden', false );
 
 			model.change( writer => {
 				model.insertContent( writer.createText( 'INSERTED', { linkHref: 'http://INSERTED' } ) );
@@ -257,8 +259,8 @@ describe( 'LinkEditing', () => {
 				'</paragraph>'
 			);
 
-			expect( [ ...model.document.selection.getAttributeKeys() ] ).to.have.members( [ 'linkHref' ] );
-			expect( model.document.selection.getAttribute( 'linkHref' ) ).to.equal( 'http://INSERTED' );
+			expect( model.document.selection ).to.have.attribute( 'linkHref' );
+			expect( model.document.selection ).to.have.attribute( 'linkHref', 'http://INSERTED' );
 		} );
 	} );
 
@@ -375,7 +377,7 @@ describe( 'LinkEditing', () => {
 				'<paragraph>foo <$text linkHref="url">b{}ar</$text> baz</paragraph>'
 			);
 
-			expect( model.document.selection.hasAttribute( 'linkHref' ) ).to.be.true;
+			expect( model.document.selection ).to.have.attribute( 'linkHref' );
 			expect( getViewData( view ) ).to.equal(
 				'<p>foo <a class="ck-link_selected" href="url">b{}ar</a> baz</p>'
 			);
@@ -386,13 +388,13 @@ describe( 'LinkEditing', () => {
 				'<paragraph>foo {}<$text linkHref="url">bar</$text> baz</paragraph>'
 			);
 
-			expect( model.document.selection.hasAttribute( 'linkHref' ) ).to.be.false;
+			expect( model.document.selection ).to.not.have.attribute( 'linkHref' );
 
 			model.change( writer => {
 				writer.setSelectionAttribute( 'linkHref', 'url' );
 			} );
 
-			expect( model.document.selection.hasAttribute( 'linkHref' ) ).to.be.true;
+			expect( model.document.selection ).to.have.attribute( 'linkHref' );
 			expect( getViewData( view ) ).to.equal(
 				'<p>foo <a class="ck-link_selected" href="url">{}bar</a> baz</p>'
 			);
@@ -403,7 +405,7 @@ describe( 'LinkEditing', () => {
 				'<paragraph>foo <$text linkHref="url">bar</$text>{} baz</paragraph>'
 			);
 
-			expect( model.document.selection.hasAttribute( 'linkHref' ) ).to.be.true;
+			expect( model.document.selection ).to.have.attribute( 'linkHref' );
 			expect( getViewData( view ) ).to.equal(
 				'<p>foo <a class="ck-link_selected" href="url">bar{}</a> baz</p>'
 			);
@@ -421,7 +423,11 @@ describe( 'LinkEditing', () => {
 				'<paragraph><$text linkHref="url">[]nk</$text> baz</paragraph>'
 			);
 
-			expect( model.document.selection.hasAttribute( 'linkHref' ) ).to.be.true;
+			expect( model.document.selection ).to.have.attribute( 'linkHref' );
+			expect( getViewData( view ) ).to.equal(
+				'<p>foo <a href="url">li</a></p>' +
+				'<p><a class="ck-link_selected" href="url">{}nk</a> baz</p>'
+			);
 		} );
 
 		it( 'should remove classes when selection is moved out from the link', () => {

+ 16 - 9
packages/ckeditor5-link/src/findlinkrange.js → packages/ckeditor5-typing/src/utils/findattributerange.js

@@ -4,38 +4,45 @@
  */
 
 /**
- * @module link/findlinkrange
+ * @module typing/utils/findattributerange
  */
 
 /**
- * Returns a range containing the entire link in which the given `position` is placed.
+ * Returns a model range that covers all consecutive nodes with the same `attributeName` and its `value`
+ * that intersect the given `position`.
  *
  * It can be used e.g. to get the entire range on which the `linkHref` attribute needs to be changed when having a
  * selection inside a link.
  *
  * @param {module:engine/model/position~Position} position The start position.
- * @param {String} value The `linkHref` attribute value.
+ * @param {String} attributeName The attribute name.
+ * @param {String} value The attribute value.
+ * @param {module:engine/model/model~Model} model The model instance.
  * @returns {module:engine/model/range~Range} The link range.
  */
-export default function findLinkRange( position, value, model ) {
-	return model.createRange( _findBound( position, value, true, model ), _findBound( position, value, false, model ) );
+export default function findAttributeRange( position, attributeName, value, model ) {
+	return model.createRange(
+		_findBound( position, attributeName, value, true, model ),
+		_findBound( position, attributeName, value, false, model )
+	);
 }
 
-// Walks forward or backward (depends on the `lookBack` flag), node by node, as long as they have the same `linkHref` attribute value
+// Walks forward or backward (depends on the `lookBack` flag), node by node, as long as they have the same attribute value
 // and returns a position just before or after (depends on the `lookBack` flag) the last matched node.
 //
 // @param {module:engine/model/position~Position} position The start position.
-// @param {String} value The `linkHref` attribute value.
+// @param {String} attributeName The attribute name.
+// @param {String} value The attribute value.
 // @param {Boolean} lookBack Whether the walk direction is forward (`false`) or backward (`true`).
 // @returns {module:engine/model/position~Position} The position just before the last matched node.
-function _findBound( position, value, lookBack, model ) {
+function _findBound( position, attributeName, value, lookBack, model ) {
 	// Get node before or after position (depends on `lookBack` flag).
 	// When position is inside text node then start searching from text node.
 	let node = position.textNode || ( lookBack ? position.nodeBefore : position.nodeAfter );
 
 	let lastNode = null;
 
-	while ( node && node.getAttribute( 'linkHref' ) == value ) {
+	while ( node && node.getAttribute( attributeName ) == value ) {
 		lastNode = node;
 		node = lookBack ? node.previousSibling : node.nextSibling;
 	}

+ 86 - 0
packages/ckeditor5-typing/src/utils/inlinehighlight.js

@@ -0,0 +1,86 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import findAttributeRange from './findattributerange';
+
+/**
+ * @module typing/utils/inlinehighlight
+ */
+
+/**
+ * Adds a visual highlight style to an attribute element in which the selection is anchored.
+ * Together with two-step caret movement, they indicate that the user is typing inside the element.
+ *
+ * Highlight is turned on by adding the given class to the attribute element in the view:
+ *
+ * * The class is removed before the conversion has started, as callbacks added with the `'highest'` priority
+ * to {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher} events.
+ * * The class is added in the view post fixer, after other changes in the model tree were converted to the view.
+ *
+ * This way, adding and removing the highlight does not interfere with conversion.
+ *
+ * Usage:
+ *
+ *		import inlineHighlight from '@ckeditor/ckeditor5-typing/src/utils/inlinehighlight';
+ *
+ *		// Make `ck-link_selected` class be applied on an `a` element
+ *		// whenever the corresponding `linkHref` attribute element is selected.
+ *		inlineHighlight( editor, 'linkHref', 'a', 'ck-link_selected' );
+ *
+ * @param {module:core/editor/editor~Editor} editor The editor instance.
+ * @param {String} attributeName The attribute name to check.
+ * @param {String} tagName The tagName of a view item.
+ * @param {String} className The class name to apply in the view.
+ */
+export default function inlineHighlight( editor, attributeName, tagName, className ) {
+	const view = editor.editing.view;
+	const highlightedElements = new Set();
+
+	// Adding the class.
+	view.document.registerPostFixer( writer => {
+		const selection = editor.model.document.selection;
+		let changed = false;
+
+		if ( selection.hasAttribute( attributeName ) ) {
+			const modelRange = findAttributeRange(
+				selection.getFirstPosition(),
+				attributeName,
+				selection.getAttribute( attributeName ),
+				editor.model
+			);
+			const viewRange = editor.editing.mapper.toViewRange( modelRange );
+
+			// There might be multiple view elements in the `viewRange`, for example, when the `a` element is
+			// broken by a UIElement.
+			for ( const item of viewRange.getItems() ) {
+				if ( item.is( 'element', tagName ) && !item.hasClass( className ) ) {
+					writer.addClass( className, item );
+					highlightedElements.add( item );
+					changed = true;
+				}
+			}
+		}
+
+		return changed;
+	} );
+
+	// Removing the class.
+	editor.conversion.for( 'editingDowncast' ).add( dispatcher => {
+		// Make sure the highlight is removed on every possible event, before conversion is started.
+		dispatcher.on( 'insert', removeHighlight, { priority: 'highest' } );
+		dispatcher.on( 'remove', removeHighlight, { priority: 'highest' } );
+		dispatcher.on( 'attribute', removeHighlight, { priority: 'highest' } );
+		dispatcher.on( 'selection', removeHighlight, { priority: 'highest' } );
+
+		function removeHighlight() {
+			view.change( writer => {
+				for ( const item of highlightedElements.values() ) {
+					writer.removeClass( className, item );
+					highlightedElements.delete( item );
+				}
+			} );
+		}
+	} );
+}

+ 30 - 30
packages/ckeditor5-typing/tests/twostepcaretmovement.js

@@ -31,8 +31,8 @@ describe( 'TwoStepCaretMovement()', () => {
 			view = editor.editing.view;
 			plugin = editor.plugins.get( TwoStepCaretMovement );
 
-			preventDefaultSpy = sinon.spy();
-			evtStopSpy = sinon.spy();
+			preventDefaultSpy = sinon.spy().named( 'preventDefaultSpy' );
+			evtStopSpy = sinon.spy().named( 'evtStopSpy' );
 
 			editor.model.schema.extend( '$text', {
 				allowAttributes: [ 'a', 'b', 'c' ],
@@ -511,7 +511,7 @@ describe( 'TwoStepCaretMovement()', () => {
 			} );
 
 			// <$text a="1">x</$text><$text b="1">[]</$text>
-			expect( selection.isGravityOverridden ).to.be.true;
+			expect( selection ).to.have.property( 'isGravityOverridden', true );
 			expect( getSelectionAttributesArray( selection ) ).to.have.members( [ 'b' ] );
 
 			model.change( writer => {
@@ -519,7 +519,7 @@ describe( 'TwoStepCaretMovement()', () => {
 			} );
 
 			// <$text a="1">x</$text><$text b="1">yz[]</$text>
-			expect( selection.isGravityOverridden ).to.be.false;
+			expect( selection ).to.have.property( 'isGravityOverridden', false );
 			expect( getSelectionAttributesArray( selection ) ).to.have.members( [ 'b' ] );
 		} );
 
@@ -539,7 +539,7 @@ describe( 'TwoStepCaretMovement()', () => {
 			} );
 
 			// <$text b="1">[]</$text><$text a="1">x</$text>
-			expect( selection.isGravityOverridden ).to.be.false;
+			expect( selection ).to.have.property( 'isGravityOverridden', false );
 			expect( getSelectionAttributesArray( selection ) ).to.have.members( [ 'b' ] );
 
 			model.change( writer => {
@@ -547,7 +547,7 @@ describe( 'TwoStepCaretMovement()', () => {
 			} );
 
 			// <$text b="1">yz[]</$text><$text a="1">x</$text>
-			expect( selection.isGravityOverridden ).to.be.false;
+			expect( selection ).to.have.property( 'isGravityOverridden', false );
 			expect( getSelectionAttributesArray( selection ) ).to.have.members( [ 'b' ] );
 		} );
 	} );
@@ -646,20 +646,20 @@ describe( 'TwoStepCaretMovement()', () => {
 		it( 'should not override gravity when selection is placed at the beginning of text', () => {
 			setData( model, '<$text a="true">[]foo</$text>' );
 
-			expect( selection.isGravityOverridden ).to.be.false;
+			expect( selection ).to.have.property( 'isGravityOverridden', false );
 		} );
 
 		it( 'should not override gravity when selection is placed at the end of text', () => {
 			setData( model, '<$text a="true">foo[]</$text>' );
 
-			expect( selection.isGravityOverridden ).to.be.false;
+			expect( selection ).to.have.property( 'isGravityOverridden', false );
 		} );
 	} );
 
 	it( 'should listen with the high+1 priority on view.document#keydown', () => {
-		const highestPrioritySpy = sinon.spy();
-		const highPrioritySpy = sinon.spy();
-		const normalPrioritySpy = sinon.spy();
+		const highestPrioritySpy = sinon.spy().named( 'highestPrioritySpy' );
+		const highPrioritySpy = sinon.spy().named( 'highPrioritySpy' );
+		const normalPrioritySpy = sinon.spy().named( 'normalPrioritySpy' );
 
 		setData( model, '<$text c="true">foo[]</$text><$text a="true" b="true">bar</$text>' );
 
@@ -672,12 +672,11 @@ describe( 'TwoStepCaretMovement()', () => {
 			preventDefault: preventDefaultSpy
 		} );
 
-		sinon.assert.callOrder(
-			highestPrioritySpy,
-			preventDefaultSpy );
+		expect( highestPrioritySpy ).to.be.calledOnce;
+		expect( preventDefaultSpy ).to.be.calledImmediatelyAfter( highestPrioritySpy );
 
-		sinon.assert.notCalled( highPrioritySpy );
-		sinon.assert.notCalled( normalPrioritySpy );
+		expect( highPrioritySpy ).not.to.be.called;
+		expect( normalPrioritySpy ).not.to.be.called;
 	} );
 
 	it( 'should do nothing when key other then arrow left and right is pressed', () => {
@@ -693,7 +692,7 @@ describe( 'TwoStepCaretMovement()', () => {
 
 		fireKeyDownEvent( { keyCode: keyCodes.arrowright } );
 
-		expect( selection.isGravityOverridden ).to.be.false;
+		expect( selection ).to.have.property( 'isGravityOverridden', false );
 	} );
 
 	it( 'should do nothing when shift key is pressed', () => {
@@ -704,7 +703,7 @@ describe( 'TwoStepCaretMovement()', () => {
 			shiftKey: true
 		} );
 
-		expect( selection.isGravityOverridden ).to.be.false;
+		expect( selection ).to.have.property( 'isGravityOverridden', false );
 	} );
 
 	it( 'should do nothing when alt key is pressed', () => {
@@ -715,7 +714,7 @@ describe( 'TwoStepCaretMovement()', () => {
 			altKey: true
 		} );
 
-		expect( selection.isGravityOverridden ).to.be.false;
+		expect( selection ).to.have.property( 'isGravityOverridden', false );
 	} );
 
 	it( 'should do nothing when ctrl key is pressed', () => {
@@ -726,7 +725,7 @@ describe( 'TwoStepCaretMovement()', () => {
 			ctrlKey: true
 		} );
 
-		expect( selection.isGravityOverridden ).to.be.false;
+		expect( selection ).to.have.property( 'isGravityOverridden', false );
 	} );
 
 	it( 'should do nothing when the not a direct selection change but at the attribute boundary', () => {
@@ -743,7 +742,7 @@ describe( 'TwoStepCaretMovement()', () => {
 			writer.insertText( 'x', selection.getFirstPosition().getShiftedBy( -2 ) );
 		} );
 
-		expect( selection.isGravityOverridden ).to.be.true;
+		expect( selection ).to.have.property( 'isGravityOverridden', true );
 		expect( getSelectionAttributesArray( selection ) ).to.have.members( [] );
 	} );
 
@@ -758,8 +757,8 @@ describe( 'TwoStepCaretMovement()', () => {
 				keyCode: keyCodes.arrowright
 			} );
 
-			sinon.assert.calledOnce( forwardStub );
-			sinon.assert.notCalled( backwardStub );
+			expect( forwardStub ).to.be.calledOnce;
+			expect( backwardStub ).not.to.be.called;
 
 			setData( model, '<$text>foo</$text><$text a="true">[]bar</$text>' );
 
@@ -767,8 +766,8 @@ describe( 'TwoStepCaretMovement()', () => {
 				keyCode: keyCodes.arrowleft
 			} );
 
-			sinon.assert.calledOnce( backwardStub );
-			sinon.assert.calledOnce( forwardStub );
+			expect( forwardStub ).to.be.calledOnce;
+			expect( backwardStub ).to.be.calledOnce;
 		} );
 
 		it( 'should use the opposite helper methods (RTL content direction)', () => {
@@ -811,8 +810,8 @@ describe( 'TwoStepCaretMovement()', () => {
 						keyCode: keyCodes.arrowleft
 					} );
 
-					sinon.assert.calledOnce( forwardStub );
-					sinon.assert.notCalled( backwardStub );
+					expect( forwardStub ).to.be.calledOnce;
+					expect( backwardStub ).not.to.be.called;
 
 					setData( model, '<$text>foo</$text><$text a="true">[]bar</$text>' );
 
@@ -820,8 +819,8 @@ describe( 'TwoStepCaretMovement()', () => {
 						keyCode: keyCodes.arrowright
 					} );
 
-					sinon.assert.calledOnce( backwardStub );
-					sinon.assert.calledOnce( forwardStub );
+					expect( forwardStub ).to.be.calledOnce;
+					expect( backwardStub ).to.be.calledOnce;
 
 					return newEditor.destroy();
 				} );
@@ -910,7 +909,8 @@ describe( 'TwoStepCaretMovement()', () => {
 
 				expect( getSelectionAttributesArray( selection ) ).to.have.members(
 					step.selectionAttributes, `#attributes ${ stepString }` );
-				expect( selection.isGravityOverridden ).to.equal( step.isGravityOverridden, `#isGravityOverridden ${ stepString }` );
+				expect( selection, `#isGravityOverridden ${ stepString }` )
+					.to.have.property( 'isGravityOverridden', step.isGravityOverridden );
 				expect( preventDefaultSpy.callCount ).to.equal( step.preventDefault, `#preventDefault ${ stepString }` );
 				expect( evtStopSpy.callCount ).to.equal( step.evtStopCalled, `#evtStopCalled ${ stepString }` );
 			}

+ 12 - 12
packages/ckeditor5-link/tests/findlinkrange.js → packages/ckeditor5-typing/tests/utils/findattributerange.js

@@ -3,12 +3,12 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
-import findLinkRange from '../src/findlinkrange';
+import findAttributeRange from '../../src/utils/findattributerange';
 import Model from '@ckeditor/ckeditor5-engine/src/model/model';
 import Range from '@ckeditor/ckeditor5-engine/src/model/range';
 import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
-describe( 'findLinkRange', () => {
+describe( 'findAttributeRange', () => {
 	let model, document, root;
 
 	beforeEach( () => {
@@ -23,7 +23,7 @@ describe( 'findLinkRange', () => {
 		setData( model, '<$text linkHref="url">foobar</$text>' );
 
 		const startPosition = model.createPositionAt( root, [ 3 ] );
-		const result = findLinkRange( startPosition, 'url', model );
+		const result = findAttributeRange( startPosition, 'linkHref', 'url', model );
 
 		expect( result ).to.instanceOf( Range );
 		expect( result.isEqual( model.createRange( model.createPositionAt( root, 0 ), model.createPositionAt( root, 6 ) ) ) ).to.true;
@@ -33,7 +33,7 @@ describe( 'findLinkRange', () => {
 		setData( model, 'abc <$text linkHref="url">foobar</$text> abc' );
 
 		const startPosition = model.createPositionAt( root, [ 7 ] );
-		const result = findLinkRange( startPosition, 'url', model );
+		const result = findAttributeRange( startPosition, 'linkHref', 'url', model );
 
 		expect( result ).to.instanceOf( Range );
 		expect( result.isEqual( model.createRange( model.createPositionAt( root, 4 ), model.createPositionAt( root, 10 ) ) ) ).to.true;
@@ -43,7 +43,7 @@ describe( 'findLinkRange', () => {
 		setData( model, '<$text linkHref="url">foobar</$text>' );
 
 		const startPosition = model.createPositionAt( root, [ 0 ] );
-		const result = findLinkRange( startPosition, 'url', model );
+		const result = findAttributeRange( startPosition, 'linkHref', 'url', model );
 
 		expect( result ).to.instanceOf( Range );
 		expect( result.isEqual( model.createRange( model.createPositionAt( root, 0 ), model.createPositionAt( root, 6 ) ) ) ).to.true;
@@ -53,7 +53,7 @@ describe( 'findLinkRange', () => {
 		setData( model, 'abc <$text linkHref="url">foobar</$text> abc' );
 
 		const startPosition = model.createPositionAt( root, [ 4 ] );
-		const result = findLinkRange( startPosition, 'url', model );
+		const result = findAttributeRange( startPosition, 'linkHref', 'url', model );
 
 		expect( result ).to.instanceOf( Range );
 		expect( result.isEqual( model.createRange( model.createPositionAt( root, 4 ), model.createPositionAt( root, 10 ) ) ) ).to.true;
@@ -63,7 +63,7 @@ describe( 'findLinkRange', () => {
 		setData( model, '<$text linkHref="url">foobar</$text>' );
 
 		const startPosition = model.createPositionAt( root, [ 6 ] );
-		const result = findLinkRange( startPosition, 'url', model );
+		const result = findAttributeRange( startPosition, 'linkHref', 'url', model );
 
 		expect( result ).to.instanceOf( Range );
 		expect( result.isEqual( model.createRange( model.createPositionAt( root, 0 ), model.createPositionAt( root, 6 ) ) ) ).to.true;
@@ -73,7 +73,7 @@ describe( 'findLinkRange', () => {
 		setData( model, 'abc <$text linkHref="url">foobar</$text> abc' );
 
 		const startPosition = model.createPositionAt( root, [ 10 ] );
-		const result = findLinkRange( startPosition, 'url', model );
+		const result = findAttributeRange( startPosition, 'linkHref', 'url', model );
 
 		expect( result ).to.instanceOf( Range );
 		expect( result.isEqual( model.createRange( model.createPositionAt( root, 4 ), model.createPositionAt( root, 10 ) ) ) ).to.true;
@@ -83,7 +83,7 @@ describe( 'findLinkRange', () => {
 		setData( model, '<$text linkHref="other">abc</$text><$text linkHref="url">foobar</$text><$text linkHref="other">abc</$text>' );
 
 		const startPosition = model.createPositionAt( root, [ 6 ] );
-		const result = findLinkRange( startPosition, 'url', model );
+		const result = findAttributeRange( startPosition, 'linkHref', 'url', model );
 
 		expect( result ).to.instanceOf( Range );
 		expect( result.isEqual( model.createRange( model.createPositionAt( root, 3 ), model.createPositionAt( root, 9 ) ) ) ).to.true;
@@ -93,7 +93,7 @@ describe( 'findLinkRange', () => {
 		setData( model, '<$text linkHref="other">abc</$text><$text linkHref="url">foobar</$text><$text linkHref="other">abc</$text>' );
 
 		const startPosition = model.createPositionAt( root, [ 3 ] );
-		const result = findLinkRange( startPosition, 'url', model );
+		const result = findAttributeRange( startPosition, 'linkHref', 'url', model );
 
 		expect( result ).to.instanceOf( Range );
 		expect( result.isEqual( model.createRange( model.createPositionAt( root, 3 ), model.createPositionAt( root, 9 ) ) ) ).to.true;
@@ -103,7 +103,7 @@ describe( 'findLinkRange', () => {
 		setData( model, '<$text linkHref="other">abc</$text><$text linkHref="url">foobar</$text><$text linkHref="other">abc</$text>' );
 
 		const startPosition = model.createPositionAt( root, [ 9 ] );
-		const result = findLinkRange( startPosition, 'url', model );
+		const result = findAttributeRange( startPosition, 'linkHref', 'url', model );
 
 		expect( result ).to.instanceOf( Range );
 		expect( result.isEqual( model.createRange( model.createPositionAt( root, 3 ), model.createPositionAt( root, 9 ) ) ) ).to.true;
@@ -118,7 +118,7 @@ describe( 'findLinkRange', () => {
 		);
 
 		const startPosition = model.createPositionAt( root.getNodeByPath( [ 1 ] ), 3 );
-		const result = findLinkRange( startPosition, 'url', model );
+		const result = findAttributeRange( startPosition, 'linkHref', 'url', model );
 
 		expect( result ).to.instanceOf( Range );
 		const expectedRange = model.createRange(

+ 237 - 0
packages/ckeditor5-typing/tests/utils/inlinehighlight.js

@@ -0,0 +1,237 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+import inlineHighlight from '../../src/utils/inlinehighlight';
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+
+import '@ckeditor/ckeditor5-core/tests/_utils/assertions/attribute';
+
+/* global document */
+
+describe( 'LinkEditing', () => {
+	let element, editor, model, view;
+
+	beforeEach( async () => {
+		element = document.createElement( 'div' );
+		document.body.appendChild( element );
+
+		editor = await ClassicTestEditor.create( element );
+
+		model = editor.model;
+		view = editor.editing.view;
+
+		model.schema.register( 'paragraph', { inheritAllFrom: '$block' } );
+		editor.conversion.elementToElement( { model: 'paragraph', view: 'p' } );
+
+		model.schema.extend( '$text', { allowAttributes: 'linkHref' } );
+
+		editor.conversion.for( 'editingDowncast' )
+			.attributeToElement( { model: 'linkHref', view: ( href, writer ) => {
+				return writer.createAttributeElement( 'a', { href } );
+			} } );
+
+		// Setup highlight over selected link.
+		inlineHighlight( editor, 'linkHref', 'a', 'ck-link_selected' );
+	} );
+
+	afterEach( async () => {
+		element.remove();
+
+		await editor.destroy();
+	} );
+
+	describe( 'link highlighting', () => {
+		it( 'should convert the highlight to a proper view classes', () => {
+			setModelData( model,
+				'<paragraph>foo <$text linkHref="url">b{}ar</$text> baz</paragraph>'
+			);
+
+			expect( model.document.selection ).to.have.attribute( 'linkHref' );
+			expect( getViewData( view ) ).to.equal(
+				'<p>foo <a class="ck-link_selected" href="url">b{}ar</a> baz</p>'
+			);
+		} );
+
+		it( 'should work whenever selection has linkHref attribute - link start', () => {
+			setModelData( model,
+				'<paragraph>foo {}<$text linkHref="url">bar</$text> baz</paragraph>'
+			);
+
+			expect( model.document.selection ).to.not.have.attribute( 'linkHref' );
+
+			model.change( writer => {
+				writer.setSelectionAttribute( 'linkHref', 'url' );
+			} );
+
+			expect( model.document.selection ).to.have.attribute( 'linkHref' );
+			expect( getViewData( view ) ).to.equal(
+				'<p>foo <a class="ck-link_selected" href="url">{}bar</a> baz</p>'
+			);
+		} );
+
+		it( 'should work whenever selection has linkHref attribute - link end', () => {
+			setModelData( model,
+				'<paragraph>foo <$text linkHref="url">bar</$text>{} baz</paragraph>'
+			);
+
+			expect( model.document.selection ).to.have.attribute( 'linkHref' );
+			expect( getViewData( view ) ).to.equal(
+				'<p>foo <a class="ck-link_selected" href="url">bar{}</a> baz</p>'
+			);
+		} );
+
+		it( 'should render highlight correctly after splitting the link', () => {
+			setModelData( model,
+				'<paragraph>foo <$text linkHref="url">li{}nk</$text> baz</paragraph>'
+			);
+
+			model.change( writer => {
+				const splitPos = model.document.selection.getFirstRange().start;
+
+				writer.split( splitPos );
+				writer.setSelection( splitPos.parent.nextSibling, 0 );
+			} );
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>foo <$text linkHref="url">li</$text></paragraph>' +
+				'<paragraph><$text linkHref="url">[]nk</$text> baz</paragraph>'
+			);
+
+			expect( model.document.selection ).to.have.attribute( 'linkHref' );
+			expect( getViewData( view ) ).to.equal(
+				'<p>foo <a href="url">li</a></p>' +
+				'<p><a class="ck-link_selected" href="url">{}nk</a> baz</p>'
+			);
+		} );
+
+		it( 'should remove classes when selection is moved out from the link', () => {
+			setModelData( model,
+				'<paragraph>foo <$text linkHref="url">li{}nk</$text> baz</paragraph>'
+			);
+
+			expect( getViewData( view ) ).to.equal(
+				'<p>foo <a class="ck-link_selected" href="url">li{}nk</a> baz</p>'
+			);
+
+			model.change( writer => writer.setSelection( model.document.getRoot().getChild( 0 ), 0 ) );
+
+			expect( getViewData( view ) ).to.equal(
+				'<p>{}foo <a href="url">link</a> baz</p>'
+			);
+		} );
+
+		it( 'should work correctly when selection is moved inside link', () => {
+			setModelData( model,
+				'<paragraph>foo <$text linkHref="url">li{}nk</$text> baz</paragraph>'
+			);
+
+			expect( getViewData( view ) ).to.equal(
+				'<p>foo <a class="ck-link_selected" href="url">li{}nk</a> baz</p>'
+			);
+
+			model.change( writer => writer.setSelection( model.document.getRoot().getChild( 0 ), 5 ) );
+
+			expect( getViewData( view ) ).to.equal(
+				'<p>foo <a class="ck-link_selected" href="url">l{}ink</a> baz</p>'
+			);
+		} );
+
+		describe( 'downcast conversion integration', () => {
+			it( 'works for the #insert event', () => {
+				setModelData( model,
+					'<paragraph>foo <$text linkHref="url">li{}nk</$text> baz</paragraph>'
+				);
+
+				model.change( writer => {
+					writer.insertText( 'FOO', { linkHref: 'url' }, model.document.selection.getFirstPosition() );
+				} );
+
+				expect( getViewData( view ) ).to.equal(
+					'<p>foo <a class="ck-link_selected" href="url">liFOO{}nk</a> baz</p>'
+				);
+			} );
+
+			it( 'works for the #remove event', () => {
+				setModelData( model,
+					'<paragraph>foo <$text linkHref="url">li{}nk</$text> baz</paragraph>'
+				);
+
+				model.change( writer => {
+					writer.remove( writer.createRange(
+						writer.createPositionAt( model.document.getRoot().getChild( 0 ), 0 ),
+						writer.createPositionAt( model.document.getRoot().getChild( 0 ), 5 )
+					) );
+				} );
+
+				expect( getViewData( view ) ).to.equal(
+					'<p><a class="ck-link_selected" href="url">i{}nk</a> baz</p>'
+				);
+			} );
+
+			it( 'works for the #attribute event', () => {
+				setModelData( model,
+					'<paragraph>foo <$text linkHref="url">li{}nk</$text> baz</paragraph>'
+				);
+
+				model.change( writer => {
+					writer.setAttribute( 'linkHref', 'new-url', writer.createRange(
+						model.document.selection.getFirstPosition().getShiftedBy( -1 ),
+						model.document.selection.getFirstPosition().getShiftedBy( 1 ) )
+					);
+				} );
+
+				expect( getViewData( view ) ).to.equal(
+					'<p>foo <a href="url">l</a><a class="ck-link_selected" href="new-url">i{}n</a><a href="url">k</a> baz</p>'
+				);
+			} );
+
+			it( 'works for the #selection event', () => {
+				setModelData( model,
+					'<paragraph>foo <$text linkHref="url">li{}nk</$text> baz</paragraph>'
+				);
+
+				model.change( writer => {
+					writer.setSelection( writer.createRange(
+						model.document.selection.getFirstPosition().getShiftedBy( -1 ),
+						model.document.selection.getFirstPosition().getShiftedBy( 1 ) )
+					);
+				} );
+
+				expect( getViewData( view ) ).to.equal(
+					'<p>foo <a class="ck-link_selected" href="url">l{in}k</a> baz</p>'
+				);
+			} );
+
+			it( 'works for the addMarker and removeMarker events', () => {
+				editor.conversion.for( 'editingDowncast' ).markerToHighlight( { model: 'fooMarker', view: {} } );
+
+				setModelData( model,
+					'<paragraph>foo <$text linkHref="url">li{}nk</$text> baz</paragraph>'
+				);
+
+				model.change( writer => {
+					const range = writer.createRange(
+						writer.createPositionAt( model.document.getRoot().getChild( 0 ), 0 ),
+						writer.createPositionAt( model.document.getRoot().getChild( 0 ), 5 )
+					);
+
+					writer.addMarker( 'fooMarker', { range, usingOperation: true } );
+				} );
+
+				expect( getViewData( view ) ).to.equal(
+					'<p><span>foo </span><a class="ck-link_selected" href="url"><span>l</span>i{}nk</a> baz</p>'
+				);
+
+				model.change( writer => writer.removeMarker( 'fooMarker' ) );
+
+				expect( getViewData( view ) ).to.equal(
+					'<p>foo <a class="ck-link_selected" href="url">li{}nk</a> baz</p>'
+				);
+			} );
+		} );
+	} );
+} );