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

Merge pull request #222 from ckeditor/t/186

Feature: Introduced configurable link decorators allowing customization of link attributes in the editor data. Closes #186.
Aleksander Nowodzinski 6 лет назад
Родитель
Сommit
90eb816915

+ 3 - 1
packages/ckeditor5-link/lang/contexts.json

@@ -4,5 +4,7 @@
 	"Link URL": "Label for the URL input in the Link URL editing balloon.",
 	"Edit link": "Button opening the Link URL editing balloon.",
 	"Open link in new tab": "Button opening the link in new browser tab.",
-	"This link has no URL": "Label explaining that a link has no URL set (the URL is empty)."
+	"This link has no URL": "Label explaining that a link has no URL set (the URL is empty).",
+	"Open link in a new tab": "The label of the switch button that controls whether the edited link will open in a new tab.",
+	"Downloadable": "The label of the switch button that controls whether the edited link refers to downloadable resource."
 }

+ 4 - 1
packages/ckeditor5-link/package.json

@@ -12,12 +12,15 @@
   "dependencies": {
     "@ckeditor/ckeditor5-core": "^12.1.1",
     "@ckeditor/ckeditor5-engine": "^13.1.1",
-    "@ckeditor/ckeditor5-ui": "^13.0.0"
+    "@ckeditor/ckeditor5-ui": "^13.0.0",
+    "lodash-es": "^4.17.10"
   },
   "devDependencies": {
+    "@ckeditor/ckeditor5-clipboard": "^11.0.2",
     "@ckeditor/ckeditor5-editor-classic": "^12.1.1",
     "@ckeditor/ckeditor5-enter": "^11.0.2",
     "@ckeditor/ckeditor5-paragraph": "^11.0.2",
+    "@ckeditor/ckeditor5-theme-lark": "^14.0.0",
     "@ckeditor/ckeditor5-typing": "^12.0.2",
     "@ckeditor/ckeditor5-undo": "^11.0.2",
     "@ckeditor/ckeditor5-utils": "^12.1.1",

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

@@ -34,3 +34,168 @@ export default class Link extends Plugin {
 		return 'Link';
 	}
 }
+
+/**
+ * The configuration of the {@link module:link/link~Link} feature.
+ *
+ * Read more in {@link module:link/link~LinkConfig}.
+ *
+ * @member {module:link/link~LinkConfig} module:core/editor/editorconfig~EditorConfig#link
+ */
+
+/**
+ * The configuration of the {@link module:link/link~Link link feature}.
+ *
+ *		ClassicEditor
+ *			.create( editorElement, {
+ * 				link:  ... // Link feature configuration.
+ *			} )
+ *			.then( ... )
+ *			.catch( ... );
+ *
+ * See {@link module:core/editor/editorconfig~EditorConfig all editor options}.
+ * @interface LinkConfig
+ */
+
+/**
+ * When set `true`, the `target="blank"` and `rel="noopener noreferrer"` attributes are automatically added to all external links
+ * in the editor. By external are meant all links in the editor content starting with `http`, `https`, or `//`.
+ *
+ * Internally, this option activates a predefined {@link module:link/link~LinkConfig#decorators automatic link decorator},
+ * which extends all external links with the `target` and `rel` attributes without additional configuration.
+ *
+ * **Note**: To control the `target` and `rel` attributes of specific links in the edited content, a dedicated
+ * {@link module:link/link~LinkDecoratorManualDefinition manual} decorator must be defined in the
+ * {@link module:link/link~LinkConfig#decorators `config.link.decorators`} array. In such scenario,
+ * the `config.link.addTargetToExternalLinks` option should remain `undefined` or `false` to not interfere with the manual decorator.
+ *
+ * **Note**: It is possible to add other {@link module:link/link~LinkDecoratorAutomaticDefinition automatic}
+ * or {@link module:link/link~LinkDecoratorManualDefinition manual} link decorators when this option is active.
+ *
+ * More information about decorators can be found in the {@link module:link/link~LinkConfig#decorators decorators configuration}
+ * reference.
+ *
+ * @default false
+ * @member {Boolean} module:link/link~LinkConfig#addTargetToExternalLinks
+ */
+
+/**
+ * Decorators provide an easy way to configure and manage additional link attributes in the editor content. There are
+ * two types of link decorators:
+ *
+ * * {@link module:link/link~LinkDecoratorAutomaticDefinition automatic} – they match links against pre–defined rules and
+ * manage their attributes based on the results,
+ * * {@link module:link/link~LinkDecoratorManualDefinition manual} – they allow users to control link attributes individually
+ *  using the editor UI.
+ *
+ * Link decorators are defined as an object with key-value pairs, where the key is a name provided for a given decorator and the
+ * value is the decorator definition.
+ *
+ * The name of the decorator also corresponds to the {@glink framework/guides/architecture/editing-engine#text-attributes text attribute}
+ * in the model. For instance, the `isExternal` decorator below is represented as a `linkIsExternal` attribute in the model.
+ *
+ *		const linkConfig = {
+ *			decorators: {
+ *				isExternal: {
+ *					mode: 'automatic',
+ *					callback: url => url.startsWith( 'http://' ),
+ *					attributes: {
+ *						target: '_blank',
+ *						rel: 'noopener noreferrer'
+ *					}
+ *				},
+ *				isDownloadable: {
+ *					mode: 'manual',
+ *					label: 'Downloadable',
+ *					attributes: {
+ *						download: 'file.png',
+ *					}
+ *				},
+ *				// ...
+ *			}
+ *		}
+ *
+ * To learn more about the configuration syntax, check out the {@link module:link/link~LinkDecoratorAutomaticDefinition automatic}
+ * and {@link module:link/link~LinkDecoratorManualDefinition manual} decorator option reference.
+ *
+ * **Warning:** Currently, link decorators work independently and no conflict resolution mechanism exists.
+ * For example, configuring the `target` attribute using both an automatic and a manual decorator at a time could end up with a
+ * quirky results. The same applies if multiple manual or automatic decorators were defined for the same attribute.
+ *
+ * **Note**: Since the `target` attribute management for external links is a common use case, there is a predefined automatic decorator
+ * dedicated for that purpose which can be enabled by turning a single option on. Check out the
+ * {@link module:link/link~LinkConfig#addTargetToExternalLinks `config.link.addTargetToExternalLinks`}
+ * configuration description to learn more.
+ *
+ * @member {Object.<String, module:link/link~LinkDecoratorDefinition>} module:link/link~LinkConfig#decorators
+ */
+
+/**
+ * Represents a link decorator definition {@link module:link/link~LinkDecoratorManualDefinition `'manual'`} or
+ * {@link module:link/link~LinkDecoratorAutomaticDefinition `'automatic'`}.
+ *
+ * @interface LinkDecoratorDefinition
+ */
+
+/**
+ * The kind of the decorator. `'manual'` for all manual decorators and `'automatic'` for all automatic decorators.
+ *
+ * @member {'manual'|'automatic'} module:link/link~LinkDecoratorDefinition#mode
+ */
+
+/**
+ * Describes an automatic link {@link module:link/link~LinkConfig#decorators decorator}. This kind of a decorator matches
+ * all links in the editor content against a function which decides whether the link should gain a pre–defined set of attributes
+ * or not.
+ *
+ * It takes an object with key-value pairs of attributes and a callback function which must return a boolean based on link's
+ * `href` (URL). When the callback returns `true`, attributes are applied to the link.
+ *
+ * For example, to add the `target="_blank"` attribute to all links in the editor starting with the `http://`,
+ * then configuration could look like this:
+ *
+ *		{
+ *			mode: 'automatic',
+ *			callback: url => url.startsWith( 'http://' ),
+ *			attributes: {
+ *				target: '_blank'
+ *			}
+ *		}
+ *
+ * **Note**: Since the `target` attribute management for external links is a common use case, there is a predefined automatic decorator
+ * dedicated for that purpose which can be enabled by turning a single option on. Check out the
+ * {@link module:link/link~LinkConfig#addTargetToExternalLinks `config.link.addTargetToExternalLinks`}
+ * configuration description to learn more.
+ *
+ * @typedef {Object} module:link/link~LinkDecoratorAutomaticDefinition
+ * @property {'automatic'} mode The kind of the decorator. `'automatic'` for all automatic decorators.
+ * @property {Function} callback Takes an `url` as a parameter and returns `true` if the `attributes` should be applied to the link.
+ * @property {Object} attributes Key-value pairs used as link attributes added to the output during the
+ * {@glink framework/guides/architecture/editing-engine#conversion downcasting}.
+ * Attributes should follow the {@link module:engine/view/elementdefinition~ElementDefinition} syntax.
+ */
+
+/**
+ * Describes a manual link {@link module:link/link~LinkConfig#decorators decorator}. This kind of a decorator is represented in
+ * the link feature's {@link module:link/linkui user interface} as a switch the user can use to control the presence
+ * of a pre–defined set of attributes.
+ *
+ * For instance, to allow users to manually control the presence of the `target="_blank"` and
+ * `rel="noopener noreferrer"` attributes on specific links, the decorator could look as follows:
+ *
+ *		{
+ *			mode: 'manual',
+ *			label: 'Open in a new tab',
+ *			attributes: {
+ *				target: '_blank',
+ *				rel: 'noopener noreferrer'
+ *			}
+ *		}
+ *
+ * @typedef {Object} module:link/link~LinkDecoratorManualDefinition
+ * @property {'manual'} mode The kind of the decorator. `'manual'` for all manual decorators.
+ * @property {String} label The label of the UI button the user can use to control the presence of link attributes.
+ * @property {Object} attributes Key-value pairs used as link attributes added to the output during the
+ * {@glink framework/guides/architecture/editing-engine#conversion downcasting}.
+ * Attributes should follow the {@link module:engine/view/elementdefinition~ElementDefinition} syntax.
+ */

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

@@ -10,6 +10,7 @@
 import Command from '@ckeditor/ckeditor5-core/src/command';
 import findLinkRange from './findlinkrange';
 import toMap from '@ckeditor/ckeditor5-utils/src/tomap';
+import Collection from '@ckeditor/ckeditor5-utils/src/collection';
 
 /**
  * The link command. It is used by the {@link module:link/link~Link link feature}.
@@ -25,6 +26,30 @@ export default class LinkCommand extends Command {
 	 * @member {Object|undefined} #value
 	 */
 
+	constructor( editor ) {
+		super( editor );
+
+		/**
+		 * A collection of {@link module:link/utils~ManualDecorator manual decorators}
+		 * corresponding to the {@link module:link/link~LinkConfig#decorators decorator configuration}.
+		 *
+		 * You can consider it a model with states of manual decorators added to currently selected link.
+		 *
+		 * @readonly
+		 * @type {module:utils/collection~Collection}
+		 */
+		this.manualDecorators = new Collection();
+	}
+
+	/**
+	 * Synchronize state of {@link #manualDecorators} with actually present elements in the model.
+	 */
+	restoreManualDecoratorStates() {
+		for ( const manualDecorator of this.manualDecorators ) {
+			manualDecorator.value = this._getDecoratorStateFromModel( manualDecorator.id );
+		}
+	}
+
 	/**
 	 * @inheritDoc
 	 */
@@ -33,6 +58,11 @@ export default class LinkCommand extends Command {
 		const doc = model.document;
 
 		this.value = doc.selection.getAttribute( 'linkHref' );
+
+		for ( const manualDecorator of this.manualDecorators ) {
+			manualDecorator.value = this._getDecoratorStateFromModel( manualDecorator.id );
+		}
+
 		this.isEnabled = model.schema.checkAttributeInSelection( doc.selection, 'linkHref' );
 	}
 
@@ -49,12 +79,69 @@ export default class LinkCommand extends Command {
 	 *
 	 * When the selection is collapsed and inside the text with the `linkHref` attribute, the attribute value will be updated.
 	 *
+	 * # Decorators and model attribute management
+	 *
+	 * There is an optional argument to this command, which applies or removes model
+	 * {@glink framework/guides/architecture/editing-engine#text-attributes text attributes} brought by
+	 * {@link module:link/utils~ManualDecorator manual link decorators}.
+	 *
+	 * Text attribute names in the model correspond to the entries in the {@link module:link/link~LinkConfig#decorators configuration}.
+	 * For every decorator configured, a model text attribute exists with the "link" prefix. For example, a `'linkMyDecorator'` attribute
+	 * corresponds to the `'myDecorator'` in the configuration.
+	 *
+	 * To learn more about link decorators, check out the {@link module:link/link~LinkConfig#decorators `config.link.decorators`}
+	 * documentation.
+	 *
+	 * Here is how to manage decorator attributes via the link command:
+	 *
+	 *		const linkCommand = editor.commands.get( 'link' );
+	 *
+	 *		// Adding a new decorator attribute.
+	 *		linkCommand.execute( 'http://example.com', {
+	 *			linkIsExternal: true
+	 *		} );
+	 *
+	 *		// Removing a decorator attribute from a selection.
+	 *		linkCommand.execute( 'http://example.com', {
+	 *			linkIsExternal: false
+	 *		} );
+	 *
+	 *		// Adding multiple decorator attributes at a time.
+	 *		linkCommand.execute( 'http://example.com', {
+	 *			linkIsExternal: true,
+	 *			linkIsDownloadable: true,
+	 *		} );
+	 *
+	 *		// Removing and adding decorator attributes at a time.
+	 *		linkCommand.execute( 'http://example.com', {
+	 *			linkIsExternal: false,
+	 *			linkFoo: true,
+	 *			linkIsDownloadable: false,
+	 *		} );
+	 *
+	 * **Note**: If decorator attribute name is not specified its state remains untouched.
+	 *
+	 * **Note**: {@link module:link/unlinkcommand~UnlinkCommand#execute `UnlinkCommand#execute()`} removes all
+	 * decorator attributes.
+	 *
 	 * @fires execute
 	 * @param {String} href Link destination.
+	 * @param {Object} [manualDecoratorIds={}] The information about manual decorator attributes to be applied or removed upon execution.
 	 */
-	execute( href ) {
+	execute( href, manualDecoratorIds = {} ) {
 		const model = this.editor.model;
 		const selection = model.document.selection;
+		// Stores information about manual decorators to turn them on/off when command is applied.
+		const truthyManualDecorators = [];
+		const falsyManualDecorators = [];
+
+		for ( const name in manualDecoratorIds ) {
+			if ( manualDecoratorIds[ name ] ) {
+				truthyManualDecorators.push( name );
+			} else {
+				falsyManualDecorators.push( name );
+			}
+		}
 
 		model.change( writer => {
 			// If selection is collapsed then update selected link or insert new one at the place of caret.
@@ -64,10 +151,18 @@ 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( selection.getFirstPosition(), selection.getAttribute( 'linkHref' ), model );
+					const linkRange = findLinkRange( position, selection.getAttribute( 'linkHref' ), model );
 
 					writer.setAttribute( 'linkHref', href, linkRange );
 
+					truthyManualDecorators.forEach( item => {
+						writer.setAttribute( item, true, linkRange );
+					} );
+
+					falsyManualDecorators.forEach( item => {
+						writer.removeAttribute( item, linkRange );
+					} );
+
 					// Create new range wrapping changed link.
 					writer.setSelection( linkRange );
 				}
@@ -79,6 +174,10 @@ export default class LinkCommand extends Command {
 
 					attributes.set( 'linkHref', href );
 
+					truthyManualDecorators.forEach( item => {
+						attributes.set( item, true );
+					} );
+
 					const node = writer.createText( href, attributes );
 
 					model.insertContent( node, position );
@@ -93,8 +192,28 @@ export default class LinkCommand extends Command {
 
 				for ( const range of ranges ) {
 					writer.setAttribute( 'linkHref', href, range );
+
+					truthyManualDecorators.forEach( item => {
+						writer.setAttribute( item, true, range );
+					} );
+
+					falsyManualDecorators.forEach( item => {
+						writer.removeAttribute( item, range );
+					} );
 				}
 			}
 		} );
 	}
+
+	/**
+	 * Method provides the information if a decorator with given name is present in currently processed selection.
+	 *
+	 * @private
+	 * @param {String} decoratorName name of a manual decorator used in the model
+	 * @returns {Boolean} The information if a given decorator is currently present in a selection
+	 */
+	_getDecoratorStateFromModel( decoratorName ) {
+		const doc = this.editor.model.document;
+		return doc.selection.getAttribute( decoratorName ) || false;
+	}
 }

+ 103 - 1
packages/ckeditor5-link/src/linkediting.js

@@ -10,12 +10,17 @@
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import LinkCommand from './linkcommand';
 import UnlinkCommand from './unlinkcommand';
-import { createLinkElement, ensureSafeUrl } from './utils';
+import { createLinkElement, ensureSafeUrl, getLocalizedDecorators, normalizeDecorators } from './utils';
+import AutomaticDecorators from './utils/automaticdecorators';
+import ManualDecorator from './utils/manualdecorator';
 import bindTwoStepCaretToAttribute from '@ckeditor/ckeditor5-engine/src/utils/bindtwostepcarettoattribute';
 import findLinkRange from './findlinkrange';
 import '../theme/link.css';
 
 const HIGHLIGHT_CLASS = 'ck-link_selected';
+const DECORATOR_AUTOMATIC = 'automatic';
+const DECORATOR_MANUAL = 'manual';
+const EXTERNAL_LINKS_REGEXP = /^(https?:)?\/\//;
 
 /**
  * The link engine feature.
@@ -26,6 +31,17 @@ const HIGHLIGHT_CLASS = 'ck-link_selected';
  * @extends module:core/plugin~Plugin
  */
 export default class LinkEditing extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	constructor( editor ) {
+		super( editor );
+
+		editor.config.define( 'link', {
+			addTargetToExternalLinks: false
+		} );
+	}
+
 	/**
 	 * @inheritDoc
 	 */
@@ -61,6 +77,11 @@ export default class LinkEditing extends Plugin {
 		editor.commands.add( 'link', new LinkCommand( editor ) );
 		editor.commands.add( 'unlink', new UnlinkCommand( editor ) );
 
+		const linkDecorators = getLocalizedDecorators( editor.t, normalizeDecorators( editor.config.get( 'link.decorators' ) ) );
+
+		this._enableAutomaticDecorators( linkDecorators.filter( item => item.mode === DECORATOR_AUTOMATIC ) );
+		this._enableManualDecorators( linkDecorators.filter( item => item.mode === DECORATOR_MANUAL ) );
+
 		// Enable two-step caret movement for `linkHref` attribute.
 		bindTwoStepCaretToAttribute( editor.editing.view, editor.model, this, 'linkHref' );
 
@@ -68,6 +89,87 @@ export default class LinkEditing extends Plugin {
 		this._setupLinkHighlight();
 	}
 
+	/**
+	 * Processes an array of configured {@link module:link/link~LinkDecoratorAutomaticDefinition automatic decorators}
+	 * and registers a {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher downcast dispatcher}
+	 * for each one of them. Downcast dispatchers are obtained using the
+	 * {@link module:link/utils~AutomaticDecorators#getDispatcher} method.
+	 *
+	 * **Note**: This method also activates the automatic external link decorator if enabled via
+	 * {@link module:link/link~LinkConfig#addTargetToExternalLinks `config.link.addTargetToExternalLinks`}.
+	 *
+	 * @private
+	 * @param {Array.<module:link/link~LinkDecoratorAutomaticDefinition>} automaticDecoratorDefinitions
+	 */
+	_enableAutomaticDecorators( automaticDecoratorDefinitions ) {
+		const editor = this.editor;
+		const automaticDecorators = new AutomaticDecorators();
+
+		// Adds default decorator for external links.
+		if ( editor.config.get( 'link.addTargetToExternalLinks' ) ) {
+			automaticDecorators.add( {
+				id: 'linkIsExternal',
+				mode: DECORATOR_AUTOMATIC,
+				callback: url => EXTERNAL_LINKS_REGEXP.test( url ),
+				attributes: {
+					target: '_blank',
+					rel: 'noopener noreferrer'
+				}
+			} );
+		}
+
+		automaticDecorators.add( automaticDecoratorDefinitions );
+
+		if ( automaticDecorators.length ) {
+			editor.conversion.for( 'downcast' ).add( automaticDecorators.getDispatcher() );
+		}
+	}
+
+	/**
+	 * Processes an array of configured {@link module:link/link~LinkDecoratorManualDefinition manual decorators}
+	 * and transforms them into {@link module:link/utils~ManualDecorator} instances and stores them in the
+	 * {@link module:link/linkcommand~LinkCommand#manualDecorators} collection (a model for manual decorators state).
+	 *
+	 * Also registers an {@link module:engine/conversion/downcasthelpers~DowncastHelpers#attributeToElement attributeToElement}
+	 * converter for each manual decorator and extends the {@link module:engine/model/schema~Schema model's schema}
+	 * with adequate model attributes.
+	 *
+	 * @private
+	 * @param {Array.<module:link/link~LinkDecoratorManualDefinition>} manualDecoratorDefinitions
+	 */
+	_enableManualDecorators( manualDecoratorDefinitions ) {
+		if ( !manualDecoratorDefinitions.length ) {
+			return;
+		}
+
+		const editor = this.editor;
+		const command = editor.commands.get( 'link' );
+		const manualDecorators = command.manualDecorators;
+
+		manualDecoratorDefinitions.forEach( decorator => {
+			editor.model.schema.extend( '$text', { allowAttributes: decorator.id } );
+
+			// Keeps reference to manual decorator to decode its name to attributes during downcast.
+			manualDecorators.add( new ManualDecorator( decorator ) );
+
+			editor.conversion.for( 'downcast' ).attributeToElement( {
+				model: decorator.id,
+				view: ( manualDecoratorName, writer ) => {
+					if ( manualDecoratorName ) {
+						const element = writer.createAttributeElement(
+							'a',
+							manualDecorators.get( decorator.id ).attributes,
+							{
+								priority: 5
+							}
+						);
+						writer.setCustomProperty( 'link', true, element );
+						return element;
+					}
+				} } );
+		} );
+	}
+
 	/**
 	 * 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.

+ 10 - 2
packages/ckeditor5-link/src/linkui.js

@@ -142,9 +142,10 @@ export default class LinkUI extends Plugin {
 	 */
 	_createFormView() {
 		const editor = this.editor;
-		const formView = new LinkFormView( editor.locale );
 		const linkCommand = editor.commands.get( 'link' );
 
+		const formView = new LinkFormView( editor.locale, linkCommand.manualDecorators );
+
 		formView.urlInputView.bind( 'value' ).to( linkCommand, 'value' );
 
 		// Form elements should be read-only when corresponding commands are disabled.
@@ -153,7 +154,7 @@ export default class LinkUI extends Plugin {
 
 		// Execute link command after clicking the "Save" button.
 		this.listenTo( formView, 'submit', () => {
-			editor.execute( 'link', formView.urlInputView.inputView.element.value );
+			editor.execute( 'link', formView.urlInputView.inputView.element.value, formView.getDecoratorSwitchesState() );
 			this._closeFormView();
 		} );
 
@@ -313,11 +314,18 @@ export default class LinkUI extends Plugin {
 	 * Closes form view. Decides whether the balloon should be hidden completely or if action view should be shown. This is decided upon
 	 * link command value (which has value if the document selection is in link).
 	 *
+	 * If there are defined {@link module:link/link~LinkConfig#decorators} in editor's config, then there are additionally
+	 * rest switch buttons state responsible for manual decorators handling.
+	 *
 	 * @private
 	 */
 	_closeFormView() {
 		const linkCommand = this.editor.commands.get( 'link' );
 
+		// Reset manual decorator states to represent current model state. This case is important to reset switch buttons,
+		// when user cancel editing form.
+		linkCommand.restoreManualDecoratorStates();
+
 		if ( linkCommand.value !== undefined ) {
 			this._removeFormView();
 		} else {

+ 134 - 11
packages/ckeditor5-link/src/ui/linkformview.js

@@ -11,6 +11,7 @@ import View from '@ckeditor/ckeditor5-ui/src/view';
 import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection';
 
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import SwitchButtonView from '@ckeditor/ckeditor5-ui/src/button/switchbuttonview';
 import LabeledInputView from '@ckeditor/ckeditor5-ui/src/labeledinput/labeledinputview';
 import InputTextView from '@ckeditor/ckeditor5-ui/src/inputtext/inputtextview';
 
@@ -32,9 +33,15 @@ import '../../theme/linkform.css';
  */
 export default class LinkFormView extends View {
 	/**
-	 * @inheritDoc
+	 * Creates an instance of the {@link module:link/ui/linkformview~LinkFormView} class.
+	 *
+	 * Also see {@link #render}.
+	 *
+	 * @param {module:utils/locale~Locale} [locale] The localization services instance.
+	 * @param {module:utils/collection~Collection} [manualDecorators] Reference to manual decorators in
+	 * {@link module:link/linkcommand~LinkCommand#manualDecorators}.
 	 */
-	constructor( locale ) {
+	constructor( locale, manualDecorators = [] ) {
 		super( locale );
 
 		const t = locale.t;
@@ -77,6 +84,25 @@ export default class LinkFormView extends View {
 		 */
 		this.cancelButtonView = this._createButton( t( 'Cancel' ), cancelIcon, 'ck-button-cancel', 'cancel' );
 
+		/**
+		 * A collection of {@link module:ui/button/switchbuttonview~SwitchButtonView},
+		 * which corresponds to {@link module:link/linkcommand~LinkCommand#manualDecorators manual decorators}
+		 * configured in the editor.
+		 *
+		 * @private
+		 * @readonly
+		 * @type {module:ui/viewcollection~ViewCollection}
+		 */
+		this._manualDecoratorSwitches = this._createManualDecoratorSwitches( manualDecorators );
+
+		/**
+		 * Collection of child views in the form.
+		 *
+		 * @readonly
+		 * @type {module:ui/viewcollection~ViewCollection}
+		 */
+		this.children = this._createFormChildren( manualDecorators );
+
 		/**
 		 * A collection of views which can be focused in the form.
 		 *
@@ -106,27 +132,41 @@ export default class LinkFormView extends View {
 			}
 		} );
 
+		const classList = [ 'ck', 'ck-link-form' ];
+
+		if ( manualDecorators.length ) {
+			classList.push( 'ck-link-form_layout-vertical' );
+		}
+
 		this.setTemplate( {
 			tag: 'form',
 
 			attributes: {
-				class: [
-					'ck',
-					'ck-link-form',
-				],
+				class: classList,
 
 				// https://github.com/ckeditor/ckeditor5-link/issues/90
 				tabindex: '-1'
 			},
 
-			children: [
-				this.urlInputView,
-				this.saveButtonView,
-				this.cancelButtonView
-			]
+			children: this.children
 		} );
 	}
 
+	/**
+	 * Obtains the state of the {@link module:ui/button/switchbuttonview~SwitchButtonView switch buttons} representing
+	 * {@link module:link/linkcommand~LinkCommand#manualDecorators manual link decorators}
+	 * in the {@link module:link/ui/linkformview~LinkFormView}.
+	 *
+	 * @returns {Object.<String,Boolean>} key-value pairs, where the key is the name of the decorator and the value is
+	 * its state.
+	 */
+	getDecoratorSwitchesState() {
+		return Array.from( this._manualDecoratorSwitches ).reduce( ( accumulator, switchButton ) => {
+			accumulator[ switchButton.name ] = switchButton.isOn;
+			return accumulator;
+		}, {} );
+	}
+
 	/**
 	 * @inheritDoc
 	 */
@@ -139,6 +179,7 @@ export default class LinkFormView extends View {
 
 		const childViews = [
 			this.urlInputView,
+			...this._manualDecoratorSwitches,
 			this.saveButtonView,
 			this.cancelButtonView
 		];
@@ -210,6 +251,88 @@ export default class LinkFormView extends View {
 
 		return button;
 	}
+
+	/**
+	 * Populates {@link module:ui/viewcollection~ViewCollection} of {@link module:ui/button/switchbuttonview~SwitchButtonView}
+	 * made based on {@link module:link/linkcommand~LinkCommand#manualDecorators}
+	 *
+	 * @private
+	 * @param {module:link/linkcommand~LinkCommand#manualDecorators} manualDecorators reference to
+	 * collection of manual decorators stored in link's command.
+	 * @returns {module:ui/viewcollection~ViewCollection} of Switch Buttons.
+	 */
+	_createManualDecoratorSwitches( manualDecorators ) {
+		const switches = this.createCollection();
+
+		for ( const manualDecorator of manualDecorators ) {
+			const switchButton = new SwitchButtonView( this.locale );
+
+			switchButton.set( {
+				name: manualDecorator.id,
+				label: manualDecorator.label,
+				withText: true
+			} );
+
+			switchButton.bind( 'isOn' ).to( manualDecorator, 'value' );
+
+			switchButton.on( 'execute', () => {
+				manualDecorator.set( 'value', !switchButton.isOn );
+			} );
+
+			switches.add( switchButton );
+		}
+
+		return switches;
+	}
+
+	/**
+	 * Populates the {@link #children} collection of the form.
+	 *
+	 * If {@link module:link/linkcommand~LinkCommand#manualDecorators manual decorators} are configured in the editor, creates an
+	 * additional `View` wrapping all {@link #_manualDecoratorSwitches} switch buttons corresponding
+	 * to those decorators.
+	 *
+	 * @private
+	 * @param {module:link/linkcommand~LinkCommand#manualDecorators} manualDecorators reference to
+	 * collection of manual decorators stored in link's command.
+	 * @returns {module:ui/viewcollection~ViewCollection} children of LinkFormView.
+	 */
+	_createFormChildren( manualDecorators ) {
+		const children = this.createCollection();
+
+		children.add( this.urlInputView );
+
+		if ( manualDecorators.length ) {
+			const additionalButtonsView = new View();
+
+			additionalButtonsView.setTemplate( {
+				tag: 'ul',
+				children: this._manualDecoratorSwitches.map( switchButton => ( {
+					tag: 'li',
+					children: [ switchButton ],
+					attributes: {
+						class: [
+							'ck',
+							'ck-list__item'
+						]
+					}
+				} ) ),
+				attributes: {
+					class: [
+						'ck',
+						'ck-reset',
+						'ck-list'
+					]
+				}
+			} );
+			children.add( additionalButtonsView );
+		}
+
+		children.add( this.saveButtonView );
+		children.add( this.cancelButtonView );
+
+		return children;
+	}
 }
 
 /**

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

@@ -29,11 +29,18 @@ export default class UnlinkCommand extends Command {
 	 * When the selection is collapsed, removes the `linkHref` attribute from each node with the same `linkHref` attribute value.
 	 * When the selection is non-collapsed, removes the `linkHref` attribute from each node in selected ranges.
 	 *
+	 * # Decorators
+	 *
+	 * If {@link module:link/link~LinkConfig#decorators `config.link.decorators`} is specified,
+	 * all configured decorators are removed together with the `linkHref` attribute.
+	 *
 	 * @fires execute
 	 */
 	execute() {
+		const editor = this.editor;
 		const model = this.editor.model;
 		const selection = model.document.selection;
+		const linkCommand = editor.commands.get( 'link' );
 
 		model.change( writer => {
 			// Get ranges to unlink.
@@ -43,6 +50,12 @@ export default class UnlinkCommand extends Command {
 			// Remove `linkHref` attribute from specified ranges.
 			for ( const range of rangesToUnlink ) {
 				writer.removeAttribute( 'linkHref', range );
+				// If there are registered custom attributes, then remove them during unlink.
+				if ( linkCommand ) {
+					for ( const manualDecorator of linkCommand.manualDecorators ) {
+						writer.removeAttribute( manualDecorator.id, range );
+					}
+				}
 			}
 		} );
 	}

+ 55 - 0
packages/ckeditor5-link/src/utils.js

@@ -7,6 +7,8 @@
  * @module link/utils
  */
 
+import { upperFirst } from 'lodash-es';
+
 const ATTRIBUTE_WHITESPACES = /[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205f\u3000]/g; // eslint-disable-line no-control-regex
 const SAFE_URL = /^(?:(?:https?|ftps?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.:-]|$))/i;
 
@@ -59,3 +61,56 @@ function isSafeUrl( url ) {
 
 	return normalizedUrl.match( SAFE_URL );
 }
+
+/**
+ * Returns {@link module:link/link~LinkConfig#decorators `config.link.decorators`} configuration processed
+ * to respect the locale of the editor, i.e. to display {@link module:link/link~LinkDecoratorManualDefinition label}
+ * in the correct language.
+ *
+ * **Note**: Only the few most commonly used labels are translated automatically. Other labels should be manually
+ * translated in the {@link module:link/link~LinkConfig#decorators `config.link.decorators`} configuration.
+ *
+ * @param {module:utils/locale~Locale#t} t shorthand for {@link module:utils/locale~Locale#t Locale#t}
+ * @param {Array.<module:link/link~LinkDecoratorDefinition>} decorators reference
+ * where labels' values should be localized.
+ * @returns {Array.<module:link/link~LinkDecoratorDefinition>}
+ */
+export function getLocalizedDecorators( t, decorators ) {
+	const localizedDecoratorsLabels = {
+		'Open in a new tab': t( 'Open in a new tab' ),
+		'Downloadable': t( 'Downloadable' )
+	};
+
+	decorators.forEach( decorator => {
+		if ( decorator.label && localizedDecoratorsLabels[ decorator.label ] ) {
+			decorator.label = localizedDecoratorsLabels[ decorator.label ];
+		}
+		return decorator;
+	} );
+
+	return decorators;
+}
+
+/**
+ * Converts an object with defined decorators to a normalized array of decorators. There is also added `id` key for each decorator,
+ * which is used as attribute's name in the model.
+ *
+ * @param {Object.<String, module:link/link~LinkDecoratorDefinition>} decorators
+ * @returns {Array.<module:link/link~LinkDecoratorDefinition>}
+ */
+export function normalizeDecorators( decorators ) {
+	const retArray = [];
+
+	if ( decorators ) {
+		for ( const [ key, value ] of Object.entries( decorators ) ) {
+			const decorator = Object.assign(
+				{},
+				value,
+				{ id: `link${ upperFirst( key ) }` }
+			);
+			retArray.push( decorator );
+		}
+	}
+
+	return retArray;
+}

+ 88 - 0
packages/ckeditor5-link/src/utils/automaticdecorators.js

@@ -0,0 +1,88 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module link/utils
+ */
+
+/**
+ * Helper class which tight together all {@link module:link/link~LinkDecoratorAutomaticDefinition} and provides
+ * a {@link module:engine/conversion/downcasthelpers~DowncastHelpers#attributeToElement downcast dispatcher} for them.
+ */
+export default class AutomaticDecorators {
+	constructor() {
+		/**
+		 * Stores definition of {@link module:link/link~LinkDecoratorAutomaticDefinition automatic decorators}.
+		 * Those data are used as source for a downcast dispatcher to make proper conversion to output data.
+		 *
+		 * @private
+		 * @type {Set}
+		 */
+		this._definitions = new Set();
+	}
+
+	/**
+	 * Gives information how many decorators is stored in {@link module:link/utils~AutomaticDecorators} instance.
+	 *
+	 * @readonly
+	 * @protected
+	 * @type {Number}
+	 */
+	get length() {
+		return this._definitions.size;
+	}
+
+	/**
+	 * Add automatic decorator objects or array with them to be used during downcasting.
+	 *
+	 * @param {module:link/link~LinkDecoratorAutomaticDefinition|Array.<module:link/link~LinkDecoratorAutomaticDefinition>} item
+	 * configuration object of automatic rules for decorating links. It might be also array of such objects.
+	 */
+	add( item ) {
+		if ( Array.isArray( item ) ) {
+			item.forEach( item => this._definitions.add( item ) );
+		} else {
+			this._definitions.add( item );
+		}
+	}
+
+	/**
+	 * Provides the conversion helper used in an {@link module:engine/conversion/downcasthelpers~DowncastHelpers#add} method.
+	 *
+	 * @returns {Function} dispatcher function used as conversion helper
+	 * in {@link module:engine/conversion/downcasthelpers~DowncastHelpers#add}
+	 */
+	getDispatcher() {
+		return dispatcher => {
+			dispatcher.on( 'attribute:linkHref', ( evt, data, conversionApi ) => {
+				// There is only test as this behavior decorates links and
+				// it is run before dispatcher which actually consumes this node.
+				// This allows on writing own dispatcher with highest priority,
+				// which blocks both native converter and this additional decoration.
+				if ( !conversionApi.consumable.test( data.item, 'attribute:linkHref' ) ) {
+					return;
+				}
+				const viewWriter = conversionApi.writer;
+				const viewSelection = viewWriter.document.selection;
+
+				for ( const item of this._definitions ) {
+					const viewElement = viewWriter.createAttributeElement( 'a', item.attributes, {
+						priority: 5
+					} );
+					viewWriter.setCustomProperty( 'link', true, viewElement );
+					if ( item.callback( data.attributeNewValue ) ) {
+						if ( data.item.is( 'selection' ) ) {
+							viewWriter.wrap( viewSelection.getFirstRange(), viewElement );
+						} else {
+							viewWriter.wrap( conversionApi.mapper.toViewRange( data.range ), viewElement );
+						}
+					} else {
+						viewWriter.unwrap( conversionApi.mapper.toViewRange( data.range ), viewElement );
+					}
+				}
+			}, { priority: 'high' } );
+		};
+	}
+}

+ 64 - 0
packages/ckeditor5-link/src/utils/manualdecorator.js

@@ -0,0 +1,64 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module link/utils
+ */
+
+import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
+import mix from '@ckeditor/ckeditor5-utils/src/mix';
+
+/**
+ * Helper class which stores manual decorators with observable {@link module:link/utils~ManualDecorator#value}
+ * to support integration with the UI state. An instance of this class is a model with state of single manual decorators.
+ * These decorators are kept as collections in {@link module:link/linkcommand~LinkCommand#manualDecorators}.
+ *
+ * @mixes module:utils/observablemixin~ObservableMixin
+ */
+export default class ManualDecorator {
+	/**
+	 * Creates new instance of {@link module:link/utils~ManualDecorator}.
+	 *
+	 * @param {Object} config
+	 * @param {String} config.id name of attribute used in model, which represents given manual decorator.
+	 * For example `'linkIsExternal'`.
+	 * @param {String} config.label The label used in user interface to toggle manual decorator.
+	 * @param {Object} config.attributes Set of attributes added to output data, when decorator is active for specific link.
+	 * Attributes should keep format of attributes defined in {@link module:engine/view/elementdefinition~ElementDefinition}.
+	 */
+	constructor( { id, label, attributes } ) {
+		/**
+		 * Id of manual decorator, which is a name of attribute in model, for example 'linkManualDecorator0'.
+		 *
+		 * @type {String}
+		 */
+		this.id = id;
+
+		/**
+		 * Value of current manual decorator. It reflects its state from UI.
+		 *
+		 * @observable
+		 * @member {Boolean} module:link/utils~ManualDecorator#value
+		 */
+		this.set( 'value' );
+
+		/**
+		 * The label used in user interface to toggle manual decorator.
+		 *
+		 * @type {String}
+		 */
+		this.label = label;
+
+		/**
+		 * Set of attributes added to downcasted data, when decorator is activated for specific link.
+		 * Attributes should be added in a form of attributes defined in {@link module:engine/view/elementdefinition~ElementDefinition}.
+		 *
+		 * @type {Object}
+		 */
+		this.attributes = attributes;
+	}
+}
+
+mix( ManualDecorator, ObservableMixin );

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

@@ -5,6 +5,7 @@
 
 import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
 import LinkCommand from '../src/linkcommand';
+import ManualDecorator from '../src/utils/manualdecorator';
 import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
 describe( 'LinkCommand', () => {
@@ -259,4 +260,139 @@ describe( 'LinkCommand', () => {
 			} );
 		} );
 	} );
+
+	describe( 'manual decorators', () => {
+		beforeEach( () => {
+			editor.destroy();
+			return ModelTestEditor.create()
+				.then( newEditor => {
+					editor = newEditor;
+					model = editor.model;
+					command = new LinkCommand( editor );
+
+					command.manualDecorators.add( new ManualDecorator( {
+						id: 'linkIsFoo',
+						label: 'Foo',
+						attributes: {
+							class: 'Foo'
+						}
+					} ) );
+					command.manualDecorators.add( new ManualDecorator( {
+						id: 'linkIsBar',
+						label: 'Bar',
+						attributes: {
+							target: '_blank'
+						}
+					} ) );
+
+					model.schema.extend( '$text', {
+						allowIn: '$root',
+						allowAttributes: [ 'linkHref', 'linkIsFoo', 'linkIsBar' ]
+					} );
+
+					model.schema.register( 'p', { inheritAllFrom: '$block' } );
+				} );
+		} );
+
+		afterEach( () => {
+			return editor.destroy();
+		} );
+
+		describe( 'collapsed selection', () => {
+			it( 'should insert additional attributes to link when it is created', () => {
+				setData( model, 'foo[]bar' );
+
+				command.execute( 'url', { linkIsFoo: true, linkIsBar: true } );
+
+				expect( getData( model ) ).to
+					.equal( 'foo[<$text linkHref="url" linkIsBar="true" linkIsFoo="true">url</$text>]bar' );
+			} );
+
+			it( 'should add additional attributes to link when link is modified', () => {
+				setData( model, 'f<$text linkHref="url">o[]oba</$text>r' );
+
+				command.execute( 'url', { linkIsFoo: true, linkIsBar: true } );
+
+				expect( getData( model ) ).to
+					.equal( 'f[<$text linkHref="url" linkIsBar="true" linkIsFoo="true">ooba</$text>]r' );
+			} );
+
+			it( 'should remove additional attributes to link if those are falsy', () => {
+				setData( model, 'foo<$text linkHref="url" linkIsBar="true" linkIsFoo="true">u[]rl</$text>bar' );
+
+				command.execute( 'url', { linkIsFoo: false, linkIsBar: false } );
+
+				expect( getData( model ) ).to.equal( 'foo[<$text linkHref="url">url</$text>]bar' );
+			} );
+		} );
+
+		describe( 'range selection', () => {
+			it( 'should insert additional attributes to link when it is created', () => {
+				setData( model, 'f[ooba]r' );
+
+				command.execute( 'url', { linkIsFoo: true, linkIsBar: true } );
+
+				expect( getData( model ) ).to
+					.equal( 'f[<$text linkHref="url" linkIsBar="true" linkIsFoo="true">ooba</$text>]r' );
+			} );
+
+			it( 'should add additional attributes to link when link is modified', () => {
+				setData( model, 'f[<$text linkHref="foo">ooba</$text>]r' );
+
+				command.execute( 'url', { linkIsFoo: true, linkIsBar: true } );
+
+				expect( getData( model ) ).to
+					.equal( 'f[<$text linkHref="url" linkIsBar="true" linkIsFoo="true">ooba</$text>]r' );
+			} );
+
+			it( 'should remove additional attributes to link if those are falsy', () => {
+				setData( model, 'foo[<$text linkHref="url" linkIsBar="true" linkIsFoo="true">url</$text>]bar' );
+
+				command.execute( 'url', { linkIsFoo: false, linkIsBar: false } );
+
+				expect( getData( model ) ).to.equal( 'foo[<$text linkHref="url">url</$text>]bar' );
+			} );
+		} );
+
+		describe( 'restoreManualDecoratorStates()', () => {
+			it( 'synchronize values with current model state', () => {
+				setData( model, 'foo<$text linkHref="url" linkIsBar="true" linkIsFoo="true">u[]rl</$text>bar' );
+
+				expect( decoratorStates( command.manualDecorators ) ).to.deep.equal( {
+					linkIsFoo: true,
+					linkIsBar: true
+				} );
+
+				command.manualDecorators.first.value = false;
+
+				expect( decoratorStates( command.manualDecorators ) ).to.deep.equal( {
+					linkIsFoo: false,
+					linkIsBar: true,
+				} );
+
+				command.restoreManualDecoratorStates();
+
+				expect( decoratorStates( command.manualDecorators ) ).to.deep.equal( {
+					linkIsFoo: true,
+					linkIsBar: true,
+				} );
+			} );
+		} );
+
+		describe( '_getDecoratorStateFromModel', () => {
+			it( 'obtain current values from the model', () => {
+				setData( model, 'foo[<$text linkHref="url" linkIsBar="true">url</$text>]bar' );
+
+				expect( command._getDecoratorStateFromModel( 'linkIsFoo' ) ).to.be.false;
+				expect( command._getDecoratorStateFromModel( 'linkIsBar' ) ).to.be.true;
+			} );
+		} );
+	} );
 } );
+
+function decoratorStates( manualDecorators ) {
+	return Array.from( manualDecorators ).reduce( ( accumulator, currentValue ) => {
+		accumulator[ currentValue.id ] = currentValue.value;
+		return accumulator;
+	}, {} );
+}

+ 237 - 0
packages/ckeditor5-link/tests/linkediting.js

@@ -10,6 +10,7 @@ import UnlinkCommand from '../src/unlinkcommand';
 import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import Enter from '@ckeditor/ckeditor5-enter/src/enter';
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 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 { isLinkElement } from '../src/utils';
@@ -32,6 +33,10 @@ describe( 'LinkEditing', () => {
 			} );
 	} );
 
+	afterEach( () => {
+		editor.destroy();
+	} );
+
 	it( 'should be loaded', () => {
 		expect( editor.plugins.get( LinkEditing ) ).to.be.instanceOf( LinkEditing );
 	} );
@@ -351,4 +356,236 @@ describe( 'LinkEditing', () => {
 			} );
 		} );
 	} );
+
+	describe( 'link attributes decorator', () => {
+		describe( 'default behavior', () => {
+			const testLinks = [
+				{
+					external: true,
+					url: 'http://example.com'
+				}, {
+					external: true,
+					url: 'https://cksource.com'
+				}, {
+					external: false,
+					url: 'ftp://server.io'
+				}, {
+					external: true,
+					url: '//schemaless.org'
+				}, {
+					external: false,
+					url: 'www.ckeditor.com'
+				}, {
+					external: false,
+					url: '/relative/url.html'
+				}, {
+					external: false,
+					url: 'another/relative/url.html'
+				}, {
+					external: false,
+					url: '#anchor'
+				}, {
+					external: false,
+					url: 'mailto:some@user.org'
+				}, {
+					external: false,
+					url: 'tel:123456789'
+				}
+			];
+			it( 'link.addTargetToExternalLinks is predefined as false value', () => {
+				expect( editor.config.get( 'link.addTargetToExternalLinks' ) ).to.be.false;
+			} );
+
+			describe( 'for link.addTargetToExternalLinks = false', () => {
+				let editor, model;
+				beforeEach( () => {
+					return VirtualTestEditor
+						.create( {
+							plugins: [ Paragraph, LinkEditing, Enter ],
+							link: {
+								addTargetToExternalLinks: true
+							}
+						} )
+						.then( newEditor => {
+							editor = newEditor;
+							model = editor.model;
+							view = editor.editing.view;
+						} );
+				} );
+
+				afterEach( () => {
+					editor.destroy();
+				} );
+
+				it( 'link.addTargetToExternalLinks is set as true value', () => {
+					expect( editor.config.get( 'link.addTargetToExternalLinks' ) ).to.be.true;
+				} );
+
+				testLinks.forEach( link => {
+					it( `link: ${ link.url } should be treat as ${ link.external ? 'external' : 'non-external' } link`, () => {
+						editor.setData( `<p><a href="${ link.url }">foo</a>bar</p>` );
+
+						expect( getModelData( model, { withoutSelection: true } ) )
+							.to.equal( `<paragraph><$text linkHref="${ link.url }">foo</$text>bar</paragraph>` );
+
+						if ( link.external ) {
+							expect( editor.getData() )
+								.to.equal( `<p><a target="_blank" rel="noopener noreferrer" href="${ link.url }">foo</a>bar</p>` );
+						} else {
+							expect( editor.getData() ).to.equal( `<p><a href="${ link.url }">foo</a>bar</p>` );
+						}
+					} );
+				} );
+			} );
+			testLinks.forEach( link => {
+				it( `link: ${ link.url } should not get 'target' and 'rel' attributes`, () => {
+					editor.setData( `<p><a href="${ link.url }">foo</a>bar</p>` );
+
+					expect( getModelData( model, { withoutSelection: true } ) )
+						.to.equal( `<paragraph><$text linkHref="${ link.url }">foo</$text>bar</paragraph>` );
+
+					expect( editor.getData() ).to.equal( `<p><a href="${ link.url }">foo</a>bar</p>` );
+				} );
+			} );
+		} );
+
+		describe( 'custom config', () => {
+			describe( 'mode: automatic', () => {
+				const testLinks = [
+					{
+						url: 'relative/url.html',
+						attributes: {}
+					}, {
+						url: 'http://exmaple.com',
+						attributes: {
+							target: '_blank'
+						}
+					}, {
+						url: 'https://example.com/download/link.pdf',
+						attributes: {
+							target: '_blank',
+							download: 'download'
+						}
+					}, {
+						url: 'mailto:some@person.io',
+						attributes: {
+							class: 'mail-url'
+						}
+					}
+				];
+
+				beforeEach( () => {
+					editor.destroy();
+					return VirtualTestEditor
+						.create( {
+							plugins: [ Paragraph, LinkEditing, Enter ],
+							link: {
+								addTargetToExternalLinks: false,
+								decorators: {
+									isExternal: {
+										mode: 'automatic',
+										callback: url => url.startsWith( 'http' ),
+										attributes: {
+											target: '_blank'
+										}
+									},
+									isDownloadable: {
+										mode: 'automatic',
+										callback: url => url.includes( 'download' ),
+										attributes: {
+											download: 'download'
+										}
+									},
+									isMail: {
+										mode: 'automatic',
+										callback: url => url.startsWith( 'mailto:' ),
+										attributes: {
+											class: 'mail-url'
+										}
+									}
+								}
+							}
+						} )
+						.then( newEditor => {
+							editor = newEditor;
+							model = editor.model;
+							view = editor.editing.view;
+						} );
+				} );
+
+				testLinks.forEach( link => {
+					it( `Link: ${ link.url } should get attributes: ${ JSON.stringify( link.attributes ) }`, () => {
+						const ORDER = [ 'target', 'download', 'class' ];
+						const attr = Object.entries( link.attributes ).sort( ( a, b ) => {
+							const aIndex = ORDER.indexOf( a[ 0 ] );
+							const bIndex = ORDER.indexOf( b[ 0 ] );
+							return aIndex - bIndex;
+						} );
+						const reducedAttr = attr.reduce( ( acc, cur ) => {
+							return acc + `${ cur[ 0 ] }="${ cur[ 1 ] }" `;
+						}, '' );
+
+						editor.setData( `<p><a href="${ link.url }">foo</a>bar</p>` );
+
+						expect( getModelData( model, { withoutSelection: true } ) )
+							.to.equal( `<paragraph><$text linkHref="${ link.url }">foo</$text>bar</paragraph>` );
+
+						// Order of attributes is important, that's why this is assert is construct in such way.
+						expect( editor.getData() ).to.equal( `<p><a ${ reducedAttr }href="${ link.url }">foo</a>bar</p>` );
+					} );
+				} );
+			} );
+		} );
+
+		describe( 'custom linkHref converter', () => {
+			beforeEach( () => {
+				class CustomLinks extends Plugin {
+					init() {
+						const editor = this.editor;
+
+						editor.conversion.for( 'downcast' ).add( dispatcher => {
+							dispatcher.on( 'attribute:linkHref', ( evt, data, conversionApi ) => {
+								conversionApi.consumable.consume( data.item, 'attribute:linkHref' );
+
+								// Very simplified downcast just for test assertion.
+								const viewWriter = conversionApi.writer;
+								const linkElement = viewWriter.createAttributeElement(
+									'a',
+									{
+										href: data.attributeNewValue
+									}, {
+										priority: 5
+									}
+								);
+								viewWriter.setCustomProperty( 'link', true, linkElement );
+								viewWriter.wrap( conversionApi.mapper.toViewRange( data.range ), linkElement );
+							}, { priority: 'highest' } );
+						} );
+					}
+				}
+				editor.destroy();
+				return VirtualTestEditor
+					.create( {
+						plugins: [ Paragraph, LinkEditing, Enter, CustomLinks ],
+						link: {
+							addTargetToExternalLinks: true,
+						}
+					} )
+					.then( newEditor => {
+						editor = newEditor;
+						model = editor.model;
+						view = editor.editing.view;
+					} );
+			} );
+
+			it( 'has possibility to override default one', () => {
+				editor.setData( '<p><a href="http://example.com">foo</a>bar</p>' );
+
+				expect( getModelData( model, { withoutSelection: true } ) )
+					.to.equal( '<paragraph><$text linkHref="http://example.com">foo</$text>bar</paragraph>' );
+
+				expect( editor.getData() ).to.equal( '<p><a href="http://example.com">foo</a>bar</p>' );
+			} );
+		} );
+	} );
 } );

+ 82 - 1
packages/ckeditor5-link/tests/linkui.js

@@ -885,7 +885,7 @@ describe( 'LinkUI', () => {
 				formView.fire( 'submit' );
 
 				expect( executeSpy.calledOnce ).to.be.true;
-				expect( executeSpy.calledWithExactly( 'link', 'http://cksource.com' ) ).to.be.true;
+				expect( executeSpy.calledWithExactly( 'link', 'http://cksource.com', {} ) ).to.be.true;
 			} );
 
 			it( 'should hide and reveal the #actionsView on formView#submit event', () => {
@@ -958,6 +958,87 @@ describe( 'LinkUI', () => {
 
 				expect( focusSpy.calledBefore( removeSpy ) ).to.equal( true );
 			} );
+
+			describe( 'support manual decorators', () => {
+				let editorElement, editor, model, formView, linkUIFeature;
+
+				beforeEach( () => {
+					editorElement = document.createElement( 'div' );
+					document.body.appendChild( editorElement );
+					return ClassicTestEditor
+						.create( editorElement, {
+							plugins: [ LinkEditing, LinkUI, Paragraph ],
+							link: {
+								decorators: {
+									isFoo: {
+										mode: 'manual',
+										label: 'Foo',
+										attributes: {
+											foo: 'bar'
+										}
+									}
+								}
+							}
+						} )
+						.then( newEditor => {
+							editor = newEditor;
+							model = editor.model;
+
+							model.schema.extend( '$text', {
+								allowIn: '$root',
+								allowAttributes: 'linkHref'
+							} );
+
+							linkUIFeature = editor.plugins.get( LinkUI );
+
+							const balloon = editor.plugins.get( ContextualBalloon );
+
+							formView = linkUIFeature.formView;
+
+							// There is no point to execute BalloonPanelView attachTo and pin methods so lets override it.
+							testUtils.sinon.stub( balloon.view, 'attachTo' ).returns( {} );
+							testUtils.sinon.stub( balloon.view, 'pin' ).returns( {} );
+
+							formView.render();
+						} );
+				} );
+
+				afterEach( () => {
+					editorElement.remove();
+				} );
+
+				it( 'should gather information about manual decorators', () => {
+					const executeSpy = testUtils.sinon.spy( editor, 'execute' );
+
+					setModelData( model, 'f[<$text linkHref="url" linkIsFoo="true">ooba</$text>]r' );
+					expect( formView.urlInputView.inputView.element.value ).to.equal( 'url' );
+					expect( formView.getDecoratorSwitchesState() ).to.deep.equal( { linkIsFoo: true } );
+
+					formView.fire( 'submit' );
+
+					expect( executeSpy.calledOnce ).to.be.true;
+					expect( executeSpy.calledWithExactly( 'link', 'url', { linkIsFoo: true } ) ).to.be.true;
+				} );
+
+				it( 'should reset switch state when form view is closed', () => {
+					setModelData( model, 'f[<$text linkHref="url" linkIsFoo="true">ooba</$text>]r' );
+
+					const manualDecorators = editor.commands.get( 'link' ).manualDecorators;
+					const firstDecoratorModel = manualDecorators.first;
+					const firstDecoratorSwitch = formView._manualDecoratorSwitches.first;
+
+					expect( firstDecoratorModel.value, 'Initial value should be read from the model (true)' ).to.be.true;
+					expect( firstDecoratorSwitch.isOn, 'Initial value should be read from the model (true)' ).to.be.true;
+
+					firstDecoratorSwitch.fire( 'execute' );
+					expect( firstDecoratorModel.value, 'Pressing button toggles value' ).to.be.false;
+					expect( firstDecoratorSwitch.isOn, 'Pressing button toggles value' ).to.be.false;
+
+					linkUIFeature._closeFormView();
+					expect( firstDecoratorModel.value, 'Close form view without submit resets value to initial state' ).to.be.true;
+					expect( firstDecoratorSwitch.isOn, 'Close form view without submit resets value to initial state' ).to.be.true;
+				} );
+			} );
 		} );
 	} );
 } );

+ 19 - 0
packages/ckeditor5-link/tests/manual/linkdecorator.html

@@ -0,0 +1,19 @@
+<h2>Manual decorators</h2>
+<div id="editor">
+	<p>This is <a href="http://ckeditor.com">CKEditor5</a> from <a href="http://cksource.com">CKSource</a>.</p>
+	<p>This is <a href="//ckeditor.com">CKEditor5</a> as schemaless url.</p>
+	<p>This is <a href="#anchor">anchor</a> on this page.</p>
+	<p>This is some random <a href="ftp://127.0.0.1">ftp address</a>.</p>
+	<p>This is some <a href="mailto:random@user.org">mail</a>.</p>
+	<p>This is some <a href="tel:123456789">phone number</a>.</p>
+</div>
+
+<h2>Automatic decorators</h2>
+<div id="editor2">
+	<p>This is <a href="http://ckeditor.com">CKEditor5</a> from <a href="http://cksource.com">CKSource</a>.</p>
+	<p>This is <a href="//ckeditor.com">CKEditor5</a> as schemaless url.</p>
+	<p>This is <a href="#anchor">anchor</a> on this page.</p>
+	<p>This is some random <a href="ftp://127.0.0.1">ftp address</a>.</p>
+	<p>This is some <a href="mailto:random@user.org">mail</a>.</p>
+	<p>This is some <a href="tel:123456789">phone number</a>.</p>
+</div>

+ 93 - 0
packages/ckeditor5-link/tests/manual/linkdecorator.js

@@ -0,0 +1,93 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals console:false, window, document */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import Enter from '@ckeditor/ckeditor5-enter/src/enter';
+import Typing from '@ckeditor/ckeditor5-typing/src/typing';
+import Link from '../../src/link';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Undo from '@ckeditor/ckeditor5-undo/src/undo';
+import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
+
+// Just to have nicely styles switchbutton;
+import '@ckeditor/ckeditor5-theme-lark/theme/ckeditor5-ui/components/list/list.css';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ Link, Typing, Paragraph, Clipboard, Undo, Enter ],
+		toolbar: [ 'link', 'undo', 'redo' ],
+		link: {
+			decorators: {
+				isExternal: {
+					mode: 'manual',
+					label: 'Open in a new tab',
+					attributes: {
+						target: '_blank',
+						rel: 'noopener noreferrer'
+					}
+				},
+				isDownloadable: {
+					mode: 'manual',
+					label: 'Downloadable',
+					attributes: {
+						download: 'download'
+					}
+				},
+				isGallery: {
+					mode: 'manual',
+					label: 'Gallery link',
+					attributes: {
+						class: 'gallery'
+					}
+				}
+			}
+		}
+	} )
+	.then( editor => {
+		if ( !window.editors ) {
+			window.editors = {};
+		}
+		window.editor = editor;
+		window.editors.manualDecorators = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );
+
+ClassicEditor
+	.create( document.querySelector( '#editor2' ), {
+		plugins: [ Link, Typing, Paragraph, Clipboard, Undo, Enter ],
+		toolbar: [ 'link', 'undo', 'redo' ],
+		link: {
+			decorators: {
+				isTelephone: {
+					mode: 'automatic',
+					callback: url => url.startsWith( 'tel:' ),
+					attributes: {
+						class: 'phone'
+					}
+				},
+				isInternal: {
+					mode: 'automatic',
+					callback: url => url.startsWith( '#' ),
+					attributes: {
+						class: 'internal'
+					}
+				}
+			},
+			addTargetToExternalLinks: true
+		}
+	} )
+	.then( editor => {
+		if ( !window.editors ) {
+			window.editors = {};
+		}
+		window.editors.automaticDecorators = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 21 - 0
packages/ckeditor5-link/tests/manual/linkdecorator.md

@@ -0,0 +1,21 @@
+## Link decorators
+
+### Manual decorators (window.editors.manualDecorators):
+
+1. Should be available for every link.
+2. Should be applied to the content only when "Save" was clicked.
+3. There should be 3 manual decorators available:
+  * Open in new a new tab
+  * Downloadable
+  * Gallery link
+4. State of the decorator switches should reflect the model of the currently selected link.
+5. Switch buttons should be focusable (with the tab key), after the URL input. The "save" and "cancel" buttons should be focused last.
+
+### Automatic decorators (window.editors.automaticDecorators).
+
+1. There should be a default automatic decorator turned on, which adds `target="_blank"` and `rel="noopener noreferrer"` attributes to all external links (links starting with `http://`, `https://` or `//`).
+2. There should be no changes to the model or the view of the editors.
+3. Decorator data should be added during downcast (run `window.editors.automaticDecorators.getData()` to see how it works).
+4. There are 2 additional decorators in this editor:
+  * phone, which detects all links starting with `tel:` and adds the `phone` CSS class to such link,
+  * internal, which adds the `internal` CSS class to all links starting with `#`.

+ 161 - 7
packages/ckeditor5-link/tests/ui/linkformview.js

@@ -3,7 +3,7 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
-/* globals Event */
+/* globals Event, document */
 
 import LinkFormView from '../../src/ui/linkformview';
 import View from '@ckeditor/ckeditor5-ui/src/view';
@@ -13,6 +13,11 @@ import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
 import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler';
 import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import ManualDecorator from '../../src/utils/manualdecorator';
+import Collection from '@ckeditor/ckeditor5-utils/src/collection';
+import { add as addTranslations, _clear as clearTranslations } from '@ckeditor/ckeditor5-utils/src/translation-service';
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import Link from '../../src/link';
 
 describe( 'LinkFormView', () => {
 	let view;
@@ -24,6 +29,10 @@ describe( 'LinkFormView', () => {
 		view.render();
 	} );
 
+	afterEach( () => {
+		view.destroy();
+	} );
+
 	describe( 'constructor()', () => {
 		it( 'should create element from template', () => {
 			expect( view.element.classList.contains( 'ck' ) ).to.true;
@@ -39,9 +48,9 @@ describe( 'LinkFormView', () => {
 			expect( view.saveButtonView.element.classList.contains( 'ck-button-save' ) ).to.be.true;
 			expect( view.cancelButtonView.element.classList.contains( 'ck-button-cancel' ) ).to.be.true;
 
-			expect( view._unboundChildren.get( 0 ) ).to.equal( view.urlInputView );
-			expect( view._unboundChildren.get( 1 ) ).to.equal( view.saveButtonView );
-			expect( view._unboundChildren.get( 2 ) ).to.equal( view.cancelButtonView );
+			expect( view.children.get( 0 ) ).to.equal( view.urlInputView );
+			expect( view.children.get( 1 ) ).to.equal( view.saveButtonView );
+			expect( view.children.get( 2 ) ).to.equal( view.cancelButtonView );
 		} );
 
 		it( 'should create #focusTracker instance', () => {
@@ -78,12 +87,12 @@ describe( 'LinkFormView', () => {
 
 		describe( 'template', () => {
 			it( 'has url input view', () => {
-				expect( view.template.children[ 0 ] ).to.equal( view.urlInputView );
+				expect( view.template.children[ 0 ].get( 0 ) ).to.equal( view.urlInputView );
 			} );
 
 			it( 'has button views', () => {
-				expect( view.template.children[ 1 ] ).to.equal( view.saveButtonView );
-				expect( view.template.children[ 2 ] ).to.equal( view.cancelButtonView );
+				expect( view.template.children[ 0 ].get( 1 ) ).to.equal( view.saveButtonView );
+				expect( view.template.children[ 0 ].get( 2 ) ).to.equal( view.cancelButtonView );
 			} );
 		} );
 	} );
@@ -182,4 +191,149 @@ describe( 'LinkFormView', () => {
 			sinon.assert.calledOnce( spy );
 		} );
 	} );
+
+	describe( 'manual decorators', () => {
+		let view, collection;
+		beforeEach( () => {
+			collection = new Collection();
+			collection.add( new ManualDecorator( {
+				id: 'decorator1',
+				label: 'Foo',
+				attributes: {
+					foo: 'bar'
+				}
+			} ) );
+			collection.add( new ManualDecorator( {
+				id: 'decorator2',
+				label: 'Download',
+				attributes: {
+					download: 'download'
+				}
+			} ) );
+			collection.add( new ManualDecorator( {
+				id: 'decorator3',
+				label: 'Multi',
+				attributes: {
+					class: 'fancy-class',
+					target: '_blank',
+					rel: 'noopener noreferrer'
+				}
+			} ) );
+
+			view = new LinkFormView( { t: val => val }, collection );
+			view.render();
+		} );
+
+		afterEach( () => {
+			view.destroy();
+			collection.clear();
+		} );
+
+		it( 'switch buttons reflects state of manual decorators', () => {
+			expect( view._manualDecoratorSwitches.length ).to.equal( 3 );
+
+			expect( view._manualDecoratorSwitches.get( 0 ) ).to.deep.include( {
+				name: 'decorator1',
+				label: 'Foo'
+			} );
+			expect( view._manualDecoratorSwitches.get( 1 ) ).to.deep.include( {
+				name: 'decorator2',
+				label: 'Download'
+			} );
+			expect( view._manualDecoratorSwitches.get( 2 ) ).to.deep.include( {
+				name: 'decorator3',
+				label: 'Multi'
+			} );
+		} );
+
+		it( 'reacts on switch button changes', () => {
+			const modelItem = collection.first;
+			const viewItem = view._manualDecoratorSwitches.first;
+
+			expect( modelItem.value ).to.be.undefined;
+			expect( viewItem.isOn ).to.be.undefined;
+
+			viewItem.element.dispatchEvent( new Event( 'click' ) );
+
+			expect( modelItem.value ).to.be.true;
+			expect( viewItem.isOn ).to.be.true;
+
+			viewItem.element.dispatchEvent( new Event( 'click' ) );
+
+			expect( modelItem.value ).to.be.false;
+			expect( viewItem.isOn ).to.be.false;
+		} );
+
+		describe( 'getDecoratorSwitchesState()', () => {
+			it( 'should provide object with decorators states', () => {
+				expect( view.getDecoratorSwitchesState() ).to.deep.equal( {
+					decorator1: undefined,
+					decorator2: undefined,
+					decorator3: undefined
+				} );
+
+				view._manualDecoratorSwitches.map( item => {
+					item.element.dispatchEvent( new Event( 'click' ) );
+				} );
+
+				view._manualDecoratorSwitches.get( 2 ).element.dispatchEvent( new Event( 'click' ) );
+
+				expect( view.getDecoratorSwitchesState() ).to.deep.equal( {
+					decorator1: true,
+					decorator2: true,
+					decorator3: false
+				} );
+			} );
+		} );
+	} );
+
+	describe( 'localization of manual decorators', () => {
+		before( () => {
+			addTranslations( 'pl', {
+				'Open in a new tab': 'Otwórz w nowym oknie'
+			} );
+		} );
+		after( () => {
+			clearTranslations();
+		} );
+
+		let editor, editorElement, linkFormView;
+
+		beforeEach( () => {
+			editorElement = document.createElement( 'div' );
+			document.body.appendChild( editorElement );
+
+			return ClassicTestEditor
+				.create( editorElement, {
+					plugins: [ Link ],
+					toolbar: [ 'link' ],
+					language: 'pl',
+					link: {
+						decorators: {
+							IsExternal: {
+								mode: 'manual',
+								label: 'Open in a new tab',
+								attributes: {
+									target: '_blank'
+								}
+							}
+						}
+					}
+				} )
+				.then( newEditor => {
+					editor = newEditor;
+					linkFormView = new LinkFormView( editor.locale, editor.commands.get( 'link' ).manualDecorators );
+				} );
+		} );
+
+		afterEach( () => {
+			editorElement.remove();
+
+			return editor.destroy();
+		} );
+
+		it( 'translates labels of manual decorators UI', () => {
+			expect( linkFormView._manualDecoratorSwitches.first.label ).to.equal( 'Otwórz w nowym oknie' );
+		} );
+	} );
 } );

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

@@ -5,6 +5,7 @@
 
 import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
 import UnlinkCommand from '../src/unlinkcommand';
+import LinkEditing from '../src/linkediting';
 import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 
@@ -227,4 +228,56 @@ describe( 'UnlinkCommand', () => {
 			} );
 		} );
 	} );
+
+	describe( 'manual decorators', () => {
+		beforeEach( () => {
+			editor.destroy();
+			return ModelTestEditor.create( {
+				extraPlugins: [ LinkEditing ],
+				link: {
+					decorators: {
+						isFoo: {
+							mode: 'manual',
+							label: 'Foo',
+							attributes: {
+								class: 'foo'
+							}
+						},
+						isBar: {
+							mode: 'manual',
+							label: 'Bar',
+							attributes: {
+								target: '_blank'
+							}
+						}
+					}
+				}
+			} )
+				.then( newEditor => {
+					editor = newEditor;
+					model = editor.model;
+					document = model.document;
+					command = new UnlinkCommand( editor );
+
+					model.schema.extend( '$text', {
+						allowIn: '$root',
+						allowAttributes: [ 'linkHref', 'linkIsFoo', 'linkIsBar' ]
+					} );
+
+					model.schema.register( 'p', { inheritAllFrom: '$block' } );
+				} );
+		} );
+
+		afterEach( () => {
+			return editor.destroy();
+		} );
+
+		it( 'should remove manual decorators from links together with linkHref', () => {
+			setData( model, '<$text linkIsFoo="true" linkIsBar="true" linkHref="url">f[]oobar</$text>' );
+
+			command.execute();
+
+			expect( getData( model ) ).to.equal( 'f[]oobar' );
+		} );
+	} );
 } );

+ 59 - 1
packages/ckeditor5-link/tests/utils.js

@@ -9,7 +9,7 @@ import AttributeElement from '@ckeditor/ckeditor5-engine/src/view/attributeeleme
 import ContainerElement from '@ckeditor/ckeditor5-engine/src/view/containerelement';
 import Text from '@ckeditor/ckeditor5-engine/src/view/text';
 
-import { createLinkElement, isLinkElement, ensureSafeUrl } from '../src/utils';
+import { createLinkElement, isLinkElement, ensureSafeUrl, normalizeDecorators } from '../src/utils';
 
 describe( 'utils', () => {
 	describe( 'isLinkElement()', () => {
@@ -157,4 +157,62 @@ describe( 'utils', () => {
 			expect( ensureSafeUrl( url ) ).to.equal( '#' );
 		} );
 	} );
+
+	describe( 'normalizeDecorators()', () => {
+		it( 'should transform an entry object to a normalized array', () => {
+			const callback = () => {};
+			const entryObject = {
+				foo: {
+					mode: 'manual',
+					label: 'Foo',
+					attributes: {
+						foo: 'foo'
+					}
+				},
+				bar: {
+					mode: 'automatic',
+					callback,
+					attributes: {
+						bar: 'bar'
+					}
+				},
+				baz: {
+					mode: 'manual',
+					label: 'Baz label',
+					attributes: {
+						target: '_blank',
+						rel: 'noopener noreferrer'
+					}
+				}
+			};
+
+			expect( normalizeDecorators( entryObject ) ).to.deep.equal( [
+				{
+					id: 'linkFoo',
+					mode: 'manual',
+					label: 'Foo',
+					attributes: {
+						foo: 'foo'
+					}
+				},
+				{
+					id: 'linkBar',
+					mode: 'automatic',
+					callback,
+					attributes: {
+						bar: 'bar'
+					}
+				},
+				{
+					id: 'linkBaz',
+					mode: 'manual',
+					label: 'Baz label',
+					attributes: {
+						target: '_blank',
+						rel: 'noopener noreferrer'
+					}
+				}
+			] );
+		} );
+	} );
 } );

+ 98 - 0
packages/ckeditor5-link/tests/utils/automaticdecorators.js

@@ -0,0 +1,98 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import AutomaticDecorators from '../../src/utils/automaticdecorators';
+
+describe( 'Automatic Decorators', () => {
+	let automaticDecorators;
+	beforeEach( () => {
+		automaticDecorators = new AutomaticDecorators();
+	} );
+
+	describe( 'constructor()', () => {
+		it( 'initialise with empty Set', () => {
+			expect( automaticDecorators._definitions ).to.be.instanceOf( Set );
+		} );
+	} );
+
+	it( 'has length equal 0 after initialization', () => {
+		expect( automaticDecorators.length ).to.equal( 0 );
+	} );
+
+	describe( 'add()', () => {
+		const tests = [
+			{
+				mode: 'automatic',
+				callback: () => {},
+				attributes: {
+					foo: 'bar'
+				}
+			},
+			{
+				mode: 'automatic',
+				callback: () => {},
+				attributes: {
+					bar: 'baz'
+				}
+			},
+			{
+				mode: 'automatic',
+				callback: () => {},
+				attributes: {
+					test1: 'one',
+					test2: 'two',
+					test3: 'three'
+				}
+			}
+		];
+		it( 'can accept single object', () => {
+			expect( automaticDecorators.length ).to.equal( 0 );
+
+			automaticDecorators.add( tests[ 0 ] );
+			expect( automaticDecorators.length ).to.equal( 1 );
+
+			const firstValue = automaticDecorators._definitions.values().next().value;
+
+			expect( firstValue ).to.deep.include( {
+				mode: 'automatic',
+				attributes: {
+					foo: 'bar'
+				}
+			} );
+			expect( firstValue ).to.have.property( 'callback' );
+			expect( firstValue.callback ).to.be.a( 'function' );
+		} );
+
+		it( 'can accept array of objects', () => {
+			expect( automaticDecorators.length ).to.equal( 0 );
+
+			automaticDecorators.add( tests );
+
+			expect( automaticDecorators.length ).to.equal( 3 );
+
+			const setIterator = automaticDecorators._definitions.values();
+			setIterator.next();
+			setIterator.next();
+			const thirdValue = setIterator.next().value;
+
+			expect( thirdValue ).to.deep.include( {
+				mode: 'automatic',
+				attributes: {
+					test1: 'one',
+					test2: 'two',
+					test3: 'three'
+				}
+			} );
+			expect( thirdValue ).to.have.property( 'callback' );
+			expect( thirdValue.callback ).to.be.a( 'function' );
+		} );
+	} );
+
+	describe( 'getDispatcher()', () => {
+		it( 'should return a dispatcher function', () => {
+			expect( automaticDecorators.getDispatcher() ).to.be.a( 'function' );
+		} );
+	} );
+} );

+ 43 - 0
packages/ckeditor5-link/tests/utils/manualdecorator.js

@@ -0,0 +1,43 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import ManualDecorator from '../../src/utils/manualdecorator';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+
+describe( 'Manual Decorator', () => {
+	let manualDecorator;
+	testUtils.createSinonSandbox();
+
+	beforeEach( () => {
+		manualDecorator = new ManualDecorator( {
+			id: 'foo',
+			label: 'bar',
+			attributes: {
+				one: 'two'
+			}
+		} );
+	} );
+
+	it( 'constructor', () => {
+		expect( manualDecorator.id ).to.equal( 'foo' );
+		expect( manualDecorator.label ).to.equal( 'bar' );
+		expect( manualDecorator.attributes ).to.deep.equal( { one: 'two' } );
+	} );
+
+	it( '#value is observable', () => {
+		const spy = testUtils.sinon.spy();
+		expect( manualDecorator.value ).to.be.undefined;
+
+		manualDecorator.on( 'change:value', spy );
+		manualDecorator.value = true;
+
+		expect( spy.calledOnce ).to.be.true;
+		testUtils.sinon.assert.calledWithExactly( spy.firstCall, testUtils.sinon.match.any, 'value', true, undefined );
+
+		manualDecorator.value = false;
+		expect( spy.calledTwice ).to.be.true;
+		testUtils.sinon.assert.calledWithExactly( spy.secondCall, testUtils.sinon.match.any, 'value', false, true );
+	} );
+} );

+ 9 - 2
packages/ckeditor5-link/theme/linkform.css

@@ -7,8 +7,6 @@
 
 .ck.ck-link-form {
 	display: flex;
-	flex-direction: row;
-	flex-wrap: nowrap;
 
 	& .ck-label {
 		display: none;
@@ -26,3 +24,12 @@
 		}
 	}
 }
+
+/*
+ * Style link form differently when manual decorators are available.
+ * See: https://github.com/ckeditor/ckeditor5-link/issues/186.
+ */
+.ck.ck-link-form_layout-vertical {
+	display: block;
+}
+