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

Merge pull request #7 from ckeditor/t/1

Basic link engine and commands
Szymon Cofalik 9 лет назад
Родитель
Сommit
00096a8357

+ 41 - 0
packages/ckeditor5-link/src/findlinkrange.js

@@ -0,0 +1,41 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Range from '../engine/model/range.js';
+import Position from '../engine/model/position.js';
+
+/**
+ * Walk backward and forward from start position, node by node as long as they have the same `linkHref` attribute value and return
+ * {@link engine.model.Range Range} with found link.
+ *
+ * @param {engine.model.Position} position Start position.
+ * @param {String} value `linkHref` attribute value.
+ * @returns {engine.model.Range} Link range.
+ */
+export default function findLinkRange( position, value ) {
+	return new Range( _findBound( position, value, true ), _findBound( position, value, false ) );
+}
+
+// Walk forward or backward (depends on `lookBack` flag), node by node as long as they have the same `linkHref` attribute value
+// and return position just before or after (depends on `lookBack` flag) last matched node.
+//
+// @param {engine.model.Position} position Start position.
+// @param {String} value `linkHref` attribute value.
+// @param {Boolean} lookBack Whether walk direction is forward `false` or backward `true`.
+// @returns {engine.model.Position} Position just before last matched node.
+function _findBound( position, value, lookBack ) {
+	// 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 ) {
+		lastNode = node;
+		node = lookBack ? node.previousSibling : node.nextSibling;
+	}
+
+	return lastNode ? Position.createAt( lastNode, lookBack ? 'before' : 'after' ) : position;
+}

+ 110 - 0
packages/ckeditor5-link/src/linkcommand.js

@@ -0,0 +1,110 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Command from '../core/command/command.js';
+import Text from '../engine/model/text.js';
+import Range from '../engine/model/range.js';
+import getSchemaValidRanges from '../core/command/helpers/getschemavalidranges.js';
+import isAttributeAllowedInSelection from '../core/command/helpers/isattributeallowedinselection.js';
+import findLinkRange from './findlinkrange.js';
+
+/**
+ * The link command. It is used by the {@link Link.Link link feature}.
+ *
+ * @memberOf link
+ * @extends core.command.Command
+ */
+export default class LinkCommand extends Command {
+	/**
+	 * @see core.command.Command
+	 * @param {core.editor.Editor} editor
+	 */
+	constructor( editor ) {
+		super( editor );
+
+		/**
+		 * Currently selected linkHref attribute value.
+		 *
+		 * @observable
+		 * @member {Boolean} core.command.ToggleAttributeCommand#value
+		 */
+		this.set( 'value', undefined );
+
+		this.listenTo( this.editor.document.selection, 'change:attribute', () => {
+			this.value = this.editor.document.selection.getAttribute( 'linkHref' );
+		} );
+	}
+
+	/**
+	 * Checks if {@link engine.model.Document#schema} allows to create attribute in {@link engine.model.Document#selection}
+	 *
+	 * @protected
+	 * @returns {Boolean}
+	 */
+	_checkEnabled() {
+		const document = this.editor.document;
+
+		return isAttributeAllowedInSelection( 'linkHref', document.selection, document.schema );
+	}
+
+	/**
+	 * Executes the command.
+	 *
+	 * When selection is non-collapsed then `linkHref` attribute will be applied to nodes inside selection, but only to
+	 * this nodes where `linkHref` attribute is allowed (disallowed nodes will be omitted).
+	 *
+	 * When selection is collapsed and is not inside text with `linkHref` attribute then new {@link engine.model.Text Text node} with
+	 * `linkHref` attribute will be inserted in place of caret, but only if such an element is allowed in this place. _data of inserted
+	 * text will be equal to `href` parameter. Selection will be updated to wrap just inserted text node.
+	 *
+	 * When selection is collapsed and is inside text with `linkHref` attribute then attribute value will be updated.
+	 *
+	 * @protected
+	 * @param {String} href Link destination.
+	 */
+	_doExecute( href ) {
+		const document = this.editor.document;
+		const selection = document.selection;
+
+		document.enqueueChanges( () => {
+			// Keep it as one undo step.
+			const batch = document.batch();
+
+			// If selection is collapsed then update selected link or insert new one at the place of caret.
+			if ( selection.isCollapsed ) {
+				const position = selection.getFirstPosition();
+				const parent = position.parent;
+
+				// When selection is inside text with `linkHref` attribute.
+				if ( selection.hasAttribute( 'linkHref' ) ) {
+					// Then update `linkHref` value.
+					const linkRange = findLinkRange( selection.getFirstPosition(), selection.getAttribute( 'linkHref' ) );
+
+					batch.setAttribute( linkRange, 'linkHref', href );
+
+					// Create new range wrapping changed link.
+					selection.setRanges( [ linkRange ] );
+				}
+				// If not then insert text node with `linkHref` attribute in place of caret.
+				else if ( document.schema.check( { name: '$text', attributes: 'linkHref', inside: parent.name } ) ) {
+					const node = new Text( href, { linkHref: href } );
+
+					batch.insert( position, node );
+
+					// Create new range wrapping created node.
+					selection.setRanges( [ Range.createOn( node ) ] );
+				}
+			} else {
+				// If selection has non-collapsed ranges, we change attribute on nodes inside those ranges
+				// omitting nodes where `linkHref` attribute is disallowed.
+				const ranges = getSchemaValidRanges( 'linkHref', selection.getRanges(), document.schema );
+
+				for ( let range of ranges ) {
+					batch.setAttribute( range, 'linkHref', href );
+				}
+			}
+		} );
+	}
+}

+ 50 - 0
packages/ckeditor5-link/src/linkengine.js

@@ -0,0 +1,50 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Feature from '../core/feature.js';
+import buildModelConverter from '../engine/conversion/buildmodelconverter.js';
+import buildViewConverter from '../engine/conversion/buildviewconverter.js';
+import AttributeElement from '../engine/view/attributeelement.js';
+import LinkCommand from './linkcommand.js';
+import UnlinkCommand from './unlinkcommand.js';
+
+/**
+ * The link engine feature.
+ *
+ * It introduces the `linkHref="url"` attribute in the model which renders to the view as a `<a href="url">` element.
+ *
+ * @memberOf link
+ * @extends core.Feature
+ */
+export default class LinkEngine extends Feature {
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const data = editor.data;
+		const editing = editor.editing;
+
+		// Allow link attribute on all inline nodes.
+		editor.document.schema.allow( { name: '$inline', attributes: 'linkHref' } );
+
+		// Build converter from model to view for data and editing pipelines.
+		buildModelConverter().for( data.modelToView, editing.modelToView )
+			.fromAttribute( 'linkHref' )
+			.toElement( ( linkHref ) => new AttributeElement( 'a', { href: linkHref } ) );
+
+		// Build converter from view to model for data pipeline.
+		buildViewConverter().for( data.viewToModel )
+			.fromElement( 'a' )
+			.toAttribute( ( viewElement ) => ( {
+				key: 'linkHref',
+				value: viewElement.getAttribute( 'href' )
+			} ) );
+
+		// Create linking commands.
+		editor.commands.set( 'link', new LinkCommand( editor ) );
+		editor.commands.set( 'unlink', new UnlinkCommand( editor ) );
+	}
+}

+ 43 - 0
packages/ckeditor5-link/src/unlinkcommand.js

@@ -0,0 +1,43 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Command from '../core/command/command.js';
+import findLinkRange from './findlinkrange.js';
+
+/**
+ * The unlink command. It is used by the {@link Link.Link link feature}.
+ *
+ * @memberOf link
+ * @extends core.command.Command
+ */
+export default class UnlinkCommand extends Command {
+	/**
+	 * Executes the command.
+	 *
+	 * When selection is collapsed then remove `linkHref` attribute from each stick node with the same `linkHref` attribute value.
+	 *
+	 * When selection is non-collapsed then remove `linkHref` from each node in selected ranges.
+	 *
+	 * @protected
+	 */
+	_doExecute() {
+		const document = this.editor.document;
+		const selection = document.selection;
+
+		document.enqueueChanges( () => {
+			// Get ranges to unlink.
+			const rangesToUnlink = selection.isCollapsed ?
+				[ findLinkRange( selection.getFirstPosition(), selection.getAttribute( 'linkHref' ) ) ] : selection.getRanges();
+
+			// Keep it as one undo step.
+			const batch = document.batch();
+
+			// Remove `linkHref` attribute from specified ranges.
+			for ( let range of rangesToUnlink ) {
+				batch.removeAttribute( range, 'linkHref' );
+			}
+		} );
+	}
+}

+ 126 - 0
packages/ckeditor5-link/tests/findlinkrange.js

@@ -0,0 +1,126 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import findLinkRange from '/ckeditor5/link/findlinkrange.js';
+import Document from '/ckeditor5/engine/model/document.js';
+import Range from '/ckeditor5/engine/model/range.js';
+import Position from '/ckeditor5/engine/model/position.js';
+import { setData } from '/tests/engine/_utils/model.js';
+
+describe( 'findLinkRange', () => {
+	let document, root;
+
+	beforeEach( () => {
+		document = new Document();
+		root = document.createRoot();
+		document.schema.allow( { name: '$text', inside: '$root' } );
+		document.schema.registerItem( 'p', '$block' );
+	} );
+
+	it( 'should find link range searching from the center of the link #1', () => {
+		setData( document, '<$text linkHref="url">foobar</$text>' );
+
+		const startPosition = new Position( root, [ 3 ] );
+		const result = findLinkRange( startPosition, 'url' );
+
+		expect( result ).to.instanceOf( Range );
+		expect( result.isEqual( Range.createFromParentsAndOffsets( root, 0, root, 6 ) ) ).to.true;
+	} );
+
+	it( 'should find link range searching from the center of the link #2', () => {
+		setData( document, 'abc <$text linkHref="url">foobar</$text> abc' );
+
+		const startPosition = new Position( root, [ 7 ] );
+		const result = findLinkRange( startPosition, 'url' );
+
+		expect( result ).to.instanceOf( Range );
+		expect( result.isEqual( Range.createFromParentsAndOffsets( root, 4, root, 10 ) ) ).to.true;
+	} );
+
+	it( 'should find link range searching from the beginning of the link #1', () => {
+		setData( document, '<$text linkHref="url">foobar</$text>' );
+
+		const startPosition = new Position( root, [ 0 ] );
+		const result = findLinkRange( startPosition, 'url' );
+
+		expect( result ).to.instanceOf( Range );
+		expect( result.isEqual( Range.createFromParentsAndOffsets( root, 0, root, 6 ) ) ).to.true;
+	} );
+
+	it( 'should find link range searching from the beginning of the link #2', () => {
+		setData( document, 'abc <$text linkHref="url">foobar</$text> abc' );
+
+		const startPosition = new Position( root, [ 4 ] );
+		const result = findLinkRange( startPosition, 'url' );
+
+		expect( result ).to.instanceOf( Range );
+		expect( result.isEqual( Range.createFromParentsAndOffsets( root, 4, root, 10 ) ) ).to.true;
+	} );
+
+	it( 'should find link range searching from the end of the link #1', () => {
+		setData( document, '<$text linkHref="url">foobar</$text>' );
+
+		const startPosition = new Position( root, [ 6 ] );
+		const result = findLinkRange( startPosition, 'url' );
+
+		expect( result ).to.instanceOf( Range );
+		expect( result.isEqual( Range.createFromParentsAndOffsets( root, 0, root, 6 ) ) ).to.true;
+	} );
+
+	it( 'should find link range searching from the end of the link #2', () => {
+		setData( document, 'abc <$text linkHref="url">foobar</$text> abc' );
+
+		const startPosition = new Position( root, [ 10 ] );
+		const result = findLinkRange( startPosition, 'url' );
+
+		expect( result ).to.instanceOf( Range );
+		expect( result.isEqual( Range.createFromParentsAndOffsets( root, 4, root, 10 ) ) ).to.true;
+	} );
+
+	it( 'should find link range when link stick to other link searching from the center of the link', () => {
+		setData( document, '<$text linkHref="other">abc</$text><$text linkHref="url">foobar</$text><$text linkHref="other">abc</$text>' );
+
+		const startPosition = new Position( root, [ 6 ] );
+		const result = findLinkRange( startPosition, 'url' );
+
+		expect( result ).to.instanceOf( Range );
+		expect( result.isEqual( Range.createFromParentsAndOffsets( root, 3, root, 9 ) ) ).to.true;
+	} );
+
+	it( 'should find link range when link stick to other link searching from the beginning of the link', () => {
+		setData( document, '<$text linkHref="other">abc</$text><$text linkHref="url">foobar</$text><$text linkHref="other">abc</$text>' );
+
+		const startPosition = new Position( root, [ 3 ] );
+		const result = findLinkRange( startPosition, 'url' );
+
+		expect( result ).to.instanceOf( Range );
+		expect( result.isEqual( Range.createFromParentsAndOffsets( root, 3, root, 9 ) ) ).to.true;
+	} );
+
+	it( 'should find link range when link stick to other link searching from the end of the link', () => {
+		setData( document, '<$text linkHref="other">abc</$text><$text linkHref="url">foobar</$text><$text linkHref="other">abc</$text>' );
+
+		const startPosition = new Position( root, [ 9 ] );
+		const result = findLinkRange( startPosition, 'url' );
+
+		expect( result ).to.instanceOf( Range );
+		expect( result.isEqual( Range.createFromParentsAndOffsets( root, 3, root, 9 ) ) ).to.true;
+	} );
+
+	it( 'should find link range only inside current parent', () => {
+		setData(
+			document,
+			'<p><$text linkHref="url">foobar</$text></p>' +
+			'<p><$text linkHref="url">foobar</$text></p>' +
+			'<p><$text linkHref="url">foobar</$text></p>'
+		);
+
+		const startPosition = new Position( root, [ 1, 3 ] );
+		const result = findLinkRange( startPosition, 'url' );
+
+		expect( result ).to.instanceOf( Range );
+		expect( result.isEqual( new Range( new Position( root, [ 1, 0 ] ), new Position( root, [ 1, 6 ] ) ) ) ).to.true;
+	} );
+} );

+ 233 - 0
packages/ckeditor5-link/tests/linkcommand.js

@@ -0,0 +1,233 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ModelTestEditor from '/tests/core/_utils/modeltesteditor.js';
+import LinkCommand from '/ckeditor5/link/linkcommand.js';
+import { setData, getData } from '/tests/engine/_utils/model.js';
+
+describe( 'LinkCommand', () => {
+	let editor, document, command;
+
+	beforeEach( () => {
+		return ModelTestEditor.create()
+			.then( newEditor => {
+				editor = newEditor;
+				document = editor.document;
+				command = new LinkCommand( editor );
+
+				// Allow text in $root.
+				document.schema.allow( { name: '$text', inside: '$root' } );
+
+				// Allow text with `linkHref` attribute in paragraph.
+				document.schema.registerItem( 'p', '$block' );
+				document.schema.allow( { name: '$text', attributes: 'linkHref', inside: '$root' } );
+			} );
+	} );
+
+	afterEach( () => {
+		command.destroy();
+	} );
+
+	describe( 'value', () => {
+		describe( 'collapsed selection', () => {
+			it( 'should be equal attribute value when selection is placed inside element with `linkHref` attribute', () => {
+				setData( document, `<$text linkHref="url">foo[]bar</$text>` );
+
+				expect( command.value ).to.equal( 'url' );
+			} );
+
+			it( 'should be undefined when selection is placed inside element without `linkHref` attribute', () => {
+				setData( document, `<$text bold="true">foo[]bar</$text>` );
+
+				expect( command.value ).to.undefined;
+			} );
+		} );
+
+		describe( 'non-collapsed selection', () => {
+			it( 'should be equal attribute value when selection contains only elements with `linkHref` attribute', () => {
+				setData( document, 'fo[<$text linkHref="url">ob</$text>]ar' );
+
+				expect( command.value ).to.equal( 'url' );
+			} );
+
+			it( 'should be undefined when selection contains not only elements with `linkHref` attribute', () => {
+				setData( document, 'f[o<$text linkHref="url">ob</$text>]ar' );
+
+				expect( command.value ).to.undefined;
+			} );
+		} );
+	} );
+
+	describe( '_doExecute', () => {
+		describe( 'non-collapsed selection', () => {
+			it( 'should set `linkHref` attribute to selected text', () => {
+				setData( document, 'f[ooba]r' );
+
+				expect( command.value ).to.undefined;
+
+				command._doExecute( 'url' );
+
+				expect( getData( document ) ).to.equal( 'f[<$text linkHref="url">ooba</$text>]r' );
+				expect( command.value ).to.equal( 'url' );
+			} );
+
+			it( 'should set `linkHref` attribute to selected text when text already has attributes', () => {
+				setData( document, 'f[o<$text bold="true">oba]r</$text>' );
+
+				expect( command.value ).to.undefined;
+
+				command._doExecute( 'url' );
+
+				expect( command.value ).to.equal( 'url' );
+				expect( getData( document ) ).to.equal(
+					'f[<$text linkHref="url">o</$text>' +
+					'<$text bold="true" linkHref="url">oba</$text>]' +
+					'<$text bold="true">r</$text>'
+				);
+			} );
+
+			it( 'should overwrite existing `linkHref` attribute when selected text wraps text with `linkHref` attribute', () => {
+				setData( document, 'f[o<$text linkHref="other url">o</$text>ba]r' );
+
+				expect( command.value ).to.undefined;
+
+				command._doExecute( 'url' );
+
+				expect( getData( document ) ).to.equal( 'f[<$text linkHref="url">ooba</$text>]r' );
+				expect( command.value ).to.equal( 'url' );
+			} );
+
+			it( 'should split text and overwrite attribute value when selection is inside text with `linkHref` attribute', () => {
+				setData( document, 'f<$text linkHref="other url">o[ob]a</$text>r' );
+
+				expect( command.value ).to.equal( 'other url' );
+
+				command._doExecute( 'url' );
+
+				expect( getData( document ) ).to.equal(
+					'f' +
+					'<$text linkHref="other url">o</$text>' +
+					'[<$text linkHref="url">ob</$text>]' +
+					'<$text linkHref="other url">a</$text>' +
+					'r'
+				);
+				expect( command.value ).to.equal( 'url' );
+			} );
+
+			it(
+				'should overwrite `linkHref` attribute of selected text only, when selection start inside text with `linkHref` attribute',
+			() => {
+				setData( document, 'f<$text linkHref="other url">o[o</$text>ba]r' );
+
+				expect( command.value ).to.equal( 'other url' );
+
+				command._doExecute( 'url' );
+
+				expect( getData( document ) ).to.equal( 'f<$text linkHref="other url">o</$text>[<$text linkHref="url">oba</$text>]r' );
+				expect( command.value ).to.equal( 'url' );
+			} );
+
+			it( 'should overwrite `linkHref` attribute of selected text only, when selection end inside text with `linkHref` attribute', () => {
+				setData( document, 'f[o<$text linkHref="other url">ob]a</$text>r' );
+
+				expect( command.value ).to.undefined;
+
+				command._doExecute( 'url' );
+
+				expect( getData( document ) ).to.equal( 'f[<$text linkHref="url">oob</$text>]<$text linkHref="other url">a</$text>r' );
+				expect( command.value ).to.equal( 'url' );
+			} );
+
+			it( 'should set `linkHref` attribute to selected text when text is split by $block element', () => {
+				setData( document, '<p>f[oo</p><p>ba]r</p>' );
+
+				expect( command.value ).to.undefined;
+
+				command._doExecute( 'url' );
+
+				expect( getData( document ) )
+					.to.equal( '<p>f[<$text linkHref="url">oo</$text></p><p><$text linkHref="url">ba</$text>]r</p>' );
+				expect( command.value ).to.equal( 'url' );
+			} );
+
+			it( 'should set `linkHref` attribute only to allowed elements and omit disallowed', () => {
+				// Disallow text in img.
+				document.schema.registerItem( 'img', '$block' );
+				document.schema.disallow( { name: '$text', attributes: 'linkHref', inside: 'img' } );
+
+				setData( document, '<p>f[oo<img></img>ba]r</p>' );
+
+				expect( command.value ).to.undefined;
+
+				command._doExecute( 'url' );
+
+				expect( getData( document ) )
+					.to.equal( '<p>f[<$text linkHref="url">oo</$text><img></img><$text linkHref="url">ba</$text>]r</p>' );
+				expect( command.value ).to.equal( 'url' );
+			} );
+		} );
+
+		describe( 'collapsed selection', () => {
+			it( 'should insert text with `linkHref` attribute, text data equal to href and select new link', () => {
+				setData( document, 'foo[]bar' );
+
+				command._doExecute( 'url' );
+
+				expect( getData( document ) ).to.equal( 'foo[<$text linkHref="url">url</$text>]bar' );
+			} );
+
+			it( 'should update `linkHref` attribute and select whole link when selection is inside text with `linkHref` attribute', () => {
+				setData( document, '<$text linkHref="other url">foo[]bar</$text>' );
+
+				command._doExecute( 'url' );
+
+				expect( getData( document ) ).to.equal( '[<$text linkHref="url">foobar</$text>]' );
+			} );
+
+			it( 'should not insert text with `linkHref` attribute when is not allowed in parent', () => {
+				document.schema.disallow( { name: '$text', attributes: 'linkHref', inside: 'p' } );
+				setData( document, '<p>foo[]bar</p>' );
+
+				command._doExecute( 'url' );
+
+				expect( getData( document ) ).to.equal( '<p>foo[]bar</p>' );
+			} );
+		} );
+	} );
+
+	describe( '_checkEnabled', () => {
+		// This test doesn't tests every possible case.
+		// Method `_checkEnabled` uses `isAttributeAllowedInSelection` helper which is fully tested in his own test.
+
+		beforeEach( () => {
+			document.schema.registerItem( 'x', '$block' );
+			document.schema.disallow( { name: '$text', inside: 'x', attributes: 'linkHref' } );
+		} );
+
+		describe( 'when selection is collapsed', () => {
+			it( 'should return true if characters with the attribute can be placed at caret position', () => {
+				setData( document, '<p>f[]oo</p>' );
+				expect( command._checkEnabled() ).to.be.true;
+			} );
+
+			it( 'should return false if characters with the attribute cannot be placed at caret position', () => {
+				setData( document, '<x>fo[]o</x>' );
+				expect( command._checkEnabled() ).to.be.false;
+			} );
+		} );
+
+		describe( 'when selection is not collapsed', () => {
+			it( 'should return true if there is at least one node in selection that can have the attribute', () => {
+				setData( document, '<p>[foo]</p>' );
+				expect( command._checkEnabled() ).to.be.true;
+			} );
+
+			it( 'should return false if there are no nodes in selection that can have the attribute', () => {
+				setData( document, '<x>[foo]</x>' );
+				expect( command._checkEnabled() ).to.be.false;
+			} );
+		} );
+	} );
+} );

+ 71 - 0
packages/ckeditor5-link/tests/linkengine.js

@@ -0,0 +1,71 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import LinkEngine from '/ckeditor5/link/linkengine.js';
+import LinkCommand from '/ckeditor5/link/linkcommand.js';
+import UnlinkCommand from '/ckeditor5/link/unlinkcommand.js';
+import VirtualTestEditor from '/tests/core/_utils/virtualtesteditor.js';
+import { getData as getModelData, setData as setModelData } from '/tests/engine/_utils/model.js';
+import { getData as getViewData } from '/tests/engine/_utils/view.js';
+
+describe( 'LinkEngine', () => {
+	let editor, doc;
+
+	beforeEach( () => {
+		return VirtualTestEditor.create( {
+				features: [ LinkEngine ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+
+				doc = editor.document;
+
+				doc.schema.allow( { name: '$text', inside: '$root' } );
+			} );
+	} );
+
+	it( 'should be loaded', () => {
+		expect( editor.plugins.get( LinkEngine ) ).to.be.instanceOf( LinkEngine );
+	} );
+
+	it( 'should set proper schema rules', () => {
+		expect( doc.schema.check( { name: '$inline', attributes: [ 'linkHref' ] } ) ).to.be.true;
+	} );
+
+	describe( 'command', () => {
+		it( 'should register link command', () => {
+			expect( editor.commands.has( 'link' ) ).to.be.true;
+
+			const command = editor.commands.get( 'link' );
+
+			expect( command ).to.be.instanceOf( LinkCommand );
+		} );
+
+		it( 'should register unlink command', () => {
+			expect( editor.commands.has( 'unlink' ) ).to.be.true;
+
+			const command = editor.commands.get( 'unlink' );
+
+			expect( command ).to.be.instanceOf( UnlinkCommand );
+		} );
+	} );
+
+	describe( 'data pipeline conversions', () => {
+		it( 'should convert `<a href="url">` to `linkHref="url"` attribute', () => {
+			editor.setData( '<a href="url">foo</a>bar' );
+
+			expect( getModelData( doc, { withoutSelection: true } ) ).to.equal( '<$text linkHref="url">foo</$text>bar' );
+			expect( editor.getData() ).to.equal( '<a href="url">foo</a>bar' );
+		} );
+	} );
+
+	describe( 'editing pipeline conversion', () => {
+		it( 'should convert attribute', () => {
+			setModelData( doc, '<$text linkHref="url">foo</$text>bar' );
+
+			expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( '<a href="url">foo</a>bar' );
+		} );
+	} );
+} );

+ 199 - 0
packages/ckeditor5-link/tests/unlinkcommand.js

@@ -0,0 +1,199 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ModelTestEditor from '/tests/core/_utils/modeltesteditor.js';
+import UnlinkCommand from '/ckeditor5/link/unlinkcommand.js';
+import { setData, getData } from '/tests/engine/_utils/model.js';
+
+describe( 'UnlinkCommand', () => {
+	let editor, document, command;
+
+	beforeEach( () => {
+		return ModelTestEditor.create()
+			.then( newEditor => {
+				editor = newEditor;
+				document = editor.document;
+				command = new UnlinkCommand( editor );
+
+				// Allow text in $root.
+				document.schema.allow( { name: '$text', inside: '$root' } );
+
+				// Allow text with `linkHref` attribute in paragraph.
+				document.schema.registerItem( 'p', '$block' );
+				document.schema.allow( { name: '$text', attributes: 'linkHref', inside: '$root' } );
+			} );
+	} );
+
+	afterEach( () => {
+		command.destroy();
+	} );
+
+	describe( '_doExecute', () => {
+		describe( 'non-collapsed selection', () => {
+			it( 'should remove `linkHref` attribute from selected text', () => {
+				setData( document, '<$text linkHref="url">f[ooba]r</$text>' );
+
+				command._doExecute();
+
+				expect( getData( document ) ).to.equal( '<$text linkHref="url">f</$text>[ooba]<$text linkHref="url">r</$text>' );
+			} );
+
+			it( 'should remove `linkHref` attribute from selected text and do not modified other attributes', () => {
+				setData( document, '<$text bold="true" linkHref="url">f[ooba]r</$text>' );
+
+				command._doExecute();
+
+				expect( getData( document ) ).to.equal(
+					'<$text bold="true" linkHref="url">f</$text>' +
+					'[<$text bold="true">ooba</$text>]' +
+					'<$text bold="true" linkHref="url">r</$text>'
+				);
+			} );
+
+			it( 'should remove `linkHref` attribute from selected text when attributes have different value', () => {
+				setData( document, '[<$text linkHref="url">foo</$text><$text linkHref="other url">bar</$text>]' );
+
+				command._doExecute();
+
+				expect( getData( document ) ).to.equal( '[foobar]' );
+			} );
+
+			it( 'should remove `linkHref` attribute from selection', () => {
+				setData( document, '<$text linkHref="url">f[ooba]r</$text>' );
+
+				command._doExecute();
+
+				expect( document.selection.hasAttribute( 'linkHref' ) ).to.false;
+			} );
+		} );
+
+		describe( 'collapsed selection', () => {
+			it( 'should remove `linkHref` attribute from selection siblings with the same attribute value', () => {
+				setData( document, '<$text linkHref="url">foo[]bar</$text>' );
+
+				command._doExecute();
+
+				expect( getData( document ) ).to.equal( 'foo[]bar' );
+			} );
+
+			it(
+				'should remove `linkHref` attribute from selection siblings with the same attribute value and do not modify other attributes',
+			() => {
+				setData(
+					document,
+					'<$text linkHref="other url">fo</$text>' +
+					'<$text linkHref="url">o[]b</$text>' +
+					'<$text linkHref="other url">ar</$text>'
+				);
+
+				command._doExecute();
+
+				expect( getData( document ) ).to.equal(
+					'<$text linkHref="other url">fo</$text>' +
+					'o[]b' +
+					'<$text linkHref="other url">ar</$text>'
+				);
+			} );
+
+			it( 'should do nothing with nodes with the same `linkHref` value when there is a node with different value `linkHref` ' +
+				'attribute between', () => {
+				setData(
+					document,
+					'<$text linkHref="same url">f</$text>' +
+					'<$text linkHref="other url">o</$text>' +
+					'<$text linkHref="same url">o[]b</$text>' +
+					'<$text linkHref="other url">a</$text>' +
+					'<$text linkHref="same url">r</$text>'
+				);
+
+				command._doExecute();
+
+				expect( getData( document ) )
+					.to.equal(
+						'<$text linkHref="same url">f</$text>' +
+						'<$text linkHref="other url">o</$text>' +
+						'o[]b' +
+						'<$text linkHref="other url">a</$text>' +
+						'<$text linkHref="same url">r</$text>'
+					);
+			} );
+
+			it(
+				'should remove `linkHref` attribute from selection siblings with the same attribute value and do nothing with other ' +
+				'attributes',
+			() => {
+				setData(
+					document,
+					'<$text linkHref="url">f</$text>' +
+					'<$text bold="true" linkHref="url">o</$text>' +
+					'<$text linkHref="url">o[]b</$text>' +
+					'<$text bold="true" linkHref="url">a</$text>' +
+					'<$text linkHref="url">r</$text>'
+				);
+
+				command._doExecute();
+
+				expect( getData( document ) ).to.equal(
+					'f' +
+					'<$text bold="true">o</$text>' +
+					'o[]b' +
+					'<$text bold="true">a</$text>' +
+					'r'
+				);
+			} );
+
+			it( 'should remove `linkHref` attribute from selection siblings only in the same parent as selection parent', () => {
+				setData(
+					document,
+					'<p><$text linkHref="url">bar</$text></p>' +
+					'<p><$text linkHref="url">fo[]o</$text></p>' +
+					'<p><$text linkHref="url">bar</$text></p>'
+				);
+
+				command._doExecute();
+
+				expect( getData( document ) ).to.equal(
+					'<p><$text linkHref="url">bar</$text></p>' +
+					'<p>fo[]o</p>' +
+					'<p><$text linkHref="url">bar</$text></p>'
+				);
+			} );
+
+			it( 'should remove `linkHref` attribute from selection siblings when selection is at the end of link', () => {
+				setData( document, '<$text linkHref="url">foobar</$text>[]' );
+
+				command._doExecute();
+
+				expect( getData( document ) ).to.equal( 'foobar[]' );
+			} );
+
+			it( 'should remove `linkHref` attribute from selection siblings when selection is at the beginning of link', () => {
+				setData( document, '[]<$text linkHref="url">foobar</$text>' );
+
+				command._doExecute();
+
+				expect( getData( document ) ).to.equal( '[]foobar' );
+			} );
+
+			it( 'should remove `linkHref` attribute from selection siblings on the left side when selection is between two elements with ' +
+				'different `linkHref` attributes',
+			() => {
+				setData( document, '<$text linkHref="url">foo</$text>[]<$text linkHref="other url">bar</$text>' );
+
+				command._doExecute();
+
+				expect( getData( document ) ).to.equal( 'foo[]<$text linkHref="other url">bar</$text>' );
+			} );
+
+			it( 'should remove `linkHref` attribute from selection', () => {
+				setData( document, '<$text linkHref="url">foo[]bar</$text>' );
+
+				command._doExecute();
+
+				expect( document.selection.hasAttribute( 'linkHref' ) ).to.false;
+			} );
+		} );
+	} );
+} );