Ckeditor5: Allow linking images

Created on 4 Dec 2017  ·  51Comments  ·  Source: ckeditor/ckeditor5

🐞 Is this a bug report or feature request? (choose one)

  • Feature request

💻 Version of CKEditor

1.0.0-alpha.2

📋 Steps to reproduce

  1. Go to https://ckeditor5.github.io/
  2. Drag and drop an image into the editor to upload it.
  3. Select the image by clicking it.

✅ Expected result

The link button should be enabled.

❎ Actual result

The link button is disabled. Selecting the surrounding text in addition to the image enables the link button, but no link is created for the image when the button is pressed.

📃 Other details that might be useful

This scenario works in CKEditor 4.

👍 If you need this

(Edited by @reinmar:) We need to know how important is this feature for you. Please react with 👍 if you need this feature.

Epic accessibility link 2 feature

Most helpful comment

Any update on this? We need this for generating emails with linked images.

All 51 comments

I thought there was a ticket for it already, but I can't find it. Definitely needed.

@Reinmar I guess you were referring to https://github.com/ckeditor/ckeditor5-link/issues/85, right?

Ah, right. I forgot it was the opposite issue in the past. So let's keep this one open too.

BTW, I'm curious what are the use cases for linking images.

The thing is that the only time when I wanted to link an image was when I wanted to allow opening a high-res version of that image. But then, as a user I must know the link to that high-res version. So it seems that cases like this one should rather be handled by the system, not the content author. For example, GH automatically links to the full version of images, even if you'll create a normal image in CommonMark.

The other case which comes to my mind is linking e.g. lead images in a newsletter. There may be images linking to blog posts or some other landing pages. This kinda starts falling into a structured content case and most likely should not be done inside the editor. But it's a case in which one will want to link the image manually if it's not handled by the system. If it's handled by the system, then e.g. the system may decide to use a block-level <a> to wrap the image and some additional text which follows it (the entire section of the content) which might give a better result.

So, what are the other scenarios in which the user will want to link an image which is a part of the content?

I agree with you, @Reinmar, for images that are photographs. But there are other types of images too, including line art and text. Examples include logos and highly stylized calls to action. I'd say that images that mostly consist of (stylized) text are quite likely to be turned into links.

Similarly, an image that is e.g. a thumbnail for an article is frequently linked to the article itself.

I got the need for an image to be linking to a product.
An ad on a banner.

My current idea, is to use <map><area></map>, which can placed after the <img>.
Instead of using a surrounding <a> element, which has already a meaning in CK.

I'm cloning the <figcaption> plugin for this.
It's far from perfect. As the area must have coords, and I want my images to be responsive.
I also need to generate an id for the <map>, referenced on <img>.

Here is an exemple of result : (as plain html)
https://codepen.io/long-lazuli/pen/644a741b5574b452dea02e1d61591aa1?editors=1100

We're using ckeditor to send email newsletters.
Some newsletters will contain videos and we'd like add an image of the player and surround it with an href to the actual video.

When pasting the newsletter contents (from Wordpress) into ckeditor ("@ckeditor/ckeditor5-build-classic": "10.0.0") the links on the images are removed.

What are the ways of working around this behavior?

@Reinmar Is there any way for me to workaround or add this feature using a plugin or otherwise? We're in dire need.

Thanks!

I asked @jodator to take a look on this. Sadly, the image plugin is one of the more complicated ones and there's no quick recipe how to add conversion for links in them. But to unblock you guys, we'll at least try now to find a way how such conversion could be added (either by a separate plugin or by modifying the image plugin).

Excellent, much appreciated!

@stnor & @Reinmar So far I've managed to preserve existing links that wraps <img> or <figure>.

I've tested this on "caption" manual test from image plugin. It works with CKEditor's Image & Link plugins. It preserves links and allows to edit images. It does not allow to edit those links and ImageStyle plugin stopped to work on those images also.

I think that one way to enhance this code is to change its behavior so it will convert whole link with image so it will not create <figure> and will not create a widget for such images. But to propose that I'd need to know if that is what you need :) as right now

<a href=""><img src=""></a>

will be transformed to:

<a href=""><figure><img src=""></figure></a>

The current implementation is below:

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 Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
import Heading from '@ckeditor/ckeditor5-heading/src/heading';
import Image from '../../src/image';
import ImageCaption from '../../src/imagecaption';
import Undo from '@ckeditor/ckeditor5-undo/src/undo';
import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
import ImageToolbar from '../../src/imagetoolbar';
import ImageStyle from '../../src/imagestyle';
import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
import List from '@ckeditor/ckeditor5-list/src/list';

import Link from '@ckeditor/ckeditor5-link/src/link';
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import Range from '../../../ckeditor5-engine/src/view/range';
import Position from '../../../ckeditor5-engine/src/view/position';

class ImageLink extends Plugin {
    init() {
        const editor = this.editor;

        editor.model.schema.extend( 'image', { allowAttributes: [ 'href' ] } );

        editor.conversion.for( 'upcast' ).add( upcastLink() );
        editor.conversion.for( 'upcast' ).add( upcastImageLink( 'img' ) );
        editor.conversion.for( 'upcast' ).add( upcastImageLink( 'figure' ) );
        editor.conversion.for( 'downcast' ).add( downcastImageLink() );
    }
}

ClassicEditor
    .create( document.querySelector( '#editor' ), {
        plugins: [
            Enter, Typing, Paragraph, Heading, Image, ImageToolbar, Link, ImageCaption,
            Undo, Clipboard, ImageStyle, Bold, Italic, Heading, List, ImageLink
        ],
        toolbar: [ 'heading', '|', 'undo', 'redo', 'bold', 'italic', 'bulletedList', 'numberedList', 'link' ],
        image: {
            toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ]
        }
    } )
    .then( editor => {
        window.editor = editor;
    } )
    .catch( err => {
        console.error( err.stack );
    } );

/**
 * Returns converter for links that wraps <img> or <figure> elements.
 *
 * @returns {Function}
 */
function upcastLink() {
    return dispatcher => {
        dispatcher.on( 'element:a', ( evt, data, conversionApi ) => {
            const viewLink = data.viewItem;

            const imageInLink = Array.from( viewLink.getChildren() ).find( child => child.name === 'img' || child.name === 'figure' );

            if ( imageInLink ) {
                // There's an image (or figure) inside an <a> element - we consume it so it won't be picked up by Link plugin.
                const consumableAttributes = { attributes: [ 'href' ] };

                // Consume the link so the default one will not convert it to $text attribute.
                if ( !conversionApi.consumable.test( viewLink, consumableAttributes ) ) {
                    // Might be consumed by something else - ie other converter with priority=highest - a standard check.
                    return;
                }

                // Consume 'href' attribute from link element.
                conversionApi.consumable.consume( viewLink, consumableAttributes );
            }
        }, { priority: 'high' } );
    };
}

function upcastImageLink( elementName ) {
    return dispatcher => {
        dispatcher.on( `element:${ elementName }`, ( evt, data, conversionApi ) => {
            const viewImage = data.viewItem;
            const parent = viewImage.parent;

            // Check only <img>/<figure> that are direct children of a link.
            if ( parent.name === 'a' ) {
                const modelImage = data.modelCursor.nodeBefore;
                const linkHref = parent.getAttribute( 'href' );

                if ( modelImage && linkHref ) {
                    // Set the href attribute from link element on model image element.
                    conversionApi.writer.setAttribute( 'href', linkHref, modelImage );
                }
            }
        }, { priority: 'normal' } );
    };
}

function downcastImageLink() {
    return dispatcher => {
        dispatcher.on( 'attribute:href:image', ( evt, data, conversionApi ) => {
            const href = data.attributeNewValue;
            // The image will be already converted - so it will be present in the view.
            const viewImage = conversionApi.mapper.toViewElement( data.item );

            // Below will wrap already converted image by newly created link element.

            // 1. Create empty link element.
            const linkElement = conversionApi.writer.createContainerElement( 'a', { href } );

            // 2. Insert link before associated image.
            conversionApi.writer.insert( Position.createBefore( viewImage ), linkElement );

            // 3. Move whole converted image to a link.
            conversionApi.writer.move( Range.createOn( viewImage ), new Position( linkElement, 0 ) );
        }, { priority: 'normal' } );
    };
}

Cool! :)

Do you think it would be possible to render <a> elements inside the <figure> (like in https://sdk.ckeditor.com/samples/image2.html) so only <img> is wrapped.

@Reinmar Yeah - actually it was a bit harder to make it wrap <figure/> instead of an <image/>. AFAIR I've used there AttributeAlement and conversionApi.writer.wrap() to wrap image with ''.

Hi.
Thanks for this.
However, I cant get this to work for me.
I added some logging in the dispatchers and two are invoked (upcastLink and upcastImageLink) when I paste in the contents.

When is the downcast method supposed to run?

As you can see I'm using angularjs and synching the model on 'change'.

My code:
```javascript
import CkEditor from "@ckeditor/ckeditor5-build-classic";

export default class CkEditorDirective {

constructor() {
this.restrict = 'A';
this.require = 'ngModel';
}

static create() {
return new CkEditorDirective();
}

link(scope, elem, attr, ngModel) {
CkEditor.create(elem[0]).then((editor) => {

  function upcastLink() {
    return dispatcher => {
      dispatcher.on( 'element:a', ( evt, data, conversionApi ) => {
        console.log('upcastLink', evt, data, conversionApi)

        const viewLink = data.viewItem;

        const imageInLink = Array.from( viewLink.getChildren() ).find( child => child.name === 'img' || child.name === 'figure' );

        if ( imageInLink ) {
          // There's an image (or figure) inside an <a> element - we consume it so it won't be picked up by Link plugin.
          const consumableAttributes = { attributes: [ 'href' ] };

          // Consume the link so the default one will not convert it to $text attribute.
          if ( !conversionApi.consumable.test( viewLink, consumableAttributes ) ) {
            // Might be consumed by something else - ie other converter with priority=highest - a standard check.
            return;
          }

          // Consume 'href' attribute from link element.
          conversionApi.consumable.consume( viewLink, consumableAttributes );
        }
      }, { priority: 'high' } );
    };
  }

  function upcastImageLink( elementName ) {
    return dispatcher => {
      dispatcher.on( `element:${ elementName }`, ( evt, data, conversionApi ) => {
        console.log('upcastImageLink', evt, data, conversionApi)
        const viewImage = data.viewItem;
        const parent = viewImage.parent;

        // Check only <img>/<figure> that are direct children of a link.
        if ( parent.name === 'a' ) {
          const modelImage = data.modelCursor.nodeBefore;
          const linkHref = parent.getAttribute( 'href' );

          if ( modelImage && linkHref ) {
            // Set the href attribute from link element on model image element.
            conversionApi.writer.setAttribute( 'href', linkHref, modelImage );
          }
        }
      }, { priority: 'normal' } );
    };
  }

  function downcastImageLink() {
    return dispatcher => {
      dispatcher.on( 'attribute:href:image', ( evt, data, conversionApi ) => {
        console.log('downcastImageLink', evt, data, conversionApi)
        const href = data.attributeNewValue;
        // The image will be already converted - so it will be present in the view.
        const viewImage = conversionApi.mapper.toViewElement( data.item );

        // Below will wrap already converted image by newly created link element.

        // 1. Create empty link element.
        const linkElement = conversionApi.writer.createContainerElement( 'a', { href } );

        // 2. Insert link before associated image.
        conversionApi.writer.insert( Position.createBefore( viewImage ), linkElement );

        // 3. Move whole converted image to a link.
        conversionApi.writer.move( Range.createOn( viewImage ), new Position( linkElement, 0 ) );
      }, { priority: 'normal' } );
    };
  }


  editor.model.schema.extend( 'image', { allowAttributes: [ 'href' ] } );
  editor.conversion.for( 'upcast' ).add( upcastLink() );
  editor.conversion.for( 'upcast' ).add( upcastImageLink( 'img' ) );
  editor.conversion.for( 'upcast' ).add( upcastImageLink( 'figure' ) );
  editor.conversion.for( 'downcast' ).add( downcastImageLink() );


  editor.model.document.on('change', () => {
    scope.$apply(() => {
      ngModel.$setViewValue(editor.getData());
    });
  });

  ngModel.$render = () => {
    editor.setData(ngModel.$modelValue);
  };

  scope.$on('$destroy', () => {
    editor.destroy();
  });
});

}
}

````

Pasted contents (from wordpress, wysiwyg) source:
````
Test of link...

````

screen shot 2018-05-23 at 11 42 33

@stnor The code provided by me must be implemented as a CKEditor plugin in order to properly extend upcast (from view to the model) and downcast (from model to the view) conversions.

You'll need a custom build for that.

@jodator ok, thanks! i'll check that out.

I created a new factory for CkEditor in my project:

````javascript
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 Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
import Heading from '@ckeditor/ckeditor5-heading/src/heading';
import Image from '@ckeditor/ckeditor5-image/src/image';
import ImageCaption from '@ckeditor/ckeditor5-image/src/imagecaption';
import Undo from '@ckeditor/ckeditor5-undo/src/undo';
import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
import ImageToolbar from '@ckeditor/ckeditor5-image/src/imagetoolbar';
import ImageStyle from '@ckeditor/ckeditor5-image/src/imagestyle';
import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
import List from '@ckeditor/ckeditor5-list/src/list';
import Link from '@ckeditor/ckeditor5-link/src/link';
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import Range from '@ckeditor/ckeditor5-engine/src/view/range';
import Position from '@ckeditor/ckeditor5-engine/src/view/position';

export default class CustomCkEditorFactory {

static create(element) {
return ClassicEditor
.create( element, {
plugins: [
Enter, Typing, Paragraph, Heading, Image, ImageToolbar, Link, ImageCaption,
Undo, Clipboard, ImageStyle, Bold, Italic, Heading, List, ImageLink
],
toolbar: [ 'heading', '|', 'undo', 'redo', 'bold', 'italic', 'bulletedList', 'numberedList', 'link' ],
image: {
toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ]
}
} );
}
}

class ImageLink extends Plugin {
init() {
console.log('Plugin in init()')
const editor = this.editor;
editor.model.schema.extend( 'image', { allowAttributes: [ 'href' ] } );
editor.conversion.for( 'upcast' ).add( upcastLink() );
editor.conversion.for( 'upcast' ).add( upcastImageLink( 'img' ) );
editor.conversion.for( 'upcast' ).add( upcastImageLink( 'figure' ) );
editor.conversion.for( 'downcast' ).add( downcastImageLink() );
}
}

/**

  • Returns converter for links that wraps or
    elements.
    *
  • @returns {Function}
    */
    function upcastLink() {
    return dispatcher => {
    dispatcher.on( 'element:a', ( evt, data, conversionApi ) => {
    console.log('upcastLink')
    const viewLink = data.viewItem;
  const imageInLink = Array.from( viewLink.getChildren() ).find( child => child.name === 'img' || child.name === 'figure' );

  if ( imageInLink ) {
    // There's an image (or figure) inside an <a> element - we consume it so it won't be picked up by Link plugin.
    const consumableAttributes = { attributes: [ 'href' ] };

    // Consume the link so the default one will not convert it to $text attribute.
    if ( !conversionApi.consumable.test( viewLink, consumableAttributes ) ) {
      // Might be consumed by something else - ie other converter with priority=highest - a standard check.
      return;
    }

    // Consume 'href' attribute from link element.
    conversionApi.consumable.consume( viewLink, consumableAttributes );
  }
}, { priority: 'high' } );

};
}

function upcastImageLink( elementName ) {
return dispatcher => {
dispatcher.on( element:${ elementName }, ( evt, data, conversionApi ) => {
console.log('upcastImageLink')

  const viewImage = data.viewItem;
  const parent = viewImage.parent;

  // Check only <img>/<figure> that are direct children of a link.
  if ( parent.name === 'a' ) {
    const modelImage = data.modelCursor.nodeBefore;
    const linkHref = parent.getAttribute( 'href' );

    if ( modelImage && linkHref ) {
      // Set the href attribute from link element on model image element.
      conversionApi.writer.setAttribute( 'href', linkHref, modelImage );
    }
  }
}, { priority: 'normal' } );

};
}

function downcastImageLink() {
return dispatcher => {
dispatcher.on( 'attribute:href:image', ( evt, data, conversionApi ) => {
console.log('downcastImageLink')

  const href = data.attributeNewValue;
  // The image will be already converted - so it will be present in the view.
  const viewImage = conversionApi.mapper.toViewElement( data.item );

  // Below will wrap already converted image by newly created link element.

  // 1. Create empty link element.
  const linkElement = conversionApi.writer.createContainerElement( 'a', { href } );

  // 2. Insert link before associated image.
  conversionApi.writer.insert( Position.createBefore( viewImage ), linkElement );

  // 3. Move whole converted image to a link.
  conversionApi.writer.move( Range.createOn( viewImage ), new Position( linkElement, 0 ) );
}, { priority: 'normal' } );

};
}
````

This produced an error when registering the plugin: "Class constructor Plugin cannot be invoked without 'new'", as in #649

So I copied the constructor from the Plugin class and removed the inheritance, as a workaround described in #649.

Unfortunately the outcome is the same as before, downcast is not run, even though the "ImageLink" is among the registered plugins.

´´´´
CustomCkEditorFactory.js:118 Plugin in init()
CustomCkEditorFactory.js:141 upcastLink
CustomCkEditorFactory.js:168 upcastImageLink

Any ideas?

I just managed somehow to get downcast to run once, but I am not sure how I made that happen...

The code you posted works fine for me:

  1. I copied it 1 to 1 to build-classic's src/ckeditor.js,
  2. I executed npm run build-ckeditor
  3. And I opened the sample:

image

Yes, but as you can see, downcast isn't being run, and the link is not in the HTML model when I access it using editor.getData()

WFM:

image

But anyway, the question is why do you get such a strange error:

Class constructor Plugin cannot be invoked without 'new'

Some issues with Babel? But why?

OK, according to https://github.com/ckeditor/ckeditor5/issues/649 that's indeed Babel. Moving the plugin to node_modules (as proposed in that ticket) meant that Babel started to ignore this code (so it wasn't transpiled). Apparently, if part of the code gets transpiled and part doesn't problems appear.

I think that you need to adjust your webpack config to make sure than entire CKEditor 5's source (and code which uses it) gets transpiled or that none of it is transpiled.

@szymonkups proposed this recently, when working on a React component: https://github.com/ckeditor/ckeditor5-react/tree/t/1#changes-in-webpackconfigprodjs-only

I did resolve the babel issue with in the webpack.config with the following in the babel-loader:
exclude: /node_modules\/(?!(@ckeditor)\/).*/,

The issue with downcast not being run seems to be related to pasting the contents rather than to set it using setData?

It works fine for me too when using editor.setData() with the html source from the console. Downcast is run and the link is added.

However, downcast is not run when pasting the "rich" contents into the editor.

To verify that the link is in fact in the clipboard I tested pasting the same contents from the Wordpress editor into MS Word.

I added the following in the init method to test:
````
window.editor = editor;

editor.model.document.on('change', () => {
  console.log('Data: ', editor.getData())
});

````

@Reinmar @jodator Can anyone of you reproduce my issue (ie paste the contents rather than using setData) or is it just me?

OK. So I've found what's wrong. Basically if you paste it like that (or use editor.setData()) it doesn't work:

<p>Some text... 
<a href="https://nomp.se">
<img class="aligncenter size-large wp-image-3525" src="https://blog.nomp.se/wp-content/uploads/2018/05/gdpr-1024x445.png" alt="" width="640" height="278" />
</a>
</p>

so above is a case you get while pasting from WP editor.

I'll check why it is happening and will update on this.

@stnor Hey, I've found what was wrong there. I wrongly assumed that the position in upcastImageLink() will be always the same. Instead the proper way is to search through data.modelRange for an image element:

Below is a corrected converter - only the line with const modelImage = ... is changed.

function upcastImageLink( elementName ) {
    return dispatcher => {
        dispatcher.on( `element:${ elementName }`, ( evt, data, conversionApi ) => {
            console.log( 'upcasting something', elementName );
            const viewImage = data.viewItem;
            const parent = viewImage.parent;

            // Check only <img>/<figure> that are direct children of a link.
            if ( parent.name === 'a' ) {
                // Find image in model range as the range might be split between different parents.
                const modelImage = Array.from( data.modelRange.getItems() ).find( item => item.is( 'image' ) );
                const linkHref = parent.getAttribute( 'href' );

                if ( modelImage && linkHref ) {
                    // Set the href attribute from link element on model image element.
                    conversionApi.writer.setAttribute( 'href', linkHref, modelImage );
                }
            }
        }, { priority: 'normal' } );
    };
}

@jodator I can confirm it's working now. Good job!

Thanks again @jodator.

Just for the sake of documentation, the working workaround for this issue is now:

````javascript
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 Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
import Heading from '@ckeditor/ckeditor5-heading/src/heading';
import Image from '@ckeditor/ckeditor5-image/src/image';
import ImageCaption from '@ckeditor/ckeditor5-image/src/imagecaption';
import Undo from '@ckeditor/ckeditor5-undo/src/undo';
import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
import ImageToolbar from '@ckeditor/ckeditor5-image/src/imagetoolbar';
import ImageStyle from '@ckeditor/ckeditor5-image/src/imagestyle';
import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
import List from '@ckeditor/ckeditor5-list/src/list';
import Link from '@ckeditor/ckeditor5-link/src/link';
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import Range from '@ckeditor/ckeditor5-engine/src/view/range';
import Position from '@ckeditor/ckeditor5-engine/src/view/position';

export default class CustomCkEditorFactory {

static create(element) {
return ClassicEditor
.create( element, {
plugins: [
Enter, Typing, Paragraph, Heading, Image, ImageToolbar, Link, ImageCaption,
Undo, Clipboard, ImageStyle, Bold, Italic, Heading, List, ImageLink
],
toolbar: [ 'heading', '|', 'undo', 'redo', 'bold', 'italic', 'bulletedList', 'numberedList', 'link' ],
image: {
toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ]
}
} );
}
}

class ImageLink extends Plugin {

init() {
const editor = this.editor;
editor.model.schema.extend( 'image', { allowAttributes: [ 'href' ] } );
editor.conversion.for( 'upcast' ).add( upcastLink() );
editor.conversion.for( 'upcast' ).add( upcastImageLink( 'img' ) );
editor.conversion.for( 'upcast' ).add( upcastImageLink( 'figure' ) );
editor.conversion.for( 'downcast' ).add( downcastImageLink() );
}
}

/**

  • Returns converter for links that wraps or
    elements.
    *
  • @returns {Function}
    */
    function upcastLink() {
    return dispatcher => {
    dispatcher.on( 'element:a', ( evt, data, conversionApi ) => {
    const viewLink = data.viewItem;
    const imageInLink = Array.from( viewLink.getChildren() ).find( child => child.name === 'img' || child.name === 'figure' );
  if ( imageInLink ) {
    // There's an image (or figure) inside an <a> element - we consume it so it won't be picked up by Link plugin.
    const consumableAttributes = { attributes: [ 'href' ] };

    // Consume the link so the default one will not convert it to $text attribute.
    if ( !conversionApi.consumable.test( viewLink, consumableAttributes ) ) {
      // Might be consumed by something else - ie other converter with priority=highest - a standard check.
      return;
    }

    // Consume 'href' attribute from link element.
    conversionApi.consumable.consume( viewLink, consumableAttributes );
  }
}, { priority: 'high' } );

};
}

function upcastImageLink( elementName ) {
return dispatcher => {
dispatcher.on( element:${ elementName }, ( evt, data, conversionApi ) => {
const viewImage = data.viewItem;
const parent = viewImage.parent;

  // Check only <img>/<figure> that are direct children of a link.
  if ( parent.name === 'a' ) {
    const modelImage = Array.from( data.modelRange.getItems() ).find( item => item.is( 'image' ) );
    const linkHref = parent.getAttribute( 'href' );

    if ( modelImage && linkHref ) {
      // Set the href attribute from link element on model image element.
      conversionApi.writer.setAttribute( 'href', linkHref, modelImage );
    }
  }
}, { priority: 'normal' } );

};
}

function downcastImageLink() {
return dispatcher => {
dispatcher.on( 'attribute:href:image', ( evt, data, conversionApi ) => {
const href = data.attributeNewValue;
// The image will be already converted - so it will be present in the view.
const viewImage = conversionApi.mapper.toViewElement( data.item );

  // Below will wrap already converted image by newly created link element.

  // 1. Create empty link element.
  const linkElement = conversionApi.writer.createContainerElement( 'a', { href } );

  // 2. Insert link before associated image.
  conversionApi.writer.insert( Position.createBefore( viewImage ), linkElement );

  // 3. Move whole converted image to a link.
  conversionApi.writer.move( Range.createOn( viewImage ), new Position( linkElement, 0 ) );
}, { priority: 'normal' } );

};
}

````

I was initially against having this feature at all because I couldn't find a proper use case for it. I was very focused on the article creation case.

Now, after the feedback provided in this issue, I understand that there are needs for it which seems to go toward the page creation case.

Because of these two different points of view and use cases, I'm unsure if this feature should (a) be included by default in any of the standard builds that we provide or if it should (b) stay as a plugin for customized builds. I would tend to go with "b", for now at least.

Is that implementation let you put a link outside the image, but remove the possibility for putting an <figcaption> ?

It's a good start for the model, good job,
but I'm not sure that it's a good thing to sacrifice accessibility for this.
That's why I was looking to put an image's map instead of a a[href].

See #915.

Any update on this? We need this for generating emails with linked images.

Be great to have this feature included, its actually one of the features preventing me from further using ckeditor in my projects.

Any news on this? Is there an ETA on it?

Any update on this ? Thank you

Hi, any news on this feature ?

Hi! Any updates on this feature?

This feature is more important than #436 issue that published recently.

We are also in great need of this too. This is a feature being asked of by several of my customers as well. Any ETA?

We upgraded our RTE and chose ckeditor5 between TinyMCE, QuillJS and some others. We were very pleased with ckeditor5 except found out recently that it can't link images. It's a surprise cause this use case is very common. Especially when creating email or web page blocks. We had to rollback to old editor because of missing this feature.

This ticket is open for 2 years now and lots of people asked for this feature. Really looking forward to seeing it in the next release.

Happy New Year!

i have the same problem, its a vital function and we need it now!

Thanks again @jodator.

Just for the sake of documentation, the working workaround for this issue is now:

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 Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
import Heading from '@ckeditor/ckeditor5-heading/src/heading';
import Image from '@ckeditor/ckeditor5-image/src/image';
import ImageCaption from '@ckeditor/ckeditor5-image/src/imagecaption';
import Undo from '@ckeditor/ckeditor5-undo/src/undo';
import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
import ImageToolbar from '@ckeditor/ckeditor5-image/src/imagetoolbar';
import ImageStyle from '@ckeditor/ckeditor5-image/src/imagestyle';
import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
import List from '@ckeditor/ckeditor5-list/src/list';
import Link from '@ckeditor/ckeditor5-link/src/link';
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import Range from '@ckeditor/ckeditor5-engine/src/view/range';
import Position from '@ckeditor/ckeditor5-engine/src/view/position';

export default class CustomCkEditorFactory {

  static create(element) {
    return ClassicEditor
        .create( element, {
          plugins: [
            Enter, Typing, Paragraph, Heading, Image, ImageToolbar, Link, ImageCaption,
            Undo, Clipboard, ImageStyle, Bold, Italic, Heading, List, ImageLink
          ],
          toolbar: [ 'heading', '|', 'undo', 'redo', 'bold', 'italic', 'bulletedList', 'numberedList', 'link' ],
          image: {
            toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ]
          }
        } );
  }
}

class ImageLink extends Plugin {

  init() {
    const editor = this.editor;
    editor.model.schema.extend( 'image', { allowAttributes: [ 'href' ] } );
    editor.conversion.for( 'upcast' ).add( upcastLink() );
    editor.conversion.for( 'upcast' ).add( upcastImageLink( 'img' ) );
    editor.conversion.for( 'upcast' ).add( upcastImageLink( 'figure' ) );
    editor.conversion.for( 'downcast' ).add( downcastImageLink() );
  }
}

/**
 * Returns converter for links that wraps <img> or <figure> elements.
 *
 * @returns {Function}
 */
function upcastLink() {
  return dispatcher => {
    dispatcher.on( 'element:a', ( evt, data, conversionApi ) => {
      const viewLink = data.viewItem;
      const imageInLink = Array.from( viewLink.getChildren() ).find( child => child.name === 'img' || child.name === 'figure' );

      if ( imageInLink ) {
        // There's an image (or figure) inside an <a> element - we consume it so it won't be picked up by Link plugin.
        const consumableAttributes = { attributes: [ 'href' ] };

        // Consume the link so the default one will not convert it to $text attribute.
        if ( !conversionApi.consumable.test( viewLink, consumableAttributes ) ) {
          // Might be consumed by something else - ie other converter with priority=highest - a standard check.
          return;
        }

        // Consume 'href' attribute from link element.
        conversionApi.consumable.consume( viewLink, consumableAttributes );
      }
    }, { priority: 'high' } );
  };
}

function upcastImageLink( elementName ) {
  return dispatcher => {
    dispatcher.on( `element:${ elementName }`, ( evt, data, conversionApi ) => {
      const viewImage = data.viewItem;
      const parent = viewImage.parent;

      // Check only <img>/<figure> that are direct children of a link.
      if ( parent.name === 'a' ) {
        const modelImage = Array.from( data.modelRange.getItems() ).find( item => item.is( 'image' ) );
        const linkHref = parent.getAttribute( 'href' );

        if ( modelImage && linkHref ) {
          // Set the href attribute from link element on model image element.
          conversionApi.writer.setAttribute( 'href', linkHref, modelImage );
        }
      }
    }, { priority: 'normal' } );
  };
}

function downcastImageLink() {
  return dispatcher => {
    dispatcher.on( 'attribute:href:image', ( evt, data, conversionApi ) => {
      const href = data.attributeNewValue;
      // The image will be already converted - so it will be present in the view.
      const viewImage = conversionApi.mapper.toViewElement( data.item );

      // Below will wrap already converted image by newly created link element.

      // 1. Create empty link element.
      const linkElement = conversionApi.writer.createContainerElement( 'a', { href } );

      // 2. Insert link before associated image.
      conversionApi.writer.insert( Position.createBefore( viewImage ), linkElement );

      // 3. Move whole converted image to a link.
      conversionApi.writer.move( Range.createOn( viewImage ), new Position( linkElement, 0 ) );
    }, { priority: 'normal' } );
  };
}

@stnor, @jodator
Hi, I was able to successfully use this code to create a new custom build but have a single issue = the ImageLink plugin is showing in console.log(InlineEditor.builtinPlugins.map( plugin => plugin.pluginName )); as undefined.

My code is identical to yours in but two things:

  1. I added also the Alignement plugin, which works fine in the build
  2. Im using the inline editor, not the classic one

Would anyone please be so kind and advise, what could be the issue? TIA

Hi, I was able to successfully use this code to create a new custom build but have a single issue = the ImageLink plugin is showing in console.log(InlineEditor.builtinPlugins.map( plugin => plugin.pluginName )); as undefined.

My code is identical to yours in but two things:

I added also the Alignement plugin, which works fine in the build
Im using the inline editor, not the classic one
Would anyone please be so kind and advise, what could be the issue? TIA

I was able to make the undefined component load successfully by modifying above code like so:

/**
 * @extends module:core/plugin~Plugin
 */
class ImageLink extends Plugin {
    /**
     * @inheritDoc
     */
    static get pluginName() {
        return 'ImageLink';
    }

Still cant get it to work though, will post updates

I'm using this workaround in my project, but the link toolbar button not working on images is causing issues. Is there an easy way to enable that button?

As we identified this might be a risky task to do all in one step let's split it to:

  • [x] editing part #7330
  • [x] UI part #7331
  • [ ] guides #7332

The guide is not ready yet, but the feature is merged. It will be an opt-in feature for now. It may be enabled by default in all builds in the future.

I noticed that link decorators like this one:
image
show up in the ui but don't change anything in the markup for linkImage. Is this a known issue?

Thanks for reporting the issue. We're aware of it. See: https://github.com/ckeditor/ckeditor5/issues/7519.

Incase someone finds themself in this issue where it says no docs yet from the changelog... here is the docs now. https://ckeditor.com/docs/ckeditor5/latest/features/image.html#linking-images

Was this page helpful?
0 / 5 - 0 ratings