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

Commit

Permalink
Merge pull request #222 from ckeditor/t/186
Browse files Browse the repository at this point in the history
Feature: Introduced configurable link decorators allowing customization of link attributes in the editor data. Closes #186.
  • Loading branch information
oleq committed Jun 25, 2019
2 parents 5009117 + 27298c2 commit 40d8266
Show file tree
Hide file tree
Showing 23 changed files with 1,771 additions and 29 deletions.
4 changes: 3 additions & 1 deletion lang/contexts.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
165 changes: 165 additions & 0 deletions src/link.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
123 changes: 121 additions & 2 deletions src/linkcommand.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
Expand All @@ -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
*/
Expand All @@ -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' );
}

Expand All @@ -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.
Expand All @@ -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 );
}
Expand All @@ -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 );
Expand All @@ -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;
}
}
Loading

0 comments on commit 40d8266

Please sign in to comment.