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

Merge pull request #19 from ckeditor/t/2

Implemented basic linking feature.
Aleksander Nowodzinski 9 лет назад
Родитель
Сommit
1cb2687fa2

+ 256 - 0
packages/ckeditor5-link/src/link.js

@@ -0,0 +1,256 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Feature from '../core/feature.js';
+import ClickObserver from '../engine/view/observer/clickobserver.js';
+import LinkEngine from './linkengine.js';
+import LinkElement from './linkelement.js';
+
+import Model from '../ui/model.js';
+
+import ButtonController from '../ui/button/button.js';
+import ButtonView from '../ui/button/buttonview.js';
+
+import LinkBalloonPanel from './ui/linkballoonpanel.js';
+import LinkBalloonPanelView from './ui/linkballoonpanelview.js';
+
+/**
+ * The link feature. It introduces the Link and Unlink buttons and the <kbd>Ctrl+L</kbd> keystroke.
+ *
+ * It uses the {@link link.LinkEngine link engine feature}.
+ *
+ * @memberOf link
+ * @extends core.Feature
+ */
+export default class Link extends Feature {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ LinkEngine ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		this.editor.editing.view.addObserver( ClickObserver );
+
+		/**
+		 * Link balloon panel component.
+		 *
+		 * @member {link.ui.LinkBalloonPanel} link.Link#balloonPanel
+		 */
+		this.balloonPanel = this._createBalloonPanel();
+
+		// Create toolbar buttons.
+		this._createToolbarLinkButton();
+		this._createToolbarUnlinkButton();
+	}
+
+	/**
+	 * Creates a toolbar link button. Clicking this button will show
+	 * {@link link.Link#balloonPanel} attached to the selection.
+	 *
+	 * @private
+	 */
+	_createToolbarLinkButton() {
+		const editor = this.editor;
+		const viewDocument = editor.editing.view;
+		const linkCommand = editor.commands.get( 'link' );
+		const t = editor.t;
+
+		// Create button model.
+		const linkButtonModel = new Model( {
+			isEnabled: true,
+			isOn: false,
+			label: t( 'Link' ),
+			icon: 'link',
+			keystroke: 'CTRL+L'
+		} );
+
+		// Bind button model to the command.
+		linkButtonModel.bind( 'isEnabled' ).to( linkCommand, 'isEnabled' );
+
+		// Show the panel on button click only when editor is focused.
+		this.listenTo( linkButtonModel, 'execute', () => {
+			if ( !viewDocument.isFocused ) {
+				return;
+			}
+
+			this._attachPanelToElement();
+		} );
+
+		// Add link button to feature components.
+		editor.ui.featureComponents.add( 'link', ButtonController, ButtonView, linkButtonModel );
+	}
+
+	/**
+	 * Create a toolbar unlink button. Clicking this button will unlink
+	 * the selected link.
+	 *
+	 * @private
+	 */
+	_createToolbarUnlinkButton() {
+		const editor = this.editor;
+		const t = editor.t;
+		const unlinkCommand = editor.commands.get( 'unlink' );
+
+		// Create the button model.
+		const unlinkButtonModel = new Model( {
+			isEnabled: false,
+			isOn: false,
+			label: t( 'Unlink' ),
+			icon: 'unlink'
+		} );
+
+		// Bind button model to the command.
+		unlinkButtonModel.bind( 'isEnabled' ).to( unlinkCommand, 'hasValue' );
+
+		// Execute unlink command and hide panel, if open.
+		this.listenTo( unlinkButtonModel, 'execute', () => {
+			editor.execute( 'unlink' );
+
+			if ( this.balloonPanel.view.isVisible ) {
+				this.balloonPanel.view.hide();
+			}
+		} );
+
+		// Add unlink button to feature components.
+		editor.ui.featureComponents.add( 'unlink', ButtonController, ButtonView, unlinkButtonModel );
+	}
+
+	/**
+	 * Creates the {@link link.ui.LinkBalloonPanel LinkBalloonPanel} instance
+	 * and attaches link command to {@link link.LinkBalloonPanelModel#execute} event.
+	 *
+	 *	                       +------------------------------------+
+	 *	                       | <a href="http://foo.com">[foo]</a> |
+	 *	                       +------------------------------------+
+	 *	                                      Document
+	 *	             Value set in doc   ^                   +
+	 *	             if it's correct.   |                   |
+	 *	                                |                   |
+	 *	                      +---------+--------+          |
+	 *	Panel.urlInput#value  | Value validation |          |  User clicked "Link" in
+	 *	       is validated.  +---------+--------+          |  the toolbar. Retrieving
+	 *	                                |                   |  URL from Document and setting
+	 *	             PanelModel fires   |                   |  PanelModel#url.
+	 *	          PanelModel#execute.   +                   v
+	 *
+	 *	                              +-----------------------+
+	 *	                              | url: 'http://foo.com' |
+	 *	                              +-----------------------+
+	 *	                                      PanelModel
+	 *	                                ^                   +
+	 *	                                |                   |  Input field is
+	 *	                  User clicked  |                   |  in sync with
+	 *	                       "Save".  |                   |  PanelModel#url.
+	 *	                                +                   v
+	 *
+	 *	                            +--------------------------+
+	 *	                            | +----------------------+ |
+	 *	                            | |http://foo.com        | |
+	 *	                            | +----------------------+ |
+	 *	                            |                   +----+ |
+	 *	                            |                   |Save| |
+	 *	                            |                   +----+ |
+	 *	                            +--------------------------+
+	 * @private
+	 * @returns {link.ui.LinkBalloonPanel} Link balloon panel instance.
+	 */
+	_createBalloonPanel() {
+		const editor = this.editor;
+		const viewDocument = editor.editing.view;
+		const linkCommand = editor.commands.get( 'link' );
+
+		// Create the model of the panel.
+		const panelModel = new Model( {
+			maxWidth: 300
+		} );
+
+		// Bind panel model to command.
+		panelModel.bind( 'url' ).to( linkCommand, 'value' );
+
+		// Create the balloon panel instance.
+		const balloonPanel = new LinkBalloonPanel( panelModel, new LinkBalloonPanelView( editor.locale ) );
+
+		// Observe `LinkBalloonPanelMode#executeLink` event from within the model of the panel,
+		// which means that the `Save` button has been clicked.
+		this.listenTo( panelModel, 'executeLink', () => {
+			editor.execute( 'link', balloonPanel.urlInput.value );
+			balloonPanel.view.hide();
+		} );
+
+		// Observe `LinkBalloonPanelMode#executeUnlink` event from within the model of the panel,
+		// which means that the `Unlink` button has been clicked.
+		this.listenTo( panelModel, 'executeUnlink', () => {
+			editor.execute( 'unlink' );
+			balloonPanel.view.hide();
+		} );
+
+		// Always focus editor on panel hide.
+		this.listenTo( balloonPanel.view.model, 'change:isVisible', ( evt, propertyName, value ) => {
+			if ( !value ) {
+				viewDocument.focus();
+			}
+		} );
+
+		// Hide panel on editor focus.
+		// @TODO replace it by some FocusManager.
+		viewDocument.on( 'focus', () => balloonPanel.view.hide() );
+
+		// Handle click on document and show panel when selection is placed in the link element.
+		viewDocument.on( 'click', () => {
+			if ( viewDocument.selection.isCollapsed && linkCommand.value !== undefined ) {
+				this._attachPanelToElement();
+			}
+		} );
+
+		// Handle `Ctrl+L` keystroke and show panel.
+		editor.keystrokes.set( 'CTRL+L', () => this._attachPanelToElement() );
+
+		// Append panel element to body.
+		editor.ui.add( 'body', balloonPanel );
+
+		return balloonPanel;
+	}
+
+	/**
+	 * Shows {@link link#balloonPanel LinkBalloonPanel} and attach to target element.
+	 * If selection is collapsed and is placed inside link element, then panel will be attached
+	 * to whole link element, otherwise will be attached to the selection.
+	 *
+	 * Input inside panel will be focused.
+	 *
+	 * @private
+	 */
+	_attachPanelToElement() {
+		const viewDocument = this.editor.editing.view;
+		const domEditableElement = viewDocument.domConverter.getCorrespondingDomElement( viewDocument.selection.editableElement );
+
+		const viewSelectionParent = viewDocument.selection.getFirstPosition().parent;
+		const viewSelectionParentAncestors = viewSelectionParent.getAncestors();
+		const linkElement = viewSelectionParentAncestors.find( ( ancestor ) => ancestor instanceof LinkElement );
+
+		// When selection is inside link element, then attach panel to this element.
+		if ( linkElement ) {
+			this.balloonPanel.view.attachTo(
+				viewDocument.domConverter.getCorrespondingDomElement( linkElement ),
+				domEditableElement
+			);
+		}
+		// Otherwise attach panel to the selection.
+		else {
+			this.balloonPanel.view.attachTo(
+				viewDocument.domConverter.viewRangeToDom( viewDocument.selection.getFirstRange() ),
+				domEditableElement
+			);
+		}
+
+		// Set focus to the panel input.
+		this.balloonPanel.urlInput.view.focus();
+	}
+}

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

@@ -25,7 +25,7 @@ export default class LinkCommand extends Command {
 		super( editor );
 
 		/**
-		 * Currently selected linkHref attribute value.
+		 * Currently selected `linkHref` attribute value.
 		 *
 		 * @observable
 		 * @member {Boolean} core.command.ToggleAttributeCommand#value
@@ -52,14 +52,14 @@ export default class LinkCommand extends Command {
 	/**
 	 * 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 non-collapsed, then `linkHref` attribute will be applied to nodes inside selection, but only to
+	 * those 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 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
+	 * the inserted text will equal `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.
+	 * When selection is collapsed and inside text with `linkHref` attribute, the attribute value will be updated.
 	 *
 	 * @protected
 	 * @param {String} href Link destination.

+ 17 - 0
packages/ckeditor5-link/src/linkelement.js

@@ -0,0 +1,17 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import AttributeElement from '../engine/view/attributeelement.js';
+
+/**
+ * This class is to mark specific {@link engine.view.Node} as {@link link.LinkElement}.
+ * E.g. There could be a situation when different features will create nodes with the same names,
+ * and hence they must be identified somehow.
+ *
+ * @memberOf link
+ * @extends engine.view.AttributeElement
+ */
+export default class LinkElement extends AttributeElement {
+}

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

@@ -6,7 +6,7 @@
 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 LinkElement from './linkelement.js';
 import LinkCommand from './linkcommand.js';
 import UnlinkCommand from './unlinkcommand.js';
 
@@ -33,7 +33,7 @@ export default class LinkEngine extends Feature {
 		// 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 } ) );
+			.toElement( ( linkHref ) => new LinkElement( 'a', { href: linkHref } ) );
 
 		// Build converter from view to model for data pipeline.
 		buildViewConverter().for( data.viewToModel )

+ 204 - 0
packages/ckeditor5-link/src/ui/linkballoonpanel.js

@@ -0,0 +1,204 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Model from '../../ui/model.js';
+import Button from '../../ui/button/button.js';
+import ButtonView from '../../ui/button/buttonview.js';
+import BalloonPanel from '../../ui/balloonpanel/balloonpanel.js';
+import LabeledInput from '../../ui/labeledinput/labeledinput.js';
+import LabeledInputView from '../../ui/labeledinput/labeledinputview.js';
+import LinkForm from './linkform.js';
+import LinkFormView from './linkformview.js';
+import InputText from '../../ui/inputtext/inputtext.js';
+import InputTextView from '../../ui/inputtext/inputtextview.js';
+
+/**
+ * The link balloon panel controller class.
+ *
+ *		const model = new Model( {
+ *			maxWidth: 300,
+ *			url: 'http://ckeditor.com'
+ *		} );
+ *
+ *		// An instance of LinkBalloonPanel.
+ *		new LinkBalloonPanel( model, new LinkBalloonPanelView() );
+ *
+ * See {@link link.ui.LinkBalloonPanelView}.
+ *
+ * @memberOf link.ui
+ * @extends ui.balloonPanel.BalloonPanel
+ */
+export default class LinkBalloonPanel extends BalloonPanel {
+	/**
+	 * Creates an instance of {@link link.ui.LinkBalloonPanel} class.
+	 *
+	 * @param {link.ui.LinkBalloonPanelModel} model Model of this link balloon panel.
+	 * @param {ui.View} view View of this link balloon panel.
+	 */
+	constructor( model, view ) {
+		super( model, view );
+
+		this.add( 'content', this._createForm() );
+	}
+
+	/**
+	 * Initializes {@link link.ui.Form} component with input and buttons.
+	 *
+	 * @private
+	 * @returns {link.ui.Form} Form component.
+	 */
+	_createForm() {
+		const formModel = new Model();
+
+		formModel.on( 'execute', () => this.model.fire( 'executeLink' ) );
+
+		/**
+		 * An instance of {@link link.ui.Form} component.
+		 *
+		 * @member {link.ui.Form} link.ui.LinkBalloonPanel#form
+		 */
+		this.form = new LinkForm( formModel, new LinkFormView( this.locale ) );
+
+		/**
+		 * The button component for submitting form.
+		 *
+		 * @member {ui.button.Button} link.ui.LinkBalloonPanel#saveButton
+		 */
+		this.saveButton = this._createSaveButton();
+
+		/**
+		 * The button component for canceling form.
+		 *
+		 * @member {ui.button.Button} link.ui.LinkBalloonPanel#cancelButton
+		 */
+		this.cancelButton = this._createCancelButton();
+
+		/**
+		 * The button component for unlinking.
+		 *
+		 * @member {ui.button.Button} link.ui.LinkBalloonPanel#unlinkButton
+		 */
+		this.unlinkButton = this._createUnlinkButton();
+
+		// Add Input to the form content.
+		this.form.add( 'content', this._createLabeledInput() );
+
+		// Add `Save` and `Cancel` buttons to the form actions.
+		this.form.add( 'actions', this.saveButton );
+		this.form.add( 'actions', this.cancelButton );
+		this.form.add( 'actions', this.unlinkButton );
+
+		return this.form;
+	}
+
+	/**
+	 * Initializes the {@link ui.input.LabeledInput LabeledInput} which displays
+	 * and allows manipulation of the `href` attribute in edited link.
+	 *
+	 * @private
+	 * @returns {ui.input.LabeledInput} Labeled input component.
+	 */
+	_createLabeledInput() {
+		const t = this.view.t;
+		const model = new Model( {
+			label: t( 'Link URL' )
+		} );
+
+		model.bind( 'value' ).to( this.model, 'url' );
+
+		/**
+		 * The input component to display and manipulate the `href` attribute.
+		 *
+		 * @member {ui.input.LabeledInput} link.ui.LinkBalloonPanel#urlInput
+		 */
+		return ( this.urlInput = new LabeledInput( model, new LabeledInputView( this.locale ),
+			InputText, InputTextView, new Model() ) );
+	}
+
+	/**
+	 * Initializes the {@link ui.button.Button} for submitting the form.
+	 *
+	 * @private
+	 * @returns {ui.button.Button} Save button component.
+	 */
+	_createSaveButton() {
+		const t = this.view.t;
+		const saveModel = new Model( {
+			isEnabled: true,
+			isOn: false,
+			label: t( 'Save' ),
+			withText: true,
+			type: 'submit'
+		} );
+
+		const button = new Button( saveModel, new ButtonView( this.locale ) );
+
+		button.view.element.classList.add( 'ck-button-action' );
+
+		return button;
+	}
+
+	/**
+	 * Initializes the {@link ui.button.Button Button} for canceling the form.
+	 *
+	 * @private
+	 * @returns {ui.button.Button} Cancel button component.
+	 */
+	_createCancelButton() {
+		const t = this.view.t;
+		const cancelModel = new Model( {
+			isEnabled: true,
+			isOn: false,
+			label: t( 'Cancel' ),
+			withText: true
+		} );
+
+		cancelModel.on( 'execute', () => this.view.hide() );
+
+		return new Button( cancelModel, new ButtonView( this.locale ) );
+	}
+
+	/**
+	 * Initializes the {@link ui.button.Button Button} for the `unlink` command.
+	 *
+	 * @private
+	 * @returns {ui.button.Button} Unlink button component.
+	 */
+	_createUnlinkButton() {
+		const t = this.view.t;
+		const unlinkModel = new Model( {
+			isEnabled: true,
+			isOn: false,
+			label: t( 'Unlink' ),
+			icon: 'unlink'
+		} );
+
+		unlinkModel.on( 'execute', () => this.model.fire( 'executeUnlink' ) );
+
+		const button = new Button( unlinkModel, new ButtonView( this.locale ) );
+
+		return button;
+	}
+}
+
+/**
+ * The link balloon panel component {@link ui.Model} interface.
+ *
+ * @extends ui.balloonPanel.BalloonPanelModel
+ * @interface link.ui.LinkBalloonPanelModel
+ */
+
+/**
+ * URL of the link displayed in the {@link link.ui.LinkBalloonPanel#urlInput}.
+ *
+ * @observable
+ * @member {String} link.ui.LinkBalloonPanelModel#url
+ */
+
+/**
+ * Fired when {@link link.ui.LinkBalloonPanel#saveButton} has been executed by the user.
+ *
+ * @event link.ui.LinkBalloonPanelModel#execute
+ */

+ 32 - 0
packages/ckeditor5-link/src/ui/linkballoonpanelview.js

@@ -0,0 +1,32 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Template from '../../ui/template.js';
+import BalloonPanelView from '../../ui/balloonpanel/balloonpanelview.js';
+
+/**
+ * The link balloon panel view class.
+ *
+ * See {@link link.ui.LinkBalloonPanel}.
+ *
+ * @memberOf link.ui
+ * @extends ui.balloonPanel.BalloonPanelView
+ */
+export default class LinkBalloonPanelView extends BalloonPanelView {
+	/**
+	 * @inheritDoc
+	 */
+	constructor( locale ) {
+		super( locale );
+
+		Template.extend( this.template, {
+			attributes: {
+				class: [
+					'ck-link-balloon-panel',
+				]
+			}
+		} );
+	}
+}

+ 37 - 0
packages/ckeditor5-link/src/ui/linkform.js

@@ -0,0 +1,37 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Form from '../../ui/form/form.js';
+
+/**
+ * The link form class.
+ *
+ *		new LinkForm( new Model(), new LinkFormView() );
+ *
+ * See {@link link.ui.LinkFormView}.
+ *
+ * @memberOf link.ui
+ * @extends ui.form.Form
+ */
+export default class LinkForm extends Form {
+	/**
+	 * Creates an instance of {@link link.ui.LinkForm} class.
+	 *
+	 * @param {link.ui.LinkFormModel} model Model of this link form.
+	 * @param {ui.View} view View of this link form.
+	 */
+	constructor( model, view ) {
+		super( model, view );
+
+		this.addCollection( 'actions' );
+	}
+}
+
+/**
+ * The link form component {@link ui.Model model} interface.
+ *
+ * @extends ui.form.FormModel
+ * @interface link.ui.LinkFormModel
+ */

+ 45 - 0
packages/ckeditor5-link/src/ui/linkformview.js

@@ -0,0 +1,45 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Template from '../../ui/template.js';
+import FormView from '../../ui/form/formview.js';
+
+/**
+ * The link form view controller class.
+ *
+ * See {@link link.ui.LinkForm}.
+ *
+ * @memberOf link.ui
+ * @extends ui.form.FormView
+ */
+export default class LinkFormView extends FormView {
+	/**
+	 * @inheritDoc
+	 */
+	constructor( locale ) {
+		super( locale );
+
+		Template.extend( this.template, {
+			attributes: {
+				class: [
+					'ck-link-form',
+				]
+			}
+		} );
+
+		this.template.definition.children = [
+			{
+				tag: 'div',
+				attributes: {
+					class: [
+						'ck-link-form__actions'
+					]
+				}
+			}
+		];
+
+		this.register( 'actions', 'div.ck-link-form__actions' );
+	}
+}

+ 23 - 3
packages/ckeditor5-link/src/unlinkcommand.js

@@ -13,12 +13,32 @@ import findLinkRange from './findlinkrange.js';
  * @extends core.command.Command
  */
 export default class UnlinkCommand extends Command {
+	/**
+	 * @see core.command.Command
+	 * @param {core.editor.Editor} editor
+	 */
+	constructor( editor ) {
+		super( editor );
+
+		/**
+		 * Flag indicating whether command is active. For collapsed selection it means that typed characters will have
+		 * the command's attribute set. For range selection it means that all nodes inside have the attribute applied.
+		 *
+		 * @observable
+		 * @member {Boolean} link.UnlinkCommand#hasValue
+		 */
+		this.set( 'hasValue', undefined );
+
+		this.listenTo( this.editor.document.selection, 'change:attribute', () => {
+			this.hasValue = this.editor.document.selection.hasAttribute( 'linkHref' );
+		} );
+	}
+
 	/**
 	 * 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.
+	 * When the selection is collapsed, removes `linkHref` attribute from each node with the same `linkHref` attribute value.
+	 * When the selection is non-collapsed, removes `linkHref` from each node in selected ranges.
 	 *
 	 * @protected
 	 */

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

@@ -3,6 +3,8 @@
  * For licensing, see LICENSE.md.
  */
 
+/* bender-tags: link */
+
 import findLinkRange from '/ckeditor5/link/findlinkrange.js';
 import Document from '/ckeditor5/engine/model/document.js';
 import Range from '/ckeditor5/engine/model/range.js';

+ 253 - 0
packages/ckeditor5-link/tests/link.js

@@ -0,0 +1,253 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals document */
+
+import ClassicTestEditor from '/tests/core/_utils/classictesteditor.js';
+import testUtils from '/tests/core/_utils/utils.js';
+import { keyCodes } from '/ckeditor5/utils/keyboard.js';
+import { setData as setModelData } from '/tests/engine/_utils/model.js';
+
+import Link from '/ckeditor5/link/link.js';
+import LinkEngine from '/ckeditor5/link/linkengine.js';
+import Button from '/ckeditor5/ui/button/button.js';
+import LinkBalloonPanel from '/ckeditor5/link/ui/linkballoonpanel.js';
+
+import ClickObserver from '/ckeditor5/engine/view/observer/clickobserver.js';
+
+testUtils.createSinonSandbox();
+
+describe( 'Link', () => {
+	let editor, linkFeature, linkButton, unlinkButton, balloonPanel, editorElement;
+
+	beforeEach( () => {
+		editorElement = document.createElement( 'div' );
+		document.body.appendChild( editorElement );
+
+		return ClassicTestEditor.create( editorElement, {
+			features: [ Link ]
+		} )
+		.then( newEditor => {
+			newEditor.editing.view.attachDomRoot( editorElement );
+
+			editor = newEditor;
+
+			linkFeature = editor.plugins.get( Link );
+			linkButton = editor.ui.featureComponents.create( 'link' );
+			unlinkButton = editor.ui.featureComponents.create( 'unlink' );
+			balloonPanel = linkFeature.balloonPanel;
+		} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	it( 'should be loaded', () => {
+		expect( linkFeature ).to.instanceOf( Link );
+	} );
+
+	it( 'should load LinkEngine', () => {
+		expect( editor.plugins.get( LinkEngine ) ).to.instanceOf( LinkEngine );
+	} );
+
+	it( 'should register click observer', () => {
+		expect( editor.editing.view.getObserver( ClickObserver ) ).to.instanceOf( ClickObserver );
+	} );
+
+	describe( 'link toolbar button', () => {
+		it( 'should register link feature component', () => {
+			expect( linkButton ).to.instanceOf( Button );
+		} );
+
+		it( 'should bind linkButton#model to link command', () => {
+			const model = linkButton.model;
+			const command = editor.commands.get( 'link' );
+
+			expect( model.isEnabled ).to.be.true;
+
+			command.isEnabled = false;
+			expect( model.isEnabled ).to.be.false;
+		} );
+
+		it( 'should open panel on linkButton#model execute event, when editor is focused', () => {
+			editor.editing.view.isFocused = true;
+
+			linkButton.model.fire( 'execute' );
+
+			expect( linkFeature.balloonPanel.view.isVisible ).to.true;
+		} );
+
+		it( 'should not open panel on linkButton#model execute event, when editor is not focused', () => {
+			editor.editing.view.isFocused = false;
+
+			linkButton.model.fire( 'execute' );
+
+			expect( linkFeature.balloonPanel.view.isVisible ).to.false;
+		} );
+
+		it( 'should open panel attached to the link element, when collapsed selection is inside link element', () => {
+			const attachToSpy = sinon.spy( balloonPanel.view, 'attachTo' );
+
+			editor.document.schema.allow( { name: '$text', inside: '$root' } );
+			setModelData( editor.document, '<$text linkHref="url">some[] url</$text>' );
+			editor.editing.view.isFocused = true;
+
+			linkButton.model.fire( 'execute' );
+
+			const linkElement = editorElement.querySelector( 'a' );
+
+			expect( attachToSpy.calledWithExactly( linkElement, editorElement ) ).to.true;
+		} );
+
+		it( 'should open panel attached to the selection, when there is non-collapsed selection', () => {
+			const attachToSpy = sinon.spy( balloonPanel.view, 'attachTo' );
+
+			editor.document.schema.allow( { name: '$text', inside: '$root' } );
+			setModelData( editor.document, 'so[me ur]l' );
+			editor.editing.view.isFocused = true;
+
+			linkButton.model.fire( 'execute' );
+
+			const selectedRange = editorElement.ownerDocument.getSelection().getRangeAt( 0 );
+
+			expect( attachToSpy.calledWithExactly( selectedRange, editorElement ) ).to.true;
+		} );
+	} );
+
+	describe( 'unlink toolbar button', () => {
+		it( 'should register unlink feature component', () => {
+			expect( unlinkButton ).to.instanceOf( Button );
+		} );
+
+		it( 'should bind unlinkButton#model to unlink command', () => {
+			const model = unlinkButton.model;
+			const command = editor.commands.get( 'unlink' );
+
+			expect( model.isEnabled ).to.false;
+
+			command.hasValue = true;
+			expect( model.isEnabled ).to.true;
+		} );
+
+		it( 'should execute unlink command on unlinkButton#model execute event', () => {
+			const executeSpy = testUtils.sinon.spy( editor, 'execute' );
+
+			unlinkButton.model.fire( 'execute' );
+
+			expect( executeSpy.calledOnce ).to.true;
+			expect( executeSpy.calledWithExactly( 'unlink' ) ).to.true;
+		} );
+
+		it( 'should hide panel on unlinkButton#model execute event', () => {
+			balloonPanel.view.model.isVisible = true;
+
+			unlinkButton.model.fire( 'execute' );
+
+			expect( balloonPanel.view.model.isVisible ).to.false;
+		} );
+	} );
+
+	describe( 'link balloon panel', () => {
+		it( 'should create LinkBalloonPanel component', () => {
+			expect( balloonPanel ).to.instanceOf( LinkBalloonPanel );
+		} );
+
+		it( 'should bind balloonPanel#model to link command', () => {
+			const model = balloonPanel.model;
+			const command = editor.commands.get( 'link' );
+
+			expect( model.url ).to.undefined;
+
+			command.value = 'http://cksource.com';
+
+			expect( model.url ).to.equal( 'http://cksource.com' );
+		} );
+
+		it( 'should execute link command on balloonPanel#model executeLink event', () => {
+			const executeSpy = testUtils.sinon.spy( editor, 'execute' );
+
+			balloonPanel.model.url = 'http://cksource.com';
+			balloonPanel.model.fire( 'executeLink' );
+
+			expect( executeSpy.calledOnce ).to.true;
+			expect( executeSpy.calledWithExactly( 'link', 'http://cksource.com' ) ).to.true;
+		} );
+
+		it( 'should hide balloon panel on balloonPanel#model execute event', () => {
+			const hideSpy = testUtils.sinon.spy( balloonPanel.view, 'hide' );
+
+			balloonPanel.model.fire( 'executeLink' );
+
+			expect( hideSpy.calledOnce ).to.true;
+		} );
+
+		it( 'should execute unlink command on balloonPanel#model executeUnlink event', () => {
+			const executeSpy = testUtils.sinon.spy( editor, 'execute' );
+
+			balloonPanel.model.fire( 'executeUnlink' );
+
+			expect( executeSpy.calledOnce ).to.true;
+			expect( executeSpy.calledWithExactly( 'unlink' ) ).to.true;
+		} );
+
+		it( 'should hide balloon panel on balloonPanel#model executeUnlink event', () => {
+			const hideSpy = testUtils.sinon.spy( balloonPanel.view, 'hide' );
+
+			balloonPanel.model.fire( 'executeUnlink' );
+
+			expect( hideSpy.calledOnce ).to.true;
+		} );
+
+		it( 'should append panel element to the body', () => {
+			expect( document.body.contains( balloonPanel.view.element ) );
+		} );
+
+		it( 'should open panel on `CTRL+L` keystroke', () => {
+			editor.keystrokes.press( { keyCode: keyCodes.l, ctrlKey: true } );
+
+			expect( balloonPanel.view.model.isVisible ).to.true;
+		} );
+
+		it( 'should focus editor on balloonPanel hide', () => {
+			const focusSpy = sinon.spy( editor.editing.view, 'focus' );
+
+			balloonPanel.view.model.isVisible = true;
+
+			balloonPanel.view.model.isVisible = false;
+
+			expect( focusSpy.calledOnce ).to.true;
+		} );
+
+		it( 'should hide panel on editor focus event', () => {
+			balloonPanel.view.model.isVisible = true;
+
+			editor.editing.view.fire( 'focus' );
+
+			expect( balloonPanel.view.model.isVisible ).to.false;
+		} );
+
+		it( 'should open panel on editor click, when selection is inside link element', () => {
+			const observer = editor.editing.view.getObserver( ClickObserver );
+
+			editor.document.schema.allow( { name: '$text', inside: '$root' } );
+			setModelData( editor.document, '<$text linkHref="url">some[] url</$text>' );
+
+			observer.fire( 'click', { target: document.body } );
+
+			expect( balloonPanel.view.model.isVisible ).to.true;
+		} );
+
+		it( 'should not open panel on editor click, when selection is not inside link element', () => {
+			const observer = editor.editing.view.getObserver( ClickObserver );
+
+			setModelData( editor.document, '[]' );
+
+			observer.fire( 'click', { target: document.body } );
+
+			expect( balloonPanel.view.model.isVisible ).to.false;
+		} );
+	} );
+} );

+ 13 - 0
packages/ckeditor5-link/tests/linkelement.js

@@ -0,0 +1,13 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import LinkElement from '/ckeditor5/link/linkelement.js';
+import AttributeElement from '/ckeditor5/engine/view/attributeelement.js';
+
+describe( 'LinkElement', () => {
+	it( 'should extend AttributeElement', () => {
+		expect( new LinkElement( 'a' ) ).to.instanceof( AttributeElement );
+	} );
+} );

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

@@ -5,6 +5,7 @@
 
 import LinkEngine from '/ckeditor5/link/linkengine.js';
 import LinkCommand from '/ckeditor5/link/linkcommand.js';
+import LinkElement from '/ckeditor5/link/linkelement.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';
@@ -67,5 +68,11 @@ describe( 'LinkEngine', () => {
 
 			expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( '<a href="url">foo</a>bar' );
 		} );
+
+		it( 'should convert to `LinkElement` instance', () => {
+			setModelData( doc, '<$text linkHref="url">foo</$text>bar' );
+
+			expect( editor.editing.view.getRoot().getChild( 0 ) ).to.be.instanceof( LinkElement );
+		} );
 	} );
 } );

+ 7 - 0
packages/ckeditor5-link/tests/manual/link.html

@@ -0,0 +1,7 @@
+<head>
+	<link rel="stylesheet" href="%APPS_DIR%ckeditor/build/modules/amd/theme/ckeditor.css">
+</head>
+
+<div id="editor">
+	<p>This is <a href="http://ckeditor.com">CKEditor5</a> from <a href="http://cksource.com">CKSource</a>.</p>
+</div>

+ 19 - 0
packages/ckeditor5-link/tests/manual/link.js

@@ -0,0 +1,19 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals console:false, window, document */
+
+import ClassicEditor from '/ckeditor5/editor-classic/classic.js';
+
+ClassicEditor.create( document.querySelector( '#editor' ), {
+	features: [ 'link', 'typing', 'paragraph', 'undo' ],
+	toolbar: [ 'link', 'unlink', 'undo', 'redo' ]
+} )
+.then( editor => {
+	window.editor = editor;
+} )
+.catch( err => {
+	console.error( err.stack );
+} );

+ 45 - 0
packages/ckeditor5-link/tests/manual/link.md

@@ -0,0 +1,45 @@
+@bender-ui: collapsed
+
+## Link
+
+### Create new link from text
+
+1. Select fragment of regular text.
+2. Click toolbar link button.
+3. Check if balloon panel attached to the selection appeared.
+4. Fill in `Link URL` input in the panel.
+5. Click `Save` button.
+6. Check if selected text is converted into a link.
+
+### Insert new link
+
+1. Set collapsed selection inside a regular text.
+2. Click toolbar link button.
+3. Check if balloon panel attached to the selection appeared.
+4. Fill in `Link URL` input in the panel.
+5. Click `Save` button.
+6. Check if new link with anchor text the same as url value has been inserted and selected.
+
+### Edit link
+
+1. Click a link element.
+2. Check if balloon panel attached to the link element appeared.
+3. Change `Link URL` input value.
+4. Click `Save` button.
+5. Check if link href value has changed.
+
+### Keyboard support
+
+1. Check if above use cases works for keyboard support. For opening Link panel press `Ctrl+L`, for submitting form press `Enter`.
+
+### Unlink link fragment
+
+1. Select link fragment.
+2. Click toolbar unlink button.
+3. Check if selected text has been converted into a regular text.
+
+### Unlink whole link
+
+1. Click a link element.
+2. Click toolbar unlink button.
+3. Check if link has been converted into a regular text.

+ 142 - 0
packages/ckeditor5-link/tests/ui/linkballoonpanel.js

@@ -0,0 +1,142 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: ui, balloonPanel */
+
+import LinkBalloonPanel from '/ckeditor5/link/ui/linkballoonpanel.js';
+import LinkBalloonPanelView from '/ckeditor5/link/ui/linkballoonpanelview.js';
+import BalloonPanel from '/ckeditor5/ui/balloonpanel/balloonpanel.js';
+import Model from '/ckeditor5/ui/model.js';
+
+import LinkForm from '/ckeditor5/link/ui/linkform.js';
+import LabeledInput from '/ckeditor5/ui/labeledinput/labeledinput.js';
+import Button from '/ckeditor5/ui/button/button.js';
+
+import LocaleMock from '/tests/utils/_utils/locale-mock.js';
+
+describe( 'LinkBalloonPanel', () => {
+	let model, linkBalloonPanel, view;
+
+	beforeEach( () => {
+		model = new Model( {
+			maxWidth: 200,
+			url: 'http://ckeditor.com'
+		} );
+
+		view = new LinkBalloonPanelView( new LocaleMock() );
+		linkBalloonPanel = new LinkBalloonPanel( model, view );
+	} );
+
+	describe( 'constructor', () => {
+		it( 'should extend BalloonPanel class', () => {
+			expect( linkBalloonPanel ).to.be.instanceOf( BalloonPanel );
+		} );
+
+		describe( 'child components', () => {
+			describe( 'form', () => {
+				it( 'should be created', () => {
+					expect( linkBalloonPanel.form ).to.instanceof( LinkForm );
+				} );
+
+				it( 'should be appended to "content" collection', () => {
+					expect( linkBalloonPanel.collections.get( 'content' ).get( 0 ) ).to.deep.equal( linkBalloonPanel.form );
+				} );
+
+				it( 'should fire model#executeLink event on form.model#execute event', () => {
+					const executeSpy = sinon.spy();
+
+					model.on( 'executeLink', executeSpy );
+
+					linkBalloonPanel.form.model.fire( 'execute' );
+
+					expect( executeSpy.calledOnce ).to.true;
+				} );
+			} );
+
+			describe( 'urlInput', () => {
+				it( 'should be created', () => {
+					expect( linkBalloonPanel.urlInput ).to.instanceof( LabeledInput );
+				} );
+
+				it( 'should be appended to the form "content" collection', () => {
+					expect( linkBalloonPanel.form.collections.get( 'content' ).get( 0 ) ).to.deep.equal( linkBalloonPanel.urlInput );
+				} );
+
+				it( 'should bind model#url to urlInput.model#value', () => {
+					expect( linkBalloonPanel.urlInput.model.value ).to.equal( model.url ).to.equal( 'http://ckeditor.com' );
+
+					model.url = 'http://cksource.com';
+
+					expect( linkBalloonPanel.urlInput.model.value ).to.equal( 'http://cksource.com' );
+				} );
+			} );
+
+			describe( 'saveButton', () => {
+				it( 'should be created', () => {
+					expect( linkBalloonPanel.saveButton ).to.instanceof( Button );
+				} );
+
+				it( 'should be appended to the form "actions" collection', () => {
+					expect( linkBalloonPanel.form.collections.get( 'actions' ).get( 0 ) ).to.deep.equal( linkBalloonPanel.saveButton );
+				} );
+
+				it( 'should fire model#executeLink event on DOM click event', ( done ) => {
+					const executeSpy = sinon.spy();
+
+					model.on( 'executeLink', executeSpy );
+
+					linkBalloonPanel.init().then( () => {
+						linkBalloonPanel.saveButton.view.element.click();
+
+						expect( executeSpy.calledOnce ).to.true;
+						done();
+					} );
+				} );
+
+				it( 'should be a type `submit`', () => {
+					expect( linkBalloonPanel.saveButton.model.type ).to.equal( 'submit' );
+				} );
+			} );
+
+			describe( 'cancelButton', () => {
+				it( 'should be created', () => {
+					expect( linkBalloonPanel.cancelButton ).to.instanceof( Button );
+				} );
+
+				it( 'should be appended to the form "actions" collection', () => {
+					expect( linkBalloonPanel.form.collections.get( 'actions' ).get( 1 ) ).to.deep.equal( linkBalloonPanel.cancelButton );
+				} );
+
+				it( 'should hide LinkBalloonPanel on cancelButton.model#execute event', () => {
+					const hideSpy = sinon.spy( linkBalloonPanel.view, 'hide' );
+
+					linkBalloonPanel.cancelButton.model.fire( 'execute' );
+
+					expect( hideSpy.calledOnce ).to.true;
+				} );
+			} );
+
+			describe( 'unlinkButton', () => {
+				it( 'should be created', () => {
+					expect( linkBalloonPanel.unlinkButton ).to.instanceof( Button );
+				} );
+
+				it( 'should be appended to the form "actions" collection', () => {
+					expect( linkBalloonPanel.form.collections.get( 'actions' ).get( 2 ) ).to.deep.equal( linkBalloonPanel.unlinkButton );
+				} );
+
+				it( 'should fire model#executeUnlink event on unlinkButton.model#execute event', () => {
+					const executeUnlinkSpy = sinon.spy();
+
+					model.on( 'executeUnlink', executeUnlinkSpy );
+
+					linkBalloonPanel.unlinkButton.model.fire( 'execute' );
+
+					expect( executeUnlinkSpy.calledOnce ).to.true;
+				} );
+			} );
+		} );
+	} );
+} );

+ 27 - 0
packages/ckeditor5-link/tests/ui/linkballoonpanelview.js

@@ -0,0 +1,27 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: ui, balloonPanel */
+
+import LinkBalloonPanelView from '/ckeditor5/link/ui/linkballoonpanelview.js';
+import BalloonPanelView from '/ckeditor5/ui/balloonpanel/balloonpanelview.js';
+
+describe( 'LinkBalloonPanelView', () => {
+	let view;
+
+	beforeEach( () => {
+		view = new LinkBalloonPanelView();
+	} );
+
+	describe( 'constructor', () => {
+		it( 'should extend BalloonPanelView class', () => {
+			expect( view ).to.be.instanceof( BalloonPanelView );
+		} );
+
+		it( 'should extend BalloonPanel element by additional class', () => {
+			expect( view.element.classList.contains( 'ck-link-balloon-panel' ) ).to.be.true;
+		} );
+	} );
+} );

+ 30 - 0
packages/ckeditor5-link/tests/ui/linkform.js

@@ -0,0 +1,30 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: ui, form */
+
+import LinkForm from '/ckeditor5/link/ui/linkform.js';
+import LinkFormView from '/ckeditor5/link/ui/linkformview.js';
+import Form from '/ckeditor5/ui/form/form.js';
+import Model from '/ckeditor5/ui/model.js';
+
+describe( 'LinkForm', () => {
+	let linkForm, view;
+
+	beforeEach( () => {
+		view = new LinkFormView();
+		linkForm = new LinkForm( new Model(), view );
+	} );
+
+	describe( 'constructor', () => {
+		it( 'should extend Form class', () => {
+			expect( linkForm ).to.instanceof( Form );
+		} );
+
+		it( 'should create empty "actions" collection', () => {
+			expect( linkForm.collections.get( 'actions' ) ).to.have.length( 0 );
+		} );
+	} );
+} );

+ 34 - 0
packages/ckeditor5-link/tests/ui/linkformview.js

@@ -0,0 +1,34 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: ui, form */
+
+import LinkFormView from '/ckeditor5/link/ui/linkformview.js';
+import FormView from '/ckeditor5/ui/form/formview.js';
+
+describe( 'LinkFormView', () => {
+	let view;
+
+	beforeEach( () => {
+		view = new LinkFormView();
+
+		view.init();
+	} );
+
+	describe( 'constructor', () => {
+		it( 'should extend FormView class', () => {
+			expect( view ).to.instanceof( FormView );
+		} );
+
+		it( 'should create element from template', () => {
+			expect( view.element.classList.contains( 'ck-link-form' ) ).to.true;
+		} );
+
+		it( 'should register "actions" region', () => {
+			expect( view.regions.get( 1 ).name ).to.equal( 'actions' );
+			expect( view.regions.get( 1 ).element ).to.equal( view.element.querySelector( '.ck-link-form__actions' ) );
+		} );
+	} );
+} );

+ 29 - 0
packages/ckeditor5-link/theme/components/linkballoonpanel.scss

@@ -0,0 +1,29 @@
+// Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+// For licensing, see LICENSE.md or http://ckeditor.com/license
+
+.ck-link {
+	&-balloon-panel {
+		padding: ck-spacing( 'large' );
+
+		.ck-label {
+			margin-bottom: ck-spacing( 'small' );
+		}
+	}
+
+	&-form__actions {
+		clear: both;
+		padding-top: ck-spacing( 'large' );
+
+		.ck-button {
+			float: right;
+
+			& + .ck-button {
+				margin-right: ck-spacing( 'medium' );
+
+				& + .ck-button {
+					float: left;
+				}
+			}
+		}
+	}
+}

+ 4 - 0
packages/ckeditor5-link/theme/theme.scss

@@ -0,0 +1,4 @@
+// Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+// For licensing, see LICENSE.md or http://ckeditor.com/license
+
+@import 'components/linkballoonpanel';