diff --git a/lang/contexts.json b/lang/contexts.json index 328cb96..9f4b9be 100644 --- a/lang/contexts.json +++ b/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." } diff --git a/package.json b/package.json index f6edfb7..e37344b 100644 --- a/package.json +++ b/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", diff --git a/src/link.js b/src/link.js index 775aa84..ee6dfa7 100644 --- a/src/link.js +++ b/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.} 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. + */ diff --git a/src/linkcommand.js b/src/linkcommand.js index c1dadf5..6334d55 100644 --- a/src/linkcommand.js +++ b/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; + } } diff --git a/src/linkediting.js b/src/linkediting.js index 51d0287..6a561ae 100644 --- a/src/linkediting.js +++ b/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.} 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.} 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. diff --git a/src/linkui.js b/src/linkui.js index 97feaff..ecee3a7 100644 --- a/src/linkui.js +++ b/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 { diff --git a/src/ui/linkformview.js b/src/ui/linkformview.js index bc9b1d2..6205d64 100644 --- a/src/ui/linkformview.js +++ b/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.} 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; + } } /** diff --git a/src/unlinkcommand.js b/src/unlinkcommand.js index 6e59dc7..84f3846 100644 --- a/src/unlinkcommand.js +++ b/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 ); + } + } } } ); } diff --git a/src/utils.js b/src/utils.js index 3c299b8..bed11e9 100644 --- a/src/utils.js +++ b/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.} decorators reference + * where labels' values should be localized. + * @returns {Array.} + */ +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.} decorators + * @returns {Array.} + */ +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; +} diff --git a/src/utils/automaticdecorators.js b/src/utils/automaticdecorators.js new file mode 100644 index 0000000..d0d4be4 --- /dev/null +++ b/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.} 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' } ); + }; + } +} diff --git a/src/utils/manualdecorator.js b/src/utils/manualdecorator.js new file mode 100644 index 0000000..9d4ef59 --- /dev/null +++ b/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 ); diff --git a/tests/linkcommand.js b/tests/linkcommand.js index 32812d4..7027482 100644 --- a/tests/linkcommand.js +++ b/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]bar' ); + } ); + + it( 'should add additional attributes to link when link is modified', () => { + setData( model, 'f<$text linkHref="url">o[]obar' ); + + command.execute( 'url', { linkIsFoo: true, linkIsBar: true } ); + + expect( getData( model ) ).to + .equal( 'f[<$text linkHref="url" linkIsBar="true" linkIsFoo="true">ooba]r' ); + } ); + + it( 'should remove additional attributes to link if those are falsy', () => { + setData( model, 'foo<$text linkHref="url" linkIsBar="true" linkIsFoo="true">u[]rlbar' ); + + command.execute( 'url', { linkIsFoo: false, linkIsBar: false } ); + + expect( getData( model ) ).to.equal( 'foo[<$text linkHref="url">url]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]r' ); + } ); + + it( 'should add additional attributes to link when link is modified', () => { + setData( model, 'f[<$text linkHref="foo">ooba]r' ); + + command.execute( 'url', { linkIsFoo: true, linkIsBar: true } ); + + expect( getData( model ) ).to + .equal( 'f[<$text linkHref="url" linkIsBar="true" linkIsFoo="true">ooba]r' ); + } ); + + it( 'should remove additional attributes to link if those are falsy', () => { + setData( model, 'foo[<$text linkHref="url" linkIsBar="true" linkIsFoo="true">url]bar' ); + + command.execute( 'url', { linkIsFoo: false, linkIsBar: false } ); + + expect( getData( model ) ).to.equal( 'foo[<$text linkHref="url">url]bar' ); + } ); + } ); + + describe( 'restoreManualDecoratorStates()', () => { + it( 'synchronize values with current model state', () => { + setData( model, 'foo<$text linkHref="url" linkIsBar="true" linkIsFoo="true">u[]rlbar' ); + + 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]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; + }, {} ); +} diff --git a/tests/linkediting.js b/tests/linkediting.js index e44cfdd..712a9fc 100644 --- a/tests/linkediting.js +++ b/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( `

foobar

` ); + + expect( getModelData( model, { withoutSelection: true } ) ) + .to.equal( `<$text linkHref="${ link.url }">foobar` ); + + if ( link.external ) { + expect( editor.getData() ) + .to.equal( `

foobar

` ); + } else { + expect( editor.getData() ).to.equal( `

foobar

` ); + } + } ); + } ); + } ); + testLinks.forEach( link => { + it( `link: ${ link.url } should not get 'target' and 'rel' attributes`, () => { + editor.setData( `

foobar

` ); + + expect( getModelData( model, { withoutSelection: true } ) ) + .to.equal( `<$text linkHref="${ link.url }">foobar` ); + + expect( editor.getData() ).to.equal( `

foobar

` ); + } ); + } ); + } ); + + 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( `

foobar

` ); + + expect( getModelData( model, { withoutSelection: true } ) ) + .to.equal( `<$text linkHref="${ link.url }">foobar` ); + + // Order of attributes is important, that's why this is assert is construct in such way. + expect( editor.getData() ).to.equal( `

foobar

` ); + } ); + } ); + } ); + } ); + + 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( '

foobar

' ); + + expect( getModelData( model, { withoutSelection: true } ) ) + .to.equal( '<$text linkHref="http://example.com">foobar' ); + + expect( editor.getData() ).to.equal( '

foobar

' ); + } ); + } ); + } ); } ); diff --git a/tests/linkui.js b/tests/linkui.js index 51a0904..452f2c6 100644 --- a/tests/linkui.js +++ b/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]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]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; + } ); + } ); } ); } ); } ); diff --git a/tests/manual/linkdecorator.html b/tests/manual/linkdecorator.html new file mode 100644 index 0000000..c5a24fb --- /dev/null +++ b/tests/manual/linkdecorator.html @@ -0,0 +1,19 @@ +

Manual decorators

+
+

This is CKEditor5 from CKSource.

+

This is CKEditor5 as schemaless url.

+

This is anchor on this page.

+

This is some random ftp address.

+

This is some mail.

+

This is some phone number.

+
+ +

Automatic decorators

+
+

This is CKEditor5 from CKSource.

+

This is CKEditor5 as schemaless url.

+

This is anchor on this page.

+

This is some random ftp address.

+

This is some mail.

+

This is some phone number.

+
diff --git a/tests/manual/linkdecorator.js b/tests/manual/linkdecorator.js new file mode 100644 index 0000000..31f9b16 --- /dev/null +++ b/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 ); + } ); diff --git a/tests/manual/linkdecorator.md b/tests/manual/linkdecorator.md new file mode 100644 index 0000000..310854a --- /dev/null +++ b/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 `#`. diff --git a/tests/ui/linkformview.js b/tests/ui/linkformview.js index 71b8506..7dffd84 100644 --- a/tests/ui/linkformview.js +++ b/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' ); + } ); + } ); } ); diff --git a/tests/unlinkcommand.js b/tests/unlinkcommand.js index 04b93c8..488120c 100644 --- a/tests/unlinkcommand.js +++ b/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' ); + + command.execute(); + + expect( getData( model ) ).to.equal( 'f[]oobar' ); + } ); + } ); } ); diff --git a/tests/utils.js b/tests/utils.js index 82507d1..1e1d162 100644 --- a/tests/utils.js +++ b/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' + } + } + ] ); + } ); + } ); } ); diff --git a/tests/utils/automaticdecorators.js b/tests/utils/automaticdecorators.js new file mode 100644 index 0000000..64a12d4 --- /dev/null +++ b/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' ); + } ); + } ); +} ); diff --git a/tests/utils/manualdecorator.js b/tests/utils/manualdecorator.js new file mode 100644 index 0000000..38c8133 --- /dev/null +++ b/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 ); + } ); +} ); diff --git a/theme/linkform.css b/theme/linkform.css index 2194683..057a6c9 100644 --- a/theme/linkform.css +++ b/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; +} +