How to add "target" attribute to `a` tag in ckeditor5?
Asked Answered
O

4

7

I have create my own plugin for link. Now I want to add some other attributes to a tag generated by the plugin, like target, rel.

But I am not able to get it done. Here is the my plugins code for converter. What converters I should add so that a tag can support other attributes?

/**
 * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
 * For licensing, see LICENSE.md.
 */

/**
 * @module link/linkediting
 */

import LinkEditing from '@ckeditor/ckeditor5-link/src/linkediting';
import {
    downcastAttributeToElement
} from '@ckeditor/ckeditor5-engine/src/conversion/downcast-converters';
import { upcastElementToAttribute } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters';
import LinkCommand from './uclinkcommand';
import UnlinkCommand from './ucunlinkcommand';
import { createLinkElement } from '@ckeditor/ckeditor5-link/src/utils';
import { ensureSafeUrl } from './utils';
import bindTwoStepCaretToAttribute from '@ckeditor/ckeditor5-engine/src/utils/bindtwostepcarettoattribute';

/**
 * The link engine feature.
 *
 * It introduces the `linkHref="url"` attribute in the model which renders to the view as a `<a href="url">` element.
 *
 * @extends module:core/plugin~Plugin
 */
export default class UcLinkEditing extends LinkEditing {
    /**
     * @inheritDoc
     */
    init() {
        const editor = this.editor;

        // Allow link attribute on all inline nodes.
        editor.model.schema.extend( '$text', { allowAttributes: 'linkHref' } );

        editor.conversion.for( 'dataDowncast' )
            .add( downcastAttributeToElement( { model: 'linkHref', view: createLinkElement } ) );

        editor.conversion.for( 'editingDowncast' )
            .add( downcastAttributeToElement( { model: 'linkHref', view: ( href, writer ) => {
                return createLinkElement( ensureSafeUrl( href ), writer );
            } } ) );

        editor.conversion.for( 'upcast' )
            .add( upcastElementToAttribute( {
                view: {
                    name: 'a',
                    attribute: {
                        href: true
                    }
                },
                model: {
                    key: 'linkHref',
                    value: viewElement => viewElement.getAttribute( 'href' )
                }
            } ) );

        // Create linking commands.
        editor.commands.add( 'ucLink', new LinkCommand( editor ) );
        editor.commands.add( 'ucUnlink', new UnlinkCommand( editor ) );

        // Enable two-step caret movement for `linkHref` attribute.
        bindTwoStepCaretToAttribute( editor.editing.view, editor.model, this, 'linkHref' );

        // Setup highlight over selected link.
        this._setupLinkHighlight();
    }
}
Ofay answered 12/7, 2018 at 10:58 Comment(0)
M
31

Introduction

Before I get to the code I'd like to take the occasion to explain the CKEditor 5 approach to inline elements (like <a>) so that the solution is easier to understand. With that knowledge, similar problems in the future should not be troubling. Following is meant to be a comprehensive tutorial, so expect a long read.

Even though you may know most of the things in the theory part, I recommend reading it to get the full understanding of how things work in CKEditor 5.

Also, do note that I will present a solution for the original CKEditor 5 plugin as it will be more valuable to other community members seeking a tutorial on this matter. Still, I hope that with the insight from this tutorial you will be able to adjust the code sample to your custom plugin.

Also, keep in mind that this tutorial does not discuss UI part of this plugin, only how things should be configured for conversion purposes. Adding and removing attributes is the job for UI or for some other part of code. Here I discuss only engine stuff.

Inline elements in CKEditor 5

First, let's establish which elements are inline. By inline elements I understand elements like <strong>, <a> or <span>. Unlike <p>, <blockquote> or <div>, inline elements do not structure the data. Instead, they mark some text in a specific (visual and semantical) way. So, in a way, these elements are a characteristic of a given part of a text. As a result, we say that given part of a text is bold, or that given part of a text is/has a link.

Similarly, in the model, we don't represent <a> or <strong> directly as elements. Instead, we allow adding attributes to a part of a text. This is how text characteristics (as bold, italic or link) are represented.

For example, in the model, we might have a <paragraph> element with Foo bar text, where bar has the bold attribute set to true. We would note it this way: <paragraph>Foo <$text bold="true">bar</$text></paragraph>. See, that there is no <strong> or any other additional element there. It's just some text with an attribute. Later, the bold attribute is converted to <strong> element.

By the way: view elements that come from model attributes have their own class: view.AttributeElement and instead of inline elements can be also called attribute elements. Sadly, the name conflicts with "attribute" as an attribute of a view element (what is worse, attribute element is allowed to have attributes).

Of course, text may have multiple attributes and all of them are converted to their respective view inline elements. Keep in mind that in the model, attributes do not have any set order. This is contrary to the view or HTML, where inline elements are nested one in another. The nesting happens during conversion from the model to the view. This makes working in model simpler, as features do not need to take care of breaking or rearranging elements in the model.

Consider this model string:

<paragraph>
    <$text bold="true">Foo </$text>
    <$text bold="true" linkHref="bar.html">bar</$text>
    <$text bold="true"> baz</$text>
</paragraph>

It is a bold Foo bar baz text with a link on bar. During conversion, it will be converted to:

<p>
    <strong>Foo </strong><a href="bar.html"><strong>bar</strong></a><strong> baz</strong>
</p>

Note, that the <a> element is converted in a way that it is always the topmost element. This is intentional so that none element will ever break an <a> element. See this, incorrect view/HTML string:

<p>
    <a href="bar.html">Foo </a><strong><a href="bar.html">bar</a></strong>
</p>

The generated view/HTML has two link elements next to each other, which is wrong.

We use priority property of view.AttributeElement to define which element should be on top of others. Most elements, like <strong> do not care about it and keep the default priority. However, <a> element has changed priority to guarantee a proper order in the view/HTML.

Complex inline elements and merging

So far we mostly discussed the simpler inline elements, i.e. elements which don't have attributes. Examples are <strong>, <em>. In contrary, <a> has additional attributes.

It is easy to come up with features that need to mark/style a part of a text but are custom enough so that simply using a tag is not enough. An example would be a font family feature. When used, it adds fontFamily attribute to a text, which is later converted to <span> element with an appropriate style attribute.

At this point, you need to ask what should happen if multiple such attributes are set on the same part of a text? Take this model example:

<paragraph>
    <$text fontFamily="Tahoma" fontSize="big">Foo</$text>
</paragraph>

The above attributes convert as follow:

  • fontFamily="value" converts to <span style="font-family: value;">,
  • fontSize="value" converts to <span class="text-value">.

So, what kind of view/HTML could we expect?

<p>
    <span style="font-family: Tahoma;">
        <span class="text-big">Foo</span>
    </span>
</p>

This, however, seems wrong. Why not have just one <span> element? Wouldn't it be better this way?

<p>
    <span style="font-family: Tahoma;" class="text-big">Foo</span>
</p>

To solve situations like these, in CKEditor 5 conversion mechanism we, in fact, introduced a merging mechanism.

In the above scenario, we have two attributes that convert to <span>. When the first attribute (say, fontFamily is converted, there is no <span> in the view yet. So the <span> is added with the style attribute. However, when fontSize is converted, there is already <span> in the view. view.Writer recognizes this and checks whether those elements can be merged. The rules are three:

  • elements must have the same view.Element#name,
  • elements must have the same view.AttributeElement#priority,
  • neither element may have view.AttributeElement#id set.

We haven't discussed id property yet but, for simplicity reasons, I won't talk about it now. It is enough to say that it is important for some attribute elements to prevent merging them.

Adding another attribute to the link

At this point, it should be pretty clear how to add another attribute to <a> element.

All that needs to be done is defining a new model attribute (linkTarget or linkRel) and make it convert to <a> element with the desired (target="..." or rel="...") attribute. Then, it will be merged with the original <a href="..."> element.

Keep in mind that <a> element from the original CKEditor 5 link plugin has custom priority specified. This means that the element generated by the new plugin need to have the same priority specified to be properly merged.

Upcasting merged attribute elements

For now, we only discussed downcasting (i.e. converting from the model to the view). Now let's talk about upcasting (i.e. converting from the view to the model). Fortunately, it is easier than the previous part.

There are two "things" that can be upcasted - elements and attributes. No magic here - elements are elements (<p>, <a>, <strong>, etc.) and attributes are attributes (class="", href="", etc.).

Elements can be upcast to elements (<p> -> <paragraph>) or attributes (<strong> -> bold, <a> -> linkHref). Attributes can be upcast to attributes.

Our example clearly needs upcasting from an element to an attribute. Indeed, <a> element is converted to linkHref attribute and the linkHref attribute value is taken from href="" attribute of the <a> element.

Naturally, one would define the same conversion for their new linkTarget or linkRel attribute. However, there is a trap here. Each part of the view can be converted ("consumed") only once (this is also true for the model when downcasting).

What does it mean? Simply, if one feature already converted given element name or given element attribute, neither feature can also convert it. This way features can correctly overwrite each other. This also means that general-purpose converters can be introduced (for example, <div> can be converted to <paragraph> if no other feature recognized <div> as something that can be converted by that feature). This also helps to spot conflicting converters.

Back to our example. We cannot define two element-to-attribute converters that convert the same element (<a>) and expect them to work together at the same time. One will overwrite the other.

Since we don't want to change the original link plugin, we need to keep that converter as is. However, the upcast converter for the new plugin will be an attribute-to-attribute converter. Since that converter won't convert element (or rather, element name) it will work together with the original converter.

Code sample

Here is a code sample for a link target plugin. Below I will explain some parts of it.

import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import { downcastAttributeToElement } from '@ckeditor/ckeditor5-engine/src/conversion/downcast-converters';
import { upcastAttributeToAttribute } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters';

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

        editor.model.schema.extend( '$text', { allowAttributes: 'linkTarget' } );

        editor.conversion.for( 'downcast' ).add( downcastAttributeToElement( {
            model: 'linkTarget',
            view: ( attributeValue, writer ) => {
                return writer.createAttributeElement( 'a', { target: attributeValue }, { priority: 5 } );
            },
            converterPriority: 'low'
        } ) );

        editor.conversion.for( 'upcast' ).add( upcastAttributeToAttribute( {
            view: {
                name: 'a',
                key: 'target'
            },
            model: 'linkTarget',
            converterPriority: 'low'
        } ) );
    }
}

For such a long tutorial it surely is a small snippet. Hopefully, most of it is self-explanatory.

First, we expand Schema by defining a new attribute linkTarget that is allowed on text.

Then, we define downcast conversion. downcastAttributeToElement is used as we want to create <a target="..."> element that will be merged with the original <a> element. Keep in mind that the <a> element that is created here has the priority defined to 5, just as in the original link plugin.

The last step is upcast conversion. upcastAttributeToAttribute helper is used, as discussed earlier. In view configuration, it is specified that only target attribute of <a> element should be converted (name: 'a'). This does not mean that <a> element will be converted! This is only filtering configuration for the converter, so it won't convert target attribute of some other element.

Lastly, both converters are added with priority lower than the original converters to prevent any hypothetical problems.

The above sample works for me on the current master of ckeditor5-engine and ckeditor5-link.

Mid answered 16/7, 2018 at 15:38 Comment(5)
This is an extremely informative thorough explanation. It was not clear from the CKEditor documentation that this is how it works.Compaction
Which version of this package did you use @ckeditor/ckeditor5-engine? The current version of 24.0.0 does not seem to have this path '@ckeditor/ckeditor5-engine/src/conversion/downcast-converters'Timaru
This beautiful answer really only demonstrates how terrible bad ckeditor 5 is...Chisholm
@UtkristAdhikari the src path has changed to this apparently: import { downcastAttributeToElement } from '@ckeditor/ckeditor5-engine/src/conversion/downcasthelpers'; import { upcastAttributeToAttribute } from '@ckeditor/ckeditor5-engine/src/conversion/upcasthelpers';Indeterminism
How do I add a cite attribute to a blockquote?Ophiolatry
I
1

Szymon Cofalik's answer is great, but not working anymore for at least the current CKE5 version (34.2.0).

The functions downcastAttributeToElement() and upcastAttributeToAttribute() are not exported anymore, so you have to use attributeToElement() and attributeToAttribute() from the Conversion API, which are available by default.

Updated code example:

import Plugin from '@ckeditor/ckeditor5-core/src/plugin';

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

        editor.model.schema.extend( '$text', { allowAttributes: 'linkTarget' } );

        editor.conversion.for( 'downcast' ).attributeToElement( {
            model: 'linkTarget',
            view: ( attributeValue, writer ) => {
                return writer.createAttributeElement( 'a', { target: attributeValue }, { priority: 5 } );
            },
            converterPriority: 'low'
        } );

        editor.conversion.for( 'upcast' ).attributeToAttribute( {
            view: {
                name: 'a',
                key: 'target'
            },
            model: 'linkTarget',
            converterPriority: 'low'
        } );
    }
}   
Immobilize answered 6/7, 2022 at 9:44 Comment(1)
CKEditor changed the API for the view callback from passing writer directly to passing conversionApi that has the property of writer. In newer versions this callback would be rewritten as: return conversionApi.writer.createAttributeElement( 'a', { target: attributeValue }, { priority: 5 } );Gambrel
G
0

as I came to the same problem in 2022, I founded this Answer very helpful, I wanted to add id attribute but didn't create my own plugin, I just edited the Link plugin in ckeditor5-build-classic package then I re-builded it.

1- in @module link/linkediting:

  • Allow link attribute on all inline nodes.
    • editor.model.schema.extend( '$text', { allowAttributes: ['linkHref', 'linkId'] } );
  • add conversion for upcast to conserve existed id attribute or create new one:
editor.conversion.for( 'upcast' ).attributeToAttribute( {
    view: {
        name: 'a'
    },
    model: {
        key: 'linkId',
        value: viewElement => {
            let id = viewElement.getAttribute( 'id' );
            if (id)
                return id;
            return 'id_'+Math.floor(Math.random() * 10000)
        }
    },
    converterPriority: 'low'
} ) ;  
  • add conversion for editingDowncast, to transform the Model into the view:
editor.conversion.for( 'editingDowncast' ).attributeToElement( {
        model: 'linkId',
        view: ( attributeValue, conversionApi ) => {
            return conversionApi.writer.createAttributeElement( 'a', { id: attributeValue }, { priority: 5 } );
        },
        converterPriority: 'low'
    } ) ;
  • add conversion for dataDowncast, to get the Attribute when getDate() is called:
editor.conversion.for( 'dataDowncast' )
    .attributeToElement( {
        model: 'linkId',
        view: ( attributeValue, conversionApi ) => {
            return conversionApi.writer.createAttributeElement( 'a', { id: attributeValue }, { priority: 5 } );
        }
    } ) ;

2- in @module link/linkcommand : to create the id, I just wanted a random string so I didn't made any new field in the form, and just added this line after the one that is responsible for linkHref Attribute

writer.setAttribute( 'linkId', 'id_'+Math.floor(Math.random() * 10000), range );
Glomerule answered 17/4, 2022 at 11:19 Comment(0)
C
0

Thanks for the detailed description. It was very helpful in solving an issue I was having with adding data-* attribute support to the <a> tag.

In testing I noticed that <a> tag attributes were not being upcast if the <a> was in a <td>, but not wrapped in a <p>. I found the solution to defining the upcast in the link plugin code found at https://github.com/ckeditor/ckeditor5/blob/b7366ab5312d5a0810af47b45eeea9d0f2b5e199/packages/ckeditor5-link/src/linkediting.ts#L106

conversion.for('upcast').elementToAttribute({
    view: {
        name: 'a',
        attributes: {
            'data-value': true
        }
    },
    model: {
        key: 'dataValue',
        value: ( viewElement ) => viewElement.getAttribute( 'data-value' )
    },
});
Cordelia answered 3/3 at 14:5 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.