Skip to content

Commit

Permalink
Merge pull request #7478 from ckeditor/i/4715
Browse files Browse the repository at this point in the history
Feature (link): Added the `AutoLink` feature which replaces a plain text with a link if typed or pasted content is the link. Closes #4715.
  • Loading branch information
pomek authored Jun 24, 2020
2 parents 390054e + 753ad6d commit c3f3078
Show file tree
Hide file tree
Showing 10 changed files with 740 additions and 0 deletions.
4 changes: 4 additions & 0 deletions packages/ckeditor5-link/docs/_snippets/features/autolink.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<div id="snippet-autolink">
<p>Type space, or Enter or Shift+Enter after a link: ckeditor.com</p>
<p>Will link to E-mail addresses also: example@example.com</p>
</div>
23 changes: 23 additions & 0 deletions packages/ckeditor5-link/docs/_snippets/features/autolink.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/

/* globals console, window, document, ClassicEditor, CS_CONFIG, CKEditorPlugins */

ClassicEditor
.create( document.querySelector( '#snippet-autolink' ), {
cloudServices: CS_CONFIG,
extraPlugins: [
CKEditorPlugins.AutoLink
],
toolbar: {
viewportTopOffset: window.getViewportTopOffsetConfig()
}
} )
.then( editor => {
window.editor = editor;
} )
.catch( err => {
console.error( err.stack );
} );
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@

import ClassicEditor from '@ckeditor/ckeditor5-build-classic/src/ckeditor';
import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config';
import AutoLink from '@ckeditor/ckeditor5-link/src/autolink';

window.CKEditorPlugins = {
AutoLink
};

window.ClassicEditor = ClassicEditor;
window.CS_CONFIG = CS_CONFIG;
21 changes: 21 additions & 0 deletions packages/ckeditor5-link/docs/features/link.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,27 @@ ClassicEditor
.catch( ... );
```

## Autolink feature

You can enable automatic linking of URLs typed or pasted into editor. The `AutoLink` feature will automatically add links to URLs or e-mail addresses.

<info-box>
Autolink action can be always reverted using undo (<kbd>CTRL</kbd>+<kbd>Z</kbd>).
</info-box>

{@snippet features/autolink}

```js
import AutoLink from '@ckeditor/ckeditor5-link/src/autolink';

ClassicEditor
.create( document.querySelector( '#editor' ), {
plugins: [ Link, AutoLink, ... ]
} )
.then( ... )
.catch( ... );
```

## Installation

<info-box info>
Expand Down
1 change: 1 addition & 0 deletions packages/ckeditor5-link/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"@ckeditor/ckeditor5-basic-styles": "^20.0.0",
"@ckeditor/ckeditor5-block-quote": "^20.0.0",
"@ckeditor/ckeditor5-clipboard": "^20.0.0",
"@ckeditor/ckeditor5-code-block": "^20.0.0",
"@ckeditor/ckeditor5-editor-classic": "^20.0.0",
"@ckeditor/ckeditor5-enter": "^20.0.0",
"@ckeditor/ckeditor5-paragraph": "^20.0.0",
Expand Down
248 changes: 248 additions & 0 deletions packages/ckeditor5-link/src/autolink.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
/**
* @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/

/**
* @module link/autolink
*/

import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import TextWatcher from '@ckeditor/ckeditor5-typing/src/textwatcher';
import getLastTextLine from '@ckeditor/ckeditor5-typing/src/utils/getlasttextline';

const MIN_LINK_LENGTH_WITH_SPACE_AT_END = 4; // Ie: "t.co " (length 5).

// This was tweak from https://gist.github.com/dperini/729294.
const URL_REG_EXP = new RegExp(
// Group 1: Line start or after a space.
'(^|\\s)' +
// Group 2: Detected URL (or e-mail).
'(' +
// Protocol identifier or short syntax "//"
// a. Full form http://user@foo.bar.baz:8080/foo/bar.html#baz?foo=bar
'(' +
'(?:(?:(?:https?|ftp):)?\\/\\/)' +
// BasicAuth using user:pass (optional)
'(?:\\S+(?::\\S*)?@)?' +
'(?:' +
// Host & domain names.
'(?![-_])(?:[-\\w\\u00a1-\\uffff]{0,63}[^-_]\\.)+' +
// TLD identifier name.
'(?:[a-z\\u00a1-\\uffff]{2,})' +
')' +
// port number (optional)
'(?::\\d{2,5})?' +
// resource path (optional)
'(?:[/?#]\\S*)?' +
')' +
'|' +
// b. Short form (either www.example.com or example@example.com)
'(' +
'(www.|(\\S+@))' +
// Host & domain names.
'((?![-_])(?:[-\\w\\u00a1-\\uffff]{0,63}[^-_]\\.))+' +
// TLD identifier name.
'(?:[a-z\\u00a1-\\uffff]{2,})' +
')' +
')$', 'i' );

const URL_GROUP_IN_MATCH = 2;

// Simplified email test - should be run over previously found URL.
const EMAIL_REG_EXP = /^[\S]+@((?![-_])(?:[-\w\u00a1-\uffff]{0,63}[^-_]\.))+(?:[a-z\u00a1-\uffff]{2,})$/i;

/**
* The auto link plugin.
*
* @extends module:core/plugin~Plugin
*/
export default class AutoLink extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return 'AutoLink';
}

/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const selection = editor.model.document.selection;

selection.on( 'change:range', () => {
// Disable plugin when selection is inside a code block.
this.isEnabled = !selection.anchor.parent.is( 'codeBlock' );
} );

this._enableTypingHandling();
}

/**
* @inheritDoc
*/
afterInit() {
this._enableEnterHandling();
this._enableShiftEnterHandling();
}

/**
* Enables auto-link on typing.
*
* @private
*/
_enableTypingHandling() {
const editor = this.editor;

const watcher = new TextWatcher( editor.model, text => {
// 1. Detect "space" after a text with a potential link.
if ( !isSingleSpaceAtTheEnd( text ) ) {
return;
}

// 2. Check text before last typed "space".
const url = getUrlAtTextEnd( text.substr( 0, text.length - 1 ) );

if ( url ) {
return { url };
}
} );

const input = editor.plugins.get( 'Input' );

watcher.on( 'matched:data', ( evt, data ) => {
const { batch, range, url } = data;

if ( !input.isInput( batch ) ) {
return;
}

const linkEnd = range.end.getShiftedBy( -1 ); // Executed after a space character.
const linkStart = linkEnd.getShiftedBy( -url.length );

const linkRange = editor.model.createRange( linkStart, linkEnd );

this._applyAutoLink( url, linkRange );
} );

watcher.bind( 'isEnabled' ).to( this );
}

/**
* Enables auto-link on <kbd>enter</kbd> key.
*
* @private
*/
_enableEnterHandling() {
const editor = this.editor;
const model = editor.model;
const enterCommand = editor.commands.get( 'enter' );

if ( !enterCommand ) {
return;
}

enterCommand.on( 'execute', () => {
const position = model.document.selection.getFirstPosition();

const rangeToCheck = model.createRange(
model.createPositionAt( position.parent.previousSibling, 0 ),
model.createPositionAt( position.parent.previousSibling, 'end' )
);

this._checkAndApplyAutoLinkOnRange( rangeToCheck );
} );
}

/**
* Enables auto-link on <kbd>shift</kbd>+<kbd>enter</kbd> key.
*
* @private
*/
_enableShiftEnterHandling() {
const editor = this.editor;
const model = editor.model;

const shiftEnterCommand = editor.commands.get( 'shiftEnter' );

if ( !shiftEnterCommand ) {
return;
}

shiftEnterCommand.on( 'execute', () => {
const position = model.document.selection.getFirstPosition();

const rangeToCheck = model.createRange(
model.createPositionAt( position.parent, 0 ),
position.getShiftedBy( -1 )
);

this._checkAndApplyAutoLinkOnRange( rangeToCheck );
} );
}

/**
* Checks passed range if it contains a linkable text.
*
* @param {module:engine/model/range~Range} rangeToCheck
* @private
*/
_checkAndApplyAutoLinkOnRange( rangeToCheck ) {
const model = this.editor.model;
const { text, range } = getLastTextLine( rangeToCheck, model );

const url = getUrlAtTextEnd( text );

if ( url ) {
const linkRange = model.createRange(
range.end.getShiftedBy( -url.length ),
range.end
);

this._applyAutoLink( url, linkRange );
}
}

/**
* Applies link on a given range.
*
* @param {String} url URL to link.
* @param {module:engine/model/range~Range} range Text range to apply link attribute.
* @private
*/
_applyAutoLink( url, range ) {
const model = this.editor.model;

if ( !this.isEnabled || !isLinkAllowedOnRange( range, model ) ) {
return;
}

// Enqueue change to make undo step.
model.enqueueChange( writer => {
const linkHrefValue = isEmail( url ) ? `mailto://${ url }` : url;

writer.setAttribute( 'linkHref', linkHrefValue, range );
} );
}
}

// Check if text should be evaluated by the plugin in order to reduce number of RegExp checks on whole text.
function isSingleSpaceAtTheEnd( text ) {
return text.length > MIN_LINK_LENGTH_WITH_SPACE_AT_END && text[ text.length - 1 ] === ' ' && text[ text.length - 2 ] !== ' ';
}

function getUrlAtTextEnd( text ) {
const match = URL_REG_EXP.exec( text );

return match ? match[ URL_GROUP_IN_MATCH ] : null;
}

function isEmail( linkHref ) {
return EMAIL_REG_EXP.exec( linkHref );
}

function isLinkAllowedOnRange( range, model ) {
return model.schema.checkAttributeInSelection( model.createSelection( range ), 'linkHref' );
}
Loading

0 comments on commit c3f3078

Please sign in to comment.