Skip to content

Commit

Permalink
Merge pull request #7452 from ckeditor/i/7331
Browse files Browse the repository at this point in the history
Feature (link): Introduced the `LinkImageUI` plugin that brings a UI to wrap images in links. Closes #7331.

Internal (image): Reduced unnecessary margin under a linked image. See #7452 (comment).
  • Loading branch information
oleq committed Jun 18, 2020
2 parents 17d7bd7 + e374a14 commit 878257e
Show file tree
Hide file tree
Showing 8 changed files with 212 additions and 16 deletions.
2 changes: 1 addition & 1 deletion packages/ckeditor5-image/theme/image.css
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
/* Make sure there is some space between the content and the image. Center image by default. */
margin: 1em auto;

& > img {
& img {
/* Prevent unnecessary margins caused by line-height (see #44). */
display: block;

Expand Down
2 changes: 1 addition & 1 deletion packages/ckeditor5-link/src/linkimage.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import LinkImageUI from './linkimageui';
* The `LinkImage` plugin.
*
* This is a "glue" plugin that loads the {@link module:link/linkimageediting~LinkImageEditing link image editing feature}
* and {@link module:link/linkimageui~LinkImageUI linkimage UI feature}.
* and {@link module:link/linkimageui~LinkImageUI link image UI feature}.
*
* @extends module:core/plugin~Plugin
*/
Expand Down
83 changes: 82 additions & 1 deletion packages/ckeditor5-link/src/linkimageui.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,21 @@
* @module link/linkimageui
*/

import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import Image from '@ckeditor/ckeditor5-image/src/image';
import LinkUI from './linkui';
import LinkEditing from './linkediting';
import { isImageWidget } from '@ckeditor/ckeditor5-image/src/image/utils';
import { LINK_KEYSTROKE } from './utils';

import linkIcon from '../theme/icons/link.svg';

/**
* The link image UI plugin.
*
* TODO: Docs.
* This plugin brings a `'linkImage'` button that can be displayed in the {@link module:image/imagetoolbar~ImageToolbar}
* and used to wrap images in links.
*
* @extends module:core/plugin~Plugin
*/
Expand All @@ -34,6 +40,81 @@ export default class LinkImageUI extends Plugin {
return 'LinkImageUI';
}

/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const viewDocument = editor.editing.view.document;

this.listenTo( viewDocument, 'click', ( evt, data ) => {
const hasLink = isImageLinked( viewDocument.selection.getSelectedElement() );

if ( hasLink ) {
data.preventDefault();
}
} );

this._createToolbarLinkImageButton();
}

/**
* Creates a `LinkImageUI` button view.
*
* Clicking this button shows a {@link module:link/linkui~LinkUI#_balloon} attached to the selection.
* When an image is already linked, the view shows {@link module:link/linkui~LinkUI#actionsView} or
* {@link module:link/linkui~LinkUI#formView} if it's not.
*
* @private
*/
_createToolbarLinkImageButton() {
const editor = this.editor;
const t = editor.t;

editor.ui.componentFactory.add( 'linkImage', locale => {
const button = new ButtonView( locale );
const plugin = editor.plugins.get( 'LinkUI' );
const linkCommand = editor.commands.get( 'link' );

button.set( {
isEnabled: true,
label: t( 'Link image' ),
icon: linkIcon,
keystroke: LINK_KEYSTROKE,
tooltip: true,
isToggleable: true
} );

// Bind button to the command.
button.bind( 'isEnabled' ).to( linkCommand, 'isEnabled' );
button.bind( 'isOn' ).to( linkCommand, 'value', value => !!value );

// Show the actionsView or formView (both from LinkUI) on button click depending on whether the image is linked already.
this.listenTo( button, 'execute', () => {
const hasLink = isImageLinked( editor.editing.view.document.selection.getSelectedElement() );

if ( hasLink ) {
plugin._addActionsView();
} else {
plugin._showUI( true );
}
} );

return button;
} );
}
}

// A helper function that checks whether the element is a linked image.
//
// @param {module:engine/model/element~Element} element
// @returns {Boolean}
function isImageLinked( element ) {
const isImage = element && isImageWidget( element );

if ( !isImage ) {
return false;
}

return element.getChild( 0 ).is( 'a' );
}
10 changes: 5 additions & 5 deletions packages/ckeditor5-link/src/linkui.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@

import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import ClickObserver from '@ckeditor/ckeditor5-engine/src/view/observer/clickobserver';
import { isLinkElement } from './utils';
import { isLinkElement, LINK_KEYSTROKE } from './utils';

import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';

import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsidehandler';
Expand All @@ -20,7 +21,6 @@ import LinkActionsView from './ui/linkactionsview';

import linkIcon from '../theme/icons/link.svg';

const linkKeystroke = 'Ctrl+K';
const protocolRegExp = /^((\w+:(\/{2,})?)|(\W))/i;
const emailRegExp = /[\w-]+@[\w-]+\.+[\w-]+/i;
const VISUAL_SELECTION_MARKER_NAME = 'link-ui';
Expand Down Expand Up @@ -146,7 +146,7 @@ export default class LinkUI extends Plugin {
} );

// Open the form view on Ctrl+K when the **actions have focus**..
actionsView.keystrokes.set( linkKeystroke, ( data, cancel ) => {
actionsView.keystrokes.set( LINK_KEYSTROKE, ( data, cancel ) => {
this._addFormView();
cancel();
} );
Expand Down Expand Up @@ -215,7 +215,7 @@ export default class LinkUI extends Plugin {
const t = editor.t;

// Handle the `Ctrl+K` keystroke and show the panel.
editor.keystrokes.set( linkKeystroke, ( keyEvtData, cancel ) => {
editor.keystrokes.set( LINK_KEYSTROKE, ( keyEvtData, cancel ) => {
// Prevent focusing the search bar in FF, Chrome and Edge. See https://github.com/ckeditor/ckeditor5/issues/4811.
cancel();

Expand All @@ -228,7 +228,7 @@ export default class LinkUI extends Plugin {
button.isEnabled = true;
button.label = t( 'Link' );
button.icon = linkIcon;
button.keystroke = linkKeystroke;
button.keystroke = LINK_KEYSTROKE;
button.tooltip = true;
button.isToggleable = true;

Expand Down
5 changes: 5 additions & 0 deletions packages/ckeditor5-link/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ 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;

/**
* A keystroke used by the {@link module:link/linkui~LinkUI link UI feature}.
*/
export const LINK_KEYSTROKE = 'Ctrl+K';

/**
* Returns `true` if a given view node is the link element.
*
Expand Down
120 changes: 116 additions & 4 deletions packages/ckeditor5-link/tests/linkimageui.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,18 @@
/* globals document */

import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo';
import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata';
import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';

import LinkImage from '../src/linkimage';
import LinkImageUI from '../src/linkimageui';

describe( 'LinkImageUI', () => {
let editor, editorElement;
let editor, viewDocument, editorElement;
let plugin, linkButton;

testUtils.createSinonSandbox();

Expand All @@ -20,10 +27,14 @@ describe( 'LinkImageUI', () => {

return ClassicTestEditor
.create( editorElement, {
plugins: [ LinkImageUI ]
plugins: [ LinkImageUI, LinkImage, Paragraph ]
} )
.then( newEditor => {
editor = newEditor;
viewDocument = editor.editing.view.document;
linkButton = editor.ui.componentFactory.create( 'linkImage' );

plugin = editor.plugins.get( 'LinkImageUI' );
} );
} );

Expand All @@ -33,9 +44,110 @@ describe( 'LinkImageUI', () => {
return editor.destroy();
} );

it( 'should be named"', () => {
expect( LinkImageUI.pluginName ).to.equal( 'LinkImageUI' );
} );

describe( 'init()', () => {
it( 'does nothing', () => {
expect( editor.plugins.get( LinkImageUI ).init() ).to.equal( undefined );
it( 'should listen to the click event on the images', () => {
const listenToSpy = sinon.stub( plugin, 'listenTo' );

listenToSpy( viewDocument, 'click' );

viewDocument.fire( 'click' );

sinon.assert.calledOnce( listenToSpy );
} );
} );

describe( 'link toolbar UI component', () => {
it( 'should be registered', () => {
expect( linkButton ).to.be.instanceOf( ButtonView );
} );

describe( 'link button', () => {
it( 'should have a toggleable button', () => {
expect( linkButton.isToggleable ).to.be.true;
} );

it( 'should be bound to the link command', () => {
const command = editor.commands.get( 'link' );

command.isEnabled = true;
command.value = 'http://ckeditor.com';

expect( linkButton.isOn ).to.be.true;
expect( linkButton.isEnabled ).to.be.true;

command.isEnabled = false;
command.value = undefined;

expect( linkButton.isOn ).to.be.false;
expect( linkButton.isEnabled ).to.be.false;
} );

it( 'should call #_showUI upon #execute', () => {
const spy = testUtils.sinon.stub( editor.plugins.get( 'LinkUI' ), '_showUI' );

linkButton.fire( 'execute' );
sinon.assert.calledWithExactly( spy, true );
} );
} );
} );

describe( 'click', () => {
it( 'should prevent default behavior if image is wrapped with a link', () => {
editor.setData( '<figure class="image"><a href="https://example.com"><img src="" /></a></figure>' );

editor.editing.view.change( writer => {
writer.setSelection( viewDocument.getRoot().getChild( 0 ), 'on' );
} );

const img = viewDocument.selection.getSelectedElement();
const data = fakeEventData();
const eventInfo = new EventInfo( img, 'click' );
const domEventDataMock = new DomEventData( viewDocument, eventInfo, data );

viewDocument.fire( 'click', domEventDataMock );

expect( img.getChild( 0 ).name ).to.equal( 'a' );
expect( data.preventDefault.called ).to.be.true;
} );
} );

describe( 'event handling', () => {
it( 'should show plugin#actionsView after "execute" if image is already linked', () => {
const linkUIPlugin = editor.plugins.get( 'LinkUI' );

editor.setData( '<figure class="image"><a href="https://example.com"><img src="" /></a></figure>' );

editor.editing.view.change( writer => {
writer.setSelection( viewDocument.getRoot().getChild( 0 ), 'on' );
} );

linkButton.fire( 'execute' );

expect( linkUIPlugin._balloon.visibleView ).to.equals( linkUIPlugin.actionsView );
} );

it( 'should show plugin#formView after "execute" if image is not linked', () => {
const linkUIPlugin = editor.plugins.get( 'LinkUI' );

editor.setData( '<figure class="image"><img src="" /></a>' );

editor.editing.view.change( writer => {
writer.setSelection( viewDocument.getRoot().getChild( 0 ), 'on' );
} );

linkButton.fire( 'execute' );

expect( linkUIPlugin._balloon.visibleView ).to.equals( linkUIPlugin.formView );
} );
} );
} );

function fakeEventData() {
return {
preventDefault: sinon.spy()
};
}
4 changes: 1 addition & 3 deletions packages/ckeditor5-link/tests/manual/linkimage.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,5 @@
</a>
<figcaption>CKEditor logo - caption</figcaption>
</figure>
<a href="https://cksource.com">
<img alt="bar" src="sample.jpg">
</a>
<img alt="bar" src="sample.jpg">
</div>
2 changes: 1 addition & 1 deletion packages/ckeditor5-link/tests/manual/linkimage.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ ClassicEditor
'redo'
],
image: {
toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ]
toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative', '|', 'linkImage' ]
},
table: {
contentToolbar: [
Expand Down

0 comments on commit 878257e

Please sign in to comment.