Skip to content

Commit

Permalink
Merge pull request #15134 from TheSpyder/ck/5656
Browse files Browse the repository at this point in the history
Feature (link): Links can now be applied by pasting a URL on selected text. Closes #5656.
  • Loading branch information
scofalik authored Dec 1, 2023
2 parents b32a2b6 + 074fff3 commit ba66ba1
Show file tree
Hide file tree
Showing 3 changed files with 274 additions and 4 deletions.
87 changes: 84 additions & 3 deletions packages/ckeditor5-link/src/autolink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@
*/

import { Plugin } from 'ckeditor5/src/core';
import type { DocumentSelectionChangeEvent, Element, Model, Range } from 'ckeditor5/src/engine';
import { Delete, TextWatcher, getLastTextLine, type TextWatcherMatchedDataEvent } from 'ckeditor5/src/typing';
import type { ClipboardInputTransformationData } from 'ckeditor5/src/clipboard';
import type { DocumentSelectionChangeEvent, Element, Model, Position, Range, Writer } from 'ckeditor5/src/engine';
import { Delete, TextWatcher, getLastTextLine, findAttributeRange, type TextWatcherMatchedDataEvent } from 'ckeditor5/src/typing';
import type { EnterCommand, ShiftEnterCommand } from 'ckeditor5/src/enter';

import { addLinkProtocolIfApplicable, linkHasProtocol } from './utils';
import LinkEditing from './linkediting';

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

Expand Down Expand Up @@ -73,7 +75,7 @@ export default class AutoLink extends Plugin {
* @inheritDoc
*/
public static get requires() {
return [ Delete ] as const;
return [ Delete, LinkEditing ] as const;
}

/**
Expand Down Expand Up @@ -104,6 +106,85 @@ export default class AutoLink extends Plugin {
public afterInit(): void {
this._enableEnterHandling();
this._enableShiftEnterHandling();
this._enablePasteLinking();
}

/**
* For given position, returns a range that includes the whole link that contains the position.
*
* If position is not inside a link, returns `null`.
*/
private _expandLinkRange( model: Model, position: Position ): Range | null {
if ( position.textNode && position.textNode.hasAttribute( 'linkHref' ) ) {
return findAttributeRange( position, 'linkHref', position.textNode.getAttribute( 'linkHref' ), model );
} else {
return null;
}
}

/**
* Extends the document selection to includes all links that intersects with given `selectedRange`.
*/
private _selectEntireLinks( writer: Writer, selectedRange: Range ): void {
const editor = this.editor;
const model = editor.model;
const selection = model.document.selection;
const selStart = selection.getFirstPosition()!;
const selEnd = selection.getLastPosition()!;

let updatedSelection = selectedRange.getJoined( this._expandLinkRange( model, selStart ) || selectedRange );
if ( updatedSelection ) {
updatedSelection = updatedSelection.getJoined( this._expandLinkRange( model, selEnd ) || selectedRange );
}

if ( updatedSelection && ( updatedSelection.start.isBefore( selStart ) || updatedSelection.end.isAfter( selEnd ) ) ) {
// Only update the selection if it changed.
writer.setSelection( updatedSelection );
}
}

/**
* Enables autolinking on pasting a URL when some content is selected.
*/
private _enablePasteLinking(): void {
const editor = this.editor;
const model = editor.model;
const selection = model.document.selection;
const clipboardPipeline = editor.plugins.get( 'ClipboardPipeline' );
const linkCommand = editor.commands.get( 'link' )!;

clipboardPipeline.on( 'inputTransformation', ( evt, data: ClipboardInputTransformationData ) => {
if ( !this.isEnabled || !linkCommand.isEnabled || selection.isCollapsed ) {
// Abort if we are disabled or the selection is collapsed.
return;
}

if ( selection.rangeCount > 1 ) {
// Abort if there are multiple selection ranges.
return;
}

const selectedRange = selection.getFirstRange()!;

const newLink = data.dataTransfer.getData( 'text/plain' );

if ( !newLink ) {
// Abort if there is no plain text on the clipboard.
return;
}

const matches = newLink.match( URL_REG_EXP );

// If the text in the clipboard has a URL, and that URL is the whole clipboard.
if ( matches && matches[ 2 ] === newLink ) {
model.change( writer => {
this._selectEntireLinks( writer, selectedRange );
linkCommand.execute( newLink );
} );

evt.stop();
}
}, { priority: 'high' } );
}

/**
Expand Down
184 changes: 183 additions & 1 deletion packages/ckeditor5-link/tests/autolink.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/

import ClipboardPipeline from '@ckeditor/ckeditor5-clipboard/src/clipboardpipeline';
import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
import CodeBlockEditing from '@ckeditor/ckeditor5-code-block/src/codeblockediting';
import Enter from '@ckeditor/ckeditor5-enter/src/enter';
import Input from '@ckeditor/ckeditor5-typing/src/input';
Expand All @@ -12,7 +15,6 @@ import ShiftEnter from '@ckeditor/ckeditor5-enter/src/shiftenter';
import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting';
import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata';
import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';

import LinkEditing from '../src/linkediting';
import AutoLink from '../src/autolink';

Expand All @@ -31,6 +33,186 @@ describe( 'AutoLink', () => {
await editor.destroy();
} );

describe( 'autolink on paste behavior', () => {
testUtils.createSinonSandbox();
let model, viewDocument;

beforeEach( async () => {
editor = await VirtualTestEditor.create( {
plugins: [ Paragraph, ClipboardPipeline, LinkEditing, AutoLink ]
} );

// VirtualTestEditor has no DOM, so this method must be stubbed for all tests.
// Otherwise it will throw as it accesses the DOM to do its job.
sinon.stub( editor.editing.view, 'scrollToTheSelection' );

model = editor.model;
viewDocument = editor.editing.view.document;
} );

describe( 'pasting on selected text', () => {
beforeEach( () => setData( model, '<paragraph>some [selected] text</paragraph>' ) );

it( 'paste link', () => {
pasteText( 'http://hello.com' );
expect( getData( model ) ).to.equal(
'<paragraph>some [<$text linkHref="http://hello.com">selected</$text>] text</paragraph>'
);
} );

it( 'paste text including a link', () => {
pasteText( ' http://hello.com' );
expect( getData( model ) ).to.equal(
'<paragraph>some http://hello.com[] text</paragraph>'
);
} );

it( 'paste not a link', () => {
pasteText( 'hello' );
expect( getData( model ) ).to.equal(
'<paragraph>some hello[] text</paragraph>'
);
} );

it( 'paste a HTML link', () => {
pasteData( {
'text/plain': 'http://hello.com',
'text/html': '<a href="http://hello.com">http://hello.com</a>'
} );
expect( getData( model ) ).to.equal(
'<paragraph>some [<$text linkHref="http://hello.com">selected</$text>] text</paragraph>'
);
} );

it( 'paste a HTML link without a protocol', () => {
pasteData( {
'text/plain': 'hello.com',
'text/html': '<a href="http://hello.com">hello.com</a>'
} );
expect( getData( model ) ).to.equal(
'<paragraph>some <$text linkHref="http://hello.com">hello.com</$text>[] text</paragraph>'
);
} );

it( 'paste HTML with no plain text', () => {
pasteData( {
'text/html': '<span style="font-color: blue">http://hello.com</span>'
} );
expect( getData( model ) ).to.equal(
'<paragraph>some http://hello.com[] text</paragraph>'
);
} );
} );

describe( 'pasting on collapsed selection', () => {
beforeEach( () => setData( model, '<paragraph>some [] text</paragraph>' ) );

it( 'paste link', () => {
pasteText( 'http://hello.com' );
expect( getData( model ) ).to.equal(
'<paragraph>some http://hello.com[] text</paragraph>'
);
} );
} );

describe( 'pasting with multiple selection', () => {
beforeEach( () => {
setData( model, '<paragraph>some text</paragraph>' );
const paragraph = model.document.getRoot().getChild( 0 );
const firstRange = editor.model.createRange(
editor.model.createPositionAt( paragraph, 0 ),
editor.model.createPositionAt( paragraph, 4 )
);
const secondRange = editor.model.createRange(
editor.model.createPositionAt( paragraph, 5 ),
editor.model.createPositionAt( paragraph, 9 )
);

model.change( writer => {
writer.setSelection( [ firstRange, secondRange ] );
} );
} );

it( 'paste link', () => {
pasteText( 'http://hello.com' );
// Default behaviour: overwrites the first selection
expect( getData( model ) ).to.equal(
'<paragraph>http://hello.com[] text</paragraph>'
);
} );
} );

describe( 'pasting on a link', () => {
it( 'paste with entire link selected', () => {
setData( model, '<paragraph>some [<$text linkHref="http://hello.com">selected</$text>] text</paragraph>' );
pasteText( 'http://world.com' );
expect( getData( model ) ).to.equal(
'<paragraph>some [<$text linkHref="http://world.com">selected</$text>] text</paragraph>'
);
} );

it( 'paste with partially selected link updates and selects the entire link', () => {
setData( model, '<paragraph><$text linkHref="http://hello.com">some [selected] text</$text></paragraph>' );
pasteText( 'http://world.com' );
expect( getData( model ) ).to.equal(
'<paragraph>[<$text linkHref="http://world.com">some selected text</$text>]</paragraph>'
);
} );

it( 'paste with selection overlapping the start of the link extends the link', () => {
setData( model, '<paragraph>[some <$text linkHref="http://hello.com">selected] text</$text></paragraph>' );
pasteText( 'http://world.com' );
expect( getData( model ) ).to.equal(
'<paragraph>[<$text linkHref="http://world.com">some selected text</$text>]</paragraph>'
);
} );

it( 'paste with selection overlapping the end of the link extends the link', () => {
setData( model, '<paragraph><$text linkHref="http://hello.com">some [selected</$text> text]</paragraph>' );
pasteText( 'http://world.com' );
expect( getData( model ) ).to.equal(
'<paragraph>[<$text linkHref="http://world.com">some selected text</$text>]</paragraph>'
);
} );

it( 'paste with two partially selected links overwrites both', () => {
setData( model,
`<paragraph>
<$text linkHref="http://one.com">here [are</$text>
<$text linkHref="http://two.com">two] links</$text>
</paragraph>`
);
pasteText( 'http://world.com' );
expect( getData( model ) ).to.equal(
'<paragraph>[<$text linkHref="http://world.com">here are two links</$text>]</paragraph>'
);
} );
} );

function pasteText( text ) {
pasteData( {
'text/plain': text
} );
}

function pasteData( data ) {
const dataTransferMock = createDataTransfer( data );
viewDocument.fire( 'paste', {
dataTransfer: dataTransferMock,
preventDefault() {},
stopPropagation() {}
} );
}

function createDataTransfer( data ) {
return {
getData( type ) {
return data[ type ];
}
};
}
} );

describe( 'auto link behavior', () => {
let model;

Expand Down
7 changes: 7 additions & 0 deletions packages/ckeditor5-link/tests/manual/autolink.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,10 @@
3. Check if *only* created link was removed:
- For "space" - the space after the text link should be preserved.
- For "enter" - the new block or `<softBreak>` should be preserved.

### Paste integration

1. Copy a URL to the clipboard
2. Select some content
3. Paste
4. Check the selected content is now a link using the copied URL.

0 comments on commit ba66ba1

Please sign in to comment.