Skip to content

Commit

Permalink
Merge pull request #10737 from ckeditor/ck/1381-cmd-click
Browse files Browse the repository at this point in the history
Feature (link): Adds the possibility to open a link by Ctrl/Cmd+click or Alt+Enter. Closes #1381.
  • Loading branch information
niegowski authored Nov 2, 2021
2 parents 6a5b974 + 318bdc2 commit 654410f
Show file tree
Hide file tree
Showing 6 changed files with 321 additions and 7 deletions.
69 changes: 67 additions & 2 deletions packages/ckeditor5-link/src/linkediting.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ import { Plugin } from 'ckeditor5/src/core';
import { MouseObserver } from 'ckeditor5/src/engine';
import { Input, TwoStepCaretMovement, inlineHighlight, findAttributeRange } from 'ckeditor5/src/typing';
import { ClipboardPipeline } from 'ckeditor5/src/clipboard';
import { keyCodes } from 'ckeditor5/src/utils';
import { keyCodes, env } from 'ckeditor5/src/utils';

import LinkCommand from './linkcommand';
import UnlinkCommand from './unlinkcommand';
import ManualDecorator from './utils/manualdecorator';
import { createLinkElement, ensureSafeUrl, getLocalizedDecorators, normalizeDecorators } from './utils';
import { createLinkElement, ensureSafeUrl, getLocalizedDecorators, normalizeDecorators, openLink } from './utils';

import '../theme/link.css';

Expand Down Expand Up @@ -107,6 +107,9 @@ export default class LinkEditing extends Plugin {
// Setup highlight over selected link.
inlineHighlight( editor, 'linkHref', 'a', HIGHLIGHT_CLASS );

// Handle link following by CTRL+click or ALT+ENTER
this._enableLinkOpen();

// Change the attributes of the selection in certain situations after the link was inserted into the document.
this._enableInsertContentSelectionAttributesFixer();

Expand Down Expand Up @@ -220,6 +223,68 @@ export default class LinkEditing extends Plugin {
} );
}

/**
* Attaches handlers for {@link module:engine/view/document~Document#event:enter} and
* {@link module:engine/view/document~Document#event:click} to enable link following.
*
* @private
*/
_enableLinkOpen() {
const editor = this.editor;
const view = editor.editing.view;
const viewDocument = view.document;
const modelDocument = editor.model.document;

this.listenTo( viewDocument, 'click', ( evt, data ) => {
const shouldOpen = env.isMac ? data.domEvent.metaKey : data.domEvent.ctrlKey;

if ( !shouldOpen ) {
return;
}

let clickedElement = data.domTarget;

if ( clickedElement.tagName.toLowerCase() != 'a' ) {
clickedElement = clickedElement.closest( 'a' );
}

if ( !clickedElement ) {
return;
}

const url = clickedElement.getAttribute( 'href' );

if ( !url ) {
return;
}

evt.stop();
data.preventDefault();

openLink( url );
}, { context: '$capture' } );

this.listenTo( viewDocument, 'enter', ( evt, data ) => {
const selection = modelDocument.selection;

const selectedElement = selection.getSelectedElement();

const url = selectedElement ?
selectedElement.getAttribute( 'linkHref' ) :
selection.getAttribute( 'linkHref' );

const shouldOpen = url && data.domEvent.altKey;

if ( !shouldOpen ) {
return;
}

evt.stop();

openLink( url );
}, { context: 'a' } );
}

/**
* Starts listening to {@link module:engine/model/model~Model#event:insertContent} and corrects the model
* selection attributes if the selection is at the end of a link after inserting the content.
Expand Down
11 changes: 11 additions & 0 deletions packages/ckeditor5-link/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
* @module link/utils
*/

/* global window */

import { upperFirst } from 'lodash-es';

const ATTRIBUTE_WHITESPACES = /[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205f\u3000]/g; // eslint-disable-line no-control-regex
Expand Down Expand Up @@ -171,3 +173,12 @@ export function addLinkProtocolIfApplicable( link, defaultProtocol ) {

return link && isProtocolNeeded ? protocol + link : link;
}

/**
* Opens the link in a new browser tab.
*
* @param {String} link
*/
export function openLink( link ) {
window.open( link, '_blank', 'noopener' );
}
215 changes: 213 additions & 2 deletions packages/ckeditor5-link/tests/linkediting.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,16 @@ import ImageBlockEditing from '@ckeditor/ckeditor5-image/src/image/imageblockedi
import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
import Input from '@ckeditor/ckeditor5-typing/src/input';
import Delete from '@ckeditor/ckeditor5-typing/src/delete';
import ImageInline from '@ckeditor/ckeditor5-image/src/imageinline';
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 { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
import { isLinkElement } from '../src/utils';
import { env } from 'ckeditor5/src/utils';

import '@ckeditor/ckeditor5-core/tests/_utils/assertions/attribute';

/* global document */
/* global document, window */

describe( 'LinkEditing', () => {
let element, editor, model, view;
Expand All @@ -36,7 +38,7 @@ describe( 'LinkEditing', () => {
document.body.appendChild( element );

editor = await ClassicTestEditor.create( element, {
plugins: [ Paragraph, LinkEditing, Enter, Clipboard ],
plugins: [ Paragraph, LinkEditing, Enter, Clipboard, ImageInline ],
link: {
decorators: {
isExternal: {
Expand Down Expand Up @@ -940,6 +942,215 @@ describe( 'LinkEditing', () => {
} );
} );

describe( 'link following', () => {
let stub, eventPreventDefault;

beforeEach( () => {
stub = sinon.stub( window, 'open' );

stub.returns( undefined );
} );

afterEach( () => {
stub.restore();
} );

describe( 'using mouse', () => {
const initialEnvMac = env.isMac;

afterEach( () => {
env.isMac = initialEnvMac;
} );

describe( 'on Mac', () => {
beforeEach( () => {
env.isMac = true;
} );

it( 'should follow the link after CMD+click', () => {
setModelData( model, '<paragraph><$text linkHref="http://www.ckeditor.com">Bar[]</$text></paragraph>' );

fireClickEvent( { metaKey: true, ctrlKey: false } );

expect( stub.calledOnce ).to.be.true;
expect( stub.calledOn( window ) ).to.be.true;
expect( stub.calledWith( 'http://www.ckeditor.com', '_blank', 'noopener' ) ).to.be.true;
expect( eventPreventDefault.calledOnce ).to.be.true;
} );

it( 'should not follow the link after CTRL+click', () => {
setModelData( model, '<paragraph><$text linkHref="http://www.ckeditor.com">Bar[]</$text></paragraph>' );

fireClickEvent( { metaKey: false, ctrlKey: true } );

expect( stub.notCalled ).to.be.true;
expect( eventPreventDefault.calledOnce ).to.be.false;
} );

it( 'should not follow the link after click with neither CMD nor CTRL pressed', () => {
setModelData( model, '<paragraph><$text linkHref="http://www.ckeditor.com">Bar[]</$text></paragraph>' );

fireClickEvent( { metaKey: false, ctrlKey: false } );

expect( stub.notCalled ).to.be.true;
expect( eventPreventDefault.calledOnce ).to.be.false;
} );
} );

describe( 'on non-Mac', () => {
beforeEach( () => {
env.isMac = false;
} );

it( 'should follow the link after CTRL+click', () => {
setModelData( model, '<paragraph><$text linkHref="http://www.ckeditor.com">Bar[]</$text></paragraph>' );

fireClickEvent( { metaKey: false, ctrlKey: true } );

expect( stub.calledOnce ).to.be.true;
expect( stub.calledOn( window ) ).to.be.true;
expect( stub.calledWith( 'http://www.ckeditor.com', '_blank', 'noopener' ) ).to.be.true;
} );

it( 'should not follow the link after CMD+click', () => {
setModelData( model, '<paragraph><$text linkHref="http://www.ckeditor.com">Bar[]</$text></paragraph>' );

fireClickEvent( { metaKey: true, ctrlKey: false } );

expect( stub.notCalled ).to.be.true;
} );

it( 'should not follow the link after click with neither CMD nor CTRL pressed', () => {
setModelData( model, '<paragraph><$text linkHref="http://www.ckeditor.com">Bar[]</$text></paragraph>' );

fireClickEvent( { metaKey: false, ctrlKey: false } );

expect( stub.notCalled ).to.be.true;
} );
} );

it( 'should follow the inline image link', () => {
setModelData( model, '<paragraph>[<imageInline linkHref="http://www.ckeditor.com"></imageInline>]</paragraph>' );

fireClickEvent( { metaKey: env.isMac, ctrlKey: !env.isMac }, 'img' );

expect( stub.calledOnce ).to.be.true;
expect( stub.calledOn( window ) ).to.be.true;
expect( stub.calledWith( 'http://www.ckeditor.com', '_blank', 'noopener' ) ).to.be.true;
expect( eventPreventDefault.calledOnce ).to.be.true;
} );

it( 'should not follow the link if "a" element doesn\'t have "href" attribute', () => {
editor.conversion.attributeToElement( {
model: 'customLink',
view: 'a'
} );

setModelData( model, '<paragraph><$text customLink="">Bar[]</$text></paragraph>' );

fireClickEvent( { metaKey: env.isMac, ctrlKey: !env.isMac } );

expect( stub.notCalled ).to.be.true;
expect( eventPreventDefault.calledOnce ).to.be.false;
} );

it( 'should not follow the link if no link is clicked', () => {
editor.conversion.attributeToElement( {
model: 'customLink',
view: 'span'
} );

setModelData( model, '<paragraph><$text customLink="">Bar[]</$text></paragraph>' );

fireClickEvent( { metaKey: env.isMac, ctrlKey: !env.isMac }, 'span' );

expect( stub.notCalled ).to.be.true;
expect( eventPreventDefault.calledOnce ).to.be.false;
} );

function fireClickEvent( options, tagName = 'a' ) {
const linkElement = editor.ui.getEditableElement().getElementsByTagName( tagName )[ 0 ];

eventPreventDefault = sinon.spy();

view.document.fire( 'click', {
domTarget: linkElement,
domEvent: options,
preventDefault: eventPreventDefault
} );
}
} );

describe( 'using keyboard', () => {
const positiveScenarios = [
{
condition: 'selection is collapsed inside the link',
modelData: '<paragraph><$text linkHref="http://www.ckeditor.com">Ba[]r</$text></paragraph>'
},
{
condition: 'selection is collapsed at the end of the link',
modelData: '<paragraph><$text linkHref="http://www.ckeditor.com">Bar[]</$text></paragraph>'
},
{
condition: 'selection is collapsed at the begining of the link',
modelData: '<paragraph><$text linkHref="http://www.ckeditor.com">[]Bar</$text></paragraph>'
},
{
condition: 'part of the link is selected',
modelData: '<paragraph><$text linkHref="http://www.ckeditor.com">B[a]r</$text></paragraph>'
},
{
condition: 'the whole link is selected',
modelData: '<paragraph><$text linkHref="http://www.ckeditor.com">[Bar]</$text></paragraph>'
},
{
condition: 'linked image is selected',
modelData: '<paragraph>[<imageInline linkHref="http://www.ckeditor.com"></imageInline>]</paragraph>'
}
];

for ( const { condition, modelData } of positiveScenarios ) {
it( `should open link after pressing ALT+ENTER if ${ condition }`, () => {
setModelData( model, modelData );

fireEnterPressedEvent( { altKey: true } );

expect( stub.calledOnce ).to.be.true;
expect( stub.calledOn( window ) ).to.be.true;
expect( stub.calledWith( 'http://www.ckeditor.com', '_blank', 'noopener' ) ).to.be.true;
} );
}

it( 'should not open link after pressing ENTER without ALT', () => {
setModelData( model, '<paragraph><$text linkHref="http://www.ckeditor.com">Ba[]r</$text></paragraph>' );

fireEnterPressedEvent( { altKey: false } );

expect( stub.notCalled ).to.be.true;
} );

it( 'should not open link after pressing ALT+ENTER if not inside a link', () => {
setModelData( model, '<paragraph><$text linkHref="http://www.ckeditor.com">Bar</$text>Baz[]</paragraph>' );

fireEnterPressedEvent( { altKey: true } );

expect( stub.notCalled ).to.be.true;
} );

function fireEnterPressedEvent( options ) {
view.document.fire( 'keydown', {
keyCode: keyCodes.enter,
domEvent: {
keyCode: keyCodes.enter,
preventDefault: () => {},
target: document.body,
...options
}
} );
}
} );
} );

// https://github.com/ckeditor/ckeditor5/issues/1016
describe( 'typing around the link after a click', () => {
let editor;
Expand Down
2 changes: 1 addition & 1 deletion packages/ckeditor5-link/tests/linkimageui.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ describe( 'LinkImageUI', () => {

listenToSpy( viewDocument, 'click' );

viewDocument.fire( 'click' );
viewDocument.fire( 'click', { domEvent: {} } );

sinon.assert.calledOnce( listenToSpy );
} );
Expand Down
Loading

0 comments on commit 654410f

Please sign in to comment.